v1.1
This commit is contained in:
@@ -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
|
||||
@@ -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/<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
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -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")
|
||||
|
||||
+11
-7
@@ -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:
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<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 |
@@ -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' }),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user