diff --git a/src/App.tsx b/src/App.tsx index 4c1349b..2eb0dd1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import Toolbar from "./components/Toolbar/Toolbar"; import MenuBar from "./components/Menu/MenuBar"; import DocumentManager from "./components/Workspace/DocumentManager"; import KeyboardShortcutsHelp from "./components/Common/KeyboardShortcutsHelp"; +import ToastContainer from "./components/Common/ToastContainer"; import { KeyboardShortcutProvider } from "./contexts/KeyboardShortcutContext"; import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; import { useDocumentHistory } from "./hooks/useDocumentHistory"; @@ -205,6 +206,9 @@ function AppContent() { isOpen={showKeyboardHelp} onClose={() => setShowKeyboardHelp(false)} /> + + {/* Toast Notifications */} + ); } diff --git a/src/components/Common/Toast.tsx b/src/components/Common/Toast.tsx new file mode 100644 index 0000000..7f76548 --- /dev/null +++ b/src/components/Common/Toast.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import InfoIcon from '@mui/icons-material/Info'; +import WarningIcon from '@mui/icons-material/Warning'; +import CloseIcon from '@mui/icons-material/Close'; +import { IconButton } from '@mui/material'; +import type { Toast as ToastType } from '../../stores/toastStore'; + +interface ToastProps { + toast: ToastType; + onClose: (id: string) => void; +} + +const Toast = ({ toast, onClose }: ToastProps) => { + const [isExiting, setIsExiting] = useState(false); + + // Icon and color configuration based on type + const config = { + success: { + icon: , + bgColor: 'bg-green-50', + borderColor: 'border-green-200', + textColor: 'text-green-800', + iconColor: 'text-green-600', + }, + error: { + icon: , + bgColor: 'bg-red-50', + borderColor: 'border-red-200', + textColor: 'text-red-800', + iconColor: 'text-red-600', + }, + info: { + icon: , + bgColor: 'bg-blue-50', + borderColor: 'border-blue-200', + textColor: 'text-blue-800', + iconColor: 'text-blue-600', + }, + warning: { + icon: , + bgColor: 'bg-orange-50', + borderColor: 'border-orange-200', + textColor: 'text-orange-800', + iconColor: 'text-orange-600', + }, + }[toast.type]; + + const handleClose = () => { + setIsExiting(true); + // Wait for animation to complete before removing from store + setTimeout(() => { + onClose(toast.id); + }, 300); + }; + + // Auto-close when duration expires (handled by store, but we track for animation) + useEffect(() => { + const timer = setTimeout(() => { + setIsExiting(true); + }, toast.duration - 300); // Start exit animation 300ms before removal + + return () => clearTimeout(timer); + }, [toast.duration]); + + return ( +
+ {/* Icon */} +
+ {config.icon} +
+ + {/* Message */} +
+ {toast.message} +
+ + {/* Close button */} + + + +
+ ); +}; + +export default Toast; diff --git a/src/components/Common/ToastContainer.tsx b/src/components/Common/ToastContainer.tsx new file mode 100644 index 0000000..388febc --- /dev/null +++ b/src/components/Common/ToastContainer.tsx @@ -0,0 +1,58 @@ +import { useToastStore } from '../../stores/toastStore'; +import { usePanelStore } from '../../stores/panelStore'; +import Toast from './Toast'; + +/** + * ToastContainer - Container for toast notifications + * + * Features: + * - Positioned in top-right corner (below header/menu) + * - Intelligently avoids overlapping with right panel + * - Stacks toasts vertically + * - Max 3 toasts visible (enforced by store) + * - Slide-in animation from right + * + * Positioning Strategy: + * - When right panel is hidden/collapsed: 16px from right edge + * - When right panel is visible: positioned to the left of the panel with 16px gap + */ +const ToastContainer = () => { + const { toasts, hideToast } = useToastStore(); + const { rightPanelVisible, rightPanelCollapsed, rightPanelWidth } = usePanelStore(); + + // Calculate right offset to avoid right panel + // Header (88px) + MenuBar (40px) + Tabs (~48px) + Toolbar (~56px) = ~232px from top + const topOffset = 240; // px from top (safely below UI chrome) + + // Right offset calculation: + // - If right panel is visible and expanded: offset by panel width + 16px gap + // - If right panel is collapsed: offset by collapsed width (40px) + 16px gap + // - If right panel is hidden: just 16px from edge + const rightOffset = rightPanelVisible + ? rightPanelCollapsed + ? 40 + 16 // Collapsed width + gap + : rightPanelWidth + 16 // Panel width + gap + : 16; // Just gap from edge + + if (toasts.length === 0) { + return null; + } + + return ( +
+
+ {toasts.map((toast) => ( + + ))} +
+
+ ); +}; + +export default ToastContainer; diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 28e03dc..6c4de4e 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -34,6 +34,7 @@ import { createNode } from "../../utils/nodeUtils"; import DeleteIcon from "@mui/icons-material/Delete"; import { useConfirm } from "../../hooks/useConfirm"; import { useGraphExport } from "../../hooks/useGraphExport"; +import { useToastStore } from "../../stores/toastStore"; import type { ExportOptions } from "../../utils/graphExport"; import type { Actor, Relation } from "../../types"; diff --git a/src/stores/toastStore.ts b/src/stores/toastStore.ts new file mode 100644 index 0000000..0e1808a --- /dev/null +++ b/src/stores/toastStore.ts @@ -0,0 +1,57 @@ +import { create } from 'zustand'; + +export type ToastType = 'success' | 'error' | 'info' | 'warning'; + +export interface Toast { + id: string; + message: string; + type: ToastType; + duration: number; +} + +interface ToastState { + toasts: Toast[]; + showToast: (message: string, type?: ToastType, duration?: number) => void; + hideToast: (id: string) => void; + clearAllToasts: () => void; +} + +const MAX_TOASTS = 3; +const DEFAULT_DURATION = 4000; // 4 seconds + +let toastIdCounter = 0; + +export const useToastStore = create((set) => ({ + toasts: [], + + showToast: (message: string, type: ToastType = 'info', duration: number = DEFAULT_DURATION) => { + const id = `toast-${++toastIdCounter}`; + const newToast: Toast = { id, message, type, duration }; + + set((state) => { + // Limit to MAX_TOASTS, remove oldest if needed (FIFO) + const toasts = [...state.toasts, newToast]; + if (toasts.length > MAX_TOASTS) { + toasts.shift(); // Remove the oldest toast + } + return { toasts }; + }); + + // Auto-dismiss after duration + setTimeout(() => { + set((state) => ({ + toasts: state.toasts.filter((t) => t.id !== id), + })); + }, duration); + }, + + hideToast: (id: string) => { + set((state) => ({ + toasts: state.toasts.filter((t) => t.id !== id), + })); + }, + + clearAllToasts: () => { + set({ toasts: [] }); + }, +})); diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts index ad8111c..a55ef21 100644 --- a/src/stores/workspaceStore.ts +++ b/src/stores/workspaceStore.ts @@ -22,6 +22,7 @@ import { exportWorkspace as exportWorkspaceToZip, selectWorkspaceZipForImport, } from './workspace/workspaceIO'; +import { useToastStore } from './toastStore'; /** * Workspace Store @@ -173,6 +174,8 @@ export const useWorkspaceStore = create((set, get) return newState; }); + useToastStore.getState().showToast(`Document "${title}" created`, 'success'); + return documentId; }, @@ -342,6 +345,8 @@ export const useWorkspaceStore = create((set, get) ); if (!confirmed) return false; + const docTitle = metadata?.title || 'Untitled'; + // Delete from storage deleteDocumentFromStorage(documentId); @@ -374,6 +379,8 @@ export const useWorkspaceStore = create((set, get) }; }); + useToastStore.getState().showToast(`Document "${docTitle}" deleted`, 'info'); + return true; }, @@ -400,6 +407,8 @@ export const useWorkspaceStore = create((set, get) return {}; }); + + useToastStore.getState().showToast(`Document renamed to "${newTitle}"`, 'success'); }, // Duplicate document @@ -408,6 +417,7 @@ export const useWorkspaceStore = create((set, get) const sourceDoc = state.documents.get(documentId); if (!sourceDoc) { console.error(`Document ${documentId} not found`); + useToastStore.getState().showToast('Failed to duplicate: Document not found', 'error'); return ''; } @@ -462,6 +472,8 @@ export const useWorkspaceStore = create((set, get) }; }); + useToastStore.getState().showToast(`Document duplicated as "${newTitle}"`, 'success'); + return newDocumentId; }, @@ -557,10 +569,14 @@ export const useWorkspaceStore = create((set, get) }; }); + // Show success toast + useToastStore.getState().showToast('Document imported successfully', 'success'); + resolve(documentId); }, (error) => { - alert(`Failed to import file: ${error}`); + // Show error toast + useToastStore.getState().showToast(`Failed to import file: ${error}`, 'error', 5000); resolve(null); } ); @@ -572,15 +588,22 @@ export const useWorkspaceStore = create((set, get) const doc = get().documents.get(documentId); if (!doc) { console.error(`Document ${documentId} not found`); + useToastStore.getState().showToast('Failed to export: Document not found', 'error'); return; } - exportGraphToFile( - doc.graph.nodes, - doc.graph.edges, - doc.graph.nodeTypes, - doc.graph.edgeTypes - ); + try { + exportGraphToFile( + doc.graph.nodes, + doc.graph.edges, + doc.graph.nodeTypes, + doc.graph.edgeTypes + ); + useToastStore.getState().showToast('Document exported successfully', 'success'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + useToastStore.getState().showToast(`Failed to export document: ${message}`, 'error', 5000); + } }, // Save workspace @@ -671,44 +694,56 @@ export const useWorkspaceStore = create((set, get) exportAllDocumentsAsZip: async () => { const state = get(); - // Ensure all documents are loaded - const allDocs = new Map(); - for (const docId of state.documentOrder) { - let doc = state.documents.get(docId); - if (!doc) { - const loadedDoc = loadDocumentFromStorage(docId); - if (loadedDoc) { - doc = loadedDoc; + try { + // Ensure all documents are loaded + const allDocs = new Map(); + for (const docId of state.documentOrder) { + let doc = state.documents.get(docId); + if (!doc) { + const loadedDoc = loadDocumentFromStorage(docId); + if (loadedDoc) { + doc = loadedDoc; + } + } + if (doc) { + allDocs.set(docId, doc); } } - if (doc) { - allDocs.set(docId, doc); - } - } - await exportAllDocumentsAsZip(allDocs, state.workspaceName); + await exportAllDocumentsAsZip(allDocs, state.workspaceName); + useToastStore.getState().showToast('All documents exported successfully', 'success'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + useToastStore.getState().showToast(`Failed to export documents: ${message}`, 'error', 5000); + } }, // Export workspace exportWorkspace: async () => { const state = get(); - const loadDoc = async (id: string): Promise => { - return loadDocumentFromStorage(id); - }; + try { + const loadDoc = async (id: string): Promise => { + return loadDocumentFromStorage(id); + }; - await exportWorkspaceToZip( - { - workspaceId: state.workspaceId, - workspaceName: state.workspaceName, - documentOrder: state.documentOrder, - activeDocumentId: state.activeDocumentId, - settings: state.settings, - }, - state.documents, - state.documentOrder, - loadDoc - ); + await exportWorkspaceToZip( + { + workspaceId: state.workspaceId, + workspaceName: state.workspaceName, + documentOrder: state.documentOrder, + activeDocumentId: state.activeDocumentId, + settings: state.settings, + }, + state.documents, + state.documentOrder, + loadDoc + ); + useToastStore.getState().showToast('Workspace exported successfully', 'success'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + useToastStore.getState().showToast(`Failed to export workspace: ${message}`, 'error', 5000); + } }, // Import workspace @@ -750,11 +785,11 @@ export const useWorkspaceStore = create((set, get) documentMetadata: allMetadata, }); - alert('Workspace imported successfully!'); + useToastStore.getState().showToast('Workspace imported successfully', 'success'); resolve(); }, (error) => { - alert(`Failed to import workspace: ${error}`); + useToastStore.getState().showToast(`Failed to import workspace: ${error}`, 'error', 5000); resolve(); } ); diff --git a/tailwind.config.js b/tailwind.config.js index 77bdfd1..1e91c3a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -19,7 +19,16 @@ export default { 800: '#075985', 900: '#0c4a6e', } - } + }, + keyframes: { + 'slide-in-right': { + '0%': { transform: 'translateX(100%)', opacity: '0' }, + '100%': { transform: 'translateX(0)', opacity: '1' }, + }, + }, + animation: { + 'slide-in-right': 'slide-in-right 0.3s ease-out', + }, }, }, plugins: [],