feat: add recipe editing, image upload management, and UI improvements
Some checks failed
CI Pipeline / Test Shared Package (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
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
Docker Build & Deploy / Build Docker Images (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
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 / Dependency License Check (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
Security Scanning / Security Summary (pull_request) Has been cancelled
Some checks failed
CI Pipeline / Test Shared Package (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
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
Docker Build & Deploy / Build Docker Images (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
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 / Dependency License Check (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
Security Scanning / Security Summary (pull_request) Has been cancelled
Added comprehensive recipe editing functionality with improved image handling and better UX for large file uploads. **Features:** - Recipe editing: Full CRUD support for recipes with edit page - Image management: Upload, replace, and delete recipe images - Upload feedback: Processing and uploading states for better UX - File size increase: Raised limit from 10MB to 20MB for images - Nginx configuration: Added client_max_body_size for large uploads **Changes:** - Created EditRecipe.tsx page for editing existing recipes - Created NewRecipe.tsx page wrapper for recipe creation - Created RecipeForm.tsx comprehensive form component with: - Simple and multi-section recipe modes - Image upload with confirmation dialogs - Processing state feedback during file handling - Smaller, button-style upload controls - Updated recipes.routes.ts: - Increased multer fileSize limit to 20MB - Added file validation for image types - Image upload now updates Recipe.imageUrl field - Added DELETE /recipes/:id/image endpoint - Automatic cleanup of old images when uploading new ones - Updated nginx.conf: Added client_max_body_size 20M for API proxy - Updated App.css: Improved upload button styling (smaller, more compact) - Updated RecipeForm.tsx: Better file processing feedback with setTimeout - Updated help text to reflect 20MB limit and WEBP support **Technical Details:** - Fixed static file serving path in index.ts - Added detailed logging for upload debugging - Improved TypeScript type safety in upload handlers - Better error handling and user feedback 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ services:
|
||||
POSTGRES_USER: basil
|
||||
POSTGRES_PASSWORD: basil
|
||||
POSTGRES_DB: basil
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
|
||||
8137
package-lock.json
generated
Normal file
8137
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,33 +16,36 @@
|
||||
"prisma:studio": "prisma studio",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"keywords": ["basil", "api"],
|
||||
"keywords": [
|
||||
"basil",
|
||||
"api"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@basil/shared": "^1.0.0",
|
||||
"@prisma/client": "^5.8.0",
|
||||
"express": "^4.18.2",
|
||||
"@prisma/client": "^6.18.0",
|
||||
"axios": "^1.7.9",
|
||||
"cheerio": "^1.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"axios": "^1.6.5",
|
||||
"cheerio": "^1.0.0-rc.12"
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"prisma": "^5.8.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3",
|
||||
"eslint": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"vitest": "^1.2.0",
|
||||
"@vitest/ui": "^1.2.0",
|
||||
"@vitest/coverage-v8": "^1.2.0",
|
||||
"supertest": "^6.3.4"
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"@vitest/ui": "^2.1.8",
|
||||
"eslint": "^9.17.0",
|
||||
"prisma": "^6.18.0",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ model Recipe {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sections RecipeSection[]
|
||||
ingredients Ingredient[]
|
||||
instructions Instruction[]
|
||||
images RecipeImage[]
|
||||
@@ -35,30 +36,51 @@ model Recipe {
|
||||
@@index([category])
|
||||
}
|
||||
|
||||
model Ingredient {
|
||||
model RecipeSection {
|
||||
id String @id @default(cuid())
|
||||
recipeId String
|
||||
name String
|
||||
amount String?
|
||||
unit String?
|
||||
notes String?
|
||||
name String // e.g., "Starter", "Dough", "Assembly"
|
||||
order Int
|
||||
timing String? // e.g., "Day 1 - 8PM", "12 hours before mixing"
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
ingredients Ingredient[]
|
||||
instructions Instruction[]
|
||||
|
||||
@@index([recipeId])
|
||||
}
|
||||
|
||||
model Instruction {
|
||||
id String @id @default(cuid())
|
||||
recipeId String
|
||||
step Int
|
||||
text String @db.Text
|
||||
imageUrl String?
|
||||
model Ingredient {
|
||||
id String @id @default(cuid())
|
||||
recipeId String? // Optional - can be derived from section
|
||||
sectionId String? // Optional - if null, belongs to recipe directly
|
||||
name String
|
||||
amount String?
|
||||
unit String?
|
||||
notes String?
|
||||
order Int
|
||||
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([recipeId])
|
||||
@@index([sectionId])
|
||||
}
|
||||
|
||||
model Instruction {
|
||||
id String @id @default(cuid())
|
||||
recipeId String? // Optional - can be derived from section
|
||||
sectionId String? // Optional - if null, belongs to recipe directly
|
||||
step Int
|
||||
text String @db.Text
|
||||
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)
|
||||
|
||||
@@index([recipeId])
|
||||
@@index([sectionId])
|
||||
}
|
||||
|
||||
model RecipeImage {
|
||||
|
||||
@@ -17,7 +17,8 @@ app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Serve uploaded files
|
||||
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
|
||||
const uploadsPath = process.env.LOCAL_STORAGE_PATH || path.join(__dirname, '../../uploads');
|
||||
app.use('/uploads', express.static(uploadsPath));
|
||||
|
||||
// Routes
|
||||
app.use('/api/recipes', recipesRoutes);
|
||||
|
||||
@@ -6,7 +6,19 @@ import { ScraperService } from '../services/scraper.service';
|
||||
import { ApiResponse, RecipeImportRequest } from '@basil/shared';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024, // 20MB limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images only
|
||||
if (!file.originalname.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
||||
return cb(new Error('Only image files are allowed!'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
const storageService = StorageService.getInstance();
|
||||
const scraperService = new ScraperService();
|
||||
|
||||
@@ -34,6 +46,13 @@ router.get('/', async (req, res) => {
|
||||
skip,
|
||||
take: limitNum,
|
||||
include: {
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
},
|
||||
},
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
images: { orderBy: { order: 'asc' } },
|
||||
@@ -62,6 +81,13 @@ router.get('/:id', async (req, res) => {
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
sections: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
},
|
||||
},
|
||||
ingredients: { orderBy: { order: 'asc' } },
|
||||
instructions: { orderBy: { step: 'asc' } },
|
||||
images: { orderBy: { order: 'asc' } },
|
||||
@@ -83,13 +109,31 @@ router.get('/:id', async (req, res) => {
|
||||
// Create recipe
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { title, description, ingredients, instructions, tags, ...recipeData } = req.body;
|
||||
const { title, description, sections, ingredients, instructions, tags, ...recipeData } = req.body;
|
||||
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: {
|
||||
title,
|
||||
description,
|
||||
...recipeData,
|
||||
sections: sections
|
||||
? {
|
||||
create: sections.map((section: any) => ({
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
timing: section.timing,
|
||||
ingredients: {
|
||||
create: section.ingredients?.map((ing: any, index: number) => ({
|
||||
...ing,
|
||||
order: ing.order ?? index,
|
||||
})),
|
||||
},
|
||||
instructions: {
|
||||
create: section.instructions?.map((inst: any) => inst),
|
||||
},
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
ingredients: {
|
||||
create: ingredients?.map((ing: any, index: number) => ({
|
||||
...ing,
|
||||
@@ -113,6 +157,12 @@ router.post('/', async (req, res) => {
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
sections: {
|
||||
include: {
|
||||
ingredients: true,
|
||||
instructions: true,
|
||||
},
|
||||
},
|
||||
ingredients: true,
|
||||
instructions: true,
|
||||
images: true,
|
||||
@@ -130,26 +180,59 @@ router.post('/', async (req, res) => {
|
||||
// Update recipe
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { ingredients, instructions, tags, ...recipeData } = req.body;
|
||||
const { sections, ingredients, instructions, tags, ...recipeData } = req.body;
|
||||
|
||||
// Delete existing relations
|
||||
await prisma.recipeSection.deleteMany({ where: { recipeId: req.params.id } });
|
||||
await prisma.ingredient.deleteMany({ where: { recipeId: req.params.id } });
|
||||
await prisma.instruction.deleteMany({ where: { recipeId: req.params.id } });
|
||||
await prisma.recipeTag.deleteMany({ where: { recipeId: req.params.id } });
|
||||
|
||||
// Helper to clean IDs from nested data
|
||||
const cleanIngredient = (ing: any, index: number) => ({
|
||||
name: ing.name,
|
||||
amount: ing.amount,
|
||||
unit: ing.unit,
|
||||
notes: ing.notes,
|
||||
order: ing.order ?? index,
|
||||
});
|
||||
|
||||
const cleanInstruction = (inst: any) => ({
|
||||
step: inst.step,
|
||||
text: inst.text,
|
||||
imageUrl: inst.imageUrl,
|
||||
timing: inst.timing,
|
||||
});
|
||||
|
||||
const recipe = await prisma.recipe.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...recipeData,
|
||||
ingredients: ingredients
|
||||
sections: sections
|
||||
? {
|
||||
create: ingredients.map((ing: any, index: number) => ({
|
||||
...ing,
|
||||
order: ing.order ?? index,
|
||||
create: sections.map((section: any) => ({
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
timing: section.timing,
|
||||
ingredients: {
|
||||
create: section.ingredients?.map(cleanIngredient) || [],
|
||||
},
|
||||
instructions: {
|
||||
create: section.instructions?.map(cleanInstruction) || [],
|
||||
},
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
instructions: instructions ? { create: instructions } : undefined,
|
||||
ingredients: ingredients
|
||||
? {
|
||||
create: ingredients.map(cleanIngredient),
|
||||
}
|
||||
: undefined,
|
||||
instructions: instructions
|
||||
? {
|
||||
create: instructions.map(cleanInstruction),
|
||||
}
|
||||
: undefined,
|
||||
tags: tags
|
||||
? {
|
||||
create: tags.map((tagName: string) => ({
|
||||
@@ -164,6 +247,12 @@ router.put('/:id', async (req, res) => {
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
sections: {
|
||||
include: {
|
||||
ingredients: true,
|
||||
instructions: true,
|
||||
},
|
||||
},
|
||||
ingredients: true,
|
||||
instructions: true,
|
||||
images: true,
|
||||
@@ -209,25 +298,85 @@ router.delete('/:id', async (req, res) => {
|
||||
// Upload image
|
||||
router.post('/:id/images', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
console.log('Image upload request received for recipe:', req.params.id);
|
||||
console.log('File info:', req.file ? {
|
||||
originalname: req.file.originalname,
|
||||
mimetype: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
} : 'No file');
|
||||
|
||||
if (!req.file) {
|
||||
console.error('No file in request');
|
||||
return res.status(400).json({ error: 'No image provided' });
|
||||
}
|
||||
|
||||
console.log('Saving file to storage...');
|
||||
const imageUrl = await storageService.saveFile(req.file, 'recipes');
|
||||
console.log('File saved, URL:', imageUrl);
|
||||
|
||||
// Add to recipe images
|
||||
const image = await prisma.recipeImage.create({
|
||||
data: {
|
||||
recipeId: req.params.id,
|
||||
url: imageUrl,
|
||||
order: 0,
|
||||
},
|
||||
// Get existing recipe to delete old image
|
||||
const existingRecipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { imageUrl: true },
|
||||
});
|
||||
|
||||
res.json({ data: image });
|
||||
// Delete old image from storage if it exists
|
||||
if (existingRecipe?.imageUrl) {
|
||||
console.log('Deleting old image:', existingRecipe.imageUrl);
|
||||
await storageService.deleteFile(existingRecipe.imageUrl);
|
||||
}
|
||||
|
||||
console.log('Updating database...');
|
||||
// Add to recipe images and update main imageUrl
|
||||
const [image, recipe] = await Promise.all([
|
||||
prisma.recipeImage.create({
|
||||
data: {
|
||||
recipeId: req.params.id,
|
||||
url: imageUrl,
|
||||
order: 0,
|
||||
},
|
||||
}),
|
||||
prisma.recipe.update({
|
||||
where: { id: req.params.id },
|
||||
data: { imageUrl },
|
||||
}),
|
||||
]);
|
||||
|
||||
console.log('Image upload successful');
|
||||
res.json({ data: { image, imageUrl } });
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
console.error('Error stack:', error instanceof Error ? error.stack : 'No stack');
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to upload image';
|
||||
res.status(500).json({ error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete recipe image
|
||||
router.delete('/:id/image', async (req, res) => {
|
||||
try {
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { imageUrl: true },
|
||||
});
|
||||
|
||||
if (!recipe?.imageUrl) {
|
||||
return res.status(404).json({ error: 'No image to delete' });
|
||||
}
|
||||
|
||||
// Delete image from storage
|
||||
await storageService.deleteFile(recipe.imageUrl);
|
||||
|
||||
// Update recipe to remove imageUrl
|
||||
await prisma.recipe.update({
|
||||
where: { id: req.params.id },
|
||||
data: { imageUrl: null },
|
||||
});
|
||||
|
||||
res.json({ message: 'Image deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting image:', error);
|
||||
res.status(500).json({ error: 'Failed to delete image' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface Recipe {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
sections?: RecipeSection[]; // Optional sections for multi-part recipes
|
||||
ingredients: Ingredient[];
|
||||
instructions: Instruction[];
|
||||
prepTime?: number; // minutes
|
||||
@@ -20,8 +21,19 @@ export interface Recipe {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface RecipeSection {
|
||||
id?: string;
|
||||
recipeId?: string;
|
||||
name: string; // e.g., "Starter", "Dough", "Assembly"
|
||||
order: number;
|
||||
timing?: string; // e.g., "Day 1 - 8PM", "12 hours before mixing"
|
||||
ingredients: Ingredient[];
|
||||
instructions: Instruction[];
|
||||
}
|
||||
|
||||
export interface Ingredient {
|
||||
id?: string;
|
||||
sectionId?: string; // Optional - if present, belongs to a section
|
||||
name: string;
|
||||
amount?: string;
|
||||
unit?: string;
|
||||
@@ -31,9 +43,11 @@ export interface Ingredient {
|
||||
|
||||
export interface Instruction {
|
||||
id?: string;
|
||||
sectionId?: string; // Optional - if present, belongs to a section
|
||||
step: number;
|
||||
text: string;
|
||||
imageUrl?: string;
|
||||
timing?: string; // e.g., "8:00am", "After 30 minutes", "Day 2 - Morning"
|
||||
}
|
||||
|
||||
export interface RecipeImportRequest {
|
||||
|
||||
@@ -19,6 +19,7 @@ server {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
client_max_body_size 20M;
|
||||
}
|
||||
|
||||
# Proxy uploads requests to backend
|
||||
|
||||
@@ -118,6 +118,20 @@ nav a:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.recipe-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.recipe-actions-right {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.recipe-detail img {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
@@ -190,6 +204,53 @@ nav a:hover {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipe-sections {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.recipe-section {
|
||||
margin-bottom: 3rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2d5016;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
color: #2d5016;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-timing {
|
||||
background-color: #2d5016;
|
||||
color: white;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.instruction-timing {
|
||||
color: #2d5016;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.recipe-section h4 {
|
||||
color: #2d5016;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.ingredients, .instructions {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -276,3 +337,273 @@ button:disabled {
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Recipe Form Styles */
|
||||
.recipe-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sections-container {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.section-form {
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2d5016;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-form-header h4 {
|
||||
margin: 0;
|
||||
color: #2d5016;
|
||||
}
|
||||
|
||||
.subsection {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.subsection h5 {
|
||||
color: #2d5016;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ingredient-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ingredient-row .input-small {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.ingredient-row .input-flex {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.instruction-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.instruction-number {
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
background-color: #2d5016;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.instruction-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.instruction-timing-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #666;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background-color: #d32f2f;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
|
||||
.btn-danger-small {
|
||||
background-color: #d32f2f;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-danger-small:hover {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #eee;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Image Upload Styles */
|
||||
.image-upload-section {
|
||||
padding: 1.5rem;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.current-image {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.current-image img {
|
||||
max-width: 300px;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.image-note {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-delete-image {
|
||||
background-color: #d32f2f;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-delete-image:hover:not(:disabled) {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
|
||||
.btn-delete-image:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.image-upload-control {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-input-label {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #a8d08d;
|
||||
color: #1a3a0d;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.file-input-label:hover {
|
||||
background-color: #8cc269;
|
||||
color: #0f2408;
|
||||
}
|
||||
|
||||
.file-input:disabled + .file-input-label {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.image-help-text {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.info-note {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
border-left: 4px solid #1976d2;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import RecipeList from './pages/RecipeList';
|
||||
import RecipeDetail from './pages/RecipeDetail';
|
||||
import RecipeImport from './pages/RecipeImport';
|
||||
import NewRecipe from './pages/NewRecipe';
|
||||
import EditRecipe from './pages/EditRecipe';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@@ -13,6 +15,7 @@ function App() {
|
||||
<h1 className="logo">🌿 Basil</h1>
|
||||
<nav>
|
||||
<Link to="/">Recipes</Link>
|
||||
<Link to="/new">New Recipe</Link>
|
||||
<Link to="/import">Import Recipe</Link>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -23,6 +26,8 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<RecipeList />} />
|
||||
<Route path="/recipes/:id" element={<RecipeDetail />} />
|
||||
<Route path="/recipes/:id/edit" element={<EditRecipe />} />
|
||||
<Route path="/new" element={<NewRecipe />} />
|
||||
<Route path="/import" element={<RecipeImport />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
71
packages/web/src/pages/EditRecipe.tsx
Normal file
71
packages/web/src/pages/EditRecipe.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Recipe } from '@basil/shared';
|
||||
import RecipeForm from './RecipeForm';
|
||||
import { recipesApi } from '../services/api';
|
||||
|
||||
function EditRecipe() {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadRecipe(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadRecipe = async (recipeId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await recipesApi.getById(recipeId);
|
||||
setRecipe(response.data || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load recipe');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (updatedRecipe: Partial<Recipe>) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const response = await recipesApi.update(id, updatedRecipe);
|
||||
if (response.data) {
|
||||
navigate(`/recipes/${response.data.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update recipe:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (id) {
|
||||
navigate(`/recipes/${id}`);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading recipe...</div>;
|
||||
}
|
||||
|
||||
if (error || !recipe) {
|
||||
return <div className="error">{error || 'Recipe not found'}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RecipeForm initialRecipe={recipe} onSubmit={handleSubmit} onCancel={handleCancel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditRecipe;
|
||||
32
packages/web/src/pages/NewRecipe.tsx
Normal file
32
packages/web/src/pages/NewRecipe.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Recipe } from '@basil/shared';
|
||||
import RecipeForm from './RecipeForm';
|
||||
import { recipesApi } from '../services/api';
|
||||
|
||||
function NewRecipe() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (recipe: Partial<Recipe>) => {
|
||||
try {
|
||||
const response = await recipesApi.create(recipe);
|
||||
if (response.data) {
|
||||
navigate(`/recipes/${response.data.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create recipe:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RecipeForm onSubmit={handleSubmit} onCancel={handleCancel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewRecipe;
|
||||
@@ -78,10 +78,15 @@ function RecipeDetail() {
|
||||
|
||||
return (
|
||||
<div className="recipe-detail">
|
||||
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
||||
<button onClick={handleDelete} style={{ marginLeft: '1rem', backgroundColor: '#d32f2f' }}>
|
||||
Delete Recipe
|
||||
</button>
|
||||
<div className="recipe-actions">
|
||||
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
||||
<div className="recipe-actions-right">
|
||||
<button onClick={() => navigate(`/recipes/${id}/edit`)}>Edit Recipe</button>
|
||||
<button onClick={handleDelete} style={{ backgroundColor: '#d32f2f' }}>
|
||||
Delete Recipe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipe.imageUrl && <img src={recipe.imageUrl} alt={recipe.title} />}
|
||||
|
||||
@@ -120,47 +125,113 @@ function RecipeDetail() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{recipe.ingredients && recipe.ingredients.length > 0 && (
|
||||
<div className="ingredients">
|
||||
<h3>Ingredients</h3>
|
||||
<ul>
|
||||
{recipe.ingredients.map((ingredient, index) => {
|
||||
// Construct the full 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;
|
||||
}
|
||||
{/* Display sections if they exist */}
|
||||
{recipe.sections && recipe.sections.length > 0 ? (
|
||||
<div className="recipe-sections">
|
||||
{recipe.sections.map((section) => (
|
||||
<div key={section.id} className="recipe-section">
|
||||
<div className="section-header">
|
||||
<h3>{section.name}</h3>
|
||||
{section.timing && <span className="section-timing">{section.timing}</span>}
|
||||
</div>
|
||||
|
||||
// Apply scaling if servings changed
|
||||
const displayStr =
|
||||
recipe.servings && currentServings && recipe.servings !== currentServings
|
||||
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||||
: ingredientStr;
|
||||
{section.ingredients && section.ingredients.length > 0 && (
|
||||
<div className="ingredients">
|
||||
<h4>Ingredients</h4>
|
||||
<ul>
|
||||
{section.ingredients.map((ingredient, index) => {
|
||||
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 (
|
||||
<li key={index}>
|
||||
{displayStr}
|
||||
{ingredient.notes && ` (${ingredient.notes})`}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
const displayStr =
|
||||
recipe.servings && currentServings && recipe.servings !== currentServings
|
||||
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||||
: ingredientStr;
|
||||
|
||||
return (
|
||||
<li key={index}>
|
||||
{displayStr}
|
||||
{ingredient.notes && ` (${ingredient.notes})`}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section.instructions && section.instructions.length > 0 && (
|
||||
<div className="instructions">
|
||||
<h4>Instructions</h4>
|
||||
<ol>
|
||||
{section.instructions.map((instruction) => (
|
||||
<li key={instruction.step}>
|
||||
{instruction.timing && (
|
||||
<strong className="instruction-timing">{instruction.timing}: </strong>
|
||||
)}
|
||||
{instruction.text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{/* Display regular ingredients and instructions if no sections */}
|
||||
{recipe.ingredients && recipe.ingredients.length > 0 && (
|
||||
<div className="ingredients">
|
||||
<h3>Ingredients</h3>
|
||||
<ul>
|
||||
{recipe.ingredients.map((ingredient, index) => {
|
||||
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;
|
||||
}
|
||||
|
||||
{recipe.instructions && recipe.instructions.length > 0 && (
|
||||
<div className="instructions">
|
||||
<h3>Instructions</h3>
|
||||
<ol>
|
||||
{recipe.instructions.map((instruction) => (
|
||||
<li key={instruction.step}>{instruction.text}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
const displayStr =
|
||||
recipe.servings && currentServings && recipe.servings !== currentServings
|
||||
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||||
: ingredientStr;
|
||||
|
||||
return (
|
||||
<li key={index}>
|
||||
{displayStr}
|
||||
{ingredient.notes && ` (${ingredient.notes})`}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.instructions && recipe.instructions.length > 0 && (
|
||||
<div className="instructions">
|
||||
<h3>Instructions</h3>
|
||||
<ol>
|
||||
{recipe.instructions.map((instruction) => (
|
||||
<li key={instruction.step}>
|
||||
{instruction.timing && (
|
||||
<strong className="instruction-timing">{instruction.timing}: </strong>
|
||||
)}
|
||||
{instruction.text}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
678
packages/web/src/pages/RecipeForm.tsx
Normal file
678
packages/web/src/pages/RecipeForm.tsx
Normal file
@@ -0,0 +1,678 @@
|
||||
import { useState } from 'react';
|
||||
import { Recipe, RecipeSection, Ingredient, Instruction } from '@basil/shared';
|
||||
import { recipesApi } from '../services/api';
|
||||
|
||||
interface RecipeFormProps {
|
||||
initialRecipe?: Partial<Recipe>;
|
||||
onSubmit: (recipe: Partial<Recipe>) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
|
||||
// Basic recipe fields
|
||||
const [title, setTitle] = useState(initialRecipe?.title || '');
|
||||
const [description, setDescription] = useState(initialRecipe?.description || '');
|
||||
const [prepTime, setPrepTime] = useState(initialRecipe?.prepTime?.toString() || '');
|
||||
const [cookTime, setCookTime] = useState(initialRecipe?.cookTime?.toString() || '');
|
||||
const [servings, setServings] = useState(initialRecipe?.servings?.toString() || '');
|
||||
const [cuisine, setCuisine] = useState(initialRecipe?.cuisine || '');
|
||||
const [category, setCategory] = useState(initialRecipe?.category || '');
|
||||
|
||||
// Image handling
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [processingImage, setProcessingImage] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
|
||||
// Section mode toggle
|
||||
const [useSections, setUseSections] = useState(
|
||||
(initialRecipe?.sections && initialRecipe.sections.length > 0) || false
|
||||
);
|
||||
|
||||
// Sections
|
||||
const [sections, setSections] = useState<RecipeSection[]>(
|
||||
initialRecipe?.sections || [
|
||||
{
|
||||
name: '',
|
||||
order: 0,
|
||||
timing: '',
|
||||
ingredients: [],
|
||||
instructions: [],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Simple mode (no sections)
|
||||
const [ingredients, setIngredients] = useState<Ingredient[]>(
|
||||
initialRecipe?.ingredients || [{ name: '', amount: '', unit: '', order: 0 }]
|
||||
);
|
||||
|
||||
const [instructions, setInstructions] = useState<Instruction[]>(
|
||||
initialRecipe?.instructions || [{ step: 1, text: '', timing: '' }]
|
||||
);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Section management
|
||||
const addSection = () => {
|
||||
setSections([
|
||||
...sections,
|
||||
{
|
||||
name: '',
|
||||
order: sections.length,
|
||||
timing: '',
|
||||
ingredients: [{ name: '', amount: '', unit: '', order: 0 }],
|
||||
instructions: [{ step: 1, text: '', timing: '' }],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeSection = (index: number) => {
|
||||
const newSections = sections.filter((_, i) => i !== index);
|
||||
setSections(newSections.map((s, i) => ({ ...s, order: i })));
|
||||
};
|
||||
|
||||
const updateSection = (index: number, field: keyof RecipeSection, value: any) => {
|
||||
const newSections = [...sections];
|
||||
newSections[index] = { ...newSections[index], [field]: value };
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
// Section ingredient management
|
||||
const addSectionIngredient = (sectionIndex: number) => {
|
||||
const newSections = [...sections];
|
||||
const ingredients = newSections[sectionIndex].ingredients;
|
||||
ingredients.push({
|
||||
name: '',
|
||||
amount: '',
|
||||
unit: '',
|
||||
order: ingredients.length,
|
||||
});
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
const removeSectionIngredient = (sectionIndex: number, ingredientIndex: number) => {
|
||||
const newSections = [...sections];
|
||||
newSections[sectionIndex].ingredients = newSections[sectionIndex].ingredients.filter(
|
||||
(_, i) => i !== ingredientIndex
|
||||
);
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
const updateSectionIngredient = (
|
||||
sectionIndex: number,
|
||||
ingredientIndex: number,
|
||||
field: keyof Ingredient,
|
||||
value: string
|
||||
) => {
|
||||
const newSections = [...sections];
|
||||
newSections[sectionIndex].ingredients[ingredientIndex] = {
|
||||
...newSections[sectionIndex].ingredients[ingredientIndex],
|
||||
[field]: value,
|
||||
};
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
// Section instruction management
|
||||
const addSectionInstruction = (sectionIndex: number) => {
|
||||
const newSections = [...sections];
|
||||
const instructions = newSections[sectionIndex].instructions;
|
||||
instructions.push({
|
||||
step: instructions.length + 1,
|
||||
text: '',
|
||||
timing: '',
|
||||
});
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
const removeSectionInstruction = (sectionIndex: number, instructionIndex: number) => {
|
||||
const newSections = [...sections];
|
||||
newSections[sectionIndex].instructions = newSections[sectionIndex].instructions
|
||||
.filter((_, i) => i !== instructionIndex)
|
||||
.map((inst, i) => ({ ...inst, step: i + 1 }));
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
const updateSectionInstruction = (
|
||||
sectionIndex: number,
|
||||
instructionIndex: number,
|
||||
field: keyof Instruction,
|
||||
value: string | number
|
||||
) => {
|
||||
const newSections = [...sections];
|
||||
newSections[sectionIndex].instructions[instructionIndex] = {
|
||||
...newSections[sectionIndex].instructions[instructionIndex],
|
||||
[field]: value,
|
||||
};
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
// Simple mode ingredient management
|
||||
const addIngredient = () => {
|
||||
setIngredients([
|
||||
...ingredients,
|
||||
{ name: '', amount: '', unit: '', order: ingredients.length },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeIngredient = (index: number) => {
|
||||
setIngredients(ingredients.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateIngredient = (index: number, field: keyof Ingredient, value: string) => {
|
||||
const newIngredients = [...ingredients];
|
||||
newIngredients[index] = { ...newIngredients[index], [field]: value };
|
||||
setIngredients(newIngredients);
|
||||
};
|
||||
|
||||
// Simple mode instruction management
|
||||
const addInstruction = () => {
|
||||
setInstructions([
|
||||
...instructions,
|
||||
{ step: instructions.length + 1, text: '', timing: '' },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeInstruction = (index: number) => {
|
||||
setInstructions(
|
||||
instructions.filter((_, i) => i !== index).map((inst, i) => ({ ...inst, step: i + 1 }))
|
||||
);
|
||||
};
|
||||
|
||||
const updateInstruction = (index: number, field: keyof Instruction, value: string | number) => {
|
||||
const newInstructions = [...instructions];
|
||||
newInstructions[index] = { ...newInstructions[index], [field]: value };
|
||||
setInstructions(newInstructions);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !initialRecipe?.id) return;
|
||||
|
||||
const recipeId = initialRecipe.id;
|
||||
const hasExistingImage = !!initialRecipe.imageUrl;
|
||||
|
||||
// Show processing state immediately
|
||||
setProcessingImage(true);
|
||||
setImageError(null);
|
||||
|
||||
// Use setTimeout to allow UI to update before showing confirm dialog
|
||||
setTimeout(async () => {
|
||||
// Confirm replacement if image already exists
|
||||
if (hasExistingImage) {
|
||||
const confirmed = window.confirm(
|
||||
'This will replace the current recipe image. Are you sure you want to continue?'
|
||||
);
|
||||
if (!confirmed) {
|
||||
e.target.value = ''; // Reset file input
|
||||
setProcessingImage(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
setProcessingImage(false);
|
||||
await recipesApi.uploadImage(recipeId, file);
|
||||
// Reload the page to show the new image
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
setImageError('Failed to upload image. Please try again.');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
setProcessingImage(false);
|
||||
e.target.value = ''; // Reset file input
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const handleImageDelete = async () => {
|
||||
if (!initialRecipe?.id || !initialRecipe?.imageUrl) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to delete this image? This action cannot be undone.'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
setImageError(null);
|
||||
await recipesApi.deleteImage(initialRecipe.id);
|
||||
// Reload the page to show the change
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete image:', error);
|
||||
setImageError('Failed to delete image. Please try again.');
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const recipe: Partial<Recipe> = {
|
||||
title,
|
||||
description: description || undefined,
|
||||
prepTime: prepTime ? parseInt(prepTime) : undefined,
|
||||
cookTime: cookTime ? parseInt(cookTime) : undefined,
|
||||
servings: servings ? parseInt(servings) : undefined,
|
||||
cuisine: cuisine || undefined,
|
||||
category: category || undefined,
|
||||
};
|
||||
|
||||
if (useSections) {
|
||||
recipe.sections = sections.filter((s) => s.name.trim() !== '');
|
||||
recipe.ingredients = [];
|
||||
recipe.instructions = [];
|
||||
} else {
|
||||
recipe.ingredients = ingredients.filter((i) => i.name.trim() !== '');
|
||||
recipe.instructions = instructions.filter((i) => i.text.trim() !== '');
|
||||
recipe.sections = [];
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await onSubmit(recipe);
|
||||
} catch (error) {
|
||||
console.error('Failed to submit recipe:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="recipe-form">
|
||||
<h2>{initialRecipe?.id ? 'Edit Recipe' : 'New Recipe'}</h2>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="title">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="prepTime">Prep Time (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="prepTime"
|
||||
value={prepTime}
|
||||
onChange={(e) => setPrepTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cookTime">Cook Time (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
id="cookTime"
|
||||
value={cookTime}
|
||||
onChange={(e) => setCookTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="servings">Servings</label>
|
||||
<input
|
||||
type="number"
|
||||
id="servings"
|
||||
value={servings}
|
||||
onChange={(e) => setServings(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="cuisine">Cuisine</label>
|
||||
<input
|
||||
type="text"
|
||||
id="cuisine"
|
||||
value={cuisine}
|
||||
onChange={(e) => setCuisine(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="category">Category</label>
|
||||
<input
|
||||
type="text"
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload (only for editing existing recipes) */}
|
||||
{initialRecipe?.id && (
|
||||
<div className="form-group image-upload-section">
|
||||
<label>Recipe Images</label>
|
||||
|
||||
{/* Display existing images */}
|
||||
{initialRecipe.imageUrl && (
|
||||
<div className="current-image">
|
||||
<img src={initialRecipe.imageUrl} alt="Recipe" />
|
||||
<div className="image-actions">
|
||||
<p className="image-note">Current main image</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImageDelete}
|
||||
disabled={uploadingImage || processingImage}
|
||||
className="btn-delete-image"
|
||||
>
|
||||
Delete Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload new image */}
|
||||
<div className="image-upload-control">
|
||||
<input
|
||||
type="file"
|
||||
id="imageUpload"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploadingImage || processingImage}
|
||||
className="file-input"
|
||||
/>
|
||||
<label htmlFor="imageUpload" className="file-input-label">
|
||||
{processingImage ? 'Processing...' : uploadingImage ? 'Uploading...' : initialRecipe.imageUrl ? 'Replace Image' : 'Upload Image'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{imageError && <div className="error">{imageError}</div>}
|
||||
|
||||
<p className="image-help-text">
|
||||
Supported formats: JPG, PNG, GIF, WEBP. Max size: 20MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!initialRecipe?.id && (
|
||||
<div className="form-group">
|
||||
<p className="info-note">
|
||||
Note: Images can be added after creating the recipe
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section Mode Toggle */}
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useSections}
|
||||
onChange={(e) => setUseSections(e.target.checked)}
|
||||
/>
|
||||
<span>Multi-section recipe (for recipes with multiple parts like sourdough)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Sections Mode */}
|
||||
{useSections ? (
|
||||
<div className="sections-container">
|
||||
<h3>Recipe Sections</h3>
|
||||
|
||||
{sections.map((section, sectionIndex) => (
|
||||
<div key={sectionIndex} className="section-form">
|
||||
<div className="section-form-header">
|
||||
<h4>Section {sectionIndex + 1}</h4>
|
||||
{sections.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSection(sectionIndex)}
|
||||
className="btn-danger-small"
|
||||
>
|
||||
Remove Section
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Section Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={section.name}
|
||||
onChange={(e) => updateSection(sectionIndex, 'name', e.target.value)}
|
||||
placeholder="e.g., Starter, Dough, Assembly"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Section Timing (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={section.timing}
|
||||
onChange={(e) => updateSection(sectionIndex, 'timing', e.target.value)}
|
||||
placeholder="e.g., Day 1 - 8PM, 12 hours before mixing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Section Ingredients */}
|
||||
<div className="subsection">
|
||||
<h5>Ingredients</h5>
|
||||
{section.ingredients.map((ingredient, ingredientIndex) => (
|
||||
<div key={ingredientIndex} className="ingredient-row">
|
||||
<input
|
||||
type="text"
|
||||
value={ingredient.amount}
|
||||
onChange={(e) =>
|
||||
updateSectionIngredient(sectionIndex, ingredientIndex, 'amount', e.target.value)
|
||||
}
|
||||
placeholder="Amount"
|
||||
className="input-small"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={ingredient.unit}
|
||||
onChange={(e) =>
|
||||
updateSectionIngredient(sectionIndex, ingredientIndex, 'unit', e.target.value)
|
||||
}
|
||||
placeholder="Unit"
|
||||
className="input-small"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={ingredient.name}
|
||||
onChange={(e) =>
|
||||
updateSectionIngredient(sectionIndex, ingredientIndex, 'name', e.target.value)
|
||||
}
|
||||
placeholder="Ingredient name *"
|
||||
className="input-flex"
|
||||
required
|
||||
/>
|
||||
{section.ingredients.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSectionIngredient(sectionIndex, ingredientIndex)}
|
||||
className="btn-remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addSectionIngredient(sectionIndex)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
+ Add Ingredient
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section Instructions */}
|
||||
<div className="subsection">
|
||||
<h5>Instructions</h5>
|
||||
{section.instructions.map((instruction, instructionIndex) => (
|
||||
<div key={instructionIndex} className="instruction-row">
|
||||
<div className="instruction-number">{instruction.step}</div>
|
||||
<div className="instruction-content">
|
||||
<input
|
||||
type="text"
|
||||
value={instruction.timing}
|
||||
onChange={(e) =>
|
||||
updateSectionInstruction(
|
||||
sectionIndex,
|
||||
instructionIndex,
|
||||
'timing',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Timing (optional, e.g., 8:00am)"
|
||||
className="instruction-timing-input"
|
||||
/>
|
||||
<textarea
|
||||
value={instruction.text}
|
||||
onChange={(e) =>
|
||||
updateSectionInstruction(
|
||||
sectionIndex,
|
||||
instructionIndex,
|
||||
'text',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Instruction text *"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{section.instructions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSectionInstruction(sectionIndex, instructionIndex)}
|
||||
className="btn-remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addSectionInstruction(sectionIndex)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
+ Add Instruction
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" onClick={addSection} className="btn-secondary">
|
||||
+ Add Section
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Simple Mode */
|
||||
<>
|
||||
{/* Ingredients */}
|
||||
<div className="form-section">
|
||||
<h3>Ingredients</h3>
|
||||
{ingredients.map((ingredient, index) => (
|
||||
<div key={index} className="ingredient-row">
|
||||
<input
|
||||
type="text"
|
||||
value={ingredient.amount}
|
||||
onChange={(e) => updateIngredient(index, 'amount', e.target.value)}
|
||||
placeholder="Amount"
|
||||
className="input-small"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={ingredient.unit}
|
||||
onChange={(e) => updateIngredient(index, 'unit', e.target.value)}
|
||||
placeholder="Unit"
|
||||
className="input-small"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={ingredient.name}
|
||||
onChange={(e) => updateIngredient(index, 'name', e.target.value)}
|
||||
placeholder="Ingredient name *"
|
||||
className="input-flex"
|
||||
required
|
||||
/>
|
||||
{ingredients.length > 1 && (
|
||||
<button type="button" onClick={() => removeIngredient(index)} className="btn-remove">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addIngredient} className="btn-secondary">
|
||||
+ Add Ingredient
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="form-section">
|
||||
<h3>Instructions</h3>
|
||||
{instructions.map((instruction, index) => (
|
||||
<div key={index} className="instruction-row">
|
||||
<div className="instruction-number">{instruction.step}</div>
|
||||
<div className="instruction-content">
|
||||
<input
|
||||
type="text"
|
||||
value={instruction.timing}
|
||||
onChange={(e) => updateInstruction(index, 'timing', e.target.value)}
|
||||
placeholder="Timing (optional, e.g., 8:00am)"
|
||||
className="instruction-timing-input"
|
||||
/>
|
||||
<textarea
|
||||
value={instruction.text}
|
||||
onChange={(e) => updateInstruction(index, 'text', e.target.value)}
|
||||
placeholder="Instruction text *"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{instructions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeInstruction(index)}
|
||||
className="btn-remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addInstruction} className="btn-secondary">
|
||||
+ Add Instruction
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="form-actions">
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Saving...' : 'Save Recipe'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecipeForm;
|
||||
@@ -49,6 +49,11 @@ export const recipesApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteImage: async (id: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.delete(`/recipes/${id}/image`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
importFromUrl: async (url: string): Promise<RecipeImportResponse> => {
|
||||
const response = await api.post('/recipes/import', { url } as RecipeImportRequest);
|
||||
return response.data;
|
||||
|
||||
Reference in New Issue
Block a user