This commit is contained in:
2026-06-09 15:25:54 +02:00
commit 7b3b9a890f
16 changed files with 600 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# Rezept Finder
MVP: Gemeinsame Food-Swipe-Webapp.
## Entwicklung starten
### 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
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
## Postgres lokal per Docker
```bash
docker compose up -d postgres
```
+4
View File
@@ -0,0 +1,4 @@
FLASK_ENV=development
DATABASE_URL=postgresql+psycopg2://rezept_finder:rezept_finder@localhost:5432/rezept_finder
SECRET_KEY=dev-secret-change-me
FRONTEND_ORIGIN=http://localhost:5173
+32
View File
@@ -0,0 +1,32 @@
import os
from flask import Flask
from flask_migrate import Migrate
from flask_socketio import SocketIO
from dotenv import load_dotenv
from .models import db
socketio = SocketIO(cors_allowed_origins="*")
migrate = Migrate()
def create_app():
load_dotenv()
app = Flask(__name__)
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret")
app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv(
"DATABASE_URL",
"postgresql+psycopg2://rezept_finder:rezept_finder@localhost:5432/rezept_finder",
)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
migrate.init_app(app, db)
socketio.init_app(app, cors_allowed_origins=os.getenv("FRONTEND_ORIGIN", "*"))
from .routes.sessions import bp as sessions_bp
app.register_blueprint(sessions_bp, url_prefix="/api/sessions")
from .socket import events # noqa: F401
return app
+5
View File
@@ -0,0 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
from .session import Session, Participant, Recipe, Vote # noqa: E402,F401
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime
from . import db
class Session(db.Model):
__tablename__ = "sessions"
id = db.Column(db.Integer, primary_key=True)
code = db.Column(db.String(6), unique=True, nullable=False, index=True)
status = db.Column(db.String(20), nullable=False, default="waiting")
current_recipe_index = db.Column(db.Integer, nullable=False, default=0)
matched_recipe_id = db.Column(db.Integer, db.ForeignKey("recipes.id"), nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
participants = db.relationship("Participant", backref="session", cascade="all, delete-orphan")
recipes = db.relationship("Recipe", backref="session", cascade="all, delete-orphan", order_by="Recipe.position")
class Participant(db.Model):
__tablename__ = "participants"
id = db.Column(db.Integer, primary_key=True)
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False)
name = db.Column(db.String(80), nullable=False)
is_host = db.Column(db.Boolean, nullable=False, default=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
class Recipe(db.Model):
__tablename__ = "recipes"
id = db.Column(db.Integer, primary_key=True)
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False)
position = db.Column(db.Integer, nullable=False)
title = db.Column(db.String(255), nullable=False)
url = db.Column(db.Text, nullable=False)
image_url = db.Column(db.Text, nullable=True)
meta = db.Column(db.String(255), nullable=True)
class Vote(db.Model):
__tablename__ = "votes"
id = db.Column(db.Integer, primary_key=True)
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False)
participant_id = db.Column(db.Integer, db.ForeignKey("participants.id"), nullable=False)
recipe_id = db.Column(db.Integer, db.ForeignKey("recipes.id"), nullable=False)
vote = db.Column(db.String(3), nullable=False) # yes/no
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint("participant_id", "recipe_id", name="uq_participant_recipe_vote"),
)
+81
View File
@@ -0,0 +1,81 @@
import random
import string
from flask import Blueprint, jsonify, request
from ..models import db
from ..models.session import Session, Participant, Recipe
from ..services.recipes import get_recipes
bp = Blueprint("sessions", __name__)
def generate_code():
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
def serialize_session(session):
return {
"id": session.id,
"code": session.code,
"status": session.status,
"currentRecipeIndex": session.current_recipe_index,
"participants": [
{"id": p.id, "name": p.name, "isHost": p.is_host}
for p in session.participants
],
"recipes": [
{
"id": r.id,
"title": r.title,
"url": r.url,
"imageUrl": r.image_url,
"meta": r.meta,
"position": r.position,
}
for r in session.recipes
],
"matchedRecipeId": session.matched_recipe_id,
}
@bp.post("")
def create_session():
data = request.get_json() or {}
name = (data.get("name") or "Host").strip()
code = generate_code()
while Session.query.filter_by(code=code).first():
code = generate_code()
session = Session(code=code)
db.session.add(session)
db.session.flush()
host = Participant(session_id=session.id, name=name, is_host=True)
db.session.add(host)
for index, recipe_data in enumerate(get_recipes()):
db.session.add(Recipe(session_id=session.id, position=index, **recipe_data))
db.session.commit()
return jsonify({"session": serialize_session(session), "participantId": host.id})
@bp.post("/<code>/join")
def join_session(code):
data = request.get_json() or {}
name = (data.get("name") or "Gast").strip()
session = Session.query.filter_by(code=code.upper()).first_or_404()
if session.status != "waiting":
return jsonify({"error": "Session wurde bereits gestartet."}), 409
participant = Participant(session_id=session.id, name=name, is_host=False)
db.session.add(participant)
db.session.commit()
return jsonify({"session": serialize_session(session), "participantId": participant.id})
@bp.get("/<code>")
def get_session(code):
session = Session.query.filter_by(code=code.upper()).first_or_404()
return jsonify({"session": serialize_session(session)})
+55
View File
@@ -0,0 +1,55 @@
DUMMY_RECIPES = [
{
"title": "Cremige Pasta mit Tomaten",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "ca. 25 Minuten",
},
{
"title": "Kartoffel-Brokkoli-Auflauf",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "vegetarisch",
},
{
"title": "Schnelle Reispfanne",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "einfach",
},
{
"title": "Wraps mit Hähnchen",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "schnell",
},
{
"title": "Gemüse-Curry",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "würzig",
},
{
"title": "Ofenkartoffeln mit Dip",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "günstig",
},
{
"title": "Gnocchi-Pfanne",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "15 Minuten",
},
{
"title": "Pizza-Toast",
"url": "https://www.chefkoch.de/",
"image_url": "",
"meta": "MVP-Klassiker",
},
]
def get_recipes():
# Später ersetzen durch Chefkoch-Vorschau-Importer.
return DUMMY_RECIPES
+1
View File
@@ -0,0 +1 @@
# Socket package
+117
View File
@@ -0,0 +1,117 @@
from flask_socketio import emit, join_room
from sqlalchemy.exc import IntegrityError
from .. import socketio
from ..models import db
from ..models.session import Session, Vote
def room_name(code):
return f"session:{code.upper()}"
def serialize_recipe(recipe):
return {
"id": recipe.id,
"title": recipe.title,
"url": recipe.url,
"imageUrl": recipe.image_url,
"meta": recipe.meta,
"position": recipe.position,
}
def emit_session_update(session):
socketio.emit(
"session:update",
{
"code": session.code,
"status": session.status,
"currentRecipeIndex": session.current_recipe_index,
"matchedRecipeId": session.matched_recipe_id,
"participants": [
{"id": p.id, "name": p.name, "isHost": p.is_host}
for p in session.participants
],
},
room=room_name(session.code),
)
@socketio.on("session:subscribe")
def subscribe(data):
code = data.get("code", "").upper()
session = Session.query.filter_by(code=code).first()
if not session:
emit("error", {"message": "Session nicht gefunden."})
return
join_room(room_name(code))
emit_session_update(session)
@socketio.on("session:start")
def start_session(data):
code = data.get("code", "").upper()
participant_id = data.get("participantId")
session = Session.query.filter_by(code=code).first()
if not session:
emit("error", {"message": "Session nicht gefunden."})
return
host = next((p for p in session.participants if p.id == participant_id and p.is_host), None)
if not host:
emit("error", {"message": "Nur der Host kann starten."})
return
session.status = "running"
session.current_recipe_index = 0
db.session.commit()
emit_session_update(session)
@socketio.on("vote")
def vote(data):
code = data.get("code", "").upper()
participant_id = data.get("participantId")
recipe_id = data.get("recipeId")
vote_value = data.get("vote")
if vote_value not in ["yes", "no"]:
emit("error", {"message": "Ungültige Stimme."})
return
session = Session.query.filter_by(code=code).first()
if not session or session.status != "running":
emit("error", {"message": "Session läuft nicht."})
return
recipe = next((r for r in session.recipes if r.id == recipe_id), None)
if not recipe:
emit("error", {"message": "Rezept nicht gefunden."})
return
db.session.add(Vote(session_id=session.id, participant_id=participant_id, recipe_id=recipe.id, vote=vote_value))
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return
participant_count = len(session.participants)
yes_count = Vote.query.filter_by(session_id=session.id, recipe_id=recipe.id, vote="yes").count()
vote_count = Vote.query.filter_by(session_id=session.id, recipe_id=recipe.id).count()
if yes_count == participant_count:
session.status = "finished"
session.matched_recipe_id = recipe.id
db.session.commit()
socketio.emit("session:match", {"recipe": serialize_recipe(recipe)}, room=room_name(code))
emit_session_update(session)
return
if vote_count == participant_count:
session.current_recipe_index += 1
if session.current_recipe_index >= len(session.recipes):
session.status = "finished"
db.session.commit()
emit_session_update(session)
+7
View File
@@ -0,0 +1,7 @@
Flask==3.0.3
Flask-SocketIO==5.3.6
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
psycopg2-binary==2.9.9
python-dotenv==1.0.1
eventlet==0.36.1
+6
View File
@@ -0,0 +1,6 @@
from app import create_app, socketio
app = create_app()
if __name__ == "__main__":
socketio.run(app, host="0.0.0.0", port=5000, debug=True)
+15
View File
@@ -0,0 +1,15 @@
services:
postgres:
image: postgres:16
container_name: rezept_finder_postgres
environment:
POSTGRES_DB: rezept_finder
POSTGRES_USER: rezept_finder
POSTGRES_PASSWORD: rezept_finder
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rezept Finder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest",
"react": "latest",
"react-dom": "latest",
"socket.io-client": "latest"
},
"devDependencies": {}
}
+151
View File
@@ -0,0 +1,151 @@
import React, { useEffect, useMemo, useState } from 'react';
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';
function App() {
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [participantId, setParticipantId] = useState(null);
const [session, setSession] = useState(null);
const [match, setMatch] = useState(null);
const [error, setError] = useState('');
const socket = useMemo(() => io(API_URL, { autoConnect: false }), []);
useEffect(() => {
socket.connect();
socket.on('session:update', (update) => {
setSession((current) => current ? { ...current, ...update } : update);
});
socket.on('session:match', (data) => setMatch(data.recipe));
socket.on('error', (data) => setError(data.message));
return () => socket.disconnect();
}, [socket]);
const subscribe = (sessionCode) => socket.emit('session:subscribe', { code: sessionCode });
async function createSession() {
setError('');
const response = await fetch(`${API_URL}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name || 'Host' }),
});
const data = await response.json();
setSession(data.session);
setParticipantId(data.participantId);
setCode(data.session.code);
subscribe(data.session.code);
}
async function joinSession() {
setError('');
const response = await fetch(`${API_URL}/api/sessions/${code}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name || 'Gast' }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Beitritt fehlgeschlagen.');
return;
}
setSession(data.session);
setParticipantId(data.participantId);
subscribe(data.session.code);
}
function startSession() {
socket.emit('session:start', { code: session.code, participantId });
}
function sendVote(vote) {
const recipe = session.recipes[session.currentRecipeIndex];
socket.emit('vote', { code: session.code, participantId, recipeId: recipe.id, vote });
}
if (match) {
return <Result recipe={match} />;
}
if (!session) {
return (
<main className="page">
<section className="card">
<h1>Rezept Finder</h1>
<p>Findet gemeinsam heraus, was ihr heute essen wollt.</p>
<input placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={createSession}>Session erstellen</button>
<div className="divider">oder</div>
<input placeholder="Session-Code" value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} />
<button onClick={joinSession}>Session beitreten</button>
{error && <p className="error">{error}</p>}
</section>
</main>
);
}
if (session.status === 'waiting') {
const currentUser = session.participants?.find((p) => p.id === participantId);
return (
<main className="page">
<section className="card">
<h1>Lobby</h1>
<p className="code">{session.code}</p>
<h2>Teilnehmer</h2>
<ul>{session.participants?.map((p) => <li key={p.id}>{p.name}{p.isHost ? ' 👑' : ''}</li>)}</ul>
{currentUser?.isHost && <button onClick={startSession}>Session starten</button>}
{!currentUser?.isHost && <p>Warten auf Host…</p>}
{error && <p className="error">{error}</p>}
</section>
</main>
);
}
const recipe = session.recipes?.[session.currentRecipeIndex];
if (!recipe) {
return <Result recipe={null} />;
}
return (
<main className="page">
<section className="recipe-card">
<p className="counter">{session.currentRecipeIndex + 1} / {session.recipes.length}</p>
<div className="image-placeholder">🍽️</div>
<h1>{recipe.title}</h1>
<p>{recipe.meta}</p>
<div className="actions">
<button className="no" onClick={() => sendVote('no')}>Nein</button>
<button className="yes" onClick={() => sendVote('yes')}>Ja</button>
</div>
</section>
</main>
);
}
function Result({ recipe }) {
return (
<main className="page">
<section className="card">
{recipe ? (
<>
<h1>🎉 Match gefunden</h1>
<h2>{recipe.title}</h2>
<p>{recipe.meta}</p>
<a className="button" href={recipe.url} target="_blank" rel="noreferrer">Zum Rezept</a>
</>
) : (
<>
<h1>Kein Match</h1>
<p>Bei den aktuellen Vorschlägen war nichts für alle dabei.</p>
</>
)}
</section>
</main>
);
}
createRoot(document.getElementById('root')).render(<App />);
+18
View File
@@ -0,0 +1,18 @@
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: #fff7ed; color: #1f2937; }
.page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.card, .recipe-card { width: min(460px, 100%); background: white; border-radius: 28px; padding: 28px; box-shadow: 0 20px 50px rgba(0,0,0,.12); }
h1 { margin-top: 0; }
input { width: 100%; padding: 14px 16px; border: 1px solid #e5e7eb; border-radius: 14px; margin: 8px 0; font-size: 16px; }
button, .button { width: 100%; border: 0; border-radius: 14px; padding: 14px 18px; font-weight: 700; font-size: 16px; cursor: pointer; background: #f97316; color: white; text-decoration: none; display: inline-block; text-align: center; margin-top: 10px; }
.divider { text-align: center; margin: 18px 0; color: #6b7280; }
.code { font-size: 36px; letter-spacing: 8px; font-weight: 800; text-align: center; background: #f3f4f6; border-radius: 16px; padding: 16px; }
ul { padding-left: 20px; }
.error { color: #dc2626; }
.recipe-card { text-align: center; }
.counter { color: #6b7280; }
.image-placeholder { height: 220px; border-radius: 22px; background: #fed7aa; display: grid; place-items: center; font-size: 72px; margin-bottom: 20px; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.actions button { margin-top: 0; }
.no { background: #ef4444; }
.yes { background: #22c55e; }