feat(ui): implement comprehensive toast notification system

Added a complete toast notification system with:
- Toast store using Zustand for state management
- Toast component with 4 types: success, error, warning, info
- Animated slide-in/slide-out transitions
- Auto-dismiss after configurable duration
- Close button on each toast
- Utility functions for easy access (toast.success(), toast.error(), etc.)

Integrated toast notifications into file operations:
- Success notifications for: open image, open project, export image, save project
- Error notifications for: failed operations
- Warning notifications for: unsupported file types

UI Features:
- Stacks toasts in top-right corner
- Color-coded by type with icons (CheckCircle, AlertCircle, AlertTriangle, Info)
- Accessible with ARIA attributes
- Smooth animations using custom CSS keyframes

This provides immediate user feedback for all major operations throughout
the application.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-21 15:42:50 +01:00
parent 2f51f11263
commit 3ad7dbf314
8 changed files with 209 additions and 23 deletions

View File

@@ -275,6 +275,17 @@
}
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out;
}
@@ -283,6 +294,10 @@
animation: slideInFromRight 0.3s ease-out;
}
.animate-slideOutRight {
animation: slideOutRight 0.3s ease-out;
}
.animate-slideDown {
animation: slideDown 0.3s ease-out;
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import './globals.css';
import { ToastProvider } from '@/components/providers/toast-provider';
export const metadata: Metadata = {
title: 'Paint UI - Browser Image Editor',
@@ -34,6 +35,7 @@ export default function RootLayout({
</head>
<body className="min-h-screen antialiased overflow-hidden">
{children}
<ToastProvider />
</body>
</html>
);

View File

@@ -0,0 +1,24 @@
'use client';
import { useToastStore } from '@/store/toast-store';
import { Toast } from '@/components/ui/toast';
export function ToastProvider() {
const { toasts } = useToastStore();
if (toasts.length === 0) return null;
return (
<div
className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none"
aria-live="polite"
aria-atomic="true"
>
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<Toast toast={toast} />
</div>
))}
</div>
);
}

59
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,59 @@
'use client';
import { useEffect, useState } from 'react';
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
import type { Toast as ToastType } from '@/store/toast-store';
import { useToastStore } from '@/store/toast-store';
interface ToastProps {
toast: ToastType;
}
const iconMap = {
success: CheckCircle,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
};
const colorMap = {
success: 'bg-success text-success-foreground border-success',
error: 'bg-destructive text-destructive-foreground border-destructive',
warning: 'bg-warning text-warning-foreground border-warning',
info: 'bg-info text-info-foreground border-info',
};
export function Toast({ toast }: ToastProps) {
const { removeToast } = useToastStore();
const [isExiting, setIsExiting] = useState(false);
const Icon = iconMap[toast.type];
const handleClose = () => {
setIsExiting(true);
setTimeout(() => {
removeToast(toast.id);
}, 300); // Match animation duration
};
return (
<div
className={`
flex items-center gap-3 min-w-[300px] max-w-md p-4 rounded-lg border-2 shadow-lg
${colorMap[toast.type]}
${isExiting ? 'animate-slideOutRight' : 'animate-slideInFromRight'}
`}
role="alert"
aria-live="polite"
>
<Icon className="w-5 h-5 flex-shrink-0" />
<p className="flex-1 text-sm font-medium">{toast.message}</p>
<button
onClick={handleClose}
className="flex-shrink-0 rounded-full p-1 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
aria-label="Close notification"
>
<X className="w-4 h-4" />
</button>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
isImageFile,
isProjectFile,
} from '@/lib/file-utils';
import { toast } from '@/lib/toast-utils';
import type { Layer } from '@/types';
export function useFileOperations() {
@@ -63,9 +64,11 @@ export function useFileOperations() {
ctx.drawImage(img, 0, 0);
}
}
toast.success(`Opened ${file.name}`);
} catch (error) {
console.error('Failed to open image:', error);
alert('Failed to open image file');
toast.error('Failed to open image file');
}
},
[clearLayers, clearHistory, setDimensions, createLayer]
@@ -106,9 +109,11 @@ export function useFileOperations() {
}
}
}
toast.success(`Opened project ${file.name}`);
} catch (error) {
console.error('Failed to open project:', error);
alert('Failed to open project file');
toast.error('Failed to open project file');
}
},
[clearLayers, clearHistory, setDimensions, createLayer]
@@ -119,29 +124,38 @@ export function useFileOperations() {
*/
const exportImage = useCallback(
async (format: 'png' | 'jpeg' | 'webp', quality: number, filename: string) => {
// Create temporary canvas with all layers
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const ctx = tempCanvas.getContext('2d');
try {
// Create temporary canvas with all layers
const tempCanvas = document.createElement('canvas');
tempCanvas.width = width;
tempCanvas.height = height;
const ctx = tempCanvas.getContext('2d');
if (!ctx) return;
if (!ctx) {
toast.error('Failed to create export canvas');
return;
}
// Draw all visible layers
layers
.filter((layer) => layer.visible && layer.canvas)
.sort((a, b) => a.order - b.order)
.forEach((layer) => {
if (!layer.canvas) return;
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode as GlobalCompositeOperation;
ctx.drawImage(layer.canvas, layer.x, layer.y);
});
// Draw all visible layers
layers
.filter((layer) => layer.visible && layer.canvas)
.sort((a, b) => a.order - b.order)
.forEach((layer) => {
if (!layer.canvas) return;
ctx.globalAlpha = layer.opacity;
ctx.globalCompositeOperation = layer.blendMode as GlobalCompositeOperation;
ctx.drawImage(layer.canvas, layer.x, layer.y);
});
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
await exportCanvasAsImage(tempCanvas, format, quality, filename);
await exportCanvasAsImage(tempCanvas, format, quality, filename);
toast.success(`Exported ${filename}.${format}`);
} catch (error) {
console.error('Failed to export image:', error);
toast.error('Failed to export image');
}
},
[layers, width, height]
);
@@ -151,7 +165,13 @@ export function useFileOperations() {
*/
const saveProject = useCallback(
async (filename: string) => {
await exportProject(layers, width, height, filename);
try {
await exportProject(layers, width, height, filename);
toast.success(`Saved project ${filename}.json`);
} catch (error) {
console.error('Failed to save project:', error);
toast.error('Failed to save project');
}
},
[layers, width, height]
);
@@ -166,7 +186,7 @@ export function useFileOperations() {
} else if (isImageFile(file)) {
await openImage(file);
} else {
alert('Unsupported file type');
toast.warning('Unsupported file type');
}
},
[openProject, openImage]

19
lib/toast-utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import { useToastStore } from '@/store/toast-store';
/**
* Toast utility functions for easy access
*/
export const toast = {
success: (message: string, duration?: number) => {
useToastStore.getState().addToast(message, 'success', duration);
},
error: (message: string, duration?: number) => {
useToastStore.getState().addToast(message, 'error', duration);
},
warning: (message: string, duration?: number) => {
useToastStore.getState().addToast(message, 'warning', duration);
},
info: (message: string, duration?: number) => {
useToastStore.getState().addToast(message, 'info', duration);
},
};

View File

@@ -9,3 +9,4 @@ export * from './transform-store';
export * from './shape-store';
export * from './text-store';
export * from './ui-store';
export * from './toast-store';

46
store/toast-store.ts Normal file
View File

@@ -0,0 +1,46 @@
import { create } from 'zustand';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ToastStore {
toasts: Toast[];
addToast: (message: string, type?: ToastType, duration?: number) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
}
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
addToast: (message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const toast: Toast = { id, message, type, duration };
set((state) => ({
toasts: [...state.toasts, toast],
}));
// Auto-remove after duration
if (duration > 0) {
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
}, duration);
}
},
removeToast: (id: string) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
clearToasts: () => set({ toasts: [] }),
}));