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: [],