This commit is contained in:
2026-06-09 15:25:54 +02:00
commit 7b3b9a890f
16 changed files with 600 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rezept Finder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest",
"react": "latest",
"react-dom": "latest",
"socket.io-client": "latest"
},
"devDependencies": {}
}
+151
View File
@@ -0,0 +1,151 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { io } from 'socket.io-client';
import './style.css';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
function App() {
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [participantId, setParticipantId] = useState(null);
const [session, setSession] = useState(null);
const [match, setMatch] = useState(null);
const [error, setError] = useState('');
const socket = useMemo(() => io(API_URL, { autoConnect: false }), []);
useEffect(() => {
socket.connect();
socket.on('session:update', (update) => {
setSession((current) => current ? { ...current, ...update } : update);
});
socket.on('session:match', (data) => setMatch(data.recipe));
socket.on('error', (data) => setError(data.message));
return () => socket.disconnect();
}, [socket]);
const subscribe = (sessionCode) => socket.emit('session:subscribe', { code: sessionCode });
async function createSession() {
setError('');
const response = await fetch(`${API_URL}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name || 'Host' }),
});
const data = await response.json();
setSession(data.session);
setParticipantId(data.participantId);
setCode(data.session.code);
subscribe(data.session.code);
}
async function joinSession() {
setError('');
const response = await fetch(`${API_URL}/api/sessions/${code}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name || 'Gast' }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Beitritt fehlgeschlagen.');
return;
}
setSession(data.session);
setParticipantId(data.participantId);
subscribe(data.session.code);
}
function startSession() {
socket.emit('session:start', { code: session.code, participantId });
}
function sendVote(vote) {
const recipe = session.recipes[session.currentRecipeIndex];
socket.emit('vote', { code: session.code, participantId, recipeId: recipe.id, vote });
}
if (match) {
return <Result recipe={match} />;
}
if (!session) {
return (
<main className="page">
<section className="card">
<h1>Rezept Finder</h1>
<p>Findet gemeinsam heraus, was ihr heute essen wollt.</p>
<input placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={createSession}>Session erstellen</button>
<div className="divider">oder</div>
<input placeholder="Session-Code" value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} />
<button onClick={joinSession}>Session beitreten</button>
{error && <p className="error">{error}</p>}
</section>
</main>
);
}
if (session.status === 'waiting') {
const currentUser = session.participants?.find((p) => p.id === participantId);
return (
<main className="page">
<section className="card">
<h1>Lobby</h1>
<p className="code">{session.code}</p>
<h2>Teilnehmer</h2>
<ul>{session.participants?.map((p) => <li key={p.id}>{p.name}{p.isHost ? ' 👑' : ''}</li>)}</ul>
{currentUser?.isHost && <button onClick={startSession}>Session starten</button>}
{!currentUser?.isHost && <p>Warten auf Host…</p>}
{error && <p className="error">{error}</p>}
</section>
</main>
);
}
const recipe = session.recipes?.[session.currentRecipeIndex];
if (!recipe) {
return <Result recipe={null} />;
}
return (
<main className="page">
<section className="recipe-card">
<p className="counter">{session.currentRecipeIndex + 1} / {session.recipes.length}</p>
<div className="image-placeholder">🍽️</div>
<h1>{recipe.title}</h1>
<p>{recipe.meta}</p>
<div className="actions">
<button className="no" onClick={() => sendVote('no')}>Nein</button>
<button className="yes" onClick={() => sendVote('yes')}>Ja</button>
</div>
</section>
</main>
);
}
function Result({ recipe }) {
return (
<main className="page">
<section className="card">
{recipe ? (
<>
<h1>🎉 Match gefunden</h1>
<h2>{recipe.title}</h2>
<p>{recipe.meta}</p>
<a className="button" href={recipe.url} target="_blank" rel="noreferrer">Zum Rezept</a>
</>
) : (
<>
<h1>Kein Match</h1>
<p>Bei den aktuellen Vorschlägen war nichts für alle dabei.</p>
</>
)}
</section>
</main>
);
}
createRoot(document.getElementById('root')).render(<App />);
+18
View File
@@ -0,0 +1,18 @@
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: #fff7ed; color: #1f2937; }
.page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.card, .recipe-card { width: min(460px, 100%); background: white; border-radius: 28px; padding: 28px; box-shadow: 0 20px 50px rgba(0,0,0,.12); }
h1 { margin-top: 0; }
input { width: 100%; padding: 14px 16px; border: 1px solid #e5e7eb; border-radius: 14px; margin: 8px 0; font-size: 16px; }
button, .button { width: 100%; border: 0; border-radius: 14px; padding: 14px 18px; font-weight: 700; font-size: 16px; cursor: pointer; background: #f97316; color: white; text-decoration: none; display: inline-block; text-align: center; margin-top: 10px; }
.divider { text-align: center; margin: 18px 0; color: #6b7280; }
.code { font-size: 36px; letter-spacing: 8px; font-weight: 800; text-align: center; background: #f3f4f6; border-radius: 16px; padding: 16px; }
ul { padding-left: 20px; }
.error { color: #dc2626; }
.recipe-card { text-align: center; }
.counter { color: #6b7280; }
.image-placeholder { height: 220px; border-radius: 22px; background: #fed7aa; display: grid; place-items: center; font-size: 72px; margin-bottom: 20px; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.actions button { margin-top: 0; }
.no { background: #ef4444; }
.yes { background: #22c55e; }