Some checks failed
CI/CD Pipeline / Run Tests (pull_request) Has been cancelled
CI/CD Pipeline / Code Quality (pull_request) Has been cancelled
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Dependency License Check (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
CI/CD Pipeline / Build and Push Docker Images (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled
## Summary - Add complete CI/CD pipeline with Gitea Actions for automated testing, building, and deployment - Implement backup and restore system with full database and file backup to ZIP - Add deployment automation with webhook receiver and systemd service - Enhance recipe editing UI with improved ingredient parsing and cooking mode features - Add comprehensive documentation for CI/CD, deployment, and backup features ## CI/CD Pipeline - New workflow in .gitea/workflows/ci-cd.yml with test, build, and deploy stages - Automated Docker image building and pushing to registry - Webhook-triggered deployments to production servers ## Backup & Restore - New backup service with ZIP creation including database dump and uploads - REST API endpoints for create, list, download, restore, and delete operations - Configurable backup path via BACKUP_PATH environment variable ## Deployment - Automated deployment scripts (deploy.sh, manual-deploy.sh) - Webhook receiver with systemd service for deployment triggers - Environment configuration template (.env.deploy.example) ## Documentation - docs/CI-CD-SETUP.md - Complete CI/CD pipeline setup guide - docs/DEPLOYMENT-QUICK-START.md - Quick deployment reference - docs/BACKUP.md - Backup and restore documentation - docs/REMOTE_DATABASE.md - Remote database configuration guide - scripts/README.md - Deployment scripts documentation ## Web Improvements - Enhanced ingredient parser with better unit and quantity detection - Improved recipe editing interface with unified edit experience - Better cooking mode functionality - Updated dependencies in package.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { Recipe, Cookbook } from '@basil/shared';
|
||
import { recipesApi, cookbooksApi } from '../services/api';
|
||
import { scaleIngredientString } from '../utils/ingredientParser';
|
||
|
||
function RecipeDetail() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const navigate = useNavigate();
|
||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [currentServings, setCurrentServings] = useState<number | null>(null);
|
||
const [showCookbookModal, setShowCookbookModal] = useState(false);
|
||
const [cookbooks, setCookbooks] = useState<Cookbook[]>([]);
|
||
const [loadingCookbooks, setLoadingCookbooks] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (id) {
|
||
loadRecipe(id);
|
||
}
|
||
}, [id]);
|
||
|
||
const loadRecipe = async (recipeId: string) => {
|
||
try {
|
||
setLoading(true);
|
||
const response = await recipesApi.getById(recipeId);
|
||
const loadedRecipe = response.data || null;
|
||
setRecipe(loadedRecipe);
|
||
setCurrentServings(loadedRecipe?.servings || null);
|
||
setError(null);
|
||
} catch (err) {
|
||
setError('Failed to load recipe');
|
||
console.error(err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const incrementServings = () => {
|
||
if (currentServings !== null) {
|
||
setCurrentServings(currentServings + 1);
|
||
}
|
||
};
|
||
|
||
const decrementServings = () => {
|
||
if (currentServings !== null && currentServings > 1) {
|
||
setCurrentServings(currentServings - 1);
|
||
}
|
||
};
|
||
|
||
const resetServings = () => {
|
||
setCurrentServings(recipe?.servings || null);
|
||
};
|
||
|
||
const scaleServings = (multiplier: number) => {
|
||
if (recipe?.servings) {
|
||
const newServings = Math.round(recipe.servings * multiplier);
|
||
setCurrentServings(newServings > 0 ? newServings : 1);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!id || !confirm('Are you sure you want to delete this recipe?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await recipesApi.delete(id);
|
||
navigate('/');
|
||
} catch (err) {
|
||
setError('Failed to delete recipe');
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
const handleOpenCookbookModal = async () => {
|
||
setLoadingCookbooks(true);
|
||
setShowCookbookModal(true);
|
||
try {
|
||
const response = await cookbooksApi.getAll();
|
||
setCookbooks(response.data || []);
|
||
} catch (err) {
|
||
console.error('Failed to load cookbooks:', err);
|
||
alert('Failed to load cookbooks');
|
||
} finally {
|
||
setLoadingCookbooks(false);
|
||
}
|
||
};
|
||
|
||
const handleAddToCookbook = async (cookbookId: string) => {
|
||
if (!id) return;
|
||
|
||
try {
|
||
await cookbooksApi.addRecipe(cookbookId, id);
|
||
alert('Recipe added to cookbook!');
|
||
setShowCookbookModal(false);
|
||
} catch (err: any) {
|
||
if (err.response?.status === 400) {
|
||
alert('Recipe is already in this cookbook');
|
||
} else {
|
||
console.error('Failed to add recipe to cookbook:', err);
|
||
alert('Failed to add recipe to cookbook');
|
||
}
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return <div className="loading">Loading recipe...</div>;
|
||
}
|
||
|
||
if (error) {
|
||
return <div className="error">{error}</div>;
|
||
}
|
||
|
||
if (!recipe) {
|
||
return <div className="error">Recipe not found</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="recipe-detail">
|
||
<div className="recipe-actions">
|
||
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
||
<div className="recipe-actions-right">
|
||
<button onClick={handleOpenCookbookModal} style={{ backgroundColor: '#1976d2' }}>
|
||
📚 Add to Cookbook
|
||
</button>
|
||
<button onClick={() => navigate(`/recipes/${id}/cook`)} style={{ backgroundColor: '#2e7d32' }}>
|
||
👨🍳 Cooking Mode
|
||
</button>
|
||
<button onClick={() => navigate(`/recipes/${id}/edit`)}>Edit Recipe</button>
|
||
<button onClick={handleDelete} style={{ backgroundColor: '#d32f2f' }}>
|
||
Delete Recipe
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{recipe.imageUrl && <img src={recipe.imageUrl} alt={recipe.title} />}
|
||
|
||
<h2>{recipe.title}</h2>
|
||
|
||
{recipe.description && <p>{recipe.description}</p>}
|
||
|
||
<div className="recipe-meta">
|
||
{recipe.prepTime && <span>Prep: {recipe.prepTime} min</span>}
|
||
{recipe.cookTime && <span>Cook: {recipe.cookTime} min</span>}
|
||
{recipe.totalTime && <span>Total: {recipe.totalTime} min</span>}
|
||
{recipe.servings && currentServings !== null && (
|
||
<div className="servings-control">
|
||
<div className="servings-adjuster">
|
||
<button onClick={decrementServings} disabled={currentServings <= 1}>
|
||
−
|
||
</button>
|
||
<span>Servings: {currentServings}</span>
|
||
<button onClick={incrementServings}>
|
||
+
|
||
</button>
|
||
{currentServings !== recipe.servings && (
|
||
<button onClick={resetServings} className="reset-button">
|
||
Reset
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="quick-scale-buttons">
|
||
<button onClick={() => scaleServings(0.5)} className="scale-button" title="Half recipe">
|
||
½×
|
||
</button>
|
||
<button onClick={() => scaleServings(1.5)} className="scale-button" title="1.5× recipe">
|
||
1.5×
|
||
</button>
|
||
<button onClick={() => scaleServings(2)} className="scale-button" title="Double recipe">
|
||
2×
|
||
</button>
|
||
<button onClick={() => scaleServings(3)} className="scale-button" title="Triple recipe">
|
||
3×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{recipe.sourceUrl && (
|
||
<p>
|
||
<strong>Source: </strong>
|
||
<a href={recipe.sourceUrl} target="_blank" rel="noopener noreferrer">
|
||
{recipe.sourceUrl}
|
||
</a>
|
||
</p>
|
||
)}
|
||
|
||
{/* Display sections if they exist */}
|
||
{recipe.sections && recipe.sections.length > 0 ? (
|
||
<div className="recipe-sections">
|
||
{recipe.sections.map((section) => (
|
||
<div key={section.id} className="recipe-section">
|
||
<div className="section-header">
|
||
<h3>{section.name}</h3>
|
||
{section.timing && <span className="section-timing">{section.timing}</span>}
|
||
</div>
|
||
|
||
{section.ingredients && section.ingredients.length > 0 && (
|
||
<div className="ingredients">
|
||
<h4>Ingredients</h4>
|
||
<ul>
|
||
{section.ingredients.map((ingredient, index) => {
|
||
let ingredientStr = '';
|
||
if (ingredient.amount && ingredient.unit) {
|
||
ingredientStr = `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`;
|
||
} else if (ingredient.amount) {
|
||
ingredientStr = `${ingredient.amount} ${ingredient.name}`;
|
||
} else {
|
||
ingredientStr = ingredient.name;
|
||
}
|
||
|
||
const displayStr =
|
||
recipe.servings && currentServings && recipe.servings !== currentServings
|
||
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||
: ingredientStr;
|
||
|
||
return (
|
||
<li key={index}>
|
||
{displayStr}
|
||
{ingredient.notes && ` (${ingredient.notes})`}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{section.instructions && section.instructions.length > 0 && (
|
||
<div className="instructions">
|
||
<h4>Instructions</h4>
|
||
<ol>
|
||
{section.instructions.map((instruction) => (
|
||
<li key={instruction.step}>
|
||
{instruction.timing && (
|
||
<strong className="instruction-timing">{instruction.timing}: </strong>
|
||
)}
|
||
{instruction.text}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Display regular ingredients and instructions if no sections */}
|
||
{recipe.ingredients && recipe.ingredients.length > 0 && (
|
||
<div className="ingredients">
|
||
<h3>Ingredients</h3>
|
||
<ul>
|
||
{recipe.ingredients.map((ingredient, index) => {
|
||
let ingredientStr = '';
|
||
if (ingredient.amount && ingredient.unit) {
|
||
ingredientStr = `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`;
|
||
} else if (ingredient.amount) {
|
||
ingredientStr = `${ingredient.amount} ${ingredient.name}`;
|
||
} else {
|
||
ingredientStr = ingredient.name;
|
||
}
|
||
|
||
const displayStr =
|
||
recipe.servings && currentServings && recipe.servings !== currentServings
|
||
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||
: ingredientStr;
|
||
|
||
return (
|
||
<li key={index}>
|
||
{displayStr}
|
||
{ingredient.notes && ` (${ingredient.notes})`}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{recipe.instructions && recipe.instructions.length > 0 && (
|
||
<div className="instructions">
|
||
<h3>Instructions</h3>
|
||
<ol>
|
||
{recipe.instructions.map((instruction) => (
|
||
<li key={instruction.step}>
|
||
{instruction.timing && (
|
||
<strong className="instruction-timing">{instruction.timing}: </strong>
|
||
)}
|
||
{instruction.text}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Add to Cookbook Modal */}
|
||
{showCookbookModal && (
|
||
<div className="modal-overlay" onClick={() => setShowCookbookModal(false)}>
|
||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||
<h2>Add to Cookbook</h2>
|
||
{loadingCookbooks ? (
|
||
<p>Loading cookbooks...</p>
|
||
) : cookbooks.length === 0 ? (
|
||
<div>
|
||
<p>You don't have any cookbooks yet.</p>
|
||
<button onClick={() => navigate('/')} className="btn-primary">
|
||
Create a Cookbook
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="cookbook-list">
|
||
{cookbooks.map((cookbook) => (
|
||
<div key={cookbook.id} className="cookbook-item" onClick={() => handleAddToCookbook(cookbook.id)}>
|
||
<div>
|
||
<h3>{cookbook.name}</h3>
|
||
{cookbook.description && <p>{cookbook.description}</p>}
|
||
<span className="recipe-count">{cookbook.recipeCount || 0} recipes</span>
|
||
</div>
|
||
<button className="btn-add">+</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="modal-actions">
|
||
<button onClick={() => setShowCookbookModal(false)} className="btn-secondary">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default RecipeDetail;
|