feat: add recipe editing, image upload management, and UI improvements #3
@@ -7,6 +7,8 @@ services:
|
|||||||
POSTGRES_USER: basil
|
POSTGRES_USER: basil
|
||||||
POSTGRES_PASSWORD: basil
|
POSTGRES_PASSWORD: basil
|
||||||
POSTGRES_DB: basil
|
POSTGRES_DB: basil
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
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",
|
"prisma:studio": "prisma studio",
|
||||||
"lint": "eslint src --ext .ts"
|
"lint": "eslint src --ext .ts"
|
||||||
},
|
},
|
||||||
"keywords": ["basil", "api"],
|
"keywords": [
|
||||||
|
"basil",
|
||||||
|
"api"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@basil/shared": "^1.0.0",
|
"@basil/shared": "^1.0.0",
|
||||||
"@prisma/client": "^5.8.0",
|
"@prisma/client": "^6.18.0",
|
||||||
"express": "^4.18.2",
|
"axios": "^1.7.9",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.4.7",
|
||||||
"multer": "^1.4.5-lts.1",
|
"express": "^4.21.2",
|
||||||
"axios": "^1.6.5",
|
"multer": "^2.0.2"
|
||||||
"cheerio": "^1.0.0-rc.12"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/multer": "^1.4.11",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^20.10.6",
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"prisma": "^5.8.0",
|
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||||
"tsx": "^4.7.0",
|
"@typescript-eslint/parser": "^8.18.2",
|
||||||
"typescript": "^5.3.3",
|
"@vitest/coverage-v8": "^2.1.8",
|
||||||
"eslint": "^8.56.0",
|
"@vitest/ui": "^2.1.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
"eslint": "^9.17.0",
|
||||||
"@typescript-eslint/parser": "^6.17.0",
|
"prisma": "^6.18.0",
|
||||||
"vitest": "^1.2.0",
|
"supertest": "^7.0.0",
|
||||||
"@vitest/ui": "^1.2.0",
|
"tsx": "^4.19.2",
|
||||||
"@vitest/coverage-v8": "^1.2.0",
|
"typescript": "^5.7.2",
|
||||||
"supertest": "^6.3.4"
|
"vitest": "^2.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ model Recipe {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
sections RecipeSection[]
|
||||||
ingredients Ingredient[]
|
ingredients Ingredient[]
|
||||||
instructions Instruction[]
|
instructions Instruction[]
|
||||||
images RecipeImage[]
|
images RecipeImage[]
|
||||||
@@ -35,30 +36,51 @@ model Recipe {
|
|||||||
@@index([category])
|
@@index([category])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Ingredient {
|
model RecipeSection {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
recipeId String
|
recipeId String
|
||||||
name String
|
name String // e.g., "Starter", "Dough", "Assembly"
|
||||||
amount String?
|
|
||||||
unit String?
|
|
||||||
notes String?
|
|
||||||
order Int
|
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])
|
@@index([recipeId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Instruction {
|
model Ingredient {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
recipeId String
|
recipeId String? // Optional - can be derived from section
|
||||||
step Int
|
sectionId String? // Optional - if null, belongs to recipe directly
|
||||||
text String @db.Text
|
name String
|
||||||
imageUrl 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([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 {
|
model RecipeImage {
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ app.use(express.json());
|
|||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
// Serve uploaded files
|
// 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
|
// Routes
|
||||||
app.use('/api/recipes', recipesRoutes);
|
app.use('/api/recipes', recipesRoutes);
|
||||||
|
|||||||
@@ -6,7 +6,19 @@ import { ScraperService } from '../services/scraper.service';
|
|||||||
import { ApiResponse, RecipeImportRequest } from '@basil/shared';
|
import { ApiResponse, RecipeImportRequest } from '@basil/shared';
|
||||||
|
|
||||||
const router = Router();
|
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 storageService = StorageService.getInstance();
|
||||||
const scraperService = new ScraperService();
|
const scraperService = new ScraperService();
|
||||||
|
|
||||||
@@ -34,6 +46,13 @@ router.get('/', async (req, res) => {
|
|||||||
skip,
|
skip,
|
||||||
take: limitNum,
|
take: limitNum,
|
||||||
include: {
|
include: {
|
||||||
|
sections: {
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: {
|
||||||
|
ingredients: { orderBy: { order: 'asc' } },
|
||||||
|
instructions: { orderBy: { step: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
ingredients: { orderBy: { order: 'asc' } },
|
ingredients: { orderBy: { order: 'asc' } },
|
||||||
instructions: { orderBy: { step: 'asc' } },
|
instructions: { orderBy: { step: 'asc' } },
|
||||||
images: { orderBy: { order: 'asc' } },
|
images: { orderBy: { order: 'asc' } },
|
||||||
@@ -62,6 +81,13 @@ router.get('/:id', async (req, res) => {
|
|||||||
const recipe = await prisma.recipe.findUnique({
|
const recipe = await prisma.recipe.findUnique({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
include: {
|
include: {
|
||||||
|
sections: {
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
include: {
|
||||||
|
ingredients: { orderBy: { order: 'asc' } },
|
||||||
|
instructions: { orderBy: { step: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
ingredients: { orderBy: { order: 'asc' } },
|
ingredients: { orderBy: { order: 'asc' } },
|
||||||
instructions: { orderBy: { step: 'asc' } },
|
instructions: { orderBy: { step: 'asc' } },
|
||||||
images: { orderBy: { order: 'asc' } },
|
images: { orderBy: { order: 'asc' } },
|
||||||
@@ -83,13 +109,31 @@ router.get('/:id', async (req, res) => {
|
|||||||
// Create recipe
|
// Create recipe
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
try {
|
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({
|
const recipe = await prisma.recipe.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
...recipeData,
|
...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: {
|
ingredients: {
|
||||||
create: ingredients?.map((ing: any, index: number) => ({
|
create: ingredients?.map((ing: any, index: number) => ({
|
||||||
...ing,
|
...ing,
|
||||||
@@ -113,6 +157,12 @@ router.post('/', async (req, res) => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
sections: {
|
||||||
|
include: {
|
||||||
|
ingredients: true,
|
||||||
|
instructions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
ingredients: true,
|
ingredients: true,
|
||||||
instructions: true,
|
instructions: true,
|
||||||
images: true,
|
images: true,
|
||||||
@@ -130,26 +180,59 @@ router.post('/', async (req, res) => {
|
|||||||
// Update recipe
|
// Update recipe
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { ingredients, instructions, tags, ...recipeData } = req.body;
|
const { sections, ingredients, instructions, tags, ...recipeData } = req.body;
|
||||||
|
|
||||||
// Delete existing relations
|
// Delete existing relations
|
||||||
|
await prisma.recipeSection.deleteMany({ where: { recipeId: req.params.id } });
|
||||||
await prisma.ingredient.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.instruction.deleteMany({ where: { recipeId: req.params.id } });
|
||||||
await prisma.recipeTag.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({
|
const recipe = await prisma.recipe.update({
|
||||||
where: { id: req.params.id },
|
where: { id: req.params.id },
|
||||||
data: {
|
data: {
|
||||||
...recipeData,
|
...recipeData,
|
||||||
ingredients: ingredients
|
sections: sections
|
||||||
? {
|
? {
|
||||||
create: ingredients.map((ing: any, index: number) => ({
|
create: sections.map((section: any) => ({
|
||||||
...ing,
|
name: section.name,
|
||||||
order: ing.order ?? index,
|
order: section.order,
|
||||||
|
timing: section.timing,
|
||||||
|
ingredients: {
|
||||||
|
create: section.ingredients?.map(cleanIngredient) || [],
|
||||||
|
},
|
||||||
|
instructions: {
|
||||||
|
create: section.instructions?.map(cleanInstruction) || [],
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
instructions: instructions ? { create: instructions } : undefined,
|
ingredients: ingredients
|
||||||
|
? {
|
||||||
|
create: ingredients.map(cleanIngredient),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
instructions: instructions
|
||||||
|
? {
|
||||||
|
create: instructions.map(cleanInstruction),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
tags: tags
|
tags: tags
|
||||||
? {
|
? {
|
||||||
create: tags.map((tagName: string) => ({
|
create: tags.map((tagName: string) => ({
|
||||||
@@ -164,6 +247,12 @@ router.put('/:id', async (req, res) => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
sections: {
|
||||||
|
include: {
|
||||||
|
ingredients: true,
|
||||||
|
instructions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
ingredients: true,
|
ingredients: true,
|
||||||
instructions: true,
|
instructions: true,
|
||||||
images: true,
|
images: true,
|
||||||
@@ -209,25 +298,85 @@ router.delete('/:id', async (req, res) => {
|
|||||||
// Upload image
|
// Upload image
|
||||||
router.post('/:id/images', upload.single('image'), async (req, res) => {
|
router.post('/:id/images', upload.single('image'), async (req, res) => {
|
||||||
try {
|
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) {
|
if (!req.file) {
|
||||||
|
console.error('No file in request');
|
||||||
return res.status(400).json({ error: 'No image provided' });
|
return res.status(400).json({ error: 'No image provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Saving file to storage...');
|
||||||
const imageUrl = await storageService.saveFile(req.file, 'recipes');
|
const imageUrl = await storageService.saveFile(req.file, 'recipes');
|
||||||
|
console.log('File saved, URL:', imageUrl);
|
||||||
|
|
||||||
// Add to recipe images
|
// Get existing recipe to delete old image
|
||||||
const image = await prisma.recipeImage.create({
|
const existingRecipe = await prisma.recipe.findUnique({
|
||||||
data: {
|
where: { id: req.params.id },
|
||||||
recipeId: req.params.id,
|
select: { imageUrl: true },
|
||||||
url: imageUrl,
|
|
||||||
order: 0,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Error uploading image:', 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;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
sections?: RecipeSection[]; // Optional sections for multi-part recipes
|
||||||
ingredients: Ingredient[];
|
ingredients: Ingredient[];
|
||||||
instructions: Instruction[];
|
instructions: Instruction[];
|
||||||
prepTime?: number; // minutes
|
prepTime?: number; // minutes
|
||||||
@@ -20,8 +21,19 @@ export interface Recipe {
|
|||||||
updatedAt: Date;
|
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 {
|
export interface Ingredient {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
sectionId?: string; // Optional - if present, belongs to a section
|
||||||
name: string;
|
name: string;
|
||||||
amount?: string;
|
amount?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
@@ -31,9 +43,11 @@ export interface Ingredient {
|
|||||||
|
|
||||||
export interface Instruction {
|
export interface Instruction {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
sectionId?: string; // Optional - if present, belongs to a section
|
||||||
step: number;
|
step: number;
|
||||||
text: string;
|
text: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
timing?: string; // e.g., "8:00am", "After 30 minutes", "Day 2 - Morning"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecipeImportRequest {
|
export interface RecipeImportRequest {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
client_max_body_size 20M;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy uploads requests to backend
|
# Proxy uploads requests to backend
|
||||||
|
|||||||
@@ -118,6 +118,20 @@ nav a:hover {
|
|||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
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 {
|
.recipe-detail img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
@@ -190,6 +204,53 @@ nav a:hover {
|
|||||||
white-space: nowrap;
|
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 {
|
.ingredients, .instructions {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
@@ -276,3 +337,273 @@ button:disabled {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #666;
|
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 RecipeList from './pages/RecipeList';
|
||||||
import RecipeDetail from './pages/RecipeDetail';
|
import RecipeDetail from './pages/RecipeDetail';
|
||||||
import RecipeImport from './pages/RecipeImport';
|
import RecipeImport from './pages/RecipeImport';
|
||||||
|
import NewRecipe from './pages/NewRecipe';
|
||||||
|
import EditRecipe from './pages/EditRecipe';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -13,6 +15,7 @@ function App() {
|
|||||||
<h1 className="logo">🌿 Basil</h1>
|
<h1 className="logo">🌿 Basil</h1>
|
||||||
<nav>
|
<nav>
|
||||||
<Link to="/">Recipes</Link>
|
<Link to="/">Recipes</Link>
|
||||||
|
<Link to="/new">New Recipe</Link>
|
||||||
<Link to="/import">Import Recipe</Link>
|
<Link to="/import">Import Recipe</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,6 +26,8 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<RecipeList />} />
|
<Route path="/" element={<RecipeList />} />
|
||||||
<Route path="/recipes/:id" element={<RecipeDetail />} />
|
<Route path="/recipes/:id" element={<RecipeDetail />} />
|
||||||
|
<Route path="/recipes/:id/edit" element={<EditRecipe />} />
|
||||||
|
<Route path="/new" element={<NewRecipe />} />
|
||||||
<Route path="/import" element={<RecipeImport />} />
|
<Route path="/import" element={<RecipeImport />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="recipe-detail">
|
<div className="recipe-detail">
|
||||||
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
<div className="recipe-actions">
|
||||||
<button onClick={handleDelete} style={{ marginLeft: '1rem', backgroundColor: '#d32f2f' }}>
|
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
||||||
Delete Recipe
|
<div className="recipe-actions-right">
|
||||||
</button>
|
<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} />}
|
{recipe.imageUrl && <img src={recipe.imageUrl} alt={recipe.title} />}
|
||||||
|
|
||||||
@@ -120,47 +125,113 @@ function RecipeDetail() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recipe.ingredients && recipe.ingredients.length > 0 && (
|
{/* Display sections if they exist */}
|
||||||
<div className="ingredients">
|
{recipe.sections && recipe.sections.length > 0 ? (
|
||||||
<h3>Ingredients</h3>
|
<div className="recipe-sections">
|
||||||
<ul>
|
{recipe.sections.map((section) => (
|
||||||
{recipe.ingredients.map((ingredient, index) => {
|
<div key={section.id} className="recipe-section">
|
||||||
// Construct the full ingredient string
|
<div className="section-header">
|
||||||
let ingredientStr = '';
|
<h3>{section.name}</h3>
|
||||||
if (ingredient.amount && ingredient.unit) {
|
{section.timing && <span className="section-timing">{section.timing}</span>}
|
||||||
ingredientStr = `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`;
|
</div>
|
||||||
} else if (ingredient.amount) {
|
|
||||||
ingredientStr = `${ingredient.amount} ${ingredient.name}`;
|
|
||||||
} else {
|
|
||||||
ingredientStr = ingredient.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply scaling if servings changed
|
{section.ingredients && section.ingredients.length > 0 && (
|
||||||
const displayStr =
|
<div className="ingredients">
|
||||||
recipe.servings && currentServings && recipe.servings !== currentServings
|
<h4>Ingredients</h4>
|
||||||
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
<ul>
|
||||||
: ingredientStr;
|
{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 (
|
const displayStr =
|
||||||
<li key={index}>
|
recipe.servings && currentServings && recipe.servings !== currentServings
|
||||||
{displayStr}
|
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||||||
{ingredient.notes && ` (${ingredient.notes})`}
|
: ingredientStr;
|
||||||
</li>
|
|
||||||
);
|
return (
|
||||||
})}
|
<li key={index}>
|
||||||
</ul>
|
{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>
|
</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 && (
|
const displayStr =
|
||||||
<div className="instructions">
|
recipe.servings && currentServings && recipe.servings !== currentServings
|
||||||
<h3>Instructions</h3>
|
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||||||
<ol>
|
: ingredientStr;
|
||||||
{recipe.instructions.map((instruction) => (
|
|
||||||
<li key={instruction.step}>{instruction.text}</li>
|
return (
|
||||||
))}
|
<li key={index}>
|
||||||
</ol>
|
{displayStr}
|
||||||
</div>
|
{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>
|
</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;
|
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> => {
|
importFromUrl: async (url: string): Promise<RecipeImportResponse> => {
|
||||||
const response = await api.post('/recipes/import', { url } as RecipeImportRequest);
|
const response = await api.post('/recipes/import', { url } as RecipeImportRequest);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user