Local (esprimo) funktioniert, remote noch nicht
This commit is contained in:
18
.env.production
Normal file
18
.env.production
Normal file
@@ -0,0 +1,18 @@
|
||||
# Production Environment - rezepte.fuerst-stuttgart.de
|
||||
# Generated on September 27, 2025
|
||||
|
||||
# Database Configuration
|
||||
MYSQL_PASSWORD=RezepteProd2025!Secure
|
||||
MYSQL_ROOT_PASSWORD=RezepteRootProd2025!SuperSecure
|
||||
|
||||
# CORS Origin - Production domain
|
||||
CORS_ORIGIN=https://rezepte.fuerst-stuttgart.de
|
||||
|
||||
# API Base URL for frontend
|
||||
API_BASE_URL=https://rezepte.fuerst-stuttgart.de/api
|
||||
|
||||
# JWT Secret for future authentication features
|
||||
JWT_SECRET=RezepteJwtSecret2025SuperSecure32Plus
|
||||
|
||||
# Production settings
|
||||
NODE_ENV=production
|
||||
25
.env.traefik
Normal file
25
.env.traefik
Normal file
@@ -0,0 +1,25 @@
|
||||
# Traefik Production Environment - rezepte.fuerst-stuttgart.de
|
||||
# Generated on September 27, 2025
|
||||
|
||||
# Domain Configuration
|
||||
DOMAIN=fuerst-stuttgart.de
|
||||
|
||||
# Let's Encrypt Configuration
|
||||
ACME_EMAIL=admin@fuerst-stuttgart.de
|
||||
|
||||
# Database Configuration
|
||||
MYSQL_PASSWORD=RezepteProd2025!Secure
|
||||
MYSQL_ROOT_PASSWORD=RezepteRootProd2025!SuperSecure
|
||||
|
||||
# CORS Origin - Production domain
|
||||
CORS_ORIGIN=https://rezepte.fuerst-stuttgart.de
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=RezepteJwtSecret2025SuperSecure32Plus
|
||||
|
||||
# Production settings
|
||||
NODE_ENV=production
|
||||
|
||||
# Docker Images (optional - will build locally if not specified)
|
||||
# FRONTEND_IMAGE=ghcr.io/your-username/rezepte-frontend:latest
|
||||
# BACKEND_IMAGE=ghcr.io/your-username/rezepte-backend:latest
|
||||
@@ -49,7 +49,7 @@ allowedOrigins = Array.from(new Set(allowedOrigins.map(o => o.replace(/\/$/, '')
|
||||
|
||||
// Auto-add common localhost dev origins if not prod and not wildcard
|
||||
if (!isProd && !allowedOrigins.includes('*')) {
|
||||
['http://localhost:5173','http://localhost:3000'].forEach(def => {
|
||||
['http://localhost:5173','http://localhost:3000','http://esprimo:3000','http://esprimo:5173'].forEach(def => {
|
||||
if (!allowedOrigins.includes(def)) allowedOrigins.push(def);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -333,7 +333,23 @@ router.post('/reorder/:recipeId', async (req: Request, res: Response, next: Next
|
||||
}
|
||||
|
||||
const recipeNumber = recipe.recipeNumber;
|
||||
const baseDir = getUploadsDir(recipeNumber);
|
||||
|
||||
// Determine the actual directory from the first image path
|
||||
// Images might be in uploads/R005/ format instead of uploads/5/
|
||||
let actualDir = recipeNumber;
|
||||
if (images.length > 0 && images[0]) {
|
||||
const firstImagePath = images[0].filePath;
|
||||
// Extract directory from path like "uploads/R005/R005_0.jpg" -> "R005"
|
||||
const pathParts = firstImagePath.split('/');
|
||||
if (pathParts.length >= 2) {
|
||||
const dirName = pathParts[pathParts.length - 2];
|
||||
if (dirName) {
|
||||
actualDir = dirName; // Get the directory name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseDir = getUploadsDir(actualDir);
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
return res.status(500).json({ success: false, message: 'Upload directory missing on server' });
|
||||
}
|
||||
@@ -348,19 +364,19 @@ router.post('/reorder/:recipeId', async (req: Request, res: Response, next: Next
|
||||
const fileName = image.filePath.split('/').pop() || '';
|
||||
const extMatch = fileName.match(/\.(jpg|jpeg|png|webp)$/i);
|
||||
const ext = extMatch && extMatch[1] ? extMatch[1].toLowerCase() : 'jpg';
|
||||
const finalName = `${recipeNumber}_${idx}.${ext === 'jpeg' ? 'jpg' : ext}`;
|
||||
const finalName = `${actualDir}_${idx}.${ext === 'jpeg' ? 'jpg' : ext}`;
|
||||
// Previous implementation incorrectly traversed ../../ resulting in /uploads/... (missing /app prefix in container)
|
||||
// image.filePath is like 'uploads/R100/R100_0.png' and baseDir points to '/app/uploads/R100'
|
||||
// image.filePath is like 'uploads/R005/R005_0.png' and baseDir points to '/app/uploads/R005'
|
||||
// So we join baseDir with just the filename to locate the current file.
|
||||
const oldFull = path.join(baseDir, path.basename(fileName));
|
||||
const tempName = `${recipeNumber}__reorder_${idx}_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const tempName = `${actualDir}__reorder_${idx}_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const tempFull = path.join(baseDir, tempName);
|
||||
const finalFull = path.join(baseDir, finalName);
|
||||
if (!fs.existsSync(oldFull)) {
|
||||
return res.status(500).json({ success: false, message: `File missing on disk: ${fileName}` });
|
||||
}
|
||||
fs.renameSync(oldFull, tempFull);
|
||||
tempRenames.push({ from: tempFull, to: finalFull, final: `uploads/${recipeNumber}/${finalName}`, idx, ext });
|
||||
tempRenames.push({ from: tempFull, to: finalFull, final: `uploads/${actualDir}/${finalName}`, idx, ext });
|
||||
}
|
||||
|
||||
// Phase 2: rename temp -> final
|
||||
|
||||
66
deploy-traefik-production.sh
Executable file
66
deploy-traefik-production.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Rezepte to production with Traefik..."
|
||||
echo "📍 Target: https://rezepte.fuerst-stuttgart.de"
|
||||
|
||||
# Check if .env.traefik exists
|
||||
if [ ! -f .env.traefik ]; then
|
||||
echo "❌ Error: .env.traefik file not found!"
|
||||
echo "Please copy .env.traefik.example to .env.traefik and configure it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load environment variables
|
||||
export $(cat .env.traefik | grep -v '^#' | xargs)
|
||||
|
||||
# Validate required environment variables
|
||||
if [ -z "$MYSQL_PASSWORD" ] || [ -z "$DOMAIN" ] || [ -z "$ACME_EMAIL" ]; then
|
||||
echo "❌ Error: Required environment variables not set in .env.traefik"
|
||||
echo "Please configure MYSQL_PASSWORD, DOMAIN, and ACME_EMAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📥 Pulling latest changes..."
|
||||
git pull origin main
|
||||
|
||||
echo "🛑 Stopping existing containers..."
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik down
|
||||
|
||||
echo "🏗️ Building images..."
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik build --no-cache
|
||||
|
||||
echo "🚀 Starting services..."
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik up -d
|
||||
|
||||
echo "⏳ Waiting for services to start..."
|
||||
sleep 45
|
||||
|
||||
echo "🔍 Checking service health..."
|
||||
HEALTHY_SERVICES=$(docker compose -f docker-compose.traefik.yml --env-file .env.traefik ps --filter "status=running" --format "table {{.Service}}\t{{.Status}}" | grep -c "Up" || true)
|
||||
|
||||
if [ "$HEALTHY_SERVICES" -ge 3 ]; then
|
||||
echo "✅ Deployment successful!"
|
||||
echo ""
|
||||
echo "🌐 Application URLs:"
|
||||
echo " 📱 Rezepte App: https://rezepte.fuerst-stuttgart.de"
|
||||
echo " 🗄️ phpMyAdmin: https://pma.fuerst-stuttgart.de"
|
||||
echo " ⚙️ Traefik Dashboard: https://traefik.fuerst-stuttgart.de"
|
||||
echo ""
|
||||
echo "📊 Service Status:"
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik ps
|
||||
echo ""
|
||||
echo "🔐 Let's Encrypt certificates will be automatically generated."
|
||||
echo "🔄 Services are configured with auto-restart policies."
|
||||
else
|
||||
echo "❌ Deployment failed! Check logs:"
|
||||
echo ""
|
||||
echo "🐳 Container Status:"
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik ps
|
||||
echo ""
|
||||
echo "📋 Recent logs:"
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik logs --tail=20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🎉 Production deployment completed successfully!"
|
||||
@@ -68,8 +68,10 @@ services:
|
||||
- rezepte-network
|
||||
|
||||
backend:
|
||||
# Use pre-built image from registry instead of building
|
||||
image: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-backend:latest}
|
||||
# Build locally instead of using registry
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rezepte-backend-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -106,8 +108,10 @@ services:
|
||||
- rezepte-network
|
||||
|
||||
frontend:
|
||||
# Use pre-built image from registry instead of building
|
||||
image: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-frontend:latest}
|
||||
# Build locally instead of using registry
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rezepte-frontend-prod
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
||||
@@ -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