392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
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; |