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 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 */}
<ToastContainer />
</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 { 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";

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,
selectWorkspaceZipForImport,
} from './workspace/workspaceIO';
import { useToastStore } from './toastStore';
/**
* Workspace Store
@ -173,6 +174,8 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
return newState;
});
useToastStore.getState().showToast(`Document "${title}" created`, 'success');
return documentId;
},
@ -342,6 +345,8 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
);
if (!confirmed) return false;
const docTitle = metadata?.title || 'Untitled';
// Delete from storage
deleteDocumentFromStorage(documentId);
@ -374,6 +379,8 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
};
});
useToastStore.getState().showToast(`Document "${docTitle}" deleted`, 'info');
return true;
},
@ -400,6 +407,8 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
return {};
});
useToastStore.getState().showToast(`Document renamed to "${newTitle}"`, 'success');
},
// Duplicate document
@ -408,6 +417,7 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((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<Workspace & WorkspaceActions>((set, get)
};
});
useToastStore.getState().showToast(`Document duplicated as "${newTitle}"`, 'success');
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);
},
(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<Workspace & WorkspaceActions>((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<Workspace & WorkspaceActions>((set, get)
exportAllDocumentsAsZip: async () => {
const state = get();
// Ensure all documents are loaded
const allDocs = new Map<string, ConstellationDocument>();
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<string, ConstellationDocument>();
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<ConstellationDocument | null> => {
return loadDocumentFromStorage(id);
};
try {
const loadDoc = async (id: string): Promise<ConstellationDocument | null> => {
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<Workspace & WorkspaceActions>((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();
}
);

View file

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