diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f36613b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] + +node_modules/ +dist/ + +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..30711dc --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py index b344913..b53440a 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -3,6 +3,7 @@ from flask import Flask from flask_migrate import Migrate from flask_socketio import SocketIO from dotenv import load_dotenv +from sqlalchemy import text from .models import db socketio = SocketIO(cors_allowed_origins="*") @@ -20,6 +21,14 @@ def create_app(): ) 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) migrate.init_app(app, db) socketio.init_app(app, cors_allowed_origins=os.getenv("FRONTEND_ORIGIN", "*")) @@ -29,4 +38,24 @@ def create_app(): from .socket import events # noqa: F401 + with app.app_context(): + ensure_schema() + 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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9ca6159..c29e946 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,4 +2,4 @@ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() -from .session import Session, Participant, Recipe, Vote # noqa: E402,F401 +from .session import Session, SessionSettings, Participant, Recipe, Vote # noqa: E402,F401 diff --git a/backend/app/models/session.py b/backend/app/models/session.py index b4ab232..9b4c49b 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -13,7 +13,14 @@ class Session(db.Model): 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") + 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): @@ -26,6 +33,16 @@ class Participant(db.Model): 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): __tablename__ = "recipes" diff --git a/backend/app/routes/sessions.py b/backend/app/routes/sessions.py index 816e712..135f32a 100644 --- a/backend/app/routes/sessions.py +++ b/backend/app/routes/sessions.py @@ -2,8 +2,9 @@ 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 +from ..models.session import Session, Participant +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__) @@ -22,18 +23,11 @@ def serialize_session(session): {"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 - ], + "recipes": [serialize_recipe(recipe) for recipe in session.recipes], "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) db.session.add(host) - for index, recipe_data in enumerate(get_recipes()): - db.session.add(Recipe(session_id=session.id, position=index, **recipe_data)) + add_recipe_batch(session) db.session.commit() return jsonify({"session": serialize_session(session), "participantId": host.id}) diff --git a/backend/app/services/match_modes.py b/backend/app/services/match_modes.py new file mode 100644 index 0000000..c3a1e3c --- /dev/null +++ b/backend/app/services/match_modes.py @@ -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 diff --git a/backend/app/services/recipes.py b/backend/app/services/recipes.py index 506391c..d77e239 100644 --- a/backend/app/services/recipes.py +++ b/backend/app/services/recipes.py @@ -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", "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\d+[,.]\d)\s*/?\s*5?\s*\((?P\d+)\)\s*(?P.+)", 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(): - # Später ersetzen durch Chefkoch-Vorschau-Importer. - return DUMMY_RECIPES + try: + recipes = fetch_chefkoch_today_recipes() + except Exception: + return FALLBACK_RECIPES + + return recipes or FALLBACK_RECIPES diff --git a/backend/app/services/session_recipes.py b/backend/app/services/session_recipes.py new file mode 100644 index 0000000..4e196b6 --- /dev/null +++ b/backend/app/services/session_recipes.py @@ -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) diff --git a/backend/app/services/session_settings.py b/backend/app/services/session_settings.py new file mode 100644 index 0000000..14a9e2b --- /dev/null +++ b/backend/app/services/session_settings.py @@ -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 diff --git a/backend/app/socket/events.py b/backend/app/socket/events.py index 95c93b8..be99660 100644 --- a/backend/app/socket/events.py +++ b/backend/app/socket/events.py @@ -3,23 +3,15 @@ from sqlalchemy.exc import IntegrityError from .. import socketio from ..models import db 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): 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", @@ -32,6 +24,10 @@ def emit_session_update(session): {"id": p.id, "name": p.name, "isHost": p.is_host} 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), ) @@ -65,6 +61,76 @@ def start_session(data): session.status = "running" 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() emit_session_update(session) @@ -97,11 +163,17 @@ def vote(data): 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 is_final_round_mode(session): + result = handle_final_round_vote(session, recipe) + 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.matched_recipe_id = recipe.id db.session.commit() @@ -109,9 +181,12 @@ def vote(data): 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) + participant_vote_count = Vote.query.filter_by( + session_id=session.id, + participant_id=participant_id, + ).count() + if participant_vote_count >= len(session.recipes): + add_recipe_batch(session) + + db.session.commit() + emit_session_update(session) diff --git a/docker-compose.yml b/docker-compose.yml index f4bb00f..3478102 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,45 @@ services: - "5432:5432" volumes: - 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: postgres_data: + frontend_node_modules: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1a5c829 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json . +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev"] diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index f423800..b6bf9ff 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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 { io } from 'socket.io-client'; import './style.css'; 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() { - const [name, setName] = useState(''); + const [hostName, setHostName] = useState(''); + const [guestName, setGuestName] = useState(''); const [code, setCode] = useState(''); + const [joinStep, setJoinStep] = useState('code'); const [participantId, setParticipantId] = useState(null); const [session, setSession] = useState(null); const [match, setMatch] = useState(null); 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 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(() => { socket.connect(); 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); }); socket.on('session:match', (data) => setMatch(data.recipe)); @@ -25,63 +86,254 @@ function App() { return () => socket.disconnect(); }, [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 }); 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); + setIsLoading(true); + try { + const response = await fetch(`${API_URL}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: hostName || 'Host' }), + }); + const data = await response.json(); + if (!response.ok) { + 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() { 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.'); + setIsLoading(true); + try { + const response = await fetch(`${API_URL}/api/sessions/${code}/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: guestName || 'Gast' }), + }); + 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; } - setSession(data.session); - setParticipantId(data.participantId); - subscribe(data.session.code); + + setError(''); + 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() { socket.emit('session:start', { code: session.code, participantId }); } - function sendVote(vote) { - const recipe = session.recipes[session.currentRecipeIndex]; + function restartSession() { + 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 }); } + 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) { - 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 (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 ( <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> + <input placeholder="Host-Name" value={hostName} onChange={(e) => setHostName(e.target.value)} /> + <button onClick={createSession} disabled={isLoading}> + {isLoading ? 'Bitte warten...' : '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> + <button onClick={continueToGuestName} disabled={isLoading}>Weiter</button> {error && <p className="error">{error}</p>} </section> </main> @@ -89,14 +341,50 @@ function App() { } 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 ( <main className="page"> <section className="card"> <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> <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 && <p>Warten auf Host…</p>} {error && <p className="error">{error}</p>} @@ -105,42 +393,79 @@ function App() { ); } - const recipe = session.recipes?.[session.currentRecipeIndex]; - if (!recipe) { - return <Result recipe={null} />; + if (!currentRecipe) { + return ( + <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 ( <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> + <section + className={`recipe-card ${drag.active ? 'is-dragging' : ''}`} + onPointerDown={handleSwipeStart} + onPointerMove={handleSwipeMove} + 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"> - <button className="no" onClick={() => sendVote('no')}>Nein</button> - <button className="yes" onClick={() => sendVote('yes')}>Ja</button> + <button className="no" onPointerDown={(event) => event.stopPropagation()} onClick={() => voteWithAnimation('no')}>Nein</button> + <button className="yes" onPointerDown={(event) => event.stopPropagation()} onClick={() => voteWithAnimation('yes')}>Ja</button> </div> </section> </main> ); } -function Result({ recipe }) { +function Result({ recipe, isHost, onHome, onRestart }) { return ( <main className="page"> <section className="card"> {recipe ? ( <> <h1>🎉 Match gefunden</h1> + {recipe.imageUrl && <img className="recipe-image" src={recipe.imageUrl} alt={recipe.title} />} <h2>{recipe.title}</h2> <p>{recipe.meta}</p> <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> <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> diff --git a/frontend/src/style.css b/frontend/src/style.css index 52def8b..de5aa9c 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,17 +1,31 @@ * { 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; } +.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); } 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:disabled { cursor: wait; opacity: .7; } +button.secondary { background: #e5e7eb; color: #1f2937; } .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-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; } .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; } .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 button { margin-top: 0; } .no { background: #ef4444; }