v1.0
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
.env
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python", "run.py"]
|
||||||
@@ -3,6 +3,7 @@ from flask import Flask
|
|||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_socketio import SocketIO
|
from flask_socketio import SocketIO
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from sqlalchemy import text
|
||||||
from .models import db
|
from .models import db
|
||||||
|
|
||||||
socketio = SocketIO(cors_allowed_origins="*")
|
socketio = SocketIO(cors_allowed_origins="*")
|
||||||
@@ -20,6 +21,14 @@ def create_app():
|
|||||||
)
|
)
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def add_cors_headers(response):
|
||||||
|
origin = os.getenv("FRONTEND_ORIGIN", "*")
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
|
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||||
|
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||||
|
return response
|
||||||
|
|
||||||
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", "*"))
|
||||||
@@ -29,4 +38,24 @@ def create_app():
|
|||||||
|
|
||||||
from .socket import events # noqa: F401
|
from .socket import events # noqa: F401
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
ensure_schema()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema():
|
||||||
|
db.create_all()
|
||||||
|
engine_name = db.engine.url.get_backend_name()
|
||||||
|
if engine_name != "postgresql":
|
||||||
|
return
|
||||||
|
|
||||||
|
db.session.execute(text(
|
||||||
|
"ALTER TABLE session_settings "
|
||||||
|
"ADD COLUMN IF NOT EXISTS match_mode VARCHAR(20) NOT NULL DEFAULT 'first_match'"
|
||||||
|
))
|
||||||
|
db.session.execute(text(
|
||||||
|
"ALTER TABLE session_settings "
|
||||||
|
"ADD COLUMN IF NOT EXISTS voting_round INTEGER NOT NULL DEFAULT 1"
|
||||||
|
))
|
||||||
|
db.session.commit()
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
|
||||||
from .session import Session, Participant, Recipe, Vote # noqa: E402,F401
|
from .session import Session, SessionSettings, Participant, Recipe, Vote # noqa: E402,F401
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ class Session(db.Model):
|
|||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
participants = db.relationship("Participant", backref="session", cascade="all, delete-orphan")
|
participants = db.relationship("Participant", backref="session", cascade="all, delete-orphan")
|
||||||
recipes = db.relationship("Recipe", backref="session", cascade="all, delete-orphan", order_by="Recipe.position")
|
settings = db.relationship("SessionSettings", backref="session", cascade="all, delete-orphan", uselist=False)
|
||||||
|
recipes = db.relationship(
|
||||||
|
"Recipe",
|
||||||
|
backref="session",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
foreign_keys="Recipe.session_id",
|
||||||
|
order_by="Recipe.position",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Participant(db.Model):
|
class Participant(db.Model):
|
||||||
@@ -26,6 +33,16 @@ class Participant(db.Model):
|
|||||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionSettings(db.Model):
|
||||||
|
__tablename__ = "session_settings"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False, unique=True)
|
||||||
|
required_likes = db.Column(db.Integer, nullable=False, default=2)
|
||||||
|
match_mode = db.Column(db.String(20), nullable=False, default="first_match")
|
||||||
|
voting_round = db.Column(db.Integer, nullable=False, default=1)
|
||||||
|
|
||||||
|
|
||||||
class Recipe(db.Model):
|
class Recipe(db.Model):
|
||||||
__tablename__ = "recipes"
|
__tablename__ = "recipes"
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import random
|
|||||||
import string
|
import string
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from ..models import db
|
from ..models import db
|
||||||
from ..models.session import Session, Participant, Recipe
|
from ..models.session import Session, Participant
|
||||||
from ..services.recipes import get_recipes
|
from ..services.session_recipes import add_recipe_batch, serialize_recipe
|
||||||
|
from ..services.session_settings import get_match_mode, get_required_likes, get_voting_round
|
||||||
|
|
||||||
bp = Blueprint("sessions", __name__)
|
bp = Blueprint("sessions", __name__)
|
||||||
|
|
||||||
@@ -22,18 +23,11 @@ def serialize_session(session):
|
|||||||
{"id": p.id, "name": p.name, "isHost": p.is_host}
|
{"id": p.id, "name": p.name, "isHost": p.is_host}
|
||||||
for p in session.participants
|
for p in session.participants
|
||||||
],
|
],
|
||||||
"recipes": [
|
"recipes": [serialize_recipe(recipe) for recipe in session.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,
|
"matchedRecipeId": session.matched_recipe_id,
|
||||||
|
"requiredLikes": get_required_likes(session),
|
||||||
|
"matchMode": get_match_mode(session),
|
||||||
|
"votingRound": get_voting_round(session),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -53,8 +47,7 @@ def create_session():
|
|||||||
host = Participant(session_id=session.id, name=name, is_host=True)
|
host = Participant(session_id=session.id, name=name, is_host=True)
|
||||||
db.session.add(host)
|
db.session.add(host)
|
||||||
|
|
||||||
for index, recipe_data in enumerate(get_recipes()):
|
add_recipe_batch(session)
|
||||||
db.session.add(Recipe(session_id=session.id, position=index, **recipe_data))
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({"session": serialize_session(session), "participantId": host.id})
|
return jsonify({"session": serialize_session(session), "participantId": host.id})
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from ..models import db
|
||||||
|
from ..models.session import Vote
|
||||||
|
from .session_recipes import add_recipe_batch
|
||||||
|
from .session_settings import FINAL_ROUND_MODE, get_match_mode, get_required_likes, get_voting_round, set_voting_round
|
||||||
|
|
||||||
|
|
||||||
|
def get_qualified_recipes(session):
|
||||||
|
required_likes = get_required_likes(session)
|
||||||
|
qualified = []
|
||||||
|
|
||||||
|
for recipe in session.recipes:
|
||||||
|
yes_count = Vote.query.filter_by(
|
||||||
|
session_id=session.id,
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
vote="yes",
|
||||||
|
).count()
|
||||||
|
if yes_count >= required_likes:
|
||||||
|
qualified.append(recipe)
|
||||||
|
|
||||||
|
return qualified
|
||||||
|
|
||||||
|
|
||||||
|
def all_participants_finished_current_recipes(session):
|
||||||
|
recipe_count = len(session.recipes)
|
||||||
|
if recipe_count == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for participant in session.participants:
|
||||||
|
vote_count = Vote.query.filter_by(
|
||||||
|
session_id=session.id,
|
||||||
|
participant_id=participant.id,
|
||||||
|
).count()
|
||||||
|
if vote_count < recipe_count:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def enter_final_round(session, qualified_recipes):
|
||||||
|
qualified_ids = {recipe.id for recipe in qualified_recipes}
|
||||||
|
|
||||||
|
session.matched_recipe_id = None
|
||||||
|
Vote.query.filter_by(session_id=session.id).delete()
|
||||||
|
|
||||||
|
for recipe in list(session.recipes):
|
||||||
|
if recipe.id not in qualified_ids:
|
||||||
|
session.recipes.remove(recipe)
|
||||||
|
db.session.delete(recipe)
|
||||||
|
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
finalist_recipes = sorted(session.recipes, key=lambda recipe: recipe.position)
|
||||||
|
for index, recipe in enumerate(finalist_recipes):
|
||||||
|
recipe.position = index
|
||||||
|
|
||||||
|
set_voting_round(session, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_final_round_vote(session, recipe):
|
||||||
|
yes_count = Vote.query.filter_by(session_id=session.id, recipe_id=recipe.id, vote="yes").count()
|
||||||
|
if get_voting_round(session) == 2 and yes_count >= get_required_likes(session):
|
||||||
|
session.status = "finished"
|
||||||
|
session.matched_recipe_id = recipe.id
|
||||||
|
return "match"
|
||||||
|
|
||||||
|
if all_participants_finished_current_recipes(session):
|
||||||
|
qualified_recipes = get_qualified_recipes(session)
|
||||||
|
if get_voting_round(session) == 1:
|
||||||
|
if len(qualified_recipes) >= 3:
|
||||||
|
enter_final_round(session, qualified_recipes)
|
||||||
|
return "final_round"
|
||||||
|
|
||||||
|
add_recipe_batch(session)
|
||||||
|
return "more_recipes"
|
||||||
|
|
||||||
|
if qualified_recipes:
|
||||||
|
session.status = "finished"
|
||||||
|
session.matched_recipe_id = qualified_recipes[0].id
|
||||||
|
return "match"
|
||||||
|
|
||||||
|
set_voting_round(session, 1)
|
||||||
|
add_recipe_batch(session)
|
||||||
|
return "more_recipes"
|
||||||
|
|
||||||
|
return "update"
|
||||||
|
|
||||||
|
|
||||||
|
def is_final_round_mode(session):
|
||||||
|
return get_match_mode(session) == FINAL_ROUND_MODE
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
DUMMY_RECIPES = [
|
import random
|
||||||
|
import re
|
||||||
|
from html import unescape
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
CHEFKOCH_TODAY_URL = "https://www.chefkoch.de/rezepte/was-koche-ich-heute/"
|
||||||
|
|
||||||
|
FALLBACK_RECIPES = [
|
||||||
{
|
{
|
||||||
"title": "Cremige Pasta mit Tomaten",
|
"title": "Cremige Pasta mit Tomaten",
|
||||||
"url": "https://www.chefkoch.de/",
|
"url": "https://www.chefkoch.de/",
|
||||||
@@ -50,6 +60,90 @@ DUMMY_RECIPES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ChefkochTodayParser(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.recipes = []
|
||||||
|
self._current_link = None
|
||||||
|
self._current_text = []
|
||||||
|
self._current_image_url = ""
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
attrs = dict(attrs)
|
||||||
|
if tag == "a":
|
||||||
|
href = attrs.get("href", "")
|
||||||
|
if re.search(r"/rezepte/\d+/.+\.html", href):
|
||||||
|
self._current_link = urljoin(CHEFKOCH_TODAY_URL, href)
|
||||||
|
self._current_text = []
|
||||||
|
self._current_image_url = ""
|
||||||
|
elif tag == "img" and self._current_link:
|
||||||
|
self._current_image_url = attrs.get("src") or attrs.get("data-src") or ""
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self._current_link:
|
||||||
|
self._current_text.append(data)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag != "a" or not self._current_link:
|
||||||
|
return
|
||||||
|
|
||||||
|
title, meta = parse_recipe_text(" ".join(self._current_text))
|
||||||
|
if title and not any(recipe["url"] == self._current_link for recipe in self.recipes):
|
||||||
|
self.recipes.append(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"url": self._current_link,
|
||||||
|
"image_url": self._current_image_url,
|
||||||
|
"meta": meta,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self._current_link = None
|
||||||
|
self._current_text = []
|
||||||
|
self._current_image_url = ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_recipe_text(text):
|
||||||
|
text = unescape(re.sub(r"\s+", " ", text)).strip()
|
||||||
|
text = text.replace("rating_star", "").strip()
|
||||||
|
|
||||||
|
rating_match = re.match(r"(?P<rating>\d+[,.]\d)\s*/?\s*5?\s*\((?P<count>\d+)\)\s*(?P<title>.+)", text)
|
||||||
|
if rating_match:
|
||||||
|
rating = rating_match.group("rating")
|
||||||
|
count = rating_match.group("count")
|
||||||
|
title = clean_recipe_title(rating_match.group("title"))
|
||||||
|
return title, f"{rating}/5 ({count})"
|
||||||
|
|
||||||
|
return clean_recipe_title(text), ""
|
||||||
|
|
||||||
|
|
||||||
|
def clean_recipe_title(title):
|
||||||
|
title = re.sub(r"\s+von\s+essen\s*&\s*trinken.*$", "", title, flags=re.IGNORECASE)
|
||||||
|
title = re.sub(r"[^\w\sÄÖÜäöüß.,&'’:/()\-]", "", title)
|
||||||
|
return re.sub(r"\s+", " ", title).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_chefkoch_today_recipes():
|
||||||
|
cache_buster = random.randint(100000, 999999)
|
||||||
|
request = Request(
|
||||||
|
f"{CHEFKOCH_TODAY_URL}?rf={cache_buster}",
|
||||||
|
headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (compatible; RezeptFinder/1.0)",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urlopen(request, timeout=8) as response:
|
||||||
|
html = response.read().decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
parser = ChefkochTodayParser()
|
||||||
|
parser.feed(html)
|
||||||
|
return parser.recipes[:8]
|
||||||
|
|
||||||
|
|
||||||
def get_recipes():
|
def get_recipes():
|
||||||
# Später ersetzen durch Chefkoch-Vorschau-Importer.
|
try:
|
||||||
return DUMMY_RECIPES
|
recipes = fetch_chefkoch_today_recipes()
|
||||||
|
except Exception:
|
||||||
|
return FALLBACK_RECIPES
|
||||||
|
|
||||||
|
return recipes or FALLBACK_RECIPES
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from ..models import db
|
||||||
|
from ..models.session import Recipe, Vote
|
||||||
|
from .recipes import get_recipes
|
||||||
|
|
||||||
|
|
||||||
|
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 add_recipe_batch(session, size=8):
|
||||||
|
existing_urls = {recipe.url for recipe in session.recipes}
|
||||||
|
next_position = len(session.recipes)
|
||||||
|
added = 0
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
for recipe_data in get_recipes():
|
||||||
|
if recipe_data["url"] in existing_urls:
|
||||||
|
continue
|
||||||
|
|
||||||
|
session.recipes.append(Recipe(position=next_position, **recipe_data))
|
||||||
|
existing_urls.add(recipe_data["url"])
|
||||||
|
next_position += 1
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
if added == size:
|
||||||
|
db.session.flush()
|
||||||
|
return added
|
||||||
|
|
||||||
|
if added:
|
||||||
|
db.session.flush()
|
||||||
|
return added
|
||||||
|
|
||||||
|
for recipe_data in get_recipes()[:size]:
|
||||||
|
session.recipes.append(Recipe(position=next_position, **recipe_data))
|
||||||
|
next_position += 1
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
db.session.flush()
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def replace_recipe_batch(session, size=8):
|
||||||
|
session.matched_recipe_id = None
|
||||||
|
Vote.query.filter_by(session_id=session.id).delete()
|
||||||
|
for recipe in list(session.recipes):
|
||||||
|
db.session.delete(recipe)
|
||||||
|
|
||||||
|
db.session.flush()
|
||||||
|
session.recipes.clear()
|
||||||
|
return add_recipe_batch(session, size)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from ..models import db
|
||||||
|
from ..models.session import SessionSettings
|
||||||
|
|
||||||
|
FIRST_MATCH_MODE = "first_match"
|
||||||
|
FINAL_ROUND_MODE = "final_round"
|
||||||
|
MATCH_MODES = {FIRST_MATCH_MODE, FINAL_ROUND_MODE}
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_likes(session):
|
||||||
|
participant_count = max(2, len(session.participants))
|
||||||
|
if not session.settings:
|
||||||
|
return participant_count
|
||||||
|
|
||||||
|
return max(2, min(session.settings.required_likes, participant_count))
|
||||||
|
|
||||||
|
|
||||||
|
def set_required_likes(session, required_likes):
|
||||||
|
participant_count = max(2, len(session.participants))
|
||||||
|
required_likes = max(2, min(int(required_likes), participant_count))
|
||||||
|
|
||||||
|
if not session.settings:
|
||||||
|
session.settings = SessionSettings(required_likes=required_likes)
|
||||||
|
db.session.add(session.settings)
|
||||||
|
else:
|
||||||
|
session.settings.required_likes = required_likes
|
||||||
|
|
||||||
|
return required_likes
|
||||||
|
|
||||||
|
|
||||||
|
def get_match_mode(session):
|
||||||
|
if not session.settings:
|
||||||
|
return FIRST_MATCH_MODE
|
||||||
|
|
||||||
|
return session.settings.match_mode if session.settings.match_mode in MATCH_MODES else FIRST_MATCH_MODE
|
||||||
|
|
||||||
|
|
||||||
|
def get_voting_round(session):
|
||||||
|
if not session.settings:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return max(1, session.settings.voting_round)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_settings(session):
|
||||||
|
if not session.settings:
|
||||||
|
session.settings = SessionSettings()
|
||||||
|
db.session.add(session.settings)
|
||||||
|
|
||||||
|
return session.settings
|
||||||
|
|
||||||
|
|
||||||
|
def set_match_mode(session, match_mode):
|
||||||
|
settings = ensure_settings(session)
|
||||||
|
settings.match_mode = match_mode if match_mode in MATCH_MODES else FIRST_MATCH_MODE
|
||||||
|
settings.voting_round = 1
|
||||||
|
return settings.match_mode
|
||||||
|
|
||||||
|
|
||||||
|
def set_voting_round(session, voting_round):
|
||||||
|
settings = ensure_settings(session)
|
||||||
|
settings.voting_round = max(1, int(voting_round))
|
||||||
|
return settings.voting_round
|
||||||
@@ -3,23 +3,15 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from .. import socketio
|
from .. import socketio
|
||||||
from ..models import db
|
from ..models import db
|
||||||
from ..models.session import Session, Vote
|
from ..models.session import Session, Vote
|
||||||
|
from ..services.match_modes import handle_final_round_vote, is_final_round_mode
|
||||||
|
from ..services.session_recipes import add_recipe_batch, replace_recipe_batch, serialize_recipe
|
||||||
|
from ..services.session_settings import get_match_mode, get_required_likes, get_voting_round, set_match_mode, set_required_likes, set_voting_round
|
||||||
|
|
||||||
|
|
||||||
def room_name(code):
|
def room_name(code):
|
||||||
return f"session:{code.upper()}"
|
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):
|
def emit_session_update(session):
|
||||||
socketio.emit(
|
socketio.emit(
|
||||||
"session:update",
|
"session:update",
|
||||||
@@ -32,6 +24,10 @@ def emit_session_update(session):
|
|||||||
{"id": p.id, "name": p.name, "isHost": p.is_host}
|
{"id": p.id, "name": p.name, "isHost": p.is_host}
|
||||||
for p in session.participants
|
for p in session.participants
|
||||||
],
|
],
|
||||||
|
"recipes": [serialize_recipe(recipe) for recipe in session.recipes],
|
||||||
|
"requiredLikes": get_required_likes(session),
|
||||||
|
"matchMode": get_match_mode(session),
|
||||||
|
"votingRound": get_voting_round(session),
|
||||||
},
|
},
|
||||||
room=room_name(session.code),
|
room=room_name(session.code),
|
||||||
)
|
)
|
||||||
@@ -65,6 +61,76 @@ def start_session(data):
|
|||||||
|
|
||||||
session.status = "running"
|
session.status = "running"
|
||||||
session.current_recipe_index = 0
|
session.current_recipe_index = 0
|
||||||
|
session.matched_recipe_id = None
|
||||||
|
set_voting_round(session, 1)
|
||||||
|
db.session.commit()
|
||||||
|
emit_session_update(session)
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("session:set_required_likes")
|
||||||
|
def update_required_likes(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 die Match-Regel ändern."})
|
||||||
|
return
|
||||||
|
|
||||||
|
if session.status != "waiting":
|
||||||
|
emit("error", {"message": "Die Match-Regel kann nur in der Lobby geändert werden."})
|
||||||
|
return
|
||||||
|
|
||||||
|
set_required_likes(session, data.get("requiredLikes", len(session.participants)))
|
||||||
|
db.session.commit()
|
||||||
|
emit_session_update(session)
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("session:set_match_mode")
|
||||||
|
def update_match_mode(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 den Modus ändern."})
|
||||||
|
return
|
||||||
|
|
||||||
|
if session.status != "waiting":
|
||||||
|
emit("error", {"message": "Der Modus kann nur in der Lobby geändert werden."})
|
||||||
|
return
|
||||||
|
|
||||||
|
set_match_mode(session, data.get("matchMode"))
|
||||||
|
db.session.commit()
|
||||||
|
emit_session_update(session)
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("session:restart")
|
||||||
|
def restart_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 neu starten."})
|
||||||
|
return
|
||||||
|
|
||||||
|
session.status = "running"
|
||||||
|
session.current_recipe_index = 0
|
||||||
|
set_voting_round(session, 1)
|
||||||
|
replace_recipe_batch(session)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
emit_session_update(session)
|
emit_session_update(session)
|
||||||
|
|
||||||
@@ -97,11 +163,17 @@ def vote(data):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return
|
return
|
||||||
|
|
||||||
participant_count = len(session.participants)
|
if is_final_round_mode(session):
|
||||||
yes_count = Vote.query.filter_by(session_id=session.id, recipe_id=recipe.id, vote="yes").count()
|
result = handle_final_round_vote(session, recipe)
|
||||||
vote_count = Vote.query.filter_by(session_id=session.id, recipe_id=recipe.id).count()
|
db.session.commit()
|
||||||
|
if result == "match":
|
||||||
|
matched_recipe = next((r for r in session.recipes if r.id == session.matched_recipe_id), recipe)
|
||||||
|
socketio.emit("session:match", {"recipe": serialize_recipe(matched_recipe)}, room=room_name(code))
|
||||||
|
emit_session_update(session)
|
||||||
|
return
|
||||||
|
|
||||||
if yes_count == participant_count:
|
yes_count = Vote.query.filter_by(session_id=session.id, recipe_id=recipe.id, vote="yes").count()
|
||||||
|
if yes_count >= get_required_likes(session):
|
||||||
session.status = "finished"
|
session.status = "finished"
|
||||||
session.matched_recipe_id = recipe.id
|
session.matched_recipe_id = recipe.id
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -109,9 +181,12 @@ def vote(data):
|
|||||||
emit_session_update(session)
|
emit_session_update(session)
|
||||||
return
|
return
|
||||||
|
|
||||||
if vote_count == participant_count:
|
participant_vote_count = Vote.query.filter_by(
|
||||||
session.current_recipe_index += 1
|
session_id=session.id,
|
||||||
if session.current_recipe_index >= len(session.recipes):
|
participant_id=participant_id,
|
||||||
session.status = "finished"
|
).count()
|
||||||
db.session.commit()
|
if participant_vote_count >= len(session.recipes):
|
||||||
emit_session_update(session)
|
add_recipe_batch(session)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
emit_session_update(session)
|
||||||
|
|||||||
@@ -10,6 +10,45 @@ services:
|
|||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U rezept_finder -d rezept_finder"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
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
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app:Z
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
command: >
|
||||||
|
sh -c "python -c 'from app import create_app; from app.models import db; app = create_app(); ctx = app.app_context(); ctx.push(); db.create_all()'
|
||||||
|
&& python run.py"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
container_name: rezept_finder_frontend
|
||||||
|
environment:
|
||||||
|
VITE_API_URL: http://localhost:5000
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app:Z
|
||||||
|
- frontend_node_modules:/app/node_modules
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
frontend_node_modules:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
+367
-42
@@ -1,23 +1,84 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
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_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
|
||||||
|
|
||||||
|
function shuffle(items) {
|
||||||
|
const shuffled = [...items];
|
||||||
|
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||||
|
const swapIndex = Math.floor(Math.random() * (index + 1));
|
||||||
|
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [name, setName] = useState('');
|
const [hostName, setHostName] = useState('');
|
||||||
|
const [guestName, setGuestName] = useState('');
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
|
const [joinStep, setJoinStep] = useState('code');
|
||||||
const [participantId, setParticipantId] = useState(null);
|
const [participantId, setParticipantId] = useState(null);
|
||||||
const [session, setSession] = useState(null);
|
const [session, setSession] = useState(null);
|
||||||
const [match, setMatch] = useState(null);
|
const [match, setMatch] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [copyMessage, setCopyMessage] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [votedRecipeIds, setVotedRecipeIds] = useState([]);
|
||||||
|
const [recipeOrderIds, setRecipeOrderIds] = useState([]);
|
||||||
|
const [drag, setDrag] = useState({ x: 0, y: 0, active: false, leaving: null });
|
||||||
|
const swipeStart = useRef(null);
|
||||||
|
const sessionStatusRef = useRef(null);
|
||||||
|
const votingRoundRef = useRef(null);
|
||||||
|
const SWIPE_THRESHOLD = 110;
|
||||||
|
|
||||||
const socket = useMemo(() => io(API_URL, { autoConnect: false }), []);
|
const socket = useMemo(() => io(API_URL, { autoConnect: false }), []);
|
||||||
|
const votedRecipeIdSet = useMemo(() => new Set(votedRecipeIds), [votedRecipeIds]);
|
||||||
|
const orderedRecipes = useMemo(() => {
|
||||||
|
if (session?.matchMode !== 'final_round') {
|
||||||
|
return session?.recipes || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipesById = new Map((session?.recipes || []).map((recipe) => [recipe.id, recipe]));
|
||||||
|
const ordered = recipeOrderIds.map((id) => recipesById.get(id)).filter(Boolean);
|
||||||
|
const orderedIdSet = new Set(recipeOrderIds);
|
||||||
|
const missing = (session?.recipes || []).filter((recipe) => !orderedIdSet.has(recipe.id));
|
||||||
|
return [...ordered, ...missing];
|
||||||
|
}, [session, recipeOrderIds]);
|
||||||
|
const currentRecipe = useMemo(
|
||||||
|
() => orderedRecipes.find((recipe) => !votedRecipeIdSet.has(recipe.id)),
|
||||||
|
[orderedRecipes, votedRecipeIdSet],
|
||||||
|
);
|
||||||
|
const matchedRecipe = useMemo(
|
||||||
|
() => session?.recipes?.find((recipe) => recipe.id === session.matchedRecipeId),
|
||||||
|
[session],
|
||||||
|
);
|
||||||
|
const currentUser = useMemo(
|
||||||
|
() => session?.participants?.find((participant) => participant.id === participantId),
|
||||||
|
[session, participantId],
|
||||||
|
);
|
||||||
|
const sessionLink = useMemo(() => {
|
||||||
|
if (!session || typeof window === 'undefined') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${window.location.origin}${window.location.pathname}?code=${session.code}`;
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
socket.connect();
|
socket.connect();
|
||||||
socket.on('session:update', (update) => {
|
socket.on('session:update', (update) => {
|
||||||
|
if (update.status === 'running' && sessionStatusRef.current !== 'running') {
|
||||||
|
setMatch(null);
|
||||||
|
setVotedRecipeIds([]);
|
||||||
|
}
|
||||||
|
if (update.votingRound && update.votingRound !== votingRoundRef.current) {
|
||||||
|
setVotedRecipeIds([]);
|
||||||
|
setRecipeOrderIds([]);
|
||||||
|
}
|
||||||
|
sessionStatusRef.current = update.status;
|
||||||
|
votingRoundRef.current = update.votingRound || null;
|
||||||
setSession((current) => current ? { ...current, ...update } : update);
|
setSession((current) => current ? { ...current, ...update } : update);
|
||||||
});
|
});
|
||||||
socket.on('session:match', (data) => setMatch(data.recipe));
|
socket.on('session:match', (data) => setMatch(data.recipe));
|
||||||
@@ -25,63 +86,254 @@ function App() {
|
|||||||
return () => socket.disconnect();
|
return () => socket.disconnect();
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const linkedCode = params.get('code');
|
||||||
|
if (linkedCode) {
|
||||||
|
setCode(linkedCode.toUpperCase());
|
||||||
|
setJoinStep('name');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.matchMode !== 'final_round' || !session.recipes?.length) {
|
||||||
|
setRecipeOrderIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecipeOrderIds((currentOrder) => {
|
||||||
|
const recipeIds = session.recipes.map((recipe) => recipe.id);
|
||||||
|
const recipeIdSet = new Set(recipeIds);
|
||||||
|
const keptOrder = currentOrder.filter((id) => recipeIdSet.has(id));
|
||||||
|
const keptIdSet = new Set(keptOrder);
|
||||||
|
const newIds = recipeIds.filter((id) => !keptIdSet.has(id));
|
||||||
|
return [...keptOrder, ...shuffle(newIds)];
|
||||||
|
});
|
||||||
|
}, [session?.matchMode, session?.votingRound, session?.recipes]);
|
||||||
|
|
||||||
const subscribe = (sessionCode) => socket.emit('session:subscribe', { code: sessionCode });
|
const subscribe = (sessionCode) => socket.emit('session:subscribe', { code: sessionCode });
|
||||||
|
|
||||||
async function createSession() {
|
async function createSession() {
|
||||||
setError('');
|
setError('');
|
||||||
const response = await fetch(`${API_URL}/api/sessions`, {
|
setIsLoading(true);
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const response = await fetch(`${API_URL}/api/sessions`, {
|
||||||
body: JSON.stringify({ name: name || 'Host' }),
|
method: 'POST',
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const data = await response.json();
|
body: JSON.stringify({ name: hostName || 'Host' }),
|
||||||
setSession(data.session);
|
});
|
||||||
setParticipantId(data.participantId);
|
const data = await response.json();
|
||||||
setCode(data.session.code);
|
if (!response.ok) {
|
||||||
subscribe(data.session.code);
|
setError(data.error || 'Session konnte nicht erstellt werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSession(data.session);
|
||||||
|
setParticipantId(data.participantId);
|
||||||
|
setCode(data.session.code);
|
||||||
|
setVotedRecipeIds([]);
|
||||||
|
subscribe(data.session.code);
|
||||||
|
} catch {
|
||||||
|
setError('Backend nicht erreichbar. Bitte Container prüfen.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function joinSession() {
|
async function joinSession() {
|
||||||
setError('');
|
setError('');
|
||||||
const response = await fetch(`${API_URL}/api/sessions/${code}/join`, {
|
setIsLoading(true);
|
||||||
method: 'POST',
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const response = await fetch(`${API_URL}/api/sessions/${code}/join`, {
|
||||||
body: JSON.stringify({ name: name || 'Gast' }),
|
method: 'POST',
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const data = await response.json();
|
body: JSON.stringify({ name: guestName || 'Gast' }),
|
||||||
if (!response.ok) {
|
});
|
||||||
setError(data.error || 'Beitritt fehlgeschlagen.');
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(data.error || 'Beitritt fehlgeschlagen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSession(data.session);
|
||||||
|
setParticipantId(data.participantId);
|
||||||
|
setVotedRecipeIds([]);
|
||||||
|
subscribe(data.session.code);
|
||||||
|
} catch {
|
||||||
|
setError('Backend nicht erreichbar. Bitte Container prüfen.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function continueToGuestName() {
|
||||||
|
const trimmedCode = code.trim().toUpperCase();
|
||||||
|
if (!trimmedCode) {
|
||||||
|
setError('Bitte gib zuerst den Session-Code ein.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSession(data.session);
|
|
||||||
setParticipantId(data.participantId);
|
setError('');
|
||||||
subscribe(data.session.code);
|
setCode(trimmedCode);
|
||||||
|
setJoinStep('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text, message) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopyMessage(message);
|
||||||
|
window.setTimeout(() => setCopyMessage(''), 1800);
|
||||||
|
} catch {
|
||||||
|
setCopyMessage('Kopieren fehlgeschlagen.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSession() {
|
function startSession() {
|
||||||
socket.emit('session:start', { code: session.code, participantId });
|
socket.emit('session:start', { code: session.code, participantId });
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendVote(vote) {
|
function restartSession() {
|
||||||
const recipe = session.recipes[session.currentRecipeIndex];
|
socket.emit('session:restart', { code: session.code, participantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRequiredLikes(value) {
|
||||||
|
socket.emit('session:set_required_likes', {
|
||||||
|
code: session.code,
|
||||||
|
participantId,
|
||||||
|
requiredLikes: Number(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMatchMode(value) {
|
||||||
|
socket.emit('session:set_match_mode', {
|
||||||
|
code: session.code,
|
||||||
|
participantId,
|
||||||
|
matchMode: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
setHostName('');
|
||||||
|
setGuestName('');
|
||||||
|
setCode('');
|
||||||
|
setJoinStep('code');
|
||||||
|
setParticipantId(null);
|
||||||
|
setSession(null);
|
||||||
|
sessionStatusRef.current = null;
|
||||||
|
votingRoundRef.current = null;
|
||||||
|
setMatch(null);
|
||||||
|
setError('');
|
||||||
|
setCopyMessage('');
|
||||||
|
setVotedRecipeIds([]);
|
||||||
|
setRecipeOrderIds([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendVote(vote, recipe = currentRecipe) {
|
||||||
|
if (!recipe || votedRecipeIdSet.has(recipe.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVotedRecipeIds((current) => [...current, recipe.id]);
|
||||||
socket.emit('vote', { code: session.code, participantId, recipeId: recipe.id, vote });
|
socket.emit('vote', { code: session.code, participantId, recipeId: recipe.id, vote });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function voteWithAnimation(vote) {
|
||||||
|
if (!currentRecipe || drag.leaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = vote === 'yes' ? 1 : -1;
|
||||||
|
setDrag({ x: direction * 520, y: 20, active: false, leaving: vote });
|
||||||
|
window.setTimeout(() => {
|
||||||
|
sendVote(vote);
|
||||||
|
setDrag({ x: 0, y: 0, active: false, leaving: null });
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeStart(event) {
|
||||||
|
if (event.target.closest('button, a, input, select')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drag.leaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
swipeStart.current = { x: event.clientX, y: event.clientY };
|
||||||
|
setDrag((current) => ({ ...current, active: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeMove(event) {
|
||||||
|
if (!swipeStart.current || drag.leaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrag({
|
||||||
|
x: event.clientX - swipeStart.current.x,
|
||||||
|
y: event.clientY - swipeStart.current.y,
|
||||||
|
active: true,
|
||||||
|
leaving: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipeEnd(event) {
|
||||||
|
if (!swipeStart.current || drag.leaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = event.clientX - swipeStart.current.x;
|
||||||
|
swipeStart.current = null;
|
||||||
|
|
||||||
|
if (Math.abs(distance) < SWIPE_THRESHOLD) {
|
||||||
|
setDrag({ x: 0, y: 0, active: false, leaving: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
voteWithAnimation(distance > 0 ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
return <Result recipe={match} />;
|
return <Result recipe={match} isHost={currentUser?.isHost} onHome={goHome} onRestart={restartSession} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session?.status === 'finished') {
|
||||||
|
return <Result recipe={matchedRecipe || null} isHost={currentUser?.isHost} onHome={goHome} onRestart={restartSession} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
if (joinStep === 'name') {
|
||||||
|
return (
|
||||||
|
<main className="page">
|
||||||
|
<section className="card">
|
||||||
|
<h1>Session beitreten</h1>
|
||||||
|
<p className="join-code">Session {code}</p>
|
||||||
|
<input placeholder="Dein Name" value={guestName} onChange={(e) => setGuestName(e.target.value)} />
|
||||||
|
<button onClick={joinSession} disabled={isLoading}>
|
||||||
|
{isLoading ? 'Bitte warten...' : 'Beitreten'}
|
||||||
|
</button>
|
||||||
|
<button className="secondary" onClick={() => setJoinStep('code')} disabled={isLoading}>Code ändern</button>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h1>Rezept Finder</h1>
|
<h1>Rezept Finder</h1>
|
||||||
<p>Findet gemeinsam heraus, was ihr heute essen wollt.</p>
|
<p>Findet gemeinsam heraus, was ihr heute essen wollt.</p>
|
||||||
<input placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} />
|
<input placeholder="Host-Name" value={hostName} onChange={(e) => setHostName(e.target.value)} />
|
||||||
<button onClick={createSession}>Session erstellen</button>
|
<button onClick={createSession} disabled={isLoading}>
|
||||||
|
{isLoading ? 'Bitte warten...' : 'Session erstellen'}
|
||||||
|
</button>
|
||||||
<div className="divider">oder</div>
|
<div className="divider">oder</div>
|
||||||
<input placeholder="Session-Code" value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} />
|
<input placeholder="Session-Code" value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} />
|
||||||
<button onClick={joinSession}>Session beitreten</button>
|
<button onClick={continueToGuestName} disabled={isLoading}>Weiter</button>
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -89,14 +341,50 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session.status === 'waiting') {
|
if (session.status === 'waiting') {
|
||||||
const currentUser = session.participants?.find((p) => p.id === participantId);
|
const participantCount = Math.max(2, session.participants?.length || 1);
|
||||||
|
const requiredLikes = Math.min(session.requiredLikes || participantCount, participantCount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h1>Lobby</h1>
|
<h1>Lobby</h1>
|
||||||
<p className="code">{session.code}</p>
|
<button className="code code-button" onClick={() => copyToClipboard(session.code, 'Code kopiert')}>
|
||||||
|
{session.code}
|
||||||
|
</button>
|
||||||
|
{currentUser?.isHost && (
|
||||||
|
<button className="secondary" onClick={() => copyToClipboard(sessionLink, 'Link kopiert')}>
|
||||||
|
Einladungslink kopieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{copyMessage && <p className="copy-message">{copyMessage}</p>}
|
||||||
<h2>Teilnehmer</h2>
|
<h2>Teilnehmer</h2>
|
||||||
<ul>{session.participants?.map((p) => <li key={p.id}>{p.name}{p.isHost ? ' 👑' : ''}</li>)}</ul>
|
<ul>{session.participants?.map((p) => <li key={p.id}>{p.name}{p.isHost ? ' 👑' : ''}</li>)}</ul>
|
||||||
|
<div className="setting">
|
||||||
|
<label htmlFor="match-mode">Modus</label>
|
||||||
|
{currentUser?.isHost ? (
|
||||||
|
<select id="match-mode" value={session.matchMode || 'first_match'} onChange={(event) => updateMatchMode(event.target.value)}>
|
||||||
|
<option value="first_match">Erstes Match gewinnt</option>
|
||||||
|
<option value="final_round">Finalrunde aus Favoriten</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p>{session.matchMode === 'final_round' ? 'Finalrunde aus Favoriten' : 'Erstes Match gewinnt'}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="setting">
|
||||||
|
<label htmlFor="required-likes">Likes bis Match</label>
|
||||||
|
{currentUser?.isHost ? (
|
||||||
|
<input
|
||||||
|
id="required-likes"
|
||||||
|
type="number"
|
||||||
|
min="2"
|
||||||
|
max={participantCount}
|
||||||
|
value={requiredLikes}
|
||||||
|
onChange={(event) => updateRequiredLikes(event.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p>{requiredLikes} von {participantCount}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{currentUser?.isHost && <button onClick={startSession}>Session starten</button>}
|
{currentUser?.isHost && <button onClick={startSession}>Session starten</button>}
|
||||||
{!currentUser?.isHost && <p>Warten auf Host…</p>}
|
{!currentUser?.isHost && <p>Warten auf Host…</p>}
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
@@ -105,42 +393,79 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipe = session.recipes?.[session.currentRecipeIndex];
|
if (!currentRecipe) {
|
||||||
if (!recipe) {
|
return (
|
||||||
return <Result recipe={null} />;
|
<main className="page">
|
||||||
|
<section className="card">
|
||||||
|
<h1>{session.matchMode === 'final_round' ? 'Warten auf die Runde' : 'Neue Rezepte laden'}</h1>
|
||||||
|
<p>{session.matchMode === 'final_round' ? 'Sobald alle fertig sind, geht es weiter.' : 'Der nächste Schwung Vorschläge ist gleich da.'}</p>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<section className="recipe-card">
|
<section
|
||||||
<p className="counter">{session.currentRecipeIndex + 1} / {session.recipes.length}</p>
|
className={`recipe-card ${drag.active ? 'is-dragging' : ''}`}
|
||||||
<div className="image-placeholder">🍽️</div>
|
onPointerDown={handleSwipeStart}
|
||||||
<h1>{recipe.title}</h1>
|
onPointerMove={handleSwipeMove}
|
||||||
<p>{recipe.meta}</p>
|
onPointerUp={handleSwipeEnd}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
swipeStart.current = null;
|
||||||
|
setDrag({ x: 0, y: 0, active: false, leaving: null });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
'--swipe-x': `${drag.x}px`,
|
||||||
|
'--swipe-y': `${drag.y}px`,
|
||||||
|
'--swipe-rotation': `${drag.x / 18}deg`,
|
||||||
|
'--yes-opacity': Math.max(0, Math.min(1, drag.x / SWIPE_THRESHOLD)),
|
||||||
|
'--no-opacity': Math.max(0, Math.min(1, -drag.x / SWIPE_THRESHOLD)),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="swipe-badge swipe-badge-yes">Ja</div>
|
||||||
|
<div className="swipe-badge swipe-badge-no">Nein</div>
|
||||||
|
<p className="counter">
|
||||||
|
{session.matchMode === 'final_round' && session.votingRound === 2 ? 'Finalrunde · ' : ''}
|
||||||
|
{votedRecipeIds.length + 1} / {session.recipes.length}
|
||||||
|
</p>
|
||||||
|
{currentRecipe.imageUrl ? (
|
||||||
|
<img className="recipe-image" src={currentRecipe.imageUrl} alt={currentRecipe.title} />
|
||||||
|
) : (
|
||||||
|
<div className="image-placeholder">🍽️</div>
|
||||||
|
)}
|
||||||
|
<h1>{currentRecipe.title}</h1>
|
||||||
|
<p>{currentRecipe.meta}</p>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className="no" onClick={() => sendVote('no')}>Nein</button>
|
<button className="no" onPointerDown={(event) => event.stopPropagation()} onClick={() => voteWithAnimation('no')}>Nein</button>
|
||||||
<button className="yes" onClick={() => sendVote('yes')}>Ja</button>
|
<button className="yes" onPointerDown={(event) => event.stopPropagation()} onClick={() => voteWithAnimation('yes')}>Ja</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Result({ recipe }) {
|
function Result({ recipe, isHost, onHome, onRestart }) {
|
||||||
return (
|
return (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
{recipe ? (
|
{recipe ? (
|
||||||
<>
|
<>
|
||||||
<h1>🎉 Match gefunden</h1>
|
<h1>🎉 Match gefunden</h1>
|
||||||
|
{recipe.imageUrl && <img className="recipe-image" src={recipe.imageUrl} alt={recipe.title} />}
|
||||||
<h2>{recipe.title}</h2>
|
<h2>{recipe.title}</h2>
|
||||||
<p>{recipe.meta}</p>
|
<p>{recipe.meta}</p>
|
||||||
<a className="button" href={recipe.url} target="_blank" rel="noreferrer">Zum Rezept</a>
|
<a className="button" href={recipe.url} target="_blank" rel="noreferrer">Zum Rezept</a>
|
||||||
|
{isHost && <button onClick={onRestart}>Session neu starten</button>}
|
||||||
|
<button className="secondary" onClick={onHome}>Zurück zur Startseite</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h1>Kein Match</h1>
|
<h1>Kein Match</h1>
|
||||||
<p>Bei den aktuellen Vorschlägen war nichts für alle dabei.</p>
|
<p>Bei den aktuellen Vorschlägen war nichts für alle dabei.</p>
|
||||||
|
{isHost && <button onClick={onRestart}>Session neu starten</button>}
|
||||||
|
<button className="secondary" onClick={onHome}>Zurück zur Startseite</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+17
-3
@@ -1,17 +1,31 @@
|
|||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; font-family: system-ui, sans-serif; background: #fff7ed; color: #1f2937; }
|
body { margin: 0; font-family: system-ui, sans-serif; background: #fff7ed; color: #1f2937; }
|
||||||
.page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
.page { min-height: 100vh; display: grid; place-items: center; padding: 24px; overflow: hidden; }
|
||||||
.card, .recipe-card { width: min(460px, 100%); background: white; border-radius: 28px; padding: 28px; box-shadow: 0 20px 50px rgba(0,0,0,.12); }
|
.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; }
|
h1 { margin-top: 0; }
|
||||||
input { width: 100%; padding: 14px 16px; border: 1px solid #e5e7eb; border-radius: 14px; margin: 8px 0; font-size: 16px; }
|
input, select { width: 100%; padding: 14px 16px; border: 1px solid #e5e7eb; border-radius: 14px; margin: 8px 0; font-size: 16px; background: white; }
|
||||||
|
label { display: block; font-weight: 700; margin-bottom: 6px; }
|
||||||
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; }
|
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; }
|
||||||
|
button:disabled { cursor: wait; opacity: .7; }
|
||||||
|
button.secondary { background: #e5e7eb; color: #1f2937; }
|
||||||
.divider { text-align: center; margin: 18px 0; color: #6b7280; }
|
.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; }
|
.code { font-size: 36px; letter-spacing: 8px; font-weight: 800; text-align: center; background: #f3f4f6; border-radius: 16px; padding: 16px; }
|
||||||
|
.code-button { color: #1f2937; margin: 0 0 10px; }
|
||||||
|
.join-code, .copy-message { text-align: center; color: #6b7280; }
|
||||||
|
.setting { margin: 18px 0; padding: 14px; border-radius: 14px; background: #f9fafb; }
|
||||||
|
.setting p { margin: 0; color: #6b7280; }
|
||||||
|
.setting input { margin: 0; }
|
||||||
ul { padding-left: 20px; }
|
ul { padding-left: 20px; }
|
||||||
.error { color: #dc2626; }
|
.error { color: #dc2626; }
|
||||||
.recipe-card { text-align: center; }
|
.recipe-card { text-align: center; touch-action: pan-y; user-select: none; cursor: grab; position: relative; transform: translate3d(var(--swipe-x, 0), var(--swipe-y, 0), 0) rotate(var(--swipe-rotation, 0deg)); transition: transform 180ms ease, box-shadow 180ms ease; will-change: transform; }
|
||||||
|
.recipe-card:active { cursor: grabbing; }
|
||||||
|
.recipe-card.is-dragging { transition: none; box-shadow: 0 26px 60px rgba(0,0,0,.18); }
|
||||||
|
.swipe-badge { position: absolute; top: 34px; z-index: 2; padding: 10px 14px; border: 4px solid currentColor; border-radius: 10px; background: rgba(255,255,255,.82); font-size: 28px; font-weight: 900; text-transform: uppercase; pointer-events: none; }
|
||||||
|
.swipe-badge-yes { right: 24px; color: #16a34a; opacity: var(--yes-opacity, 0); transform: rotate(14deg); }
|
||||||
|
.swipe-badge-no { left: 24px; color: #dc2626; opacity: var(--no-opacity, 0); transform: rotate(-14deg); }
|
||||||
.counter { color: #6b7280; }
|
.counter { color: #6b7280; }
|
||||||
.image-placeholder { height: 220px; border-radius: 22px; background: #fed7aa; display: grid; place-items: center; font-size: 72px; margin-bottom: 20px; }
|
.image-placeholder { height: 220px; border-radius: 22px; background: #fed7aa; display: grid; place-items: center; font-size: 72px; margin-bottom: 20px; }
|
||||||
|
.recipe-image { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 22px; margin-bottom: 20px; background: #fed7aa; display: block; }
|
||||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
.actions button { margin-top: 0; }
|
.actions button { margin-top: 0; }
|
||||||
.no { background: #ef4444; }
|
.no { background: #ef4444; }
|
||||||
|
|||||||
Reference in New Issue
Block a user