diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4fdc6b9 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +APP_DOMAIN=https://example.com +FRONTEND_ORIGIN=https://example.com + +POSTGRES_DB=rezept_finder +POSTGRES_USER=rezept_finder +POSTGRES_PASSWORD=change-me +DATABASE_URL=postgresql+psycopg2://rezept_finder:change-me@postgres:5432/rezept_finder + +SECRET_KEY=change-me +SOCKETIO_PATH=/api/socket.io + +VITE_API_BASE=/api +VITE_SOCKET_PATH=/api/socket.io +VITE_DEV_API_TARGET=http://backend:5000 diff --git a/README.md b/README.md index 6268548..dd11700 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,354 @@ # Rezept Finder -MVP: Gemeinsame Food-Swipe-Webapp. +Gemeinsame Food-Swipe-Webapp, mit der mehrere Personen in einer Session Rezepte bewerten und am Ende ein gemeinsames Essen finden. -## Entwicklung starten +Die App lädt dynamisch Rezeptvorschläge von Chefkoch, synchronisiert Teilnehmer per Socket.IO und unterstützt zwei Match-Modi: ein schnelles erstes Match oder eine zweite Finalrunde aus gemeinsamen Favoriten. + +## Features + +- Gemeinsame Sessions mit kurzem Session-Code +- Einladungslink für Gäste +- Host-/Gast-Flow mit eigener Namenseingabe +- Swipe-Bedienung wie bei Dating-Apps: links `Nein`, rechts `Ja` +- Buttons als Alternative zum Swipen +- Dynamische Chefkoch-Rezepte aus „Was koche ich heute?“ +- Automatisches Nachladen weiterer 8 Rezepte +- Asynchrones Voting: alle Teilnehmer können in eigener Geschwindigkeit voten +- Einstellbare Like-Schwelle ab mindestens 2 Likes +- Zwei Match-Modi: + - `Erstes Match gewinnt` + - `Finalrunde aus Favoriten` +- Session-Neustart durch den Host mit frischen Rezepten +- Same-Domain-Setup über `/api`, vorbereitet für Nginx + +## Tech Stack + +- Frontend: React + Vite +- Realtime: Socket.IO +- Backend: Flask + Flask-SocketIO +- Datenbank: PostgreSQL +- ORM: Flask-SQLAlchemy +- Container: Docker Compose + +## Projektstruktur + +```text +. +├── backend/ +│ ├── app/ +│ │ ├── models/ +│ │ ├── routes/ +│ │ ├── services/ +│ │ └── socket/ +│ ├── Dockerfile +│ ├── requirements.txt +│ └── run.py +├── frontend/ +│ ├── public/ +│ ├── src/ +│ ├── Dockerfile +│ ├── index.html +│ ├── package.json +│ └── vite.config.js +├── docker-compose.yml +├── .env.example +└── README.md +``` + +## Schnellstart + +```bash +cp .env.example .env +docker compose up --build +``` + +Danach ist die App lokal erreichbar unter: + +```text +http://localhost:5173 +``` + +Die API läuft im Dev-Setup über denselben Origin: + +```text +http://localhost:5173/api +``` + +Vite proxyt `/api` intern an den Backend-Container. + +## Environment + +Alle zentralen Konfigurationswerte liegen in `.env`. Die Datei ist absichtlich nicht versioniert. Als Vorlage dient [.env.example](.env.example). + +Wichtige Variablen: + +| Variable | Bedeutung | +| --- | --- | +| `APP_DOMAIN` | Öffentliche Domain der App, z. B. `https://example.com` | +| `FRONTEND_ORIGIN` | Erlaubter Browser-Origin für CORS und Socket.IO | +| `DATABASE_URL` | SQLAlchemy-Verbindung zum Backend-Postgres | +| `POSTGRES_DB` | Datenbankname für den lokalen Postgres-Container | +| `POSTGRES_USER` | Datenbanknutzer für den lokalen Postgres-Container | +| `POSTGRES_PASSWORD` | Datenbankpasswort für den lokalen Postgres-Container | +| `SECRET_KEY` | Flask Secret Key | +| `SOCKETIO_PATH` | Backend-Socket.IO-Pfad, standardmäßig `/api/socket.io` | +| `VITE_API_BASE` | Frontend-API-Basis, standardmäßig `/api` | +| `VITE_SOCKET_PATH` | Frontend-Socket.IO-Pfad, standardmäßig `/api/socket.io` | +| `VITE_DEV_API_TARGET` | Ziel für den Vite-Dev-Proxy im Docker-Netzwerk | + +Beispiel für lokale Entwicklung: + +```env +APP_DOMAIN=http://localhost:5173 +FRONTEND_ORIGIN=http://localhost:5173 + +POSTGRES_DB=rezept_finder +POSTGRES_USER=rezept_finder +POSTGRES_PASSWORD=rezept_finder +DATABASE_URL=postgresql+psycopg2://rezept_finder:rezept_finder@postgres:5432/rezept_finder + +SECRET_KEY=dev-secret +SOCKETIO_PATH=/api/socket.io + +VITE_API_BASE=/api +VITE_SOCKET_PATH=/api/socket.io +VITE_DEV_API_TARGET=http://backend:5000 +``` + +## Same-Domain-Routing + +Das Frontend spricht bewusst keine harte Backend-URL mehr an. Stattdessen laufen alle Backend-Zugriffe unter `/api`. + +Aktuelle Pfade: + +| Pfad | Ziel | +| --- | --- | +| `/` | Frontend | +| `/api/sessions` | Flask-API | +| `/api/sessions//join` | Flask-API | +| `/api/socket.io` | Socket.IO / WebSocket | + +Das macht die App bereit für ein Nginx-Setup mit einer einzigen öffentlichen Domain. + +## Beispiel: Nginx + +Eine mögliche Nginx-Skizze: + +```nginx +server { + server_name example.com; + + location / { + proxy_pass http://frontend:5173; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/socket.io { + proxy_pass http://backend:5000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://backend:5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Je nach Deployment laufen `frontend` und `backend` natürlich unter anderen Upstream-Namen oder Ports. + +## Docker-Kommandos + +Alle Services starten: + +```bash +docker compose up --build +``` + +Detached starten: + +```bash +docker compose up -d --build +``` + +Logs ansehen: + +```bash +docker compose logs -f +``` + +Nur Backend-Logs: + +```bash +docker compose logs -f backend +``` + +Services stoppen: + +```bash +docker compose down +``` + +Datenbankvolume ebenfalls löschen: + +```bash +docker compose down -v +``` + +## Entwicklung Ohne Docker ### Backend + ```bash cd backend python -m venv .venv source .venv/bin/activate pip install -r requirements.txt -cp .env.example .env -flask --app app:create_app db upgrade python run.py ``` +Das Backend erwartet eine gültige `DATABASE_URL`. + ### Frontend + ```bash cd frontend npm install npm run dev ``` -## Postgres lokal per Docker -```bash -docker compose up -d postgres +Für lokale Entwicklung ohne Docker muss `VITE_DEV_API_TARGET` passend auf dein Backend zeigen, z. B.: + +```env +VITE_DEV_API_TARGET=http://localhost:5000 +``` + +## App-Ablauf + +1. Host erstellt eine Session. +2. Gäste treten per Code oder Einladungslink bei. +3. Host stellt optional Modus und Like-Schwelle ein. +4. Host startet die Session. +5. Alle Teilnehmer bewerten Rezepte per Swipe oder Button. +6. Je nach Modus gewinnt: + - das erste Rezept mit genug Likes + - oder ein Rezept aus einer zweiten Finalrunde + +## Match-Modi + +### Erstes Match Gewinnt + +Ein Rezept gewinnt sofort, sobald es die eingestellte Like-Schwelle erreicht. + +Beispiel: + +- 4 Teilnehmer +- Like-Schwelle: 2 +- Sobald 2 Personen dasselbe Rezept liken, ist es das Match. + +### Finalrunde Aus Favoriten + +In diesem Modus gewinnt nicht sofort das erste passende Rezept. + +Ablauf: + +1. Alle bewerten die aktuellen 8 Rezepte. +2. Rezepte mit genug Likes werden Favoriten. +3. Gibt es weniger als 3 Favoriten, lädt die App weitere 8 Rezepte nach. +4. Sobald mindestens 3 Favoriten da sind, startet Runde 2. +5. In Runde 2 werden nur die Favoriten erneut bewertet. +6. In Runde 2 gewinnt ein Rezept, sobald es die Like-Schwelle erreicht. + +Die Reihenfolge im zweiten Modus wird pro Teilnehmer lokal zufällig gemischt. + +## Rezeptquelle + +Die App lädt Vorschläge von: + +```text +https://www.chefkoch.de/rezepte/was-koche-ich-heute/ +``` + +Falls Chefkoch nicht erreichbar ist oder das Parsen fehlschlägt, nutzt das Backend Fallback-Rezepte, damit die App weiter bedienbar bleibt. + +## Favicon Und Assets + +Das Favicon liegt unter: + +```text +frontend/public/ +``` + +Dateien: + +- `favicon.ico` +- `favicon-32.png` +- `favicon-512.png` +- `apple-touch-icon.png` + +## Hinweise Zur Datenbank + +Beim App-Start wird das Schema automatisch vorbereitet. Zusätzlich gibt es eine kleine idempotente Aktualisierung für neue Settings-Spalten in `session_settings`. + +Für produktive Deployments sollte später eine echte Migration-Strategie verwendet werden, z. B. Flask-Migrate/Alembic. + +## Troubleshooting + +### Frontend Kann Backend Nicht Erreichen + +Prüfe: + +```bash +docker compose ps +docker compose logs backend +docker compose logs frontend +``` + +Im Dev-Setup sollte dieser Request funktionieren: + +```bash +curl -i http://localhost:5173/api/socket.io/?EIO=4\&transport=polling +``` + +Und eine Session-Erstellung: + +```bash +curl -i -X POST http://localhost:5173/api/sessions \ + -H 'Content-Type: application/json' \ + -d '{"name":"Test"}' +``` + +### Socket.IO Verbindet Nicht Hinter Nginx + +Stelle sicher, dass `/api/socket.io` WebSocket-Upgrades erlaubt: + +```nginx +proxy_http_version 1.1; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection "upgrade"; +``` + +### SELinux / Permission Denied Bei Volumes + +Die Docker-Volumes nutzen in `docker-compose.yml` `:Z`, damit Bind-Mounts auf SELinux-Systemen lesbar sind. + +### Alte Daten Stören + +Lokale Datenbank komplett zurücksetzen: + +```bash +docker compose down -v +docker compose up --build ``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py index b53440a..fd1a9c6 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -23,7 +23,7 @@ def create_app(): @app.after_request def add_cors_headers(response): - origin = os.getenv("FRONTEND_ORIGIN", "*") + origin = os.getenv("FRONTEND_ORIGIN") or os.getenv("APP_DOMAIN") or "*" response.headers["Access-Control-Allow-Origin"] = origin response.headers["Access-Control-Allow-Headers"] = "Content-Type" response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" @@ -31,7 +31,11 @@ def create_app(): db.init_app(app) migrate.init_app(app, db) - socketio.init_app(app, cors_allowed_origins=os.getenv("FRONTEND_ORIGIN", "*")) + socketio.init_app( + app, + cors_allowed_origins=os.getenv("FRONTEND_ORIGIN") or os.getenv("APP_DOMAIN") or "*", + path=os.getenv("SOCKETIO_PATH", "/api/socket.io"), + ) from .routes.sessions import bp as sessions_bp app.register_blueprint(sessions_bp, url_prefix="/api/sessions") diff --git a/docker-compose.yml b/docker-compose.yml index 3478102..64bcd95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,9 @@ services: image: postgres:16 container_name: rezept_finder_postgres environment: - POSTGRES_DB: rezept_finder - POSTGRES_USER: rezept_finder - POSTGRES_PASSWORD: rezept_finder + POSTGRES_DB: ${POSTGRES_DB:-rezept_finder} + POSTGRES_USER: ${POSTGRES_USER:-rezept_finder} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rezept_finder} ports: - "5432:5432" volumes: @@ -21,9 +21,11 @@ services: context: ./backend container_name: rezept_finder_backend environment: - DATABASE_URL: postgresql+psycopg2://rezept_finder:rezept_finder@postgres:5432/rezept_finder - FRONTEND_ORIGIN: http://localhost:5173 - SECRET_KEY: dev-secret + APP_DOMAIN: ${APP_DOMAIN:-http://localhost:5173} + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://rezept_finder:rezept_finder@postgres:5432/rezept_finder} + FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:5173} + SECRET_KEY: ${SECRET_KEY:-dev-secret} + SOCKETIO_PATH: ${SOCKETIO_PATH:-/api/socket.io} ports: - "5000:5000" volumes: @@ -40,7 +42,9 @@ services: context: ./frontend container_name: rezept_finder_frontend environment: - VITE_API_URL: http://localhost:5000 + VITE_API_BASE: ${VITE_API_BASE:-/api} + VITE_SOCKET_PATH: ${VITE_SOCKET_PATH:-/api/socket.io} + VITE_DEV_API_TARGET: ${VITE_DEV_API_TARGET:-http://backend:5000} ports: - "5173:5173" volumes: diff --git a/frontend/index.html b/frontend/index.html index f6def25..7ff9bf2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,10 @@ + + + + Rezept Finder diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..19c56ca Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-32.png b/frontend/public/favicon-32.png new file mode 100644 index 0000000..e6c8bb2 Binary files /dev/null and b/frontend/public/favicon-32.png differ diff --git a/frontend/public/favicon-512.png b/frontend/public/favicon-512.png new file mode 100644 index 0000000..115cd83 Binary files /dev/null and b/frontend/public/favicon-512.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..50e51ff Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index b6bf9ff..24f6169 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,7 +3,9 @@ import { createRoot } from 'react-dom/client'; import { io } from 'socket.io-client'; import './style.css'; -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'; +const API_BASE = import.meta.env.VITE_API_BASE || '/api'; +const SOCKET_ORIGIN = import.meta.env.VITE_SOCKET_ORIGIN || window.location.origin; +const SOCKET_PATH = import.meta.env.VITE_SOCKET_PATH || `${API_BASE}/socket.io`; function shuffle(items) { const shuffled = [...items]; @@ -33,7 +35,7 @@ function App() { const votingRoundRef = useRef(null); const SWIPE_THRESHOLD = 110; - const socket = useMemo(() => io(API_URL, { autoConnect: false }), []); + const socket = useMemo(() => io(SOCKET_ORIGIN, { autoConnect: false, path: SOCKET_PATH }), []); const votedRecipeIdSet = useMemo(() => new Set(votedRecipeIds), [votedRecipeIds]); const orderedRecipes = useMemo(() => { if (session?.matchMode !== 'final_round') { @@ -117,7 +119,7 @@ function App() { setError(''); setIsLoading(true); try { - const response = await fetch(`${API_URL}/api/sessions`, { + const response = await fetch(`${API_BASE}/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: hostName || 'Host' }), @@ -143,7 +145,7 @@ function App() { setError(''); setIsLoading(true); try { - const response = await fetch(`${API_URL}/api/sessions/${code}/join`, { + const response = await fetch(`${API_BASE}/sessions/${code}/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: guestName || 'Gast' }), diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..62cc9fb --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +const apiTarget = process.env.VITE_DEV_API_TARGET || 'http://backend:5000'; + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + proxy: { + '/api': { + target: apiTarget, + changeOrigin: true, + ws: true, + }, + }, + }, +});