feat: add database-backed ingredient-instruction mapping system
Some checks failed
Security Scanning / Dependency License Check (pull_request) Has been cancelled
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled
Some checks failed
Security Scanning / Dependency License Check (pull_request) Has been cancelled
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled
Implement comprehensive solution for managing ingredient-to-instruction mappings in cooking mode. This moves from client-side state management to persistent database storage, significantly improving reliability and user experience. ## Key Changes ### Database & Backend - Add IngredientInstructionMapping table with many-to-many relationship - Implement automatic ingredient matching algorithm with smart name extraction - Add API endpoints for mapping management (update, regenerate) - Create migration script for existing recipes ### Frontend - Simplify CookingMode to read-only display of stored mappings - Add ManageIngredientMappings page with drag-and-drop editing - Remove complex client-side state management (~200 lines) - Add navigation between cooking mode and management interface ### Testing - Add 11 comprehensive unit tests for ingredient matcher service - Update integration tests with proper mocking - All new features fully tested (24/25 API tests passing) ## Benefits - Persistent mappings across all clients/devices - Automatic generation on recipe import/creation - User control via dedicated management interface - Cleaner, more maintainable codebase 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,15 @@ body {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.logo a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo a:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
||||
@@ -4,6 +4,8 @@ import RecipeDetail from './pages/RecipeDetail';
|
||||
import RecipeImport from './pages/RecipeImport';
|
||||
import NewRecipe from './pages/NewRecipe';
|
||||
import EditRecipe from './pages/EditRecipe';
|
||||
import CookingMode from './pages/CookingMode';
|
||||
import ManageIngredientMappings from './pages/ManageIngredientMappings';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@@ -12,7 +14,7 @@ function App() {
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<h1 className="logo">🌿 Basil</h1>
|
||||
<h1 className="logo"><Link to="/">🌿 Basil</Link></h1>
|
||||
<nav>
|
||||
<Link to="/">Recipes</Link>
|
||||
<Link to="/new">New Recipe</Link>
|
||||
@@ -27,6 +29,8 @@ function App() {
|
||||
<Route path="/" element={<RecipeList />} />
|
||||
<Route path="/recipes/:id" element={<RecipeDetail />} />
|
||||
<Route path="/recipes/:id/edit" element={<EditRecipe />} />
|
||||
<Route path="/recipes/:id/cook" element={<CookingMode />} />
|
||||
<Route path="/recipes/:id/manage-mappings" element={<ManageIngredientMappings />} />
|
||||
<Route path="/new" element={<NewRecipe />} />
|
||||
<Route path="/import" element={<RecipeImport />} />
|
||||
</Routes>
|
||||
|
||||
404
packages/web/src/pages/CookingMode.tsx
Normal file
404
packages/web/src/pages/CookingMode.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Recipe, Ingredient, Instruction } from '@basil/shared';
|
||||
import { recipesApi } from '../services/api';
|
||||
import { scaleIngredientString } from '../utils/ingredientParser';
|
||||
import '../styles/CookingMode.css';
|
||||
|
||||
interface InstructionWithIngredients {
|
||||
instruction: Instruction;
|
||||
matchedIngredients: Ingredient[];
|
||||
}
|
||||
|
||||
function CookingMode() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentServings, setCurrentServings] = useState<number | null>(null);
|
||||
const [showInlineIngredients, setShowInlineIngredients] = useState(true);
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadRecipe(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle fullscreen changes
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const loadRecipe = async (recipeId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await recipesApi.getById(recipeId);
|
||||
const loadedRecipe = response.data || null;
|
||||
setRecipe(loadedRecipe);
|
||||
setCurrentServings(loadedRecipe?.servings || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load recipe');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (!document.fullscreenElement) {
|
||||
try {
|
||||
await document.documentElement.requestFullscreen();
|
||||
} catch (err) {
|
||||
console.error('Error attempting to enable fullscreen:', err);
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStep = (stepNumber: number) => {
|
||||
const newCompleted = new Set(completedSteps);
|
||||
if (newCompleted.has(stepNumber)) {
|
||||
newCompleted.delete(stepNumber);
|
||||
} else {
|
||||
newCompleted.add(stepNumber);
|
||||
}
|
||||
setCompletedSteps(newCompleted);
|
||||
};
|
||||
|
||||
const exitCookingMode = () => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
navigate(`/recipes/${id}`);
|
||||
};
|
||||
|
||||
const incrementServings = () => {
|
||||
if (currentServings !== null) {
|
||||
setCurrentServings(currentServings + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const decrementServings = () => {
|
||||
if (currentServings !== null && currentServings > 1) {
|
||||
setCurrentServings(currentServings - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const getScaledIngredientText = (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;
|
||||
}
|
||||
|
||||
const displayStr =
|
||||
recipe?.servings && currentServings && recipe.servings !== currentServings
|
||||
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||||
: ingredientStr;
|
||||
|
||||
return ingredient.notes ? `${displayStr} (${ingredient.notes})` : displayStr;
|
||||
};
|
||||
|
||||
const getInstructionsWithIngredients = (): InstructionWithIngredients[] => {
|
||||
if (!recipe) return [];
|
||||
|
||||
// Handle recipes with sections
|
||||
if (recipe.sections && recipe.sections.length > 0) {
|
||||
const allInstructions: InstructionWithIngredients[] = [];
|
||||
|
||||
for (const section of recipe.sections) {
|
||||
if (section.instructions && section.instructions.length > 0) {
|
||||
section.instructions.forEach(instruction => {
|
||||
// Get mapped ingredients from database (stored in instruction.ingredients)
|
||||
const matchedIngredients = (instruction.ingredients || [])
|
||||
.sort((a, b) => a.order - b.order) // Sort by order field
|
||||
.map(mapping => mapping.ingredient)
|
||||
.filter((ing): ing is Ingredient => ing !== undefined);
|
||||
|
||||
allInstructions.push({
|
||||
instruction,
|
||||
matchedIngredients
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allInstructions;
|
||||
}
|
||||
|
||||
// Handle regular recipes without sections
|
||||
return (recipe.instructions || []).map(instruction => {
|
||||
// Get mapped ingredients from database (stored in instruction.ingredients)
|
||||
const matchedIngredients = (instruction.ingredients || [])
|
||||
.sort((a, b) => a.order - b.order) // Sort by order field
|
||||
.map(mapping => mapping.ingredient)
|
||||
.filter((ing): ing is Ingredient => ing !== undefined);
|
||||
|
||||
return {
|
||||
instruction,
|
||||
matchedIngredients
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="cooking-mode">
|
||||
<div className="loading">Loading recipe...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !recipe) {
|
||||
return (
|
||||
<div className="cooking-mode">
|
||||
<div className="error">{error || 'Recipe not found'}</div>
|
||||
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsWithIngredients = getInstructionsWithIngredients();
|
||||
|
||||
// Get all ingredients for the ingredients section
|
||||
const getAllIngredients = (): Ingredient[] => {
|
||||
const ingredients: Ingredient[] = [...(recipe.ingredients || [])];
|
||||
if (recipe.sections) {
|
||||
recipe.sections.forEach(section => {
|
||||
if (section.ingredients) {
|
||||
ingredients.push(...section.ingredients);
|
||||
}
|
||||
});
|
||||
}
|
||||
return ingredients;
|
||||
};
|
||||
|
||||
const allIngredients = getAllIngredients();
|
||||
|
||||
return (
|
||||
<div className={`cooking-mode ${isFullscreen ? 'fullscreen' : ''}`}>
|
||||
<div className="cooking-mode-header">
|
||||
<div className="cooking-mode-title">
|
||||
<h1>{recipe.title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="cooking-mode-controls">
|
||||
{recipe.servings && currentServings !== null && (
|
||||
<div className="servings-control">
|
||||
<button onClick={decrementServings} disabled={currentServings <= 1}>
|
||||
−
|
||||
</button>
|
||||
<span>Servings: {currentServings}</span>
|
||||
<button onClick={incrementServings}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="toggle-inline-ingredients">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInlineIngredients}
|
||||
onChange={(e) => setShowInlineIngredients(e.target.checked)}
|
||||
/>
|
||||
Show ingredients with steps
|
||||
</label>
|
||||
|
||||
<button onClick={() => navigate(`/recipes/${id}/manage-mappings`)} className="manage-btn">
|
||||
⚙️ Manage Ingredients
|
||||
</button>
|
||||
|
||||
<button onClick={toggleFullscreen} className="fullscreen-btn">
|
||||
{isFullscreen ? '⊗ Exit Fullscreen' : '⛶ Fullscreen'}
|
||||
</button>
|
||||
|
||||
<button onClick={exitCookingMode} className="exit-btn">
|
||||
Exit Cooking Mode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipe.prepTime || recipe.cookTime || recipe.totalTime ? (
|
||||
<div className="cooking-mode-meta">
|
||||
{recipe.prepTime && <span>⏱️ Prep: {recipe.prepTime} min</span>}
|
||||
{recipe.cookTime && <span>🔥 Cook: {recipe.cookTime} min</span>}
|
||||
{recipe.totalTime && <span>⏰ Total: {recipe.totalTime} min</span>}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Ingredients Section */}
|
||||
<div className="cooking-mode-ingredients-section">
|
||||
<h2>Ingredients</h2>
|
||||
|
||||
{recipe.sections && recipe.sections.length > 0 ? (
|
||||
<>
|
||||
{recipe.sections.map(section => (
|
||||
<div key={section.id} className="ingredient-section">
|
||||
<h3>{section.name}</h3>
|
||||
{section.timing && <p className="section-timing">{section.timing}</p>}
|
||||
<ul>
|
||||
{section.ingredients?.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
{getScaledIngredientText(ingredient)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<ul>
|
||||
{allIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
{getScaledIngredientText(ingredient)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions Section */}
|
||||
<div className="cooking-mode-instructions">
|
||||
<h2>Instructions</h2>
|
||||
|
||||
{recipe.sections && recipe.sections.length > 0 ? (
|
||||
<>
|
||||
{recipe.sections.map(section => {
|
||||
const sectionInstructions = instructionsWithIngredients.filter(
|
||||
item => item.instruction.sectionId === section.id
|
||||
);
|
||||
|
||||
if (sectionInstructions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={section.id} className="instruction-section">
|
||||
<div className="section-header">
|
||||
<h3>{section.name}</h3>
|
||||
{section.timing && <span className="section-timing">{section.timing}</span>}
|
||||
</div>
|
||||
|
||||
{sectionInstructions.map(({ instruction, matchedIngredients }) => (
|
||||
<div
|
||||
key={instruction.id}
|
||||
className={`instruction-step ${completedSteps.has(instruction.step) ? 'completed' : ''}`}
|
||||
>
|
||||
<div className="step-header">
|
||||
<label className="step-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={completedSteps.has(instruction.step)}
|
||||
onChange={() => toggleStep(instruction.step)}
|
||||
/>
|
||||
<span className="step-number">Step {instruction.step}</span>
|
||||
</label>
|
||||
{instruction.timing && (
|
||||
<span className="instruction-timing">{instruction.timing}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="step-text">{instruction.text}</div>
|
||||
|
||||
{instruction.imageUrl && (
|
||||
<img
|
||||
src={instruction.imageUrl}
|
||||
alt={`Step ${instruction.step}`}
|
||||
className="step-image"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInlineIngredients && matchedIngredients.length > 0 && (
|
||||
<div className="inline-ingredients">
|
||||
<strong>Ingredients needed:</strong>
|
||||
<ul>
|
||||
{matchedIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id} className="ingredient-item">
|
||||
<span className="ingredient-text">
|
||||
{getScaledIngredientText(ingredient)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{instructionsWithIngredients.map(({ instruction, matchedIngredients }) => (
|
||||
<div
|
||||
key={instruction.id}
|
||||
className={`instruction-step ${completedSteps.has(instruction.step) ? 'completed' : ''}`}
|
||||
>
|
||||
<div className="step-header">
|
||||
<label className="step-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={completedSteps.has(instruction.step)}
|
||||
onChange={() => toggleStep(instruction.step)}
|
||||
/>
|
||||
<span className="step-number">Step {instruction.step}</span>
|
||||
</label>
|
||||
{instruction.timing && (
|
||||
<span className="instruction-timing">{instruction.timing}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="step-text">{instruction.text}</div>
|
||||
|
||||
{instruction.imageUrl && (
|
||||
<img
|
||||
src={instruction.imageUrl}
|
||||
alt={`Step ${instruction.step}`}
|
||||
className="step-image"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInlineIngredients && matchedIngredients.length > 0 && (
|
||||
<div className="inline-ingredients">
|
||||
<strong>Ingredients needed:</strong>
|
||||
<ul>
|
||||
{matchedIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id} className="ingredient-item">
|
||||
<span className="ingredient-text">
|
||||
{getScaledIngredientText(ingredient)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cooking-mode-footer">
|
||||
<button onClick={exitCookingMode} className="exit-btn-large">
|
||||
Exit Cooking Mode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CookingMode;
|
||||
501
packages/web/src/pages/ManageIngredientMappings.tsx
Normal file
501
packages/web/src/pages/ManageIngredientMappings.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Recipe, Ingredient, Instruction } from '@basil/shared';
|
||||
import { recipesApi } from '../services/api';
|
||||
import '../styles/ManageIngredientMappings.css';
|
||||
|
||||
interface InstructionWithIngredients {
|
||||
instruction: Instruction;
|
||||
matchedIngredients: Ingredient[];
|
||||
}
|
||||
|
||||
interface MappingChange {
|
||||
ingredientId: string;
|
||||
instructionId: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
function ManageIngredientMappings() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [draggedIngredient, setDraggedIngredient] = useState<Ingredient | null>(null);
|
||||
|
||||
// Local state for mappings (will be saved to database)
|
||||
const [localMappings, setLocalMappings] = useState<Record<string, string[]>>({}); // instructionId -> [ingredientIds]
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadRecipe(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadRecipe = async (recipeId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await recipesApi.getById(recipeId);
|
||||
const loadedRecipe = response.data || null;
|
||||
setRecipe(loadedRecipe);
|
||||
|
||||
// Initialize local mappings from database
|
||||
if (loadedRecipe) {
|
||||
const mappings: Record<string, string[]> = {};
|
||||
|
||||
// Process sections if they exist
|
||||
if (loadedRecipe.sections && loadedRecipe.sections.length > 0) {
|
||||
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 {
|
||||
// Process non-sectioned recipes
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIngredientDragStart = (ingredient: Ingredient) => {
|
||||
setDraggedIngredient(ingredient);
|
||||
};
|
||||
|
||||
const handleStepDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleStepDrop = (e: React.DragEvent, instructionId: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedIngredient && draggedIngredient.id) {
|
||||
const newMappings = { ...localMappings };
|
||||
|
||||
// Initialize this instruction's mappings if needed
|
||||
if (!newMappings[instructionId]) {
|
||||
newMappings[instructionId] = [];
|
||||
}
|
||||
|
||||
// Add ingredient to this instruction if not already there
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Reload recipe to get new mappings
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const saveMappings = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Convert local mappings to API format
|
||||
const mappings: MappingChange[] = [];
|
||||
Object.entries(localMappings).forEach(([instructionId, ingredientIds]) => {
|
||||
ingredientIds.forEach((ingredientId, index) => {
|
||||
mappings.push({
|
||||
ingredientId,
|
||||
instructionId,
|
||||
order: index,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await recipesApi.updateMappings(id, mappings);
|
||||
|
||||
setHasChanges(false);
|
||||
alert('Mappings saved successfully!');
|
||||
} catch (err) {
|
||||
console.error('Error saving mappings:', err);
|
||||
alert('Failed to save mappings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getInstructionsWithIngredients = (): InstructionWithIngredients[] => {
|
||||
if (!recipe) return [];
|
||||
|
||||
const allIngredients = getAllIngredients();
|
||||
|
||||
// Handle recipes with sections
|
||||
if (recipe.sections && recipe.sections.length > 0) {
|
||||
const allInstructions: InstructionWithIngredients[] = [];
|
||||
|
||||
for (const section of recipe.sections) {
|
||||
if (section.instructions && section.instructions.length > 0) {
|
||||
section.instructions.forEach(instruction => {
|
||||
if (!instruction.id) return;
|
||||
|
||||
// Get ingredients from local mappings
|
||||
const ingredientIds = localMappings[instruction.id] || [];
|
||||
const matchedIngredients = ingredientIds
|
||||
.map(id => allIngredients.find(ing => ing.id === id))
|
||||
.filter((ing): ing is Ingredient => ing !== undefined);
|
||||
|
||||
allInstructions.push({
|
||||
instruction,
|
||||
matchedIngredients
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allInstructions;
|
||||
}
|
||||
|
||||
// Handle regular recipes without sections
|
||||
return (recipe.instructions || []).map(instruction => {
|
||||
if (!instruction.id) {
|
||||
return { instruction, matchedIngredients: [] };
|
||||
}
|
||||
|
||||
// Get ingredients from local mappings
|
||||
const ingredientIds = localMappings[instruction.id] || [];
|
||||
const matchedIngredients = ingredientIds
|
||||
.map(id => allIngredients.find(ing => ing.id === id))
|
||||
.filter((ing): ing is Ingredient => ing !== undefined);
|
||||
|
||||
return {
|
||||
instruction,
|
||||
matchedIngredients
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getAllIngredients = (): Ingredient[] => {
|
||||
if (!recipe) return [];
|
||||
|
||||
const ingredients: Ingredient[] = [...(recipe.ingredients || [])];
|
||||
if (recipe.sections) {
|
||||
recipe.sections.forEach(section => {
|
||||
if (section.ingredients) {
|
||||
ingredients.push(...section.ingredients);
|
||||
}
|
||||
});
|
||||
}
|
||||
return ingredients;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="manage-mappings">
|
||||
<div className="loading">Loading recipe...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !recipe) {
|
||||
return (
|
||||
<div className="manage-mappings">
|
||||
<div className="error">{error || 'Recipe not found'}</div>
|
||||
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsWithIngredients = getInstructionsWithIngredients();
|
||||
const allIngredients = getAllIngredients();
|
||||
|
||||
return (
|
||||
<div className="manage-mappings">
|
||||
<div className="manage-mappings-header">
|
||||
<div className="manage-mappings-title">
|
||||
<h1>Manage Ingredient Mappings</h1>
|
||||
<h2>{recipe.title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="manage-mappings-controls">
|
||||
<button onClick={() => navigate(`/recipes/${id}/cook`)} className="cooking-mode-btn">
|
||||
👨🍳 Preview in Cooking Mode
|
||||
</button>
|
||||
|
||||
<button onClick={regenerateMappings} disabled={saving} className="regenerate-btn">
|
||||
🔄 Regenerate Auto Mappings
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={saveMappings}
|
||||
disabled={!hasChanges || saving}
|
||||
className="save-btn"
|
||||
>
|
||||
{saving ? 'Saving...' : '💾 Save Changes'}
|
||||
</button>
|
||||
|
||||
<button onClick={() => navigate(`/recipes/${id}`)} className="exit-btn">
|
||||
← Back to Recipe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="unsaved-changes-banner">
|
||||
⚠️ You have unsaved changes. Click "Save Changes" to persist them to the database.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="instructions-help">
|
||||
<p><strong>💡 How to use:</strong></p>
|
||||
<ul>
|
||||
<li>Drag ingredients from the list below to the steps where they're used</li>
|
||||
<li>Click the ✕ to remove an ingredient from a step</li>
|
||||
<li>Changes are saved to the database and visible to all clients</li>
|
||||
<li>Click "Regenerate Auto Mappings" to run automatic matching again</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Ingredients Section - Draggable Source */}
|
||||
<div className="manage-mappings-ingredients-section">
|
||||
<h2>Available Ingredients <span className="hint-text">(Drag to steps below)</span></h2>
|
||||
|
||||
{recipe.sections && recipe.sections.length > 0 ? (
|
||||
<>
|
||||
{recipe.sections.map(section => (
|
||||
<div key={section.id} className="ingredient-section">
|
||||
<h3>{section.name}</h3>
|
||||
{section.timing && <p className="section-timing">{section.timing}</p>}
|
||||
<ul>
|
||||
{section.ingredients?.map((ingredient) => (
|
||||
<li
|
||||
key={ingredient.id}
|
||||
className="ingredient-item draggable-source"
|
||||
draggable
|
||||
onDragStart={() => handleIngredientDragStart(ingredient)}
|
||||
>
|
||||
<span className="drag-handle">⋮⋮</span>
|
||||
<span className="ingredient-text">{getIngredientText(ingredient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<ul>
|
||||
{allIngredients.map((ingredient) => (
|
||||
<li
|
||||
key={ingredient.id}
|
||||
className="ingredient-item draggable-source"
|
||||
draggable
|
||||
onDragStart={() => handleIngredientDragStart(ingredient)}
|
||||
>
|
||||
<span className="drag-handle">⋮⋮</span>
|
||||
<span className="ingredient-text">{getIngredientText(ingredient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions Section - Drop Zones */}
|
||||
<div className="manage-mappings-instructions">
|
||||
<h2>Instructions</h2>
|
||||
|
||||
{recipe.sections && recipe.sections.length > 0 ? (
|
||||
<>
|
||||
{recipe.sections.map(section => {
|
||||
const sectionInstructions = instructionsWithIngredients.filter(
|
||||
item => item.instruction.sectionId === section.id
|
||||
);
|
||||
|
||||
if (sectionInstructions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={section.id} className="instruction-section">
|
||||
<div className="section-header">
|
||||
<h3>{section.name}</h3>
|
||||
{section.timing && <span className="section-timing">{section.timing}</span>}
|
||||
</div>
|
||||
|
||||
{sectionInstructions.map(({ instruction, matchedIngredients }) => (
|
||||
<div
|
||||
key={instruction.id}
|
||||
className="instruction-step"
|
||||
onDragOver={handleStepDragOver}
|
||||
onDrop={(e) => instruction.id && handleStepDrop(e, instruction.id)}
|
||||
>
|
||||
<div className="step-header">
|
||||
<span className="step-number">Step {instruction.step}</span>
|
||||
{instruction.timing && (
|
||||
<span className="instruction-timing">{instruction.timing}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="step-text">{instruction.text}</div>
|
||||
|
||||
{instruction.imageUrl && (
|
||||
<img
|
||||
src={instruction.imageUrl}
|
||||
alt={`Step ${instruction.step}`}
|
||||
className="step-image"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="inline-ingredients">
|
||||
<strong>Ingredients for this step:</strong>
|
||||
{matchedIngredients.length === 0 ? (
|
||||
<p className="no-ingredients">No ingredients mapped. Drag ingredients here.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{matchedIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id} className="ingredient-item">
|
||||
<span className="ingredient-text">
|
||||
{getIngredientText(ingredient)}
|
||||
</span>
|
||||
<button
|
||||
className="remove-ingredient-btn"
|
||||
onClick={() => ingredient.id && instruction.id && removeIngredientFromInstruction(ingredient.id, instruction.id)}
|
||||
title="Remove ingredient from this step"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{instructionsWithIngredients.map(({ instruction, matchedIngredients }) => (
|
||||
<div
|
||||
key={instruction.id}
|
||||
className="instruction-step"
|
||||
onDragOver={handleStepDragOver}
|
||||
onDrop={(e) => instruction.id && handleStepDrop(e, instruction.id)}
|
||||
>
|
||||
<div className="step-header">
|
||||
<span className="step-number">Step {instruction.step}</span>
|
||||
{instruction.timing && (
|
||||
<span className="instruction-timing">{instruction.timing}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="step-text">{instruction.text}</div>
|
||||
|
||||
{instruction.imageUrl && (
|
||||
<img
|
||||
src={instruction.imageUrl}
|
||||
alt={`Step ${instruction.step}`}
|
||||
className="step-image"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="inline-ingredients">
|
||||
<strong>Ingredients for this step:</strong>
|
||||
{matchedIngredients.length === 0 ? (
|
||||
<p className="no-ingredients">No ingredients mapped. Drag ingredients here.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{matchedIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id} className="ingredient-item">
|
||||
<span className="ingredient-text">
|
||||
{getIngredientText(ingredient)}
|
||||
</span>
|
||||
<button
|
||||
className="remove-ingredient-btn"
|
||||
onClick={() => ingredient.id && instruction.id && removeIngredientFromInstruction(ingredient.id, instruction.id)}
|
||||
title="Remove ingredient from this step"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="manage-mappings-footer">
|
||||
<button
|
||||
onClick={saveMappings}
|
||||
disabled={!hasChanges || saving}
|
||||
className="save-btn-large"
|
||||
>
|
||||
{saving ? 'Saving...' : '💾 Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageIngredientMappings;
|
||||
@@ -81,6 +81,9 @@ function RecipeDetail() {
|
||||
<div className="recipe-actions">
|
||||
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
||||
<div className="recipe-actions-right">
|
||||
<button onClick={() => navigate(`/recipes/${id}/cook`)} style={{ backgroundColor: '#2e7d32' }}>
|
||||
👨🍳 Cooking Mode
|
||||
</button>
|
||||
<button onClick={() => navigate(`/recipes/${id}/edit`)}>Edit Recipe</button>
|
||||
<button onClick={handleDelete} style={{ backgroundColor: '#d32f2f' }}>
|
||||
Delete Recipe
|
||||
|
||||
@@ -58,6 +58,19 @@ export const recipesApi = {
|
||||
const response = await api.post('/recipes/import', { url } as RecipeImportRequest);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateMappings: async (
|
||||
id: string,
|
||||
mappings: Array<{ ingredientId: string; instructionId: string; order: number }>
|
||||
): Promise<ApiResponse<void>> => {
|
||||
const response = await api.post(`/recipes/${id}/ingredient-mappings`, { mappings });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
regenerateMappings: async (id: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.post(`/recipes/${id}/regenerate-mappings`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
425
packages/web/src/styles/CookingMode.css
Normal file
425
packages/web/src/styles/CookingMode.css
Normal file
@@ -0,0 +1,425 @@
|
||||
/* Cooking Mode Styles - Optimized for hands-free cooking */
|
||||
|
||||
.cooking-mode {
|
||||
background-color: #fafafa;
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Fullscreen mode */
|
||||
.cooking-mode.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.cooking-mode-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 3px solid #2e7d32;
|
||||
}
|
||||
|
||||
.cooking-mode-title h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0;
|
||||
color: #1b5e20;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cooking-mode-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cooking-mode-controls button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cooking-mode-controls button:hover {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
.cooking-mode-controls button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cooking-mode-controls .exit-btn {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
.cooking-mode-controls .exit-btn:hover {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
|
||||
.cooking-mode-controls .fullscreen-btn {
|
||||
background-color: #1976d2;
|
||||
}
|
||||
|
||||
.cooking-mode-controls .fullscreen-btn:hover {
|
||||
background-color: #0d47a1;
|
||||
}
|
||||
|
||||
.cooking-mode-controls .manage-btn {
|
||||
background-color: #757575;
|
||||
}
|
||||
|
||||
.cooking-mode-controls .manage-btn:hover {
|
||||
background-color: #424242;
|
||||
}
|
||||
|
||||
.cooking-mode-controls .reset-mappings-btn {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
|
||||
.cooking-mode-controls .reset-mappings-btn:hover {
|
||||
background-color: #f57c00;
|
||||
}
|
||||
|
||||
/* Servings control */
|
||||
.servings-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background-color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.servings-control button {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.servings-control span {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Toggle inline ingredients */
|
||||
.toggle-inline-ingredients {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e0e0e0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.toggle-inline-ingredients input[type="checkbox"] {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Meta info */
|
||||
.cooking-mode-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.cooking-mode-meta span {
|
||||
background-color: white;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Ingredients section */
|
||||
.cooking-mode-ingredients-section {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2rem;
|
||||
border: 2px solid #2e7d32;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section .hint-text {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section h2 {
|
||||
margin-top: 0;
|
||||
font-size: 2rem;
|
||||
color: #2e7d32;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section h3 {
|
||||
font-size: 1.5rem;
|
||||
color: #1b5e20;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section li {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ingredient-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Instructions section */
|
||||
.cooking-mode-instructions {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.cooking-mode-instructions h2 {
|
||||
font-size: 2rem;
|
||||
color: #2e7d32;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Section headers (for multi-section recipes) */
|
||||
.instruction-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid #2e7d32;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.section-timing {
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
background-color: #fff3e0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Individual instruction steps */
|
||||
.instruction-step {
|
||||
background-color: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.instruction-step.completed {
|
||||
background-color: #e8f5e9;
|
||||
border-color: #2e7d32;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.instruction-step.completed .step-text {
|
||||
text-decoration: line-through;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.step-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.step-checkbox input[type="checkbox"] {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.instruction-timing {
|
||||
font-size: 1rem;
|
||||
color: #d84315;
|
||||
background-color: #fff3e0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.8;
|
||||
color: #212121;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Inline ingredients */
|
||||
.inline-ingredients {
|
||||
background-color: #f1f8e9;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
border-left: 4px solid #2e7d32;
|
||||
}
|
||||
|
||||
.inline-ingredients strong {
|
||||
color: #1b5e20;
|
||||
font-size: 1.1rem;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.inline-ingredients ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inline-ingredients li {
|
||||
padding: 0.4rem 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.5;
|
||||
color: #33691e;
|
||||
}
|
||||
|
||||
|
||||
/* Step images */
|
||||
.step-image {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.cooking-mode-footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.exit-btn-large {
|
||||
padding: 1rem 3rem;
|
||||
font-size: 1.25rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background-color: #d32f2f;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.exit-btn-large:hover {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.cooking-mode-title h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.cooking-mode-controls {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cooking-mode-controls button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.inline-ingredients li {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles (if user wants to print from cooking mode) */
|
||||
@media print {
|
||||
.cooking-mode-header,
|
||||
.cooking-mode-controls,
|
||||
.cooking-mode-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cooking-mode {
|
||||
background-color: white;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.instruction-step {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
}
|
||||
402
packages/web/src/styles/ManageIngredientMappings.css
Normal file
402
packages/web/src/styles/ManageIngredientMappings.css
Normal file
@@ -0,0 +1,402 @@
|
||||
/* Manage Ingredient Mappings Styles */
|
||||
|
||||
.manage-mappings {
|
||||
background-color: #fafafa;
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.manage-mappings-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 3px solid #1976d2;
|
||||
}
|
||||
|
||||
.manage-mappings-title h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
color: #1976d2;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.manage-mappings-title h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: #555;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.manage-mappings-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.manage-mappings-controls button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.manage-mappings-controls button:hover:not(:disabled) {
|
||||
background-color: #0d47a1;
|
||||
}
|
||||
|
||||
.manage-mappings-controls button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.manage-mappings-controls .exit-btn {
|
||||
background-color: #757575;
|
||||
}
|
||||
|
||||
.manage-mappings-controls .exit-btn:hover {
|
||||
background-color: #424242;
|
||||
}
|
||||
|
||||
.manage-mappings-controls .save-btn {
|
||||
background-color: #2e7d32;
|
||||
}
|
||||
|
||||
.manage-mappings-controls .save-btn:hover:not(:disabled) {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
.manage-mappings-controls .regenerate-btn {
|
||||
background-color: #ff9800;
|
||||
}
|
||||
|
||||
.manage-mappings-controls .regenerate-btn:hover:not(:disabled) {
|
||||
background-color: #f57c00;
|
||||
}
|
||||
|
||||
.manage-mappings-controls .cooking-mode-btn {
|
||||
background-color: #2e7d32;
|
||||
}
|
||||
|
||||
.manage-mappings-controls .cooking-mode-btn:hover {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
/* Unsaved changes banner */
|
||||
.unsaved-changes-banner {
|
||||
background-color: #fff3e0;
|
||||
border: 2px solid #ff9800;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
/* Instructions help */
|
||||
.instructions-help {
|
||||
background-color: #e3f2fd;
|
||||
border: 2px solid #1976d2;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.instructions-help p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.instructions-help ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.instructions-help li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Ingredients section */
|
||||
.manage-mappings-ingredients-section {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2rem;
|
||||
border: 2px solid #2e7d32;
|
||||
}
|
||||
|
||||
.manage-mappings-ingredients-section .hint-text {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.manage-mappings-ingredients-section h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.75rem;
|
||||
color: #2e7d32;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.manage-mappings-ingredients-section h3 {
|
||||
font-size: 1.3rem;
|
||||
color: #1b5e20;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.manage-mappings-ingredients-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.manage-mappings-ingredients-section li.ingredient-item.draggable-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.manage-mappings-ingredients-section li.ingredient-item.draggable-source:hover {
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
|
||||
.manage-mappings-ingredients-section li.ingredient-item.draggable-source:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ingredient-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Instructions section */
|
||||
.manage-mappings-instructions {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.manage-mappings-instructions h2 {
|
||||
font-size: 1.75rem;
|
||||
color: #1976d2;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Section headers (for multi-section recipes) */
|
||||
.instruction-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid #1976d2;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
color: #0d47a1;
|
||||
}
|
||||
|
||||
.section-timing {
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
background-color: #fff3e0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Individual instruction steps */
|
||||
.instruction-step {
|
||||
background-color: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.instruction-step:hover {
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.instruction-timing {
|
||||
font-size: 1rem;
|
||||
color: #d84315;
|
||||
background-color: #fff3e0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.8;
|
||||
color: #212121;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Inline ingredients */
|
||||
.inline-ingredients {
|
||||
background-color: #f1f8e9;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
border-left: 4px solid #2e7d32;
|
||||
}
|
||||
|
||||
.inline-ingredients strong {
|
||||
color: #1b5e20;
|
||||
font-size: 1.05rem;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.inline-ingredients ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inline-ingredients li.ingredient-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.inline-ingredients li.ingredient-item:hover {
|
||||
background-color: #dcedc8;
|
||||
}
|
||||
|
||||
.inline-ingredients .no-ingredients {
|
||||
color: #757575;
|
||||
font-style: italic;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: #9e9e9e;
|
||||
font-size: 1rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ingredient-text {
|
||||
flex: 1;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.remove-ingredient-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #d32f2f;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.remove-ingredient-btn:hover {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
|
||||
/* Step images */
|
||||
.step-image {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.manage-mappings-footer {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.save-btn-large {
|
||||
padding: 1rem 3rem;
|
||||
font-size: 1.25rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.save-btn-large:hover:not(:disabled) {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
.save-btn-large:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.manage-mappings-title h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.manage-mappings-controls {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manage-mappings-controls button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.inline-ingredients li {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
204
packages/web/src/utils/ingredientMatcher.ts
Normal file
204
packages/web/src/utils/ingredientMatcher.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Ingredient Matcher Utility
|
||||
* Matches ingredient references in instruction text with actual ingredients
|
||||
*/
|
||||
|
||||
import { Ingredient } from '@basil/shared';
|
||||
|
||||
export interface IngredientMatch {
|
||||
ingredient: Ingredient;
|
||||
displayText: string; // e.g., "1 cup flour"
|
||||
}
|
||||
|
||||
/**
|
||||
* Common units to strip from ingredient names when matching
|
||||
*/
|
||||
const UNITS_REGEX = /^(\d+[\d\s\/\-\.]*\s*)?(cup|cups|tablespoon|tablespoons|tbsp|tbs|tb|teaspoon|teaspoons|tsp|ts|pound|pounds|lb|lbs|ounce|ounces|oz|gram|grams|g|kilogram|kilograms|kg|milliliter|milliliters|ml|liter|liters|l|pint|pints|pt|quart|quarts|qt|gallon|gallons|gal|piece|pieces|slice|slices|clove|cloves|can|cans|package|packages|pkg|bunch|bunches|pinch|pinches|dash|dashes|handful|handfuls)?\s*/i;
|
||||
|
||||
/**
|
||||
* Extract the core ingredient name from a full ingredient string
|
||||
* Examples:
|
||||
* "3/4 cup flour" -> "flour"
|
||||
* "2-3 Gala apples (sliced thinly)" -> "gala apples"
|
||||
* "4 eggs" -> "eggs"
|
||||
*/
|
||||
function extractIngredientName(ingredientString: string): string {
|
||||
let name = ingredientString.trim();
|
||||
|
||||
// Remove parenthetical notes like "(sliced thinly)"
|
||||
name = name.replace(/\([^)]*\)/g, '').trim();
|
||||
|
||||
// Remove leading quantities and units
|
||||
name = name.replace(UNITS_REGEX, '').trim();
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate variations of an ingredient name for matching
|
||||
*/
|
||||
function generateNameVariations(name: string): string[] {
|
||||
const variations: string[] = [];
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
variations.push(lowerName);
|
||||
|
||||
// Add plural form
|
||||
if (!lowerName.endsWith('s')) {
|
||||
variations.push(lowerName + 's');
|
||||
}
|
||||
|
||||
// Add singular form (remove trailing 's')
|
||||
if (lowerName.endsWith('s') && lowerName.length > 2) {
|
||||
variations.push(lowerName.slice(0, -1));
|
||||
}
|
||||
|
||||
// Handle specific cases
|
||||
if (lowerName.endsWith('ies')) {
|
||||
// berries -> berry
|
||||
variations.push(lowerName.slice(0, -3) + 'y');
|
||||
} else if (lowerName.endsWith('es') && lowerName.length > 3) {
|
||||
// tomatoes -> tomato
|
||||
variations.push(lowerName.slice(0, -2));
|
||||
}
|
||||
|
||||
// For compound names like "gala apples", also try just the last word
|
||||
const words = lowerName.split(/\s+/);
|
||||
if (words.length > 1) {
|
||||
const lastWord = words[words.length - 1];
|
||||
variations.push(lastWord);
|
||||
|
||||
// Add singular/plural of last word
|
||||
if (!lastWord.endsWith('s')) {
|
||||
variations.push(lastWord + 's');
|
||||
}
|
||||
if (lastWord.endsWith('s') && lastWord.length > 2) {
|
||||
variations.push(lastWord.slice(0, -1));
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(variations)]; // Remove duplicates
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the position of an ingredient name in the instruction text
|
||||
* Returns -1 if not found
|
||||
*/
|
||||
function findIngredientPosition(ingredientName: string, instructionText: string): number {
|
||||
const lowerInstruction = instructionText.toLowerCase();
|
||||
const variations = generateNameVariations(ingredientName);
|
||||
|
||||
let earliestPosition = Infinity;
|
||||
|
||||
for (const variation of variations) {
|
||||
const regex = new RegExp(`\\b${variation.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
|
||||
const match = lowerInstruction.match(regex);
|
||||
if (match && match.index !== undefined && match.index < earliestPosition) {
|
||||
earliestPosition = match.index;
|
||||
}
|
||||
}
|
||||
|
||||
return earliestPosition === Infinity ? -1 : earliestPosition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ingredients referenced in an instruction step
|
||||
* Uses fuzzy matching to identify ingredient names in instruction text
|
||||
* Returns ingredients sorted by order of appearance in the instruction text
|
||||
* When multiple ingredients have the same core name (e.g., "1 Tbsp sugar" and "1/3 cup sugar"),
|
||||
* only the first one (by ingredient list order) is included
|
||||
*
|
||||
* @param instructionText - The instruction text to search for ingredients
|
||||
* @param ingredients - List of all available ingredients
|
||||
* @param usedIngredientIds - Set of ingredient IDs that have already been used in previous steps
|
||||
*/
|
||||
export function findIngredientsInInstruction(
|
||||
instructionText: string,
|
||||
ingredients: Ingredient[],
|
||||
usedIngredientIds: Set<string> = new Set()
|
||||
): IngredientMatch[] {
|
||||
const matchesWithPosition: Array<IngredientMatch & { position: number; coreName: string; ingredientOrder: number }> = [];
|
||||
const seenCoreNames = new Set<string>();
|
||||
|
||||
for (let i = 0; i < ingredients.length; i++) {
|
||||
const ingredient = ingredients[i];
|
||||
|
||||
// Skip if this ingredient has already been used in a previous step
|
||||
if (ingredient.id && usedIngredientIds.has(ingredient.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the core ingredient name
|
||||
const coreName = extractIngredientName(ingredient.name).toLowerCase();
|
||||
|
||||
// Skip if we've already matched this core ingredient name in this step
|
||||
if (seenCoreNames.has(coreName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find position in instruction text
|
||||
const position = findIngredientPosition(coreName, instructionText);
|
||||
|
||||
if (position >= 0) {
|
||||
// Mark this core name as seen
|
||||
seenCoreNames.add(coreName);
|
||||
|
||||
// Build display text
|
||||
let displayText = '';
|
||||
|
||||
if (ingredient.amount && ingredient.unit) {
|
||||
displayText = `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`;
|
||||
} else if (ingredient.amount) {
|
||||
displayText = `${ingredient.amount} ${ingredient.name}`;
|
||||
} else {
|
||||
displayText = ingredient.name;
|
||||
}
|
||||
|
||||
if (ingredient.notes) {
|
||||
displayText += ` (${ingredient.notes})`;
|
||||
}
|
||||
|
||||
matchesWithPosition.push({
|
||||
ingredient,
|
||||
displayText,
|
||||
position,
|
||||
coreName,
|
||||
ingredientOrder: i
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by position in instruction text
|
||||
matchesWithPosition.sort((a, b) => a.position - b.position);
|
||||
|
||||
// Return without position property
|
||||
return matchesWithPosition.map(({ ingredient, displayText }) => ({
|
||||
ingredient,
|
||||
displayText
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ingredients list for display in cooking mode
|
||||
*/
|
||||
export function formatIngredientsForDisplay(ingredients: Ingredient[]): string {
|
||||
return ingredients
|
||||
.map(ing => {
|
||||
let text = '';
|
||||
|
||||
if (ing.amount && ing.unit) {
|
||||
text = `${ing.amount} ${ing.unit} ${ing.name}`;
|
||||
} else if (ing.amount) {
|
||||
text = `${ing.amount} ${ing.name}`;
|
||||
} else {
|
||||
text = ing.name;
|
||||
}
|
||||
|
||||
if (ing.notes) {
|
||||
text += ` (${ing.notes})`;
|
||||
}
|
||||
|
||||
return text;
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
Reference in New Issue
Block a user