import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Recipe, Ingredient, Instruction, RecipeSection, Tag } from '@basil/shared'; import { recipesApi, tagsApi } from '../services/api'; import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'; import '../styles/UnifiedRecipeEdit.css'; interface MappingChange { ingredientId: string; instructionId: string; order: number; } function UnifiedEditRecipe() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); // Recipe data const [recipe, setRecipe] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Basic info fields const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [prepTime, setPrepTime] = useState(''); const [cookTime, setCookTime] = useState(''); const [servings, setServings] = useState(''); const [cuisine, setCuisine] = useState(''); const [recipeTags, setRecipeTags] = useState([]); const [tagInput, setTagInput] = useState(''); const [availableTags, setAvailableTags] = useState([]); // Section mode const [useSections, setUseSections] = useState(false); const [sections, setSections] = useState([]); // Simple mode const [ingredients, setIngredients] = useState([]); const [instructions, setInstructions] = useState([]); // Ingredient-Instruction mappings const [localMappings, setLocalMappings] = useState>({}); // instructionId -> [ingredientIds] // UI state const [hasChanges, setHasChanges] = useState(false); const [saving, setSaving] = useState(false); const [basicInfoCollapsed, setBasicInfoCollapsed] = useState(false); const [selectedIngredients, setSelectedIngredients] = useState>(new Set()); const [editingInstructionId, setEditingInstructionId] = useState(null); const [editingInstructionText, setEditingInstructionText] = useState(''); const [editingInstructionTiming, setEditingInstructionTiming] = useState(''); const [draggedIngredient, setDraggedIngredient] = useState(null); const [dragOverInstructionId, setDragOverInstructionId] = useState(null); const [bulkMapTarget, setBulkMapTarget] = useState(''); // Image handling const [uploadingImage, setUploadingImage] = useState(false); const [processingImage, setProcessingImage] = useState(false); const [imageError, setImageError] = useState(null); useEffect(() => { if (id) { loadRecipe(id); } loadAvailableTags(); }, [id]); const loadAvailableTags = async () => { try { const response = await tagsApi.getAll(); setAvailableTags(response.data || []); } catch (err) { console.error('Failed to load tags:', err); } }; const loadRecipe = async (recipeId: string) => { try { setLoading(true); const response = await recipesApi.getById(recipeId); const loadedRecipe = response.data || null; setRecipe(loadedRecipe); if (loadedRecipe) { // Set basic info setTitle(loadedRecipe.title); setDescription(loadedRecipe.description || ''); setPrepTime(loadedRecipe.prepTime?.toString() || ''); setCookTime(loadedRecipe.cookTime?.toString() || ''); setServings(loadedRecipe.servings?.toString() || ''); setCuisine(loadedRecipe.cuisine || ''); // Handle tags - API returns array of {tag: {id, name}} objects, we need string[] const tagNames = (loadedRecipe.tags as any)?.map((t: any) => t.tag?.name || t).filter(Boolean) || []; setRecipeTags(tagNames); // Set sections or simple mode const hasSections = !!(loadedRecipe.sections && loadedRecipe.sections.length > 0); setUseSections(hasSections); if (hasSections) { setSections(loadedRecipe.sections || []); } else { setIngredients(loadedRecipe.ingredients || []); setInstructions(loadedRecipe.instructions || []); } // Initialize mappings from database const mappings: Record = {}; if (hasSections) { loadedRecipe.sections?.forEach(section => { section.instructions?.forEach(instruction => { if (instruction.id && instruction.ingredients) { mappings[instruction.id] = instruction.ingredients .sort((a, b) => a.order - b.order) .map(m => m.ingredient?.id || '') .filter(id => id !== ''); } }); }); } else { loadedRecipe.instructions?.forEach(instruction => { if (instruction.id && instruction.ingredients) { mappings[instruction.id] = instruction.ingredients .sort((a, b) => a.order - b.order) .map(m => m.ingredient?.id || '') .filter(id => id !== ''); } }); } setLocalMappings(mappings); } setError(null); } catch (err) { setError('Failed to load recipe'); console.error(err); } finally { setLoading(false); } }; // Get all ingredients (either from sections or simple mode) const getAllIngredients = (): Ingredient[] => { if (useSections) { const allIngs: Ingredient[] = []; sections.forEach(section => { if (section.ingredients) { allIngs.push(...section.ingredients); } }); return allIngs; } return ingredients; }; // Get all instructions (either from sections or simple mode) const getAllInstructions = (): Instruction[] => { if (useSections) { const allInsts: Instruction[] = []; sections.forEach(section => { if (section.instructions) { allInsts.push(...section.instructions); } }); return allInsts; } return instructions; }; // Ingredient management const addIngredient = () => { const newIngredient: Ingredient = { name: '', amount: '', unit: '', order: ingredients.length, id: `temp-${Date.now()}`, // Temporary ID for UI purposes }; setIngredients([...ingredients, newIngredient]); setHasChanges(true); }; const updateIngredient = (index: number, field: keyof Ingredient, value: string) => { const newIngredients = [...ingredients]; newIngredients[index] = { ...newIngredients[index], [field]: value }; setIngredients(newIngredients); setHasChanges(true); }; const removeIngredient = (index: number) => { const ingredientId = ingredients[index].id; setIngredients(ingredients.filter((_, i) => i !== index)); // Remove from mappings if (ingredientId) { const newMappings = { ...localMappings }; Object.keys(newMappings).forEach(instructionId => { newMappings[instructionId] = newMappings[instructionId].filter(id => id !== ingredientId); }); setLocalMappings(newMappings); } setHasChanges(true); }; const deleteSelectedIngredients = () => { if (selectedIngredients.size === 0) return; if (!confirm(`Delete ${selectedIngredients.size} selected ingredient(s)?`)) return; // Remove from ingredients list const newIngredients = ingredients.filter(ing => !selectedIngredients.has(ing.id || '')); setIngredients(newIngredients); // Remove from mappings const newMappings = { ...localMappings }; Object.keys(newMappings).forEach(instructionId => { newMappings[instructionId] = newMappings[instructionId].filter( id => !selectedIngredients.has(id) ); }); setLocalMappings(newMappings); setSelectedIngredients(new Set()); setHasChanges(true); }; const toggleIngredientSelection = (ingredientId: string) => { const newSelection = new Set(selectedIngredients); if (newSelection.has(ingredientId)) { newSelection.delete(ingredientId); } else { newSelection.add(ingredientId); } setSelectedIngredients(newSelection); }; // Instruction management const addInstruction = () => { const newInstruction: Instruction = { step: instructions.length + 1, text: '', timing: '', id: `temp-${Date.now()}`, // Temporary ID for UI purposes }; setInstructions([...instructions, newInstruction]); setHasChanges(true); }; const startEditingInstruction = (instruction: Instruction) => { setEditingInstructionId(instruction.id || null); setEditingInstructionText(instruction.text); setEditingInstructionTiming(instruction.timing || ''); }; const cancelEditingInstruction = () => { setEditingInstructionId(null); setEditingInstructionText(''); setEditingInstructionTiming(''); }; const saveEditingInstruction = () => { if (!editingInstructionId) return; const index = instructions.findIndex(inst => inst.id === editingInstructionId); if (index >= 0) { const newInstructions = [...instructions]; newInstructions[index] = { ...newInstructions[index], text: editingInstructionText, timing: editingInstructionTiming, }; setInstructions(newInstructions); setHasChanges(true); } cancelEditingInstruction(); }; const removeInstruction = (index: number) => { if (!confirm('Delete this instruction?')) return; const instructionId = instructions[index].id; // Remove instruction const newInstructions = instructions .filter((_, i) => i !== index) .map((inst, i) => ({ ...inst, step: i + 1 })); setInstructions(newInstructions); // Remove from mappings if (instructionId) { const newMappings = { ...localMappings }; delete newMappings[instructionId]; setLocalMappings(newMappings); } setHasChanges(true); }; const reorderInstructions = (result: DropResult) => { if (!result.destination) return; const items = Array.from(instructions); const [reorderedItem] = items.splice(result.source.index, 1); items.splice(result.destination.index, 0, reorderedItem); // Update step numbers const updatedItems = items.map((item, index) => ({ ...item, step: index + 1 })); setInstructions(updatedItems); setHasChanges(true); }; // Drag and drop const handleIngredientDragStart = (ingredient: Ingredient) => { setDraggedIngredient(ingredient); }; const handleIngredientDragEnd = () => { setDraggedIngredient(null); setDragOverInstructionId(null); }; const handleInstructionDragOver = (e: React.DragEvent, instructionId: string) => { e.preventDefault(); setDragOverInstructionId(instructionId); }; const handleInstructionDragLeave = () => { setDragOverInstructionId(null); }; const handleInstructionDrop = (e: React.DragEvent, instructionId: string) => { e.preventDefault(); setDragOverInstructionId(null); if (draggedIngredient && draggedIngredient.id) { const newMappings = { ...localMappings }; if (!newMappings[instructionId]) { newMappings[instructionId] = []; } if (!newMappings[instructionId].includes(draggedIngredient.id)) { newMappings[instructionId] = [...newMappings[instructionId], draggedIngredient.id]; setLocalMappings(newMappings); setHasChanges(true); } } setDraggedIngredient(null); }; const removeIngredientFromInstruction = (ingredientId: string, instructionId: string) => { const newMappings = { ...localMappings }; if (newMappings[instructionId]) { newMappings[instructionId] = newMappings[instructionId].filter(id => id !== ingredientId); setLocalMappings(newMappings); setHasChanges(true); } }; // Bulk mapping const handleBulkMapToInstruction = () => { if (!bulkMapTarget || selectedIngredients.size === 0) return; const newMappings = { ...localMappings }; if (!newMappings[bulkMapTarget]) { newMappings[bulkMapTarget] = []; } selectedIngredients.forEach(ingredientId => { if (!newMappings[bulkMapTarget].includes(ingredientId)) { newMappings[bulkMapTarget].push(ingredientId); } }); setLocalMappings(newMappings); setSelectedIngredients(new Set()); setBulkMapTarget(''); setHasChanges(true); }; // Image handling const handleImageUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !id) return; const hasExistingImage = !!recipe?.imageUrl; setProcessingImage(true); setImageError(null); setTimeout(async () => { if (hasExistingImage) { const confirmed = window.confirm( 'This will replace the current recipe image. Are you sure?' ); if (!confirmed) { e.target.value = ''; setProcessingImage(false); return; } } try { setUploadingImage(true); setProcessingImage(false); await recipesApi.uploadImage(id, file); 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 = ''; } }, 10); }; const handleImageDelete = async () => { if (!id || !recipe?.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(id); window.location.reload(); } catch (error) { console.error('Failed to delete image:', error); setImageError('Failed to delete image. Please try again.'); } finally { setUploadingImage(false); } }; // Regenerate mappings const regenerateMappings = async () => { if (!id || !confirm('Regenerate all ingredient mappings automatically? This will replace your current mappings.')) { return; } try { setSaving(true); await recipesApi.regenerateMappings(id); await loadRecipe(id); setHasChanges(false); alert('Mappings regenerated successfully!'); } catch (err) { console.error('Error regenerating mappings:', err); alert('Failed to regenerate mappings'); } finally { setSaving(false); } }; // Save everything const handleSaveAll = async () => { if (!id) return; try { setSaving(true); // 1. Save recipe data (ingredients & instructions) const recipeUpdate: Partial = { title, description: description || undefined, prepTime: prepTime ? parseInt(prepTime) : undefined, cookTime: cookTime ? parseInt(cookTime) : undefined, servings: servings ? parseInt(servings) : undefined, cuisine: cuisine || undefined, tags: recipeTags, }; if (useSections) { recipeUpdate.sections = sections.filter((s) => s.name.trim() !== ''); recipeUpdate.ingredients = []; recipeUpdate.instructions = []; } else { recipeUpdate.ingredients = ingredients.filter((i) => i.name.trim() !== ''); recipeUpdate.instructions = instructions.filter((i) => i.text.trim() !== ''); recipeUpdate.sections = []; } await recipesApi.update(id, recipeUpdate); // 2. Reload the recipe to get the new IDs for ingredients and instructions const reloadResponse = await recipesApi.getById(id); const reloadedRecipe = reloadResponse.data; if (!reloadedRecipe) { throw new Error('Failed to reload recipe after save'); } // 3. Build mappings using the NEW IDs by matching ingredient/instruction content and order const mappings: MappingChange[] = []; const newIngredients = reloadedRecipe.ingredients || []; const newInstructions = reloadedRecipe.instructions || []; // Build a map of old ingredient index to new ingredient for better matching const ingredientIndexMap = new Map(); ingredients.forEach((oldIng, oldIndex) => { // Find matching new ingredient by comparing content AND order/index // This handles duplicates better by using position const matchingNew = newIngredients.find((newIng, newIndex) => { return newIng.name === oldIng.name && newIng.amount === oldIng.amount && newIng.unit === oldIng.unit && Math.abs(newIndex - oldIndex) <= 1; // Allow for minor position differences }); if (matchingNew) { ingredientIndexMap.set(oldIndex, matchingNew); } }); // Build a map of old instruction step to new instruction const instructionStepMap = new Map(); instructions.forEach((oldInst) => { const matchingNew = newInstructions.find(newInst => newInst.step === oldInst.step); if (matchingNew) { instructionStepMap.set(oldInst.step, matchingNew); } }); Object.entries(localMappings).forEach(([oldInstructionId, oldIngredientIds]) => { // Find the old instruction in our local state const oldInstruction = instructions.find(inst => inst.id === oldInstructionId); if (!oldInstruction) return; // Find the matching new instruction by step number const newInstruction = instructionStepMap.get(oldInstruction.step); if (!newInstruction?.id) return; // For each old ingredient ID in the mapping oldIngredientIds.forEach((oldIngredientId, mappingOrder) => { // Find the old ingredient in our local state const oldIngredientIndex = ingredients.findIndex(ing => ing.id === oldIngredientId); if (oldIngredientIndex === -1) return; const oldIngredient = ingredients[oldIngredientIndex]; // Get the matching new ingredient from our map let newIngredient = ingredientIndexMap.get(oldIngredientIndex); // Fallback: try to find by exact content match if map lookup failed if (!newIngredient) { newIngredient = newIngredients.find( ing => ing.name === oldIngredient.name && ing.amount === oldIngredient.amount && ing.unit === oldIngredient.unit ); } if (!newIngredient?.id || !newInstruction.id) { console.warn('Could not find matching ingredient or instruction for:', oldIngredient); return; } // Add the mapping with the NEW IDs mappings.push({ ingredientId: newIngredient.id, instructionId: newInstruction.id, order: mappingOrder, }); }); }); // 4. Save mappings with the new IDs if (mappings.length > 0) { await recipesApi.updateMappings(id, mappings); } setHasChanges(false); navigate(`/recipes/${id}`); } catch (err) { console.error('Error saving recipe:', err); alert('Failed to save recipe. Please try again.'); } finally { setSaving(false); } }; const handleCancel = () => { if (hasChanges && !confirm('You have unsaved changes. Are you sure you want to cancel?')) { return; } navigate(`/recipes/${id}`); }; // Tag management functions const handleAddTag = async (tagName: string) => { const trimmedTag = tagName.trim(); if (!trimmedTag) return; if (recipeTags.includes(trimmedTag)) { setTagInput(''); return; // Tag already exists } // Create or find tag in database (for autocomplete purposes) try { await tagsApi.createOrFind(trimmedTag); await loadAvailableTags(); // Refresh available tags } catch (err) { console.error('Failed to create tag:', err); } setRecipeTags([...recipeTags, trimmedTag]); setTagInput(''); setHasChanges(true); }; const handleRemoveTag = (tagToRemove: string) => { setRecipeTags(recipeTags.filter(tag => tag !== tagToRemove)); setHasChanges(true); }; const handleTagInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag(tagInput); } }; const getIngredientText = (ingredient: 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; } return ingredient.notes ? `${ingredientStr} (${ingredient.notes})` : ingredientStr; }; const getMappedIngredientsForInstruction = (instructionId: string): Ingredient[] => { const ingredientIds = localMappings[instructionId] || []; const allIngs = getAllIngredients(); return ingredientIds .map(id => allIngs.find(ing => ing.id === id)) .filter((ing): ing is Ingredient => ing !== undefined); }; if (loading) { return (
Loading recipe...
); } if (error || !recipe) { return (
{error || 'Recipe not found'}
); } const allIngredients = getAllIngredients(); const allInstructions = getAllInstructions(); return (
{/* Header */}

Edit Recipe

{title || 'Untitled Recipe'}

{/* Unsaved changes banner */} {hasChanges && (
⚠️ You have unsaved changes. Click "Save All Changes" to persist them.
)} {/* Collapsible Basic Info Section */}
setBasicInfoCollapsed(!basicInfoCollapsed)} >

Recipe Details

{!basicInfoCollapsed && (
{ setTitle(e.target.value); setHasChanges(true); }} required />