This commit is contained in:
2026-06-10 11:19:39 +02:00
parent 90e5f61b91
commit aaa9365e81
11 changed files with 392 additions and 20 deletions
+14
View File
@@ -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
+333 -7
View File
@@ -1,28 +1,354 @@
# Rezept Finder # 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/<code>/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 ### Backend
```bash ```bash
cd backend cd backend
python -m venv .venv python -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
cp .env.example .env
flask --app app:create_app db upgrade
python run.py python run.py
``` ```
Das Backend erwartet eine gültige `DATABASE_URL`.
### Frontend ### Frontend
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev npm run dev
``` ```
## Postgres lokal per Docker Für lokale Entwicklung ohne Docker muss `VITE_DEV_API_TARGET` passend auf dein Backend zeigen, z. B.:
```bash
docker compose up -d postgres ```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
``` ```
+6 -2
View File
@@ -23,7 +23,7 @@ def create_app():
@app.after_request @app.after_request
def add_cors_headers(response): 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-Origin"] = origin
response.headers["Access-Control-Allow-Headers"] = "Content-Type" response.headers["Access-Control-Allow-Headers"] = "Content-Type"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
@@ -31,7 +31,11 @@ def create_app():
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) 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 from .routes.sessions import bp as sessions_bp
app.register_blueprint(sessions_bp, url_prefix="/api/sessions") app.register_blueprint(sessions_bp, url_prefix="/api/sessions")
+11 -7
View File
@@ -3,9 +3,9 @@ services:
image: postgres:16 image: postgres:16
container_name: rezept_finder_postgres container_name: rezept_finder_postgres
environment: environment:
POSTGRES_DB: rezept_finder POSTGRES_DB: ${POSTGRES_DB:-rezept_finder}
POSTGRES_USER: rezept_finder POSTGRES_USER: ${POSTGRES_USER:-rezept_finder}
POSTGRES_PASSWORD: rezept_finder POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rezept_finder}
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
@@ -21,9 +21,11 @@ services:
context: ./backend context: ./backend
container_name: rezept_finder_backend container_name: rezept_finder_backend
environment: environment:
DATABASE_URL: postgresql+psycopg2://rezept_finder:rezept_finder@postgres:5432/rezept_finder APP_DOMAIN: ${APP_DOMAIN:-http://localhost:5173}
FRONTEND_ORIGIN: http://localhost:5173 DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg2://rezept_finder:rezept_finder@postgres:5432/rezept_finder}
SECRET_KEY: dev-secret FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:5173}
SECRET_KEY: ${SECRET_KEY:-dev-secret}
SOCKETIO_PATH: ${SOCKETIO_PATH:-/api/socket.io}
ports: ports:
- "5000:5000" - "5000:5000"
volumes: volumes:
@@ -40,7 +42,9 @@ services:
context: ./frontend context: ./frontend
container_name: rezept_finder_frontend container_name: rezept_finder_frontend
environment: 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: ports:
- "5173:5173" - "5173:5173"
volumes: volumes:
+4
View File
@@ -3,6 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Rezept Finder</title> <title>Rezept Finder</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+6 -4
View File
@@ -3,7 +3,9 @@ import { createRoot } from 'react-dom/client';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import './style.css'; 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) { function shuffle(items) {
const shuffled = [...items]; const shuffled = [...items];
@@ -33,7 +35,7 @@ function App() {
const votingRoundRef = useRef(null); const votingRoundRef = useRef(null);
const SWIPE_THRESHOLD = 110; 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 votedRecipeIdSet = useMemo(() => new Set(votedRecipeIds), [votedRecipeIds]);
const orderedRecipes = useMemo(() => { const orderedRecipes = useMemo(() => {
if (session?.matchMode !== 'final_round') { if (session?.matchMode !== 'final_round') {
@@ -117,7 +119,7 @@ function App() {
setError(''); setError('');
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch(`${API_URL}/api/sessions`, { const response = await fetch(`${API_BASE}/sessions`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: hostName || 'Host' }), body: JSON.stringify({ name: hostName || 'Host' }),
@@ -143,7 +145,7 @@ function App() {
setError(''); setError('');
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch(`${API_URL}/api/sessions/${code}/join`, { const response = await fetch(`${API_BASE}/sessions/${code}/join`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: guestName || 'Gast' }), body: JSON.stringify({ name: guestName || 'Gast' }),
+18
View File
@@ -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,
},
},
},
});