diff --git a/app/globals.css b/app/globals.css index b8207ad..67778bf 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/app/layout.tsx b/app/layout.tsx index 11340dd..044d75f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ {children} + ); diff --git a/components/providers/toast-provider.tsx b/components/providers/toast-provider.tsx new file mode 100644 index 0000000..329306d --- /dev/null +++ b/components/providers/toast-provider.tsx @@ -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 ( +
+ {toasts.map((toast) => ( +
+ +
+ ))} +
+ ); +} diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 0000000..eac0948 --- /dev/null +++ b/components/ui/toast.tsx @@ -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 ( +
+ +

{toast.message}

+ +
+ ); +} diff --git a/hooks/use-file-operations.ts b/hooks/use-file-operations.ts index 00b2510..cf1b2f8 100644 --- a/hooks/use-file-operations.ts +++ b/hooks/use-file-operations.ts @@ -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] diff --git a/lib/toast-utils.ts b/lib/toast-utils.ts new file mode 100644 index 0000000..24cb489 --- /dev/null +++ b/lib/toast-utils.ts @@ -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); + }, +}; diff --git a/store/index.ts b/store/index.ts index 0d8d70d..6904d0c 100644 --- a/store/index.ts +++ b/store/index.ts @@ -9,3 +9,4 @@ export * from './transform-store'; export * from './shape-store'; export * from './text-store'; export * from './ui-store'; +export * from './toast-store'; diff --git a/store/toast-store.ts b/store/toast-store.ts new file mode 100644 index 0000000..bd0a55a --- /dev/null +++ b/store/toast-store.ts @@ -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((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: [] }), +}));