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