Files
basil/packages/web/src/pages/RecipeDetail.tsx
Paul R Kartchner d1156833a2
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
feat: add CI/CD pipeline, backup system, and deployment automation
## 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>
2025-12-08 05:04:39 +00:00

341 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;