diff --git a/app/main.py b/app/main.py index d13b01a..d5277da 100755 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ import os import uuid import json +import random import datetime from fastapi import FastAPI, Request, Depends, HTTPException, status, Form from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse @@ -28,6 +29,27 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) +def format_question_for_client(q: Question, number: int, total: int) -> dict: + options_list = None + if q.question_type == "multiple_choice" and q.options: + try: + parsed = json.loads(q.options) + if isinstance(parsed, list): + options_list = list(parsed) + random.shuffle(options_list) + except Exception: + options_list = [opt.strip() for opt in q.options.split(",") if opt.strip()] + random.shuffle(options_list) + + return { + "question_number": number, + "total_questions": total, + "category": q.category, + "question": q.question, + "question_type": q.question_type or "text", + "options": options_list + } + @app.on_event("startup") def startup_event(): seed_db() @@ -65,16 +87,14 @@ async def start_game(db: Session = Depends(get_db)): db.commit() first_q = questions[0] - return { + res = format_question_for_client(first_q, 1, len(question_ids)) + res.update({ "session_id": session_id, - "question_number": 1, - "total_questions": len(question_ids), - "category": first_q.category, - "question": first_q.question, "score": 0, "attempts_used": 0, "max_attempts": 3 - } + }) + return res @app.post("/api/game/check") async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)): @@ -118,12 +138,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) if not is_finished: next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first() if next_q: - next_q_data = { - "question_number": session.current_question_idx + 1, - "total_questions": len(question_ids), - "category": next_q.category, - "question": next_q.question - } + next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids)) return { "correct": True, @@ -155,12 +170,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) if not is_finished: next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first() if next_q: - next_q_data = { - "question_number": session.current_question_idx + 1, - "total_questions": len(question_ids), - "category": next_q.category, - "question": next_q.question - } + next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids)) return { "correct": False, @@ -218,11 +228,23 @@ async def submit_suggestion(payload: SuggestionCreateRequest, db: Session = Depe if not payload.frage.strip() or not payload.antwort.strip(): raise HTTPException(status_code=400, detail="Frage und Antwort dürfen nicht leer sein.") + options_json = None + q_type = payload.question_type or "text" + if q_type == "multiple_choice": + opts = [payload.antwort.strip()] + if payload.falsche_antworten: + for opt in payload.falsche_antworten: + if opt and opt.strip(): + opts.append(opt.strip()) + options_json = json.dumps(opts, ensure_ascii=False) + new_suggestion = Question( category="community", question=payload.frage.strip(), canonical_answer=payload.antwort.strip(), hint=payload.tipp.strip() if payload.tipp else None, + question_type=q_type, + options=options_json, status="needs_review", # User Question Need Test / Review submitted_by=payload.name.strip() if payload.name else "Gast", sort_order=999 @@ -304,17 +326,31 @@ async def admin_add_question( canonical_answer: str = Form(...), hint: str = Form(None), category: str = Form("harry_potter"), + question_type: str = Form("text"), + wrong_option_1: str = Form(None), + wrong_option_2: str = Form(None), + wrong_option_3: str = Form(None), sort_order: int = Form(0), db: Session = Depends(get_db) ): if not request.session.get("admin_user"): return RedirectResponse(url="/admin/login", status_code=303) + options_json = None + if question_type == "multiple_choice": + opts = [canonical_answer.strip()] + for w in [wrong_option_1, wrong_option_2, wrong_option_3]: + if w and w.strip(): + opts.append(w.strip()) + options_json = json.dumps(opts, ensure_ascii=False) + new_q = Question( category=category.strip(), question=question.strip(), canonical_answer=canonical_answer.strip(), hint=hint.strip() if hint else None, + question_type=question_type.strip(), + options=options_json, status="active", sort_order=sort_order, submitted_by=f"Admin ({request.session['admin_user']})" @@ -365,6 +401,8 @@ async def admin_edit_question( question: str = Form(...), canonical_answer: str = Form(...), hint: str = Form(None), + question_type: str = Form("text"), + options_raw: str = Form(None), status_val: str = Form("active"), sort_order: int = Form(0), db: Session = Depends(get_db) @@ -379,6 +417,18 @@ async def admin_edit_question( q.question = question.strip() q.canonical_answer = canonical_answer.strip() q.hint = hint.strip() if hint else None + q.question_type = question_type.strip() + + if options_raw and options_raw.strip(): + val = options_raw.strip() + if val.startswith("[") and val.endswith("]"): + q.options = val + else: + items = [x.strip() for x in val.split(",") if x.strip()] + q.options = json.dumps(items, ensure_ascii=False) + else: + q.options = None + q.status = status_val.strip() q.sort_order = sort_order db.commit() diff --git a/app/models.py b/app/models.py index f36f24c..bc4899c 100755 --- a/app/models.py +++ b/app/models.py @@ -10,6 +10,8 @@ class Question(Base): question = Column(Text, nullable=False) canonical_answer = Column(String(255), nullable=False) accepted_answers = Column(Text, nullable=True) # JSON or comma-delimited + question_type = Column(String(20), default="text") # 'text' or 'multiple_choice' + options = Column(Text, nullable=True) # JSON list of options for multiple choice hint = Column(Text, nullable=True) status = Column(String(30), default="active", index=True) # active, needs_review, rejected, archived submitted_by = Column(String(100), nullable=True) diff --git a/app/schemas.py b/app/schemas.py index 6fa8b16..1306545 100755 --- a/app/schemas.py +++ b/app/schemas.py @@ -30,6 +30,8 @@ class SuggestionCreateRequest(BaseModel): antwort: str tipp: Optional[str] = None name: Optional[str] = "Gast" + question_type: Optional[str] = "text" + falsche_antworten: Optional[List[str]] = None class QuestionAdminCreate(BaseModel): category: str = "harry_potter" diff --git a/app/static/css/style.css b/app/static/css/style.css index c762a88..6da826b 100755 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -428,6 +428,88 @@ body { box-shadow: 0 0 12px rgba(201, 168, 106, 0.4); } +/* Multiple Choice Styles */ +.choice-group { + width: 100%; + margin-top: 10px; +} + +.choice-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; + width: 100%; + max-width: 650px; + margin: 0 auto; +} + +.choice-btn { + background: rgba(255, 255, 255, 0.05); + border: 2px solid var(--border-gold); + border-radius: 10px; + padding: 14px 18px; + color: var(--text-main); + font-size: 1.05rem; + font-family: inherit; + cursor: pointer; + text-align: left; + display: flex; + align-items: center; + gap: 12px; + transition: all 0.25s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.choice-btn:hover:not(:disabled) { + background: rgba(201, 168, 106, 0.18); + border-color: var(--gold); + box-shadow: 0 0 15px rgba(201, 168, 106, 0.4); + transform: translateY(-2px); +} + +.choice-btn:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +.choice-letter { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: rgba(201, 168, 106, 0.2); + border: 1px solid var(--gold); + border-radius: 50%; + color: var(--gold-bright); + font-weight: bold; + font-size: 0.9rem; + flex-shrink: 0; +} + +.choice-text { + flex: 1; +} + +.choice-selected { + border-color: var(--gold-bright); + background: rgba(201, 168, 106, 0.3); +} + +.choice-correct { + background: rgba(46, 204, 113, 0.25) !important; + border-color: #2ecc71 !important; + color: #55efc4 !important; + box-shadow: 0 0 15px rgba(46, 204, 113, 0.6) !important; +} + +.choice-wrong { + background: rgba(231, 76, 60, 0.25) !important; + border-color: #e74c3c !important; + color: #ff7675 !important; + box-shadow: 0 0 15px rgba(231, 76, 60, 0.5) !important; +} + .hint-box { margin-top: 20px; padding: 15px; diff --git a/app/static/js/admin.js b/app/static/js/admin.js index a2d2c9c..bc5d0ad 100755 --- a/app/static/js/admin.js +++ b/app/static/js/admin.js @@ -7,8 +7,9 @@ function showTab(tabId) { targetTab.classList.add("active"); } - // Mark active button - event.currentTarget.classList.add("active"); + if (event && event.currentTarget) { + event.currentTarget.classList.add("active"); + } } function toggleEdit(id) { @@ -18,6 +19,17 @@ function toggleEdit(id) { } } +function toggleAdminNewType(type) { + const mcGroup = document.getElementById("adminMcOptions"); + if (mcGroup) { + if (type === "multiple_choice") { + mcGroup.classList.remove("hidden"); + } else { + mcGroup.classList.add("hidden"); + } + } +} + async function approveQuestion(id) { if (!confirm(`Möchtest du Frage #${id} wirklich freischalten und für alle Spieler aktivieren?`)) { return; diff --git a/app/static/js/quiz.js b/app/static/js/quiz.js index a7047cc..a23fe3f 100755 --- a/app/static/js/quiz.js +++ b/app/static/js/quiz.js @@ -103,16 +103,42 @@ function renderQuestion(qData) { document.getElementById("attemptBadge").innerText = `Versuch ${attemptsUsed + 1} von ${maxAttempts}`; document.getElementById("frageText").innerText = qData.question; + const textGroup = document.getElementById("textAnswerGroup"); + const choiceGroup = document.getElementById("choiceAnswerGroup"); + const choiceContainer = document.getElementById("choiceOptionsContainer"); const input = document.getElementById("answerInput"); - input.value = ""; - input.disabled = false; - input.focus(); - document.getElementById("checkBtn").disabled = false; - document.getElementById("hintBtn").disabled = false; document.getElementById("hintBox").classList.add("hidden"); document.getElementById("feedbackBox").innerHTML = ""; + if (qData.question_type === "multiple_choice" && qData.options && qData.options.length > 0) { + textGroup.classList.add("hidden"); + choiceGroup.classList.remove("hidden"); + choiceContainer.innerHTML = ""; + + const letters = ["A", "B", "C", "D", "E", "F"]; + qData.options.forEach((opt, idx) => { + const btn = document.createElement("button"); + btn.className = "choice-btn"; + btn.type = "button"; + btn.innerHTML = `${letters[idx] || "•"} ${opt}`; + btn.onclick = () => submitAnswer(opt, btn); + choiceContainer.appendChild(btn); + }); + } else { + choiceGroup.classList.add("hidden"); + textGroup.classList.remove("hidden"); + input.value = ""; + input.disabled = false; + document.getElementById("checkBtn").disabled = false; + input.focus(); + } + + const hintBtn = document.getElementById("hintBtn"); + if (hintBtn) hintBtn.disabled = false; + const hintBtnMC = document.getElementById("hintBtnMC"); + if (hintBtnMC) hintBtnMC.disabled = false; + updateProgress(); } @@ -121,17 +147,28 @@ function updateProgress() { document.getElementById("progressBar").style.width = percent + "%"; } -async function checkAnswer() { +function checkAnswer() { const input = document.getElementById("answerInput"); const answer = input.value.trim(); - if (!answer) { input.focus(); return; } + submitAnswer(answer); +} +async function submitAnswer(answer, clickedBtn = null) { const checkBtn = document.getElementById("checkBtn"); - checkBtn.disabled = true; + const input = document.getElementById("answerInput"); + if (checkBtn) checkBtn.disabled = true; + if (input) input.disabled = true; + + // Disable all choice buttons + const choiceBtns = document.querySelectorAll(".choice-btn"); + choiceBtns.forEach(b => b.disabled = true); + if (clickedBtn) { + clickedBtn.classList.add("choice-selected"); + } try { const res = await fetch("/api/game/check", { @@ -157,9 +194,12 @@ async function checkAnswer() { if (result.correct) { // Success! playSound('magic'); + if (clickedBtn) { + clickedBtn.classList.remove("choice-selected"); + clickedBtn.classList.add("choice-correct"); + } frageBox.classList.add("sparkle-effect"); feedbackBox.innerHTML = `✨ Richtig! 1 Hauspunkt für dein Haus! ✨`; - input.disabled = true; setTimeout(() => { frageBox.classList.remove("sparkle-effect"); @@ -175,6 +215,10 @@ async function checkAnswer() { } else { // Incorrect playSound('wrong'); + if (clickedBtn) { + clickedBtn.classList.remove("choice-selected"); + clickedBtn.classList.add("choice-wrong"); + } frageBox.classList.add("shake-effect"); setTimeout(() => frageBox.classList.remove("shake-effect"), 500); @@ -182,12 +226,11 @@ async function checkAnswer() { document.getElementById("attemptBadge").innerText = `Versuch ${Math.min(attemptsUsed + 1, maxAttempts)} von ${maxAttempts}`; if (result.attempts_left === 0) { - // Exhausted all 3 attempts + // Exhausted all attempts feedbackBox.innerHTML = `
Erwartete Antwort: {{ q.canonical_answer }}
+ {% if q.options %} +Auswahl-Optionen: {{ q.options }}
Tipp: {{ q.hint }}
+Tipp: {{ q.hint }}
{% endif %} @@ -93,10 +103,21 @@Antwort: {{ q.canonical_answer }}
+ {% if q.options %} +Optionen: {{ q.options }}
Tipp: {{ q.hint }}
+Tipp: {{ q.hint }}
{% endif %}