mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 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
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,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
exportGraphToFile(
|
exportGraphToFile(
|
||||||
doc.graph.nodes,
|
doc.graph.nodes,
|
||||||
doc.graph.edges,
|
doc.graph.edges,
|
||||||
doc.graph.nodeTypes,
|
doc.graph.nodeTypes,
|
||||||
doc.graph.edgeTypes
|
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,6 +694,7 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
exportAllDocumentsAsZip: async () => {
|
exportAllDocumentsAsZip: async () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|
||||||
|
try {
|
||||||
// Ensure all documents are loaded
|
// Ensure all documents are loaded
|
||||||
const allDocs = new Map<string, ConstellationDocument>();
|
const allDocs = new Map<string, ConstellationDocument>();
|
||||||
for (const docId of state.documentOrder) {
|
for (const docId of state.documentOrder) {
|
||||||
|
|
@ -687,12 +711,18 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
try {
|
||||||
const loadDoc = async (id: string): Promise<ConstellationDocument | null> => {
|
const loadDoc = async (id: string): Promise<ConstellationDocument | null> => {
|
||||||
return loadDocumentFromStorage(id);
|
return loadDocumentFromStorage(id);
|
||||||
};
|
};
|
||||||
|
|
@ -709,6 +739,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
|
||||||
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();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue