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

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:
2025-10-30 05:31:12 +00:00
parent 5797dade02
commit 33eadde671
15 changed files with 9614 additions and 92 deletions

View File

@@ -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"
}
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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' });
}
});