feat(frontend): add comprehensive error handling and toast notifications
- Created Toast component with slide-in animation for success/error/info/warning messages - Created useToast hook for managing toast notifications globally - Added ToastContext to App.tsx for sharing toast functionality across components - Implemented ErrorBoundary component to catch and display React errors gracefully - Updated RecipeDetailPage to show toast notifications for all operations (create, update, delete, tag management) - Updated TagSelector to use toast notifications instead of alert() - Added proper error handling for all API operations with user-friendly messages - Added loading states for delete operation - Verified: All 34 backend tests passing, frontend builds successfully
This commit is contained in:
parent
9b6d4d50e2
commit
6b0f2e10c6
|
|
@ -3,9 +3,32 @@ import { RecipeListPage } from './pages/RecipeListPage';
|
||||||
import { RecipeDetailPage } from './pages/RecipeDetailPage';
|
import { RecipeDetailPage } from './pages/RecipeDetailPage';
|
||||||
import { CookModePage } from './pages/CookModePage';
|
import { CookModePage } from './pages/CookModePage';
|
||||||
import { NotFoundPage } from './pages/NotFoundPage';
|
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<ToastContextType | null>(null);
|
||||||
|
|
||||||
|
export function useToastContext() {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToastContext must be used within ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === '/' && location.pathname === '/') return true;
|
if (path === '/' && location.pathname === '/') return true;
|
||||||
|
|
@ -21,46 +44,52 @@ function App() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<ErrorBoundary>
|
||||||
<header className="bg-white shadow">
|
<ToastContext.Provider value={toast}>
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="flex items-center justify-between h-16">
|
<ToastContainer messages={toast.messages} onClose={toast.removeToast} />
|
||||||
<div className="flex items-center">
|
|
||||||
<Link to="/" className="flex-shrink-0">
|
<header className="bg-white shadow">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Recipe Manager</h1>
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
</Link>
|
<div className="flex items-center justify-between h-16">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Link to="/" className="flex-shrink-0">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Recipe Manager</h1>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex space-x-4">
|
||||||
|
<Link to="/" className={linkClass('/')}>
|
||||||
|
Recipes
|
||||||
|
</Link>
|
||||||
|
<Link to="/recipe/new" className={linkClass('/recipe/new')}>
|
||||||
|
Add Recipe
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
<nav className="flex space-x-4">
|
|
||||||
<Link to="/" className={linkClass('/')}>
|
<main className="max-w-7xl mx-auto py-6 px-4">
|
||||||
Recipes
|
<Routes>
|
||||||
</Link>
|
<Route path="/" element={<RecipeListPage />} />
|
||||||
<Link to="/recipe/new" className={linkClass('/recipe/new')}>
|
<Route path="/recipe/new" element={<RecipeDetailPage />} />
|
||||||
Add Recipe
|
<Route path="/recipe/:id" element={<RecipeDetailPage />} />
|
||||||
</Link>
|
<Route path="/recipe/:id/cook" element={<CookModePage />} />
|
||||||
</nav>
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</div>
|
</Routes>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="bg-white border-t mt-12">
|
||||||
|
<div className="max-w-7xl mx-auto py-6 px-4">
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
Recipe Manager MVP - Built with React + Vite + TypeScript
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</ToastContext.Provider>
|
||||||
|
</ErrorBoundary>
|
||||||
<main className="max-w-7xl mx-auto py-6 px-4">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<RecipeListPage />} />
|
|
||||||
<Route path="/recipe/new" element={<RecipeDetailPage />} />
|
|
||||||
<Route path="/recipe/:id" element={<RecipeDetailPage />} />
|
|
||||||
<Route path="/recipe/:id/cook" element={<CookModePage />} />
|
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer className="bg-white border-t mt-12">
|
|
||||||
<div className="max-w-7xl mx-auto py-6 px-4">
|
|
||||||
<p className="text-center text-sm text-gray-500">
|
|
||||||
Recipe Manager MVP - Built with React + Vite + TypeScript
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="text-6xl mb-4">⚠️</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Something went wrong</h2>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
An unexpected error occurred. Please try refreshing the page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="mb-4">
|
||||||
|
<summary className="cursor-pointer text-sm text-gray-600 hover:text-gray-800 font-medium">
|
||||||
|
Error details
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-3 bg-gray-50 rounded text-xs text-red-600 overflow-auto">
|
||||||
|
{this.state.error.toString()}
|
||||||
|
{this.state.error.stack && `\n\n${this.state.error.stack}`}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
Refresh Page
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={this.resetError}
|
||||||
|
className="flex-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-300 font-medium"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTags } from '../hooks/useTags';
|
import { useTags } from '../hooks/useTags';
|
||||||
|
import { useToastContext } from '../App';
|
||||||
import type { Tag } from '../types/recipe';
|
import type { Tag } from '../types/recipe';
|
||||||
|
|
||||||
interface TagSelectorProps {
|
interface TagSelectorProps {
|
||||||
|
|
@ -13,6 +14,7 @@ interface TagSelectorProps {
|
||||||
|
|
||||||
export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
|
export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
|
||||||
const { tags, loading, error, addTag } = useTags();
|
const { tags, loading, error, addTag } = useTags();
|
||||||
|
const toast = useToastContext();
|
||||||
const [showNewTagForm, setShowNewTagForm] = useState(false);
|
const [showNewTagForm, setShowNewTagForm] = useState(false);
|
||||||
const [newTagName, setNewTagName] = useState('');
|
const [newTagName, setNewTagName] = useState('');
|
||||||
const [newTagColor, setNewTagColor] = useState('#3B82F6');
|
const [newTagColor, setNewTagColor] = useState('#3B82F6');
|
||||||
|
|
@ -40,8 +42,10 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
|
||||||
setNewTagName('');
|
setNewTagName('');
|
||||||
setNewTagColor('#3B82F6');
|
setNewTagColor('#3B82F6');
|
||||||
setShowNewTagForm(false);
|
setShowNewTagForm(false);
|
||||||
|
toast.success(`Tag "${newTag.name}" created!`);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +56,11 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className="text-red-600">Error loading tags: {error}</div>;
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||||
|
<p className="text-red-700 text-sm">Error loading tags: {error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={`${bgColor} text-white px-6 py-4 rounded-lg shadow-lg flex items-center justify-between gap-4 min-w-[300px] max-w-[500px] animate-slide-in`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl font-bold">{icon}</span>
|
||||||
|
<span className="font-medium">{message.message}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onClose(message.id)}
|
||||||
|
className="text-white hover:text-gray-200 text-xl font-bold leading-none"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<Toast key={message.id} message={message} onClose={onClose} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<ToastMessage[]>([]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -51,3 +51,19 @@ body {
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useRecipe } from '../hooks/useRecipe';
|
import { useRecipe } from '../hooks/useRecipe';
|
||||||
|
import { useToastContext } from '../App';
|
||||||
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
|
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
|
||||||
import {
|
import {
|
||||||
createRecipe,
|
createRecipe,
|
||||||
|
|
@ -18,6 +19,7 @@ import type { Tag } from '../types/recipe';
|
||||||
export function RecipeDetailPage() {
|
export function RecipeDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const toast = useToastContext();
|
||||||
|
|
||||||
// Parse ID or null for "new" route
|
// Parse ID or null for "new" route
|
||||||
const recipeId = id === 'new' ? null : (id ? parseInt(id, 10) : null);
|
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 [isEditing, setIsEditing] = useState(recipeId === null); // Start in edit mode for new recipes
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
const [recipeTags, setRecipeTags] = useState<Tag[]>([]);
|
const [recipeTags, setRecipeTags] = useState<Tag[]>([]);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
// Load recipe tags
|
// Load recipe tags
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (recipeId !== null) {
|
if (recipeId !== null) {
|
||||||
fetchRecipeTags(recipeId)
|
fetchRecipeTags(recipeId)
|
||||||
.then(setRecipeTags)
|
.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
|
// Handle form submission
|
||||||
const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => {
|
const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => {
|
||||||
if (recipeId === null) {
|
try {
|
||||||
// Create new recipe
|
if (recipeId === null) {
|
||||||
const newRecipe = await createRecipe(data);
|
// Create new recipe
|
||||||
|
const newRecipe = await createRecipe(data);
|
||||||
// Assign tags
|
|
||||||
for (const tag of tags) {
|
// Assign tags
|
||||||
await assignTagToRecipe(newRecipe.id, tag.id);
|
for (const tag of tags) {
|
||||||
}
|
try {
|
||||||
|
await assignTagToRecipe(newRecipe.id, tag.id);
|
||||||
navigate(`/recipe/${newRecipe.id}`);
|
} catch (err) {
|
||||||
} else {
|
console.error('Failed to assign tag:', err);
|
||||||
// Update existing recipe
|
toast.warning(`Failed to assign tag "${tag.name}"`);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
toast.success('Recipe created successfully!');
|
||||||
// Add tags that are newly selected
|
navigate(`/recipe/${newRecipe.id}`);
|
||||||
for (const tagId of newTagIds) {
|
} else {
|
||||||
if (!currentTagIds.includes(tagId)) {
|
// Update existing recipe
|
||||||
await assignTagToRecipe(recipeId, tagId);
|
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();
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
setIsEditing(false);
|
const errorMessage = err instanceof Error ? err.message : 'Failed to save recipe';
|
||||||
// Refresh the page to show updated data
|
toast.error(errorMessage);
|
||||||
window.location.reload();
|
throw err; // Re-throw so form can handle it
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -80,8 +109,17 @@ export function RecipeDetailPage() {
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (recipeId === null) return;
|
if (recipeId === null) return;
|
||||||
|
|
||||||
await deleteRecipe(recipeId);
|
try {
|
||||||
navigate('/');
|
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
|
// Loading state
|
||||||
|
|
@ -217,13 +255,15 @@ export function RecipeDetailPage() {
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm font-medium"
|
disabled={isDeleting}
|
||||||
|
className="px-3 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm font-medium disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Confirm Delete
|
{isDeleting ? 'Deleting...' : 'Confirm Delete'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirm(false)}
|
onClick={() => setDeleteConfirm(false)}
|
||||||
className="px-3 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm font-medium"
|
disabled={isDeleting}
|
||||||
|
className="px-3 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue