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

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