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:
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user