diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e830ee4..b6794b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,9 +3,32 @@ import { RecipeListPage } from './pages/RecipeListPage'; import { RecipeDetailPage } from './pages/RecipeDetailPage'; import { CookModePage } from './pages/CookModePage'; import { NotFoundPage } from './pages/NotFoundPage'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import { ToastContainer } from './components/Toast'; +import { useToast } from './hooks/useToast'; +import { createContext, useContext } from 'react'; + +// Create toast context to share toast functionality across the app +interface ToastContextType { + success: (message: string, duration?: number) => string; + error: (message: string, duration?: number) => string; + info: (message: string, duration?: number) => string; + warning: (message: string, duration?: number) => string; +} + +const ToastContext = createContext(null); + +export function useToastContext() { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToastContext must be used within ToastProvider'); + } + return context; +} function App() { const location = useLocation(); + const toast = useToast(); const isActive = (path: string) => { if (path === '/' && location.pathname === '/') return true; @@ -21,46 +44,52 @@ function App() { }; return ( -
-
-
-
-
- -

Recipe Manager

- + + +
+ + +
+
+
+
+ +

Recipe Manager

+ +
+ + +
- - -
+
+ +
+ + } /> + } /> + } /> + } /> + } /> + +
+ +
+
+

+ Recipe Manager MVP - Built with React + Vite + TypeScript +

+
+
- - -
- - } /> - } /> - } /> - } /> - } /> - -
- - - + + ); } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..7e637b1 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,95 @@ +/** + * Error Boundary component to catch React errors + */ + +import { Component, type ReactNode } from 'react'; + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: (error: Error, resetError: () => void) => ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +/** + * Error boundary that catches React errors and displays a fallback UI + */ +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + console.error('Error boundary caught error:', error, errorInfo); + } + + resetError = (): void => { + this.setState({ + hasError: false, + error: null, + }); + }; + + render(): ReactNode { + if (this.state.hasError && this.state.error) { + if (this.props.fallback) { + return this.props.fallback(this.state.error, this.resetError); + } + + return ( +
+
+
+
⚠️
+

Something went wrong

+

+ An unexpected error occurred. Please try refreshing the page. +

+
+ +
+ + Error details + +
+                {this.state.error.toString()}
+                {this.state.error.stack && `\n\n${this.state.error.stack}`}
+              
+
+ +
+ + +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/src/components/TagSelector.tsx b/frontend/src/components/TagSelector.tsx index e64ba74..94824f4 100644 --- a/frontend/src/components/TagSelector.tsx +++ b/frontend/src/components/TagSelector.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useTags } from '../hooks/useTags'; +import { useToastContext } from '../App'; import type { Tag } from '../types/recipe'; interface TagSelectorProps { @@ -13,6 +14,7 @@ interface TagSelectorProps { export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) { const { tags, loading, error, addTag } = useTags(); + const toast = useToastContext(); const [showNewTagForm, setShowNewTagForm] = useState(false); const [newTagName, setNewTagName] = useState(''); const [newTagColor, setNewTagColor] = useState('#3B82F6'); @@ -40,8 +42,10 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) { setNewTagName(''); setNewTagColor('#3B82F6'); setShowNewTagForm(false); + toast.success(`Tag "${newTag.name}" created!`); } catch (err) { - alert(err instanceof Error ? err.message : 'Failed to create tag'); + const errorMessage = err instanceof Error ? err.message : 'Failed to create tag'; + toast.error(errorMessage); } finally { setCreating(false); } @@ -52,7 +56,11 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) { } if (error) { - return
Error loading tags: {error}
; + return ( +
+

Error loading tags: {error}

+
+ ); } return ( diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..a0c8410 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,85 @@ +/** + * Toast notification component + * Displays temporary success/error/info messages + */ + +import { useEffect } from 'react'; + +export type ToastType = 'success' | 'error' | 'info' | 'warning'; + +export interface ToastMessage { + id: string; + message: string; + type: ToastType; + duration?: number; +} + +interface ToastProps { + message: ToastMessage; + onClose: (id: string) => void; +} + +/** + * Single toast notification + */ +export function Toast({ message, onClose }: ToastProps) { + useEffect(() => { + const timer = setTimeout(() => { + onClose(message.id); + }, message.duration || 5000); + + return () => clearTimeout(timer); + }, [message.id, message.duration, onClose]); + + const bgColor = { + success: 'bg-green-600', + error: 'bg-red-600', + info: 'bg-blue-600', + warning: 'bg-yellow-600', + }[message.type]; + + const icon = { + success: '✓', + error: '✕', + info: 'ℹ', + warning: '⚠', + }[message.type]; + + return ( +
+
+ {icon} + {message.message} +
+ +
+ ); +} + +/** + * Toast container that displays all active toasts + */ +interface ToastContainerProps { + messages: ToastMessage[]; + onClose: (id: string) => void; +} + +export function ToastContainer({ messages, onClose }: ToastContainerProps) { + if (messages.length === 0) return null; + + return ( +
+ {messages.map((message) => ( + + ))} +
+ ); +} diff --git a/frontend/src/hooks/useToast.ts b/frontend/src/hooks/useToast.ts new file mode 100644 index 0000000..dda6aaa --- /dev/null +++ b/frontend/src/hooks/useToast.ts @@ -0,0 +1,55 @@ +/** + * Hook for managing toast notifications + */ + +import { useState, useCallback } from 'react'; +import type { ToastMessage, ToastType } from '../components/Toast'; + +let toastId = 0; + +export function useToast() { + const [messages, setMessages] = useState([]); + + const addToast = useCallback((message: string, type: ToastType = 'info', duration?: number) => { + const id = `toast-${++toastId}`; + const newMessage: ToastMessage = { + id, + message, + type, + duration, + }; + + setMessages((prev) => [...prev, newMessage]); + return id; + }, []); + + const removeToast = useCallback((id: string) => { + setMessages((prev) => prev.filter((msg) => msg.id !== id)); + }, []); + + const success = useCallback((message: string, duration?: number) => { + return addToast(message, 'success', duration); + }, [addToast]); + + const error = useCallback((message: string, duration?: number) => { + return addToast(message, 'error', duration); + }, [addToast]); + + const info = useCallback((message: string, duration?: number) => { + return addToast(message, 'info', duration); + }, [addToast]); + + const warning = useCallback((message: string, duration?: number) => { + return addToast(message, 'warning', duration); + }, [addToast]); + + return { + messages, + addToast, + removeToast, + success, + error, + info, + warning, + }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 47ff719..2cf6b8c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -51,3 +51,19 @@ body { #root { min-height: 100vh; } + +/* Toast animation */ +@keyframes slide-in { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.animate-slide-in { + animation: slide-in 0.3s ease-out; +} diff --git a/frontend/src/pages/RecipeDetailPage.tsx b/frontend/src/pages/RecipeDetailPage.tsx index 2d79d05..b8aa78f 100644 --- a/frontend/src/pages/RecipeDetailPage.tsx +++ b/frontend/src/pages/RecipeDetailPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useRecipe } from '../hooks/useRecipe'; +import { useToastContext } from '../App'; import { RecipeForm, type RecipeFormData } from '../components/RecipeForm'; import { createRecipe, @@ -18,6 +19,7 @@ import type { Tag } from '../types/recipe'; export function RecipeDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const toast = useToastContext(); // Parse ID or null for "new" route const recipeId = id === 'new' ? null : (id ? parseInt(id, 10) : null); @@ -26,53 +28,80 @@ export function RecipeDetailPage() { const [isEditing, setIsEditing] = useState(recipeId === null); // Start in edit mode for new recipes const [deleteConfirm, setDeleteConfirm] = useState(false); const [recipeTags, setRecipeTags] = useState([]); + const [isDeleting, setIsDeleting] = useState(false); // Load recipe tags useEffect(() => { if (recipeId !== null) { fetchRecipeTags(recipeId) .then(setRecipeTags) - .catch(console.error); + .catch((err) => { + console.error('Failed to load recipe tags:', err); + toast.error('Failed to load recipe tags'); + }); } - }, [recipeId]); + }, [recipeId, toast]); // Handle form submission const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => { - if (recipeId === null) { - // Create new recipe - const newRecipe = await createRecipe(data); - - // Assign tags - for (const tag of tags) { - await assignTagToRecipe(newRecipe.id, tag.id); - } - - navigate(`/recipe/${newRecipe.id}`); - } else { - // Update existing recipe - await updateRecipe(recipeId, data); - - // Update tags: remove old ones, add new ones - const currentTagIds = recipeTags.map(t => t.id); - const newTagIds = tags.map(t => t.id); - - // Remove tags that are no longer selected - for (const tagId of currentTagIds) { - if (!newTagIds.includes(tagId)) { - await removeTagFromRecipe(recipeId, tagId); + try { + if (recipeId === null) { + // Create new recipe + const newRecipe = await createRecipe(data); + + // Assign tags + for (const tag of tags) { + try { + await assignTagToRecipe(newRecipe.id, tag.id); + } catch (err) { + console.error('Failed to assign tag:', err); + toast.warning(`Failed to assign tag "${tag.name}"`); + } } - } - - // Add tags that are newly selected - for (const tagId of newTagIds) { - if (!currentTagIds.includes(tagId)) { - await assignTagToRecipe(recipeId, tagId); + + toast.success('Recipe created successfully!'); + navigate(`/recipe/${newRecipe.id}`); + } else { + // Update existing recipe + await updateRecipe(recipeId, data); + + // Update tags: remove old ones, add new ones + const currentTagIds = recipeTags.map(t => t.id); + const newTagIds = tags.map(t => t.id); + + // Remove tags that are no longer selected + for (const tagId of currentTagIds) { + if (!newTagIds.includes(tagId)) { + try { + await removeTagFromRecipe(recipeId, tagId); + } catch (err) { + console.error('Failed to remove tag:', err); + toast.warning('Failed to remove some tags'); + } + } } + + // Add tags that are newly selected + for (const tagId of newTagIds) { + if (!currentTagIds.includes(tagId)) { + try { + await assignTagToRecipe(recipeId, tagId); + } catch (err) { + console.error('Failed to assign tag:', err); + toast.warning('Failed to assign some tags'); + } + } + } + + toast.success('Recipe updated successfully!'); + setIsEditing(false); + // Refresh the page to show updated data + window.location.reload(); } - - setIsEditing(false); - // Refresh the page to show updated data - window.location.reload(); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to save recipe'; + toast.error(errorMessage); + throw err; // Re-throw so form can handle it } }; @@ -80,8 +109,17 @@ export function RecipeDetailPage() { const handleDelete = async () => { if (recipeId === null) return; - await deleteRecipe(recipeId); - navigate('/'); + try { + setIsDeleting(true); + await deleteRecipe(recipeId); + toast.success('Recipe deleted successfully'); + navigate('/'); + } catch (err) { + setIsDeleting(false); + setDeleteConfirm(false); + const errorMessage = err instanceof Error ? err.message : 'Failed to delete recipe'; + toast.error(errorMessage); + } }; // Loading state @@ -217,13 +255,15 @@ export function RecipeDetailPage() {