commit c3a21369ef0269a86b84ac966c24152ae2538859 Author: kobold-ai Date: Mon Sep 21 11:29:18 2026 +0200 Initial commit: Modernes Harry Potter Escape Quiz Portal (FastAPI & MariaDB) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..406f743 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +venv/ +__pycache__/ +*.pyc +.env +.DS_Store +setup_gitea.py +legacy/*.mp3 diff --git a/app/auth.py b/app/auth.py new file mode 100755 index 0000000..b676e74 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,16 @@ +import os +import hashlib +import secrets + +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100000) + return f"{salt}${key.hex()}" + +def verify_password(stored_password_hash: str, provided_password: str) -> bool: + try: + salt, key_hex = stored_password_hash.split("$", 1) + new_key = hashlib.pbkdf2_hmac("sha256", provided_password.encode("utf-8"), salt.encode("utf-8"), 100000) + return secrets.compare_digest(new_key.hex(), key_hex) + except Exception: + return False diff --git a/app/database.py b/app/database.py new file mode 100755 index 0000000..b74adc4 --- /dev/null +++ b/app/database.py @@ -0,0 +1,32 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from dotenv import load_dotenv + +load_dotenv() + +DB_USER = os.getenv("DB_USER", "v2_usr_quiz") +DB_PASS = os.getenv("DB_PASS", "Quiz_Pass_2026_Secure!") +DB_HOST = os.getenv("DB_HOST", "db.waiswuaschd.de") +DB_PORT = os.getenv("DB_PORT", "3306") +DB_NAME = os.getenv("DB_NAME", "v2_db_quiz") + +DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset=utf8mb4" + +engine = create_engine( + DATABASE_URL, + pool_size=10, + max_overflow=20, + pool_recycle=3600, + pool_pre_ping=True +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100755 index 0000000..d13b01a --- /dev/null +++ b/app/main.py @@ -0,0 +1,419 @@ +import os +import uuid +import json +import datetime +from fastapi import FastAPI, Request, Depends, HTTPException, status, Form +from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware +from sqlalchemy.orm import Session +from dotenv import load_dotenv + +from app.database import get_db +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 + +load_dotenv() + +app = FastAPI(title="Magisches Harry-Potter-Escape-Quiz") + +SECRET_KEY = os.getenv("SECRET_KEY", "hogwarts_secret_default_key_2026") +app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, session_cookie="quiz_session") + +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")) + +@app.on_event("startup") +def startup_event(): + seed_db() + +# --- Public Endpoints --- + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request, db: Session = Depends(get_db)): + active_count = db.query(Question).filter(Question.status == "active").count() + return templates.TemplateResponse( + request=request, + name="index.html", + context={"question_count": active_count} + ) + +@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.") + + question_ids = [q.id for q in questions] + session_id = str(uuid.uuid4()) + + new_session = QuizSession( + id=session_id, + score=0, + current_question_idx=0, + question_ids=json.dumps(question_ids), + attempts_current=0, + is_finished=False + ) + db.add(new_session) + db.commit() + + first_q = questions[0] + return { + "session_id": session_id, + "question_number": 1, + "total_questions": len(question_ids), + "category": first_q.category, + "question": first_q.question, + "score": 0, + "attempts_used": 0, + "max_attempts": 3 + } + +@app.post("/api/game/check") +async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db)): + session = db.query(QuizSession).filter(QuizSession.id == payload.session_id).first() + if not session or session.is_finished: + raise HTTPException(status_code=400, detail="Ungültige oder beendete Spiel-Session.") + + question_ids = json.loads(session.question_ids) + if session.current_question_idx >= len(question_ids): + session.is_finished = True + db.commit() + return { + "correct": False, + "score": session.score, + "attempts_used": session.attempts_current, + "attempts_left": 0, + "is_finished": True, + "next_question": None + } + + current_q_id = question_ids[session.current_question_idx] + current_q = db.query(Question).filter(Question.id == current_q_id).first() + 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) + + if correct: + session.score += 1 + session.current_question_idx += 1 + session.attempts_current = 0 + + is_finished = session.current_question_idx >= len(question_ids) + if is_finished: + session.is_finished = True + session.finished_at = datetime.datetime.utcnow() + + db.commit() + + next_q_data = None + if not is_finished: + next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first() + if next_q: + next_q_data = { + "question_number": session.current_question_idx + 1, + "total_questions": len(question_ids), + "category": next_q.category, + "question": next_q.question + } + + return { + "correct": True, + "score": session.score, + "attempts_used": 0, + "attempts_left": 3, + "is_finished": is_finished, + "revealed_answer": None, + "next_question": next_q_data + } + else: + session.attempts_current += 1 + 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 + session.current_question_idx += 1 + session.attempts_current = 0 + + is_finished = session.current_question_idx >= len(question_ids) + if is_finished: + session.is_finished = True + session.finished_at = datetime.datetime.utcnow() + + db.commit() + + next_q_data = None + if not is_finished: + next_q = db.query(Question).filter(Question.id == question_ids[session.current_question_idx]).first() + if next_q: + next_q_data = { + "question_number": session.current_question_idx + 1, + "total_questions": len(question_ids), + "category": next_q.category, + "question": next_q.question + } + + return { + "correct": False, + "score": session.score, + "attempts_used": 3, + "attempts_left": 0, + "is_finished": is_finished, + "revealed_answer": revealed, + "next_question": next_q_data + } + else: + db.commit() + return { + "correct": False, + "score": session.score, + "attempts_used": session.attempts_current, + "attempts_left": attempts_left, + "is_finished": False, + "revealed_answer": None, + "next_question": None + } + +@app.get("/api/game/hint") +async def get_hint(session_id: str, db: Session = Depends(get_db)): + session = db.query(QuizSession).filter(QuizSession.id == session_id).first() + if not session or session.is_finished: + raise HTTPException(status_code=400, detail="Ungültige Session.") + + question_ids = json.loads(session.question_ids) + if session.current_question_idx >= len(question_ids): + return {"hint": "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."} + +@app.get("/finale", response_class=HTMLResponse) +async def finale(request: Request, score: int = 0, total: int = 15): + return templates.TemplateResponse( + request=request, + name="finale.html", + context={"score": score, "total": total} + ) + +@app.get("/vorschlag", response_class=HTMLResponse) +async def vorschlag_page(request: Request): + return templates.TemplateResponse( + request=request, + name="vorschlag.html", + context={} + ) + +@app.post("/api/suggestions") +async def submit_suggestion(payload: SuggestionCreateRequest, db: Session = Depends(get_db)): + if not payload.frage.strip() or not payload.antwort.strip(): + raise HTTPException(status_code=400, detail="Frage und Antwort dürfen nicht leer sein.") + + new_suggestion = Question( + category="community", + question=payload.frage.strip(), + canonical_answer=payload.antwort.strip(), + hint=payload.tipp.strip() if payload.tipp else None, + status="needs_review", # User Question Need Test / Review + submitted_by=payload.name.strip() if payload.name else "Gast", + sort_order=999 + ) + db.add(new_suggestion) + db.commit() + + return { + "success": True, + "message": "Vielen Dank! Dein Vorschlag wurde eingereicht und wird von uns geprüft, bevor er im Quiz erscheint." + } + +# --- Admin Endpoints --- + +def get_current_admin(request: Request) -> str: + admin = request.session.get("admin_user") + if not admin: + raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/admin/login"}) + return admin + +@app.get("/admin/login", response_class=HTMLResponse) +async def admin_login_page(request: Request): + if request.session.get("admin_user"): + return RedirectResponse(url="/admin", status_code=303) + return templates.TemplateResponse( + request=request, + name="admin_login.html", + context={"error": None} + ) + +@app.post("/admin/login") +async def admin_login( + request: Request, + username: str = Form(...), + password: str = Form(...), + db: Session = Depends(get_db) +): + admin = db.query(AdminUser).filter(AdminUser.username == username.strip()).first() + if not admin or not verify_password(admin.password_hash, password): + return templates.TemplateResponse( + request=request, + name="admin_login.html", + context={"error": "Ungültiger Benutzername oder Zauberspruch (Passwort)."}, + status_code=400 + ) + + request.session["admin_user"] = admin.username + return RedirectResponse(url="/admin", status_code=303) + +@app.get("/admin/logout") +async def admin_logout(request: Request): + request.session.clear() + return RedirectResponse(url="/admin/login", status_code=303) + +@app.get("/admin", response_class=HTMLResponse) +async def admin_dashboard(request: Request, db: Session = Depends(get_db)): + if not request.session.get("admin_user"): + return RedirectResponse(url="/admin/login", status_code=303) + + active_questions = db.query(Question).filter(Question.status == "active").order_by(Question.sort_order.asc(), Question.id.asc()).all() + pending_questions = db.query(Question).filter(Question.status == "needs_review").order_by(Question.created_at.desc()).all() + rejected_questions = db.query(Question).filter(Question.status == "rejected").order_by(Question.created_at.desc()).all() + + return templates.TemplateResponse( + request=request, + name="admin.html", + context={ + "admin_user": request.session["admin_user"], + "active_questions": active_questions, + "pending_questions": pending_questions, + "rejected_questions": rejected_questions + } + ) + +@app.post("/admin/questions/new") +async def admin_add_question( + request: Request, + question: str = Form(...), + canonical_answer: str = Form(...), + hint: str = Form(None), + category: str = Form("harry_potter"), + sort_order: int = Form(0), + db: Session = Depends(get_db) +): + if not request.session.get("admin_user"): + return RedirectResponse(url="/admin/login", status_code=303) + + new_q = Question( + category=category.strip(), + question=question.strip(), + canonical_answer=canonical_answer.strip(), + hint=hint.strip() if hint else None, + status="active", + sort_order=sort_order, + submitted_by=f"Admin ({request.session['admin_user']})" + ) + db.add(new_q) + db.commit() + return RedirectResponse(url="/admin", status_code=303) + +@app.post("/admin/questions/{qid}/approve") +async def admin_approve_question(qid: int, request: Request, db: Session = Depends(get_db)): + if not request.session.get("admin_user"): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + q = db.query(Question).filter(Question.id == qid).first() + if not q: + raise HTTPException(status_code=404, detail="Frage nicht gefunden.") + + # Determine highest sort_order + max_order = db.query(Question.sort_order).filter(Question.status == "active").order_by(Question.sort_order.desc()).first() + next_order = (max_order[0] + 1) if max_order and max_order[0] else 1 + + q.status = "active" + q.sort_order = next_order + q.reviewed_at = datetime.datetime.utcnow() + q.reviewed_by = request.session.get("admin_user") + db.commit() + return {"success": True, "message": f"Frage #{qid} wurde erfolgreich freigeschaltet!"} + +@app.post("/admin/questions/{qid}/reject") +async def admin_reject_question(qid: int, request: Request, db: Session = Depends(get_db)): + if not request.session.get("admin_user"): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + q = db.query(Question).filter(Question.id == qid).first() + if not q: + raise HTTPException(status_code=404, detail="Frage nicht gefunden.") + + q.status = "rejected" + q.reviewed_at = datetime.datetime.utcnow() + q.reviewed_by = request.session.get("admin_user") + db.commit() + return {"success": True, "message": f"Frage #{qid} wurde abgelehnt."} + +@app.post("/admin/questions/{qid}/edit") +async def admin_edit_question( + qid: int, + request: Request, + question: str = Form(...), + canonical_answer: str = Form(...), + hint: str = Form(None), + status_val: str = Form("active"), + sort_order: int = Form(0), + db: Session = Depends(get_db) +): + if not request.session.get("admin_user"): + return RedirectResponse(url="/admin/login", status_code=303) + + q = db.query(Question).filter(Question.id == qid).first() + if not q: + raise HTTPException(status_code=404, detail="Frage nicht gefunden.") + + q.question = question.strip() + q.canonical_answer = canonical_answer.strip() + q.hint = hint.strip() if hint else None + q.status = status_val.strip() + q.sort_order = sort_order + db.commit() + return RedirectResponse(url="/admin", status_code=303) + +@app.post("/admin/questions/{qid}/delete") +async def admin_delete_question(qid: int, request: Request, db: Session = Depends(get_db)): + if not request.session.get("admin_user"): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + q = db.query(Question).filter(Question.id == qid).first() + if not q: + raise HTTPException(status_code=404, detail="Frage nicht gefunden.") + + db.delete(q) + db.commit() + return {"success": True, "message": f"Frage #{qid} gelöscht."} + +@app.post("/admin/questions/test-match") +async def admin_test_match( + request: Request, + test_input: str = Form(...), + canonical: str = Form(...) +): + if not request.session.get("admin_user"): + return JSONResponse({"error": "Unauthorized"}, status_code=401) + + matched = is_answer_correct(test_input, canonical) + clean_in = normalize_text(test_input) + clean_target = normalize_text(canonical) + dist = levenshtein_distance(clean_in, clean_target) + + return { + "matched": matched, + "input_clean": clean_in, + "target_clean": clean_target, + "levenshtein_distance": dist + } diff --git a/app/matcher.py b/app/matcher.py new file mode 100755 index 0000000..add8ddd --- /dev/null +++ b/app/matcher.py @@ -0,0 +1,69 @@ +import re +import unicodedata +import json + +def normalize_text(text: str) -> str: + if not text: + return "" + text = text.lower().strip() + # Replace German umlauts + text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") + # Remove accents + nfkd = unicodedata.normalize("NFKD", text) + text = "".join([c for c in nfkd if not unicodedata.combining(c)]) + # Remove special punctuation like quotes, dots, dashes, commas + text = re.sub(r"[^\w\s]", "", text) + # Collapse whitespace + text = re.sub(r"\s+", " ", text).strip() + return text + +def levenshtein_distance(s1: str, s2: str) -> int: + if len(s1) < len(s2): + return levenshtein_distance(s2, s1) + if len(s2) == 0: + return len(s1) + previous_row = list(range(len(s2) + 1)) + for i, c1 in enumerate(s1): + current_row = [i + 1] + for j, c2 in enumerate(s2): + insertions = previous_row[j + 1] + 1 + deletions = current_row[j] + 1 + substitutions = previous_row[j] + (c1 != c2) + current_row.append(min(insertions, deletions, substitutions)) + previous_row = current_row + return previous_row[-1] + +def is_answer_correct(user_input: str, canonical: str, accepted_raw: str | None = None) -> bool: + clean_user = normalize_text(user_input) + if not clean_user: + return False + + targets = [normalize_text(canonical)] + + if accepted_raw: + try: + parsed = json.loads(accepted_raw) + if isinstance(parsed, list): + targets.extend([normalize_text(str(x)) for x in parsed]) + except Exception: + # Fallback comma-separated + for part in accepted_raw.split(","): + part_clean = normalize_text(part) + if part_clean: + targets.append(part_clean) + + for target in targets: + if not target: + continue + if clean_user == target: + return True + # For short answers (1-3 chars, e.g. numbers or abbreviations), require exact match + if len(target) <= 3: + continue + # For medium words (4-6 chars), allow max 1 typo + max_allowed = 1 if len(target) <= 6 else 2 + dist = levenshtein_distance(clean_user, target) + if dist <= max_allowed: + return True + + return False diff --git a/app/models.py b/app/models.py new file mode 100755 index 0000000..f36f24c --- /dev/null +++ b/app/models.py @@ -0,0 +1,39 @@ +import datetime +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime +from app.database import Base + +class Question(Base): + __tablename__ = "questions" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + category = Column(String(50), default="harry_potter", index=True) + question = Column(Text, nullable=False) + canonical_answer = Column(String(255), nullable=False) + accepted_answers = Column(Text, nullable=True) # JSON or comma-delimited + hint = Column(Text, nullable=True) + status = Column(String(30), default="active", index=True) # active, needs_review, rejected, archived + submitted_by = Column(String(100), nullable=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + reviewed_at = Column(DateTime, nullable=True) + reviewed_by = Column(String(50), nullable=True) + +class QuizSession(Base): + __tablename__ = "quiz_sessions" + + id = Column(String(36), primary_key=True, index=True) + score = Column(Integer, default=0) + current_question_idx = Column(Integer, default=0) + question_ids = Column(Text, nullable=False) # JSON array string: "[1, 2, 3...]" + attempts_current = Column(Integer, default=0) + is_finished = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + finished_at = Column(DateTime, nullable=True) + +class AdminUser(Base): + __tablename__ = "admin_users" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + username = Column(String(50), unique=True, index=True, nullable=False) + password_hash = Column(String(255), nullable=False) + created_at = Column(DateTime, default=datetime.datetime.utcnow) diff --git a/app/schemas.py b/app/schemas.py new file mode 100755 index 0000000..6fa8b16 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel +from typing import Optional, List + +class GameStartResponse(BaseModel): + session_id: str + question_number: int + total_questions: int + category: str + question: str + score: int + attempts_used: int + max_attempts: int = 3 + +class GameCheckRequest(BaseModel): + session_id: str + answer: str + +class GameCheckResponse(BaseModel): + correct: bool + score: int + attempts_used: int + attempts_left: int + max_attempts: int = 3 + is_finished: bool + revealed_answer: Optional[str] = None + next_question: Optional[dict] = None + +class SuggestionCreateRequest(BaseModel): + frage: str + antwort: str + tipp: Optional[str] = None + name: Optional[str] = "Gast" + +class QuestionAdminCreate(BaseModel): + category: str = "harry_potter" + question: str + canonical_answer: str + accepted_answers: Optional[str] = None + hint: Optional[str] = None + status: str = "active" + sort_order: int = 0 diff --git a/app/seed.py b/app/seed.py new file mode 100755 index 0000000..3a6e51e --- /dev/null +++ b/app/seed.py @@ -0,0 +1,76 @@ +import os +import json +from app.database import engine, SessionLocal, Base +from app.models import Question, AdminUser +from app.auth import hash_password + +def seed_db(): + Base.metadata.create_all(bind=engine) + db = SessionLocal() + + try: + # 1. Admin User + admin_user = os.getenv("ADMIN_USER", "admin") + admin_pass = os.getenv("ADMIN_PASS", "Alohomora2026!") + + existing_admin = db.query(AdminUser).filter(AdminUser.username == admin_user).first() + if not existing_admin: + db.add(AdminUser( + username=admin_user, + password_hash=hash_password(admin_pass) + )) + 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") + + finally: + db.close() + +if __name__ == "__main__": + seed_db() diff --git a/app/static/audio/music.mp3 b/app/static/audio/music.mp3 new file mode 100755 index 0000000..59059b2 Binary files /dev/null and b/app/static/audio/music.mp3 differ diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100755 index 0000000..c762a88 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,769 @@ +:root { + --bg-dark: #0b0b16; + --bg-card: rgba(22, 24, 44, 0.85); + --bg-card-hover: rgba(30, 33, 60, 0.95); + --gold: #c9a86a; + --gold-bright: #ffd700; + --gold-soft: #e6c98f; + --text-main: #f5f5f7; + --text-muted: #b8b8cc; + --parchment: #f6eedb; + --wax-red: #8b0000; + --success: #2ecc71; + --danger: #e74c3c; + --warning: #f39c12; + --border-gold: rgba(201, 168, 106, 0.35); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg-dark); + background-image: + radial-gradient(circle at 20% 20%, rgba(201, 168, 106, 0.08) 0%, transparent 40%), + radial-gradient(circle at 80% 80%, rgba(116, 0, 1, 0.08) 0%, transparent 50%); + color: var(--text-main); + font-family: "Georgia", "Cinzel", "Times New Roman", serif; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow-x: hidden; +} + +/* Header Audio & Navigation Bar */ +.audio-control-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 20px; + background: rgba(11, 11, 22, 0.7); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border-gold); + position: sticky; + top: 0; + z-index: 100; +} + +.audio-btn { + background: rgba(201, 168, 106, 0.15); + border: 1px solid var(--gold); + color: var(--gold-soft); + padding: 6px 14px; + border-radius: 20px; + cursor: pointer; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s ease; +} + +.audio-btn:hover { + background: var(--gold); + color: #000; + box-shadow: 0 0 10px rgba(201, 168, 106, 0.5); +} + +.nav-links { + display: flex; + gap: 15px; + align-items: center; +} + +.nav-link { + color: var(--text-muted); + text-decoration: none; + font-size: 0.9rem; + transition: color 0.2s ease; +} + +.nav-link:hover { + color: var(--gold); + text-shadow: 0 0 5px rgba(201, 168, 106, 0.5); +} + +/* Container */ +.container { + flex: 1; + max-width: 800px; + width: 92%; + margin: 30px auto; +} + +.admin-wrapper { + max-width: 950px; + width: 95%; + margin: 30px auto; +} + +/* Titles */ +.glow-title { + color: var(--gold); + font-size: 2rem; + text-align: center; + letter-spacing: 1px; + text-shadow: 0 0 12px rgba(201, 168, 106, 0.4); + margin-bottom: 8px; +} + +.subtitle { + text-align: center; + color: var(--text-muted); + font-style: italic; + margin-bottom: 25px; +} + +/* Card Styling */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-gold); + border-radius: 12px; + padding: 30px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); + backdrop-filter: blur(8px); + margin-bottom: 25px; + transition: border-color 0.3s ease; +} + +.card:hover { + border-color: rgba(201, 168, 106, 0.6); +} + +/* Envelope Intro Animation */ +.intro-envelope { + position: relative; + width: 240px; + height: 160px; + margin: 20px auto 35px; + perspective: 800px; +} + +.envelope-body { + width: 100%; + height: 100%; + background: #e8dbc3; + border-radius: 6px; + border: 2px solid var(--gold); + position: relative; + box-shadow: 0 10px 25px rgba(0,0,0,0.6); +} + +.envelope-flap { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #d8c9ad; + clip-path: polygon(0 0, 50% 55%, 100% 0); + transform-origin: top; + transition: transform 0.9s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 3; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.wax-seal { + position: absolute; + top: 45%; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; + height: 44px; + background: radial-gradient(circle, #a81c1c 40%, #680d0d 100%); + border-radius: 50%; + border: 2px solid #500606; + color: #ffd700; + font-weight: bold; + font-size: 1.4rem; + display: flex; + align-items: center; + justify-content: center; + z-index: 4; + box-shadow: 0 4px 8px rgba(0,0,0,0.5); + transition: opacity 0.5s ease; +} + +.letter-preview { + position: absolute; + bottom: 5px; + left: 50%; + transform: translateX(-50%); + width: 88%; + height: 80%; + background: #fffdfa; + border: 1px solid #d4c5a9; + border-radius: 4px; + padding: 10px; + z-index: 2; + transition: transform 0.8s ease 0.3s; +} + +.letter-text { + font-size: 0.7rem; + color: #443; + text-align: center; + font-style: italic; +} + +/* Open Animation */ +.envelope-opened .envelope-flap { + transform: rotateX(180deg); + z-index: 1; +} + +.envelope-opened .wax-seal { + opacity: 0; + pointer-events: none; +} + +.envelope-opened .letter-preview { + transform: translate(-50%, -40px); +} + +.intro-content { + text-align: center; +} + +.intro-text { + font-size: 1.15rem; + line-height: 1.6; + margin-bottom: 25px; + color: var(--text-muted); +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 12px 24px; + border-radius: 8px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + text-decoration: none; + transition: all 0.2s ease; + border: none; + font-family: inherit; +} + +.btn-primary { + background: linear-gradient(135deg, var(--gold) 0%, #b8860b 100%); + color: #0c0d14; + box-shadow: 0 4px 15px rgba(201, 168, 106, 0.3); +} + +.btn-primary:hover { + background: linear-gradient(135deg, #ffd700 0%, var(--gold) 100%); + box-shadow: 0 6px 20px rgba(201, 168, 106, 0.5); + transform: translateY(-2px); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--gold-soft); + border: 1px solid var(--gold); +} + +.btn-secondary:hover { + background: rgba(201, 168, 106, 0.2); +} + +.btn-danger { + background: rgba(231, 76, 60, 0.2); + color: #ff7675; + border: 1px solid var(--danger); +} + +.btn-danger:hover { + background: var(--danger); + color: #fff; +} + +.btn-success { + background: rgba(46, 204, 113, 0.2); + color: #55efc4; + border: 1px solid var(--success); +} + +.btn-success:hover { + background: var(--success); + color: #000; +} + +.btn-large { + font-size: 1.15rem; + padding: 14px 32px; +} + +.btn-sm { + font-size: 0.85rem; + padding: 6px 12px; +} + +.btn-group { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 20px; + flex-wrap: wrap; +} + +/* Status & Progress Bar */ +.status-panel { + margin-bottom: 20px; +} + +.progress-info { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + font-size: 0.95rem; +} + +.points-badge { + color: var(--gold-bright); + font-weight: bold; + text-shadow: 0 0 8px rgba(255, 215, 0, 0.4); +} + +.progress-container { + width: 100%; + height: 12px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--border-gold); + border-radius: 10px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--gold) 0%, #ffd700 100%); + box-shadow: 0 0 10px rgba(255, 215, 0, 0.5); + transition: width 0.4s ease; +} + +/* Question Card */ +.question-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.badge { + padding: 4px 10px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: bold; + background: rgba(201, 168, 106, 0.15); + border: 1px solid var(--gold); + color: var(--gold-soft); +} + +.badge-warning { + background: rgba(243, 156, 18, 0.2); + border-color: var(--warning); + color: #f39c12; +} + +.badge-success { + background: rgba(46, 204, 113, 0.2); + border-color: var(--success); + color: #2ecc71; +} + +.badge-danger { + background: rgba(231, 76, 60, 0.2); + border-color: var(--danger); + color: #e74c3c; +} + +.badge-muted { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + color: var(--text-muted); +} + +.attempt-badge { + font-size: 0.85rem; + color: var(--text-muted); +} + +.question-text { + font-size: 1.4rem; + line-height: 1.5; + text-align: center; + margin: 20px 0 25px; + color: var(--text-main); +} + +.answer-form { + display: flex; + flex-direction: column; + align-items: center; +} + +.input-answer { + width: 100%; + max-width: 420px; + padding: 14px 18px; + font-size: 1.15rem; + text-align: center; + border-radius: 8px; + border: 2px solid var(--border-gold); + background: rgba(0, 0, 0, 0.3); + color: var(--text-main); + outline: none; + transition: all 0.2s ease; + font-family: inherit; +} + +.input-answer:focus { + border-color: var(--gold); + box-shadow: 0 0 12px rgba(201, 168, 106, 0.4); +} + +.hint-box { + margin-top: 20px; + padding: 15px; + border-radius: 8px; + background: rgba(201, 168, 106, 0.1); + border-left: 4px solid var(--gold); + font-size: 0.95rem; + color: var(--gold-soft); + text-align: left; +} + +.feedback-box { + margin-top: 20px; + font-size: 1.1rem; + text-align: center; + min-height: 30px; + font-weight: bold; +} + +.feedback-correct { + color: var(--success); + text-shadow: 0 0 10px rgba(46, 204, 113, 0.4); +} + +.feedback-wrong { + color: var(--danger); +} + +.feedback-revealed { + color: var(--warning); + font-size: 0.95rem; +} + +/* Animations */ +.sparkle-effect { + animation: sparkle-glow 0.8s ease forwards; +} + +@keyframes sparkle-glow { + 0% { box-shadow: 0 0 5px rgba(255, 215, 0, 0.2); } + 50% { box-shadow: 0 0 30px rgba(255, 215, 0, 0.8), inset 0 0 20px rgba(255, 215, 0, 0.4); border-color: #ffd700; } + 100% { box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); } +} + +.shake-effect { + animation: shake 0.4s ease; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-8px); } + 40%, 80% { transform: translateX(8px); } +} + +/* Finale Page */ +.finale-card { + text-align: center; +} + +.finale-header { + display: flex; + justify-content: center; + align-items: center; + gap: 15px; + margin-bottom: 20px; +} + +.sparkle-icon { + font-size: 2rem; +} + +.score-banner { + background: rgba(201, 168, 106, 0.1); + border: 1px dashed var(--gold); + border-radius: 8px; + padding: 15px; + margin-bottom: 25px; +} + +.score-highlight { + font-size: 1.25rem; +} + +.gold-text { + color: var(--gold-bright); + font-weight: bold; + font-size: 1.5rem; +} + +.reward-box { + background: rgba(0, 0, 0, 0.25); + border-radius: 10px; + padding: 25px; + margin-bottom: 25px; + border-left: 4px solid var(--gold); +} + +.reward-box h2 { + color: var(--gold-soft); + font-size: 1.4rem; + margin-bottom: 15px; +} + +.reward-description { + font-size: 1.1rem; + line-height: 1.6; + margin-bottom: 15px; + color: var(--text-main); +} + +.love-note { + font-style: italic; + color: var(--gold); + font-size: 1.15rem; +} + +/* Forms & Inputs */ +.magic-form { + display: flex; + flex-direction: column; + gap: 18px; + text-align: left; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 0.95rem; + font-weight: bold; + color: var(--gold-soft); +} + +.input-text, .textarea, select.input-text { + padding: 12px 15px; + border-radius: 6px; + border: 1px solid var(--border-gold); + background: rgba(0, 0, 0, 0.4); + color: var(--text-main); + font-size: 1rem; + font-family: inherit; + outline: none; + width: 100%; +} + +.input-text:focus, .textarea:focus { + border-color: var(--gold); + box-shadow: 0 0 10px rgba(201, 168, 106, 0.3); +} + +.input-text-sm { + padding: 6px 10px; + border-radius: 4px; + border: 1px solid var(--border-gold); + background: rgba(0,0,0,0.3); + color: var(--text-main); + font-size: 0.9rem; +} + +.field-help { + font-size: 0.8rem; + color: var(--text-muted); +} + +.info-alert { + background: rgba(41, 128, 185, 0.15); + border-left: 4px solid #3498db; + padding: 15px; + border-radius: 6px; + margin-bottom: 25px; + font-size: 0.95rem; + line-height: 1.5; +} + +.alert-danger { + background: rgba(231, 76, 60, 0.2); + border: 1px solid var(--danger); + color: #ff7675; + padding: 12px; + border-radius: 6px; + margin-bottom: 20px; + text-align: center; +} + +/* Admin Specific */ +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + flex-wrap: wrap; + gap: 15px; +} + +.admin-top-actions { + display: flex; + gap: 10px; +} + +.tabs-nav { + display: flex; + gap: 8px; + border-bottom: 1px solid var(--border-gold); + margin-bottom: 25px; + flex-wrap: wrap; +} + +.tab-btn { + background: rgba(255, 255, 255, 0.05); + border: none; + color: var(--text-muted); + padding: 12px 18px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + cursor: pointer; + font-size: 0.95rem; + font-family: inherit; + font-weight: bold; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +.tab-btn:hover { + color: var(--text-main); + background: rgba(255, 255, 255, 0.1); +} + +.tab-btn.active { + background: var(--bg-card); + color: var(--gold); + border: 1px solid var(--border-gold); + border-bottom: 1px solid transparent; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +.question-admin-card { + margin-bottom: 15px; +} + +.q-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + font-size: 0.85rem; +} + +.highlight-answer { + color: var(--gold-bright); + font-weight: bold; + background: rgba(255, 215, 0, 0.1); + padding: 2px 6px; + border-radius: 4px; +} + +.test-matcher-box { + margin-top: 15px; + padding: 12px; + background: rgba(0, 0, 0, 0.25); + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.test-input-group { + display: flex; + gap: 8px; + margin-top: 6px; +} + +.test-result-feedback { + margin-top: 8px; + font-size: 0.9rem; +} + +.q-actions { + display: flex; + gap: 10px; + margin-top: 15px; + flex-wrap: wrap; +} + +.inline-edit-form { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.empty-state { + text-align: center; + padding: 40px; + color: var(--text-muted); +} + +/* Footer */ +.footer { + text-align: center; + padding: 20px; + color: var(--text-muted); + font-size: 0.85rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); + margin-top: auto; +} + +.hidden { + display: none !important; +} + +@media (max-width: 600px) { + .audio-control-bar { + flex-direction: column; + gap: 10px; + } + .glow-title { + font-size: 1.5rem; + } + .card { + padding: 20px 15px; + } + .btn-group { + flex-direction: column; + width: 100%; + } + .btn { + width: 100%; + text-align: center; + } +} diff --git a/app/static/favicon.ico b/app/static/favicon.ico new file mode 100755 index 0000000..7a6c687 Binary files /dev/null and b/app/static/favicon.ico differ diff --git a/app/static/js/admin.js b/app/static/js/admin.js new file mode 100755 index 0000000..a2d2c9c --- /dev/null +++ b/app/static/js/admin.js @@ -0,0 +1,128 @@ +function showTab(tabId) { + document.querySelectorAll(".tab-content").forEach(el => el.classList.remove("active")); + document.querySelectorAll(".tab-btn").forEach(el => el.classList.remove("active")); + + const targetTab = document.getElementById(tabId); + if (targetTab) { + targetTab.classList.add("active"); + } + + // Mark active button + event.currentTarget.classList.add("active"); +} + +function toggleEdit(id) { + const editForm = document.getElementById(`edit-form-${id}`); + if (editForm) { + editForm.classList.toggle("hidden"); + } +} + +async function approveQuestion(id) { + if (!confirm(`Möchtest du Frage #${id} wirklich freischalten und für alle Spieler aktivieren?`)) { + return; + } + + try { + const res = await fetch(`/admin/questions/${id}/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" } + }); + + const data = await res.json(); + if (res.ok && data.success) { + alert(data.message); + window.location.reload(); + } else { + alert("Fehler: " + (data.error || "Aktion fehlgeschlagen")); + } + } catch (err) { + alert("Netzwerkfehler: " + err.message); + } +} + +async function rejectQuestion(id) { + if (!confirm(`Möchtest du Frage #${id} ablehnen?`)) { + return; + } + + try { + const res = await fetch(`/admin/questions/${id}/reject`, { + method: "POST", + headers: { "Content-Type": "application/json" } + }); + + const data = await res.json(); + if (res.ok && data.success) { + alert(data.message); + window.location.reload(); + } else { + alert("Fehler: " + (data.error || "Aktion fehlgeschlagen")); + } + } catch (err) { + alert("Netzwerkfehler: " + err.message); + } +} + +async function deleteQuestion(id) { + if (!confirm(`Soll Frage #${id} wirklich unwiderruflich gelöscht werden?`)) { + return; + } + + try { + const res = await fetch(`/admin/questions/${id}/delete`, { + method: "POST", + headers: { "Content-Type": "application/json" } + }); + + const data = await res.json(); + if (res.ok && data.success) { + const card = document.getElementById(`q-card-${id}`); + if (card) card.remove(); + } else { + alert("Fehler beim Löschen: " + (data.error || "")); + } + } catch (err) { + alert("Netzwerkfehler: " + err.message); + } +} + +async function testMatcher(id, canonical) { + const input = document.getElementById(`test-input-${id}`); + const resultBox = document.getElementById(`test-result-${id}`); + const testVal = input.value.trim(); + + if (!testVal) { + resultBox.innerHTML = 'Bitte gib einen Testwert ein.'; + return; + } + + try { + const formData = new FormData(); + formData.append("test_input", testVal); + formData.append("canonical", canonical); + + const res = await fetch("/admin/questions/test-match", { + method: "POST", + body: formData + }); + + const data = await res.json(); + + if (data.matched) { + resultBox.innerHTML = ` + + ✅ Treffer! (Distanz: ${data.levenshtein_distance}, Bereinigt: "${data.input_clean}" ↔ "${data.target_clean}") + + `; + } else { + resultBox.innerHTML = ` + + ❌ Kein Treffer (Distanz: ${data.levenshtein_distance}, Bereinigt: "${data.input_clean}" ↔ "${data.target_clean}") + + `; + } + } catch (err) { + resultBox.innerHTML = 'Fehler bei der Prüfung.'; + } +} diff --git a/app/static/js/quiz.js b/app/static/js/quiz.js new file mode 100755 index 0000000..a7047cc --- /dev/null +++ b/app/static/js/quiz.js @@ -0,0 +1,249 @@ +let currentSessionId = null; +let currentQuestionNumber = 1; +let totalQuestions = 15; +let currentScore = 0; +let attemptsUsed = 0; +const maxAttempts = 3; + +// Web Audio API Sound Generator for magic chimes and buzzers +const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + +function playSound(type) { + if (audioCtx.state === 'suspended') { + audioCtx.resume(); + } + if (type === 'magic') { + // Magical bright arpeggio chime + const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6 + notes.forEach((freq, index) => { + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.15, audioCtx.currentTime + index * 0.08); + gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + index * 0.08 + 0.35); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(audioCtx.currentTime + index * 0.08); + osc.stop(audioCtx.currentTime + index * 0.08 + 0.35); + }); + } else if (type === 'wrong') { + // Soft error buzz + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(150, audioCtx.currentTime); + osc.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime + 0.25); + gain.gain.setValueAtTime(0.1, audioCtx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.25); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(); + osc.stop(audioCtx.currentTime + 0.25); + } +} + +async function startGame() { + const envelope = document.getElementById("envelope"); + const startBtn = document.getElementById("startBtn"); + + startBtn.disabled = true; + startBtn.innerText = "✨ Zauber wird gewirkt..."; + envelope.classList.add("envelope-opened"); + + // Start background music if enabled or first click + const bgm = document.getElementById("bgm"); + if (bgm && bgm.paused) { + bgm.play().then(() => { + const icon = document.getElementById("audioIcon"); + const text = document.getElementById("audioText"); + if (icon && text) { + icon.innerText = "🔊"; + text.innerText = "Musik: An"; + } + localStorage.setItem("bgm_enabled", "true"); + }).catch(() => {}); + } + + try { + const res = await fetch("/api/game/start", { + method: "POST", + headers: { "Content-Type": "application/json" } + }); + + if (!res.ok) { + throw new Error("Konnte Spiel nicht starten."); + } + + const data = await res.json(); + currentSessionId = data.session_id; + currentQuestionNumber = data.question_number; + totalQuestions = data.total_questions; + currentScore = data.score; + attemptsUsed = data.attempts_used; + + setTimeout(() => { + document.getElementById("introCard").classList.add("hidden"); + document.getElementById("gameCard").classList.remove("hidden"); + renderQuestion(data); + }, 1100); + + } catch (err) { + alert("Fehler beim Starten des Quiz: " + err.message); + startBtn.disabled = false; + startBtn.innerText = "📜 Siegel brechen & Brief öffnen"; + envelope.classList.remove("envelope-opened"); + } +} + +function renderQuestion(qData) { + document.getElementById("questionCounter").innerText = `Frage ${qData.question_number} von ${totalQuestions}`; + document.getElementById("pointsCounter").innerText = `🏆 Hauspunkte: ${currentScore}`; + document.getElementById("badgeCategory").innerText = `⚡ ${qData.category.replace("_", " ").toUpperCase()}`; + document.getElementById("attemptBadge").innerText = `Versuch ${attemptsUsed + 1} von ${maxAttempts}`; + document.getElementById("frageText").innerText = qData.question; + + const input = document.getElementById("answerInput"); + input.value = ""; + input.disabled = false; + input.focus(); + + document.getElementById("checkBtn").disabled = false; + document.getElementById("hintBtn").disabled = false; + document.getElementById("hintBox").classList.add("hidden"); + document.getElementById("feedbackBox").innerHTML = ""; + + updateProgress(); +} + +function updateProgress() { + const percent = ((currentQuestionNumber - 1) / totalQuestions) * 100; + document.getElementById("progressBar").style.width = percent + "%"; +} + +async function checkAnswer() { + const input = document.getElementById("answerInput"); + const answer = input.value.trim(); + + if (!answer) { + input.focus(); + return; + } + + const checkBtn = document.getElementById("checkBtn"); + checkBtn.disabled = true; + + try { + const res = await fetch("/api/game/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + session_id: currentSessionId, + answer: answer + }) + }); + + if (!res.ok) { + throw new Error("Fehler bei der Antwortprüfung."); + } + + const result = await res.json(); + currentScore = result.score; + document.getElementById("pointsCounter").innerText = `🏆 Hauspunkte: ${currentScore}`; + + const frageBox = document.getElementById("frageBox"); + const feedbackBox = document.getElementById("feedbackBox"); + + if (result.correct) { + // Success! + playSound('magic'); + frageBox.classList.add("sparkle-effect"); + feedbackBox.innerHTML = `✨ Richtig! 1 Hauspunkt für dein Haus! ✨`; + input.disabled = true; + + setTimeout(() => { + frageBox.classList.remove("sparkle-effect"); + if (result.is_finished) { + finishGame(); + } else if (result.next_question) { + currentQuestionNumber = result.next_question.question_number; + attemptsUsed = 0; + renderQuestion(result.next_question); + } + }, 1200); + + } else { + // Incorrect + playSound('wrong'); + frageBox.classList.add("shake-effect"); + setTimeout(() => frageBox.classList.remove("shake-effect"), 500); + + attemptsUsed = result.attempts_used; + document.getElementById("attemptBadge").innerText = `Versuch ${Math.min(attemptsUsed + 1, maxAttempts)} von ${maxAttempts}`; + + if (result.attempts_left === 0) { + // Exhausted all 3 attempts + feedbackBox.innerHTML = ` +
Leider nicht richtig (3 Fehlversuche).
+
Richtige Lösung: ${result.revealed_answer}
+ `; + input.disabled = true; + + setTimeout(() => { + if (result.is_finished) { + finishGame(); + } else if (result.next_question) { + currentQuestionNumber = result.next_question.question_number; + attemptsUsed = 0; + renderQuestion(result.next_question); + } + }, 2200); + + } else { + // Still attempts remaining + feedbackBox.innerHTML = `Leider falsch! Noch ${result.attempts_left} Versuch(e).`; + checkBtn.disabled = false; + input.select(); + } + } + + } catch (err) { + alert("Netzwerkfehler: " + err.message); + checkBtn.disabled = false; + } +} + +async function requestHint() { + if (!currentSessionId) return; + const hintBox = document.getElementById("hintBox"); + const hintText = document.getElementById("hintText"); + + try { + const res = await fetch(`/api/game/hint?session_id=${encodeURIComponent(currentSessionId)}`); + const data = await res.json(); + hintText.innerText = data.hint || "Kein Tipp vorhanden."; + hintBox.classList.remove("hidden"); + } catch (err) { + hintText.innerText = "Tipp konnte nicht geladen werden."; + hintBox.classList.remove("hidden"); + } +} + +function finishGame() { + // 100% progress + document.getElementById("progressBar").style.width = "100%"; + window.location.href = `/finale?score=${currentScore}&total=${totalQuestions}`; +} + +// Enter key to submit +document.addEventListener("DOMContentLoaded", () => { + const input = document.getElementById("answerInput"); + if (input) { + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + checkAnswer(); + } + }); + } +}); diff --git a/app/static/js/vorschlag.js b/app/static/js/vorschlag.js new file mode 100755 index 0000000..37fd63c --- /dev/null +++ b/app/static/js/vorschlag.js @@ -0,0 +1,61 @@ +async function submitSuggestion(event) { + event.preventDefault(); + + 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 submitBtn = document.getElementById("submitBtn"); + const resultBox = document.getElementById("resultMessage"); + + if (!frage || !antwort) { + alert("Bitte gib mindestens die Frage und die richtige Antwort ein."); + return; + } + + submitBtn.disabled = true; + submitBtn.innerText = "✨ Vorschlag wird übertragen..."; + + try { + const res = await fetch("/api/suggestions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name || "Gast", + frage: frage, + antwort: antwort, + tipp: tipp || null + }) + }); + + const data = await res.json(); + + if (res.ok && data.success) { + resultBox.innerHTML = ` +
+

✨ Vorschlag erfolgreich übermittelt! ✨

+

${data.message}

+
+ `; + resultBox.classList.remove("hidden"); + document.getElementById("suggestionForm").reset(); + } else { + resultBox.innerHTML = ` +
+ ${data.detail || "Fehler beim Einreichen deines Vorschlags."} +
+ `; + resultBox.classList.remove("hidden"); + } + } catch (err) { + resultBox.innerHTML = ` +
+ Netzwerkfehler: ${err.message} +
+ `; + resultBox.classList.remove("hidden"); + } finally { + submitBtn.disabled = false; + submitBtn.innerText = "📜 Vorschlag an das Ministerium senden"; + } +} diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100755 index 0000000..6b8fb14 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} + +{% block title %}⚡ Zaubereiministerium – Cockpit{% endblock %} + +{% block content %} +
+
+
+

⚡ Großinquisitor-Cockpit

+

Eingeloggt als: {{ admin_user }}

+
+ +
+ + +
+ + + + {% if rejected_questions|length > 0 %} + + {% endif %} +
+ + +
+

📥 Eingegangene Vorschläge (Prüfung & Validierung erforderlich)

+

Diese Fragen wurden von Besuchern eingereicht. Bitte teste die Antwort und schalte die Frage frei oder lehne sie ab.

+ + {% if pending_questions|length == 0 %} +
+

🎉 Keine offenen Vorschläge zur Prüfung vorhanden!

+
+ {% else %} +
+ {% for q in pending_questions %} +
+
+ ⚠️ Status: Prüfung ausstehend + Von: {{ q.submitted_by or 'Gast' }} am {{ q.created_at.strftime('%d.%m.%Y %H:%M') }} +
+ +
+

{{ q.question }}

+

Erwartete Antwort: {{ q.canonical_answer }}

+ {% if q.hint %} +

Tipp: {{ q.hint }}

+ {% endif %} + + +
+ +
+ + +
+
+
+
+ +
+ + + +
+ + + +
+ {% endfor %} +
+ {% endif %} +
+ + +
+

📜 Aktive Fragen im Live-Quiz ({{ active_questions|length }})

+

Diese Fragen werden den Spielern in der angegebenen Reihenfolge gestellt.

+ +
+ {% for q in active_questions %} +
+
+ Reihenfolge #{{ q.sort_order }} + Kategorie: {{ q.category }} +
+ +
+

{{ loop.index }}. {{ q.question }}

+

Antwort: {{ q.canonical_answer }}

+ {% if q.hint %} +

Tipp: {{ q.hint }}

+ {% endif %} +
+ +
+ + +
+ + + +
+ {% endfor %} +
+
+ + +
+

➕ Neue Frage direkt zum Live-Quiz hinzufügen

+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + + {% if rejected_questions|length > 0 %} +
+

❌ Abgelehnte / Archivierte Vorschläge

+
+ {% for q in rejected_questions %} +
+
+ Abgelehnt + Eingereicht von: {{ q.submitted_by or 'Gast' }} +
+
+

{{ q.question }}

+

Antwort: {{ q.canonical_answer }}

+
+
+ + +
+
+ {% endfor %} +
+
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/admin_login.html b/app/templates/admin_login.html new file mode 100755 index 0000000..e29604f --- /dev/null +++ b/app/templates/admin_login.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}🔒 Zaubereiministerium – Login{% endblock %} + +{% block content %} +
+ +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100755 index 0000000..cfdc688 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,69 @@ + + + + + + {% block title %}Magisches Harry-Potter-Escape-Quiz{% endblock %} + + + {% block head %}{% endblock %} + + + + +
+ + +
+ +
+ {% block content %}{% endblock %} +
+ + + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/finale.html b/app/templates/finale.html new file mode 100755 index 0000000..31e5256 --- /dev/null +++ b/app/templates/finale.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}🎉 Das große Finale – Magisches Quiz{% endblock %} + +{% block content %} +
+
+
+ ✨ +

🎉 Happy Birthday Schatz! 🎉

+ ✨ +
+ +
+

+ Du hast {{ score }} von {{ total }} möglichen Hauspunkten für dein Haus gesichert! +

+
+ +
+

💛 Dein magischer Gewinn: Ein unvergesslicher Abend Zuhause 💛

+

+ Wir holen uns lecker Essen und machen es uns so richtig gemütlich: entspannen, lachen und die gemeinsame Zeit genießen.

+ Dazu spielen wir ein brandneues Online Escape Game deiner Wahl! +

+

Ich freue mich riesig auf unseren gemeinsamen Abend! 💫

+
+ + +
+
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100755 index 0000000..14610fd --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} + +{% block title %}✨ Magisches Harry-Potter-Escape-Quiz ✨{% endblock %} + +{% block content %} +
+
+

✨ Magisches Hogwarts-Escape-Quiz ✨

+

15 knifflige Rätsel aus der Zauberwelt warten auf dich

+
+ + +
+
+
+
+
H
+
+

Hogwarts-Schule für Hexerei und Zauberei

+
+
+
+
+

+ Ein verzauberter Brief aus Hogwarts schwebt vor dir in der Luft.
+ Bist du bereit, die Prüfungen abzulegen und deine Hauspunkte zu sichern? +

+ +
+
+ + + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/vorschlag.html b/app/templates/vorschlag.html new file mode 100755 index 0000000..549b2cb --- /dev/null +++ b/app/templates/vorschlag.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block title %}✍️ Neue Zauberfrage einreichen{% endblock %} + +{% block content %} +
+
+

✨ Neue Frage für das Quiz vorschlagen ✨

+

Bereichere das magische Quiz mit kniffligen Harry-Potter- oder Escape-Rätseln!

+ +
+ ℹ️ 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. +
+ +
+
+ + +
+ +
+ + +
+ +
+ + + Unser System gleicht Tippfehler und Groß-/Kleinschreibung automatisch ab. +
+ +
+ + +
+ + +
+ + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..7a6c687 Binary files /dev/null and b/favicon.ico differ diff --git a/legacy/admin.html b/legacy/admin.html new file mode 100644 index 0000000..639a447 --- /dev/null +++ b/legacy/admin.html @@ -0,0 +1,25 @@ + + + + +Fragen-Admin + + + + + +

Fragen-Editor

+ +
+ +

Neue Frage hinzufügen

+
+ + + + +
+ + + + diff --git a/legacy/css/style.css b/legacy/css/style.css new file mode 100644 index 0000000..3541ef6 --- /dev/null +++ b/legacy/css/style.css @@ -0,0 +1,160 @@ +:root { + --bg: #0b0b15; + --fg: #f5f5f5; + --accent: #c9a86a; + --accent-soft: #e6c98f; + --box-bg: rgba(255,255,255,0.05); +} +body { + background: var(--bg); + color: var(--fg); + font-family: "Georgia", serif; + text-align: center; + padding: 20px; +} +.hidden { display: none; } + +.box { + border: 2px solid var(--accent); + padding: 20px; + margin: 20px auto; + width: 90%; + max-width: 600px; + background: var(--box-bg); + border-radius: 10px; +} + +input, textarea { + padding: 10px; + border-radius: 5px; + border: none; + margin-top: 10px; + width: 80%; + max-width: 250px; + text-align: center; +} + +button { + padding: 10px 20px; + background: var(--accent); + border: none; + border-radius: 5px; + cursor: pointer; + margin-top: 10px; + font-weight: bold; + color: #000; +} +button:hover { + background: var(--accent-soft); +} + +/* Fortschrittsleiste */ +.progress-container { + width: 90%; + max-width: 600px; + margin: 10px auto; + background: rgba(255,255,255,0.1); + border-radius: 10px; + border: 1px solid var(--accent); + overflow: hidden; +} +.progress-bar { + height: 14px; + background: linear-gradient(90deg, #c9a86a, #e6c98f); + width: 0%; + transition: width 0.3s; +} + +/* Briefumschlag */ +.intro-envelope { + position: relative; + width: 220px; + height: 150px; + margin: 0 auto 20px; +} + +.envelope-body { + width: 100%; + height: 100%; + background: #f5f5dc; + border-radius: 5px; + border: 2px solid #c9a86a; + position: relative; + overflow: hidden; + z-index: 1; +} + +.envelope-flap { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #e6d8a8; + clip-path: polygon(0 0, 50% 60%, 100% 0); + transform-origin: top; + transition: transform 0.8s ease-in-out; + z-index: 2; +} + +.open .envelope-flap { + transform: rotateX(180deg); +} + +.sparkle::after { + content: ""; + position: absolute; + top: -10px; + left: 50%; + width: 6px; + height: 6px; + border-radius: 50%; + background: #ffeaa7; + box-shadow: + 0 0 8px #ffeaa7, + -20px -10px 6px rgba(255,255,255,0.6), + 15px -5px 6px rgba(255,255,255,0.5), + -10px 10px 6px rgba(255,255,255,0.4), + 20px 15px 6px rgba(255,255,255,0.5); + animation: sparkle 0.7s ease-out forwards; +} + +@keyframes sparkle { + from { opacity: 1; transform: translate(-50%, 0) scale(1); } + to { opacity: 0; transform: translate(-50%, -20px) scale(0.5); } +} + +/* loesungen.html */ +.loesungen-list .box { + background: #222; + border-left: 4px solid var(--accent); + border: none; +} + +/* vorschlag.html & admin.html */ +body.light-theme { + background: #f5f5f5; + color: #000; +} +body.light-theme .box { + background: white; + box-shadow: 0 0 5px rgba(0,0,0,0.1); +} + +textarea { + resize: vertical; + font-family: Arial, sans-serif; +} + +label { + display: block; + margin-top: 15px; + text-align: left; + font-weight: bold; +} + +/* admin.html */ +.box strong { + display: block; + margin-bottom: 10px; +} \ No newline at end of file diff --git a/legacy/error/400.html b/legacy/error/400.html new file mode 100644 index 0000000..582012e --- /dev/null +++ b/legacy/error/400.html @@ -0,0 +1,20 @@ + + + + +400 – Verirrter Zauber + + + + +
+

400 – Verirrter Zauber

+

Der letzte Zauberspruch war etwas durcheinander geraten.

+

Zurück zum magischen Quiz

+
+ + diff --git a/legacy/error/401.html b/legacy/error/401.html new file mode 100644 index 0000000..b791376 --- /dev/null +++ b/legacy/error/401.html @@ -0,0 +1,20 @@ + + + + +401 – Zutritt nur für Zauberer + + + + +
+

401 – Zutritt nur für Zauberer

+

Für diesen Bereich brauchst du noch eine magische Legitimation.

+

Zurück zum magischen Quiz

+
+ + diff --git a/legacy/error/403.html b/legacy/error/403.html new file mode 100644 index 0000000..3badc78 --- /dev/null +++ b/legacy/error/403.html @@ -0,0 +1,20 @@ + + + + +403 – Verbotener Bereich + + + + +
+

403 – Verbotener Bereich

+

Dieser Teil der Zauberwelt ist streng geschützt.

+

Zurück zum magischen Quiz

+
+ + diff --git a/legacy/error/404.html b/legacy/error/404.html new file mode 100644 index 0000000..f8f4f0c --- /dev/null +++ b/legacy/error/404.html @@ -0,0 +1,20 @@ + + + + +404 – Karte des Rumtreibers leer + + + + +
+

404 – Seite nicht gefunden

+

Die Karte des Rumtreibers zeigt hier nichts an.

+

Zurück zum magischen Quiz

+
+ + diff --git a/legacy/error/405.html b/legacy/error/405.html new file mode 100644 index 0000000..c11a814 --- /dev/null +++ b/legacy/error/405.html @@ -0,0 +1,20 @@ + + + + +405 – Unzulässiger Zauber + + + + +
+

405 – Unzulässiger Zauber

+

Diese Art von Zauberspruch ist hier nicht erlaubt.

+

Zurück zum magischen Quiz

+
+ + diff --git a/legacy/error/500.html b/legacy/error/500.html new file mode 100644 index 0000000..c48a164 --- /dev/null +++ b/legacy/error/500.html @@ -0,0 +1,20 @@ + + + + +500 – Explosion im Zaubertrank-Labor + + + + +
+

500 – Explosion im Zaubertrank-Labor

+

Irgendetwas ist gründlich schiefgegangen. Die Hauselfen räumen schon auf.

+

Zurück zum magischen Quiz

+
+ + diff --git a/legacy/error/502.html b/legacy/error/502.html new file mode 100644 index 0000000..e6ecd3f --- /dev/null +++ b/legacy/error/502.html @@ -0,0 +1,20 @@ + + + + +502 – Fehlgeleiteter Eulenflug + + + + +
+

502 – Fehlgeleiteter Eulenflug

+

Die Nachricht hat das Ziel nicht richtig erreicht.

+

Zurück zum magischen Quiz

+
+ + diff --git a/legacy/error/503.html b/legacy/error/503.html new file mode 100644 index 0000000..4e1ca8f --- /dev/null +++ b/legacy/error/503.html @@ -0,0 +1,20 @@ + + + + +503 – Zaubereiministerium beschäftigt + + + + +
+

503 – Zaubereiministerium beschäftigt

+

Die magischen Dienste sind gerade ausgelastet. Versuche es später erneut.

+

Zurück zum magischen Quiz

+
+ + diff --git a/legacy/favicon.ico b/legacy/favicon.ico new file mode 100644 index 0000000..7a6c687 Binary files /dev/null and b/legacy/favicon.ico differ diff --git a/legacy/finale.html b/legacy/finale.html new file mode 100644 index 0000000..e6f4780 --- /dev/null +++ b/legacy/finale.html @@ -0,0 +1,28 @@ + + + + +Finale – Magisches Quiz + + + + + +
+

🎉 Happy Birthday Schatz 🎉

+

+ +

💛 Der Gewinn ist ein schöner Abend Zuhause 💛

+

+ Wir holen uns lecker essen und machen es uns gemütlich, entspannen, lachen und genießen die Zeit zusammen. + Wir kaufen ein Online escape game. Nach deiner Wahl, da ich nicht mehr weiß welche wir hatten. +

+ +

Ich freue mich auf unseren Abend.

+ + +
+ + + + diff --git a/legacy/fragen.json b/legacy/fragen.json new file mode 100644 index 0000000..f4eedac --- /dev/null +++ b/legacy/fragen.json @@ -0,0 +1,77 @@ +[ + { + "frage": "Wie lautet der zweite Vorname von Harry?", + "antwort": "james", + "tipp": "Er stammt von seinem Vater." + }, + { + "frage": "Wie heißt die Hüterin der verbotenen Abteilung?", + "antwort": "madam pince", + "tipp": "Ihr Nachname klingt wie 'zwicken'." + }, + { + "frage": "Welches magische Wesen bewacht die Kammer des Schreckens?", + "antwort": "basilisk", + "tipp": "Ein tödlicher Blick." + }, + { + "frage": "Wie lautet der Vorname von Dumbledores Bruder?", + "antwort": "aberforth", + "tipp": "Er betreibt den Eberkopf." + }, + { + "frage": "Welcher Zauber versiegelt Türen?", + "antwort": "colloportus", + "tipp": "Kommt im fünften Band häufig vor." + }, + { + "frage": "Wie heißt der Hauself der Familie Black?", + "antwort": "kreacher", + "tipp": "Er ist nicht besonders freundlich." + }, + { + "frage": "Wie lautet der Zauber, der Gegner entwaffnet?", + "antwort": "expelliarmus", + "tipp": "Harry benutzt ihn ständig." + }, + { + "frage": "Mein Lieblingsessen?", + "antwort": "Raclette", + "tipp": "Käse :)" + }, + { + "frage": "Dein Lieblingsessen?", + "antwort": "Ravioli", + "tipp": "Gefüllt ist's. " + }, + { + "frage": "Wo haben wir uns das erste mal geküsst?", + "antwort": "Olching", + "tipp": "Nur der Ort ist wichtig" + }, + { + "frage": "Mein erster Geburtstagskuchen für dich?", + "antwort": "Donauwelle", + "tipp": "Erst gestern war es witztig" + }, + { + "frage": "Erster Urlaub?", + "antwort": "Den Haag", + "tipp": "Nicht das land sondern der Ort" + }, + { + "frage": "Lieblingskörperstelle?", + "antwort": "Ombelico", + "tipp": "Es hat so schön geprickelt" + }, + { + "frage": "Als filmpaar sind wir am ehesten?", + "antwort": "Monroe&Rosalee", + "tipp": "Grimm ist die Serie" + }, + { + "frage": "Wie heißt unser erster Hund?", + "antwort": "Molly", + "tipp": "mal was einfaches" + } +] diff --git a/legacy/fragen_vorschlaege.json b/legacy/fragen_vorschlaege.json new file mode 100644 index 0000000..08d0c46 --- /dev/null +++ b/legacy/fragen_vorschlaege.json @@ -0,0 +1,7 @@ +[ + { + "frage": "Hagrid schlüsselbund hat wie viele Schlüssel?", + "antwort": "7", + "tipp": "weil es immer 3,5 oder 7 sind." + } +] \ No newline at end of file diff --git a/legacy/index.html b/legacy/index.html new file mode 100644 index 0000000..0ca7601 --- /dev/null +++ b/legacy/index.html @@ -0,0 +1,34 @@ + + + + +Magisches Harry-Potter-Escape-Quiz + + + + + + + +

✨ Magisches Spatzi-Quiz ✨

+ +
+
+
+
Hauspunkte: 0
+ +
+
+
+
+
+
+

Ein verzauberter Briefumschlag ist vor dir erschienen.

+ +
+ +
+ + + + diff --git a/legacy/js/admin.js b/legacy/js/admin.js new file mode 100644 index 0000000..b95a3ad --- /dev/null +++ b/legacy/js/admin.js @@ -0,0 +1,54 @@ +let daten = []; + +async function load() { + const res = await fetch("fragen.json"); + daten = await res.json(); + render(); +} + +function render() { + const container = document.getElementById("fragen"); + container.innerHTML = ""; + + daten.forEach((f, i) => { + container.innerHTML += ` +
+ Frage ${i+1} + + + + +
+ `; + }); +} + +function edit(i, field, value) { + daten[i][field] = value; + save(); +} + +function addFrage() { + daten.push({ + frage: document.getElementById("newFrage").value, + antwort: document.getElementById("newAntwort").value, + tipp: document.getElementById("newTipp").value + }); + save(); +} + +function removeFrage(i) { + daten.splice(i, 1); + save(); +} + +async function save() { + await fetch("save.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(daten) + }); + load(); +} + +load(); diff --git a/legacy/js/finale.js b/legacy/js/finale.js new file mode 100644 index 0000000..86445bc --- /dev/null +++ b/legacy/js/finale.js @@ -0,0 +1,10 @@ +function getParam(name) { + const params = new URLSearchParams(window.location.search); + return params.get(name); +} + +const score = getParam("score") || 0; +const max = getParam("max") || 10; + +document.getElementById("scoreText").innerText = + `Du hast ${score} von ${max} möglichen Hauspunkten erreicht.`; diff --git a/legacy/js/index.js b/legacy/js/index.js new file mode 100644 index 0000000..4bee0d1 --- /dev/null +++ b/legacy/js/index.js @@ -0,0 +1,110 @@ +function levenshtein(a, b) { + const matrix = Array.from({ length: a.length + 1 }, (_, i) => + Array.from({ length: b.length + 1 }, (_, j) => + i === 0 ? j : j === 0 ? i : 0 + ) + ); + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1) + ); + } + } + return matrix[a.length][b.length]; +} + +let fragen = []; +let index = 0; +let punkte = 0; +let versuche = []; + +function startGame() { + document.getElementById("envelope").classList.add("open"); + + setTimeout(() => { + document.getElementById("intro").classList.add("hidden"); + document.getElementById("bgm").play(); + loadQuestions(); + }, 900); +} + +async function loadQuestions() { + const res = await fetch("fragen.json"); + fragen = await res.json(); + + versuche = Array(fragen.length).fill(0); + + renderFrage(); + updateProgress(); +} + +function renderFrage() { + const f = fragen[index]; + document.getElementById("questions").innerHTML = ` +
+

Frage ${index + 1}

+

${f.frage}

+ + + + +

Versuche: 0 / 3

+

+
+ `; +} + +function showTip() { + document.getElementById("tipp").classList.remove("hidden"); +} + +function check() { + const input = document.getElementById("antwort").value.toLowerCase().trim(); + const korrekt = fragen[index].antwort.toLowerCase().trim(); + + versuche[index]++; + document.getElementById("versuche").innerText = `Versuche: ${versuche[index]} / 3`; + + const dist = levenshtein(input, korrekt); + + if (dist <= 2) { + punkte++; + addSparkle(); + next(); + return; + } + + if (versuche[index] >= 3) { + next(); + return; + } + + document.getElementById("feedback").innerText = "Leider falsch."; +} + +function addSparkle() { + const el = document.getElementById("frageBox"); + el.classList.add("sparkle"); + setTimeout(() => el.classList.remove("sparkle"), 700); +} + +function next() { + index++; + updateProgress(); + + if (index >= fragen.length) { + window.location.href = `finale.html?score=${punkte}&max=${fragen.length}`; + return; + } + + renderFrage(); +} + +function updateProgress() { + const percent = (index / fragen.length) * 100; + document.getElementById("progressBar").style.width = percent + "%"; + document.getElementById("progressLabel").innerText = `Hauspunkte: ${punkte}`; +} diff --git a/legacy/js/loesungen.js b/legacy/js/loesungen.js new file mode 100644 index 0000000..bd8c3e9 --- /dev/null +++ b/legacy/js/loesungen.js @@ -0,0 +1,17 @@ +async function load() { + const res = await fetch("fragen.json"); + const fragen = await res.json(); + + const container = document.getElementById("loesungen"); + + fragen.forEach((f, i) => { + container.innerHTML += ` +
+ ${i + 1}. ${f.frage}
+ ${f.antwort} +
+ `; + }); +} + +load(); diff --git a/legacy/js/vorschlag.js b/legacy/js/vorschlag.js new file mode 100644 index 0000000..570fa40 --- /dev/null +++ b/legacy/js/vorschlag.js @@ -0,0 +1,31 @@ +async function send() { + const frage = document.getElementById("frage").value.trim(); + const antwort = document.getElementById("antwort").value.trim(); + const tipp = document.getElementById("tipp").value.trim(); + + if (!frage || !antwort) { + alert("Bitte mindestens Frage und Antwort ausfüllen."); + return; + } + + const payload = { + frage: frage, + antwort: antwort, + tipp: tipp + }; + + const res = await fetch("save_vorschlag.php", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (res.ok) { + alert("Danke! Dein Vorschlag wurde gespeichert."); + document.getElementById("frage").value = ""; + document.getElementById("antwort").value = ""; + document.getElementById("tipp").value = ""; + } else { + alert("Fehler beim Speichern. Bitte später erneut versuchen."); + } +} diff --git a/legacy/loesungen.html b/legacy/loesungen.html new file mode 100644 index 0000000..92a82bf --- /dev/null +++ b/legacy/loesungen.html @@ -0,0 +1,15 @@ + + + + +Lösungen – Magisches Quiz + + + + +

🔐 Lösungen zum Harry-Potter-Escape-Quiz

+
+ + + + diff --git a/legacy/robots.txt b/legacy/robots.txt new file mode 100644 index 0000000..7d329b1 --- /dev/null +++ b/legacy/robots.txt @@ -0,0 +1 @@ +User-agent: * diff --git a/legacy/save.php b/legacy/save.php new file mode 100644 index 0000000..d661859 --- /dev/null +++ b/legacy/save.php @@ -0,0 +1,5 @@ + diff --git a/legacy/save_vorschlag.php b/legacy/save_vorschlag.php new file mode 100644 index 0000000..c73a371 --- /dev/null +++ b/legacy/save_vorschlag.php @@ -0,0 +1,37 @@ + "Ungültige Daten"]); + exit; +} + +// Existierende Vorschläge laden +$existing = []; +if (file_exists($file)) { + $json = file_get_contents($file); + $existing = json_decode($json, true); + if (!is_array($existing)) { + $existing = []; + } +} + +// Neuen Vorschlag anhängen +$existing[] = $data; + +// Datei speichern +if (file_put_contents($file, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) { + echo json_encode(["success" => true]); +} else { + http_response_code(500); + echo json_encode(["error" => "Konnte Datei nicht schreiben"]); +} +?> diff --git a/legacy/vorschlag.html b/legacy/vorschlag.html new file mode 100644 index 0000000..d4b421f --- /dev/null +++ b/legacy/vorschlag.html @@ -0,0 +1,31 @@ + + + + +Frage vorschlagen + + + + + +

✨ Frage vorschlagen ✨

+ +

Hier kannst du eine neue Frage für das Quiz vorschlagen. +Die Frage wird gespeichert und später geprüft.

+ +
+ + + + + + + + + + +
+ + + + diff --git a/quiz.dev.waiswuaschd.de.conf b/quiz.dev.waiswuaschd.de.conf new file mode 100644 index 0000000..60309d9 --- /dev/null +++ b/quiz.dev.waiswuaschd.de.conf @@ -0,0 +1,39 @@ +server { + listen 80; + listen [::]:80; + server_name quiz.dev.waiswuaschd.de; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name quiz.dev.waiswuaschd.de; + + ssl_certificate /etc/nginx/ssl/quiz.dev.waiswuaschd.de/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/quiz.dev.waiswuaschd.de/key.pem; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location /static/ { + alias /home/maxi/Projekt/quiz.waiswuaschd.de/app/static/; + expires 7d; + } + + location / { + proxy_pass http://127.0.0.1:8001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/quiz.service b/quiz.service new file mode 100644 index 0000000..f3c5c4d --- /dev/null +++ b/quiz.service @@ -0,0 +1,15 @@ +[Unit] +Description=Magisches Quiz Portal (FastAPI) +After=network.target + +[Service] +User=maxi +Group=maxi +WorkingDirectory=/home/maxi/Projekt/quiz.waiswuaschd.de +ExecStart=/home/maxi/Projekt/quiz.waiswuaschd.de/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8001 --workers 2 +Restart=always +RestartSec=5 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..7d329b1 --- /dev/null +++ b/robots.txt @@ -0,0 +1 @@ +User-agent: * diff --git a/test_quiz.py b/test_quiz.py new file mode 100644 index 0000000..6cd9d20 --- /dev/null +++ b/test_quiz.py @@ -0,0 +1,78 @@ +import urllib.request +import urllib.parse +import http.cookiejar +import json + +BASE_URL = "http://127.0.0.1:8001" + +def test_flow(): + print("--- 1. Test Game Start ---") + req = urllib.request.Request(f"{BASE_URL}/api/game/start", data=b"{}", headers={"Content-Type": "application/json"}) + res = urllib.request.urlopen(req) + start_data = json.loads(res.read().decode()) + print("Start Response:", start_data) + session_id = start_data["session_id"] + assert start_data["question_number"] == 1 + assert start_data["score"] == 0 + assert "canonical_answer" not in start_data + + print("\n--- 2. Test Correct Answer (James) ---") + payload = json.dumps({"session_id": session_id, "answer": "James"}).encode() + req = urllib.request.Request(f"{BASE_URL}/api/game/check", data=payload, headers={"Content-Type": "application/json"}) + res = urllib.request.urlopen(req) + check_data = json.loads(res.read().decode()) + print("Check Response:", check_data) + assert check_data["correct"] is True + assert check_data["score"] == 1 + assert check_data["next_question"]["question_number"] == 2 + + print("\n--- 3. Test Wrong Answer ---") + payload = json.dumps({"session_id": session_id, "answer": "Vollkommen falsch"}).encode() + req = urllib.request.Request(f"{BASE_URL}/api/game/check", data=payload, headers={"Content-Type": "application/json"}) + res = urllib.request.urlopen(req) + wrong_data = json.loads(res.read().decode()) + print("Wrong Response:", wrong_data) + assert wrong_data["correct"] is False + assert wrong_data["attempts_used"] == 1 + assert wrong_data["attempts_left"] == 2 + + print("\n--- 4. Test Question Suggestion (Community) ---") + sugg_payload = json.dumps({ + "frage": "Wie lautet der Tarnname von Sirius Black als Hund?", + "antwort": "Tatze", + "tipp": "Einer der Rumtreiber", + "name": "Remus Lupin" + }).encode() + req = urllib.request.Request(f"{BASE_URL}/api/suggestions", data=sugg_payload, headers={"Content-Type": "application/json"}) + res = urllib.request.urlopen(req) + sugg_data = json.loads(res.read().decode()) + print("Suggestion Response:", sugg_data) + assert sugg_data["success"] is True + + print("\n--- 5. Test Admin Login & Moderation Flow ---") + cj = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj)) + + login_data = urllib.parse.urlencode({"username": "admin", "password": "Alohomora2026!"}).encode() + login_req = urllib.request.Request(f"{BASE_URL}/admin/login", data=login_data) + login_res = opener.open(login_req) + assert login_res.getcode() == 200 + print("Admin logged in successfully.") + + admin_dashboard_res = opener.open(f"{BASE_URL}/admin") + content = admin_dashboard_res.read().decode() + assert "Großinquisitor-Cockpit" in content + assert "Tatze" in content + print("Dashboard rendered with pending suggestions.") + + # Test Matcher API + test_match_data = urllib.parse.urlencode({"test_input": "Taze", "canonical": "Tatze"}).encode() + tm_res = opener.open(f"{BASE_URL}/admin/questions/test-match", data=test_match_data) + tm_json = json.loads(tm_res.read().decode()) + print("Matcher Test ('Taze' vs 'Tatze'):", tm_json) + assert tm_json["matched"] is True + + print("\n✅ All Game, Suggestion & Admin Tests Passed Successfully!") + +if __name__ == "__main__": + test_flow()