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

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