Local (esprimo) funktioniert, remote noch nicht

This commit is contained in:
2025-09-27 18:28:30 +00:00
parent db431553b9
commit f812921ff5
10 changed files with 277 additions and 72 deletions

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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`;
}
};