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
|
// 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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
|
- 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:
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -213,6 +216,22 @@ const RecipeDetail: React.FC = () => {
|
|||||||
const m = fileName.match(/_(\d+)\.(jpg|jpeg|png|webp)$/i);
|
const m = fileName.match(/_(\d+)\.(jpg|jpeg|png|webp)$/i);
|
||||||
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">
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
|
||||||
{recipe.category && (
|
<div className="recipe-meta">
|
||||||
<span className="recipe-category">{recipe.category}</span>
|
{recipe.category && (
|
||||||
)}
|
<span className="recipe-category">{recipe.category}</span>
|
||||||
<span className="recipe-servings">👥 {recipe.servings} Portionen</span>
|
)}
|
||||||
{recipe.lastUsed ? (
|
<span className="recipe-servings">👥 {recipe.servings} Portionen</span>
|
||||||
<span className="recipe-lastused" title={new Date(recipe.lastUsed).toLocaleString('de-DE')}>
|
{recipe.lastUsed ? (
|
||||||
🍳 {new Date(recipe.lastUsed).toLocaleDateString('de-DE')}
|
<span className="recipe-lastused" title={new Date(recipe.lastUsed).toLocaleString('de-DE')}>
|
||||||
</span>
|
🍳 {new Date(recipe.lastUsed).toLocaleDateString('de-DE')}
|
||||||
) : (
|
</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">
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user