From 7b3b9a890f05765aa17888497d0cde57f0859212 Mon Sep 17 00:00:00 2001 From: Winz Date: Tue, 9 Jun 2026 15:25:54 +0200 Subject: [PATCH] init --- README.md | 28 ++++++ backend/.env.example | 4 + backend/app/__init__.py | 32 +++++++ backend/app/models/__init__.py | 5 ++ backend/app/models/session.py | 53 +++++++++++ backend/app/routes/sessions.py | 81 +++++++++++++++++ backend/app/services/recipes.py | 55 ++++++++++++ backend/app/socket/__init__.py | 1 + backend/app/socket/events.py | 117 +++++++++++++++++++++++++ backend/requirements.txt | 7 ++ backend/run.py | 6 ++ docker-compose.yml | 15 ++++ frontend/index.html | 12 +++ frontend/package.json | 15 ++++ frontend/src/main.jsx | 151 ++++++++++++++++++++++++++++++++ frontend/src/style.css | 18 ++++ 16 files changed, 600 insertions(+) create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/app/__init__.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/session.py create mode 100644 backend/app/routes/sessions.py create mode 100644 backend/app/services/recipes.py create mode 100644 backend/app/socket/__init__.py create mode 100644 backend/app/socket/events.py create mode 100644 backend/requirements.txt create mode 100644 backend/run.py create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/style.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..6268548 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5932f62 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..b344913 --- /dev/null +++ b/backend/app/__init__.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..9ca6159 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,5 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + +from .session import Session, Participant, Recipe, Vote # noqa: E402,F401 diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..b4ab232 --- /dev/null +++ b/backend/app/models/session.py @@ -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"), + ) diff --git a/backend/app/routes/sessions.py b/backend/app/routes/sessions.py new file mode 100644 index 0000000..816e712 --- /dev/null +++ b/backend/app/routes/sessions.py @@ -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("//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("/") +def get_session(code): + session = Session.query.filter_by(code=code.upper()).first_or_404() + return jsonify({"session": serialize_session(session)}) diff --git a/backend/app/services/recipes.py b/backend/app/services/recipes.py new file mode 100644 index 0000000..506391c --- /dev/null +++ b/backend/app/services/recipes.py @@ -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 diff --git a/backend/app/socket/__init__.py b/backend/app/socket/__init__.py new file mode 100644 index 0000000..a53ed92 --- /dev/null +++ b/backend/app/socket/__init__.py @@ -0,0 +1 @@ +# Socket package diff --git a/backend/app/socket/events.py b/backend/app/socket/events.py new file mode 100644 index 0000000..95c93b8 --- /dev/null +++ b/backend/app/socket/events.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..af76889 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..85bdfdf --- /dev/null +++ b/backend/run.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f4bb00f --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f6def25 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Rezept Finder + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d078f2d --- /dev/null +++ b/frontend/package.json @@ -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": {} +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..f423800 --- /dev/null +++ b/frontend/src/main.jsx @@ -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 ; + } + + if (!session) { + return ( +
+
+

Rezept Finder

+

Findet gemeinsam heraus, was ihr heute essen wollt.

+ setName(e.target.value)} /> + +
oder
+ setCode(e.target.value.toUpperCase())} /> + + {error &&

{error}

} +
+
+ ); + } + + if (session.status === 'waiting') { + const currentUser = session.participants?.find((p) => p.id === participantId); + return ( +
+
+

Lobby

+

{session.code}

+

Teilnehmer

+
    {session.participants?.map((p) =>
  • {p.name}{p.isHost ? ' 👑' : ''}
  • )}
+ {currentUser?.isHost && } + {!currentUser?.isHost &&

Warten auf Host…

} + {error &&

{error}

} +
+
+ ); + } + + const recipe = session.recipes?.[session.currentRecipeIndex]; + if (!recipe) { + return ; + } + + return ( +
+
+

{session.currentRecipeIndex + 1} / {session.recipes.length}

+
🍽️
+

{recipe.title}

+

{recipe.meta}

+
+ + +
+
+
+ ); +} + +function Result({ recipe }) { + return ( +
+
+ {recipe ? ( + <> +

🎉 Match gefunden

+

{recipe.title}

+

{recipe.meta}

+ Zum Rezept + + ) : ( + <> +

Kein Match

+

Bei den aktuellen Vorschlägen war nichts für alle dabei.

+ + )} +
+
+ ); +} + +createRoot(document.getElementById('root')).render(); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..52def8b --- /dev/null +++ b/frontend/src/style.css @@ -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; }