From cf89cd31c723df12fc34b93568291fc3df2e2687 Mon Sep 17 00:00:00 2001 From: kobold-ai Date: Mon, 21 Sep 2026 12:33:34 +0200 Subject: [PATCH] Feature: 40 bilingual questions, random 10 game run, difficulty levels, and full i18n support --- app/main.py | 113 +++++-- app/matcher.py | 6 +- app/models.py | 16 +- app/schemas.py | 7 + app/seed.py | 52 +-- app/seed_40.py | 609 +++++++++++++++++++++++++++++++++++ app/static/css/style.css | 127 ++++++++ app/static/js/quiz.js | 188 +++++++++-- app/static/js/vorschlag.js | 128 +++++++- app/templates/admin.html | 19 ++ app/templates/base.html | 69 +++- app/templates/finale.html | 87 ++++- app/templates/index.html | 37 ++- app/templates/vorschlag.html | 29 +- migrate_v3.py | 20 ++ 15 files changed, 1369 insertions(+), 138 deletions(-) create mode 100644 app/seed_40.py create mode 100644 migrate_v3.py diff --git a/app/main.py b/app/main.py index d5277da..38c8d49 100755 --- a/app/main.py +++ b/app/main.py @@ -16,7 +16,7 @@ from app.models import Question, QuizSession, AdminUser from app.matcher import is_answer_correct, levenshtein_distance, normalize_text from app.auth import verify_password from app.seed import seed_db -from app.schemas import GameCheckRequest, SuggestionCreateRequest +from app.schemas import GameCheckRequest, SuggestionCreateRequest, GameStartRequest load_dotenv() @@ -29,25 +29,33 @@ 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: +def format_question_for_client(q: Question, number: int, total: int, lang: str = "de") -> dict: options_list = None - if q.question_type == "multiple_choice" and q.options: + if lang == "en" and q.question_en: + q_text = q.question_en + opts_raw = q.options_en or q.options + else: + q_text = q.question + opts_raw = q.options + + if q.question_type == "multiple_choice" and opts_raw: try: - parsed = json.loads(q.options) + parsed = json.loads(opts_raw) 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()] + options_list = [opt.strip() for opt in opts_raw.split(",") if opt.strip()] random.shuffle(options_list) return { "question_number": number, "total_questions": total, "category": q.category, - "question": q.question, + "question": q_text, "question_type": q.question_type or "text", - "options": options_list + "options": options_list, + "difficulty": q.difficulty or "medium" } @app.on_event("startup") @@ -66,13 +74,26 @@ async def index(request: Request, db: Session = Depends(get_db)): ) @app.post("/api/game/start") -async def start_game(db: Session = Depends(get_db)): - # Fetch all active questions in order - questions = db.query(Question).filter(Question.status == "active").order_by(Question.sort_order.asc(), Question.id.asc()).all() - if not questions: - raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.") +async def start_game(payload: GameStartRequest = None, db: Session = Depends(get_db)): + lang = payload.lang if payload and payload.lang in ["de", "en"] else "de" + difficulty = payload.difficulty if payload and payload.difficulty in ["easy", "medium", "hard"] else "all" - question_ids = [q.id for q in questions] + # Fetch active questions matching difficulty (or all active) + query = db.query(Question).filter(Question.status == "active") + if difficulty != "all": + query = query.filter(Question.difficulty == difficulty) + + pool = query.all() + if not pool: + # Fallback to all active if filtered pool empty + pool = db.query(Question).filter(Question.status == "active").all() + if not pool: + raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.") + + # Randomly sample 10 questions (or all if fewer) + sample_size = min(10, len(pool)) + selected_questions = random.sample(pool, sample_size) + question_ids = [q.id for q in selected_questions] session_id = str(uuid.uuid4()) new_session = QuizSession( @@ -81,18 +102,22 @@ async def start_game(db: Session = Depends(get_db)): current_question_idx=0, question_ids=json.dumps(question_ids), attempts_current=0, - is_finished=False + is_finished=False, + lang=lang, + difficulty=difficulty ) db.add(new_session) db.commit() - first_q = questions[0] - res = format_question_for_client(first_q, 1, len(question_ids)) + first_q = selected_questions[0] + res = format_question_for_client(first_q, 1, len(question_ids), lang=lang) res.update({ "session_id": session_id, "score": 0, "attempts_used": 0, - "max_attempts": 3 + "max_attempts": 3, + "lang": lang, + "difficulty": difficulty }) return res @@ -102,6 +127,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) if not session or session.is_finished: raise HTTPException(status_code=400, detail="Ungültige oder beendete Spiel-Session.") + session_lang = session.lang or "de" question_ids = json.loads(session.question_ids) if session.current_question_idx >= len(question_ids): session.is_finished = True @@ -120,7 +146,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) if not current_q: raise HTTPException(status_code=500, detail="Frage nicht in Datenbank gefunden.") - correct = is_answer_correct(payload.answer, current_q.canonical_answer, current_q.accepted_answers) + correct = is_answer_correct(payload.answer, current_q.canonical_answer, current_q.accepted_answers, current_q.answer_en) if correct: session.score += 1 @@ -138,7 +164,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 = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids)) + next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids), lang=session_lang) return { "correct": True, @@ -154,8 +180,12 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)) attempts_left = max(0, 3 - session.attempts_current) if attempts_left == 0: - # 3 failed attempts: move to next question, reveal answer so player learns it - revealed = current_q.canonical_answer + # 3 failed attempts: move to next question, reveal answer + if session_lang == "en" and current_q.answer_en: + revealed = current_q.answer_en + else: + revealed = current_q.canonical_answer + session.current_question_idx += 1 session.attempts_current = 0 @@ -170,7 +200,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 = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids)) + next_q_data = format_question_for_client(next_q, session.current_question_idx + 1, len(question_ids), lang=session_lang) return { "correct": False, @@ -199,20 +229,29 @@ async def get_hint(session_id: str, db: Session = Depends(get_db)): if not session or session.is_finished: raise HTTPException(status_code=400, detail="Ungültige Session.") + session_lang = session.lang or "de" question_ids = json.loads(session.question_ids) if session.current_question_idx >= len(question_ids): - return {"hint": "Kein Hinweis verfügbar."} + return {"hint": "No hint available." if session_lang == "en" else "Kein Hinweis verfügbar."} current_q_id = question_ids[session.current_question_idx] current_q = db.query(Question).filter(Question.id == current_q_id).first() - return {"hint": current_q.hint if current_q and current_q.hint else "Kein Tipp verfügbar."} + if not current_q: + return {"hint": "No hint available." if session_lang == "en" else "Kein Hinweis verfügbar."} + + if session_lang == "en": + hint_text = current_q.hint_en if current_q.hint_en else (current_q.hint or "No hint available.") + else: + hint_text = current_q.hint or "Kein Tipp verfügbar." + + return {"hint": hint_text} @app.get("/finale", response_class=HTMLResponse) -async def finale(request: Request, score: int = 0, total: int = 15): +async def finale(request: Request, score: int = 0, total: int = 10, lang: str = "de"): return templates.TemplateResponse( request=request, name="finale.html", - context={"score": score, "total": total} + context={"score": score, "total": total, "lang": lang} ) @app.get("/vorschlag", response_class=HTMLResponse) @@ -238,6 +277,8 @@ async def submit_suggestion(payload: SuggestionCreateRequest, db: Session = Depe opts.append(opt.strip()) options_json = json.dumps(opts, ensure_ascii=False) + diff = payload.difficulty if payload.difficulty in ["easy", "medium", "hard"] else "medium" + new_suggestion = Question( category="community", question=payload.frage.strip(), @@ -245,6 +286,7 @@ async def submit_suggestion(payload: SuggestionCreateRequest, db: Session = Depe hint=payload.tipp.strip() if payload.tipp else None, question_type=q_type, options=options_json, + difficulty=diff, status="needs_review", # User Question Need Test / Review submitted_by=payload.name.strip() if payload.name else "Gast", sort_order=999 @@ -327,6 +369,10 @@ async def admin_add_question( hint: str = Form(None), category: str = Form("harry_potter"), question_type: str = Form("text"), + difficulty: str = Form("medium"), + question_en: str = Form(None), + answer_en: str = Form(None), + hint_en: str = Form(None), wrong_option_1: str = Form(None), wrong_option_2: str = Form(None), wrong_option_3: str = Form(None), @@ -344,6 +390,8 @@ async def admin_add_question( opts.append(w.strip()) options_json = json.dumps(opts, ensure_ascii=False) + diff = difficulty.strip() if difficulty and difficulty.strip() in ["easy", "medium", "hard"] else "medium" + new_q = Question( category=category.strip(), question=question.strip(), @@ -351,6 +399,10 @@ async def admin_add_question( hint=hint.strip() if hint else None, question_type=question_type.strip(), options=options_json, + difficulty=diff, + question_en=question_en.strip() if question_en else None, + answer_en=answer_en.strip() if answer_en else None, + hint_en=hint_en.strip() if hint_en else None, status="active", sort_order=sort_order, submitted_by=f"Admin ({request.session['admin_user']})" @@ -402,6 +454,10 @@ async def admin_edit_question( canonical_answer: str = Form(...), hint: str = Form(None), question_type: str = Form("text"), + difficulty: str = Form("medium"), + question_en: str = Form(None), + answer_en: str = Form(None), + hint_en: str = Form(None), options_raw: str = Form(None), status_val: str = Form("active"), sort_order: int = Form(0), @@ -418,6 +474,11 @@ async def admin_edit_question( q.canonical_answer = canonical_answer.strip() q.hint = hint.strip() if hint else None q.question_type = question_type.strip() + if difficulty and difficulty.strip() in ["easy", "medium", "hard"]: + q.difficulty = difficulty.strip() + q.question_en = question_en.strip() if question_en else None + q.answer_en = answer_en.strip() if answer_en else None + q.hint_en = hint_en.strip() if hint_en else None if options_raw and options_raw.strip(): val = options_raw.strip() diff --git a/app/matcher.py b/app/matcher.py index add8ddd..d2b9916 100755 --- a/app/matcher.py +++ b/app/matcher.py @@ -33,12 +33,16 @@ def levenshtein_distance(s1: str, s2: str) -> int: previous_row = current_row return previous_row[-1] -def is_answer_correct(user_input: str, canonical: str, accepted_raw: str | None = None) -> bool: +def is_answer_correct(user_input: str, canonical: str, accepted_raw: str | None = None, canonical_en: str | None = None) -> bool: clean_user = normalize_text(user_input) if not clean_user: return False targets = [normalize_text(canonical)] + if canonical_en: + clean_en = normalize_text(canonical_en) + if clean_en and clean_en not in targets: + targets.append(clean_en) if accepted_raw: try: diff --git a/app/models.py b/app/models.py index bc4899c..7c6f62e 100755 --- a/app/models.py +++ b/app/models.py @@ -7,12 +7,22 @@ class Question(Base): id = Column(Integer, primary_key=True, index=True, autoincrement=True) category = Column(String(50), default="harry_potter", index=True) + difficulty = Column(String(20), default="medium", index=True) # easy, medium, hard + question_type = Column(String(20), default="text") # 'text' or 'multiple_choice' + + # German content (default) 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) + + # English content + question_en = Column(Text, nullable=True) + answer_en = Column(String(255), nullable=True) + options_en = Column(Text, nullable=True) # JSON list of options in English + hint_en = Column(Text, nullable=True) + + accepted_answers = Column(Text, nullable=True) # JSON or comma-delimited status = Column(String(30), default="active", index=True) # active, needs_review, rejected, archived submitted_by = Column(String(100), nullable=True) sort_order = Column(Integer, default=0) @@ -29,6 +39,8 @@ class QuizSession(Base): question_ids = Column(Text, nullable=False) # JSON array string: "[1, 2, 3...]" attempts_current = Column(Integer, default=0) is_finished = Column(Boolean, default=False) + lang = Column(String(10), default="de") # 'de' or 'en' + difficulty = Column(String(20), default="all") # 'all', 'easy', 'medium', 'hard' created_at = Column(DateTime, default=datetime.datetime.utcnow) finished_at = Column(DateTime, nullable=True) diff --git a/app/schemas.py b/app/schemas.py index 1306545..b7ea9e8 100755 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,6 +1,10 @@ from pydantic import BaseModel from typing import Optional, List +class GameStartRequest(BaseModel): + lang: Optional[str] = "de" + difficulty: Optional[str] = "all" + class GameStartResponse(BaseModel): session_id: str question_number: int @@ -10,6 +14,8 @@ class GameStartResponse(BaseModel): score: int attempts_used: int max_attempts: int = 3 + difficulty: Optional[str] = "medium" + lang: Optional[str] = "de" class GameCheckRequest(BaseModel): session_id: str @@ -32,6 +38,7 @@ class SuggestionCreateRequest(BaseModel): name: Optional[str] = "Gast" question_type: Optional[str] = "text" falsche_antworten: Optional[List[str]] = None + difficulty: Optional[str] = "medium" class QuestionAdminCreate(BaseModel): category: str = "harry_potter" diff --git a/app/seed.py b/app/seed.py index 3a6e51e..371dce2 100755 --- a/app/seed.py +++ b/app/seed.py @@ -3,6 +3,7 @@ import json from app.database import engine, SessionLocal, Base from app.models import Question, AdminUser from app.auth import hash_password +from app.seed_40 import seed_40_questions def seed_db(): Base.metadata.create_all(bind=engine) @@ -22,52 +23,11 @@ def seed_db(): db.commit() print(f"Created default admin user: {admin_user}") - # 2. Existing Questions from legacy/fragen.json - legacy_questions_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "legacy", "fragen.json") - if os.path.exists(legacy_questions_path): - with open(legacy_questions_path, "r", encoding="utf-8") as f: - legacy_questions = json.load(f) - - count = 0 - for idx, q in enumerate(legacy_questions): - # Check if question text already in DB - existing = db.query(Question).filter(Question.question == q.get("frage", "").strip()).first() - if not existing: - db.add(Question( - category="harry_potter", - question=q.get("frage", "").strip(), - canonical_answer=q.get("antwort", "").strip(), - hint=q.get("tipp", "").strip(), - status="active", - sort_order=idx + 1, - submitted_by="Admin" - )) - count += 1 - db.commit() - print(f"Imported {count} active questions from legacy/fragen.json") - - # 3. Existing Suggestions from legacy/fragen_vorschlaege.json - legacy_sugg_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "legacy", "fragen_vorschlaege.json") - if os.path.exists(legacy_sugg_path): - with open(legacy_sugg_path, "r", encoding="utf-8") as f: - legacy_sugg = json.load(f) - - count_sugg = 0 - for q in legacy_sugg: - existing = db.query(Question).filter(Question.question == q.get("frage", "").strip()).first() - if not existing: - db.add(Question( - category="harry_potter", - question=q.get("frage", "").strip(), - canonical_answer=q.get("antwort", "").strip(), - hint=q.get("tipp", "").strip(), - status="needs_review", - sort_order=999, - submitted_by="Gast (Legacy Vorschlag)" - )) - count_sugg += 1 - db.commit() - print(f"Imported {count_sugg} pending suggestions from legacy/fragen_vorschlaege.json") + # 2. Seed 40 questions if database is empty + question_count = db.query(Question).count() + if question_count == 0: + print("Question table is empty, seeding 40 initial bilingual questions...") + seed_40_questions() finally: db.close() diff --git a/app/seed_40.py b/app/seed_40.py new file mode 100644 index 0000000..8180268 --- /dev/null +++ b/app/seed_40.py @@ -0,0 +1,609 @@ +import json +from app.database import engine, SessionLocal +from app.models import Question +from sqlalchemy import text + +QUESTIONS_DATA = [ + # --- EASY (1-14) --- + { + "category": "creatures", + "difficulty": "easy", + "question_type": "multiple_choice", + "question": "Wie heißt Harrys schneeweiße Eule?", + "canonical_answer": "Hedwig", + "options": ["Hedwig", "Errol", "Pigwidgeon", "Fawkes"], + "hint": "Harry bekam sie von Hagrid in der Winkelgasse geschenkt.", + "question_en": "What is the name of Harry's snowy pet owl?", + "answer_en": "Hedwig", + "options_en": ["Hedwig", "Errol", "Pigwidgeon", "Fawkes"], + "hint_en": "Harry received her as a gift from Hagrid in Diagon Alley." + }, + { + "category": "hogwarts", + "difficulty": "easy", + "question_type": "multiple_choice", + "question": "Welche Farben repräsentieren das Haus Gryffindor?", + "canonical_answer": "Rot und Gold", + "options": ["Rot und Gold", "Grün und Silber", "Blau und Bronze", "Gelb und Schwarz"], + "hint": "Sie erinnern an ein loderndes Löwenfeuer.", + "question_en": "Which colours represent the House of Gryffindor?", + "answer_en": "Red and Gold", + "options_en": ["Red and Gold", "Green and Silver", "Blue and Bronze", "Yellow and Black"], + "hint_en": "They evoke the fiery colours of a proud lion." + }, + { + "category": "hogwarts", + "difficulty": "easy", + "question_type": "text", + "question": "Von welchem Gleis am Bahnhof King's Cross fährt der Hogwarts-Express ab?", + "canonical_answer": "9 3/4", + "options": None, + "hint": "Eine Bruchzahl zwischen Bahnsteig 9 und 10.", + "question_en": "From which platform at King's Cross station does the Hogwarts Express depart?", + "answer_en": "9 3/4", + "options_en": None, + "hint_en": "A fraction between platforms 9 and 10." + }, + { + "category": "quidditch", + "difficulty": "easy", + "question_type": "text", + "question": "Wie viele Punkte bringt das Fangen des Goldenen Schnatzes im Quidditch?", + "canonical_answer": "150", + "options": None, + "hint": "Mehr als einhundert Punkte.", + "question_en": "How many points is catching the Golden Snitch worth in Quidditch?", + "answer_en": "150", + "options_en": None, + "hint_en": "More than one hundred points." + }, + { + "category": "hogwarts", + "difficulty": "easy", + "question_type": "text", + "question": "In wie viele verschiedene Häuser teilt der Sprechende Hut die Schüler ein?", + "canonical_answer": "4", + "options": None, + "hint": "Gryffindor, Hufflepuff, Ravenclaw und Slytherin.", + "question_en": "Into how many different houses does the Sorting Hat divide students?", + "answer_en": "4", + "options_en": None, + "hint_en": "Gryffindor, Hufflepuff, Ravenclaw, and Slytherin." + }, + { + "category": "spells", + "difficulty": "easy", + "question_type": "multiple_choice", + "question": "Mit welchem Zauberspruch beschwört man einen Patronus herauf?", + "canonical_answer": "Expecto Patronum", + "options": ["Expecto Patronum", "Expelliarmus", "Lumos Solem", "Avada Kedavra"], + "hint": "Er erfordert eine zutiefst glückliche Erinnerung.", + "question_en": "Which incantation conjures a Patronus guardian?", + "answer_en": "Expecto Patronum", + "options_en": ["Expecto Patronum", "Expelliarmus", "Lumos Solem", "Avada Kedavra"], + "hint_en": "It requires a powerfully happy memory." + }, + { + "category": "characters", + "difficulty": "easy", + "question_type": "text", + "question": "Wie lautet der zweite Vorname von Harry Potter?", + "canonical_answer": "James", + "options": None, + "hint": "Er trägt denselben Namen wie sein Vater.", + "question_en": "What is Harry Potter's middle name?", + "answer_en": "James", + "options_en": None, + "hint_en": "He shares this name with his father." + }, + { + "category": "lore", + "difficulty": "easy", + "question_type": "multiple_choice", + "question": "Welcher magische Kern befindet sich im Zauberstab von Harry Potter?", + "canonical_answer": "Phönixfeder", + "options": ["Phönixfeder", "Drachenherzfaser", "Einhornhaar", "Veela-Haar"], + "hint": "Die Feder stammt von Dumbledores Vogel Fawkes.", + "question_en": "What magical core is inside Harry Potter's wand?", + "answer_en": "Phoenix feather", + "options_en": ["Phoenix feather", "Dragon heartstring", "Unicorn hair", "Veela hair"], + "hint_en": "The feather was donated by Dumbledore's phoenix Fawkes." + }, + { + "category": "characters", + "difficulty": "easy", + "question_type": "text", + "question": "Wer war der weise Schulleiter von Hogwarts zu Harrys Schulzeit?", + "canonical_answer": "Albus Dumbledore", + "options": None, + "hint": "Er trägt eine Halbmondbrille und liebt Zitronenbrausebonbons.", + "question_en": "Who was the wise Headmaster of Hogwarts during most of Harry's schooling?", + "answer_en": "Albus Dumbledore", + "options_en": None, + "hint_en": "He wears half-moon spectacles and loves Lemon Drops." + }, + { + "category": "characters", + "difficulty": "easy", + "question_type": "text", + "question": "Wie heißt der Zwillingsbruder von Fred Weasley?", + "canonical_answer": "George", + "options": None, + "hint": "Gemeinsam eröffneten sie 'Weasleys Zauberhafte Zauberscherze'.", + "question_en": "What is the name of Fred Weasley's twin brother?", + "answer_en": "George", + "options_en": None, + "hint_en": "Together they opened 'Weasleys' Wizard Wheezes'." + }, + { + "category": "hogwarts", + "difficulty": "easy", + "question_type": "multiple_choice", + "question": "Welches Tier ist das Wappentier des Hauses Hufflepuff?", + "canonical_answer": "Dachs", + "options": ["Dachs", "Biber", "Eichhörnchen", "Otter"], + "hint": "Ein grabendes, treues und wehrhaftes Tier.", + "question_en": "Which animal is the emblem of House Hufflepuff?", + "answer_en": "Badger", + "options_en": ["Badger", "Beaver", "Squirrel", "Otter"], + "hint_en": "A loyal, hard-working, burrowing animal." + }, + { + "category": "spells", + "difficulty": "easy", + "question_type": "text", + "question": "Welcher Zauberspruch entwaffnet einen gegnerischen Zauberer?", + "canonical_answer": "Expelliarmus", + "options": None, + "hint": "Harrys Markenzeichen im Duell.", + "question_en": "Which spell is used to disarm an opponent?", + "answer_en": "Expelliarmus", + "options_en": None, + "hint_en": "Harry's trademark defensive spell in duels." + }, + { + "category": "characters", + "difficulty": "easy", + "question_type": "multiple_choice", + "question": "In welches Tier kann sich Sirius Black als Animagus verwandeln?", + "canonical_answer": "Schwarzer Hund", + "options": ["Schwarzer Hund", "Wolf", "Hirsch", "Ratte"], + "hint": "Sein Spitzname lautet 'Tatze' (Padfoot).", + "question_en": "Which animal does Sirius Black transform into as an Animagus?", + "answer_en": "Black Dog", + "options_en": ["Black Dog", "Wolf", "Stag", "Rat"], + "hint_en": "His Marauder nickname is 'Padfoot'." + }, + { + "category": "lore", + "difficulty": "easy", + "question_type": "text", + "question": "Wie viele Heiligtümer des Todes gibt es laut der Legende der drei Brüder?", + "canonical_answer": "3", + "options": None, + "hint": "Elderstab, Stein der Auferstehung und Tarnumhang.", + "question_en": "How many Deathly Hallows exist according to the Tale of the Three Brothers?", + "answer_en": "3", + "options_en": None, + "hint_en": "The Elder Wand, the Resurrection Stone, and the Invisibility Cloak." + }, + + # --- MEDIUM (15-30) --- + { + "category": "lore", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Wie heißt die beliebte Wirtin des Gasthauses 'Drei Besen' in Hogsmeade?", + "canonical_answer": "Madam Rosmerta", + "options": ["Madam Rosmerta", "Madam Malkin", "Madam Pince", "Madam Pomfrey"], + "hint": "Ron Weasley hat eine kleine Schwäche für sie.", + "question_en": "What is the name of the popular landlady of 'The Three Broomsticks' in Hogsmeade?", + "answer_en": "Madam Rosmerta", + "options_en": ["Madam Rosmerta", "Madam Malkin", "Madam Pince", "Madam Pomfrey"], + "hint_en": "Ron Weasley harbors quite a crush on her." + }, + { + "category": "creatures", + "difficulty": "medium", + "question_type": "text", + "question": "Welche riesige Schlange hauste tief unter Hogwarts in der Kammer des Schreckens?", + "canonical_answer": "Basilisk", + "options": None, + "hint": "Ihr Blick ist absolut tödlich.", + "question_en": "Which giant serpent dwelt deep beneath Hogwarts in the Chamber of Secrets?", + "answer_en": "Basilisk", + "options_en": None, + "hint_en": "Its direct stare causes instant death." + }, + { + "category": "creatures", + "difficulty": "medium", + "question_type": "text", + "question": "Wie heißt Albus Dumbledores treuer Phönix?", + "canonical_answer": "Fawkes", + "options": None, + "hint": "Seine Tränen besitzen wundersame Heilkräfte.", + "question_en": "What is the name of Albus Dumbledore's loyal pet phoenix?", + "answer_en": "Fawkes", + "options_en": None, + "hint_en": "His tears have miraculous healing powers." + }, + { + "category": "characters", + "difficulty": "medium", + "question_type": "text", + "question": "Wie heißt der mürrische alte Hauself der Familie Black am Grimmauldplatz 12?", + "canonical_answer": "Kreacher", + "options": None, + "hint": "Er verehrte Regulus Black über alles.", + "question_en": "What is the name of the grumpy house-elf serving the Black family at 12 Grimmauld Place?", + "answer_en": "Kreacher", + "options_en": None, + "hint_en": "He was fiercely loyal to Regulus Black." + }, + { + "category": "characters", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Welchen Spitznamen trug Remus Lupin als Schöpfer der Karte des Rumtreibers?", + "canonical_answer": "Moony", + "options": ["Moony", "Wurmschwanz", "Tatze", "Krone"], + "hint": "Eine Anspielung auf den Vollmond.", + "question_en": "What was Remus Lupin's Marauder nickname?", + "answer_en": "Moony", + "options_en": ["Moony", "Wormtail", "Padfoot", "Prongs"], + "hint_en": "A direct reference to the full moon." + }, + { + "category": "creatures", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Wie heißt Hermines krummbeiniger roter Kater mit Kniesel-Abstammung?", + "canonical_answer": "Krummbein", + "options": ["Krummbein", "Knut", "Knecht Ruprecht", "Klaue"], + "hint": "Auf Englisch heißt er Crookshanks.", + "question_en": "What is the name of Hermione's half-Kneazle ginger cat?", + "answer_en": "Crookshanks", + "options_en": ["Crookshanks", "Krummbein", "Claw", "Rusty"], + "hint_en": "He immediately recognized Scabbers as an imposter." + }, + { + "category": "quidditch", + "difficulty": "medium", + "question_type": "text", + "question": "Wie viele Bälle sind insgesamt gleichzeitig bei einem regulären Quidditch-Spiel im Einsatz?", + "canonical_answer": "4", + "options": None, + "hint": "Ein Quaffel, zwei Klatscher und ein Schnatz.", + "question_en": "How many total balls are in play during a regular Quidditch match?", + "answer_en": "4", + "options_en": None, + "hint_en": "One Quaffle, two Bludgers, and one Golden Snitch." + }, + { + "category": "spells", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Mit welchem Spruch wird ein gestaltwandelnder Irrwicht unschädlich gemacht?", + "canonical_answer": "Riddikulus", + "options": ["Riddikulus", "Reducto", "Rictusempra", "Relashio"], + "hint": "Man muss sich etwas Lächerliches und Heiteres vorstellen.", + "question_en": "Which charm dispels a shape-shifting Boggart by turning it comical?", + "answer_en": "Riddikulus", + "options_en": ["Riddikulus", "Reducto", "Rictusempra", "Relashio"], + "hint_en": "It requires turning the creature into something funny." + }, + { + "category": "spells", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Welcher Zauberspruch lässt Gegenstände in die Luft schweben?", + "canonical_answer": "Wingardium Leviosa", + "options": ["Wingardium Leviosa", "Accio", "Alohomora", "Avis"], + "hint": "'Es heißt Leviooooosa, nicht Leviosaaaa!'", + "question_en": "Which levitation charm makes objects fly into the air?", + "answer_en": "Wingardium Leviosa", + "options_en": ["Wingardium Leviosa", "Accio", "Alohomora", "Avis"], + "hint_en": "'It's Levi-ooo-sa, not Levio-saaa!' - Hermione Granger." + }, + { + "category": "creatures", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Gegen welche gefährliche Drachenart musste Harry in der ersten Aufgabe des Trimagischen Turniers antreten?", + "canonical_answer": "Ungarischer Hornschwanz", + "options": ["Ungarischer Hornschwanz", "Schwedischer Kurzschnäuzler", "Gemeiner Walisischer Grünling", "Chinesischer Feuerball"], + "hint": "Der gefährlichste und bösartigste aller vier Drachen.", + "question_en": "Which fierce dragon breed did Harry face in the First Task of the Triwizard Tournament?", + "answer_en": "Hungarian Horntail", + "options_en": ["Hungarian Horntail", "Swedish Short-Snout", "Common Welsh Green", "Chinese Fireball"], + "hint_en": "Considered the most vicious and dangerous dragon of all four." + }, + { + "category": "hogwarts", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Welches Tier ziert das offizielle Hauswappen von Ravenclaw?", + "canonical_answer": "Adler", + "options": ["Adler", "Rabe", "Falke", "Eule"], + "hint": "Entgegen dem Namensbestandteil 'Raven' ist es kein Rabe!", + "question_en": "Which bird is the official emblem of House Ravenclaw in the books?", + "answer_en": "Eagle", + "options_en": ["Eagle", "Raven", "Falcon", "Owl"], + "hint_en": "Despite the house name, it is definitely not a raven!" + }, + { + "category": "hogwarts", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Wie heißt der geheimnisvolle Raum im 7. Stock, der nur erscheint, wenn man ihn wirklich braucht?", + "canonical_answer": "Raum der Wünsche", + "options": ["Raum der Wünsche", "Raum der Rätsel", "Spiegelkammer", "Halle der Prophezeiungen"], + "hint": "Dumbledores Armee trainierte hier heimlich.", + "question_en": "What is the name of the magical 7th-floor room that only appears when someone is in real need of it?", + "answer_en": "Room of Requirement", + "options_en": ["Room of Requirement", "Chamber of Riddles", "Hall of Prophecy", "Mirror Room"], + "hint_en": "Dumbledore's Army used it as their secret training ground." + }, + { + "category": "characters", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Welche Gestalt nahm der Patronus von Severus Snape an?", + "canonical_answer": "Silberne Hirschkuh", + "options": ["Silberne Hirschkuh", "Großer Hirsch", "Schlange", "Fledermaus"], + "hint": "'Nach all dieser Zeit?' – 'Immer.'", + "question_en": "What shape did Severus Snape's Patronus take?", + "answer_en": "Silver Doe", + "options_en": ["Silver Doe", "Stag", "Serpent", "Bat"], + "hint_en": "'After all this time?' – 'Always.'" + }, + { + "category": "lore", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Welcher Horkrux von Lord Voldemort wurde als allererstes von Harry Potter zerstört?", + "canonical_answer": "Tom Riddles Tagebuch", + "options": ["Tom Riddles Tagebuch", "Medaillon von Slytherin", "Ring der Gaunts", "Diadem von Ravenclaw"], + "hint": "In der Kammer des Schreckens mit einem Basiliskenzahn.", + "question_en": "Which of Voldemort's Horcruxes was the very first to be destroyed?", + "answer_en": "Tom Riddle's Diary", + "options_en": ["Tom Riddle's Diary", "Slytherin's Locket", "Gaunt's Ring", "Ravenclaw's Diadem"], + "hint_en": "Destroyed in the Chamber of Secrets with a basilisk fang." + }, + { + "category": "characters", + "difficulty": "medium", + "question_type": "multiple_choice", + "question": "Wie lautet der vollständige Vorname des weltberühmten Zauberstabmachers Ollivander?", + "canonical_answer": "Garrick", + "options": ["Garrick", "Gerbold", "Gervase", "Gellert"], + "hint": "Sein Geschäft in der Winkelgasse besteht seit 382 v. Chr.", + "question_en": "What is the first name of the master wandmaker Mr. Ollivander?", + "answer_en": "Garrick", + "options_en": ["Garrick", "Gerbold", "Gervase", "Gellert"], + "hint_en": "Makers of fine wands since 382 B.C." + }, + { + "category": "characters", + "difficulty": "medium", + "question_type": "text", + "question": "Wie lautet der echte Vorname von Auror 'Mad-Eye' Moody?", + "canonical_answer": "Alastor", + "options": None, + "hint": "Beginnt mit dem Buchstaben A.", + "question_en": "What is the actual first name of Auror 'Mad-Eye' Moody?", + "answer_en": "Alastor", + "options_en": None, + "hint_en": "Starts with the letter A." + }, + + # --- HARD (31-40) --- + { + "category": "characters", + "difficulty": "hard", + "question_type": "multiple_choice", + "question": "Wer war der Schulleiter von Hogwarts unmittelbar vor Albus Dumbledore?", + "canonical_answer": "Armando Dippet", + "options": ["Armando Dippet", "Phineas Nigellus Black", "Dexter Fortescue", "Dilys Derwent"], + "hint": "Er war Schulleiter, als Tom Riddle 1943 die Kammer des Schreckens öffnete.", + "question_en": "Who served as Headmaster of Hogwarts immediately before Albus Dumbledore?", + "answer_en": "Armando Dippet", + "options_en": ["Armando Dippet", "Phineas Nigellus Black", "Dexter Fortescue", "Dilys Derwent"], + "hint_en": "He was in charge when Tom Riddle opened the Chamber of Secrets in 1943." + }, + { + "category": "characters", + "difficulty": "hard", + "question_type": "text", + "question": "Wie hieß die jüngere Schwester von Albus und Aberforth Dumbledore, die tragisch verstarb?", + "canonical_answer": "Ariana", + "options": None, + "hint": "Ihr unkontrollierter Zauberausbruch tötete einst ihre Mutter Kendra.", + "question_en": "What was the name of Albus and Aberforth Dumbledore's tragic younger sister?", + "answer_en": "Ariana", + "options_en": None, + "hint_en": "She was traumatized as a child and died during a three-way duel." + }, + { + "category": "characters", + "difficulty": "hard", + "question_type": "text", + "question": "Wie hieß die Mutter von Tom Marvolo Riddle (Lord Voldemort) mit Vornamen?", + "canonical_answer": "Merope", + "options": None, + "hint": "Merope Gaunt, eine Nachfahrin von Salazar Slytherin.", + "question_en": "What was the first name of Tom Riddle's (Lord Voldemort's) pure-blood mother?", + "answer_en": "Merope", + "options_en": None, + "hint_en": "Merope Gaunt, direct descendant of Salazar Slytherin." + }, + { + "category": "lore", + "difficulty": "hard", + "question_type": "multiple_choice", + "question": "In welcher exakten Reihenfolge werden die vier Schöpfer auf der Karte des Rumtreibers genannt?", + "canonical_answer": "Moony, Wurmschwanz, Tatze und Krone", + "options": [ + "Moony, Wurmschwanz, Tatze und Krone", + "Krone, Tatze, Wurmschwanz und Moony", + "Moony, Tatze, Wurmschwanz und Krone", + "Tatze, Moony, Wurmschwanz und Krone" + ], + "hint": "Moony steht ganz am Anfang.", + "question_en": "In what exact order are the four Marauders listed on the Marauder's Map?", + "answer_en": "Moony, Wormtail, Padfoot, and Prongs", + "options_en": [ + "Moony, Wormtail, Padfoot, and Prongs", + "Prongs, Padfoot, Wormtail, and Moony", + "Moony, Padfoot, Wormtail, and Prongs", + "Padfoot, Moony, Wormtail, and Prongs" + ], + "hint_en": "Moony is listed first, reflecting the reverse order of their deaths." + }, + { + "category": "lore", + "difficulty": "hard", + "question_type": "multiple_choice", + "question": "Wie lange muss der Vielsafttrank laut Rezeptur mindestens im Kessel köcheln?", + "canonical_answer": "Einen Monat", + "options": ["Einen Monat", "Zwei Wochen", "Sieben Tage", "Drei Monate"], + "hint": "Unter anderem müssen Florfliegen 21 Tage lang gedünstet werden.", + "question_en": "How long must the complex Polyjuice Potion simmer before it is ready to drink?", + "answer_en": "One month", + "options_en": ["One month", "Two weeks", "Seven days", "Three months"], + "hint_en": "Lacewing flies alone must be stewed for 21 days." + }, + { + "category": "spells", + "difficulty": "hard", + "question_type": "text", + "question": "Mit welchem lateinischstämmigen Zauber versiegelt man Türen gegen unbefugtes Betreten?", + "canonical_answer": "Colloportus", + "options": None, + "hint": "Erzeugt ein eigenartiges matschiges Sauggeräusch beim Schließen.", + "question_en": "Which locking spell seals doors securely, making an odd squelching noise?", + "answer_en": "Colloportus", + "options_en": None, + "hint_en": "Frequently used by Hermione in the Department of Mysteries." + }, + { + "category": "characters", + "difficulty": "hard", + "question_type": "text", + "question": "Wie lautet der Nachname der strengen Bibliothekarin der verbotenen Abteilung in Hogwarts?", + "canonical_answer": "Madam Pince", + "options": None, + "hint": "Ihr Vorname ist Irma, ihr Nachname klingt wie 'zwicken' auf Englisch.", + "question_en": "What is the surname of Hogwarts' notoriously strict head librarian?", + "answer_en": "Madam Pince", + "options_en": None, + "hint_en": "Her first name is Irma; she guards the Restricted Section fiercely." + }, + { + "category": "lore", + "difficulty": "hard", + "question_type": "multiple_choice", + "question": "Welcher Blutstatus trifft biologisch auf Lord Voldemort zu?", + "canonical_answer": "Halbblut", + "options": ["Halbblut", "Reinblut", "Muggelgeboren", "Squib"], + "hint": "Sein Vater Tom Riddle Senior war ein gewöhnlicher Muggel.", + "question_en": "What was Lord Voldemort's actual biological blood status?", + "answer_en": "Half-blood", + "options_en": ["Half-blood", "Pure-blood", "Muggle-born", "Squib"], + "hint_en": "His father Tom Riddle Sr. was an ordinary Muggle." + }, + { + "category": "quidditch", + "difficulty": "hard", + "question_type": "multiple_choice", + "question": "Auf welcher Position spielte Harrys Vater James Potter im Gryffindor-Quidditch-Team?", + "canonical_answer": "Jäger", + "options": ["Jäger", "Sucher", "Treiber", "Hüter"], + "hint": "Obwohl er im Film mit einem Schnatz spielte, war er in den Büchern Jäger (Chaser).", + "question_en": "Which position did Harry's father James Potter play on the Gryffindor Quidditch team in the books?", + "answer_en": "Chaser", + "options_en": ["Chaser", "Seeker", "Beater", "Keeper"], + "hint_en": "Though often seen fiddling with a Snitch, J.K. Rowling confirmed he was a Chaser." + }, + { + "category": "lore", + "difficulty": "hard", + "question_type": "multiple_choice", + "question": "Was bedeutet der rückwärts gelesene Spruch auf dem Spiegel Nerhegeb ('Erised stra ehru oyt ube cafru oyt on wohsi')?", + "canonical_answer": "Ich zeige nicht dein Gesicht, sondern deines Herzens Verlangen", + "options": [ + "Ich zeige nicht dein Gesicht, sondern deines Herzens Verlangen", + "Nur wer die Wahrheit sucht, wird seine Zukunft sehen", + "Der Blick in die Vergangenheit heilt keine Wunden", + "Wer mich erblickt, verliert den Verstand an seine Träume" + ], + "hint": "'Nerhegeb' rückwärts gelesen ergibt 'Begehren'.", + "question_en": "What does the reversed inscription on the Mirror of Erised mean?", + "answer_en": "I show not your face but your heart's desire", + "options_en": [ + "I show not your face but your heart's desire", + "Only the pure of heart may see their future here", + "Look not into the past lest you lose your present", + "He who gazes within shall drown in their own dreams" + ], + "hint_en": "'Erised' backwards spells 'Desire'." + } +] + +def seed_40(): + db = SessionLocal() + try: + # 1. Clear all existing questions as requested + print("Clearing existing questions...") + db.query(Question).delete() + db.commit() + + # 2. Insert the 40 new rich bilingual questions + print(f"Inserting {len(QUESTIONS_DATA)} new bilingual questions...") + for idx, q_data in enumerate(QUESTIONS_DATA, 1): + options_json = json.dumps(q_data["options"], ensure_ascii=False) if q_data["options"] else None + options_en_json = json.dumps(q_data["options_en"], ensure_ascii=False) if q_data["options_en"] else None + + # Accepted answers list includes both German and English answers + accepted = [q_data["canonical_answer"], q_data["answer_en"]] + # Also common variations + if q_data["canonical_answer"] == "9 3/4": + accepted.extend(["9 3/4", "9 3/4", "9,75", "neun dreiviertel", "nine and three quarters"]) + elif q_data["canonical_answer"] == "Albus Dumbledore": + accepted.append("Dumbledore") + elif q_data["canonical_answer"] == "Tom Riddles Tagebuch": + accepted.extend(["Tagebuch", "Diary", "Riddles Tagebuch"]) + + accepted_json = json.dumps(list(set(accepted)), ensure_ascii=False) + + new_q = Question( + id=idx, + category=q_data["category"], + difficulty=q_data["difficulty"], + question_type=q_data["question_type"], + question=q_data["question"], + canonical_answer=q_data["canonical_answer"], + options=options_json, + hint=q_data["hint"], + question_en=q_data["question_en"], + answer_en=q_data["answer_en"], + options_en=options_en_json, + hint_en=q_data["hint_en"], + accepted_answers=accepted_json, + status="active", + sort_order=idx, + submitted_by="Zaubereiministerium" + ) + db.add(new_q) + + db.commit() + print("Successfully seeded 40 bilingual questions across Easy, Medium, and Hard!") + + finally: + db.close() + +seed_40_questions = seed_40 + +if __name__ == "__main__": + seed_40() diff --git a/app/static/css/style.css b/app/static/css/style.css index 2832e94..f29ff20 100755 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -83,6 +83,41 @@ body { gap: 10px; } +.lang-switch-group { + display: inline-flex; + background: rgba(20, 22, 40, 0.85); + border: 1px solid var(--border-gold); + border-radius: 20px; + padding: 2px; + overflow: hidden; +} + +.lang-btn { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 600; + padding: 4px 9px; + border-radius: 16px; + cursor: pointer; + transition: all 0.2s ease; + min-height: 28px; + display: flex; + align-items: center; + gap: 4px; +} + +.lang-btn:hover { + color: var(--gold); +} + +.lang-btn.active { + background: var(--gold); + color: #0b0b16; + box-shadow: 0 0 8px rgba(201, 168, 106, 0.4); +} + .audio-btn { background: rgba(201, 168, 106, 0.15); border: 1px solid var(--gold); @@ -302,6 +337,57 @@ body { color: var(--text-muted); } +.difficulty-selection { + margin: 18px 0 22px; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.difficulty-label { + font-size: 0.95rem; + color: var(--gold-soft); + font-weight: 600; +} + +.difficulty-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} + +.diff-chip { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-gold); + color: var(--text-muted); + border-radius: 20px; + padding: 8px 16px; + font-size: 0.88rem; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.2s ease; + font-family: inherit; +} + +.diff-chip:hover { + border-color: var(--gold); + color: var(--text-main); + transform: translateY(-1px); + background: rgba(201, 168, 106, 0.12); +} + +.diff-chip.active { + background: rgba(201, 168, 106, 0.25); + border-color: var(--gold); + color: var(--gold-bright); + box-shadow: 0 0 10px rgba(201, 168, 106, 0.35); + font-weight: bold; +} + /* ========================================= BUTTONS ========================================= */ @@ -446,6 +532,13 @@ body { gap: 8px; } +.meta-left { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .badge { padding: 4px 10px; border-radius: 12px; @@ -457,6 +550,30 @@ body { white-space: nowrap; } +.badge-diff { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.2); + color: var(--text-main); +} + +.badge-diff-easy { + background: rgba(46, 204, 113, 0.15); + border-color: #2ecc71; + color: #2ecc71; +} + +.badge-diff-medium { + background: rgba(243, 156, 18, 0.15); + border-color: #f39c12; + color: #f39c12; +} + +.badge-diff-hard { + background: rgba(231, 76, 60, 0.15); + border-color: #e74c3c; + color: #e74c3c; +} + .badge-warning { background: rgba(243, 156, 18, 0.2); border-color: var(--warning); @@ -1055,4 +1172,14 @@ body { .admin-top-actions { justify-content: center; } + + .lang-btn { + padding: 4px 6px; + font-size: 0.75rem; + } + + .diff-chip { + padding: 6px 10px; + font-size: 0.8rem; + } } diff --git a/app/static/js/quiz.js b/app/static/js/quiz.js index a23fe3f..6c38a7e 100755 --- a/app/static/js/quiz.js +++ b/app/static/js/quiz.js @@ -1,9 +1,72 @@ let currentSessionId = null; let currentQuestionNumber = 1; -let totalQuestions = 15; +let totalQuestions = 10; let currentScore = 0; let attemptsUsed = 0; const maxAttempts = 3; +let selectedDifficulty = "all"; +let lastQuestionData = null; + +const UI_TEXT = { + de: { + pageTitle: "✨ Magisches Hogwarts-Escape-Quiz ✨", + pageSubtitle: "10 knifflige Rätsel aus der Zauberwelt warten auf dich", + envelopeLetter: "Hogwarts-Schule für Hexerei und Zauberei", + introDesc: "Ein verzauberter Brief aus Hogwarts schwebt vor dir in der Luft.
Bist du bereit, die Prüfungen abzulegen und deine Hauspunkte zu sichern?", + diffLabel: "Schwierigkeitsgrad wählen:", + diffAll: "Alle gemischt", + diffEasy: "Leicht", + diffMed: "Mittel", + diffHard: "Schwer", + startBtn: "📜 Siegel brechen & Brief öffnen", + startBtnCasting: "✨ Zauber wird gewirkt...", + pointsLabel: "🏆 Hauspunkte", + questionLabel: "Frage", + ofLabel: "von", + attemptLabel: "Versuch", + checkBtn: "✨ Zauber prüfen", + hintBtn: "💡 Zaubertipp", + hintHeading: "📜 Notiz aus der verbotenen Abteilung:", + placeholder: "Deine Antwort hier eingeben...", + noHint: "Kein Tipp vorhanden.", + hintFailed: "Tipp konnte nicht geladen werden.", + correctFeedback: "✨ Richtig! 1 Hauspunkt für dein Haus! ✨", + wrongFeedback: "Leider falsch! Noch {attempts} Versuch(e).", + exhaustedWrong: "Leider nicht richtig (3 Fehlversuche).", + solutionLabel: "Richtige Lösung:", + startError: "Fehler beim Starten des Quiz: ", + networkError: "Netzwerkfehler: " + }, + en: { + pageTitle: "✨ Magical Hogwarts Escape Quiz ✨", + pageSubtitle: "10 tricky wizarding world puzzles await you", + envelopeLetter: "Hogwarts School of Witchcraft and Wizardry", + introDesc: "An enchanted letter from Hogwarts is floating in the air before you.
Are you ready to take the exams and secure House Points?", + diffLabel: "Choose difficulty level:", + diffAll: "All mixed", + diffEasy: "Easy", + diffMed: "Medium", + diffHard: "Hard", + startBtn: "📜 Break Seal & Open Letter", + startBtnCasting: "✨ Casting spell...", + pointsLabel: "🏆 House Points", + questionLabel: "Question", + ofLabel: "of", + attemptLabel: "Attempt", + checkBtn: "✨ Check Spell", + hintBtn: "💡 Magic Hint", + hintHeading: "📜 Note from the Restricted Section:", + placeholder: "Type your answer here...", + noHint: "No hint available.", + hintFailed: "Could not load hint.", + correctFeedback: "✨ Correct! 1 House Point for your House! ✨", + wrongFeedback: "Incorrect! {attempts} attempt(s) remaining.", + exhaustedWrong: "Unfortunately incorrect (3 failed attempts).", + solutionLabel: "Correct solution:", + startError: "Error starting quiz: ", + networkError: "Network error: " + } +}; // Web Audio API Sound Generator for magic chimes and buzzers const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); @@ -43,12 +106,63 @@ function playSound(type) { } } +function selectDifficulty(diff) { + selectedDifficulty = diff; + document.querySelectorAll(".diff-chip").forEach(chip => { + chip.classList.toggle("active", chip.getAttribute("data-diff") === diff); + }); +} + +function onLanguageChange(lang) { + applyQuizTranslations(lang); +} + +function applyQuizTranslations(lang) { + const t = UI_TEXT[lang] || UI_TEXT.de; + const pageTitle = document.getElementById("pageTitle"); + const pageSubtitle = document.getElementById("pageSubtitle"); + const envelopeLetter = document.getElementById("envelopeLetter"); + const introDesc = document.getElementById("introDesc"); + const diffLabel = document.getElementById("diffLabel"); + const diffAllText = document.getElementById("diffAllText"); + const diffEasyText = document.getElementById("diffEasyText"); + const diffMedText = document.getElementById("diffMedText"); + const diffHardText = document.getElementById("diffHardText"); + const startBtn = document.getElementById("startBtn"); + const checkBtn = document.getElementById("checkBtn"); + const hintBtn = document.getElementById("hintBtn"); + const hintBtnMC = document.getElementById("hintBtnMC"); + const hintHeading = document.getElementById("hintHeading"); + const answerInput = document.getElementById("answerInput"); + + if (pageTitle) pageTitle.innerText = t.pageTitle; + if (pageSubtitle) pageSubtitle.innerText = t.pageSubtitle; + if (envelopeLetter) envelopeLetter.innerText = t.envelopeLetter; + if (introDesc) introDesc.innerHTML = t.introDesc; + if (diffLabel) diffLabel.innerText = t.diffLabel; + if (diffAllText) diffAllText.innerText = t.diffAll; + if (diffEasyText) diffEasyText.innerText = t.diffEasy; + if (diffMedText) diffMedText.innerText = t.diffMed; + if (diffHardText) diffHardText.innerText = t.diffHard; + if (startBtn && !startBtn.disabled) startBtn.innerText = t.startBtn; + if (checkBtn) checkBtn.innerText = t.checkBtn; + if (hintBtn) hintBtn.innerText = t.hintBtn; + if (hintBtnMC) hintBtnMC.innerText = t.hintBtn; + if (hintHeading) hintHeading.innerText = t.hintHeading; + if (answerInput) answerInput.placeholder = t.placeholder; + + if (lastQuestionData) { + renderQuestion(lastQuestionData); + } +} + async function startGame() { const envelope = document.getElementById("envelope"); const startBtn = document.getElementById("startBtn"); + const t = UI_TEXT[currentLang] || UI_TEXT.de; startBtn.disabled = true; - startBtn.innerText = "✨ Zauber wird gewirkt..."; + startBtn.innerText = t.startBtnCasting; envelope.classList.add("envelope-opened"); // Start background music if enabled or first click @@ -59,7 +173,7 @@ async function startGame() { const text = document.getElementById("audioText"); if (icon && text) { icon.innerText = "🔊"; - text.innerText = "Musik: An"; + text.innerText = (currentLang === "de") ? "Musik: An" : "Music: On"; } localStorage.setItem("bgm_enabled", "true"); }).catch(() => {}); @@ -68,11 +182,15 @@ async function startGame() { try { const res = await fetch("/api/game/start", { method: "POST", - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + lang: currentLang, + difficulty: selectedDifficulty + }) }); if (!res.ok) { - throw new Error("Konnte Spiel nicht starten."); + throw new Error(currentLang === "en" ? "Could not start game session." : "Konnte Spiel nicht starten."); } const data = await res.json(); @@ -89,18 +207,39 @@ async function startGame() { }, 1100); } catch (err) { - alert("Fehler beim Starten des Quiz: " + err.message); + alert(t.startError + err.message); startBtn.disabled = false; - startBtn.innerText = "📜 Siegel brechen & Brief öffnen"; + startBtn.innerText = t.startBtn; envelope.classList.remove("envelope-opened"); } } function renderQuestion(qData) { - document.getElementById("questionCounter").innerText = `Frage ${qData.question_number} von ${totalQuestions}`; - document.getElementById("pointsCounter").innerText = `🏆 Hauspunkte: ${currentScore}`; + lastQuestionData = qData; + const t = UI_TEXT[currentLang] || UI_TEXT.de; + + document.getElementById("questionCounter").innerText = `${t.questionLabel} ${qData.question_number} ${t.ofLabel} ${totalQuestions}`; + document.getElementById("pointsCounter").innerText = `${t.pointsLabel}: ${currentScore}`; document.getElementById("badgeCategory").innerText = `⚡ ${qData.category.replace("_", " ").toUpperCase()}`; - document.getElementById("attemptBadge").innerText = `Versuch ${attemptsUsed + 1} von ${maxAttempts}`; + + // Difficulty badge + const badgeDiff = document.getElementById("badgeDifficulty"); + if (badgeDiff) { + const diff = qData.difficulty || "medium"; + badgeDiff.className = `badge badge-diff badge-diff-${diff}`; + let diffText = t.diffMed; + let diffIcon = "🟡"; + if (diff === "easy") { + diffText = t.diffEasy; + diffIcon = "🟢"; + } else if (diff === "hard") { + diffText = t.diffHard; + diffIcon = "🔴"; + } + badgeDiff.innerText = `${diffIcon} ${diffText}`; + } + + document.getElementById("attemptBadge").innerText = `${t.attemptLabel} ${attemptsUsed + 1} ${t.ofLabel} ${maxAttempts}`; document.getElementById("frageText").innerText = qData.question; const textGroup = document.getElementById("textAnswerGroup"); @@ -129,6 +268,7 @@ function renderQuestion(qData) { choiceGroup.classList.add("hidden"); textGroup.classList.remove("hidden"); input.value = ""; + input.placeholder = t.placeholder; input.disabled = false; document.getElementById("checkBtn").disabled = false; input.focus(); @@ -160,6 +300,8 @@ function checkAnswer() { async function submitAnswer(answer, clickedBtn = null) { const checkBtn = document.getElementById("checkBtn"); const input = document.getElementById("answerInput"); + const t = UI_TEXT[currentLang] || UI_TEXT.de; + if (checkBtn) checkBtn.disabled = true; if (input) input.disabled = true; @@ -181,12 +323,12 @@ async function submitAnswer(answer, clickedBtn = null) { }); if (!res.ok) { - throw new Error("Fehler bei der Antwortprüfung."); + throw new Error(currentLang === "en" ? "Answer check failed." : "Fehler bei der Antwortprüfung."); } const result = await res.json(); currentScore = result.score; - document.getElementById("pointsCounter").innerText = `🏆 Hauspunkte: ${currentScore}`; + document.getElementById("pointsCounter").innerText = `${t.pointsLabel}: ${currentScore}`; const frageBox = document.getElementById("frageBox"); const feedbackBox = document.getElementById("feedbackBox"); @@ -199,7 +341,7 @@ async function submitAnswer(answer, clickedBtn = null) { clickedBtn.classList.add("choice-correct"); } frageBox.classList.add("sparkle-effect"); - feedbackBox.innerHTML = `✨ Richtig! 1 Hauspunkt für dein Haus! ✨`; + feedbackBox.innerHTML = `${t.correctFeedback}`; setTimeout(() => { frageBox.classList.remove("sparkle-effect"); @@ -223,13 +365,13 @@ async function submitAnswer(answer, clickedBtn = null) { setTimeout(() => frageBox.classList.remove("shake-effect"), 500); attemptsUsed = result.attempts_used; - document.getElementById("attemptBadge").innerText = `Versuch ${Math.min(attemptsUsed + 1, maxAttempts)} von ${maxAttempts}`; + document.getElementById("attemptBadge").innerText = `${t.attemptLabel} ${Math.min(attemptsUsed + 1, maxAttempts)} ${t.ofLabel} ${maxAttempts}`; if (result.attempts_left === 0) { // Exhausted all attempts feedbackBox.innerHTML = ` -
Leider nicht richtig (3 Fehlversuche).
-
Richtige Lösung: ${result.revealed_answer}
+
${t.exhaustedWrong}
+
${t.solutionLabel} ${result.revealed_answer}
`; setTimeout(() => { @@ -244,7 +386,8 @@ async function submitAnswer(answer, clickedBtn = null) { } else { // Still attempts remaining - feedbackBox.innerHTML = `Leider falsch! Noch ${result.attempts_left} Versuch(e).`; + const msg = t.wrongFeedback.replace("{attempts}", result.attempts_left); + feedbackBox.innerHTML = `${msg}`; if (checkBtn) checkBtn.disabled = false; if (input) { input.disabled = false; @@ -256,7 +399,7 @@ async function submitAnswer(answer, clickedBtn = null) { } } catch (err) { - alert("Netzwerkfehler: " + err.message); + alert(t.networkError + err.message); if (checkBtn) checkBtn.disabled = false; if (input) input.disabled = false; choiceBtns.forEach(b => b.disabled = false); @@ -267,22 +410,22 @@ async function requestHint() { if (!currentSessionId) return; const hintBox = document.getElementById("hintBox"); const hintText = document.getElementById("hintText"); + const t = UI_TEXT[currentLang] || UI_TEXT.de; try { const res = await fetch(`/api/game/hint?session_id=${encodeURIComponent(currentSessionId)}`); const data = await res.json(); - hintText.innerText = data.hint || "Kein Tipp vorhanden."; + hintText.innerText = data.hint || t.noHint; hintBox.classList.remove("hidden"); } catch (err) { - hintText.innerText = "Tipp konnte nicht geladen werden."; + hintText.innerText = t.hintFailed; hintBox.classList.remove("hidden"); } } function finishGame() { - // 100% progress document.getElementById("progressBar").style.width = "100%"; - window.location.href = `/finale?score=${currentScore}&total=${totalQuestions}`; + window.location.href = `/finale?score=${currentScore}&total=${totalQuestions}&lang=${currentLang}`; } // Enter key to submit @@ -296,4 +439,5 @@ document.addEventListener("DOMContentLoaded", () => { } }); } + applyQuizTranslations(currentLang); }); diff --git a/app/static/js/vorschlag.js b/app/static/js/vorschlag.js index e8ab344..5c5f26e 100755 --- a/app/static/js/vorschlag.js +++ b/app/static/js/vorschlag.js @@ -1,32 +1,136 @@ +const SUGGESTION_TEXT = { + de: { + title: "✨ Neue Frage für das Quiz vorschlagen ✨", + subtitle: "Bereichere das magische Quiz mit kniffligen Harry-Potter- oder Escape-Rätseln!", + alert: "ℹ️ Hinweis zur Moderation: Eingereichte Fragen werden nicht sofort öffentlich sichtbar, sondern erhalten automatisch den Status „Prüfung ausstehend“. Unser Zaubereiministerium prüft und testet die Antwort, bevor die Frage freigeschaltet wird.", + nameLabel: "Dein Name / Zauberer-Alias (optional):", + namePlaceholder: "z. B. Hermine Granger", + frageLabel: "Deine Frage: *", + fragePlaceholder: "z. B. Wie heißt der Schulleiter von Hogwarts?", + diffLabel: "Schwierigkeitsgrad:", + typeLabel: "Art der Frage:", + optText: "🔤 Freitext-Antwort (Tippen mit Fehlertoleranz)", + optMC: "🔘 Multiple Choice (Auswahl aus 4 Antwortmöglichkeiten)", + antwortLabelText: "Richtige Antwort:", + antwortLabelMC: "Richtige Antwort (die korrekte Option):", + antwortHelpText: "Unser System gleicht Tippfehler und Groß-/Kleinschreibung automatisch ab.", + antwortHelpMC: "Diese Antwort wird zusammen mit den falschen Optionen zufällig für den Spieler angeordnet.", + wrongLabel: "Falsche Antwortmöglichkeiten (zur Auswahl für die Spieler):", + wrongPlaceholder1: "Falsche Option 1 (z. B. Severus Snape)", + wrongPlaceholder2: "Falsche Option 2 (z. B. Minerva McGonagall)", + wrongPlaceholder3: "Falsche Option 3 (z. B. Rubeus Hagrid)", + tippLabel: "Tipp / Hinweis (optional):", + tippPlaceholder: "z. B. Er trägt eine Halbmondbrille.", + submitBtn: "📜 Vorschlag an das Ministerium senden", + submitBtnCasting: "✨ Vorschlag wird übertragen...", + alertFillRequired: "Bitte gib mindestens die Frage und die richtige Antwort ein.", + alertMCRequired: "Bitte gib bei Multiple-Choice mindestens eine falsche Antwortmöglichkeit an.", + successHeader: "✨ Vorschlag erfolgreich übermittelt! ✨" + }, + en: { + title: "✨ Suggest a New Quiz Question ✨", + subtitle: "Enrich the magical quiz with tricky Harry Potter or escape puzzles!", + alert: "ℹ️ Moderation Note: Submitted questions are not immediately public; they automatically receive the status 'Pending Review'. The Ministry of Magic tests and verifies the answer before approval.", + nameLabel: "Your Name / Wizard Alias (optional):", + namePlaceholder: "e.g. Hermione Granger", + frageLabel: "Your Question: *", + fragePlaceholder: "e.g. Who is the Headmaster of Hogwarts?", + diffLabel: "Difficulty Level:", + typeLabel: "Question Type:", + optText: "🔤 Free-Text Input (with typo tolerance)", + optMC: "🔘 Multiple Choice (Selection of 4 options)", + antwortLabelText: "Correct Answer:", + antwortLabelMC: "Correct Answer (the winning option):", + antwortHelpText: "Our system automatically matches minor typos and casing.", + antwortHelpMC: "This option will be shuffled together with the false choices.", + wrongLabel: "Wrong Answer Options (for player to choose from):", + wrongPlaceholder1: "Wrong Option 1 (e.g. Severus Snape)", + wrongPlaceholder2: "Wrong Option 2 (e.g. Minerva McGonagall)", + wrongPlaceholder3: "Wrong Option 3 (e.g. Rubeus Hagrid)", + tippLabel: "Hint / Clue (optional):", + tippPlaceholder: "e.g. He wears half-moon spectacles.", + submitBtn: "📜 Submit Proposal to the Ministry", + submitBtnCasting: "✨ Transmitting proposal...", + alertFillRequired: "Please enter at least the question and the correct answer.", + alertMCRequired: "For Multiple Choice, please provide at least one incorrect option.", + successHeader: "✨ Question successfully submitted! ✨" + } +}; + +function onLanguageChange(lang) { + const t = SUGGESTION_TEXT[lang] || SUGGESTION_TEXT.de; + const title = document.getElementById("suggTitle"); + const subtitle = document.getElementById("suggSubtitle"); + const alertBox = document.getElementById("suggAlert"); + const nameLabel = document.getElementById("suggNameLabel"); + const nameInput = document.getElementById("name"); + const frageLabel = document.getElementById("suggFrageLabel"); + const frageInput = document.getElementById("frage"); + const diffLabel = document.getElementById("suggDiffLabel"); + const typeLabel = document.getElementById("suggTypeLabel"); + const optText = document.getElementById("optText"); + const optMC = document.getElementById("optMC"); + const mcWrongLabel = document.getElementById("mcWrongLabel"); + const tippLabel = document.getElementById("suggTippLabel"); + const tippInput = document.getElementById("tipp"); + const submitBtn = document.getElementById("submitBtn"); + + if (title) title.innerText = t.title; + if (subtitle) subtitle.innerText = t.subtitle; + if (alertBox) alertBox.innerHTML = t.alert; + if (nameLabel) nameLabel.innerText = t.nameLabel; + if (nameInput) nameInput.placeholder = t.namePlaceholder; + if (frageLabel) frageLabel.innerText = t.frageLabel; + if (frageInput) frageInput.placeholder = t.fragePlaceholder; + if (diffLabel) diffLabel.innerText = t.diffLabel; + if (typeLabel) typeLabel.innerText = t.typeLabel; + if (optText) optText.innerText = t.optText; + if (optMC) optMC.innerText = t.optMC; + if (mcWrongLabel) mcWrongLabel.innerText = t.wrongLabel; + if (tippLabel) tippLabel.innerText = t.tippLabel; + if (tippInput) tippInput.placeholder = t.tippPlaceholder; + if (submitBtn && !submitBtn.disabled) submitBtn.innerText = t.submitBtn; + + const qType = document.getElementById("q_type"); + if (qType) toggleVorschlagType(qType.value); +} + function toggleVorschlagType(type) { + const lang = (typeof currentLang !== "undefined") ? currentLang : (localStorage.getItem("quiz_lang") || "de"); + const t = SUGGESTION_TEXT[lang] || SUGGESTION_TEXT.de; + const mcGroup = document.getElementById("mcOptionsGroup"); const antwortLabel = document.getElementById("antwortLabel"); const antwortHelp = document.getElementById("antwortHelp"); if (type === "multiple_choice") { mcGroup.classList.remove("hidden"); - antwortLabel.innerText = "Richtige Antwort (die korrekte Option):"; - antwortHelp.innerText = "Diese Antwort wird zusammen mit den falschen Optionen zufällig für den Spieler angeordnet."; + antwortLabel.innerText = t.antwortLabelMC; + antwortHelp.innerText = t.antwortHelpMC; } else { mcGroup.classList.add("hidden"); - antwortLabel.innerText = "Richtige Antwort:"; - antwortHelp.innerText = "Unser System gleicht Tippfehler und Groß-/Kleinschreibung automatisch ab."; + antwortLabel.innerText = t.antwortLabelText; + antwortHelp.innerText = t.antwortHelpText; } } async function submitSuggestion(event) { event.preventDefault(); + const lang = (typeof currentLang !== "undefined") ? currentLang : (localStorage.getItem("quiz_lang") || "de"); + const t = SUGGESTION_TEXT[lang] || SUGGESTION_TEXT.de; + const name = document.getElementById("name").value.trim(); const frage = document.getElementById("frage").value.trim(); const antwort = document.getElementById("antwort").value.trim(); const tipp = document.getElementById("tipp").value.trim(); const qType = document.getElementById("q_type").value; + const diff = document.getElementById("diff_select") ? document.getElementById("diff_select").value : "medium"; const submitBtn = document.getElementById("submitBtn"); const resultBox = document.getElementById("resultMessage"); if (!frage || !antwort) { - alert("Bitte gib mindestens die Frage und die richtige Antwort ein."); + alert(t.alertFillRequired); return; } @@ -38,13 +142,13 @@ async function submitSuggestion(event) { falscheAntworten = [w1, w2, w3].filter(x => x.length > 0); if (falscheAntworten.length === 0) { - alert("Bitte gib bei Multiple-Choice mindestens eine falsche Antwortmöglichkeit an."); + alert(t.alertMCRequired); return; } } submitBtn.disabled = true; - submitBtn.innerText = "✨ Vorschlag wird übertragen..."; + submitBtn.innerText = t.submitBtnCasting; try { const payload = { @@ -53,6 +157,7 @@ async function submitSuggestion(event) { antwort: antwort, tipp: tipp || null, question_type: qType, + difficulty: diff, falsche_antworten: falscheAntworten }; @@ -67,7 +172,7 @@ async function submitSuggestion(event) { if (res.ok && data.success) { resultBox.innerHTML = `
-

✨ Vorschlag erfolgreich übermittelt! ✨

+

${t.successHeader}

${data.message}

`; @@ -91,6 +196,11 @@ async function submitSuggestion(event) { resultBox.classList.remove("hidden"); } finally { submitBtn.disabled = false; - submitBtn.innerText = "📜 Vorschlag an das Ministerium senden"; + submitBtn.innerText = t.submitBtn; } } + +document.addEventListener("DOMContentLoaded", () => { + const lang = (typeof currentLang !== "undefined") ? currentLang : (localStorage.getItem("quiz_lang") || "de"); + onLanguageChange(lang); +}); diff --git a/app/templates/admin.html b/app/templates/admin.html index 3c3c6d0..f970503 100755 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -54,6 +54,7 @@
⚠️ Prüfung ausstehend + {{ (q.difficulty or 'medium')|capitalize }} {% if q.question_type == 'multiple_choice' %} 🔘 Multiple-Choice {% else %} @@ -143,6 +144,7 @@
Reihenfolge #{{ q.sort_order }} + {{ (q.difficulty or 'medium')|capitalize }} {% if q.question_type == 'multiple_choice' %} 🔘 Multiple-Choice {% else %} @@ -182,6 +184,14 @@
+
+ + +
@@ -221,6 +231,15 @@
+
+ + +
+
- +
- + + +
+ +
+
@@ -39,14 +48,14 @@
- +
diff --git a/migrate_v3.py b/migrate_v3.py new file mode 100644 index 0000000..2fb4207 --- /dev/null +++ b/migrate_v3.py @@ -0,0 +1,20 @@ +from app.database import engine +from sqlalchemy import text + +def migrate(): + with engine.connect() as conn: + # Questions table + conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS difficulty VARCHAR(20) DEFAULT 'medium'")) + conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS question_en TEXT NULL")) + conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS answer_en VARCHAR(255) NULL")) + conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS options_en TEXT NULL")) + conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS hint_en TEXT NULL")) + + # Quiz sessions table + conn.execute(text("ALTER TABLE quiz_sessions ADD COLUMN IF NOT EXISTS lang VARCHAR(10) DEFAULT 'de'")) + conn.execute(text("ALTER TABLE quiz_sessions ADD COLUMN IF NOT EXISTS difficulty VARCHAR(20) DEFAULT 'all'")) + conn.commit() + print("Migration v3 successful: added difficulty, multilingual columns, and session preferences.") + +if __name__ == "__main__": + migrate()