feat: add cookbooks, multiple categories, and image management
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled

Major features added:
- Cookbook management with CRUD operations
- Auto-filter cookbooks by categories and tags
- Multiple categories per recipe (changed from single category)
- Image upload and URL download for cookbooks
- Improved image management UI

Database changes:
- Changed Recipe.category (string) to Recipe.categories (string array)
- Added Cookbook and CookbookRecipe models
- Added Tag and RecipeTag models for recipe tagging

Backend changes:
- Added cookbooks API routes with image upload
- Added tags API routes
- Added auto-filter functionality to add recipes to cookbooks automatically
- Added downloadAndSaveImage() to StorageService for URL downloads
- Updated recipes routes to support multiple categories

Frontend changes:
- Added Cookbooks page with grid view
- Added CookbookDetail page with filtering
- Added EditCookbook page with image upload/download
- Updated recipe forms to use chip-based UI for multiple categories
- Improved image upload UX with separate file upload and URL download
- Added remove image functionality with immediate save

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-02 05:19:34 +00:00
parent d6fceccba5
commit 6d6abd7729
26 changed files with 4380 additions and 146 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Recipe, Ingredient, Instruction, RecipeSection } from '@basil/shared';
import { recipesApi } from '../services/api';
import { Recipe, Ingredient, Instruction, RecipeSection, Tag } from '@basil/shared';
import { recipesApi, tagsApi } from '../services/api';
import '../styles/UnifiedRecipeEdit.css';
interface MappingChange {
@@ -26,7 +26,11 @@ function UnifiedEditRecipe() {
const [cookTime, setCookTime] = useState('');
const [servings, setServings] = useState('');
const [cuisine, setCuisine] = useState('');
const [category, setCategory] = useState('');
const [recipeCategories, setRecipeCategories] = useState<string[]>([]);
const [categoryInput, setCategoryInput] = useState('');
const [recipeTags, setRecipeTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState('');
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
// Section mode
const [useSections, setUseSections] = useState(false);
@@ -60,8 +64,18 @@ function UnifiedEditRecipe() {
if (id) {
loadRecipe(id);
}
loadAvailableTags();
}, [id]);
const loadAvailableTags = async () => {
try {
const response = await tagsApi.getAll();
setAvailableTags(response.data || []);
} catch (err) {
console.error('Failed to load tags:', err);
}
};
const loadRecipe = async (recipeId: string) => {
try {
setLoading(true);
@@ -77,7 +91,8 @@ function UnifiedEditRecipe() {
setCookTime(loadedRecipe.cookTime?.toString() || '');
setServings(loadedRecipe.servings?.toString() || '');
setCuisine(loadedRecipe.cuisine || '');
setCategory(loadedRecipe.category || '');
setRecipeCategories(loadedRecipe.categories || []);
setRecipeTags(loadedRecipe.tags || []);
// Set sections or simple mode
const hasSections = !!(loadedRecipe.sections && loadedRecipe.sections.length > 0);
@@ -450,7 +465,8 @@ function UnifiedEditRecipe() {
cookTime: cookTime ? parseInt(cookTime) : undefined,
servings: servings ? parseInt(servings) : undefined,
cuisine: cuisine || undefined,
category: category || undefined,
categories: recipeCategories.length > 0 ? recipeCategories : undefined,
tags: recipeTags,
};
if (useSections) {
@@ -569,6 +585,68 @@ function UnifiedEditRecipe() {
navigate(`/recipes/${id}`);
};
// Category management functions
const handleAddCategory = (categoryName: string) => {
const trimmedCategory = categoryName.trim();
if (!trimmedCategory) return;
if (recipeCategories.includes(trimmedCategory)) {
setCategoryInput('');
return; // Category already exists
}
setRecipeCategories([...recipeCategories, trimmedCategory]);
setCategoryInput('');
setHasChanges(true);
};
const handleRemoveCategory = (categoryToRemove: string) => {
setRecipeCategories(recipeCategories.filter(cat => cat !== categoryToRemove));
setHasChanges(true);
};
const handleCategoryInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCategory(categoryInput);
}
};
// Tag management functions
const handleAddTag = async (tagName: string) => {
const trimmedTag = tagName.trim();
if (!trimmedTag) return;
if (recipeTags.includes(trimmedTag)) {
setTagInput('');
return; // Tag already exists
}
// Create or find tag in database (for autocomplete purposes)
try {
await tagsApi.createOrFind(trimmedTag);
await loadAvailableTags(); // Refresh available tags
} catch (err) {
console.error('Failed to create tag:', err);
}
setRecipeTags([...recipeTags, trimmedTag]);
setTagInput('');
setHasChanges(true);
};
const handleRemoveTag = (tagToRemove: string) => {
setRecipeTags(recipeTags.filter(tag => tag !== tagToRemove));
setHasChanges(true);
};
const handleTagInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag(tagInput);
}
};
const getIngredientText = (ingredient: Ingredient): string => {
let ingredientStr = '';
if (ingredient.amount && ingredient.unit) {
@@ -743,20 +821,93 @@ function UnifiedEditRecipe() {
/>
</div>
<div className="form-group">
<label htmlFor="category">Category</label>
<input
type="text"
id="category"
value={category}
onChange={(e) => {
setCategory(e.target.value);
setHasChanges(true);
}}
/>
</div>
{/* Categories */}
<div className="form-group">
<label htmlFor="categories">Categories</label>
<div className="tags-input-container">
<div className="tags-list">
{recipeCategories.map((category) => (
<span key={category} className="tag">
{category}
<button
type="button"
onClick={() => handleRemoveCategory(category)}
className="tag-remove"
title="Remove category"
>
×
</button>
</span>
))}
</div>
<div className="tag-input-row">
<input
type="text"
id="categories"
value={categoryInput}
onChange={(e) => setCategoryInput(e.target.value)}
onKeyDown={handleCategoryInputKeyDown}
placeholder="Add a category and press Enter"
/>
<button
type="button"
onClick={() => handleAddCategory(categoryInput)}
className="btn-add-tag"
>
Add Category
</button>
</div>
</div>
</div>
{/* Tags */}
<div className="form-group">
<label htmlFor="tags">Tags</label>
<div className="tags-input-container">
<div className="tags-list">
{recipeTags.map((tag) => (
<span key={tag} className="tag">
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="tag-remove"
title="Remove tag"
>
×
</button>
</span>
))}
</div>
<div className="tag-input-row">
<input
type="text"
id="tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagInputKeyDown}
placeholder="Add a tag and press Enter"
list="available-tags"
/>
<button
type="button"
onClick={() => handleAddTag(tagInput)}
className="btn-add-tag"
>
Add Tag
</button>
</div>
<datalist id="available-tags">
{availableTags.map((tag) => (
<option key={tag.id} value={tag.name} />
))}
</datalist>
</div>
<p className="field-help">Add tags to categorize your recipe (e.g., "Quick", "Vegetarian", "Dessert")</p>
</div>
{/* Image Upload */}
<div className="form-group image-upload-section">
<label>Recipe Image</label>