commit b3160de204062c13d20c0c397a4f3844f013d23e Author: rxf Date: Fri Oct 3 18:16:58 2025 +0200 Erster commit wie von Claude erstellt (unverändert) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db06504 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +k# Dependencies +node_modules/ +package-lock.json + +# Environment Variables +.env +.env.local +.env.production + +# Uploads (Bilder nicht in Git) +uploads/ + +# Logs +logs/ +*.log +npm-debug.log* + +# OS Files +.DS_Store +Thumbs.db +desktop.ini + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Docker +docker-compose.override.yml + +# Build +dist/ +build/ + +# Temporary files +tmp/ +temp/ diff --git a/Aneitung_1.pdf b/Aneitung_1.pdf new file mode 100644 index 0000000..35ea07b Binary files /dev/null and b/Aneitung_1.pdf differ diff --git a/Anleitung.pdf b/Anleitung.pdf new file mode 100644 index 0000000..1199b97 Binary files /dev/null and b/Anleitung.pdf differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5a1a4c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM node:22-alpine + +WORKDIR /app + +# Package files kopieren +COPY server/package*.json ./ + +# Dependencies installieren +RUN npm ci --only=production + +# Source Code kopieren +COPY server/ ./ + +# Public und uploads Ordner +COPY public/ ../public/ +RUN mkdir -p ../uploads + +# Non-root User +USER node + +EXPOSE 3000 + +CMD ["node", "index.js"] + diff --git a/GIT_SETUP.md b/GIT_SETUP.md new file mode 100644 index 0000000..63b81ff --- /dev/null +++ b/GIT_SETUP.md @@ -0,0 +1,188 @@ +# Git Setup & Workflow + +## Initiales Repository erstellen + +```bash +# 1. Git Repository initialisieren +git init + +# 2. Alle Dateien hinzufügen +git add . + +# 3. Ersten Commit erstellen +git commit -m "Initial commit: PHP zu Node.js Migration" + +# 4. Main Branch umbenennen (optional, wenn du 'main' statt 'master' willst) +git branch -M main +``` + +## Remote Repository verbinden + +### GitHub +```bash +# Repository auf GitHub erstellen, dann: +git remote add origin https://github.com/DEIN_USERNAME/recipe-app.git +git push -u origin main +``` + +### GitLab +```bash +git remote add origin https://gitlab.com/DEIN_USERNAME/recipe-app.git +git push -u origin main +``` + +### Eigener Git-Server +```bash +git remote add origin git@dein-server.de:/pfad/zu/recipe-app.git +git push -u origin main +``` + +## Empfohlene Branch-Strategie + +```bash +# Feature-Branch erstellen +git checkout -b feature/neue-funktion + +# Änderungen committen +git add . +git commit -m "feat: Neue Funktion hinzugefügt" + +# In main mergen +git checkout main +git merge feature/neue-funktion + +# Feature-Branch löschen +git branch -d feature/neue-funktion +``` + +## Commit-Konventionen + +```bash +# Feature +git commit -m "feat: Neue Suchfunktion für Kategorien" + +# Bugfix +git commit -m "fix: Bild-Upload bei langen Dateinamen" + +# Dokumentation +git commit -m "docs: README aktualisiert" + +# Refactoring +git commit -m "refactor: DB-Verbindung optimiert" + +# Style +git commit -m "style: CSS für mobile Ansicht verbessert" + +# Tests +git commit -m "test: Unit-Tests für Rezept-API" +``` + +## Wichtige Dateien NICHT in Git + +Die `.gitignore` verhindert, dass folgende Dateien committed werden: +- `node_modules/` - Dependencies (werden via `npm install` installiert) +- `.env` - Enthält sensible Zugangsdaten +- `uploads/` - Benutzergenerierte Bilder (zu groß für Git) + +## Uploads separat sichern + +Da `uploads/` nicht in Git ist, erstelle ein separates Backup: + +```bash +# Backup erstellen +tar -czf uploads-backup-$(date +%Y%m%d).tar.gz uploads/ + +# Oder mit rsync zu Backup-Server +rsync -avz uploads/ user@backup-server:/backups/recipe-app/uploads/ +``` + +## .env Template + +Erstelle eine `.env.example` für andere Entwickler: + +```bash +# .env.example (WIRD in Git committed) +PORT=3000 +NODE_ENV=development +MONGODB_URI=mongodb://localhost:27017/recipes +``` + +Dann: +```bash +git add .env.example +git commit -m "docs: .env.example hinzugefügt" +``` + +Andere Entwickler kopieren dann: +```bash +cp .env.example .env +# Dann .env mit echten Werten anpassen +``` + +## Git-Befehle Cheatsheet + +```bash +# Status anzeigen +git status + +# Änderungen anzeigen +git diff + +# Log anzeigen +git log --oneline + +# Bestimmte Datei rückgängig machen +git checkout -- datei.js + +# Letzten Commit rückgängig (behält Änderungen) +git reset --soft HEAD~1 + +# Alle lokalen Änderungen verwerfen +git reset --hard HEAD + +# Remote-Änderungen holen +git pull + +# Branch wechseln +git checkout branch-name + +# Alle Branches anzeigen +git branch -a +``` + +## GitHub Actions (CI/CD) - Optional + +Erstelle `.github/workflows/deploy.yml` für automatisches Deployment: + +```yaml +name: Deploy + +on: + push: + branches: [ main ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Deploy to Server + run: | + # SSH in deinen Server und Pull+Restart +``` + +## Zusammenarbeit mit anderen + +```bash +# Anderen Entwickler hinzufügen +# Auf GitHub: Settings → Collaborators → Add people + +# Änderungen holen und mergen +git pull origin main + +# Bei Merge-Konflikten +git status # Zeigt konfliktreiche Dateien +# Dateien manuell bearbeiten und Konflikte lösen +git add . +git commit -m "fix: Merge-Konflikte gelöst" +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8b7bc8 --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# Rezepte WebApp - Node.js/MongoDB Migration + +Vollständige Migration der PHP/MySQL Rezepte-App zu Node.js/Express mit MongoDB. + +## Stack + +- **Backend**: Node.js 22 + Express.js +- **Datenbank**: MongoDB 8 +- **Frontend**: Vanilla JavaScript, HTML, CSS +- **Container**: Docker + Docker Compose + +## Projektstruktur + +``` +recipe-app/ +├── server/ # Backend +│ ├── index.js # Express Server +│ ├── db.js # MongoDB Connection +│ ├── routes/ # API Routes +│ └── middleware/ # Upload Middleware +├── public/ # Frontend +│ ├── *.html +│ ├── css/ +│ └── js/ +├── uploads/ # Bilder (Volume) +└── docker-compose.yml +``` + +## Installation & Start + +### Mit Docker (empfohlen) + +```bash +# Container bauen und starten +docker-compose up -d + +# Logs anzeigen +docker-compose logs -f app + +# Stoppen +docker-compose down +``` + +App läuft auf: **http://localhost:3000** + +### Lokal (ohne Docker) + +```bash +# MongoDB lokal installieren und starten + +# Dependencies installieren +cd server +npm install + +# Server starten +npm start + +# Entwicklungsmodus (mit Auto-Reload) +npm run dev +``` + +## Daten migrieren + +Wenn du deine bestehenden MySQL-Daten migrieren möchtest, erstelle ein Migrations-Script oder importiere die Daten manuell in MongoDB. + +## MongoDB Schema + +### Collection: `recipes` +```javascript +{ + _id: ObjectId, + rezeptnummer: Number (unique), + bezeichnung: String, + beschreibung: String, + kategorie: String, + vorbereitung: String, + anzahl: Number, + zutaten: String, + zubereitung: [ + { schritt: Number, text: String } + ], + kommentar: String, + erstelltAm: Date, + aktualisiertAm: Date +} +``` + +### Collection: `images` +```javascript +{ + _id: ObjectId, + rezeptId: ObjectId (ref recipes), + dateiPfad: String, + dateiName: String, + hochgeladenAm: Date +} +``` + +## API Endpoints + +### Rezepte +- `GET /api/recipes` - Alle Rezepte (mit Suche & Sortierung) +- `GET /api/recipes/:id` - Einzelnes Rezept +- `POST /api/recipes` - Neues Rezept erstellen +- `PUT /api/recipes/:id` - Rezept aktualisieren +- `DELETE /api/recipes/:id` - Rezept löschen + +### Bilder +- `POST /api/images/:rezeptId` - Bild hochladen +- `GET /api/images/recipe/:rezeptId` - Bilder eines Rezepts +- `DELETE /api/images/:bildId` - Bild löschen + +## Features + +✅ Rezepte anzeigen, erstellen, bearbeiten, löschen +✅ Volltextsuche +✅ Sortierung nach Nummer, Name, Kategorie +✅ Bilder hochladen (JPG, PNG) +✅ Lightbox für Bildvergrößerung +✅ Responsive Design +✅ Docker-Ready + +## Umgebungsvariablen + +Erstelle eine `.env` Datei: + +``` +PORT=3000 +NODE_ENV=production +MONGODB_URI=mongodb://mongo:27017/recipes +``` + +## Entwicklung + +VSCode Extensions empfohlen: +- ESLint +- Prettier +- MongoDB for VS Code + +## Unterschiede zur PHP-Version + +- **Datenbank**: MySQL → MongoDB (denormalisiert) +- **Backend**: PHP → Node.js/Express +- **Frontend**: Gleiche UI, aber mit Fetch API statt Forms +- **Upload**: Multer statt move_uploaded_file +- **Sessions**: Nicht mehr benötigt (Stateless API) + +## Lizenz + +Privat + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ccdb842 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + mongo: + image: mongo:8 + container_name: recipe_mongo + restart: unless-stopped + environment: + MONGO_INITDB_DATABASE: recipes + volumes: + - mongo_data:/data/db + ports: + - "27017:27017" + networks: + - recipe_network + + app: + build: . + container_name: recipe_app + restart: unless-stopped + environment: + - NODE_ENV=production + - PORT=3000 + - MONGODB_URI=mongodb://mongo:27017/recipes + ports: + - "3000:3000" + volumes: + - ./uploads:/app/uploads + depends_on: + - mongo + networks: + - recipe_network + +volumes: + mongo_data: + +networks: + recipe_network: + driver: bridge diff --git a/init-git.sh b/init-git.sh new file mode 100644 index 0000000..cb1496f --- /dev/null +++ b/init-git.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +echo "🚀 Recipe App - Git Repository Setup" +echo "======================================" + +# Prüfe ob Git installiert ist +if ! command -v git &> /dev/null; then + echo "❌ Git ist nicht installiert" + exit 1 +fi + +# Prüfe ob bereits ein Git-Repo existiert +if [ -d .git ]; then + echo "⚠️ Git Repository existiert bereits" + read -p "Möchtest du es neu initialisieren? (j/n): " answer + if [ "$answer" != "j" ]; then + echo "Abgebrochen" + exit 0 + fi + rm -rf .git +fi + +# Git Repository initialisieren +echo "" +echo "📦 Initialisiere Git Repository..." +git init +git branch -M main + +# .env.example aus .env erstellen falls nicht vorhanden +if [ ! -f .env.example ] && [ -f .env ]; then + echo "📄 Erstelle .env.example..." + cp .env .env.example +fi + +# Erste Dateien hinzufügen +echo "➕ Füge Dateien hinzu..." +git add . + +# Ersten Commit erstellen +echo "💾 Erstelle ersten Commit..." +git commit -m "Initial commit: Recipe App - PHP zu Node.js Migration + +- Node.js 22 + Express.js Backend +- MongoDB 8 Datenbank +- Vanilla JavaScript Frontend +- Docker + Docker Compose Setup +- Komplette CRUD-Funktionalität +- Bild-Upload mit Multer +- Volltextsuche" + +echo "" +echo "✅ Git Repository erfolgreich initialisiert!" +echo "" +echo "Nächste Schritte:" +echo "1. Remote Repository erstellen (GitHub/GitLab/etc.)" +echo "2. Remote hinzufügen:" +echo " git remote add origin " +echo "3. Push zum Remote:" +echo " git push -u origin main" +echo "" +echo "📚 Mehr Infos: siehe GIT_SETUP.md" diff --git a/migrate.js b/migrate.js new file mode 100644 index 0000000..f6326cd --- /dev/null +++ b/migrate.js @@ -0,0 +1,118 @@ +import mysql from 'mysql2/promise'; +import { MongoClient } from 'mongodb'; + +// Konfiguration - ANPASSEN! +const mysqlConfig = { + host: 'DEIN_MYSQL_HOST', + user: 'DEIN_MYSQL_USER', + password: 'DEIN_MYSQL_PASSWORD', + database: 'DEINE_MYSQL_DB' +}; + +const mongoUri = 'mongodb://localhost:27017/recipes'; + +async function migrate() { + let mysqlConn, mongoClient, db; + + try { + console.log('📦 Verbinde mit MySQL...'); + mysqlConn = await mysql.createConnection(mysqlConfig); + + console.log('📦 Verbinde mit MongoDB...'); + mongoClient = new MongoClient(mongoUri); + await mongoClient.connect(); + db = mongoClient.db(); + + console.log('🗑️ Lösche bestehende MongoDB-Daten...'); + await db.collection('recipes').deleteMany({}); + await db.collection('images').deleteMany({}); + + console.log('📋 Migriere Rezepte...'); + const [rezepte] = await mysqlConn.query('SELECT * FROM Rezepte ORDER BY Rezeptnummer'); + + let recipeCount = 0; + let imageCount = 0; + + for (const rezept of rezepte) { + const rezeptnr = 'R' + String(rezept.Rezeptnummer).padStart(3, '0'); + + // Hole Zutaten aus ingredients + const [ingredients] = await mysqlConn.query( + 'SELECT ingr FROM ingredients WHERE rezeptnr = ?', + [rezeptnr] + ); + const zutaten = ingredients.length > 0 ? ingredients[0].ingr : rezept.Zutaten || ''; + + // Hole Zubereitungsschritte + const [zubereitungRows] = await mysqlConn.query( + 'SELECT schritt, text FROM Zubereitung WHERE rezeptnummer = ? ORDER BY schritt', + [rezeptnr] + ); + const zubereitung = zubereitungRows.map(row => ({ + schritt: row.schritt, + text: row.text + })); + + // Erstelle MongoDB-Dokument + const recipeDoc = { + rezeptnummer: parseInt(rezept.Rezeptnummer), + bezeichnung: rezept.Bezeichnung || '', + beschreibung: rezept.Beschreibung || '', + kategorie: rezept.Kategorie || '', + vorbereitung: rezept.Vorbereitung || '', + anzahl: rezept.Anzahl || 2, + zutaten, + zubereitung, + kommentar: rezept.Kommentar || '', + erstelltAm: new Date(), + aktualisiertAm: new Date() + }; + + const result = await db.collection('recipes').insertOne(recipeDoc); + recipeCount++; + + console.log(` ✓ Rezept ${rezeptnr}: ${rezept.Bezeichnung}`); + + // Bilder migrieren + const [bilder] = await mysqlConn.query( + 'SELECT * FROM rezepte_bilder WHERE rezepte_id = ?', + [rezept.id] + ); + + for (const bild of bilder) { + const imageDoc = { + rezeptId: result.insertedId, + dateiPfad: bild.datei_pfad, + dateiName: bild.datei_pfad.split('/').pop(), + hochgeladenAm: new Date() + }; + + await db.collection('images').insertOne(imageDoc); + imageCount++; + } + } + + // Erstelle Indizes + console.log('📇 Erstelle Indizes...'); + await db.collection('recipes').createIndex({ rezeptnummer: 1 }, { unique: true }); + await db.collection('recipes').createIndex({ + bezeichnung: 'text', + beschreibung: 'text', + kategorie: 'text' + }); + + console.log('\n✅ Migration abgeschlossen!'); + console.log(` ${recipeCount} Rezepte migriert`); + console.log(` ${imageCount} Bilder migriert`); + + } catch (error) { + console.error('❌ Fehler bei der Migration:', error); + process.exit(1); + } finally { + if (mysqlConn) await mysqlConn.end(); + if (mongoClient) await mongoClient.close(); + } +} + +// Script ausführen +migrate(); diff --git a/migration/package.json b/migration/package.json new file mode 100644 index 0000000..6b27062 --- /dev/null +++ b/migration/package.json @@ -0,0 +1,12 @@ +{ + "name": "recipe-migration", + "version": "1.0.0", + "type": "module", + "scripts": { + "migrate": "node migrate.js" + }, + "dependencies": { + "mongodb": "^6.8.0", + "mysql2": "^3.11.0" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..8907dcf --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,331 @@ +body { + font-family: Arial, Helvetica, sans-serif; + background: #fcf6e3; + color: #5b4636; + font-size: 22px; + margin: 0; + padding: 0; +} + +.container { + width: 90%; + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +/* Buttons */ +.custom-button { + display: inline-block; + padding: 10px 20px; + background-color: #bb7e19; + color: white; + text-decoration: none; + border-radius: 5px; + font-size: 1em; + margin-right: 10px; + margin-bottom: 10px; + border: none; + cursor: pointer; + text-align: center; +} + +.custom-button:hover { + background-color: #a06f15; +} + +.delete-btn { + background-color: #d65c5c; +} + +.delete-btn:hover { + background-color: #b83d3d; +} + +/* Search Bar */ +.search-container { + text-align: center; + margin-bottom: 20px; +} + +.search-container form { + display: inline-flex; + align-items: center; +} + +.search-container input[type="text"] { + padding: 8px; + font-size: 18px; + width: 15ch; + border: 1px solid #ccc; + border-radius: 4px; + margin-right: 10px; +} + +.search-container button { + padding: 8px 16px; + background: #bb7e19; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 18px; +} + +.search-container button:hover { + background: #a06d15; +} + +/* Table */ +.table-aufgaben { + width: 900px; + margin: 0 auto; + border-collapse: collapse; + table-layout: fixed; + background: #fff; +} + +.table-aufgaben th, +.table-aufgaben td { + padding: 8px; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + text-align: center; +} + +.table-aufgaben th:first-child, +.table-aufgaben td:first-child { + width: 10%; + text-align: center; +} + +.table-aufgaben th a { + color: #8b5a2b; + text-decoration: none; + cursor: pointer; +} + +.table-aufgaben th a:hover { + text-decoration: underline; +} + +.table-aufgaben td.bezeichnung { + text-align: left; +} + +.table-aufgaben td.bezeichnung a { + color: #c17e1b; + text-decoration: none; +} + +.table-aufgaben td.bezeichnung a:hover { + text-decoration: underline; +} + +.table-aufgaben th.kategorie, +.table-aufgaben td.kategorie { + width: 30%; + text-align: left; +} + +.table-aufgaben tr { + background: #fbeee6; +} + +/* Form */ +.form-container { + width: 80%; + margin: 0 auto; + background: #fff; + padding: 20px; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +.form-container table { + width: 100%; + border-collapse: collapse; +} + +.form-container th, +.form-container td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #ddd; + color: #8b5a2b; +} + +.form-container input[type="text"], +.form-container input[type="number"], +.form-container textarea, +.form-container select { + width: 100%; + padding: 8px; + font-size: 18px; + border: 1px solid #ccc; + border-radius: 4px; + color: #5b4636; +} + +.form-container textarea { + height: 100px; + resize: vertical; +} + +/* Images */ +.bilder-container { + margin-top: 15px; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.bild-vorschau { + position: relative; + width: 80px; + height: 80px; +} + +.bild-vorschau img { + width: 100%; + height: 100%; + object-fit: cover; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; +} + +.bild-loeschen { + position: absolute; + top: 5px; + right: 5px; + background: rgba(255, 255, 255, 0.8); + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + cursor: pointer; + font-size: 14px; + color: #d65c5c; + line-height: 20px; + text-align: center; +} + +/* Lightbox */ +#lightbox { + display: none; + position: fixed; + z-index: 9999; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.8); + text-align: center; + padding-top: 50px; +} + +#lightbox img { + max-width: 80%; + max-height: 80%; + margin: auto; + display: block; +} + +#lightbox .close { + position: absolute; + top: 20px; + right: 30px; + color: #fff; + font-size: 35px; + font-weight: bold; + cursor: pointer; +} + +/* Recipe View */ +.recipe-container { + width: 800px; + margin: 0 auto; + background: white; + padding: 20px; + border-radius: 8px; +} + +.recipe-container img { + max-width: 100%; +} + +.recipe-container img.oben { + width: 500px; +} + +.recipe-container img.vergroesserbar { + width: 200px; + cursor: pointer; +} + +.recipe-container img.vergroessert { + width: 400px !important; + transition: width 0.3s ease; +} + +.recipe-container pre.zutaten { + font-size: 1.2em; + font-family: Arial, sans-serif; + white-space: pre-wrap; +} + +.recipe-container .unter { + font-size: 1.4em; +} + +.recipe-container .title { + font-size: 1.6em; + margin-bottom: 10px; +} + +.neu-container { + width: 100%; + display: flex; + justify-content: center; + margin-top: 22px; +} + +.neu { + background: #bb7e19; + color: #fff; + padding: 10px 22px; + border: none; + border-radius: 4px; + text-decoration: none; + font-size: 1.5em; + box-shadow: 1px 1px 4px #efd7b1; +} + +.file-upload-container { + position: relative; + overflow: hidden; + display: inline-block; + margin-top: 10px; +} + +.file-upload-container input[type="file"] { + position: absolute; + left: 0; + top: 0; + opacity: 0; + width: 100%; + height: 100%; + cursor: pointer; +} + +.file-upload-btn { + background: #bb7e19; + color: white; + padding: 8px 12px; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 16px; +} diff --git a/public/edit.html b/public/edit.html new file mode 100644 index 0000000..752238c --- /dev/null +++ b/public/edit.html @@ -0,0 +1,87 @@ + + + + + + Rezept bearbeiten + + + +
+ Zurück zur Übersicht + + +

Neues Rezept

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Rezeptnummer:
Bezeichnung:
Beschreibung:
Kategorie:
Vorbereitung:
Anzahl der Personen:
Zutaten:
Zubereitung:
Kommentar:
Bilder hochladen: +
+
+ + +
+ +
+
+
+ +
+ + +
+
+
+ + + + + + \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0e092ec --- /dev/null +++ b/public/index.html @@ -0,0 +1,46 @@ + + + + + + Rezepte Übersicht + + + +
+
+
+ + +
+
+
+ +
+ + + + + + + + + + + +
+ Rezeptnr. + + Bezeichnung + + Kategorie +
Lade Rezepte...
+ + +
+ + + + \ No newline at end of file diff --git a/public/js/edit.js b/public/js/edit.js new file mode 100644 index 0000000..39fe1c0 --- /dev/null +++ b/public/js/edit.js @@ -0,0 +1,191 @@ +const urlParams = new URLSearchParams(window.location.search); +const recipeId = urlParams.get('id'); +let currentRecipe = null; + +if (recipeId) { + document.getElementById('pageTitle').textContent = 'Rezept bearbeiten'; + document.getElementById('deleteBtn').style.display = 'inline-block'; + document.getElementById('viewRecipeBtn').style.display = 'inline-block'; + loadRecipe(); +} + +async function loadRecipe() { + try { + const response = await fetch(`/api/recipes/${recipeId}`); + if (!response.ok) throw new Error('Rezept nicht gefunden'); + + currentRecipe = await response.json(); + populateForm(currentRecipe); + loadImages(); + } catch (error) { + console.error('Fehler:', error); + alert('Fehler beim Laden des Rezepts'); + } +} + +function populateForm(recipe) { + document.getElementById('recipeId').value = recipe._id; + document.getElementById('rezeptnummer').value = recipe.rezeptnummer; + document.getElementById('bezeichnung').value = recipe.bezeichnung || ''; + document.getElementById('beschreibung').value = recipe.beschreibung || ''; + document.getElementById('kategorie').value = recipe.kategorie || ''; + document.getElementById('vorbereitung').value = recipe.vorbereitung || ''; + document.getElementById('anzahl').value = recipe.anzahl || 2; + document.getElementById('zutaten').value = recipe.zutaten || ''; + + const zubereitungText = recipe.zubereitung + ? recipe.zubereitung.map(z => z.text).join('\n') + : ''; + document.getElementById('zubereitung').value = zubereitungText; + document.getElementById('kommentar').value = recipe.kommentar || ''; +} + +async function loadImages() { + if (!recipeId) return; + + try { + const response = await fetch(`/api/images/recipe/${recipeId}`); + if (!response.ok) return; + + const images = await response.json(); + displayImages(images); + } catch (error) { + console.error('Fehler beim Laden der Bilder:', error); + } +} + +function displayImages(images) { + const container = document.getElementById('bildvorschau'); + container.innerHTML = images.map(img => ` +
+ Vorschau + +
+ `).join(''); +} + +document.getElementById('recipeForm').addEventListener('submit', async (e) => { + e.preventDefault(); + + const formData = { + rezeptnummer: document.getElementById('rezeptnummer').value, + bezeichnung: document.getElementById('bezeichnung').value, + beschreibung: document.getElementById('beschreibung').value, + kategorie: document.getElementById('kategorie').value, + vorbereitung: document.getElementById('vorbereitung').value, + anzahl: document.getElementById('anzahl').value, + zutaten: document.getElementById('zutaten').value, + zubereitung: document.getElementById('zubereitung').value, + kommentar: document.getElementById('kommentar').value + }; + + try { + let response; + if (recipeId) { + response = await fetch(`/api/recipes/${recipeId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + } else { + response = await fetch('/api/recipes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Fehler beim Speichern'); + } + + alert('Rezept erfolgreich gespeichert'); + window.location.href = '/index.html'; + } catch (error) { + console.error('Fehler:', error); + alert('Fehler beim Speichern: ' + error.message); + } +}); + +document.getElementById('uploadImageBtn').addEventListener('click', async () => { + const fileInput = document.getElementById('fileInput'); + const file = fileInput.files[0]; + + if (!file) { + alert('Bitte wähle zuerst ein Bild aus'); + return; + } + + if (!recipeId) { + alert('Bitte speichere zuerst das Rezept, bevor du Bilder hochlädst'); + return; + } + + const formData = new FormData(); + formData.append('bild', file); + formData.append('rezeptnummer', document.getElementById('rezeptnummer').value); + + try { + const response = await fetch(`/api/images/${recipeId}`, { + method: 'POST', + body: formData + }); + + if (!response.ok) throw new Error('Upload fehlgeschlagen'); + + fileInput.value = ''; + loadImages(); + } catch (error) { + console.error('Fehler:', error); + alert('Fehler beim Hochladen des Bildes'); + } +}); + +async function deleteImage(imageId) { + if (!confirm('Möchtest du dieses Bild wirklich löschen?')) return; + + try { + const response = await fetch(`/api/images/${imageId}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Löschen fehlgeschlagen'); + + loadImages(); + } catch (error) { + console.error('Fehler:', error); + alert('Fehler beim Löschen des Bildes'); + } +} + +document.getElementById('deleteBtn').addEventListener('click', async () => { + if (!confirm('Möchtest du dieses Rezept wirklich löschen?')) return; + + try { + const response = await fetch(`/api/recipes/${recipeId}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error('Löschen fehlgeschlagen'); + + alert('Rezept gelöscht'); + window.location.href = '/index.html'; + } catch (error) { + console.error('Fehler:', error); + alert('Fehler beim Löschen des Rezepts'); + } +}); + +document.getElementById('viewRecipeBtn').addEventListener('click', () => { + window.location.href = `/recipe.html?id=${recipeId}`; +}); + +function openLightbox(src) { + document.getElementById('lightbox').style.display = 'block'; + document.getElementById('lightbox-img').src = src; +} + +function closeLightbox() { + document.getElementById('lightbox').style.display = 'none'; +} diff --git a/public/js/index.js b/public/js/index.js new file mode 100644 index 0000000..8115f1e --- /dev/null +++ b/public/js/index.js @@ -0,0 +1,108 @@ +const urlParams = new URLSearchParams(window.location.search); +const recipeId = urlParams.get('id'); + +if (!recipeId) { + window.location.href = '/index.html'; +} + +async function loadRecipe() { + try { + const response = await fetch(`/api/recipes/${recipeId}`); + if (!response.ok) throw new Error('Rezept nicht gefunden'); + + const recipe = await response.json(); + displayRecipe(recipe); + } catch (error) { + console.error('Fehler:', error); + alert('Fehler beim Laden des Rezepts'); + window.location.href = '/index.html'; + } +} + +function displayRecipe(recipe) { + const rNummer = 'R' + String(recipe.rezeptnummer).padStart(3, '0'); + + document.getElementById('rezeptnummer').textContent = rNummer; + document.getElementById('bezeichnung').textContent = recipe.bezeichnung; + document.getElementById('beschreibung').innerHTML = makeClickableLinks(recipe.beschreibung || ''); + + if (recipe.bilder && recipe.bilder.length > 0) { + document.getElementById('hauptbild').innerHTML = + `Rezeptbild`; + } + + if (recipe.vorbereitung && recipe.vorbereitung.trim()) { + document.getElementById('vorbereitung').innerHTML = + `
Vorbereitung: ${makeClickableLinks(recipe.vorbereitung)}`; + } + + if (recipe.zutaten && recipe.zutaten.trim()) { + let header = '
Zutaten'; + if (recipe.anzahl) { + header += ` für ${recipe.anzahl} Personen:`; + } + document.getElementById('zutatenHeader').innerHTML = header; + document.getElementById('zutaten').textContent = recipe.zutaten; + } else { + document.getElementById('zutatenContainer').style.display = 'none'; + } + + if (recipe.zubereitung && recipe.zubereitung.length > 0) { + const maxSteps = Math.max(recipe.zubereitung.length, recipe.bilder ? recipe.bilder.length - 1 : 0); + let html = ''; + + for (let i = 0; i < maxSteps; i++) { + const step = recipe.zubereitung[i]; + const bild = recipe.bilder && recipe.bilder[i + 1] ? recipe.bilder[i + 1] : null; + + html += ''; + if (bild) { + html += `Schritt ${i+1}`; + } else { + html += ''; + } + + html += `${step ? escapeHtml(step.text) : ' '}`; + html += ''; + } + + document.getElementById('zubereitungSteps').innerHTML = html; + } else { + document.getElementById('zubereitungContainer').style.display = 'none'; + } + + if (recipe.kommentar && recipe.kommentar.trim()) { + document.getElementById('kommentarContainer').innerHTML = ` + + +


Kommentar

+ ${makeClickableLinks(recipe.kommentar)}
+ `; + } + + document.getElementById('editBtn').onclick = () => { + window.location.href = `/edit.html?id=${recipeId}`; + }; +} + +function makeClickableLinks(text) { + const urlPattern = /(?:(?:https?:\/\/)|(?:www\.))[^\s<>"'()]+/gi; + return text.replace(urlPattern, url => `${url}`); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function openLightbox(src) { + document.getElementById('lightbox').style.display = 'block'; + document.getElementById('lightbox-img').src = src; +} + +function closeLightbox() { + document.getElementById('lightbox').style.display = 'none'; +} + +loadRecipe(); diff --git a/public/js/recipe.js b/public/js/recipe.js new file mode 100644 index 0000000..8115f1e --- /dev/null +++ b/public/js/recipe.js @@ -0,0 +1,108 @@ +const urlParams = new URLSearchParams(window.location.search); +const recipeId = urlParams.get('id'); + +if (!recipeId) { + window.location.href = '/index.html'; +} + +async function loadRecipe() { + try { + const response = await fetch(`/api/recipes/${recipeId}`); + if (!response.ok) throw new Error('Rezept nicht gefunden'); + + const recipe = await response.json(); + displayRecipe(recipe); + } catch (error) { + console.error('Fehler:', error); + alert('Fehler beim Laden des Rezepts'); + window.location.href = '/index.html'; + } +} + +function displayRecipe(recipe) { + const rNummer = 'R' + String(recipe.rezeptnummer).padStart(3, '0'); + + document.getElementById('rezeptnummer').textContent = rNummer; + document.getElementById('bezeichnung').textContent = recipe.bezeichnung; + document.getElementById('beschreibung').innerHTML = makeClickableLinks(recipe.beschreibung || ''); + + if (recipe.bilder && recipe.bilder.length > 0) { + document.getElementById('hauptbild').innerHTML = + `Rezeptbild`; + } + + if (recipe.vorbereitung && recipe.vorbereitung.trim()) { + document.getElementById('vorbereitung').innerHTML = + `
Vorbereitung: ${makeClickableLinks(recipe.vorbereitung)}`; + } + + if (recipe.zutaten && recipe.zutaten.trim()) { + let header = '
Zutaten'; + if (recipe.anzahl) { + header += ` für ${recipe.anzahl} Personen:`; + } + document.getElementById('zutatenHeader').innerHTML = header; + document.getElementById('zutaten').textContent = recipe.zutaten; + } else { + document.getElementById('zutatenContainer').style.display = 'none'; + } + + if (recipe.zubereitung && recipe.zubereitung.length > 0) { + const maxSteps = Math.max(recipe.zubereitung.length, recipe.bilder ? recipe.bilder.length - 1 : 0); + let html = ''; + + for (let i = 0; i < maxSteps; i++) { + const step = recipe.zubereitung[i]; + const bild = recipe.bilder && recipe.bilder[i + 1] ? recipe.bilder[i + 1] : null; + + html += ''; + if (bild) { + html += `Schritt ${i+1}`; + } else { + html += ''; + } + + html += `${step ? escapeHtml(step.text) : ' '}`; + html += ''; + } + + document.getElementById('zubereitungSteps').innerHTML = html; + } else { + document.getElementById('zubereitungContainer').style.display = 'none'; + } + + if (recipe.kommentar && recipe.kommentar.trim()) { + document.getElementById('kommentarContainer').innerHTML = ` + + +


Kommentar

+ ${makeClickableLinks(recipe.kommentar)}
+ `; + } + + document.getElementById('editBtn').onclick = () => { + window.location.href = `/edit.html?id=${recipeId}`; + }; +} + +function makeClickableLinks(text) { + const urlPattern = /(?:(?:https?:\/\/)|(?:www\.))[^\s<>"'()]+/gi; + return text.replace(urlPattern, url => `${url}`); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function openLightbox(src) { + document.getElementById('lightbox').style.display = 'block'; + document.getElementById('lightbox-img').src = src; +} + +function closeLightbox() { + document.getElementById('lightbox').style.display = 'none'; +} + +loadRecipe(); diff --git a/public/recipe.html b/public/recipe.html new file mode 100644 index 0000000..df34406 --- /dev/null +++ b/public/recipe.html @@ -0,0 +1,46 @@ + + + + + + Rezept + + + + + + + + + +
+ Zurück zur Übersicht + +
+ + + + + + + +
+
+ + +
+
+ + + +

Zubereitung

+
+ + + + + + \ No newline at end of file diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..69b8823 --- /dev/null +++ b/server/db.js @@ -0,0 +1,43 @@ +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const rezeptnummer = req.body.rezeptnummer || req.params.rezeptnummer; + const rNummer = 'R' + String(rezeptnummer).padStart(3, '0'); + const uploadPath = path.join('uploads', rNummer); + + if (!fs.existsSync(uploadPath)) { + fs.mkdirSync(uploadPath, { recursive: true }); + } + + cb(null, uploadPath); + }, + filename: (req, file, cb) => { + const rezeptnummer = req.body.rezeptnummer || req.params.rezeptnummer; + const rNummer = 'R' + String(rezeptnummer).padStart(3, '0'); + const uploadPath = path.join('uploads', rNummer); + + const existingFiles = fs.readdirSync(uploadPath).filter(f => f.startsWith(rNummer)); + const nextIndex = existingFiles.length; + + const filename = `${rNummer}_${nextIndex}.jpg`; + cb(null, filename); + } +}); + +const fileFilter = (req, file, cb) => { + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png']; + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Nur JPG, JPEG und PNG Dateien erlaubt'), false); + } +}; + +export const upload = multer({ + storage, + fileFilter, + limits: { fileSize: 10 * 1024 * 1024 } // 10MB +}); diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..f70224b --- /dev/null +++ b/server/index.js @@ -0,0 +1,58 @@ +import express from 'express'; +import cors from 'cors'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; +import { connectDB, closeDB } from './db.js'; +import recipesRoutes from './routes/recipes.js'; +import imagesRoutes from './routes/images.js'; + +dotenv.config(); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Statische Dateien +app.use(express.static(path.join(__dirname, '../public'))); +app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); + +// API Routes +app.use('/api/recipes', recipesRoutes); +app.use('/api/images', imagesRoutes); + +// Catch-all Route für SPA +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../public/index.html')); +}); + +// Server starten +async function startServer() { + try { + await connectDB(); + + app.listen(PORT, () => { + console.log(`Server läuft auf Port ${PORT}`); + console.log(`http://localhost:${PORT}`); + }); + } catch (error) { + console.error('Fehler beim Starten des Servers:', error); + process.exit(1); + } +} + +// Graceful Shutdown +process.on('SIGINT', async () => { + console.log('\nServer wird heruntergefahren...'); + await closeDB(); + process.exit(0); +}); + +startServer(); diff --git a/server/middleware/upload.js b/server/middleware/upload.js new file mode 100644 index 0000000..69b8823 --- /dev/null +++ b/server/middleware/upload.js @@ -0,0 +1,43 @@ +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const rezeptnummer = req.body.rezeptnummer || req.params.rezeptnummer; + const rNummer = 'R' + String(rezeptnummer).padStart(3, '0'); + const uploadPath = path.join('uploads', rNummer); + + if (!fs.existsSync(uploadPath)) { + fs.mkdirSync(uploadPath, { recursive: true }); + } + + cb(null, uploadPath); + }, + filename: (req, file, cb) => { + const rezeptnummer = req.body.rezeptnummer || req.params.rezeptnummer; + const rNummer = 'R' + String(rezeptnummer).padStart(3, '0'); + const uploadPath = path.join('uploads', rNummer); + + const existingFiles = fs.readdirSync(uploadPath).filter(f => f.startsWith(rNummer)); + const nextIndex = existingFiles.length; + + const filename = `${rNummer}_${nextIndex}.jpg`; + cb(null, filename); + } +}); + +const fileFilter = (req, file, cb) => { + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png']; + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Nur JPG, JPEG und PNG Dateien erlaubt'), false); + } +}; + +export const upload = multer({ + storage, + fileFilter, + limits: { fileSize: 10 * 1024 * 1024 } // 10MB +}); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..e166cda --- /dev/null +++ b/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "recipe-app", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js" + }, + "dependencies": { + "express": "^4.19.2", + "mongodb": "^6.8.0", + "multer": "^1.4.5-lts.1", + "dotenv": "^16.4.5" + } +} diff --git a/server/routes/images.js b/server/routes/images.js new file mode 100644 index 0000000..edb08ff --- /dev/null +++ b/server/routes/images.js @@ -0,0 +1,72 @@ +import express from 'express'; +import { ObjectId } from 'mongodb'; +import { getDB } from '../db.js'; +import { upload } from '../middleware/upload.js'; +import fs from 'fs'; +import path from 'path'; + +const router = express.Router(); + +// Bild hochladen +router.post('/:rezeptId', upload.single('bild'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'Keine Datei hochgeladen' }); + } + + const db = getDB(); + const rezeptId = new ObjectId(req.params.rezeptId); + + const imageDoc = { + rezeptId, + dateiPfad: req.file.path.replace(/\\/g, '/'), + dateiName: req.file.filename, + hochgeladenAm: new Date() + }; + + const result = await db.collection('images').insertOne(imageDoc); + res.status(201).json({ _id: result.insertedId, ...imageDoc }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Bild löschen +router.delete('/:bildId', async (req, res) => { + try { + const db = getDB(); + const bildId = new ObjectId(req.params.bildId); + + const image = await db.collection('images').findOne({ _id: bildId }); + if (!image) { + return res.status(404).json({ error: 'Bild nicht gefunden' }); + } + + // Datei löschen + if (fs.existsSync(image.dateiPfad)) { + fs.unlinkSync(image.dateiPfad); + } + + await db.collection('images').deleteOne({ _id: bildId }); + res.json({ message: 'Bild gelöscht' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Bilder eines Rezepts abrufen +router.get('/recipe/:rezeptId', async (req, res) => { + try { + const db = getDB(); + const images = await db.collection('images') + .find({ rezeptId: new ObjectId(req.params.rezeptId) }) + .sort({ _id: 1 }) + .toArray(); + + res.json(images); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +export default router; diff --git a/server/routes/recipes.js b/server/routes/recipes.js new file mode 100644 index 0000000..0650513 --- /dev/null +++ b/server/routes/recipes.js @@ -0,0 +1,177 @@ +kimport express from 'express'; +import { ObjectId } from 'mongodb'; +import { getDB } from '../db.js'; + +const router = express.Router(); + +// Alle Rezepte abrufen (mit Suche und Sortierung) +router.get('/', async (req, res) => { + try { + const db = getDB(); + const { search, sort = 'rezeptnummer' } = req.query; + + let query = {}; + if (search) { + query.$text = { $search: search }; + } + + const sortField = ['rezeptnummer', 'bezeichnung', 'kategorie'].includes(sort) + ? sort + : 'rezeptnummer'; + + const recipes = await db.collection('recipes') + .find(query) + .sort({ [sortField]: 1 }) + .project({ _id: 1, rezeptnummer: 1, bezeichnung: 1, kategorie: 1 }) + .toArray(); + + res.json(recipes); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Einzelnes Rezept abrufen +router.get('/:id', async (req, res) => { + try { + const db = getDB(); + const recipe = await db.collection('recipes').findOne({ + _id: new ObjectId(req.params.id) + }); + + if (!recipe) { + return res.status(404).json({ error: 'Rezept nicht gefunden' }); + } + + // Bilder abrufen + const images = await db.collection('images') + .find({ rezeptId: new ObjectId(req.params.id) }) + .sort({ _id: 1 }) + .toArray(); + + res.json({ ...recipe, bilder: images }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Neues Rezept erstellen +router.post('/', async (req, res) => { + try { + const db = getDB(); + const { + rezeptnummer, + bezeichnung, + beschreibung, + kategorie, + vorbereitung, + anzahl, + zutaten, + zubereitung, + kommentar + } = req.body; + + // Zubereitungsschritte aufteilen + const zubereitungArray = zubereitung + .split('\n') + .map(step => step.trim()) + .filter(step => step.length > 0) + .map((text, index) => ({ schritt: index + 1, text })); + + const recipe = { + rezeptnummer: parseInt(rezeptnummer), + bezeichnung, + beschreibung, + kategorie, + vorbereitung, + anzahl: parseInt(anzahl) || 2, + zutaten, + zubereitung: zubereitungArray, + kommentar, + erstelltAm: new Date(), + aktualisiertAm: new Date() + }; + + const result = await db.collection('recipes').insertOne(recipe); + res.status(201).json({ _id: result.insertedId, ...recipe }); + } catch (error) { + if (error.code === 11000) { + res.status(400).json({ error: 'Rezeptnummer existiert bereits' }); + } else { + res.status(500).json({ error: error.message }); + } + } +}); + +// Rezept aktualisieren +router.put('/:id', async (req, res) => { + try { + const db = getDB(); + const { + rezeptnummer, + bezeichnung, + beschreibung, + kategorie, + vorbereitung, + anzahl, + zutaten, + zubereitung, + kommentar + } = req.body; + + const zubereitungArray = zubereitung + .split('\n') + .map(step => step.trim()) + .filter(step => step.length > 0) + .map((text, index) => ({ schritt: index + 1, text })); + + const updateData = { + rezeptnummer: parseInt(rezeptnummer), + bezeichnung, + beschreibung, + kategorie, + vorbereitung, + anzahl: parseInt(anzahl) || 2, + zutaten, + zubereitung: zubereitungArray, + kommentar, + aktualisiertAm: new Date() + }; + + const result = await db.collection('recipes').updateOne( + { _id: new ObjectId(req.params.id) }, + { $set: updateData } + ); + + if (result.matchedCount === 0) { + return res.status(404).json({ error: 'Rezept nicht gefunden' }); + } + + res.json({ message: 'Rezept aktualisiert' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Rezept löschen +router.delete('/:id', async (req, res) => { + try { + const db = getDB(); + const recipeId = new ObjectId(req.params.id); + + // Lösche zugehörige Bilder + await db.collection('images').deleteMany({ rezeptId: recipeId }); + + const result = await db.collection('recipes').deleteOne({ _id: recipeId }); + + if (result.deletedCount === 0) { + return res.status(404).json({ error: 'Rezept nicht gefunden' }); + } + + res.json({ message: 'Rezept gelöscht' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +export default router;