From f812921ff591055e0ea09ff6575d42ba2b4c8543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Sat, 27 Sep 2025 18:28:30 +0000 Subject: [PATCH] Local (esprimo) funktioniert, remote noch nicht --- .env.production | 18 ++++ .env.traefik | 25 ++++++ backend/src/app.ts | 2 +- backend/src/routes/images.ts | 26 ++++-- deploy-traefik-production.sh | 66 +++++++++++++++ docker-compose.traefik.yml | 12 ++- frontend/src/components/RecipeDetail.tsx | 46 +++++++--- frontend/src/components/RecipeList.css | 41 +++++++-- frontend/src/components/RecipeList.tsx | 102 +++++++++++++---------- frontend/src/services/api.ts | 11 ++- 10 files changed, 277 insertions(+), 72 deletions(-) create mode 100644 .env.production create mode 100644 .env.traefik create mode 100755 deploy-traefik-production.sh diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..f347c4f --- /dev/null +++ b/.env.production @@ -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 \ No newline at end of file diff --git a/.env.traefik b/.env.traefik new file mode 100644 index 0000000..758d5eb --- /dev/null +++ b/.env.traefik @@ -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 \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts index c78c8ab..476e167 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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); }); } diff --git a/backend/src/routes/images.ts b/backend/src/routes/images.ts index 52539e5..238d03d 100644 --- a/backend/src/routes/images.ts +++ b/backend/src/routes/images.ts @@ -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 diff --git a/deploy-traefik-production.sh b/deploy-traefik-production.sh new file mode 100755 index 0000000..c6e9200 --- /dev/null +++ b/deploy-traefik-production.sh @@ -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!" \ No newline at end of file diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 307d782..6707a62 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -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: diff --git a/frontend/src/components/RecipeDetail.tsx b/frontend/src/components/RecipeDetail.tsx index 693f801..bce6498 100644 --- a/frontend/src/components/RecipeDetail.tsx +++ b/frontend/src/components/RecipeDetail.tsx @@ -39,6 +39,7 @@ const RecipeDetail: React.FC = () => { const [markingCooked, setMarkingCooked] = useState(false); const [showCookModal, setShowCookModal] = useState(false); const [cookDate, setCookDate] = useState(''); // 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 (
@@ -291,7 +310,7 @@ const RecipeDetail: React.FC = () => { if (mainImage) { return ( {`${recipe.title} { e.currentTarget.style.display = 'none'; @@ -349,32 +368,34 @@ const RecipeDetail: React.FC = () => {
{/* Existing images */} - {recipe.images && recipe.images.length > 0 && ( + {recipe.images && recipe.images.length > 0 && (() => { + const sortedImages = getSortedImages(recipe.images); + return (

Vorhandene Bilder ({recipe.images.length})

- {recipe.images.map((image, index) => ( + {sortedImages.map((image, index) => (
{`Bild
+ >◀ + >▶
- )} + ); + })()}
)} @@ -466,7 +488,7 @@ const RecipeDetail: React.FC = () => { {stepImage && (
{`${recipe.title} { e.currentTarget.style.display = 'none'; diff --git a/frontend/src/components/RecipeList.css b/frontend/src/components/RecipeList.css index e92161b..5e5e265 100644 --- a/frontend/src/components/RecipeList.css +++ b/frontend/src/components/RecipeList.css @@ -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 { diff --git a/frontend/src/components/RecipeList.tsx b/frontend/src/components/RecipeList.tsx index 948c62a..a48ba86 100644 --- a/frontend/src/components/RecipeList.tsx +++ b/frontend/src/components/RecipeList.tsx @@ -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' }} > - - + +