feat: add cookbook nesting and auto-filtering capabilities

Enables cookbooks to include other cookbooks and automatically organize content based on tags. This allows users to create hierarchical cookbook structures and maintain collections that automatically update as new content is added.

Key features:
- Cookbook nesting: Include child cookbooks within parent cookbooks
- Auto-filtering by cookbook tags: Automatically include cookbooks matching specified tags
- Auto-filtering by recipe tags: Automatically add recipes matching specified tags
- Enhanced cookbook management UI with tag support
- Comprehensive test coverage for new functionality

Database schema updated with CookbookInclusion and CookbookTag tables.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-09 05:04:01 +00:00
parent 5707e42c0f
commit 32322f71dc
12 changed files with 1285 additions and 49 deletions

View File

@@ -28,6 +28,7 @@ model User {
sharedRecipes RecipeShare[]
refreshTokens RefreshToken[]
verificationTokens VerificationToken[]
mealPlans MealPlan[]
@@index([email])
@@index([provider, providerId])
@@ -103,6 +104,7 @@ model Recipe {
tags RecipeTag[]
cookbooks CookbookRecipe[]
sharedWith RecipeShare[]
meals MealRecipe[]
@@index([title])
@@index([cuisine])
@@ -185,9 +187,10 @@ model RecipeImage {
}
model Tag {
id String @id @default(cuid())
name String @unique
recipes RecipeTag[]
id String @id @default(cuid())
name String @unique
recipes RecipeTag[]
cookbooks CookbookTag[]
}
model RecipeTag {
@@ -202,6 +205,18 @@ model RecipeTag {
@@index([tagId])
}
model CookbookTag {
cookbookId String
tagId String
cookbook Cookbook @relation(fields: [cookbookId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([cookbookId, tagId])
@@index([cookbookId])
@@index([tagId])
}
model RecipeShare {
id String @id @default(cuid())
recipeId String
@@ -217,18 +232,22 @@ model RecipeShare {
}
model Cookbook {
id String @id @default(cuid())
name String
description String?
coverImageUrl String?
userId String? // Cookbook owner
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
description String?
coverImageUrl String?
userId String? // Cookbook owner
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
autoFilterCookbookTags String[] @default([]) // Auto-add cookbooks matching these tags
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
recipes CookbookRecipe[]
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
recipes CookbookRecipe[]
tags CookbookTag[]
includedCookbooks CookbookInclusion[] @relation("ParentCookbook")
includedIn CookbookInclusion[] @relation("ChildCookbook")
@@index([name])
@@index([userId])
@@ -247,3 +266,70 @@ model CookbookRecipe {
@@index([cookbookId])
@@index([recipeId])
}
model CookbookInclusion {
id String @id @default(cuid())
parentCookbookId String
childCookbookId String
addedAt DateTime @default(now())
parentCookbook Cookbook @relation("ParentCookbook", fields: [parentCookbookId], references: [id], onDelete: Cascade)
childCookbook Cookbook @relation("ChildCookbook", fields: [childCookbookId], references: [id], onDelete: Cascade)
@@unique([parentCookbookId, childCookbookId])
@@index([parentCookbookId])
@@index([childCookbookId])
}
model MealPlan {
id String @id @default(cuid())
userId String?
date DateTime // The day this meal plan is for (stored at midnight UTC)
notes String? @db.Text // Optional notes for the entire day
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
meals Meal[]
@@unique([userId, date]) // One meal plan per user per day
@@index([userId])
@@index([date])
@@index([userId, date])
}
model Meal {
id String @id @default(cuid())
mealPlanId String
mealType MealType
order Int // Order within the same meal type (for multi-recipe meals)
servings Int? // Servings for this specific meal (can override recipe default)
notes String? @db.Text // Meal-specific notes
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
mealPlan MealPlan @relation(fields: [mealPlanId], references: [id], onDelete: Cascade)
recipe MealRecipe?
@@index([mealPlanId])
@@index([mealType])
}
model MealRecipe {
mealId String @id
recipeId String
meal Meal @relation(fields: [mealId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
@@index([recipeId])
}
enum MealType {
BREAKFAST
LUNCH
DINNER
SNACK
DESSERT
OTHER
}

View File

@@ -84,6 +84,9 @@ describe('Cookbook & Tags - Integration Tests', () => {
// Step 3: Retrieve the cookbook with its recipes
const cookbookWithRecipes = {
...createdCookbook,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
recipes: [
{
recipe: {
@@ -98,6 +101,8 @@ describe('Cookbook & Tags - Integration Tests', () => {
},
},
],
includedCookbooks: [],
tags: [],
};
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(cookbookWithRecipes as any);
@@ -262,6 +267,9 @@ describe('Cookbook & Tags - Integration Tests', () => {
name: 'Weeknight Dinners',
description: 'Quick and healthy meals',
coverImageUrl: null,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
createdAt: new Date(),
updatedAt: new Date(),
recipes: [
@@ -277,6 +285,8 @@ describe('Cookbook & Tags - Integration Tests', () => {
},
},
],
includedCookbooks: [],
tags: [],
} as any);
const response = await request(app).get('/cookbooks/cb1').expect(200);

View File

@@ -22,6 +22,16 @@ vi.mock('../config/database', () => ({
create: vi.fn(),
delete: vi.fn(),
},
cookbookTag: {
deleteMany: vi.fn(),
},
cookbookInclusion: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
recipe: {
findMany: vi.fn(),
},
@@ -61,7 +71,9 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
coverImageUrl: '/uploads/italian.jpg',
autoFilterCategories: [],
autoFilterTags: [],
_count: { recipes: 10 },
autoFilterCookbookTags: [],
tags: [],
_count: { recipes: 10, includedCookbooks: 0 },
createdAt: new Date(),
updatedAt: new Date(),
},
@@ -72,12 +84,15 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
coverImageUrl: null,
autoFilterCategories: [],
autoFilterTags: ['quick'],
_count: { recipes: 5 },
autoFilterCookbookTags: [],
tags: [],
_count: { recipes: 5, includedCookbooks: 0 },
createdAt: new Date(),
updatedAt: new Date(),
},
];
(prisma.cookbookInclusion.findMany as any).mockResolvedValue([]);
(prisma.cookbook.findMany as any).mockResolvedValue(mockCookbooks);
const response = await request(app).get('/api/cookbooks');
@@ -90,6 +105,7 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
});
it('should return empty array when no cookbooks exist', async () => {
(prisma.cookbookInclusion.findMany as any).mockResolvedValue([]);
(prisma.cookbook.findMany as any).mockResolvedValue([]);
const response = await request(app).get('/api/cookbooks');
@@ -99,6 +115,7 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
});
it('should return 500 on database error', async () => {
(prisma.cookbookInclusion.findMany as any).mockResolvedValue([]);
(prisma.cookbook.findMany as any).mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/api/cookbooks');
@@ -117,6 +134,8 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
coverImageUrl: '/uploads/italian.jpg',
autoFilterCategories: ['Italian'],
autoFilterTags: [],
autoFilterCookbookTags: [],
tags: [],
createdAt: new Date(),
updatedAt: new Date(),
recipes: [
@@ -129,6 +148,7 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
},
},
],
includedCookbooks: [],
};
(prisma.cookbook.findUnique as any).mockResolvedValue(mockCookbook);
@@ -180,6 +200,14 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
(prisma.cookbook.create as any).mockResolvedValue(mockCreatedCookbook);
(prisma.recipe.findMany as any).mockResolvedValue([]);
(prisma.cookbook.findMany as any).mockResolvedValue([]);
(prisma.cookbook.findUnique as any).mockResolvedValue({
id: '1',
autoFilterTags: ['vegetarian'],
autoFilterCategories: [],
autoFilterCookbookTags: [],
tags: [],
});
const response = await request(app)
.post('/api/cookbooks')
@@ -219,11 +247,14 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
id: '1',
autoFilterTags: ['quick'],
autoFilterCategories: [],
autoFilterCookbookTags: [],
tags: [],
});
(prisma.recipe.findMany as any).mockResolvedValue([
{ id: 'recipe-1' },
{ id: 'recipe-2' },
]);
(prisma.cookbook.findMany as any).mockResolvedValue([]);
(prisma.cookbookRecipe.create as any).mockResolvedValue({});
const response = await request(app)
@@ -290,8 +321,11 @@ describe('Cookbooks Routes - Real Integration Tests', () => {
id: '1',
autoFilterTags: ['vegetarian'],
autoFilterCategories: [],
autoFilterCookbookTags: [],
tags: [],
});
(prisma.recipe.findMany as any).mockResolvedValue([]);
(prisma.cookbook.findMany as any).mockResolvedValue([]);
const response = await request(app)
.put('/api/cookbooks/1')

View File

@@ -18,6 +18,19 @@ vi.mock('../config/database', () => ({
create: vi.fn(),
delete: vi.fn(),
},
cookbookTag: {
deleteMany: vi.fn(),
},
cookbookInclusion: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
},
recipe: {
findMany: vi.fn(),
},
},
}));
@@ -43,44 +56,64 @@ describe('Cookbooks Routes - Unit Tests', () => {
name: 'Family Favorites',
description: 'Our favorite family recipes',
coverImageUrl: null,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
_count: { recipes: 5 },
_count: { recipes: 5, includedCookbooks: 0 },
tags: [],
},
{
id: 'cb2',
name: 'Holiday Recipes',
description: 'Recipes for holidays',
coverImageUrl: '/uploads/holiday.jpg',
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
createdAt: new Date('2025-01-02'),
updatedAt: new Date('2025-01-02'),
_count: { recipes: 3 },
_count: { recipes: 3, includedCookbooks: 0 },
tags: [],
},
];
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookInclusion.findMany).mockResolvedValue([] as any);
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue(mockCookbooks as any);
const response = await request(app).get('/cookbooks').expect(200);
expect(response.body.data).toHaveLength(2);
expect(response.body.data[0]).toEqual({
expect(response.body.data[0]).toMatchObject({
id: 'cb1',
name: 'Family Favorites',
description: 'Our favorite family recipes',
coverImageUrl: null,
recipeCount: 5,
createdAt: mockCookbooks[0].createdAt.toISOString(),
updatedAt: mockCookbooks[0].updatedAt.toISOString(),
});
expect(prisma.default.cookbook.findMany).toHaveBeenCalledWith({
include: {
_count: {
select: { recipes: true },
},
},
orderBy: { updatedAt: 'desc' },
cookbookCount: 0,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
tags: [],
});
expect(prisma.default.cookbook.findMany).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({
_count: {
select: {
recipes: true,
includedCookbooks: true
}
},
tags: {
include: { tag: true }
}
}),
orderBy: { updatedAt: 'desc' },
})
);
});
it('should handle errors gracefully', async () => {
@@ -100,6 +133,9 @@ describe('Cookbooks Routes - Unit Tests', () => {
name: 'Family Favorites',
description: 'Our favorite family recipes',
coverImageUrl: null,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
recipes: [
@@ -116,6 +152,8 @@ describe('Cookbooks Routes - Unit Tests', () => {
},
},
],
includedCookbooks: [],
tags: [],
};
const prisma = await import('../config/database');
@@ -149,24 +187,36 @@ describe('Cookbooks Routes - Unit Tests', () => {
const createdCookbook = {
id: 'cb-new',
...newCookbook,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(createdCookbook as any);
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([] as any);
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue([] as any);
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue({ tags: [] } as any);
const response = await request(app).post('/cookbooks').send(newCookbook).expect(201);
expect(response.body.data.id).toBe('cb-new');
expect(response.body.data.name).toBe('Quick Meals');
expect(prisma.default.cookbook.create).toHaveBeenCalledWith({
data: {
...newCookbook,
autoFilterCategories: [],
autoFilterTags: [],
},
});
expect(prisma.default.cookbook.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: 'Quick Meals',
description: 'Fast recipes for busy weeknights',
coverImageUrl: '/uploads/quick-meals.jpg',
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
}),
})
);
});
it('should return 400 if name is missing', async () => {
@@ -192,6 +242,7 @@ describe('Cookbooks Routes - Unit Tests', () => {
coverImageUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
};
const prisma = await import('../config/database');
@@ -203,6 +254,7 @@ describe('Cookbooks Routes - Unit Tests', () => {
expect(prisma.default.cookbook.update).toHaveBeenCalledWith({
where: { id: 'cb1' },
data: updates,
include: { tags: { include: { tag: true } } }
});
});
});
@@ -271,4 +323,279 @@ describe('Cookbooks Routes - Unit Tests', () => {
});
});
});
describe('GET /cookbooks - with child exclusion', () => {
it('should exclude child cookbooks by default', async () => {
const childCookbookIds = [{ childCookbookId: 'cb2' }];
const mockCookbooks = [
{
id: 'cb1',
name: 'Parent Cookbook',
description: 'A parent cookbook',
coverImageUrl: null,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
tags: [],
_count: { recipes: 5, includedCookbooks: 1 },
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
},
];
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookInclusion.findMany).mockResolvedValue(childCookbookIds as any);
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue(mockCookbooks as any);
const response = await request(app).get('/cookbooks').expect(200);
expect(prisma.default.cookbookInclusion.findMany).toHaveBeenCalledWith({
select: { childCookbookId: true },
distinct: ['childCookbookId'],
});
expect(prisma.default.cookbook.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { notIn: ['cb2'] } },
})
);
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].cookbookCount).toBe(1);
});
it('should include child cookbooks when includeChildren=true', async () => {
const mockCookbooks = [
{
id: 'cb1',
name: 'Parent Cookbook',
description: null,
coverImageUrl: null,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
tags: [],
_count: { recipes: 5, includedCookbooks: 1 },
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'cb2',
name: 'Child Cookbook',
description: null,
coverImageUrl: null,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: [],
tags: [],
_count: { recipes: 3, includedCookbooks: 0 },
createdAt: new Date(),
updatedAt: new Date(),
},
];
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookInclusion.findMany).mockResolvedValue([] as any);
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue(mockCookbooks as any);
const response = await request(app).get('/cookbooks?includeChildren=true').expect(200);
// When includeChildren=true, cookbookInclusion.findMany should NOT be called
expect(prisma.default.cookbookInclusion.findMany).not.toHaveBeenCalled();
expect(response.body.data).toHaveLength(2);
});
});
describe('GET /cookbooks/:id - with nested cookbooks', () => {
it('should return cookbook with included cookbooks', async () => {
const mockCookbook = {
id: 'cb1',
name: 'Parent Cookbook',
description: 'A parent cookbook',
coverImageUrl: null,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: ['holiday'],
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-01-01'),
recipes: [],
includedCookbooks: [
{
addedAt: new Date('2025-01-02'),
childCookbook: {
id: 'cb2',
name: 'Child Cookbook',
description: 'A child cookbook',
coverImageUrl: null,
_count: { recipes: 3, includedCookbooks: 0 },
tags: [{ tag: { name: 'holiday' } }],
},
},
],
tags: [],
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(mockCookbook as any);
const response = await request(app).get('/cookbooks/cb1').expect(200);
expect(response.body.data.cookbooks).toHaveLength(1);
expect(response.body.data.cookbooks[0]).toMatchObject({
id: 'cb2',
name: 'Child Cookbook',
recipeCount: 3,
cookbookCount: 0,
tags: ['holiday'],
});
});
});
describe('POST /cookbooks - with tags', () => {
it('should create cookbook with tags and autoFilterCookbookTags', async () => {
const mockCookbook = {
id: 'cb1',
name: 'Holiday Cookbook',
description: 'Holiday recipes',
coverImageUrl: null,
autoFilterCategories: [],
autoFilterTags: [],
autoFilterCookbookTags: ['seasonal'],
tags: [{ tag: { name: 'holiday' } }],
createdAt: new Date(),
updatedAt: new Date(),
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(mockCookbook as any);
vi.mocked(prisma.default.recipe.findMany).mockResolvedValue([] as any);
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue([] as any);
const response = await request(app)
.post('/cookbooks')
.send({
name: 'Holiday Cookbook',
description: 'Holiday recipes',
tags: ['holiday'],
autoFilterCookbookTags: ['seasonal'],
})
.expect(201);
expect(prisma.default.cookbook.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: 'Holiday Cookbook',
autoFilterCookbookTags: ['seasonal'],
tags: expect.objectContaining({
create: expect.arrayContaining([
expect.objectContaining({
tag: expect.objectContaining({
connectOrCreate: expect.objectContaining({
where: { name: 'holiday' },
create: { name: 'holiday' },
}),
}),
}),
]),
}),
}),
})
);
});
});
describe('PUT /cookbooks/:id - with tag updates', () => {
it('should update cookbook tags and trigger re-filtering', async () => {
const mockCookbook = {
id: 'cb1',
name: 'Updated Cookbook',
tags: [{ tag: { name: 'updated' } }],
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookTag.deleteMany).mockResolvedValue({ count: 1 } as any);
vi.mocked(prisma.default.cookbook.update).mockResolvedValue(mockCookbook as any);
vi.mocked(prisma.default.cookbookInclusion.deleteMany).mockResolvedValue({ count: 0 } as any);
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue({
...mockCookbook,
autoFilterCookbookTags: [],
} as any);
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue([] as any);
await request(app)
.put('/cookbooks/cb1')
.send({
tags: ['updated'],
})
.expect(200);
expect(prisma.default.cookbookTag.deleteMany).toHaveBeenCalledWith({
where: { cookbookId: 'cb1' },
});
expect(prisma.default.cookbookInclusion.deleteMany).toHaveBeenCalledWith({
where: { childCookbookId: 'cb1' },
});
});
});
describe('POST /cookbooks/:id/cookbooks/:childCookbookId', () => {
it('should add a cookbook to another cookbook', async () => {
const mockInclusion = {
id: 'inc1',
parentCookbookId: 'cb1',
childCookbookId: 'cb2',
addedAt: new Date(),
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookInclusion.findUnique).mockResolvedValue(null as any);
vi.mocked(prisma.default.cookbookInclusion.create).mockResolvedValue(mockInclusion as any);
const response = await request(app).post('/cookbooks/cb1/cookbooks/cb2').expect(201);
expect(response.body.data).toMatchObject({
parentCookbookId: 'cb1',
childCookbookId: 'cb2',
});
});
it('should prevent adding cookbook to itself', async () => {
const response = await request(app).post('/cookbooks/cb1/cookbooks/cb1').expect(400);
expect(response.body.error).toBe('Cannot add cookbook to itself');
});
it('should prevent duplicate cookbook inclusions', async () => {
const mockExisting = {
id: 'inc1',
parentCookbookId: 'cb1',
childCookbookId: 'cb2',
addedAt: new Date(),
};
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookInclusion.findUnique).mockResolvedValue(mockExisting as any);
const response = await request(app).post('/cookbooks/cb1/cookbooks/cb2').expect(400);
expect(response.body.error).toBe('Cookbook already included');
});
});
describe('DELETE /cookbooks/:id/cookbooks/:childCookbookId', () => {
it('should remove a cookbook from another cookbook', async () => {
const prisma = await import('../config/database');
vi.mocked(prisma.default.cookbookInclusion.delete).mockResolvedValue({} as any);
const response = await request(app).delete('/cookbooks/cb1/cookbooks/cb2').expect(200);
expect(response.body.message).toBe('Cookbook removed successfully');
expect(prisma.default.cookbookInclusion.delete).toHaveBeenCalledWith({
where: {
parentCookbookId_childCookbookId: {
parentCookbookId: 'cb1',
childCookbookId: 'cb2',
},
},
});
});
});
});

View File

@@ -88,13 +88,143 @@ async function applyFiltersToExistingRecipes(cookbookId: string) {
}
}
// Helper function to apply cookbook tag filters to existing cookbooks
async function applyFiltersToExistingCookbooks(cookbookId: string) {
try {
const cookbook = await prisma.cookbook.findUnique({
where: { id: cookbookId },
include: {
tags: {
include: { tag: true }
}
}
});
if (!cookbook) return;
const cookbookTags = cookbook.autoFilterCookbookTags || [];
if (cookbookTags.length === 0) {
return;
}
// Find matching cookbooks (excluding self)
const matchingCookbooks = await prisma.cookbook.findMany({
where: {
AND: [
{ id: { not: cookbookId } },
{
tags: {
some: {
tag: {
name: { in: cookbookTags }
}
}
}
}
]
},
select: { id: true }
});
// Add each matching cookbook to the parent cookbook
for (const childCookbook of matchingCookbooks) {
try {
await prisma.cookbookInclusion.create({
data: {
parentCookbookId: cookbookId,
childCookbookId: childCookbook.id
}
});
} catch (error: any) {
// Ignore unique constraint violations (cookbook already included)
if (error.code !== 'P2002') {
console.error(`Error adding cookbook ${childCookbook.id}:`, error);
}
}
}
console.log(`Applied cookbook filters to ${cookbook.name}: added ${matchingCookbooks.length} cookbooks`);
} catch (error) {
console.error('Error in applyFiltersToExistingCookbooks:', error);
}
}
// Helper function to auto-add cookbook to parent cookbooks based on its tags
async function autoAddToParentCookbooks(cookbookId: string) {
try {
const cookbook = await prisma.cookbook.findUnique({
where: { id: cookbookId },
include: {
tags: {
include: { tag: true }
}
}
});
if (!cookbook) return;
const cookbookTags = cookbook.tags.map(ct => ct.tag.name);
if (cookbookTags.length === 0) return;
// Find parent cookbooks with filters matching this cookbook's tags
const parentCookbooks = await prisma.cookbook.findMany({
where: {
AND: [
{ id: { not: cookbookId } },
{ autoFilterCookbookTags: { hasSome: cookbookTags } }
]
}
});
// Add this cookbook to each parent
for (const parent of parentCookbooks) {
try {
await prisma.cookbookInclusion.create({
data: {
parentCookbookId: parent.id,
childCookbookId: cookbookId
}
});
} catch (error: any) {
// Ignore unique constraint violations
if (error.code !== 'P2002') {
console.error(`Error auto-adding to parent cookbook ${parent.name}:`, error);
}
}
}
console.log(`Auto-added ${cookbook.name} to ${parentCookbooks.length} parent cookbooks`);
} catch (error) {
console.error('Error in autoAddToParentCookbooks:', error);
}
}
// Get all cookbooks with recipe count
router.get('/', async (req: Request, res: Response) => {
try {
const { includeChildren = 'false' } = req.query;
// Get child cookbook IDs to exclude from main listing (unless includeChildren is true)
const childCookbookIds = includeChildren === 'true' ? [] : (
await prisma.cookbookInclusion.findMany({
select: { childCookbookId: true },
distinct: ['childCookbookId']
})
).map(ci => ci.childCookbookId);
const cookbooks = await prisma.cookbook.findMany({
where: includeChildren === 'true' ? {} : {
id: { notIn: childCookbookIds }
},
include: {
_count: {
select: { recipes: true }
select: {
recipes: true,
includedCookbooks: true
}
},
tags: {
include: { tag: true }
}
},
orderBy: { updatedAt: 'desc' }
@@ -107,7 +237,10 @@ router.get('/', async (req: Request, res: Response) => {
coverImageUrl: cookbook.coverImageUrl,
autoFilterCategories: cookbook.autoFilterCategories,
autoFilterTags: cookbook.autoFilterTags,
autoFilterCookbookTags: cookbook.autoFilterCookbookTags,
tags: cookbook.tags.map(ct => ct.tag.name),
recipeCount: cookbook._count.recipes,
cookbookCount: cookbook._count.includedCookbooks,
createdAt: cookbook.createdAt,
updatedAt: cookbook.updatedAt
}));
@@ -141,6 +274,23 @@ router.get('/:id', async (req: Request, res: Response) => {
}
},
orderBy: { addedAt: 'desc' }
},
includedCookbooks: {
include: {
childCookbook: {
include: {
_count: {
select: { recipes: true, includedCookbooks: true }
},
tags: {
include: { tag: true }
}
}
}
}
},
tags: {
include: { tag: true }
}
}
});
@@ -156,11 +306,23 @@ router.get('/:id', async (req: Request, res: Response) => {
coverImageUrl: cookbook.coverImageUrl,
autoFilterCategories: cookbook.autoFilterCategories,
autoFilterTags: cookbook.autoFilterTags,
autoFilterCookbookTags: cookbook.autoFilterCookbookTags,
tags: cookbook.tags.map(ct => ct.tag.name),
createdAt: cookbook.createdAt,
updatedAt: cookbook.updatedAt,
recipes: cookbook.recipes.map(cr => ({
...cr.recipe,
tags: cr.recipe.tags.map(rt => rt.tag.name)
})),
cookbooks: cookbook.includedCookbooks.map(ci => ({
id: ci.childCookbook.id,
name: ci.childCookbook.name,
description: ci.childCookbook.description,
coverImageUrl: ci.childCookbook.coverImageUrl,
tags: ci.childCookbook.tags.map(ct => ct.tag.name),
recipeCount: ci.childCookbook._count.recipes,
cookbookCount: ci.childCookbook._count.includedCookbooks,
addedAt: ci.addedAt
}))
};
@@ -174,7 +336,7 @@ router.get('/:id', async (req: Request, res: Response) => {
// Create a new cookbook
router.post('/', async (req: Request, res: Response) => {
try {
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags, autoFilterCookbookTags, tags } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
@@ -186,13 +348,33 @@ router.post('/', async (req: Request, res: Response) => {
description,
coverImageUrl,
autoFilterCategories: autoFilterCategories || [],
autoFilterTags: autoFilterTags || []
autoFilterTags: autoFilterTags || [],
autoFilterCookbookTags: autoFilterCookbookTags || [],
tags: tags ? {
create: tags.map((tagName: string) => ({
tag: {
connectOrCreate: {
where: { name: tagName },
create: { name: tagName }
}
}
}))
} : undefined
},
include: {
tags: { include: { tag: true } }
}
});
// Apply filters to existing recipes
await applyFiltersToExistingRecipes(cookbook.id);
// Apply filters to existing cookbooks
await applyFiltersToExistingCookbooks(cookbook.id);
// Auto-add this cookbook to parent cookbooks
await autoAddToParentCookbooks(cookbook.id);
res.status(201).json({ data: cookbook });
} catch (error) {
console.error('Error creating cookbook:', error);
@@ -204,7 +386,7 @@ router.post('/', async (req: Request, res: Response) => {
router.put('/:id', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags, autoFilterCookbookTags, tags } = req.body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
@@ -212,10 +394,36 @@ router.put('/:id', async (req: Request, res: Response) => {
if (coverImageUrl !== undefined) updateData.coverImageUrl = coverImageUrl;
if (autoFilterCategories !== undefined) updateData.autoFilterCategories = autoFilterCategories;
if (autoFilterTags !== undefined) updateData.autoFilterTags = autoFilterTags;
if (autoFilterCookbookTags !== undefined) updateData.autoFilterCookbookTags = autoFilterCookbookTags;
// Handle tags update separately
if (tags !== undefined) {
// Delete existing tags
await prisma.cookbookTag.deleteMany({
where: { cookbookId: id }
});
// Create new tags
if (tags.length > 0) {
updateData.tags = {
create: tags.map((tagName: string) => ({
tag: {
connectOrCreate: {
where: { name: tagName },
create: { name: tagName }
}
}
}))
};
}
}
const cookbook = await prisma.cookbook.update({
where: { id },
data: updateData
data: updateData,
include: {
tags: { include: { tag: true } }
}
});
// Apply filters to existing recipes if filters were updated
@@ -223,6 +431,24 @@ router.put('/:id', async (req: Request, res: Response) => {
await applyFiltersToExistingRecipes(id);
}
// Apply cookbook filters if updated
if (autoFilterCookbookTags !== undefined) {
// Clear existing inclusions first
await prisma.cookbookInclusion.deleteMany({
where: { parentCookbookId: id }
});
await applyFiltersToExistingCookbooks(id);
}
// Re-apply to parent cookbooks if tags changed
if (tags !== undefined) {
// Clear existing parent relationships
await prisma.cookbookInclusion.deleteMany({
where: { childCookbookId: id }
});
await autoAddToParentCookbooks(id);
}
res.json({ data: cookbook });
} catch (error) {
console.error('Error updating cookbook:', error);
@@ -300,6 +526,65 @@ router.delete('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
}
});
// Add a cookbook to another cookbook
router.post('/:id/cookbooks/:childCookbookId', async (req: Request, res: Response) => {
try {
const { id, childCookbookId } = req.params;
// Prevent adding cookbook to itself
if (id === childCookbookId) {
return res.status(400).json({ error: 'Cannot add cookbook to itself' });
}
// Check if cookbook is already included
const existing = await prisma.cookbookInclusion.findUnique({
where: {
parentCookbookId_childCookbookId: {
parentCookbookId: id,
childCookbookId
}
}
});
if (existing) {
return res.status(400).json({ error: 'Cookbook already included' });
}
const inclusion = await prisma.cookbookInclusion.create({
data: {
parentCookbookId: id,
childCookbookId
}
});
res.status(201).json({ data: inclusion });
} catch (error) {
console.error('Error adding cookbook:', error);
res.status(500).json({ error: 'Failed to add cookbook' });
}
});
// Remove a cookbook from another cookbook
router.delete('/:id/cookbooks/:childCookbookId', async (req: Request, res: Response) => {
try {
const { id, childCookbookId } = req.params;
await prisma.cookbookInclusion.delete({
where: {
parentCookbookId_childCookbookId: {
parentCookbookId: id,
childCookbookId
}
}
});
res.json({ message: 'Cookbook removed successfully' });
} catch (error) {
console.error('Error removing cookbook:', error);
res.status(500).json({ error: 'Failed to remove cookbook' });
}
});
// Upload cookbook cover image
router.post('/:id/image', upload.single('image'), async (req: Request, res: Response) => {
try {

View File

@@ -105,11 +105,111 @@ export interface Cookbook {
coverImageUrl?: string;
autoFilterCategories?: string[]; // Auto-add recipes matching these categories
autoFilterTags?: string[]; // Auto-add recipes matching these tags
autoFilterCookbookTags?: string[]; // Auto-add cookbooks matching these tags
tags?: string[]; // Denormalized tag names for display
recipeCount?: number; // Computed field for display
cookbookCount?: number; // Computed field for display - count of included cookbooks
createdAt: Date;
updatedAt: Date;
}
export interface CookbookWithRecipes extends Cookbook {
recipes: Recipe[];
cookbooks?: Cookbook[]; // Included cookbooks
}
// Meal Planner Types
export enum MealType {
BREAKFAST = 'BREAKFAST',
LUNCH = 'LUNCH',
DINNER = 'DINNER',
SNACK = 'SNACK',
DESSERT = 'DESSERT',
OTHER = 'OTHER'
}
export interface MealPlan {
id: string;
userId?: string;
date: Date | string;
notes?: string;
meals: Meal[];
createdAt: Date;
updatedAt: Date;
}
export interface Meal {
id: string;
mealPlanId: string;
mealType: MealType;
order: number;
servings?: number;
notes?: string;
recipe?: MealRecipeWithDetails;
createdAt: Date;
updatedAt: Date;
}
export interface MealRecipe {
mealId: string;
recipeId: string;
}
export interface MealRecipeWithDetails extends MealRecipe {
recipe: Recipe;
}
export interface CreateMealPlanRequest {
date: string;
notes?: string;
meals?: CreateMealRequest[];
}
export interface CreateMealRequest {
mealType: MealType;
recipeId: string;
servings?: number;
notes?: string;
order?: number;
}
export interface UpdateMealPlanRequest {
notes?: string;
}
export interface UpdateMealRequest {
mealType?: MealType;
servings?: number;
notes?: string;
order?: number;
}
export interface MealPlanQueryParams {
startDate: string;
endDate: string;
}
export interface ShoppingListItem {
ingredientName: string;
totalAmount: number;
unit: string;
recipes: string[];
}
export interface ShoppingListRequest {
startDate: string;
endDate: string;
}
export interface ShoppingListResponse {
items: ShoppingListItem[];
dateRange: {
start: string;
end: string;
};
recipeCount: number;
}
export interface MealPlanResponse extends ApiResponse<MealPlan> {}
export interface MealPlansResponse extends ApiResponse<MealPlan[]> {}
export interface ShoppingListApiResponse extends ApiResponse<ShoppingListResponse> {}

View File

@@ -260,8 +260,50 @@ function CookbookDetail() {
</div>
</div>
{/* Included Cookbooks */}
{cookbook.cookbooks && cookbook.cookbooks.length > 0 && (
<section className="included-cookbooks-section">
<h2>Included Cookbooks ({cookbook.cookbooks.length})</h2>
<div className="cookbooks-grid">
{cookbook.cookbooks.map((childCookbook) => (
<div
key={childCookbook.id}
className="cookbook-card nested"
onClick={() => navigate(`/cookbooks/${childCookbook.id}`)}
>
{childCookbook.coverImageUrl ? (
<img src={childCookbook.coverImageUrl} alt={childCookbook.name} className="cookbook-cover" />
) : (
<div className="cookbook-cover-placeholder">
<span>📚</span>
</div>
)}
<div className="cookbook-info">
<h3>{childCookbook.name}</h3>
{childCookbook.description && <p className="description">{childCookbook.description}</p>}
<div className="cookbook-stats">
<p className="recipe-count">{childCookbook.recipeCount || 0} recipes</p>
{childCookbook.cookbookCount && childCookbook.cookbookCount > 0 && (
<p className="cookbook-count">{childCookbook.cookbookCount} cookbooks</p>
)}
</div>
{childCookbook.tags && childCookbook.tags.length > 0 && (
<div className="cookbook-tags">
{childCookbook.tags.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
)}
</div>
</div>
))}
</div>
</section>
)}
{/* Results */}
<div className="results-section">
<h2>Recipes</h2>
<p className="results-count">
Showing {filteredRecipes.length} of {cookbook.recipes.length} recipes
</p>

View File

@@ -15,8 +15,12 @@ function Cookbooks() {
const [newCookbookDescription, setNewCookbookDescription] = useState('');
const [autoFilterCategories, setAutoFilterCategories] = useState<string[]>([]);
const [autoFilterTags, setAutoFilterTags] = useState<string[]>([]);
const [autoFilterCookbookTags, setAutoFilterCookbookTags] = useState<string[]>([]);
const [cookbookTags, setCookbookTags] = useState<string[]>([]);
const [categoryInput, setCategoryInput] = useState('');
const [tagInput, setTagInput] = useState('');
const [cookbookTagInput, setCookbookTagInput] = useState('');
const [cookbookTagFilterInput, setCookbookTagFilterInput] = useState('');
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
@@ -68,15 +72,21 @@ function Cookbooks() {
name: newCookbookName,
description: newCookbookDescription || undefined,
autoFilterCategories: autoFilterCategories.length > 0 ? autoFilterCategories : undefined,
autoFilterTags: autoFilterTags.length > 0 ? autoFilterTags : undefined
autoFilterTags: autoFilterTags.length > 0 ? autoFilterTags : undefined,
autoFilterCookbookTags: autoFilterCookbookTags.length > 0 ? autoFilterCookbookTags : undefined,
tags: cookbookTags.length > 0 ? cookbookTags : undefined
});
setNewCookbookName('');
setNewCookbookDescription('');
setAutoFilterCategories([]);
setAutoFilterTags([]);
setAutoFilterCookbookTags([]);
setCookbookTags([]);
setCategoryInput('');
setTagInput('');
setCookbookTagInput('');
setCookbookTagFilterInput('');
setShowCreateModal(false);
loadData(); // Reload cookbooks
} catch (err) {
@@ -109,6 +119,30 @@ function Cookbooks() {
setAutoFilterTags(autoFilterTags.filter(t => t !== tag));
};
const handleAddCookbookTag = () => {
const trimmed = cookbookTagInput.trim();
if (trimmed && !cookbookTags.includes(trimmed)) {
setCookbookTags([...cookbookTags, trimmed]);
setCookbookTagInput('');
}
};
const handleRemoveCookbookTag = (tag: string) => {
setCookbookTags(cookbookTags.filter(t => t !== tag));
};
const handleAddCookbookTagFilter = () => {
const trimmed = cookbookTagFilterInput.trim();
if (trimmed && !autoFilterCookbookTags.includes(trimmed)) {
setAutoFilterCookbookTags([...autoFilterCookbookTags, trimmed]);
setCookbookTagFilterInput('');
}
};
const handleRemoveCookbookTagFilter = (tag: string) => {
setAutoFilterCookbookTags(autoFilterCookbookTags.filter(t => t !== tag));
};
if (loading) {
return (
<div className="cookbooks-page">
@@ -170,7 +204,19 @@ function Cookbooks() {
<div className="cookbook-info">
<h3>{cookbook.name}</h3>
{cookbook.description && <p className="description">{cookbook.description}</p>}
<p className="recipe-count">{cookbook.recipeCount || 0} recipes</p>
<div className="cookbook-stats">
<p className="recipe-count">{cookbook.recipeCount || 0} recipes</p>
{cookbook.cookbookCount && cookbook.cookbookCount > 0 && (
<p className="cookbook-count">{cookbook.cookbookCount} cookbooks</p>
)}
</div>
{cookbook.tags && cookbook.tags.length > 0 && (
<div className="cookbook-tags">
{cookbook.tags.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
)}
</div>
</div>
))}
@@ -305,6 +351,64 @@ function Cookbooks() {
</datalist>
</div>
<div className="form-group">
<label>Cookbook Tags (Optional)</label>
<p className="help-text">Tags to categorize this cookbook (e.g., "holiday", "quick-meals")</p>
<div className="filter-chips">
{cookbookTags.map(tag => (
<span key={tag} className="filter-chip">
{tag}
<button type="button" onClick={() => handleRemoveCookbookTag(tag)}>×</button>
</span>
))}
</div>
<div className="input-with-button">
<input
type="text"
value={cookbookTagInput}
onChange={(e) => setCookbookTagInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCookbookTag())}
placeholder="Add tag"
list="available-cookbook-tags"
/>
<button type="button" onClick={handleAddCookbookTag} className="btn-add-filter">+</button>
</div>
<datalist id="available-cookbook-tags">
{availableTags.map(tag => (
<option key={tag.id} value={tag.name} />
))}
</datalist>
</div>
<div className="form-group">
<label>Auto-Include Cookbooks by Tags (Optional)</label>
<p className="help-text">Other cookbooks with these tags will be automatically included</p>
<div className="filter-chips">
{autoFilterCookbookTags.map(tag => (
<span key={tag} className="filter-chip">
{tag}
<button type="button" onClick={() => handleRemoveCookbookTagFilter(tag)}>×</button>
</span>
))}
</div>
<div className="input-with-button">
<input
type="text"
value={cookbookTagFilterInput}
onChange={(e) => setCookbookTagFilterInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCookbookTagFilter())}
placeholder="Add tag to filter by"
list="available-cookbook-filter-tags"
/>
<button type="button" onClick={handleAddCookbookTagFilter} className="btn-add-filter">+</button>
</div>
<datalist id="available-cookbook-filter-tags">
{availableTags.map(tag => (
<option key={tag.id} value={tag.name} />
))}
</datalist>
</div>
<div className="modal-actions">
<button type="button" onClick={() => setShowCreateModal(false)} className="btn-secondary">
Cancel

View File

@@ -20,9 +20,13 @@ function EditCookbook() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [autoFilterCategories, setAutoFilterCategories] = useState<string[]>([]);
const [autoFilterTags, setAutoFilterTags] = useState<string[]>([]);
const [autoFilterCookbookTags, setAutoFilterCookbookTags] = useState<string[]>([]);
const [cookbookTags, setCookbookTags] = useState<string[]>([]);
const [categoryInput, setCategoryInput] = useState('');
const [tagInput, setTagInput] = useState('');
const [cookbookTagInput, setCookbookTagInput] = useState('');
const [cookbookTagFilterInput, setCookbookTagFilterInput] = useState('');
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
@@ -47,6 +51,8 @@ function EditCookbook() {
setCoverImageUrl(cookbook.coverImageUrl || '');
setAutoFilterCategories(cookbook.autoFilterCategories || []);
setAutoFilterTags(cookbook.autoFilterTags || []);
setAutoFilterCookbookTags(cookbook.autoFilterCookbookTags || []);
setCookbookTags(cookbook.tags || []);
}
setAvailableTags(tagsResponse.data || []);
@@ -86,7 +92,9 @@ function EditCookbook() {
description: description || undefined,
coverImageUrl: coverImageUrl === '' ? '' : (coverImageUrl || undefined),
autoFilterCategories,
autoFilterTags
autoFilterTags,
autoFilterCookbookTags,
tags: cookbookTags
});
navigate(`/cookbooks/${id}`);
@@ -122,6 +130,30 @@ function EditCookbook() {
setAutoFilterTags(autoFilterTags.filter(t => t !== tag));
};
const handleAddCookbookTag = () => {
const trimmed = cookbookTagInput.trim();
if (trimmed && !cookbookTags.includes(trimmed)) {
setCookbookTags([...cookbookTags, trimmed]);
setCookbookTagInput('');
}
};
const handleRemoveCookbookTag = (tag: string) => {
setCookbookTags(cookbookTags.filter(t => t !== tag));
};
const handleAddCookbookTagFilter = () => {
const trimmed = cookbookTagFilterInput.trim();
if (trimmed && !autoFilterCookbookTags.includes(trimmed)) {
setAutoFilterCookbookTags([...autoFilterCookbookTags, trimmed]);
setCookbookTagFilterInput('');
}
};
const handleRemoveCookbookTagFilter = (tag: string) => {
setAutoFilterCookbookTags(autoFilterCookbookTags.filter(t => t !== tag));
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
@@ -373,6 +405,72 @@ function EditCookbook() {
</datalist>
</div>
<div className="form-group">
<label>Cookbook Tags</label>
<p className="help-text">
Tags to categorize this cookbook (e.g., "holiday", "quick-meals", "vegetarian")
</p>
<div className="filter-chips">
{cookbookTags.map(tag => (
<span key={tag} className="filter-chip">
{tag}
<button type="button" onClick={() => handleRemoveCookbookTag(tag)}>×</button>
</span>
))}
</div>
<div className="input-with-button">
<input
type="text"
value={cookbookTagInput}
onChange={(e) => setCookbookTagInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCookbookTag())}
placeholder="Add tag"
list="available-cookbook-tags-edit"
/>
<button type="button" onClick={handleAddCookbookTag} className="btn-add-filter">
+
</button>
</div>
<datalist id="available-cookbook-tags-edit">
{availableTags.map(tag => (
<option key={tag.id} value={tag.name} />
))}
</datalist>
</div>
<div className="form-group">
<label>Auto-Include Cookbooks by Tags</label>
<p className="help-text">
Other cookbooks with these tags will be automatically included in this cookbook
</p>
<div className="filter-chips">
{autoFilterCookbookTags.map(tag => (
<span key={tag} className="filter-chip">
{tag}
<button type="button" onClick={() => handleRemoveCookbookTagFilter(tag)}>×</button>
</span>
))}
</div>
<div className="input-with-button">
<input
type="text"
value={cookbookTagFilterInput}
onChange={(e) => setCookbookTagFilterInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCookbookTagFilter())}
placeholder="Add tag to filter by"
list="available-cookbook-filter-tags-edit"
/>
<button type="button" onClick={handleAddCookbookTagFilter} className="btn-add-filter">
+
</button>
</div>
<datalist id="available-cookbook-filter-tags-edit">
{availableTags.map(tag => (
<option key={tag.id} value={tag.name} />
))}
</datalist>
</div>
<div className="form-actions">
<button type="button" onClick={() => navigate(`/cookbooks/${id}`)} className="btn-secondary">
Cancel

View File

@@ -1,5 +1,22 @@
import axios from 'axios';
import { Recipe, RecipeImportRequest, RecipeImportResponse, ApiResponse, PaginatedResponse, Cookbook, CookbookWithRecipes, Tag } from '@basil/shared';
import {
Recipe,
RecipeImportRequest,
RecipeImportResponse,
ApiResponse,
PaginatedResponse,
Cookbook,
CookbookWithRecipes,
Tag,
MealPlan,
MealPlanQueryParams,
CreateMealPlanRequest,
UpdateMealPlanRequest,
CreateMealRequest,
UpdateMealRequest,
ShoppingListRequest,
ShoppingListResponse
} from '@basil/shared';
const api = axios.create({
baseURL: '/api',
@@ -8,6 +25,20 @@ const api = axios.create({
},
});
// Add request interceptor to inject auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('basil_access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
export const recipesApi = {
getAll: async (params?: {
page?: number;
@@ -74,8 +105,10 @@ export const recipesApi = {
};
export const cookbooksApi = {
getAll: async (): Promise<ApiResponse<Cookbook[]>> => {
const response = await api.get('/cookbooks');
getAll: async (includeChildren: boolean = false): Promise<ApiResponse<Cookbook[]>> => {
const response = await api.get('/cookbooks', {
params: { includeChildren: includeChildren.toString() }
});
return response.data;
},
@@ -84,12 +117,12 @@ export const cookbooksApi = {
return response.data;
},
create: async (cookbook: { name: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[] }): Promise<ApiResponse<Cookbook>> => {
create: async (cookbook: { name: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[]; autoFilterCookbookTags?: string[]; tags?: string[] }): Promise<ApiResponse<Cookbook>> => {
const response = await api.post('/cookbooks', cookbook);
return response.data;
},
update: async (id: string, cookbook: { name?: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[] }): Promise<ApiResponse<Cookbook>> => {
update: async (id: string, cookbook: { name?: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[]; autoFilterCookbookTags?: string[]; tags?: string[] }): Promise<ApiResponse<Cookbook>> => {
const response = await api.put(`/cookbooks/${id}`, cookbook);
return response.data;
},
@@ -109,6 +142,16 @@ export const cookbooksApi = {
return response.data;
},
addCookbook: async (cookbookId: string, childCookbookId: string): Promise<ApiResponse<void>> => {
const response = await api.post(`/cookbooks/${cookbookId}/cookbooks/${childCookbookId}`);
return response.data;
},
removeCookbook: async (cookbookId: string, childCookbookId: string): Promise<ApiResponse<void>> => {
const response = await api.delete(`/cookbooks/${cookbookId}/cookbooks/${childCookbookId}`);
return response.data;
},
uploadImage: async (id: string, file: File): Promise<ApiResponse<{ url: string }>> => {
const formData = new FormData();
formData.append('image', file);
@@ -141,4 +184,56 @@ export const tagsApi = {
},
};
export const mealPlansApi = {
getAll: async (params: MealPlanQueryParams): Promise<ApiResponse<MealPlan[]>> => {
const response = await api.get('/meal-plans', { params });
return response.data;
},
getByDate: async (date: string): Promise<ApiResponse<MealPlan | null>> => {
const response = await api.get(`/meal-plans/date/${date}`);
return response.data;
},
getById: async (id: string): Promise<ApiResponse<MealPlan>> => {
const response = await api.get(`/meal-plans/${id}`);
return response.data;
},
create: async (data: CreateMealPlanRequest): Promise<ApiResponse<MealPlan>> => {
const response = await api.post('/meal-plans', data);
return response.data;
},
update: async (id: string, data: UpdateMealPlanRequest): Promise<ApiResponse<MealPlan>> => {
const response = await api.put(`/meal-plans/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<ApiResponse<void>> => {
const response = await api.delete(`/meal-plans/${id}`);
return response.data;
},
addMeal: async (mealPlanId: string, meal: CreateMealRequest): Promise<ApiResponse<any>> => {
const response = await api.post(`/meal-plans/${mealPlanId}/meals`, meal);
return response.data;
},
updateMeal: async (mealId: string, meal: UpdateMealRequest): Promise<ApiResponse<any>> => {
const response = await api.put(`/meal-plans/meals/${mealId}`, meal);
return response.data;
},
removeMeal: async (mealId: string): Promise<ApiResponse<void>> => {
const response = await api.delete(`/meal-plans/meals/${mealId}`);
return response.data;
},
generateShoppingList: async (params: ShoppingListRequest): Promise<ApiResponse<ShoppingListResponse>> => {
const response = await api.post('/meal-plans/shopping-list', params);
return response.data;
},
};
export default api;

View File

@@ -431,3 +431,30 @@
grid-template-columns: 1fr;
}
}
/* Included Cookbooks Section */
.included-cookbooks-section {
margin: 2rem 0;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
}
.included-cookbooks-section h2 {
margin-bottom: 1rem;
color: #333;
font-size: 1.5rem;
}
.cookbook-card.nested {
border: 2px solid #e0e0e0;
background: white;
cursor: pointer;
transition: all 0.2s ease;
}
.cookbook-card.nested:hover {
border-color: #2e7d32;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transform: translateY(-2px);
}

View File

@@ -416,3 +416,31 @@
grid-template-columns: 1fr;
}
}
/* Cookbook stats (recipe count and cookbook count) */
.cookbook-stats {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.cookbook-count {
font-size: 0.875rem;
color: #666;
}
/* Cookbook tags */
.cookbook-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.cookbook-tags .tag {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: #e3f2fd;
color: #1976d2;
border-radius: 12px;
}