mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
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:
parent
d7d91798f1
commit
8998061262
7 changed files with 307 additions and 38 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
105
src/components/Common/Toast.tsx
Normal file
105
src/components/Common/Toast.tsx
Normal 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;
|
||||
58
src/components/Common/ToastContainer.tsx
Normal file
58
src/components/Common/ToastContainer.tsx
Normal 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;
|
||||
|
|
@ -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
57
src/stores/toastStore.ts
Normal 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: [] });
|
||||
},
|
||||
}));
|
||||
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue