init
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# Rezept Finder
|
||||
|
||||
MVP: Gemeinsame Food-Swipe-Webapp.
|
||||
|
||||
## Entwicklung starten
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
flask --app app:create_app db upgrade
|
||||
python run.py
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Postgres lokal per Docker
|
||||
```bash
|
||||
docker compose up -d postgres
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
FLASK_ENV=development
|
||||
DATABASE_URL=postgresql+psycopg2://rezept_finder:rezept_finder@localhost:5432/rezept_finder
|
||||
SECRET_KEY=dev-secret-change-me
|
||||
FRONTEND_ORIGIN=http://localhost:5173
|
||||
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_migrate import Migrate
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
from .models import db
|
||||
|
||||
socketio = SocketIO(cors_allowed_origins="*")
|
||||
migrate = Migrate()
|
||||
|
||||
|
||||
def create_app():
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql+psycopg2://rezept_finder:rezept_finder@localhost:5432/rezept_finder",
|
||||
)
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
socketio.init_app(app, cors_allowed_origins=os.getenv("FRONTEND_ORIGIN", "*"))
|
||||
|
||||
from .routes.sessions import bp as sessions_bp
|
||||
app.register_blueprint(sessions_bp, url_prefix="/api/sessions")
|
||||
|
||||
from .socket import events # noqa: F401
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,5 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
from .session import Session, Participant, Recipe, Vote # noqa: E402,F401
|
||||
@@ -0,0 +1,53 @@
|
||||
from datetime import datetime
|
||||
from . import db
|
||||
|
||||
|
||||
class Session(db.Model):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(6), unique=True, nullable=False, index=True)
|
||||
status = db.Column(db.String(20), nullable=False, default="waiting")
|
||||
current_recipe_index = db.Column(db.Integer, nullable=False, default=0)
|
||||
matched_recipe_id = db.Column(db.Integer, db.ForeignKey("recipes.id"), nullable=True)
|
||||
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")
|
||||
|
||||
|
||||
class Participant(db.Model):
|
||||
__tablename__ = "participants"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False)
|
||||
name = db.Column(db.String(80), nullable=False)
|
||||
is_host = db.Column(db.Boolean, nullable=False, default=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Recipe(db.Model):
|
||||
__tablename__ = "recipes"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False)
|
||||
position = db.Column(db.Integer, nullable=False)
|
||||
title = db.Column(db.String(255), nullable=False)
|
||||
url = db.Column(db.Text, nullable=False)
|
||||
image_url = db.Column(db.Text, nullable=True)
|
||||
meta = db.Column(db.String(255), nullable=True)
|
||||
|
||||
|
||||
class Vote(db.Model):
|
||||
__tablename__ = "votes"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
session_id = db.Column(db.Integer, db.ForeignKey("sessions.id"), nullable=False)
|
||||
participant_id = db.Column(db.Integer, db.ForeignKey("participants.id"), nullable=False)
|
||||
recipe_id = db.Column(db.Integer, db.ForeignKey("recipes.id"), nullable=False)
|
||||
vote = db.Column(db.String(3), nullable=False) # yes/no
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("participant_id", "recipe_id", name="uq_participant_recipe_vote"),
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
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
|
||||
|
||||
bp = Blueprint("sessions", __name__)
|
||||
|
||||
|
||||
def generate_code():
|
||||
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||
|
||||
|
||||
def serialize_session(session):
|
||||
return {
|
||||
"id": session.id,
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"currentRecipeIndex": session.current_recipe_index,
|
||||
"participants": [
|
||||
{"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
|
||||
],
|
||||
"matchedRecipeId": session.matched_recipe_id,
|
||||
}
|
||||
|
||||
|
||||
@bp.post("")
|
||||
def create_session():
|
||||
data = request.get_json() or {}
|
||||
name = (data.get("name") or "Host").strip()
|
||||
|
||||
code = generate_code()
|
||||
while Session.query.filter_by(code=code).first():
|
||||
code = generate_code()
|
||||
|
||||
session = Session(code=code)
|
||||
db.session.add(session)
|
||||
db.session.flush()
|
||||
|
||||
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))
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({"session": serialize_session(session), "participantId": host.id})
|
||||
|
||||
|
||||
@bp.post("/<code>/join")
|
||||
def join_session(code):
|
||||
data = request.get_json() or {}
|
||||
name = (data.get("name") or "Gast").strip()
|
||||
|
||||
session = Session.query.filter_by(code=code.upper()).first_or_404()
|
||||
if session.status != "waiting":
|
||||
return jsonify({"error": "Session wurde bereits gestartet."}), 409
|
||||
|
||||
participant = Participant(session_id=session.id, name=name, is_host=False)
|
||||
db.session.add(participant)
|
||||
db.session.commit()
|
||||
return jsonify({"session": serialize_session(session), "participantId": participant.id})
|
||||
|
||||
|
||||
@bp.get("/<code>")
|
||||
def get_session(code):
|
||||
session = Session.query.filter_by(code=code.upper()).first_or_404()
|
||||
return jsonify({"session": serialize_session(session)})
|
||||
@@ -0,0 +1,55 @@
|
||||
DUMMY_RECIPES = [
|
||||
{
|
||||
"title": "Cremige Pasta mit Tomaten",
|
||||
"url": "https://www.chefkoch.de/",
|
||||
"image_url": "",
|
||||
"meta": "ca. 25 Minuten",
|
||||
},
|
||||
{
|
||||
"title": "Kartoffel-Brokkoli-Auflauf",
|
||||
"url": "https://www.chefkoch.de/",
|
||||
"image_url": "",
|
||||
"meta": "vegetarisch",
|
||||
},
|
||||
{
|
||||
"title": "Schnelle Reispfanne",
|
||||
"url": "https://www.chefkoch.de/",
|
||||
"image_url": "",
|
||||
"meta": "einfach",
|
||||
},
|
||||
{
|
||||
"title": "Wraps mit Hähnchen",
|
||||
"url": "https://www.chefkoch.de/",
|
||||
"image_url": "",
|
||||
"meta": "schnell",
|
||||
},
|
||||
{
|
||||
"title": "Gemüse-Curry",
|
||||
"url": "https://www.chefkoch.de/",
|
||||
"image_url": "",
|
||||
"meta": "würzig",
|
||||
},
|
||||
{
|
||||
"title": "Ofenkartoffeln mit Dip",
|
||||
"url": "https://www.chefkoch.de/",
|
||||
"image_url": "",
|
||||
"meta": "günstig",
|
||||
},
|
||||
{
|
||||
"title": "Gnocchi-Pfanne",
|
||||
"url": "https://www.chefkoch.de/",
|
||||
"image_url": "",
|
||||
"meta": "15 Minuten",
|
||||
},
|
||||
{
|
||||
"title": "Pizza-Toast",
|
||||
"url": "https://www.chefkoch.de/",
|
||||
"image_url": "",
|
||||
"meta": "MVP-Klassiker",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_recipes():
|
||||
# Später ersetzen durch Chefkoch-Vorschau-Importer.
|
||||
return DUMMY_RECIPES
|
||||
@@ -0,0 +1 @@
|
||||
# Socket package
|
||||
@@ -0,0 +1,117 @@
|
||||
from flask_socketio import emit, join_room
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from .. import socketio
|
||||
from ..models import db
|
||||
from ..models.session import Session, Vote
|
||||
|
||||
|
||||
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",
|
||||
{
|
||||
"code": session.code,
|
||||
"status": session.status,
|
||||
"currentRecipeIndex": session.current_recipe_index,
|
||||
"matchedRecipeId": session.matched_recipe_id,
|
||||
"participants": [
|
||||
{"id": p.id, "name": p.name, "isHost": p.is_host}
|
||||
for p in session.participants
|
||||
],
|
||||
},
|
||||
room=room_name(session.code),
|
||||
)
|
||||
|
||||
|
||||
@socketio.on("session:subscribe")
|
||||
def subscribe(data):
|
||||
code = data.get("code", "").upper()
|
||||
session = Session.query.filter_by(code=code).first()
|
||||
if not session:
|
||||
emit("error", {"message": "Session nicht gefunden."})
|
||||
return
|
||||
|
||||
join_room(room_name(code))
|
||||
emit_session_update(session)
|
||||
|
||||
|
||||
@socketio.on("session:start")
|
||||
def start_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 starten."})
|
||||
return
|
||||
|
||||
session.status = "running"
|
||||
session.current_recipe_index = 0
|
||||
db.session.commit()
|
||||
emit_session_update(session)
|
||||
|
||||
|
||||
@socketio.on("vote")
|
||||
def vote(data):
|
||||
code = data.get("code", "").upper()
|
||||
participant_id = data.get("participantId")
|
||||
recipe_id = data.get("recipeId")
|
||||
vote_value = data.get("vote")
|
||||
|
||||
if vote_value not in ["yes", "no"]:
|
||||
emit("error", {"message": "Ungültige Stimme."})
|
||||
return
|
||||
|
||||
session = Session.query.filter_by(code=code).first()
|
||||
if not session or session.status != "running":
|
||||
emit("error", {"message": "Session läuft nicht."})
|
||||
return
|
||||
|
||||
recipe = next((r for r in session.recipes if r.id == recipe_id), None)
|
||||
if not recipe:
|
||||
emit("error", {"message": "Rezept nicht gefunden."})
|
||||
return
|
||||
|
||||
db.session.add(Vote(session_id=session.id, participant_id=participant_id, recipe_id=recipe.id, vote=vote_value))
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
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 yes_count == participant_count:
|
||||
session.status = "finished"
|
||||
session.matched_recipe_id = recipe.id
|
||||
db.session.commit()
|
||||
socketio.emit("session:match", {"recipe": serialize_recipe(recipe)}, room=room_name(code))
|
||||
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)
|
||||
@@ -0,0 +1,7 @@
|
||||
Flask==3.0.3
|
||||
Flask-SocketIO==5.3.6
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Migrate==4.0.7
|
||||
psycopg2-binary==2.9.9
|
||||
python-dotenv==1.0.1
|
||||
eventlet==0.36.1
|
||||
@@ -0,0 +1,6 @@
|
||||
from app import create_app, socketio
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
socketio.run(app, host="0.0.0.0", port=5000, debug=True)
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
container_name: rezept_finder_postgres
|
||||
environment:
|
||||
POSTGRES_DB: rezept_finder
|
||||
POSTGRES_USER: rezept_finder
|
||||
POSTGRES_PASSWORD: rezept_finder
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -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>
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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 />);
|
||||
@@ -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; }
|
||||
Reference in New Issue
Block a user