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

18
.env.production Normal file
View 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
View 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

View File

@@ -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 // Auto-add common localhost dev origins if not prod and not wildcard
if (!isProd && !allowedOrigins.includes('*')) { 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); if (!allowedOrigins.includes(def)) allowedOrigins.push(def);
}); });
} }

View File

@@ -333,7 +333,23 @@ router.post('/reorder/:recipeId', async (req: Request, res: Response, next: Next
} }
const recipeNumber = recipe.recipeNumber; 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)) { if (!fs.existsSync(baseDir)) {
return res.status(500).json({ success: false, message: 'Upload directory missing on server' }); 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 fileName = image.filePath.split('/').pop() || '';
const extMatch = fileName.match(/\.(jpg|jpeg|png|webp)$/i); const extMatch = fileName.match(/\.(jpg|jpeg|png|webp)$/i);
const ext = extMatch && extMatch[1] ? extMatch[1].toLowerCase() : 'jpg'; 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) // 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. // So we join baseDir with just the filename to locate the current file.
const oldFull = path.join(baseDir, path.basename(fileName)); 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 tempFull = path.join(baseDir, tempName);
const finalFull = path.join(baseDir, finalName); const finalFull = path.join(baseDir, finalName);
if (!fs.existsSync(oldFull)) { if (!fs.existsSync(oldFull)) {
return res.status(500).json({ success: false, message: `File missing on disk: ${fileName}` }); return res.status(500).json({ success: false, message: `File missing on disk: ${fileName}` });
} }
fs.renameSync(oldFull, tempFull); 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 // Phase 2: rename temp -> final

66
deploy-traefik-production.sh Executable file
View 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!"

View File

@@ -68,8 +68,10 @@ services:
- rezepte-network - rezepte-network
backend: backend:
# Use pre-built image from registry instead of building # Build locally instead of using registry
image: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-backend:latest} build:
context: ./backend
dockerfile: Dockerfile
container_name: rezepte-backend-prod container_name: rezepte-backend-prod
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -106,8 +108,10 @@ services:
- rezepte-network - rezepte-network
frontend: frontend:
# Use pre-built image from registry instead of building # Build locally instead of using registry
image: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-frontend:latest} build:
context: ./frontend
dockerfile: Dockerfile
container_name: rezepte-frontend-prod container_name: rezepte-frontend-prod
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:

View File

@@ -39,6 +39,7 @@ const RecipeDetail: React.FC = () => {
const [markingCooked, setMarkingCooked] = useState(false); const [markingCooked, setMarkingCooked] = useState(false);
const [showCookModal, setShowCookModal] = useState(false); const [showCookModal, setShowCookModal] = useState(false);
const [cookDate, setCookDate] = useState<string>(''); // YYYY-MM-DD const [cookDate, setCookDate] = useState<string>(''); // YYYY-MM-DD
const [imageCacheBuster, setImageCacheBuster] = useState(Date.now());
useEffect(() => { useEffect(() => {
const loadRecipe = async () => { const loadRecipe = async () => {
@@ -118,6 +119,8 @@ const RecipeDetail: React.FC = () => {
try { try {
// Send ordered IDs // Send ordered IDs
await imageApi.reorderImages(parseInt(id), newOrder.map(i => i.id)); 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) // Reload recipe to pick up new filenames (indexes changed)
const response = await recipeApi.getRecipe(parseInt(id)); const response = await recipeApi.getRecipe(parseInt(id));
if (response.success) setRecipe(response.data); if (response.success) setRecipe(response.data);
@@ -214,6 +217,22 @@ const RecipeDetail: React.FC = () => {
return m ? parseInt(m[1], 10) : -1; 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 ( return (
<div className="recipe-detail"> <div className="recipe-detail">
{/* Header */} {/* Header */}
@@ -291,7 +310,7 @@ const RecipeDetail: React.FC = () => {
if (mainImage) { if (mainImage) {
return ( return (
<img <img
src={imageApi.getImageUrl(mainImage.filePath)} src={getCachedImageUrl(mainImage.filePath)}
alt={`${recipe.title} - Hauptbild`} alt={`${recipe.title} - Hauptbild`}
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none'; e.currentTarget.style.display = 'none';
@@ -349,32 +368,34 @@ const RecipeDetail: React.FC = () => {
</div> </div>
{/* Existing images */} {/* Existing images */}
{recipe.images && recipe.images.length > 0 && ( {recipe.images && recipe.images.length > 0 && (() => {
const sortedImages = getSortedImages(recipe.images);
return (
<div className="existing-images"> <div className="existing-images">
<h4>Vorhandene Bilder ({recipe.images.length})</h4> <h4>Vorhandene Bilder ({recipe.images.length})</h4>
<div className="images-grid"> <div className="images-grid">
{recipe.images.map((image, index) => ( {sortedImages.map((image, index) => (
<div key={image.id} className="image-item"> <div key={image.id} className="image-item">
<div className="image-preview"> <div className="image-preview">
<img <img
src={imageApi.getImageUrl(image.filePath)} src={getCachedImageUrl(image.filePath)}
alt={`Bild ${index + 1}`} alt={`Bild ${index + 1}`}
/> />
<div className="image-actions-inline" style={{ position: 'absolute', top: 4, left: 4, display: 'flex', gap: '4px' }}> <div className="image-actions-inline" style={{ position: 'absolute', top: 4, left: 4, display: 'flex', gap: '4px' }}>
<button <button
disabled={index === 0} disabled={index === 0}
onClick={() => handleReorder(image.id, 'up')} onClick={() => handleReorder(image.id, 'up')}
title="Nach oben" title="Nach links"
style={{ opacity: index === 0 ? 0.4 : 1 }} style={{ opacity: index === 0 ? 0.4 : 1 }}
className="reorder-btn" className="reorder-btn"
></button> ></button>
<button <button
disabled={index === recipe.images!.length - 1} disabled={index === sortedImages.length - 1}
onClick={() => handleReorder(image.id, 'down')} onClick={() => handleReorder(image.id, 'down')}
title="Nach unten" title="Nach rechts"
style={{ opacity: index === recipe.images!.length - 1 ? 0.4 : 1 }} style={{ opacity: index === sortedImages.length - 1 ? 0.4 : 1 }}
className="reorder-btn" className="reorder-btn"
></button> ></button>
</div> </div>
<button <button
className="delete-image-btn" className="delete-image-btn"
@@ -396,7 +417,8 @@ const RecipeDetail: React.FC = () => {
))} ))}
</div> </div>
</div> </div>
)} );
})()}
</div> </div>
)} )}
@@ -466,7 +488,7 @@ const RecipeDetail: React.FC = () => {
{stepImage && ( {stepImage && (
<div className="step-image"> <div className="step-image">
<img <img
src={imageApi.getImageUrl(stepImage.filePath)} src={getCachedImageUrl(stepImage.filePath)}
alt={`${recipe.title} - Schritt ${index + 1}`} alt={`${recipe.title} - Schritt ${index + 1}`}
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none'; e.currentTarget.style.display = 'none';

View File

@@ -2,6 +2,8 @@
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1rem; padding: 2rem 1rem;
color: #333;
background: #fff;
} }
.recipe-list-header { .recipe-list-header {
@@ -64,7 +66,11 @@
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-family: inherit;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
} }
.search-button:hover { .search-button:hover {
@@ -76,9 +82,17 @@
border: 2px solid #e1e5e9; border: 2px solid #e1e5e9;
border-radius: 8px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
background: white; background: white !important;
color: #333 !important;
cursor: pointer; cursor: pointer;
min-width: 180px; 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 { .category-select:focus {
@@ -86,6 +100,13 @@
border-color: #667eea; border-color: #667eea;
} }
.category-select option {
color: #333 !important;
background: white !important;
font-size: 1rem;
padding: 0.5rem;
}
.recipes-grid { .recipes-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
@@ -99,7 +120,7 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden; overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease; 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; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -110,7 +131,8 @@
} }
.recipe-image { .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; overflow: hidden;
position: relative; position: relative;
} }
@@ -142,6 +164,13 @@
flex: 1; /* Nimmt verfügbaren Platz ein */ flex: 1; /* Nimmt verfügbaren Platz ein */
display: flex; display: flex;
flex-direction: column; 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 { .recipe-title {
@@ -150,7 +179,7 @@
font-size: 1.3rem; font-size: 1.3rem;
font-weight: 600; font-weight: 600;
line-height: 1.3; 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; display: -webkit-box;
-webkit-line-clamp: 2; /* Begrenzt auf 2 Zeilen */ -webkit-line-clamp: 2; /* Begrenzt auf 2 Zeilen */
line-clamp: 2; /* Standard property für Kompatibilität */ line-clamp: 2; /* Standard property für Kompatibilität */
@@ -165,7 +194,7 @@
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
line-height: 1.5; line-height: 1.5;
font-size: 0.95rem; font-size: 0.95rem;
min-height: 3em; /* Verhindert Layout-Sprünge */ height: 4.5em; /* Fixe Höhe für 3 Zeilen */
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; /* Begrenzt auf 3 Zeilen */ -webkit-line-clamp: 3; /* Begrenzt auf 3 Zeilen */
line-clamp: 3; /* Standard property für Kompatibilität */ line-clamp: 3; /* Standard property für Kompatibilität */
@@ -179,6 +208,8 @@
gap: 1rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
height: 3em; /* Fixe Höhe für Meta-Informationen */
align-items: flex-start;
} }
.recipe-category { .recipe-category {

View File

@@ -38,6 +38,10 @@ const RecipeList: React.FC = () => {
pages: 0, 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 // Debounce search to prevent too many API calls
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
@@ -149,8 +153,8 @@ const RecipeList: React.FC = () => {
className="category-select" className="category-select"
style={{ marginLeft: '8px' }} style={{ marginLeft: '8px' }}
> >
<option value="title">Titel</option> <option value="title">Nach Titel</option>
<option value="lastUsed">Zuletzt zubereitet</option> <option value="lastUsed">Nach letzter Zubereitung</option>
</select> </select>
<button <button
onClick={() => { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); setPage(1); }} onClick={() => { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); setPage(1); }}
@@ -168,53 +172,65 @@ const RecipeList: React.FC = () => {
<div key={recipe.id} className="recipe-card"> <div key={recipe.id} className="recipe-card">
<div className="recipe-image"> <div className="recipe-image">
{recipe.images && recipe.images.length > 0 ? ( {recipe.images && recipe.images.length > 0 ? (
<img (() => {
src={imageApi.getImageUrl(recipe.images[0].filePath)} // Find the main image *_0.<ext>; if none, fallback to first image (same logic as RecipeDetail)
alt={recipe.title} let mainImage = recipe.images.find(img => isMainImage(getFileName(img.filePath)));
loading="lazy" if (!mainImage && recipe.images.length > 0) {
style={{ mainImage = recipe.images[0];
width: '100%', }
height: '100%',
objectFit: 'cover', return mainImage ? (
display: 'block' <img
}} src={imageApi.getImageUrl(mainImage.filePath)}
onError={(e) => { alt={recipe.title}
const imgSrc = recipe.images?.[0]?.filePath loading="lazy"
? imageApi.getImageUrl(recipe.images[0].filePath) style={{
: 'unknown'; width: '100%',
console.error('Failed to load image:', imgSrc); height: '100%',
e.currentTarget.style.display = 'none'; objectFit: 'cover',
e.currentTarget.parentElement!.innerHTML = '<div class="no-image">📸</div>'; 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 className="no-image">📸</div>
)} )}
</div> </div>
<div className="recipe-content"> <div className="recipe-content">
<h3 className="recipe-title">{recipe.title}</h3> <div className="recipe-info">
<p className="recipe-description"> <h3 className="recipe-title">{recipe.title}</h3>
{recipe.description ? <p className="recipe-description">
recipe.description.length > 100 ? {recipe.description ?
recipe.description.substring(0, 100) + '...' : recipe.description.length > 100 ?
recipe.description recipe.description.substring(0, 100) + '...' :
: 'Keine Beschreibung verfügbar' recipe.description
} : 'Keine Beschreibung verfügbar'
</p> }
</p>
<div className="recipe-meta"> <div className="recipe-meta">
{recipe.category && ( {recipe.category && (
<span className="recipe-category">{recipe.category}</span> <span className="recipe-category">{recipe.category}</span>
)} )}
<span className="recipe-servings">👥 {recipe.servings} Portionen</span> <span className="recipe-servings">👥 {recipe.servings} Portionen</span>
{recipe.lastUsed ? ( {recipe.lastUsed ? (
<span className="recipe-lastused" title={new Date(recipe.lastUsed).toLocaleString('de-DE')}> <span className="recipe-lastused" title={new Date(recipe.lastUsed).toLocaleString('de-DE')}>
🍳 {new Date(recipe.lastUsed).toLocaleDateString('de-DE')} 🍳 {new Date(recipe.lastUsed).toLocaleDateString('de-DE')}
</span> </span>
) : ( ) : (
<span className="recipe-never" title="Noch nie zubereitet">🆕 Nie</span> <span className="recipe-never" title="Noch nie zubereitet">🆕 Nie</span>
)} )}
</div>
</div> </div>
<div className="recipe-actions"> <div className="recipe-actions">

View File

@@ -3,13 +3,20 @@ import axios from 'axios';
// Runtime API URL detection - works in the browser // Runtime API URL detection - works in the browser
const getApiBaseUrl = (): string => { const getApiBaseUrl = (): string => {
const hostname = window.location.hostname; const hostname = window.location.hostname;
const protocol = window.location.protocol;
if (hostname === 'localhost' || hostname === '127.0.0.1') { if (hostname === 'localhost' || hostname === '127.0.0.1') {
// Local development // Local development
return 'http://localhost:3001/api'; 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 { } else {
// Network access - use same host as frontend // Network access - use same host as frontend with port
return `http://${hostname}:3001/api`; return `${protocol}//${hostname}:3001/api`;
} }
}; };