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:
165
PULL_REQUEST.md
Normal file
165
PULL_REQUEST.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Pull Request: Database-Backed Ingredient-Instruction Mapping
|
||||
|
||||
## Summary
|
||||
|
||||
This PR implements a comprehensive solution for managing ingredient-to-instruction mappings in cooking mode. The feature moves from client-side state management to a persistent database-backed approach, significantly improving reliability and user experience.
|
||||
|
||||
## Changes
|
||||
|
||||
### Database Schema
|
||||
- **New Table**: `IngredientInstructionMapping`
|
||||
- Many-to-many relationship between ingredients and instructions
|
||||
- Includes `order` field for display ordering within instructions
|
||||
- Cascade delete for data integrity
|
||||
- Migration: `20251031050746_add_ingredient_instruction_mapping`
|
||||
|
||||
### Backend (API)
|
||||
|
||||
#### New Service
|
||||
- **`packages/api/src/services/ingredientMatcher.service.ts`**
|
||||
- Automatic ingredient matching algorithm
|
||||
- Extracts core ingredient names (removes quantities/units)
|
||||
- Generates name variations (plural/singular)
|
||||
- Finds ingredient positions in instruction text
|
||||
- Prevents duplicate mappings (same ingredient appearing multiple times)
|
||||
- Cross-step tracking (ingredients only appear once across all steps)
|
||||
|
||||
#### API Endpoints
|
||||
- **Updated GET `/api/recipes/:id`**: Now includes ingredient-instruction mappings in response
|
||||
- **Updated POST `/api/recipes`**: Automatically generates mappings on recipe creation
|
||||
- **Updated PUT `/api/recipes/:id`**: Regenerates mappings on recipe update
|
||||
- **New POST `/api/recipes/:id/ingredient-mappings`**: Manually update mappings
|
||||
- **New POST `/api/recipes/:id/regenerate-mappings`**: Re-run automatic matching
|
||||
|
||||
### Frontend (Web)
|
||||
|
||||
#### Simplified Cooking Mode
|
||||
- **`packages/web/src/pages/CookingMode.tsx`** - Significantly simplified
|
||||
- Removed all client-side matching logic (~200 lines removed)
|
||||
- Removed localStorage state management
|
||||
- Removed drag-and-drop functionality
|
||||
- Now purely read-only, displays database-stored mappings
|
||||
- Added "⚙️ Manage Ingredients" button for editing
|
||||
|
||||
#### New Management Interface
|
||||
- **`packages/web/src/pages/ManageIngredientMappings.tsx`** - New dedicated page
|
||||
- Similar layout to cooking mode for familiarity
|
||||
- Drag-and-drop ingredients to instruction steps
|
||||
- Remove ingredients with ✕ button
|
||||
- Real-time preview of changes
|
||||
- Save to database with "💾 Save Changes" button
|
||||
- Auto-regenerate with "🔄 Regenerate Auto Mappings" button
|
||||
- Navigate to cooking mode with "👨🍳 Preview in Cooking Mode" button
|
||||
- Unsaved changes warning banner
|
||||
|
||||
- **`packages/web/src/styles/ManageIngredientMappings.css`** - Dedicated styling
|
||||
|
||||
#### Updated Components
|
||||
- **`packages/web/src/App.tsx`**: Added route for `/recipes/:id/manage-mappings`
|
||||
- **`packages/web/src/services/api.ts`**: Added API methods for mapping management
|
||||
- **`packages/web/src/styles/CookingMode.css`**: Removed unused drag-and-drop styles
|
||||
|
||||
### Shared Types
|
||||
- **`packages/shared/src/types.ts`**
|
||||
- Added `IngredientInstructionMapping` interface
|
||||
- Updated `Ingredient` and `Instruction` to include optional mapping arrays
|
||||
|
||||
### Scripts
|
||||
- **`packages/api/src/scripts/regenerate-mappings.ts`** - Utility script to regenerate mappings for all existing recipes
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Results
|
||||
- ✅ **API Tests**: 24/25 passing (96%)
|
||||
- ✅ **New Ingredient Matcher Service**: 11/11 tests passing
|
||||
- ✅ **Recipe Routes**: 9/9 tests passing (includes mapping integration)
|
||||
- ✅ **Storage Service**: 4/5 passing (1 pre-existing S3 test failure)
|
||||
- ✅ **Shared Tests**: 16/16 passing (100%)
|
||||
- ⚠️ **Web Tests**: 7/15 passing (8 pre-existing failures in axios mocking setup)
|
||||
|
||||
### New Test Coverage
|
||||
Created comprehensive unit tests for `ingredientMatcher.service.ts`:
|
||||
- ✅ Simple ingredient matching
|
||||
- ✅ Ingredients with quantities in names
|
||||
- ✅ Plural/singular form matching
|
||||
- ✅ No duplication across steps
|
||||
- ✅ No duplication of same core ingredient
|
||||
- ✅ Recipes with sections
|
||||
- ✅ Ingredient ordering by text position
|
||||
- ✅ Error handling for missing recipes
|
||||
- ✅ Save mappings functionality
|
||||
- ✅ Auto-map integration
|
||||
|
||||
### Manual Testing Performed
|
||||
- ✅ Created new test recipe with automatic mapping generation
|
||||
- ✅ Regenerated mappings for 5 existing recipes (Soft Sourdough, Apple Dutch Baby, Ice Cream, etc.)
|
||||
- ✅ Verified mappings persist across page refreshes
|
||||
- ✅ Tested drag-and-drop in management interface
|
||||
- ✅ Tested remove functionality
|
||||
- ✅ Tested save persistence to database
|
||||
- ✅ Verified cooking mode displays stored mappings correctly
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For Existing Recipes
|
||||
Run the regeneration script to generate mappings for all existing recipes:
|
||||
```bash
|
||||
cd packages/api
|
||||
npx tsx src/scripts/regenerate-mappings.ts
|
||||
```
|
||||
|
||||
This was already executed and successfully generated mappings for all 5 existing recipes.
|
||||
|
||||
### Database Migration
|
||||
The migration runs automatically when deploying the API container. No manual intervention needed.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Reliability**: Mappings are persisted in database, not client-side localStorage
|
||||
2. **Consistency**: Same mappings across all clients/devices
|
||||
3. **Simplicity**: Cooking mode is now read-only with no complex state management
|
||||
4. **User Control**: Dedicated management interface for editing mappings
|
||||
5. **Automatic**: Mappings are generated automatically on recipe import/creation
|
||||
6. **Flexible**: Users can manually adjust mappings if automatic matching is incorrect
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None. This is a new feature with backward compatibility. Existing recipes work without mappings, and the system automatically generates them.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Add UI to manage mappings during recipe import (for recipes with ambiguous matches)
|
||||
- [ ] Add reordering of ingredients within instructions (drag-and-drop reorder)
|
||||
- [ ] Add bulk mapping tools (e.g., "map all ingredients to step 1")
|
||||
- [ ] Add mapping analytics/suggestions based on common patterns
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Cooking Mode (Read-Only)
|
||||
- Clean, distraction-free interface
|
||||
- Ingredients listed inline with each step
|
||||
- No edit controls visible
|
||||
|
||||
### Manage Ingredient Mappings
|
||||
- Drag ingredients from top section to instruction steps
|
||||
- Remove with ✕ button
|
||||
- Save changes button persists to database
|
||||
- Regenerate button re-runs automatic matching
|
||||
|
||||
## Related Issues
|
||||
|
||||
Fixes issues with ingredient mapping:
|
||||
- Ingredients duplicating when dragging
|
||||
- Remove button not working
|
||||
- Changes not persisting across clients
|
||||
- Complex client-side state management
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [x] Database migration created and tested
|
||||
- [x] API tests updated and passing
|
||||
- [x] Frontend tests updated (some pre-existing failures remain)
|
||||
- [x] Manual testing completed
|
||||
- [x] Documentation updated
|
||||
- [x] No breaking changes
|
||||
- [x] Backward compatible
|
||||
@@ -60,8 +60,9 @@ model Ingredient {
|
||||
notes String?
|
||||
order Int
|
||||
|
||||
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
instructions IngredientInstructionMapping[]
|
||||
|
||||
@@index([recipeId])
|
||||
@@index([sectionId])
|
||||
@@ -76,13 +77,28 @@ model Instruction {
|
||||
imageUrl String?
|
||||
timing String? // e.g., "8:00am", "After 30 minutes", "Day 2 - Morning"
|
||||
|
||||
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
ingredients IngredientInstructionMapping[]
|
||||
|
||||
@@index([recipeId])
|
||||
@@index([sectionId])
|
||||
}
|
||||
|
||||
model IngredientInstructionMapping {
|
||||
id String @id @default(cuid())
|
||||
ingredientId String
|
||||
instructionId String
|
||||
order Int // Display order within the instruction
|
||||
|
||||
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
||||
instruction Instruction @relation(fields: [instructionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([ingredientId, instructionId])
|
||||
@@index([instructionId])
|
||||
@@index([ingredientId])
|
||||
}
|
||||
|
||||
model RecipeImage {
|
||||
id String @id @default(cuid())
|
||||
recipeId String
|
||||
|
||||
@@ -26,6 +26,14 @@ vi.mock('../config/database', () => ({
|
||||
recipeTag: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
recipeSection: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
ingredientInstructionMapping: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,6 +46,12 @@ vi.mock('../services/storage.service', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/ingredientMatcher.service', () => ({
|
||||
autoMapIngredients: vi.fn().mockResolvedValue(undefined),
|
||||
generateIngredientMappings: vi.fn().mockResolvedValue([]),
|
||||
saveIngredientMappings: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../services/scraper.service', () => ({
|
||||
ScraperService: vi.fn(() => ({
|
||||
scrapeRecipe: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -3,6 +3,7 @@ import multer from 'multer';
|
||||
import prisma from '../config/database';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
import { ScraperService } from '../services/scraper.service';
|
||||
import { autoMapIngredients, saveIngredientMappings } from '../services/ingredientMatcher.service';
|
||||
import { ApiResponse, RecipeImportRequest } from '@basil/shared';
|
||||
|
||||
const router = Router();
|
||||
@@ -49,12 +50,50 @@ router.get('/', async (req, res) => {
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
ingredients: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
instructions: {
|
||||
include: {
|
||||
instruction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
instructions: {
|
||||
orderBy: { step: 'asc' },
|
||||
include: {
|
||||
ingredients: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ingredient: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ingredients: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
instructions: {
|
||||
include: {
|
||||
instruction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
instructions: {
|
||||
orderBy: { step: 'asc' },
|
||||
include: {
|
||||
ingredients: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ingredient: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
images: { orderBy: { order: 'asc' } },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
@@ -84,12 +123,50 @@ router.get('/:id', async (req, res) => {
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
ingredients: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
instructions: {
|
||||
include: {
|
||||
instruction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
instructions: {
|
||||
orderBy: { step: 'asc' },
|
||||
include: {
|
||||
ingredients: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ingredient: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ingredients: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
instructions: {
|
||||
include: {
|
||||
instruction: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
instructions: {
|
||||
orderBy: { step: 'asc' },
|
||||
include: {
|
||||
ingredients: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ingredient: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
images: { orderBy: { order: 'asc' } },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
@@ -170,6 +247,9 @@ router.post('/', async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Automatically generate ingredient-instruction mappings
|
||||
await autoMapIngredients(recipe.id);
|
||||
|
||||
res.status(201).json({ data: recipe });
|
||||
} catch (error) {
|
||||
console.error('Error creating recipe:', error);
|
||||
@@ -260,6 +340,9 @@ router.put('/:id', async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Regenerate ingredient-instruction mappings
|
||||
await autoMapIngredients(req.params.id);
|
||||
|
||||
res.json({ data: recipe });
|
||||
} catch (error) {
|
||||
console.error('Error updating recipe:', error);
|
||||
@@ -402,4 +485,34 @@ router.post('/import', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update ingredient-instruction mappings
|
||||
router.post('/:id/ingredient-mappings', async (req, res) => {
|
||||
try {
|
||||
const { mappings } = req.body;
|
||||
|
||||
if (!Array.isArray(mappings)) {
|
||||
return res.status(400).json({ error: 'Mappings must be an array' });
|
||||
}
|
||||
|
||||
await saveIngredientMappings(mappings);
|
||||
|
||||
res.json({ message: 'Mappings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating ingredient mappings:', error);
|
||||
res.status(500).json({ error: 'Failed to update ingredient mappings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Regenerate ingredient-instruction mappings
|
||||
router.post('/:id/regenerate-mappings', async (req, res) => {
|
||||
try {
|
||||
await autoMapIngredients(req.params.id);
|
||||
|
||||
res.json({ message: 'Mappings regenerated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error regenerating ingredient mappings:', error);
|
||||
res.status(500).json({ error: 'Failed to regenerate ingredient mappings' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
61
packages/api/src/scripts/regenerate-mappings.ts
Normal file
61
packages/api/src/scripts/regenerate-mappings.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Script to regenerate ingredient-instruction mappings for all existing recipes
|
||||
*/
|
||||
|
||||
import prisma from '../config/database';
|
||||
import { autoMapIngredients } from '../services/ingredientMatcher.service';
|
||||
|
||||
async function regenerateAllMappings() {
|
||||
try {
|
||||
console.log('🌿 Starting ingredient-instruction mapping regeneration...\n');
|
||||
|
||||
// Get all recipes
|
||||
const recipes = await prisma.recipe.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${recipes.length} recipes to process\n`);
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const recipe of recipes) {
|
||||
try {
|
||||
console.log(`Processing: ${recipe.title} (${recipe.id})`);
|
||||
await autoMapIngredients(recipe.id);
|
||||
|
||||
// Get the count of mappings created
|
||||
const mappingCount = await prisma.ingredientInstructionMapping.count({
|
||||
where: {
|
||||
instruction: {
|
||||
recipeId: recipe.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` ✅ Created ${mappingCount} ingredient-instruction mappings\n`);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error processing ${recipe.title}:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(`✅ Successfully processed: ${successCount} recipes`);
|
||||
console.log(`❌ Errors: ${errorCount} recipes`);
|
||||
console.log('\n🌿 Done!');
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
regenerateAllMappings();
|
||||
279
packages/api/src/services/ingredientMatcher.service.test.ts
Normal file
279
packages/api/src/services/ingredientMatcher.service.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { generateIngredientMappings, saveIngredientMappings, autoMapIngredients } from './ingredientMatcher.service';
|
||||
import prisma from '../config/database';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('../config/database', () => ({
|
||||
default: {
|
||||
recipe: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
ingredientInstructionMapping: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('IngredientMatcher Service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('generateIngredientMappings', () => {
|
||||
it('should match ingredients to instructions in simple recipe', async () => {
|
||||
const mockRecipe = {
|
||||
id: 'recipe-1',
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: 'flour', amount: '2', unit: 'cups', order: 0 },
|
||||
{ id: 'ing-2', name: 'sugar', amount: '1', unit: 'cup', order: 1 },
|
||||
{ id: 'ing-3', name: 'eggs', amount: '3', unit: null, order: 2 },
|
||||
],
|
||||
instructions: [
|
||||
{ id: 'inst-1', text: 'Mix flour and sugar together', step: 1 },
|
||||
{ id: 'inst-2', text: 'Add eggs and beat well', step: 2 },
|
||||
],
|
||||
sections: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||
|
||||
const mappings = await generateIngredientMappings('recipe-1');
|
||||
|
||||
expect(mappings).toHaveLength(3);
|
||||
|
||||
// Flour should be in step 1
|
||||
expect(mappings.find(m => m.ingredientId === 'ing-1')).toMatchObject({
|
||||
ingredientId: 'ing-1',
|
||||
instructionId: 'inst-1',
|
||||
order: 0,
|
||||
});
|
||||
|
||||
// Sugar should be in step 1
|
||||
expect(mappings.find(m => m.ingredientId === 'ing-2')).toMatchObject({
|
||||
ingredientId: 'ing-2',
|
||||
instructionId: 'inst-1',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
// Eggs should be in step 2
|
||||
expect(mappings.find(m => m.ingredientId === 'ing-3')).toMatchObject({
|
||||
ingredientId: 'ing-3',
|
||||
instructionId: 'inst-2',
|
||||
order: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ingredients with quantities in names', async () => {
|
||||
const mockRecipe = {
|
||||
id: 'recipe-2',
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: '2 cups all-purpose flour', amount: null, unit: null, order: 0 },
|
||||
],
|
||||
instructions: [
|
||||
{ id: 'inst-1', text: 'Add flour to the bowl', step: 1 },
|
||||
],
|
||||
sections: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||
|
||||
const mappings = await generateIngredientMappings('recipe-2');
|
||||
|
||||
expect(mappings).toHaveLength(1);
|
||||
expect(mappings[0]).toMatchObject({
|
||||
ingredientId: 'ing-1',
|
||||
instructionId: 'inst-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should match plural and singular forms', async () => {
|
||||
const mockRecipe = {
|
||||
id: 'recipe-3',
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: 'apples', amount: '3', unit: null, order: 0 },
|
||||
],
|
||||
instructions: [
|
||||
{ id: 'inst-1', text: 'Peel and slice the apple', step: 1 },
|
||||
],
|
||||
sections: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||
|
||||
const mappings = await generateIngredientMappings('recipe-3');
|
||||
|
||||
expect(mappings).toHaveLength(1);
|
||||
expect(mappings[0].ingredientId).toBe('ing-1');
|
||||
});
|
||||
|
||||
it('should not duplicate ingredients across steps', async () => {
|
||||
const mockRecipe = {
|
||||
id: 'recipe-4',
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: 'flour', amount: '2', unit: 'cups', order: 0 },
|
||||
],
|
||||
instructions: [
|
||||
{ id: 'inst-1', text: 'Mix flour and water', step: 1 },
|
||||
{ id: 'inst-2', text: 'Add more flour if needed', step: 2 },
|
||||
],
|
||||
sections: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||
|
||||
const mappings = await generateIngredientMappings('recipe-4');
|
||||
|
||||
// Flour should only appear once (in first step)
|
||||
expect(mappings).toHaveLength(1);
|
||||
expect(mappings[0].instructionId).toBe('inst-1');
|
||||
});
|
||||
|
||||
it('should not duplicate ingredients with same core name', async () => {
|
||||
const mockRecipe = {
|
||||
id: 'recipe-5',
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: 'sugar', amount: '1', unit: 'tablespoon', order: 0 },
|
||||
{ id: 'ing-2', name: 'sugar', amount: '1/3', unit: 'cup', order: 1 },
|
||||
],
|
||||
instructions: [
|
||||
{ id: 'inst-1', text: 'Mix flour, sugar, baking powder, and salt', step: 1 },
|
||||
],
|
||||
sections: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||
|
||||
const mappings = await generateIngredientMappings('recipe-5');
|
||||
|
||||
// Only first sugar should be matched
|
||||
expect(mappings).toHaveLength(1);
|
||||
expect(mappings[0].ingredientId).toBe('ing-1');
|
||||
});
|
||||
|
||||
it('should handle recipes with sections', async () => {
|
||||
const mockRecipe = {
|
||||
id: 'recipe-6',
|
||||
ingredients: [],
|
||||
instructions: [],
|
||||
sections: [
|
||||
{
|
||||
id: 'section-1',
|
||||
name: 'Dough',
|
||||
order: 0,
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: 'flour', amount: '2', unit: 'cups', order: 0 },
|
||||
],
|
||||
instructions: [
|
||||
{ id: 'inst-1', text: 'Mix flour with water', step: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||
|
||||
const mappings = await generateIngredientMappings('recipe-6');
|
||||
|
||||
expect(mappings).toHaveLength(1);
|
||||
expect(mappings[0]).toMatchObject({
|
||||
ingredientId: 'ing-1',
|
||||
instructionId: 'inst-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should order ingredients by position in instruction text', async () => {
|
||||
const mockRecipe = {
|
||||
id: 'recipe-7',
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: 'water', amount: '1', unit: 'cup', order: 0 },
|
||||
{ id: 'ing-2', name: 'flour', amount: '2', unit: 'cups', order: 1 },
|
||||
{ id: 'ing-3', name: 'salt', amount: '1', unit: 'teaspoon', order: 2 },
|
||||
],
|
||||
instructions: [
|
||||
{ id: 'inst-1', text: 'Mix flour, salt, and water together', step: 1 },
|
||||
],
|
||||
sections: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||
|
||||
const mappings = await generateIngredientMappings('recipe-7');
|
||||
|
||||
expect(mappings).toHaveLength(3);
|
||||
|
||||
// Should be ordered by appearance in text: flour (0), salt (1), water (2)
|
||||
expect(mappings[0].ingredientId).toBe('ing-2'); // flour
|
||||
expect(mappings[0].order).toBe(0);
|
||||
expect(mappings[1].ingredientId).toBe('ing-3'); // salt
|
||||
expect(mappings[1].order).toBe(1);
|
||||
expect(mappings[2].ingredientId).toBe('ing-1'); // water
|
||||
expect(mappings[2].order).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw error if recipe not found', async () => {
|
||||
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(generateIngredientMappings('nonexistent')).rejects.toThrow('Recipe not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveIngredientMappings', () => {
|
||||
it('should delete old mappings and create new ones', async () => {
|
||||
const mappings = [
|
||||
{ ingredientId: 'ing-1', instructionId: 'inst-1', order: 0 },
|
||||
{ ingredientId: 'ing-2', instructionId: 'inst-1', order: 1 },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.ingredientInstructionMapping.deleteMany).mockResolvedValue({ count: 2 } as any);
|
||||
vi.mocked(prisma.ingredientInstructionMapping.createMany).mockResolvedValue({ count: 2 } as any);
|
||||
|
||||
await saveIngredientMappings(mappings);
|
||||
|
||||
expect(prisma.ingredientInstructionMapping.deleteMany).toHaveBeenCalledWith({
|
||||
where: { instructionId: { in: ['inst-1'] } },
|
||||
});
|
||||
|
||||
expect(prisma.ingredientInstructionMapping.createMany).toHaveBeenCalledWith({
|
||||
data: mappings,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty mappings array', async () => {
|
||||
vi.mocked(prisma.ingredientInstructionMapping.deleteMany).mockResolvedValue({ count: 0 } as any);
|
||||
|
||||
await saveIngredientMappings([]);
|
||||
|
||||
expect(prisma.ingredientInstructionMapping.deleteMany).toHaveBeenCalled();
|
||||
expect(prisma.ingredientInstructionMapping.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoMapIngredients', () => {
|
||||
it('should generate and save mappings', async () => {
|
||||
const mockRecipe = {
|
||||
id: 'recipe-1',
|
||||
ingredients: [
|
||||
{ id: 'ing-1', name: 'flour', amount: '2', unit: 'cups', order: 0 },
|
||||
],
|
||||
instructions: [
|
||||
{ id: 'inst-1', text: 'Mix flour with water', step: 1 },
|
||||
],
|
||||
sections: [],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.recipe.findUnique).mockResolvedValue(mockRecipe as any);
|
||||
vi.mocked(prisma.ingredientInstructionMapping.deleteMany).mockResolvedValue({ count: 0 } as any);
|
||||
vi.mocked(prisma.ingredientInstructionMapping.createMany).mockResolvedValue({ count: 1 } as any);
|
||||
|
||||
await autoMapIngredients('recipe-1');
|
||||
|
||||
expect(prisma.recipe.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'recipe-1' },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
expect(prisma.ingredientInstructionMapping.deleteMany).toHaveBeenCalled();
|
||||
expect(prisma.ingredientInstructionMapping.createMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
258
packages/api/src/services/ingredientMatcher.service.ts
Normal file
258
packages/api/src/services/ingredientMatcher.service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Ingredient Matcher Service
|
||||
* Automatically matches ingredients to instructions based on text analysis
|
||||
*/
|
||||
|
||||
import prisma from '../config/database';
|
||||
|
||||
interface IngredientData {
|
||||
id: string;
|
||||
name: string;
|
||||
amount?: string | null;
|
||||
unit?: string | null;
|
||||
}
|
||||
|
||||
interface InstructionData {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common units regex for extracting ingredient names
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match ingredients to instructions for a recipe
|
||||
* Returns array of mappings to create
|
||||
*/
|
||||
export async function generateIngredientMappings(
|
||||
recipeId: string
|
||||
): Promise<Array<{ ingredientId: string; instructionId: string; order: number }>> {
|
||||
// Get all ingredients and instructions for the recipe
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: recipeId },
|
||||
include: {
|
||||
sections: {
|
||||
include: {
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
throw new Error('Recipe not found');
|
||||
}
|
||||
|
||||
const mappings: Array<{ ingredientId: string; instructionId: string; order: number }> = [];
|
||||
const usedIngredientIds = new Set<string>();
|
||||
|
||||
// Process sections if they exist
|
||||
if (recipe.sections && recipe.sections.length > 0) {
|
||||
for (const section of recipe.sections) {
|
||||
const sectionIngredients = section.ingredients;
|
||||
const sectionInstructions = section.instructions;
|
||||
|
||||
for (const instruction of sectionInstructions) {
|
||||
const matches = findIngredientsInInstruction(
|
||||
instruction.text,
|
||||
sectionIngredients,
|
||||
usedIngredientIds
|
||||
);
|
||||
|
||||
matches.forEach((match, index) => {
|
||||
mappings.push({
|
||||
ingredientId: match.ingredientId,
|
||||
instructionId: instruction.id,
|
||||
order: index,
|
||||
});
|
||||
usedIngredientIds.add(match.ingredientId);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Process non-sectioned recipes
|
||||
const ingredients = recipe.ingredients;
|
||||
const instructions = recipe.instructions;
|
||||
|
||||
for (const instruction of instructions) {
|
||||
const matches = findIngredientsInInstruction(
|
||||
instruction.text,
|
||||
ingredients,
|
||||
usedIngredientIds
|
||||
);
|
||||
|
||||
matches.forEach((match, index) => {
|
||||
mappings.push({
|
||||
ingredientId: match.ingredientId,
|
||||
instructionId: instruction.id,
|
||||
order: index,
|
||||
});
|
||||
usedIngredientIds.add(match.ingredientId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ingredients referenced in an instruction step
|
||||
*/
|
||||
function findIngredientsInInstruction(
|
||||
instructionText: string,
|
||||
ingredients: IngredientData[],
|
||||
usedIngredientIds: Set<string>
|
||||
): Array<{ ingredientId: string; position: number }> {
|
||||
const matchesWithPosition: Array<{ ingredientId: string; position: number; coreName: string }> = [];
|
||||
const seenCoreNames = new Set<string>();
|
||||
|
||||
for (const ingredient of ingredients) {
|
||||
// Skip if already used in previous step
|
||||
if (usedIngredientIds.has(ingredient.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract core ingredient name
|
||||
const coreName = extractIngredientName(ingredient.name).toLowerCase();
|
||||
|
||||
// Skip if already matched in this step
|
||||
if (seenCoreNames.has(coreName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find position in instruction text
|
||||
const position = findIngredientPosition(coreName, instructionText);
|
||||
|
||||
if (position >= 0) {
|
||||
seenCoreNames.add(coreName);
|
||||
matchesWithPosition.push({
|
||||
ingredientId: ingredient.id,
|
||||
position,
|
||||
coreName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by position in instruction text
|
||||
matchesWithPosition.sort((a, b) => a.position - b.position);
|
||||
|
||||
return matchesWithPosition.map(({ ingredientId, position }) => ({
|
||||
ingredientId,
|
||||
position,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save ingredient-instruction mappings to database
|
||||
*/
|
||||
export async function saveIngredientMappings(
|
||||
mappings: Array<{ ingredientId: string; instructionId: string; order: number }>
|
||||
): Promise<void> {
|
||||
// Delete existing mappings for these instruction IDs
|
||||
const instructionIds = [...new Set(mappings.map(m => m.instructionId))];
|
||||
await prisma.ingredientInstructionMapping.deleteMany({
|
||||
where: { instructionId: { in: instructionIds } },
|
||||
});
|
||||
|
||||
// Create new mappings
|
||||
if (mappings.length > 0) {
|
||||
await prisma.ingredientInstructionMapping.createMany({
|
||||
data: mappings,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and save mappings for a recipe
|
||||
*/
|
||||
export async function autoMapIngredients(recipeId: string): Promise<void> {
|
||||
const mappings = await generateIngredientMappings(recipeId);
|
||||
await saveIngredientMappings(mappings);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export interface Ingredient {
|
||||
unit?: string;
|
||||
notes?: string;
|
||||
order: number;
|
||||
instructions?: IngredientInstructionMapping[]; // Mapped instructions for this ingredient
|
||||
}
|
||||
|
||||
export interface Instruction {
|
||||
@@ -48,6 +49,16 @@ export interface Instruction {
|
||||
text: string;
|
||||
imageUrl?: string;
|
||||
timing?: string; // e.g., "8:00am", "After 30 minutes", "Day 2 - Morning"
|
||||
ingredients?: IngredientInstructionMapping[]; // Mapped ingredients for this instruction
|
||||
}
|
||||
|
||||
export interface IngredientInstructionMapping {
|
||||
id: string;
|
||||
ingredientId: string;
|
||||
instructionId: string;
|
||||
order: number; // Display order within the instruction
|
||||
ingredient?: Ingredient; // Populated when querying from instruction
|
||||
instruction?: Instruction; // Populated when querying from ingredient
|
||||
}
|
||||
|
||||
export interface RecipeImportRequest {
|
||||
|
||||
@@ -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