feat: add toast notification system for visual feedback

Implements toast notifications from UX_ANALYSIS.md to provide clear,
non-intrusive feedback for user actions.

Features:
- Toast store with Zustand for global state management
- Four toast variants: success, error, info, warning
- Auto-dismiss after configurable duration (default 4s)
- Max 3 visible toasts with FIFO queue
- Smart positioning that avoids right panel overlap
- Smooth slide-in/fade-out animations

Notifications added for:
- File operations (import/export success/errors)
- Document operations (create/delete/rename/duplicate)
- Workspace operations (import/export)

Toast container dynamically repositions based on right panel state
to ensure toasts never overlap with critical UI elements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-10 23:13:28 +02:00
parent d7d91798f1
commit 8998061262
7 changed files with 307 additions and 38 deletions

View file

@ -8,6 +8,7 @@ import Toolbar from "./components/Toolbar/Toolbar";
import MenuBar from "./components/Menu/MenuBar"; import MenuBar from "./components/Menu/MenuBar";
import DocumentManager from "./components/Workspace/DocumentManager"; import DocumentManager from "./components/Workspace/DocumentManager";
import KeyboardShortcutsHelp from "./components/Common/KeyboardShortcutsHelp"; import KeyboardShortcutsHelp from "./components/Common/KeyboardShortcutsHelp";
import ToastContainer from "./components/Common/ToastContainer";
import { KeyboardShortcutProvider } from "./contexts/KeyboardShortcutContext"; import { KeyboardShortcutProvider } from "./contexts/KeyboardShortcutContext";
import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
import { useDocumentHistory } from "./hooks/useDocumentHistory"; import { useDocumentHistory } from "./hooks/useDocumentHistory";
@ -205,6 +206,9 @@ function AppContent() {
isOpen={showKeyboardHelp} isOpen={showKeyboardHelp}
onClose={() => setShowKeyboardHelp(false)} onClose={() => setShowKeyboardHelp(false)}
/> />
{/* Toast Notifications */}
<ToastContainer />
</div> </div>
); );
} }

View file

@ -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: <CheckCircleIcon className="w-5 h-5" />,
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
textColor: 'text-green-800',
iconColor: 'text-green-600',
},
error: {
icon: <ErrorIcon className="w-5 h-5" />,
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
textColor: 'text-red-800',
iconColor: 'text-red-600',
},
info: {
icon: <InfoIcon className="w-5 h-5" />,
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
textColor: 'text-blue-800',
iconColor: 'text-blue-600',
},
warning: {
icon: <WarningIcon className="w-5 h-5" />,
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 (
<div
className={`
${config.bgColor} ${config.borderColor} ${config.textColor}
border rounded-lg shadow-lg p-4 mb-2
flex items-start space-x-3 min-w-[320px] max-w-[400px]
transition-all duration-300 ease-in-out
${isExiting
? 'opacity-0 translate-x-8 scale-95'
: 'opacity-100 translate-x-0 scale-100 animate-slide-in-right'
}
`}
role="alert"
aria-live={toast.type === 'error' ? 'assertive' : 'polite'}
>
{/* Icon */}
<div className={`flex-shrink-0 ${config.iconColor}`}>
{config.icon}
</div>
{/* Message */}
<div className="flex-1 text-sm font-medium pt-0.5">
{toast.message}
</div>
{/* Close button */}
<IconButton
size="small"
onClick={handleClose}
className={`flex-shrink-0 -mt-1 -mr-1 ${config.iconColor}`}
aria-label="Dismiss notification"
>
<CloseIcon fontSize="small" />
</IconButton>
</div>
);
};
export default Toast;

View file

@ -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 (
<div
className="fixed z-[9999] pointer-events-none transition-all duration-300"
style={{
top: `${topOffset}px`,
right: `${rightOffset}px`,
}}
>
<div className="flex flex-col items-end pointer-events-auto">
{toasts.map((toast) => (
<Toast key={toast.id} toast={toast} onClose={hideToast} />
))}
</div>
</div>
);
};
export default ToastContainer;

View file

@ -34,6 +34,7 @@ import { createNode } from "../../utils/nodeUtils";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { useConfirm } from "../../hooks/useConfirm"; import { useConfirm } from "../../hooks/useConfirm";
import { useGraphExport } from "../../hooks/useGraphExport"; import { useGraphExport } from "../../hooks/useGraphExport";
import { useToastStore } from "../../stores/toastStore";
import type { ExportOptions } from "../../utils/graphExport"; import type { ExportOptions } from "../../utils/graphExport";
import type { Actor, Relation } from "../../types"; import type { Actor, Relation } from "../../types";

57
src/stores/toastStore.ts Normal file
View file

@ -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<ToastState>((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: [] });
},
}));

View file

@ -22,6 +22,7 @@ import {
exportWorkspace as exportWorkspaceToZip, exportWorkspace as exportWorkspaceToZip,
selectWorkspaceZipForImport, selectWorkspaceZipForImport,
} from './workspace/workspaceIO'; } from './workspace/workspaceIO';
import { useToastStore } from './toastStore';
/** /**
* Workspace Store * Workspace Store
@ -173,6 +174,8 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
return newState; return newState;
}); });
useToastStore.getState().showToast(`Document "${title}" created`, 'success');
return documentId; return documentId;
}, },
@ -342,6 +345,8 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
); );
if (!confirmed) return false; if (!confirmed) return false;
const docTitle = metadata?.title || 'Untitled';
// Delete from storage // Delete from storage
deleteDocumentFromStorage(documentId); deleteDocumentFromStorage(documentId);
@ -374,6 +379,8 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
}; };
}); });
useToastStore.getState().showToast(`Document "${docTitle}" deleted`, 'info');
return true; return true;
}, },
@ -400,6 +407,8 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
return {}; return {};
}); });
useToastStore.getState().showToast(`Document renamed to "${newTitle}"`, 'success');
}, },
// Duplicate document // Duplicate document
@ -408,6 +417,7 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
const sourceDoc = state.documents.get(documentId); const sourceDoc = state.documents.get(documentId);
if (!sourceDoc) { if (!sourceDoc) {
console.error(`Document ${documentId} not found`); console.error(`Document ${documentId} not found`);
useToastStore.getState().showToast('Failed to duplicate: Document not found', 'error');
return ''; return '';
} }
@ -462,6 +472,8 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
}; };
}); });
useToastStore.getState().showToast(`Document duplicated as "${newTitle}"`, 'success');
return newDocumentId; return newDocumentId;
}, },
@ -557,10 +569,14 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
}; };
}); });
// Show success toast
useToastStore.getState().showToast('Document imported successfully', 'success');
resolve(documentId); resolve(documentId);
}, },
(error) => { (error) => {
alert(`Failed to import file: ${error}`); // Show error toast
useToastStore.getState().showToast(`Failed to import file: ${error}`, 'error', 5000);
resolve(null); resolve(null);
} }
); );
@ -572,15 +588,22 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
const doc = get().documents.get(documentId); const doc = get().documents.get(documentId);
if (!doc) { if (!doc) {
console.error(`Document ${documentId} not found`); console.error(`Document ${documentId} not found`);
useToastStore.getState().showToast('Failed to export: Document not found', 'error');
return; return;
} }
exportGraphToFile( try {
doc.graph.nodes, exportGraphToFile(
doc.graph.edges, doc.graph.nodes,
doc.graph.nodeTypes, doc.graph.edges,
doc.graph.edgeTypes 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 // Save workspace
@ -671,44 +694,56 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
exportAllDocumentsAsZip: async () => { exportAllDocumentsAsZip: async () => {
const state = get(); const state = get();
// Ensure all documents are loaded try {
const allDocs = new Map<string, ConstellationDocument>(); // Ensure all documents are loaded
for (const docId of state.documentOrder) { const allDocs = new Map<string, ConstellationDocument>();
let doc = state.documents.get(docId); for (const docId of state.documentOrder) {
if (!doc) { let doc = state.documents.get(docId);
const loadedDoc = loadDocumentFromStorage(docId); if (!doc) {
if (loadedDoc) { const loadedDoc = loadDocumentFromStorage(docId);
doc = loadedDoc; 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 // Export workspace
exportWorkspace: async () => { exportWorkspace: async () => {
const state = get(); const state = get();
const loadDoc = async (id: string): Promise<ConstellationDocument | null> => { try {
return loadDocumentFromStorage(id); const loadDoc = async (id: string): Promise<ConstellationDocument | null> => {
}; return loadDocumentFromStorage(id);
};
await exportWorkspaceToZip( await exportWorkspaceToZip(
{ {
workspaceId: state.workspaceId, workspaceId: state.workspaceId,
workspaceName: state.workspaceName, workspaceName: state.workspaceName,
documentOrder: state.documentOrder, documentOrder: state.documentOrder,
activeDocumentId: state.activeDocumentId, activeDocumentId: state.activeDocumentId,
settings: state.settings, settings: state.settings,
}, },
state.documents, state.documents,
state.documentOrder, state.documentOrder,
loadDoc 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 // Import workspace
@ -750,11 +785,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
documentMetadata: allMetadata, documentMetadata: allMetadata,
}); });
alert('Workspace imported successfully!'); useToastStore.getState().showToast('Workspace imported successfully', 'success');
resolve(); resolve();
}, },
(error) => { (error) => {
alert(`Failed to import workspace: ${error}`); useToastStore.getState().showToast(`Failed to import workspace: ${error}`, 'error', 5000);
resolve(); resolve();
} }
); );

View file

@ -19,7 +19,16 @@ export default {
800: '#075985', 800: '#075985',
900: '#0c4a6e', 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: [], plugins: [],