Local (esprimo) funktioniert, remote noch nicht
This commit is contained in:
@@ -39,6 +39,7 @@ const RecipeDetail: React.FC = () => {
|
||||
const [markingCooked, setMarkingCooked] = useState(false);
|
||||
const [showCookModal, setShowCookModal] = useState(false);
|
||||
const [cookDate, setCookDate] = useState<string>(''); // YYYY-MM-DD
|
||||
const [imageCacheBuster, setImageCacheBuster] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const loadRecipe = async () => {
|
||||
@@ -118,6 +119,8 @@ const RecipeDetail: React.FC = () => {
|
||||
try {
|
||||
// Send ordered IDs
|
||||
await imageApi.reorderImages(parseInt(id), newOrder.map(i => i.id));
|
||||
// Update cache buster to force re-loading of images
|
||||
setImageCacheBuster(Date.now());
|
||||
// Reload recipe to pick up new filenames (indexes changed)
|
||||
const response = await recipeApi.getRecipe(parseInt(id));
|
||||
if (response.success) setRecipe(response.data);
|
||||
@@ -213,6 +216,22 @@ const RecipeDetail: React.FC = () => {
|
||||
const m = fileName.match(/_(\d+)\.(jpg|jpeg|png|webp)$/i);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
};
|
||||
|
||||
// Sort images by filename index (_0, _1, _2...)
|
||||
const getSortedImages = (images: typeof recipe.images) => {
|
||||
if (!images) return [];
|
||||
return [...images].sort((a, b) => {
|
||||
const indexA = getStepIndex(getFileName(a.filePath));
|
||||
const indexB = getStepIndex(getFileName(b.filePath));
|
||||
return indexA - indexB;
|
||||
});
|
||||
};
|
||||
|
||||
// Get image URL with cache busting parameter
|
||||
const getCachedImageUrl = (filePath: string) => {
|
||||
const baseUrl = imageApi.getImageUrl(filePath);
|
||||
return `${baseUrl}?t=${imageCacheBuster}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="recipe-detail">
|
||||
@@ -291,7 +310,7 @@ const RecipeDetail: React.FC = () => {
|
||||
if (mainImage) {
|
||||
return (
|
||||
<img
|
||||
src={imageApi.getImageUrl(mainImage.filePath)}
|
||||
src={getCachedImageUrl(mainImage.filePath)}
|
||||
alt={`${recipe.title} - Hauptbild`}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
@@ -349,32 +368,34 @@ const RecipeDetail: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Existing images */}
|
||||
{recipe.images && recipe.images.length > 0 && (
|
||||
{recipe.images && recipe.images.length > 0 && (() => {
|
||||
const sortedImages = getSortedImages(recipe.images);
|
||||
return (
|
||||
<div className="existing-images">
|
||||
<h4>Vorhandene Bilder ({recipe.images.length})</h4>
|
||||
<div className="images-grid">
|
||||
{recipe.images.map((image, index) => (
|
||||
{sortedImages.map((image, index) => (
|
||||
<div key={image.id} className="image-item">
|
||||
<div className="image-preview">
|
||||
<img
|
||||
src={imageApi.getImageUrl(image.filePath)}
|
||||
src={getCachedImageUrl(image.filePath)}
|
||||
alt={`Bild ${index + 1}`}
|
||||
/>
|
||||
<div className="image-actions-inline" style={{ position: 'absolute', top: 4, left: 4, display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
disabled={index === 0}
|
||||
onClick={() => handleReorder(image.id, 'up')}
|
||||
title="Nach oben"
|
||||
title="Nach links"
|
||||
style={{ opacity: index === 0 ? 0.4 : 1 }}
|
||||
className="reorder-btn"
|
||||
>▲</button>
|
||||
>◀</button>
|
||||
<button
|
||||
disabled={index === recipe.images!.length - 1}
|
||||
disabled={index === sortedImages.length - 1}
|
||||
onClick={() => handleReorder(image.id, 'down')}
|
||||
title="Nach unten"
|
||||
style={{ opacity: index === recipe.images!.length - 1 ? 0.4 : 1 }}
|
||||
title="Nach rechts"
|
||||
style={{ opacity: index === sortedImages.length - 1 ? 0.4 : 1 }}
|
||||
className="reorder-btn"
|
||||
>▼</button>
|
||||
>▶</button>
|
||||
</div>
|
||||
<button
|
||||
className="delete-image-btn"
|
||||
@@ -396,7 +417,8 @@ const RecipeDetail: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -466,7 +488,7 @@ const RecipeDetail: React.FC = () => {
|
||||
{stepImage && (
|
||||
<div className="step-image">
|
||||
<img
|
||||
src={imageApi.getImageUrl(stepImage.filePath)}
|
||||
src={getCachedImageUrl(stepImage.filePath)}
|
||||
alt={`${recipe.title} - Schritt ${index + 1}`}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.recipe-list-header {
|
||||
@@ -64,7 +66,11 @@
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: background-color 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
@@ -76,9 +82,17 @@
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: white;
|
||||
background: white !important;
|
||||
color: #333 !important;
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
text-indent: 0;
|
||||
text-align: left;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.category-select:focus {
|
||||
@@ -86,6 +100,13 @@
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.category-select option {
|
||||
color: #333 !important;
|
||||
background: white !important;
|
||||
font-size: 1rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
@@ -99,7 +120,7 @@
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
min-height: 450px; /* Stabile Mindesthöhe */
|
||||
min-height: 480px; /* Mindesthöhe, aber flexibel für größere Bilder */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -110,7 +131,8 @@
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 0.7; /* Breite : Höhe = 1 : 0.7, also Höhe = 70% der Breite */
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
@@ -142,6 +164,13 @@
|
||||
flex: 1; /* Nimmt verfügbaren Platz ein */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between; /* Verteilt Inhalt gleichmäßig - Actions am Ende */
|
||||
}
|
||||
|
||||
.recipe-info {
|
||||
flex: 1; /* Nimmt verfügbaren Platz für Titel, Beschreibung, Meta ein */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-title {
|
||||
@@ -150,7 +179,7 @@
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
min-height: 1.6em; /* Verhindert Layout-Sprünge */
|
||||
height: 2.6em; /* Fixe Höhe für 2 Zeilen */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2; /* Begrenzt auf 2 Zeilen */
|
||||
line-clamp: 2; /* Standard property für Kompatibilität */
|
||||
@@ -165,7 +194,7 @@
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
min-height: 3em; /* Verhindert Layout-Sprünge */
|
||||
height: 4.5em; /* Fixe Höhe für 3 Zeilen */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3; /* Begrenzt auf 3 Zeilen */
|
||||
line-clamp: 3; /* Standard property für Kompatibilität */
|
||||
@@ -179,6 +208,8 @@
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
height: 3em; /* Fixe Höhe für Meta-Informationen */
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.recipe-category {
|
||||
|
||||
@@ -38,6 +38,10 @@ const RecipeList: React.FC = () => {
|
||||
pages: 0,
|
||||
});
|
||||
|
||||
// Helper functions for main image detection (same as RecipeDetail)
|
||||
const getFileName = (fp: string) => fp.split('/').pop() || '';
|
||||
const isMainImage = (fileName: string) => /_0\.(jpg|jpeg|png|webp)$/i.test(fileName);
|
||||
|
||||
// Debounce search to prevent too many API calls
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
@@ -149,8 +153,8 @@ const RecipeList: React.FC = () => {
|
||||
className="category-select"
|
||||
style={{ marginLeft: '8px' }}
|
||||
>
|
||||
<option value="title">Titel</option>
|
||||
<option value="lastUsed">Zuletzt zubereitet</option>
|
||||
<option value="title">Nach Titel</option>
|
||||
<option value="lastUsed">Nach letzter Zubereitung</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); setPage(1); }}
|
||||
@@ -168,53 +172,65 @@ const RecipeList: React.FC = () => {
|
||||
<div key={recipe.id} className="recipe-card">
|
||||
<div className="recipe-image">
|
||||
{recipe.images && recipe.images.length > 0 ? (
|
||||
<img
|
||||
src={imageApi.getImageUrl(recipe.images[0].filePath)}
|
||||
alt={recipe.title}
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block'
|
||||
}}
|
||||
onError={(e) => {
|
||||
const imgSrc = recipe.images?.[0]?.filePath
|
||||
? imageApi.getImageUrl(recipe.images[0].filePath)
|
||||
: 'unknown';
|
||||
console.error('Failed to load image:', imgSrc);
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement!.innerHTML = '<div class="no-image">📸</div>';
|
||||
}}
|
||||
/>
|
||||
(() => {
|
||||
// Find the main image *_0.<ext>; if none, fallback to first image (same logic as RecipeDetail)
|
||||
let mainImage = recipe.images.find(img => isMainImage(getFileName(img.filePath)));
|
||||
if (!mainImage && recipe.images.length > 0) {
|
||||
mainImage = recipe.images[0];
|
||||
}
|
||||
|
||||
return mainImage ? (
|
||||
<img
|
||||
src={imageApi.getImageUrl(mainImage.filePath)}
|
||||
alt={recipe.title}
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block'
|
||||
}}
|
||||
onError={(e) => {
|
||||
const imgSrc = mainImage?.filePath
|
||||
? imageApi.getImageUrl(mainImage.filePath)
|
||||
: 'unknown';
|
||||
console.error('Failed to load image:', imgSrc);
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.parentElement!.innerHTML = '<div class="no-image">📸</div>';
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
})()
|
||||
) : (
|
||||
<div className="no-image">📸</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="recipe-content">
|
||||
<h3 className="recipe-title">{recipe.title}</h3>
|
||||
<p className="recipe-description">
|
||||
{recipe.description ?
|
||||
recipe.description.length > 100 ?
|
||||
recipe.description.substring(0, 100) + '...' :
|
||||
recipe.description
|
||||
: 'Keine Beschreibung verfügbar'
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="recipe-meta">
|
||||
{recipe.category && (
|
||||
<span className="recipe-category">{recipe.category}</span>
|
||||
)}
|
||||
<span className="recipe-servings">👥 {recipe.servings} Portionen</span>
|
||||
{recipe.lastUsed ? (
|
||||
<span className="recipe-lastused" title={new Date(recipe.lastUsed).toLocaleString('de-DE')}>
|
||||
🍳 {new Date(recipe.lastUsed).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="recipe-never" title="Noch nie zubereitet">🆕 Nie</span>
|
||||
)}
|
||||
<div className="recipe-info">
|
||||
<h3 className="recipe-title">{recipe.title}</h3>
|
||||
<p className="recipe-description">
|
||||
{recipe.description ?
|
||||
recipe.description.length > 100 ?
|
||||
recipe.description.substring(0, 100) + '...' :
|
||||
recipe.description
|
||||
: 'Keine Beschreibung verfügbar'
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="recipe-meta">
|
||||
{recipe.category && (
|
||||
<span className="recipe-category">{recipe.category}</span>
|
||||
)}
|
||||
<span className="recipe-servings">👥 {recipe.servings} Portionen</span>
|
||||
{recipe.lastUsed ? (
|
||||
<span className="recipe-lastused" title={new Date(recipe.lastUsed).toLocaleString('de-DE')}>
|
||||
🍳 {new Date(recipe.lastUsed).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="recipe-never" title="Noch nie zubereitet">🆕 Nie</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="recipe-actions">
|
||||
|
||||
@@ -3,13 +3,20 @@ import axios from 'axios';
|
||||
// Runtime API URL detection - works in the browser
|
||||
const getApiBaseUrl = (): string => {
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
// Local development
|
||||
return 'http://localhost:3001/api';
|
||||
} else if (hostname === 'esprimo') {
|
||||
// Docker container hostname - use IP instead
|
||||
return 'http://192.168.178.94:3001/api';
|
||||
} else if (hostname === 'rezepte.fuerst-stuttgart.de') {
|
||||
// Production domain - use HTTPS without port
|
||||
return 'https://rezepte.fuerst-stuttgart.de/api';
|
||||
} else {
|
||||
// Network access - use same host as frontend
|
||||
return `http://${hostname}:3001/api`;
|
||||
// Network access - use same host as frontend with port
|
||||
return `${protocol}//${hostname}:3001/api`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user