feat: add recipe editing, image upload management, and UI improvements
Some checks failed
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Dependency License Check (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled

Added comprehensive recipe editing functionality with improved image handling
and better UX for large file uploads.

**Features:**
- Recipe editing: Full CRUD support for recipes with edit page
- Image management: Upload, replace, and delete recipe images
- Upload feedback: Processing and uploading states for better UX
- File size increase: Raised limit from 10MB to 20MB for images
- Nginx configuration: Added client_max_body_size for large uploads

**Changes:**
- Created EditRecipe.tsx page for editing existing recipes
- Created NewRecipe.tsx page wrapper for recipe creation
- Created RecipeForm.tsx comprehensive form component with:
  - Simple and multi-section recipe modes
  - Image upload with confirmation dialogs
  - Processing state feedback during file handling
  - Smaller, button-style upload controls
- Updated recipes.routes.ts:
  - Increased multer fileSize limit to 20MB
  - Added file validation for image types
  - Image upload now updates Recipe.imageUrl field
  - Added DELETE /recipes/:id/image endpoint
  - Automatic cleanup of old images when uploading new ones
- Updated nginx.conf: Added client_max_body_size 20M for API proxy
- Updated App.css: Improved upload button styling (smaller, more compact)
- Updated RecipeForm.tsx: Better file processing feedback with setTimeout
- Updated help text to reflect 20MB limit and WEBP support

**Technical Details:**
- Fixed static file serving path in index.ts
- Added detailed logging for upload debugging
- Improved TypeScript type safety in upload handlers
- Better error handling and user feedback

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-30 05:31:12 +00:00
parent 5797dade02
commit 33eadde671
15 changed files with 9614 additions and 92 deletions

View File

@@ -19,6 +19,7 @@ server {
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 20M;
}
# Proxy uploads requests to backend

View File

@@ -118,6 +118,20 @@ nav a:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.recipe-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.recipe-actions-right {
display: flex;
gap: 0.75rem;
}
.recipe-detail img {
width: 100%;
max-height: 400px;
@@ -190,6 +204,53 @@ nav a:hover {
white-space: nowrap;
}
.recipe-sections {
margin-top: 2rem;
}
.recipe-section {
margin-bottom: 3rem;
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
border-left: 4px solid #2d5016;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.section-header h3 {
color: #2d5016;
margin: 0;
}
.section-timing {
background-color: #2d5016;
color: white;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 500;
}
.instruction-timing {
color: #2d5016;
font-weight: 600;
font-size: 0.95rem;
}
.recipe-section h4 {
color: #2d5016;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.ingredients, .instructions {
margin-bottom: 2rem;
}
@@ -276,3 +337,273 @@ button:disabled {
padding: 2rem;
color: #666;
}
/* Recipe Form Styles */
.recipe-form {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin: 0;
}
.sections-container {
margin-top: 2rem;
}
.section-form {
background-color: #f9f9f9;
border-radius: 8px;
border-left: 4px solid #2d5016;
padding: 1.5rem;
margin-bottom: 2rem;
}
.section-form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-form-header h4 {
margin: 0;
color: #2d5016;
}
.subsection {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
}
.subsection h5 {
color: #2d5016;
margin-bottom: 1rem;
}
.ingredient-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
align-items: center;
}
.ingredient-row .input-small {
width: 80px;
}
.ingredient-row .input-flex {
flex: 1;
}
.instruction-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
align-items: flex-start;
}
.instruction-number {
min-width: 30px;
height: 30px;
background-color: #2d5016;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-top: 0.5rem;
}
.instruction-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.instruction-timing-input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.btn-secondary {
background-color: #666;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.btn-secondary:hover {
background-color: #555;
}
.btn-remove {
background-color: #d32f2f;
color: white;
border: none;
width: 30px;
height: 30px;
border-radius: 4px;
cursor: pointer;
font-size: 1.2rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-remove:hover {
background-color: #b71c1c;
}
.btn-danger-small {
background-color: #d32f2f;
color: white;
border: none;
padding: 0.4rem 0.8rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-danger-small:hover {
background-color: #b71c1c;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid #eee;
}
.form-section {
margin-top: 2rem;
}
/* Image Upload Styles */
.image-upload-section {
padding: 1.5rem;
background-color: #f9f9f9;
border-radius: 8px;
border: 1px solid #ddd;
}
.current-image {
margin-bottom: 1rem;
}
.current-image img {
max-width: 300px;
max-height: 200px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 0.75rem;
}
.image-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.image-note {
font-size: 0.9rem;
color: #666;
margin: 0;
}
.btn-delete-image {
background-color: #d32f2f;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.btn-delete-image:hover:not(:disabled) {
background-color: #b71c1c;
}
.btn-delete-image:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.image-upload-control {
margin: 1rem 0;
}
.file-input {
display: none;
}
.file-input-label {
display: inline-block;
padding: 0.5rem 1rem;
background-color: #a8d08d;
color: #1a3a0d;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
font-weight: 500;
font-size: 0.9rem;
}
.file-input-label:hover {
background-color: #8cc269;
color: #0f2408;
}
.file-input:disabled + .file-input-label {
background-color: #ccc;
cursor: not-allowed;
}
.image-help-text {
font-size: 0.85rem;
color: #666;
margin-top: 0.5rem;
}
.info-note {
background-color: #e3f2fd;
color: #1976d2;
padding: 0.75rem;
border-radius: 4px;
font-size: 0.9rem;
border-left: 4px solid #1976d2;
}

View File

@@ -2,6 +2,8 @@ import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import RecipeList from './pages/RecipeList';
import RecipeDetail from './pages/RecipeDetail';
import RecipeImport from './pages/RecipeImport';
import NewRecipe from './pages/NewRecipe';
import EditRecipe from './pages/EditRecipe';
import './App.css';
function App() {
@@ -13,6 +15,7 @@ function App() {
<h1 className="logo">🌿 Basil</h1>
<nav>
<Link to="/">Recipes</Link>
<Link to="/new">New Recipe</Link>
<Link to="/import">Import Recipe</Link>
</nav>
</div>
@@ -23,6 +26,8 @@ function App() {
<Routes>
<Route path="/" element={<RecipeList />} />
<Route path="/recipes/:id" element={<RecipeDetail />} />
<Route path="/recipes/:id/edit" element={<EditRecipe />} />
<Route path="/new" element={<NewRecipe />} />
<Route path="/import" element={<RecipeImport />} />
</Routes>
</div>

View File

@@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Recipe } from '@basil/shared';
import RecipeForm from './RecipeForm';
import { recipesApi } from '../services/api';
function EditRecipe() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (id) {
loadRecipe(id);
}
}, [id]);
const loadRecipe = async (recipeId: string) => {
try {
setLoading(true);
const response = await recipesApi.getById(recipeId);
setRecipe(response.data || null);
setError(null);
} catch (err) {
setError('Failed to load recipe');
console.error(err);
} finally {
setLoading(false);
}
};
const handleSubmit = async (updatedRecipe: Partial<Recipe>) => {
if (!id) return;
try {
const response = await recipesApi.update(id, updatedRecipe);
if (response.data) {
navigate(`/recipes/${response.data.id}`);
}
} catch (error) {
console.error('Failed to update recipe:', error);
throw error;
}
};
const handleCancel = () => {
if (id) {
navigate(`/recipes/${id}`);
} else {
navigate('/');
}
};
if (loading) {
return <div className="loading">Loading recipe...</div>;
}
if (error || !recipe) {
return <div className="error">{error || 'Recipe not found'}</div>;
}
return (
<div>
<RecipeForm initialRecipe={recipe} onSubmit={handleSubmit} onCancel={handleCancel} />
</div>
);
}
export default EditRecipe;

View File

@@ -0,0 +1,32 @@
import { useNavigate } from 'react-router-dom';
import { Recipe } from '@basil/shared';
import RecipeForm from './RecipeForm';
import { recipesApi } from '../services/api';
function NewRecipe() {
const navigate = useNavigate();
const handleSubmit = async (recipe: Partial<Recipe>) => {
try {
const response = await recipesApi.create(recipe);
if (response.data) {
navigate(`/recipes/${response.data.id}`);
}
} catch (error) {
console.error('Failed to create recipe:', error);
throw error;
}
};
const handleCancel = () => {
navigate('/');
};
return (
<div>
<RecipeForm onSubmit={handleSubmit} onCancel={handleCancel} />
</div>
);
}
export default NewRecipe;

View File

@@ -78,10 +78,15 @@ function RecipeDetail() {
return (
<div className="recipe-detail">
<button onClick={() => navigate('/')}> Back to Recipes</button>
<button onClick={handleDelete} style={{ marginLeft: '1rem', backgroundColor: '#d32f2f' }}>
Delete Recipe
</button>
<div className="recipe-actions">
<button onClick={() => navigate('/')}> Back to Recipes</button>
<div className="recipe-actions-right">
<button onClick={() => navigate(`/recipes/${id}/edit`)}>Edit Recipe</button>
<button onClick={handleDelete} style={{ backgroundColor: '#d32f2f' }}>
Delete Recipe
</button>
</div>
</div>
{recipe.imageUrl && <img src={recipe.imageUrl} alt={recipe.title} />}
@@ -120,47 +125,113 @@ function RecipeDetail() {
</p>
)}
{recipe.ingredients && recipe.ingredients.length > 0 && (
<div className="ingredients">
<h3>Ingredients</h3>
<ul>
{recipe.ingredients.map((ingredient, index) => {
// Construct the full ingredient string
let ingredientStr = '';
if (ingredient.amount && ingredient.unit) {
ingredientStr = `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`;
} else if (ingredient.amount) {
ingredientStr = `${ingredient.amount} ${ingredient.name}`;
} else {
ingredientStr = ingredient.name;
}
{/* Display sections if they exist */}
{recipe.sections && recipe.sections.length > 0 ? (
<div className="recipe-sections">
{recipe.sections.map((section) => (
<div key={section.id} className="recipe-section">
<div className="section-header">
<h3>{section.name}</h3>
{section.timing && <span className="section-timing">{section.timing}</span>}
</div>
// Apply scaling if servings changed
const displayStr =
recipe.servings && currentServings && recipe.servings !== currentServings
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
: ingredientStr;
{section.ingredients && section.ingredients.length > 0 && (
<div className="ingredients">
<h4>Ingredients</h4>
<ul>
{section.ingredients.map((ingredient, index) => {
let ingredientStr = '';
if (ingredient.amount && ingredient.unit) {
ingredientStr = `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`;
} else if (ingredient.amount) {
ingredientStr = `${ingredient.amount} ${ingredient.name}`;
} else {
ingredientStr = ingredient.name;
}
return (
<li key={index}>
{displayStr}
{ingredient.notes && ` (${ingredient.notes})`}
</li>
);
})}
</ul>
const displayStr =
recipe.servings && currentServings && recipe.servings !== currentServings
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
: ingredientStr;
return (
<li key={index}>
{displayStr}
{ingredient.notes && ` (${ingredient.notes})`}
</li>
);
})}
</ul>
</div>
)}
{section.instructions && section.instructions.length > 0 && (
<div className="instructions">
<h4>Instructions</h4>
<ol>
{section.instructions.map((instruction) => (
<li key={instruction.step}>
{instruction.timing && (
<strong className="instruction-timing">{instruction.timing}: </strong>
)}
{instruction.text}
</li>
))}
</ol>
</div>
)}
</div>
))}
</div>
)}
) : (
<>
{/* Display regular ingredients and instructions if no sections */}
{recipe.ingredients && recipe.ingredients.length > 0 && (
<div className="ingredients">
<h3>Ingredients</h3>
<ul>
{recipe.ingredients.map((ingredient, index) => {
let ingredientStr = '';
if (ingredient.amount && ingredient.unit) {
ingredientStr = `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`;
} else if (ingredient.amount) {
ingredientStr = `${ingredient.amount} ${ingredient.name}`;
} else {
ingredientStr = ingredient.name;
}
{recipe.instructions && recipe.instructions.length > 0 && (
<div className="instructions">
<h3>Instructions</h3>
<ol>
{recipe.instructions.map((instruction) => (
<li key={instruction.step}>{instruction.text}</li>
))}
</ol>
</div>
const displayStr =
recipe.servings && currentServings && recipe.servings !== currentServings
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
: ingredientStr;
return (
<li key={index}>
{displayStr}
{ingredient.notes && ` (${ingredient.notes})`}
</li>
);
})}
</ul>
</div>
)}
{recipe.instructions && recipe.instructions.length > 0 && (
<div className="instructions">
<h3>Instructions</h3>
<ol>
{recipe.instructions.map((instruction) => (
<li key={instruction.step}>
{instruction.timing && (
<strong className="instruction-timing">{instruction.timing}: </strong>
)}
{instruction.text}
</li>
))}
</ol>
</div>
)}
</>
)}
</div>
);

View File

@@ -0,0 +1,678 @@
import { useState } from 'react';
import { Recipe, RecipeSection, Ingredient, Instruction } from '@basil/shared';
import { recipesApi } from '../services/api';
interface RecipeFormProps {
initialRecipe?: Partial<Recipe>;
onSubmit: (recipe: Partial<Recipe>) => Promise<void>;
onCancel: () => void;
}
function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
// Basic recipe fields
const [title, setTitle] = useState(initialRecipe?.title || '');
const [description, setDescription] = useState(initialRecipe?.description || '');
const [prepTime, setPrepTime] = useState(initialRecipe?.prepTime?.toString() || '');
const [cookTime, setCookTime] = useState(initialRecipe?.cookTime?.toString() || '');
const [servings, setServings] = useState(initialRecipe?.servings?.toString() || '');
const [cuisine, setCuisine] = useState(initialRecipe?.cuisine || '');
const [category, setCategory] = useState(initialRecipe?.category || '');
// Image handling
const [uploadingImage, setUploadingImage] = useState(false);
const [processingImage, setProcessingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
// Section mode toggle
const [useSections, setUseSections] = useState(
(initialRecipe?.sections && initialRecipe.sections.length > 0) || false
);
// Sections
const [sections, setSections] = useState<RecipeSection[]>(
initialRecipe?.sections || [
{
name: '',
order: 0,
timing: '',
ingredients: [],
instructions: [],
},
]
);
// Simple mode (no sections)
const [ingredients, setIngredients] = useState<Ingredient[]>(
initialRecipe?.ingredients || [{ name: '', amount: '', unit: '', order: 0 }]
);
const [instructions, setInstructions] = useState<Instruction[]>(
initialRecipe?.instructions || [{ step: 1, text: '', timing: '' }]
);
const [submitting, setSubmitting] = useState(false);
// Section management
const addSection = () => {
setSections([
...sections,
{
name: '',
order: sections.length,
timing: '',
ingredients: [{ name: '', amount: '', unit: '', order: 0 }],
instructions: [{ step: 1, text: '', timing: '' }],
},
]);
};
const removeSection = (index: number) => {
const newSections = sections.filter((_, i) => i !== index);
setSections(newSections.map((s, i) => ({ ...s, order: i })));
};
const updateSection = (index: number, field: keyof RecipeSection, value: any) => {
const newSections = [...sections];
newSections[index] = { ...newSections[index], [field]: value };
setSections(newSections);
};
// Section ingredient management
const addSectionIngredient = (sectionIndex: number) => {
const newSections = [...sections];
const ingredients = newSections[sectionIndex].ingredients;
ingredients.push({
name: '',
amount: '',
unit: '',
order: ingredients.length,
});
setSections(newSections);
};
const removeSectionIngredient = (sectionIndex: number, ingredientIndex: number) => {
const newSections = [...sections];
newSections[sectionIndex].ingredients = newSections[sectionIndex].ingredients.filter(
(_, i) => i !== ingredientIndex
);
setSections(newSections);
};
const updateSectionIngredient = (
sectionIndex: number,
ingredientIndex: number,
field: keyof Ingredient,
value: string
) => {
const newSections = [...sections];
newSections[sectionIndex].ingredients[ingredientIndex] = {
...newSections[sectionIndex].ingredients[ingredientIndex],
[field]: value,
};
setSections(newSections);
};
// Section instruction management
const addSectionInstruction = (sectionIndex: number) => {
const newSections = [...sections];
const instructions = newSections[sectionIndex].instructions;
instructions.push({
step: instructions.length + 1,
text: '',
timing: '',
});
setSections(newSections);
};
const removeSectionInstruction = (sectionIndex: number, instructionIndex: number) => {
const newSections = [...sections];
newSections[sectionIndex].instructions = newSections[sectionIndex].instructions
.filter((_, i) => i !== instructionIndex)
.map((inst, i) => ({ ...inst, step: i + 1 }));
setSections(newSections);
};
const updateSectionInstruction = (
sectionIndex: number,
instructionIndex: number,
field: keyof Instruction,
value: string | number
) => {
const newSections = [...sections];
newSections[sectionIndex].instructions[instructionIndex] = {
...newSections[sectionIndex].instructions[instructionIndex],
[field]: value,
};
setSections(newSections);
};
// Simple mode ingredient management
const addIngredient = () => {
setIngredients([
...ingredients,
{ name: '', amount: '', unit: '', order: ingredients.length },
]);
};
const removeIngredient = (index: number) => {
setIngredients(ingredients.filter((_, i) => i !== index));
};
const updateIngredient = (index: number, field: keyof Ingredient, value: string) => {
const newIngredients = [...ingredients];
newIngredients[index] = { ...newIngredients[index], [field]: value };
setIngredients(newIngredients);
};
// Simple mode instruction management
const addInstruction = () => {
setInstructions([
...instructions,
{ step: instructions.length + 1, text: '', timing: '' },
]);
};
const removeInstruction = (index: number) => {
setInstructions(
instructions.filter((_, i) => i !== index).map((inst, i) => ({ ...inst, step: i + 1 }))
);
};
const updateInstruction = (index: number, field: keyof Instruction, value: string | number) => {
const newInstructions = [...instructions];
newInstructions[index] = { ...newInstructions[index], [field]: value };
setInstructions(newInstructions);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !initialRecipe?.id) return;
const recipeId = initialRecipe.id;
const hasExistingImage = !!initialRecipe.imageUrl;
// Show processing state immediately
setProcessingImage(true);
setImageError(null);
// Use setTimeout to allow UI to update before showing confirm dialog
setTimeout(async () => {
// Confirm replacement if image already exists
if (hasExistingImage) {
const confirmed = window.confirm(
'This will replace the current recipe image. Are you sure you want to continue?'
);
if (!confirmed) {
e.target.value = ''; // Reset file input
setProcessingImage(false);
return;
}
}
try {
setUploadingImage(true);
setProcessingImage(false);
await recipesApi.uploadImage(recipeId, file);
// Reload the page to show the new image
window.location.reload();
} catch (error) {
console.error('Failed to upload image:', error);
setImageError('Failed to upload image. Please try again.');
} finally {
setUploadingImage(false);
setProcessingImage(false);
e.target.value = ''; // Reset file input
}
}, 10);
};
const handleImageDelete = async () => {
if (!initialRecipe?.id || !initialRecipe?.imageUrl) return;
const confirmed = window.confirm(
'Are you sure you want to delete this image? This action cannot be undone.'
);
if (!confirmed) return;
try {
setUploadingImage(true);
setImageError(null);
await recipesApi.deleteImage(initialRecipe.id);
// Reload the page to show the change
window.location.reload();
} catch (error) {
console.error('Failed to delete image:', error);
setImageError('Failed to delete image. Please try again.');
} finally {
setUploadingImage(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const recipe: Partial<Recipe> = {
title,
description: description || undefined,
prepTime: prepTime ? parseInt(prepTime) : undefined,
cookTime: cookTime ? parseInt(cookTime) : undefined,
servings: servings ? parseInt(servings) : undefined,
cuisine: cuisine || undefined,
category: category || undefined,
};
if (useSections) {
recipe.sections = sections.filter((s) => s.name.trim() !== '');
recipe.ingredients = [];
recipe.instructions = [];
} else {
recipe.ingredients = ingredients.filter((i) => i.name.trim() !== '');
recipe.instructions = instructions.filter((i) => i.text.trim() !== '');
recipe.sections = [];
}
try {
setSubmitting(true);
await onSubmit(recipe);
} catch (error) {
console.error('Failed to submit recipe:', error);
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="recipe-form">
<h2>{initialRecipe?.id ? 'Edit Recipe' : 'New Recipe'}</h2>
{/* Basic Info */}
<div className="form-group">
<label htmlFor="title">Title *</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="prepTime">Prep Time (minutes)</label>
<input
type="number"
id="prepTime"
value={prepTime}
onChange={(e) => setPrepTime(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="cookTime">Cook Time (minutes)</label>
<input
type="number"
id="cookTime"
value={cookTime}
onChange={(e) => setCookTime(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="servings">Servings</label>
<input
type="number"
id="servings"
value={servings}
onChange={(e) => setServings(e.target.value)}
/>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="cuisine">Cuisine</label>
<input
type="text"
id="cuisine"
value={cuisine}
onChange={(e) => setCuisine(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="category">Category</label>
<input
type="text"
id="category"
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
</div>
</div>
{/* Image Upload (only for editing existing recipes) */}
{initialRecipe?.id && (
<div className="form-group image-upload-section">
<label>Recipe Images</label>
{/* Display existing images */}
{initialRecipe.imageUrl && (
<div className="current-image">
<img src={initialRecipe.imageUrl} alt="Recipe" />
<div className="image-actions">
<p className="image-note">Current main image</p>
<button
type="button"
onClick={handleImageDelete}
disabled={uploadingImage || processingImage}
className="btn-delete-image"
>
Delete Image
</button>
</div>
</div>
)}
{/* Upload new image */}
<div className="image-upload-control">
<input
type="file"
id="imageUpload"
accept="image/*"
onChange={handleImageUpload}
disabled={uploadingImage || processingImage}
className="file-input"
/>
<label htmlFor="imageUpload" className="file-input-label">
{processingImage ? 'Processing...' : uploadingImage ? 'Uploading...' : initialRecipe.imageUrl ? 'Replace Image' : 'Upload Image'}
</label>
</div>
{imageError && <div className="error">{imageError}</div>}
<p className="image-help-text">
Supported formats: JPG, PNG, GIF, WEBP. Max size: 20MB
</p>
</div>
)}
{!initialRecipe?.id && (
<div className="form-group">
<p className="info-note">
Note: Images can be added after creating the recipe
</p>
</div>
)}
{/* Section Mode Toggle */}
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={useSections}
onChange={(e) => setUseSections(e.target.checked)}
/>
<span>Multi-section recipe (for recipes with multiple parts like sourdough)</span>
</label>
</div>
{/* Sections Mode */}
{useSections ? (
<div className="sections-container">
<h3>Recipe Sections</h3>
{sections.map((section, sectionIndex) => (
<div key={sectionIndex} className="section-form">
<div className="section-form-header">
<h4>Section {sectionIndex + 1}</h4>
{sections.length > 1 && (
<button
type="button"
onClick={() => removeSection(sectionIndex)}
className="btn-danger-small"
>
Remove Section
</button>
)}
</div>
<div className="form-group">
<label>Section Name *</label>
<input
type="text"
value={section.name}
onChange={(e) => updateSection(sectionIndex, 'name', e.target.value)}
placeholder="e.g., Starter, Dough, Assembly"
required
/>
</div>
<div className="form-group">
<label>Section Timing (optional)</label>
<input
type="text"
value={section.timing}
onChange={(e) => updateSection(sectionIndex, 'timing', e.target.value)}
placeholder="e.g., Day 1 - 8PM, 12 hours before mixing"
/>
</div>
{/* Section Ingredients */}
<div className="subsection">
<h5>Ingredients</h5>
{section.ingredients.map((ingredient, ingredientIndex) => (
<div key={ingredientIndex} className="ingredient-row">
<input
type="text"
value={ingredient.amount}
onChange={(e) =>
updateSectionIngredient(sectionIndex, ingredientIndex, 'amount', e.target.value)
}
placeholder="Amount"
className="input-small"
/>
<input
type="text"
value={ingredient.unit}
onChange={(e) =>
updateSectionIngredient(sectionIndex, ingredientIndex, 'unit', e.target.value)
}
placeholder="Unit"
className="input-small"
/>
<input
type="text"
value={ingredient.name}
onChange={(e) =>
updateSectionIngredient(sectionIndex, ingredientIndex, 'name', e.target.value)
}
placeholder="Ingredient name *"
className="input-flex"
required
/>
{section.ingredients.length > 1 && (
<button
type="button"
onClick={() => removeSectionIngredient(sectionIndex, ingredientIndex)}
className="btn-remove"
>
×
</button>
)}
</div>
))}
<button
type="button"
onClick={() => addSectionIngredient(sectionIndex)}
className="btn-secondary"
>
+ Add Ingredient
</button>
</div>
{/* Section Instructions */}
<div className="subsection">
<h5>Instructions</h5>
{section.instructions.map((instruction, instructionIndex) => (
<div key={instructionIndex} className="instruction-row">
<div className="instruction-number">{instruction.step}</div>
<div className="instruction-content">
<input
type="text"
value={instruction.timing}
onChange={(e) =>
updateSectionInstruction(
sectionIndex,
instructionIndex,
'timing',
e.target.value
)
}
placeholder="Timing (optional, e.g., 8:00am)"
className="instruction-timing-input"
/>
<textarea
value={instruction.text}
onChange={(e) =>
updateSectionInstruction(
sectionIndex,
instructionIndex,
'text',
e.target.value
)
}
placeholder="Instruction text *"
required
/>
</div>
{section.instructions.length > 1 && (
<button
type="button"
onClick={() => removeSectionInstruction(sectionIndex, instructionIndex)}
className="btn-remove"
>
×
</button>
)}
</div>
))}
<button
type="button"
onClick={() => addSectionInstruction(sectionIndex)}
className="btn-secondary"
>
+ Add Instruction
</button>
</div>
</div>
))}
<button type="button" onClick={addSection} className="btn-secondary">
+ Add Section
</button>
</div>
) : (
/* Simple Mode */
<>
{/* Ingredients */}
<div className="form-section">
<h3>Ingredients</h3>
{ingredients.map((ingredient, index) => (
<div key={index} className="ingredient-row">
<input
type="text"
value={ingredient.amount}
onChange={(e) => updateIngredient(index, 'amount', e.target.value)}
placeholder="Amount"
className="input-small"
/>
<input
type="text"
value={ingredient.unit}
onChange={(e) => updateIngredient(index, 'unit', e.target.value)}
placeholder="Unit"
className="input-small"
/>
<input
type="text"
value={ingredient.name}
onChange={(e) => updateIngredient(index, 'name', e.target.value)}
placeholder="Ingredient name *"
className="input-flex"
required
/>
{ingredients.length > 1 && (
<button type="button" onClick={() => removeIngredient(index)} className="btn-remove">
×
</button>
)}
</div>
))}
<button type="button" onClick={addIngredient} className="btn-secondary">
+ Add Ingredient
</button>
</div>
{/* Instructions */}
<div className="form-section">
<h3>Instructions</h3>
{instructions.map((instruction, index) => (
<div key={index} className="instruction-row">
<div className="instruction-number">{instruction.step}</div>
<div className="instruction-content">
<input
type="text"
value={instruction.timing}
onChange={(e) => updateInstruction(index, 'timing', e.target.value)}
placeholder="Timing (optional, e.g., 8:00am)"
className="instruction-timing-input"
/>
<textarea
value={instruction.text}
onChange={(e) => updateInstruction(index, 'text', e.target.value)}
placeholder="Instruction text *"
required
/>
</div>
{instructions.length > 1 && (
<button
type="button"
onClick={() => removeInstruction(index)}
className="btn-remove"
>
×
</button>
)}
</div>
))}
<button type="button" onClick={addInstruction} className="btn-secondary">
+ Add Instruction
</button>
</div>
</>
)}
{/* Submit Buttons */}
<div className="form-actions">
<button type="submit" disabled={submitting}>
{submitting ? 'Saving...' : 'Save Recipe'}
</button>
<button type="button" onClick={onCancel} className="btn-secondary">
Cancel
</button>
</div>
</form>
);
}
export default RecipeForm;

View File

@@ -49,6 +49,11 @@ export const recipesApi = {
return response.data;
},
deleteImage: async (id: string): Promise<ApiResponse<void>> => {
const response = await api.delete(`/recipes/${id}/image`);
return response.data;
},
importFromUrl: async (url: string): Promise<RecipeImportResponse> => {
const response = await api.post('/recipes/import', { url } as RecipeImportRequest);
return response.data;