Files
basil/packages/web/src/pages/UnifiedEditRecipe.tsx
Paul R Kartchner 9d3bdfc0bf refactor: remove save confirmation dialog in recipe edit
- Removed 'Recipe saved successfully!' alert dialog
- Now navigates directly back to recipe view after saving
- Provides cleaner, faster user experience
- Less interruption when making quick edits
2026-01-16 21:11:00 -07:00

1237 lines
43 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Recipe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string[]>([]);
const [tagInput, setTagInput] = useState('');
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
// Section mode
const [useSections, setUseSections] = useState(false);
const [sections, setSections] = useState<RecipeSection[]>([]);
// Simple mode
const [ingredients, setIngredients] = useState<Ingredient[]>([]);
const [instructions, setInstructions] = useState<Instruction[]>([]);
// Ingredient-Instruction mappings
const [localMappings, setLocalMappings] = useState<Record<string, string[]>>({}); // instructionId -> [ingredientIds]
// UI state
const [hasChanges, setHasChanges] = useState(false);
const [saving, setSaving] = useState(false);
const [basicInfoCollapsed, setBasicInfoCollapsed] = useState(false);
const [selectedIngredients, setSelectedIngredients] = useState<Set<string>>(new Set());
const [editingInstructionId, setEditingInstructionId] = useState<string | null>(null);
const [editingInstructionText, setEditingInstructionText] = useState('');
const [editingInstructionTiming, setEditingInstructionTiming] = useState('');
const [draggedIngredient, setDraggedIngredient] = useState<Ingredient | null>(null);
const [dragOverInstructionId, setDragOverInstructionId] = useState<string | null>(null);
const [bulkMapTarget, setBulkMapTarget] = useState('');
// Image handling
const [uploadingImage, setUploadingImage] = useState(false);
const [processingImage, setProcessingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(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<string, string[]> = {};
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<HTMLInputElement>) => {
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<Recipe> = {
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<number, Ingredient>();
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<number, Instruction>();
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<HTMLInputElement>) => {
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 (
<div className="unified-recipe-edit">
<div className="loading">Loading recipe...</div>
</div>
);
}
if (error || !recipe) {
return (
<div className="unified-recipe-edit">
<div className="error">{error || 'Recipe not found'}</div>
<button onClick={() => navigate('/')}> Back to Recipes</button>
</div>
);
}
const allIngredients = getAllIngredients();
const allInstructions = getAllInstructions();
return (
<div className="unified-recipe-edit">
{/* Header */}
<div className="unified-header">
<div className="unified-title">
<h1>Edit Recipe</h1>
<h2>{title || 'Untitled Recipe'}</h2>
</div>
<div className="unified-controls">
<button
onClick={handleSaveAll}
disabled={!hasChanges || saving}
className="save-btn"
>
{saving ? 'Saving...' : '💾 Save All Changes'}
</button>
<button
onClick={regenerateMappings}
disabled={saving}
className="regenerate-btn"
>
🔄 Regenerate Mappings
</button>
<button onClick={handleCancel} className="cancel-btn">
Cancel
</button>
</div>
</div>
{/* Unsaved changes banner */}
{hasChanges && (
<div className="unsaved-changes-banner">
You have unsaved changes. Click "Save All Changes" to persist them.
</div>
)}
{/* Collapsible Basic Info Section */}
<div className="basic-info-section">
<div
className="basic-info-header"
onClick={() => setBasicInfoCollapsed(!basicInfoCollapsed)}
>
<h3>Recipe Details</h3>
<span className={`collapse-icon ${basicInfoCollapsed ? 'collapsed' : ''}`}>
</span>
</div>
{!basicInfoCollapsed && (
<div className="basic-info-content">
<div className="form-group">
<label htmlFor="title">Title *</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => {
setTitle(e.target.value);
setHasChanges(true);
}}
required
/>
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<textarea
id="description"
value={description}
onChange={(e) => {
setDescription(e.target.value);
setHasChanges(true);
}}
/>
</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);
setHasChanges(true);
}}
/>
</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);
setHasChanges(true);
}}
/>
</div>
<div className="form-group">
<label htmlFor="servings">Servings</label>
<input
type="number"
id="servings"
value={servings}
onChange={(e) => {
setServings(e.target.value);
setHasChanges(true);
}}
/>
</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);
setHasChanges(true);
}}
/>
</div>
</div>
{/* Tags */}
<div className="form-group">
<label htmlFor="tags">Tags</label>
<div className="tags-input-container">
<div className="tags-list">
{recipeTags.map((tag) => (
<span key={tag} className="tag">
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="tag-remove"
title="Remove tag"
>
×
</button>
</span>
))}
</div>
<div className="tag-input-row">
<input
type="text"
id="tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
placeholder="Add a tag and press Enter"
list="available-tags"
/>
<button
type="button"
onClick={() => handleAddTag(tagInput)}
className="btn-add-tag"
>
Add Tag
</button>
</div>
<datalist id="available-tags">
{availableTags.map((tag) => (
<option key={tag.id} value={tag.name} />
))}
</datalist>
</div>
<p className="field-help">Add tags to categorize your recipe (e.g., "Quick", "Vegetarian", "Dessert")</p>
</div>
{/* Image Upload */}
<div className="form-group image-upload-section">
<label>Recipe Image</label>
{recipe.imageUrl && (
<div className="current-image">
<img src={recipe.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>
)}
<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...'
: recipe.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>
{/* Section mode toggle - disabled for now, only support simple mode */}
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={useSections}
onChange={(e) => {
setUseSections(e.target.checked);
setHasChanges(true);
}}
disabled={true} // TODO: Add section support later
/>
<span>Multi-section recipe (coming soon)</span>
</label>
</div>
</div>
)}
</div>
{/* Two-Column Layout: Ingredients + Instructions */}
{!useSections && (
<div className="edit-content">
{/* Left Column: Ingredients Panel (Sticky) */}
<div className="ingredients-panel">
<h3>Ingredients</h3>
<ul className="ingredients-list">
{allIngredients.map((ingredient, index) => (
<li
key={ingredient.id || index}
className={`ingredient-item ${
selectedIngredients.has(ingredient.id || '') ? 'selected' : ''
} ${draggedIngredient?.id === ingredient.id ? 'dragging' : ''}`}
draggable
onDragStart={() => handleIngredientDragStart(ingredient)}
onDragEnd={handleIngredientDragEnd}
>
<input
type="checkbox"
className="ingredient-checkbox"
checked={selectedIngredients.has(ingredient.id || '')}
onChange={() => toggleIngredientSelection(ingredient.id || '')}
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
/>
<span className="drag-handle"></span>
<div className="ingredient-content">
<div className="ingredient-inputs">
<input
type="text"
value={ingredient.amount || ''}
onChange={(e) => updateIngredient(index, 'amount', e.target.value)}
placeholder="Amt"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
/>
<input
type="text"
value={ingredient.unit || ''}
onChange={(e) => updateIngredient(index, 'unit', e.target.value)}
placeholder="Unit"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
/>
<input
type="text"
value={ingredient.name}
onChange={(e) => updateIngredient(index, 'name', e.target.value)}
placeholder="Name"
required
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
/>
</div>
<input
type="text"
className="ingredient-notes-input"
value={ingredient.notes || ''}
onChange={(e) => updateIngredient(index, 'notes', e.target.value)}
placeholder="Notes (e.g., sifted, room temperature)"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
/>
</div>
<button
className="btn-remove-ingredient"
onClick={() => removeIngredient(index)}
title="Remove ingredient"
draggable={false}
>
</button>
</li>
))}
</ul>
<div className="ingredient-actions">
<button className="btn-add-ingredient" onClick={addIngredient}>
+ Add
</button>
<button
className="btn-delete-selected"
onClick={deleteSelectedIngredients}
disabled={selectedIngredients.size === 0}
>
Delete ({selectedIngredients.size})
</button>
</div>
{/* Bulk Actions */}
{selectedIngredients.size > 0 && allInstructions.length > 0 && (
<div className="bulk-actions">
<p>
<strong>Map {selectedIngredients.size} selected to:</strong>
</p>
<select
value={bulkMapTarget}
onChange={(e) => setBulkMapTarget(e.target.value)}
>
<option value="">Select a step...</option>
{allInstructions.map((instruction) => (
<option key={instruction.id} value={instruction.id}>
Step {instruction.step}
</option>
))}
</select>
<button
onClick={handleBulkMapToInstruction}
disabled={!bulkMapTarget}
>
Map to Step
</button>
</div>
)}
</div>
{/* Right Column: Instructions Panel */}
<div className="instructions-panel">
<h3>Instructions</h3>
<DragDropContext onDragEnd={reorderInstructions}>
<Droppable droppableId="instructions">
{(provided) => (
<ul
className="instructions-list"
{...provided.droppableProps}
ref={provided.innerRef}
>
{allInstructions.map((instruction, index) => {
const isEditing = editingInstructionId === instruction.id;
const mappedIngredients = getMappedIngredientsForInstruction(
instruction.id || ''
);
const isDragOver = dragOverInstructionId === instruction.id;
return (
<Draggable
key={instruction.id || `instruction-${index}`}
draggableId={instruction.id || `instruction-${index}`}
index={index}
>
{(provided, snapshot) => (
<li
ref={provided.innerRef}
{...provided.draggableProps}
className={`instruction-item ${isDragOver ? 'drag-over' : ''} ${snapshot.isDragging ? 'dragging' : ''}`}
onDragOver={(e) => handleInstructionDragOver(e, instruction.id || '')}
onDragLeave={handleInstructionDragLeave}
onDrop={(e) => handleInstructionDrop(e, instruction.id || '')}
>
<div className="instruction-header">
<div className="instruction-header-left">
<div
{...provided.dragHandleProps}
className="instruction-drag-handle"
title="Drag to reorder"
>
</div>
<span className="step-number">Step {instruction.step}</span>
</div>
{!isEditing && (
<div className="instruction-controls">
<button
className="btn-edit-instruction"
onClick={() => startEditingInstruction(instruction)}
>
Edit
</button>
<button
className="btn-delete-instruction"
onClick={() => removeInstruction(index)}
>
Delete
</button>
</div>
)}
</div>
{isEditing ? (
<>
<input
type="text"
className="instruction-timing-input"
value={editingInstructionTiming}
onChange={(e) => setEditingInstructionTiming(e.target.value)}
placeholder="Timing (optional, e.g., 8:00am)"
/>
<textarea
className="instruction-text-input"
value={editingInstructionText}
onChange={(e) => setEditingInstructionText(e.target.value)}
placeholder="Instruction text"
autoFocus
/>
<div className="instruction-edit-actions">
<button
className="btn-save-instruction"
onClick={saveEditingInstruction}
>
Save
</button>
<button
className="btn-cancel-instruction"
onClick={cancelEditingInstruction}
>
Cancel
</button>
</div>
</>
) : (
<>
{instruction.timing && (
<div className="instruction-timing-display">
{instruction.timing}
</div>
)}
<div
className="instruction-text-display"
onClick={() => startEditingInstruction(instruction)}
title="Click to edit"
>
{instruction.text || <em>Click to add instruction text</em>}
</div>
</>
)}
{/* Drop zone for ingredients */}
<div className="drop-zone">
<span className="drop-zone-header">
Ingredients for this step:
</span>
{mappedIngredients.length === 0 ? (
<p className="no-ingredients-mapped">
Drag ingredients here or use bulk actions
</p>
) : (
<ul className="mapped-ingredients-list">
{mappedIngredients.map((ingredient) => (
<li
key={ingredient.id}
className="mapped-ingredient-item"
>
<span className="mapped-ingredient-text">
{getIngredientText(ingredient)}
</span>
<button
className="btn-remove-ingredient"
onClick={() =>
removeIngredientFromInstruction(
ingredient.id || '',
instruction.id || ''
)
}
title="Remove ingredient from this step"
>
</button>
</li>
))}
</ul>
)}
</div>
</li>
)}
</Draggable>
);
})}
{provided.placeholder}
</ul>
)}
</Droppable>
</DragDropContext>
<button className="btn-add-instruction" onClick={addInstruction}>
+ Add Instruction
</button>
</div>
</div>
)}
{/* TODO: Add section support later */}
{useSections && (
<div style={{ padding: '2rem', textAlign: 'center', color: '#666' }}>
<p>Multi-section recipe editing coming soon!</p>
<p>For now, please uncheck "Multi-section recipe" above.</p>
</div>
)}
{/* Footer with Save/Cancel buttons */}
<div className="unified-footer">
<button
onClick={handleSaveAll}
disabled={!hasChanges || saving}
className="save-btn-large"
>
{saving ? 'Saving...' : '💾 Save All Changes'}
</button>
<button onClick={handleCancel} className="cancel-btn-large">
Cancel
</button>
</div>
</div>
);
}
export default UnifiedEditRecipe;