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 = `
-
${data.message}