269 lines
9.9 KiB
TypeScript
269 lines
9.9 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { Recipe, Tag } from '../types/recipe';
|
|
import { TagSelector } from './TagSelector';
|
|
|
|
export interface RecipeFormData {
|
|
title: string;
|
|
description?: string;
|
|
ingredients: string[];
|
|
instructions: string[];
|
|
source_url?: string;
|
|
notes?: string;
|
|
servings?: number;
|
|
prep_time_minutes?: number;
|
|
cook_time_minutes?: number;
|
|
}
|
|
|
|
interface RecipeFormProps {
|
|
recipe?: Recipe; // May be undefined when creating
|
|
initialTags?: Tag[];
|
|
onSubmit: (data: RecipeFormData, tags: Tag[]) => Promise<void>;
|
|
onCancel: () => void;
|
|
submitLabel?: string;
|
|
}
|
|
|
|
/**
|
|
* RecipeForm - Visually polished form component for creating/editing recipes
|
|
*/
|
|
export function RecipeForm({
|
|
recipe,
|
|
initialTags = [],
|
|
onSubmit,
|
|
onCancel,
|
|
submitLabel = 'Save Recipe'
|
|
}: RecipeFormProps) {
|
|
// Form state
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [ingredientsText, setIngredientsText] = useState('');
|
|
const [instructionsText, setInstructionsText] = useState('');
|
|
const [sourceUrl, setSourceUrl] = useState('');
|
|
const [notes, setNotes] = useState('');
|
|
const [servings, setServings] = useState('');
|
|
const [prepTime, setPrepTime] = useState('');
|
|
const [cookTime, setCookTime] = useState('');
|
|
const [selectedTags, setSelectedTags] = useState<Tag[]>(initialTags);
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Populate form from recipe prop
|
|
useEffect(() => {
|
|
if (recipe) {
|
|
setTitle(recipe.title || '');
|
|
setDescription(recipe.description || '');
|
|
setIngredientsText((Array.isArray(recipe.ingredients) ? recipe.ingredients.map(ingr => ('item' in ingr ? ingr.item : (typeof ingr === 'string' ? ingr : ''))) : []).join('\n'));
|
|
setInstructionsText(
|
|
(Array.isArray(recipe.instructions) ? recipe.instructions : recipe.steps?.map(s => s.instruction) || []).join('\n')
|
|
);
|
|
setSourceUrl(recipe.source_url || '');
|
|
setNotes(recipe.notes || '');
|
|
setServings(recipe.servings?.toString() || '');
|
|
setPrepTime(recipe.prep_time_minutes?.toString() || '');
|
|
setCookTime(recipe.cook_time_minutes?.toString() || '');
|
|
}
|
|
}, [recipe]);
|
|
|
|
useEffect(() => {
|
|
setSelectedTags(initialTags);
|
|
}, [initialTags]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
// Validation
|
|
if (!title.trim()) {
|
|
setError('Title is required');
|
|
return;
|
|
}
|
|
|
|
const ingredientsList = ingredientsText
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => line.length > 0);
|
|
if (ingredientsList.length === 0) {
|
|
setError('At least one ingredient is required');
|
|
return;
|
|
}
|
|
|
|
const instructionsList = instructionsText
|
|
.split('\n')
|
|
.map(line => line.trim())
|
|
.filter(line => line.length > 0);
|
|
if (instructionsList.length === 0) {
|
|
setError('At least one instruction step is required');
|
|
return;
|
|
}
|
|
|
|
const data: RecipeFormData = {
|
|
title: title.trim(),
|
|
description: description.trim() || undefined,
|
|
ingredients: ingredientsList,
|
|
instructions: instructionsList,
|
|
source_url: sourceUrl.trim() || undefined,
|
|
notes: notes.trim() || undefined,
|
|
servings: servings ? parseInt(servings, 10) : undefined,
|
|
prep_time_minutes: prepTime ? parseInt(prepTime, 10) : undefined,
|
|
cook_time_minutes: cookTime ? parseInt(cookTime, 10) : undefined,
|
|
};
|
|
try {
|
|
setIsSubmitting(true);
|
|
await onSubmit(data, selectedTags);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save recipe');
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-8">
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-700 px-5 py-3 rounded-lg shadow-card font-medium text-base">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label htmlFor="title" className="block text-base font-semibold text-gray-700 mb-1">
|
|
Title <span className="text-error">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
id="title"
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-[17px] py-2 px-4 font-medium"
|
|
placeholder="e.g., Chocolate Chip Cookies"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="description" className="block text-base font-semibold text-gray-700 mb-1">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
id="description"
|
|
value={description}
|
|
onChange={e => setDescription(e.target.value)}
|
|
rows={2}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
|
|
placeholder="Brief description of the recipe..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-base font-semibold text-gray-700 mb-1">
|
|
Tags
|
|
</label>
|
|
<TagSelector selectedTags={selectedTags} onTagsChange={setSelectedTags} />
|
|
</div>
|
|
<div>
|
|
<label htmlFor="ingredients" className="block text-base font-semibold text-gray-700 mb-1">
|
|
Ingredients <span className="text-error">*</span>
|
|
</label>
|
|
<p className="mt-0.5 text-sm text-gray-500 mb-2">One ingredient per line</p>
|
|
<textarea
|
|
id="ingredients"
|
|
value={ingredientsText}
|
|
onChange={e => setIngredientsText(e.target.value)}
|
|
rows={7}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary font-mono text-base px-4 py-2"
|
|
placeholder="2 cups all-purpose flour\n1 cup butter, softened\n3/4 cup sugar"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="instructions" className="block text-base font-semibold text-gray-700 mb-1">
|
|
Instructions <span className="text-error">*</span>
|
|
</label>
|
|
<p className="mt-0.5 text-sm text-gray-500 mb-2">One step per line</p>
|
|
<textarea
|
|
id="instructions"
|
|
value={instructionsText}
|
|
onChange={e => setInstructionsText(e.target.value)}
|
|
rows={8}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary font-mono text-base px-4 py-2"
|
|
placeholder="Preheat oven to 350°F\nMix flour and baking soda\nCream butter and sugar"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label htmlFor="servings" className="block text-base font-semibold text-gray-700 mb-1">Servings</label>
|
|
<input
|
|
type="number"
|
|
id="servings"
|
|
value={servings}
|
|
onChange={e => setServings(e.target.value)}
|
|
min="1"
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
|
|
placeholder="4"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="prep_time" className="block text-base font-semibold text-gray-700 mb-1">Prep Time (min)</label>
|
|
<input
|
|
type="number"
|
|
id="prep_time"
|
|
value={prepTime}
|
|
onChange={e => setPrepTime(e.target.value)}
|
|
min="0"
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
|
|
placeholder="15"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="cook_time" className="block text-base font-semibold text-gray-700 mb-1">Cook Time (min)</label>
|
|
<input
|
|
type="number"
|
|
id="cook_time"
|
|
value={cookTime}
|
|
onChange={e => setCookTime(e.target.value)}
|
|
min="0"
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
|
|
placeholder="30"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="source_url" className="block text-base font-semibold text-gray-700 mb-1">Source URL</label>
|
|
<input
|
|
type="url"
|
|
id="source_url"
|
|
value={sourceUrl}
|
|
onChange={e => setSourceUrl(e.target.value)}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
|
|
placeholder="https://example.com/recipe"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="notes" className="block text-base font-semibold text-gray-700 mb-1">Notes</label>
|
|
<textarea
|
|
id="notes"
|
|
value={notes}
|
|
onChange={e => setNotes(e.target.value)}
|
|
rows={3}
|
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
|
|
placeholder="Personal notes, substitutions, tips..."
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3 pt-4">
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="flex-1 bg-primary text-white px-4 py-2 rounded-md shadow-card hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold transition-colors text-base"
|
|
>
|
|
{isSubmitting ? 'Saving...' : submitLabel}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={isSubmitting}
|
|
className="px-4 py-2 border border-gray-300 rounded-md shadow font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-base transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|