- 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
1237 lines
43 KiB
TypeScript
1237 lines
43 KiB
TypeScript
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;
|