Files
Rezepte/nodejs-version/frontend/src/components/RecipeDetail.tsx
2025-09-22 16:35:59 +02:00

392 lines
13 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 React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import type { Recipe } from '../services/api';
import { recipeApi, imageApi } from '../services/api';
import FileUpload from './FileUpload';
import './RecipeDetail.css';
// Helper function to convert URLs in text to clickable links
const linkifyText = (text: string): React.ReactNode => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
return parts.map((part, index) => {
if (urlRegex.test(part)) {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="recipe-link"
>
{part}
</a>
);
}
return part;
});
};
const RecipeDetail: React.FC = () => {
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 [editingImages, setEditingImages] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
useEffect(() => {
const loadRecipe = async () => {
if (!id) {
setError('Keine Rezept-ID angegeben');
setLoading(false);
return;
}
try {
setError(null);
const response = await recipeApi.getRecipe(parseInt(id));
if (response.success) {
setRecipe(response.data);
} else {
setError('Rezept konnte nicht geladen werden');
}
} catch (err) {
setError('Verbindungsfehler - Ist der Server gestartet?');
console.error('Error loading recipe:', err);
} finally {
setLoading(false);
}
};
loadRecipe();
}, [id]);
const handleImageUpload = async (files: File[]) => {
if (!recipe || !id) return;
try {
setUploadProgress(0);
await imageApi.uploadImages(parseInt(id), files, setUploadProgress);
// Reload recipe to get updated images
const response = await recipeApi.getRecipe(parseInt(id));
if (response.success) {
setRecipe(response.data);
}
setUploadProgress(0);
} catch (error) {
console.error('Error uploading images:', error);
setError('Fehler beim Hochladen der Bilder');
}
};
const handleImageDelete = async (imageId: number) => {
if (!recipe || !id) return;
try {
await imageApi.deleteImage(imageId);
// Reload recipe to get updated images
const response = await recipeApi.getRecipe(parseInt(id));
if (response.success) {
setRecipe(response.data);
}
} catch (error) {
console.error('Error deleting image:', error);
setError('Fehler beim Löschen des Bildes');
}
};
if (loading) {
return (
<div className="recipe-detail">
<div className="loading">Lade Rezept...</div>
</div>
);
}
if (error) {
return (
<div className="recipe-detail">
<div className="error">
<h3>Fehler</h3>
<p>{error}</p>
<div className="error-actions">
<button onClick={() => window.location.reload()} className="retry-button">
Erneut versuchen
</button>
<Link to="/" className="back-button">
Zurück zur Liste
</Link>
</div>
</div>
</div>
);
}
if (!recipe) {
return (
<div className="recipe-detail">
<div className="not-found">
<h3>Rezept nicht gefunden</h3>
<p>Das angeforderte Rezept existiert nicht.</p>
<Link to="/" className="back-button">
Zurück zur Liste
</Link>
</div>
</div>
);
}
return (
<div className="recipe-detail">
{/* Header */}
<div className="recipe-header">
<div className="breadcrumb">
<Link to="/" className="breadcrumb-link">Alle Rezepte</Link>
<span className="breadcrumb-separator"></span>
<span className="breadcrumb-current">{recipe.title}</span>
</div>
<div className="recipe-actions">
<button
onClick={() => setEditingImages(!editingImages)}
className="edit-button"
style={{ marginRight: '8px' }}
>
📸 {editingImages ? 'Fertig' : 'Bilder verwalten'}
</button>
<Link to={`/recipes/${recipe.id}/edit`} className="edit-button">
Bearbeiten
</Link>
<button onClick={() => navigate(-1)} className="back-button">
Zurück
</button>
</div>
</div>
{/* Main Content */}
<div className="recipe-content">
{/* Recipe Info - Full Width */}
<div className="recipe-info">
<div className="recipe-title-section">
<h1 className="recipe-title">{recipe.title}</h1>
{recipe.recipeNumber && (
<span className="recipe-number">#{recipe.recipeNumber}</span>
)}
</div>
{/* Hauptbild (xxx_0.jpg) */}
{recipe.images && recipe.images.length > 0 && (
<div className="main-recipe-image">
{(() => {
// Find the main image (xxx_0.jpg)
const mainImage = recipe.images.find(image => {
const fileName = image.filePath.split('/').pop() || '';
return fileName.includes('_0.jpg');
});
if (mainImage) {
return (
<img
src={imageApi.getImageUrl(mainImage.filePath)}
alt={`${recipe.title} - Hauptbild`}
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
);
}
return null;
})()}
</div>
)}
<div className="recipe-meta">
{recipe.category && (
<div className="meta-item">
<span className="meta-label">Kategorie:</span>
<span className="meta-value">{recipe.category}</span>
</div>
)}
<div className="meta-item">
<span className="meta-label">Portionen:</span>
<span className="meta-value">👥 {recipe.servings}</span>
</div>
</div>
</div>
{/* Image Management Section */}
{editingImages && (
<div className="image-management">
<h3>Bilder verwalten</h3>
{/* Upload new images */}
<div className="upload-section">
<h4>Neue Bilder hochladen</h4>
<FileUpload
onFilesSelected={handleImageUpload}
maxFiles={5}
maxFileSize={5}
disabled={uploadProgress > 0}
/>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="upload-progress">
<div className="upload-progress-bar" style={{ width: `${uploadProgress}%` }}></div>
<span className="upload-progress-text">{uploadProgress}% hochgeladen</span>
</div>
)}
</div>
{/* Existing images */}
{recipe.images && recipe.images.length > 0 && (
<div className="existing-images">
<h4>Vorhandene Bilder ({recipe.images.length})</h4>
<div className="images-grid">
{recipe.images.map((image, index) => (
<div key={image.id} className="image-item">
<div className="image-preview">
<img
src={imageApi.getImageUrl(image.filePath)}
alt={`Bild ${index + 1}`}
/>
<button
className="delete-image-btn"
onClick={() => handleImageDelete(image.id)}
title="Bild löschen"
>
🗑
</button>
</div>
<div className="image-info">
<span className="image-name">
{image.filePath.split('/').pop()}
</span>
{image.filePath.includes('_0.jpg') && (
<span className="main-image-badge">Hauptbild</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Two Column Layout for Description/Ingredients and Preparation */}
<div className="recipe-columns">
{/* Left Column - Description and Ingredients */}
<div className="recipe-sidebar">
{recipe.description && (
<div className="recipe-description">
<h3>Beschreibung</h3>
<p>{linkifyText(recipe.description)}</p>
</div>
)}
{/* Zutaten */}
{recipe.ingredients && (
<div className="recipe-section">
<h3>Zutaten</h3>
<div className="ingredients-content">
{recipe.ingredients.split('\n').map((ingredient, index) => (
<div key={index} className="ingredient-item">
{ingredient.trim()}
</div>
))}
</div>
</div>
)}
</div>
{/* Right Column - Preparation and Instructions */}
<div className="recipe-main-content">
{/* Vorbereitung */}
{recipe.preparation && (
<div className="recipe-section">
<h3>Vorbereitung</h3>
<div className="preparation-content">
{recipe.preparation.split('\n').map((step, index) => (
<p key={index} className="preparation-step">
{step.trim()}
</p>
))}
</div>
</div>
)}
{/* Anweisungen */}
{recipe.instructions && (
<div className="recipe-section">
<h3>Zubereitung</h3>
<div className="instructions-content">
{(() => {
// Get all preparation images (exclude main image _0.jpg)
const preparationImages = recipe.images
?.filter(image => {
const fileName = image.filePath.split('/').pop() || '';
// Match pattern like R005_1.jpg, R005_2.jpg, etc. but not R005_0.jpg
return fileName.match(/_[1-9]\d*\.jpg$/);
})
.sort((a, b) => {
// Sort by the number in the filename
const getNumber = (path: string) => {
const match = path.match(/_(\d+)\.jpg$/);
return match ? parseInt(match[1]) : 0;
};
return getNumber(a.filePath) - getNumber(b.filePath);
}) || [];
return recipe.instructions.split('\n').map((instruction, index) => {
// Get the corresponding image for this step
const stepImage = preparationImages[index];
return (
<div key={index} className="instruction-step-with-image">
{stepImage && (
<div className="step-image">
<img
src={imageApi.getImageUrl(stepImage.filePath)}
alt={`${recipe.title} - Schritt ${index + 1}`}
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
</div>
)}
<div className="instruction-step">
<span className="step-number">{index + 1}</span>
<p className="step-text">{instruction.trim()}</p>
</div>
</div>
);
});
})()}
</div>
</div>
)}
{/* Kommentar */}
{recipe.comment && (
<div className="recipe-section">
<h3>Tipp</h3>
<div className="comment-content">
<p className="recipe-comment">{recipe.comment}</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default RecipeDetail;