This commit is contained in:
2026-06-09 23:10:17 +02:00
parent 7b3b9a890f
commit 90e5f61b91
15 changed files with 911 additions and 86 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
node_modules/
dist/
.env
+12
View File
@@ -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"]
+29
View File
@@ -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()
+1 -1
View File
@@ -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
+18 -1
View File
@@ -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"
+8 -15
View File
@@ -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})
+89
View File
@@ -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
+97 -3
View File
@@ -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
+57
View File
@@ -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)
+62
View File
@@ -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
+96 -21
View File
@@ -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)
+39
View File
@@ -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:
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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; }