v1.0
This commit is contained in:
+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 { 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>
|
||||
|
||||
+17
-3
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user