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