Initial commit: Modernes Harry Potter Escape Quiz Portal (FastAPI & MariaDB)

This commit is contained in:
kobold-ai
2026-09-21 11:29:18 +02:00
commit c3a21369ef
50 changed files with 3346 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
venv/
__pycache__/
*.pyc
.env
.DS_Store
setup_gitea.py
legacy/*.mp3
Executable
+16
View File
@@ -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
+32
View File
@@ -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()
Executable
+419
View File
@@ -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
}
Executable
+69
View File
@@ -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
Executable
+39
View File
@@ -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)
Executable
+41
View File
@@ -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
Executable
+76
View File
@@ -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()
BIN
View File
Binary file not shown.
+769
View File
@@ -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;
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+128
View File
@@ -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 = '<span style="color: #f39c12;">Bitte gib einen Testwert ein.</span>';
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 = `
<span style="color: #2ecc71; font-weight: bold;">
✅ Treffer! (Distanz: ${data.levenshtein_distance}, Bereinigt: "${data.input_clean}" ↔ "${data.target_clean}")
</span>
`;
} else {
resultBox.innerHTML = `
<span style="color: #e74c3c; font-weight: bold;">
❌ Kein Treffer (Distanz: ${data.levenshtein_distance}, Bereinigt: "${data.input_clean}" ↔ "${data.target_clean}")
</span>
`;
}
} catch (err) {
resultBox.innerHTML = '<span style="color: #e74c3c;">Fehler bei der Prüfung.</span>';
}
}
+249
View File
@@ -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 = `<span class="feedback-correct">✨ Richtig! 1 Hauspunkt für dein Haus! ✨</span>`;
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 = `
<div class="feedback-wrong">Leider nicht richtig (3 Fehlversuche).</div>
<div class="feedback-revealed">Richtige Lösung: <strong>${result.revealed_answer}</strong></div>
`;
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 = `<span class="feedback-wrong">Leider falsch! Noch ${result.attempts_left} Versuch(e).</span>`;
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();
}
});
}
});
+61
View File
@@ -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 = `
<div style="background: rgba(46, 204, 113, 0.2); border: 1px solid #2ecc71; color: #55efc4; padding: 15px; border-radius: 8px;">
<h3>✨ Vorschlag erfolgreich übermittelt! ✨</h3>
<p>${data.message}</p>
</div>
`;
resultBox.classList.remove("hidden");
document.getElementById("suggestionForm").reset();
} else {
resultBox.innerHTML = `
<div class="alert alert-danger">
${data.detail || "Fehler beim Einreichen deines Vorschlags."}
</div>
`;
resultBox.classList.remove("hidden");
}
} catch (err) {
resultBox.innerHTML = `
<div class="alert alert-danger">
Netzwerkfehler: ${err.message}
</div>
`;
resultBox.classList.remove("hidden");
} finally {
submitBtn.disabled = false;
submitBtn.innerText = "📜 Vorschlag an das Ministerium senden";
}
}
+236
View File
@@ -0,0 +1,236 @@
{% extends "base.html" %}
{% block title %}⚡ Zaubereiministerium – Cockpit{% endblock %}
{% block content %}
<div class="admin-wrapper">
<div class="admin-header">
<div>
<h1 class="glow-title">⚡ Großinquisitor-Cockpit</h1>
<p class="subtitle">Eingeloggt als: <strong>{{ admin_user }}</strong></p>
</div>
<div class="admin-top-actions">
<a href="/" target="_blank" class="btn btn-secondary">🌐 Zum Live-Quiz</a>
<a href="/admin/logout" class="btn btn-danger">🚪 Abmelden</a>
</div>
</div>
<!-- Navigation Tabs -->
<div class="tabs-nav">
<button class="tab-btn active" onclick="showTab('tab-pending')">
📥 Eingereichte Vorschläge
{% if pending_questions|length > 0 %}
<span class="badge badge-warning">{{ pending_questions|length }}</span>
{% endif %}
</button>
<button class="tab-btn" onclick="showTab('tab-active')">
📜 Aktive Quiz-Fragen
<span class="badge badge-info">{{ active_questions|length }}</span>
</button>
<button class="tab-btn" onclick="showTab('tab-new')">
➕ Neue Frage anlegen
</button>
{% if rejected_questions|length > 0 %}
<button class="tab-btn" onclick="showTab('tab-rejected')">
❌ Archiviert / Abgelehnt
<span class="badge badge-muted">{{ rejected_questions|length }}</span>
</button>
{% endif %}
</div>
<!-- TAB 1: Eingereichte Vorschläge (User Questions Need Test) -->
<section id="tab-pending" class="tab-content active">
<h2>📥 Eingegangene Vorschläge (Prüfung & Validierung erforderlich)</h2>
<p class="description">Diese Fragen wurden von Besuchern eingereicht. Bitte teste die Antwort und schalte die Frage frei oder lehne sie ab.</p>
{% if pending_questions|length == 0 %}
<div class="empty-state">
<p>🎉 Keine offenen Vorschläge zur Prüfung vorhanden!</p>
</div>
{% else %}
<div class="questions-list">
{% for q in pending_questions %}
<div class="card question-admin-card" id="q-card-{{ q.id }}">
<div class="q-header">
<span class="badge badge-warning">⚠️ Status: Prüfung ausstehend</span>
<span class="q-submitter">Von: <strong>{{ q.submitted_by or 'Gast' }}</strong> am {{ q.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
</div>
<div class="q-body">
<h3>{{ q.question }}</h3>
<p><strong>Erwartete Antwort:</strong> <span class="highlight-answer">{{ q.canonical_answer }}</span></p>
{% if q.hint %}
<p><strong>Tipp:</strong> <em>{{ q.hint }}</em></p>
{% endif %}
<!-- Interaktives Testfeld für den Admin -->
<div class="test-matcher-box">
<label>🧪 Antwort-Validierung testen:</label>
<div class="test-input-group">
<input type="text" id="test-input-{{ q.id }}" class="input-text-sm" placeholder="Test-Eingabe (z.B. mit Tippfehler)">
<button type="button" class="btn btn-sm btn-secondary" onclick="testMatcher({{ q.id }}, '{{ q.canonical_answer|escape }}')">Testen</button>
</div>
<div id="test-result-{{ q.id }}" class="test-result-feedback"></div>
</div>
</div>
<div class="q-actions">
<button class="btn btn-success" onclick="approveQuestion({{ q.id }})">
✅ Freischalten & Aktivieren
</button>
<button class="btn btn-secondary" onclick="toggleEdit({{ q.id }})">
✏️ Bearbeiten
</button>
<button class="btn btn-danger" onclick="rejectQuestion({{ q.id }})">
❌ Ablehnen
</button>
</div>
<!-- Inline Edit Form (Hidden by default) -->
<div id="edit-form-{{ q.id }}" class="inline-edit-form hidden">
<form action="/admin/questions/{{ q.id }}/edit" method="post">
<div class="form-group">
<label>Frage:</label>
<input type="text" name="question" class="input-text" value="{{ q.question }}" required>
</div>
<div class="form-group">
<label>Richtige Antwort:</label>
<input type="text" name="canonical_answer" class="input-text" value="{{ q.canonical_answer }}" required>
</div>
<div class="form-group">
<label>Tipp:</label>
<input type="text" name="hint" class="input-text" value="{{ q.hint or '' }}">
</div>
<input type="hidden" name="status_val" value="active">
<button type="submit" class="btn btn-primary">Speichern & Aktivieren</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</section>
<!-- TAB 2: Aktive Fragen -->
<section id="tab-active" class="tab-content">
<h2>📜 Aktive Fragen im Live-Quiz ({{ active_questions|length }})</h2>
<p class="description">Diese Fragen werden den Spielern in der angegebenen Reihenfolge gestellt.</p>
<div class="questions-list">
{% for q in active_questions %}
<div class="card question-admin-card" id="q-card-{{ q.id }}">
<div class="q-header">
<span class="badge badge-success">Reihenfolge #{{ q.sort_order }}</span>
<span class="q-category">Kategorie: {{ q.category }}</span>
</div>
<div class="q-body">
<h3>{{ loop.index }}. {{ q.question }}</h3>
<p><strong>Antwort:</strong> <span class="highlight-answer">{{ q.canonical_answer }}</span></p>
{% if q.hint %}
<p><strong>Tipp:</strong> <em>{{ q.hint }}</em></p>
{% endif %}
</div>
<div class="q-actions">
<button class="btn btn-secondary btn-sm" onclick="toggleEdit({{ q.id }})">✏️ Bearbeiten</button>
<button class="btn btn-danger btn-sm" onclick="deleteQuestion({{ q.id }})">🗑️ Löschen</button>
</div>
<!-- Inline Edit Form -->
<div id="edit-form-{{ q.id }}" class="inline-edit-form hidden">
<form action="/admin/questions/{{ q.id }}/edit" method="post">
<div class="form-group">
<label>Frage:</label>
<input type="text" name="question" class="input-text" value="{{ q.question }}" required>
</div>
<div class="form-group">
<label>Richtige Antwort:</label>
<input type="text" name="canonical_answer" class="input-text" value="{{ q.canonical_answer }}" required>
</div>
<div class="form-group">
<label>Tipp:</label>
<input type="text" name="hint" class="input-text" value="{{ q.hint or '' }}">
</div>
<div class="form-group">
<label>Reihenfolge (Sort Order):</label>
<input type="number" name="sort_order" class="input-text-sm" value="{{ q.sort_order }}">
</div>
<input type="hidden" name="status_val" value="active">
<button type="submit" class="btn btn-primary">Änderungen speichern</button>
</form>
</div>
</div>
{% endfor %}
</div>
</section>
<!-- TAB 3: Neue Frage anlegen -->
<section id="tab-new" class="tab-content">
<h2>➕ Neue Frage direkt zum Live-Quiz hinzufügen</h2>
<div class="card">
<form action="/admin/questions/new" method="post" class="magic-form">
<div class="form-group">
<label for="category">Kategorie:</label>
<select name="category" id="category" class="input-text">
<option value="harry_potter" selected>Harry Potter / Escape</option>
<option value="insider">Persönlich / Insider</option>
<option value="general">Allgemeines Rätsel</option>
</select>
</div>
<div class="form-group">
<label for="new_question">Frage:</label>
<textarea name="question" id="new_question" class="textarea" rows="2" required placeholder="z. B. Welcher Zauber entwaffnet Gegner?"></textarea>
</div>
<div class="form-group">
<label for="new_answer">Richtige Antwort:</label>
<input type="text" name="canonical_answer" id="new_answer" class="input-text" required placeholder="z. B. Expelliarmus">
</div>
<div class="form-group">
<label for="new_hint">Tipp (optional):</label>
<input type="text" name="hint" id="new_hint" class="input-text" placeholder="z. B. Harrys Lieblingszauber">
</div>
<div class="form-group">
<label for="new_sort">Reihenfolge (Nummerierung):</label>
<input type="number" name="sort_order" id="new_sort" class="input-text-sm" value="{{ active_questions|length + 1 }}">
</div>
<button type="submit" class="btn btn-primary btn-large">Frage speichern & aktivieren</button>
</form>
</div>
</section>
<!-- TAB 4: Archiviert / Abgelehnt -->
{% if rejected_questions|length > 0 %}
<section id="tab-rejected" class="tab-content">
<h2>❌ Abgelehnte / Archivierte Vorschläge</h2>
<div class="questions-list">
{% for q in rejected_questions %}
<div class="card question-admin-card muted-card" id="q-card-{{ q.id }}">
<div class="q-header">
<span class="badge badge-danger">Abgelehnt</span>
<span class="q-submitter">Eingereicht von: {{ q.submitted_by or 'Gast' }}</span>
</div>
<div class="q-body">
<h3>{{ q.question }}</h3>
<p><strong>Antwort:</strong> {{ q.canonical_answer }}</p>
</div>
<div class="q-actions">
<button class="btn btn-sm btn-success" onclick="approveQuestion({{ q.id }})">Wieder aktivieren</button>
<button class="btn btn-sm btn-danger" onclick="deleteQuestion({{ q.id }})">Endgültig löschen</button>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/admin.js"></script>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}🔒 Zaubereiministerium – Login{% endblock %}
{% block content %}
<div class="quiz-wrapper">
<div class="card login-card">
<h1 class="glow-title">🔒 Zaubereiministerium</h1>
<p class="subtitle">Nur für befugte Großinquisitoren & Administratoren</p>
{% if error %}
<div class="alert alert-danger">
{{ error }}
</div>
{% endif %}
<form action="/admin/login" method="post" class="magic-form">
<div class="form-group">
<label for="username">Zauberer-Name:</label>
<input type="text" id="username" name="username" class="input-text" required autofocus>
</div>
<div class="form-group">
<label for="password">Geheimer Zauberspruch (Passwort):</label>
<input type="password" id="password" name="password" class="input-text" required>
</div>
<button type="submit" class="btn btn-primary btn-large">
Alohomora (Einloggen)
</button>
</form>
</div>
</div>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Magisches Harry-Potter-Escape-Quiz{% endblock %}</title>
<link rel="icon" href="/static/favicon.ico">
<link rel="stylesheet" href="/static/css/style.css">
{% block head %}{% endblock %}
</head>
<body>
<audio id="bgm" src="/static/audio/music.mp3" loop preload="auto"></audio>
<div class="audio-control-bar">
<button id="audioToggle" class="audio-btn" title="Musik an/aus" onclick="toggleAudio()">
<span id="audioIcon">🔇</span>
<span id="audioText">Musik: Aus</span>
</button>
<div class="nav-links">
<a href="/" class="nav-link">✨ Zum Quiz</a>
<a href="/vorschlag" class="nav-link">✍️ Frage einreichen</a>
<a href="/admin" class="nav-link">🔒 Zaubereiministerium (Admin)</a>
</div>
</div>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<p>⚡ Magisches Hogwarts-Escape-Quiz · Betrieben mit Liebe & Zauberkraft ⚡</p>
</footer>
<script>
const bgm = document.getElementById("bgm");
const audioIcon = document.getElementById("audioIcon");
const audioText = document.getElementById("audioText");
function toggleAudio() {
if (bgm.paused) {
bgm.play().then(() => {
audioIcon.innerText = "🔊";
audioText.innerText = "Musik: An";
localStorage.setItem("bgm_enabled", "true");
}).catch(err => {
console.log("Audio play blocked:", err);
});
} else {
bgm.pause();
audioIcon.innerText = "🔇";
audioText.innerText = "Musik: Aus";
localStorage.setItem("bgm_enabled", "false");
}
}
// Auto-resume audio if previously enabled
document.addEventListener("click", function initAudioOnInteraction() {
if (localStorage.getItem("bgm_enabled") === "true" && bgm.paused) {
bgm.play().then(() => {
audioIcon.innerText = "🔊";
audioText.innerText = "Musik: An";
}).catch(() => {});
}
document.removeEventListener("click", initAudioOnInteraction);
}, { once: true });
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}🎉 Das große Finale – Magisches Quiz{% endblock %}
{% block content %}
<div class="quiz-wrapper">
<div class="card finale-card">
<div class="finale-header">
<span class="sparkle-icon">✨</span>
<h1 class="glow-title">🎉 Happy Birthday Schatz! 🎉</h1>
<span class="sparkle-icon">✨</span>
</div>
<div class="score-banner">
<p class="score-highlight">
Du hast <span class="gold-text">{{ score }}</span> von <span class="gold-text">{{ total }}</span> möglichen Hauspunkten für dein Haus gesichert!
</p>
</div>
<div class="reward-box">
<h2>💛 Dein magischer Gewinn: Ein unvergesslicher Abend Zuhause 💛</h2>
<p class="reward-description">
Wir holen uns lecker Essen und machen es uns so richtig gemütlich: entspannen, lachen und die gemeinsame Zeit genießen.<br><br>
Dazu spielen wir ein brandneues <strong>Online Escape Game</strong> deiner Wahl!
</p>
<p class="love-note">Ich freue mich riesig auf unseren gemeinsamen Abend! 💫</p>
</div>
<div class="btn-group finale-actions">
<a href="/" class="btn btn-primary">🔄 Erneut spielen</a>
<a href="/vorschlag" class="btn btn-secondary">✍️ Eigene Frage vorschlagen</a>
</div>
</div>
</div>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}✨ Magisches Harry-Potter-Escape-Quiz ✨{% endblock %}
{% block content %}
<div class="quiz-wrapper">
<header class="quiz-header">
<h1 class="glow-title">✨ Magisches Hogwarts-Escape-Quiz ✨</h1>
<p class="subtitle">15 knifflige Rätsel aus der Zauberwelt warten auf dich</p>
</header>
<!-- Brief-Intro / Startmaske -->
<div id="introCard" class="card intro-card">
<div class="intro-envelope" id="envelope">
<div class="envelope-body">
<div class="envelope-flap"></div>
<div class="wax-seal">H</div>
<div class="letter-preview">
<p class="letter-text">Hogwarts-Schule für Hexerei und Zauberei</p>
</div>
</div>
</div>
<div class="intro-content">
<p class="intro-text">
Ein verzauberter Brief aus Hogwarts schwebt vor dir in der Luft.<br>
Bist du bereit, die Prüfungen abzulegen und deine Hauspunkte zu sichern?
</p>
<button id="startBtn" class="btn btn-primary btn-large" onclick="startGame()">
📜 Siegel brechen & Brief öffnen
</button>
</div>
</div>
<!-- Spielbereich (initial versteckt) -->
<div id="gameCard" class="game-area hidden">
<!-- Fortschritt & Punkte -->
<div class="status-panel">
<div class="progress-info">
<span id="questionCounter">Frage 1 von {{ question_count }}</span>
<span id="pointsCounter" class="points-badge">🏆 Hauspunkte: 0</span>
</div>
<div class="progress-container">
<div id="progressBar" class="progress-bar"></div>
</div>
</div>
<!-- Frage-Container -->
<div id="frageBox" class="card question-card">
<div class="question-meta">
<span id="badgeCategory" class="badge">⚡ Harry Potter</span>
<span id="attemptBadge" class="attempt-badge">Versuch 1 von 3</span>
</div>
<h2 id="frageText" class="question-text">Lade verzauberte Schriftrollen...</h2>
<div class="answer-form">
<input type="text" id="answerInput" class="input-answer" placeholder="Deine Antwort hier eingeben..." autocomplete="off">
<div class="btn-group">
<button id="checkBtn" class="btn btn-primary" onclick="checkAnswer()">
✨ Zauber prüfen
</button>
<button id="hintBtn" class="btn btn-secondary" onclick="requestHint()">
💡 Zaubertipp
</button>
</div>
</div>
<div id="hintBox" class="hint-box hidden">
<strong>📜 Notiz aus der verbotenen Abteilung:</strong>
<p id="hintText"></p>
</div>
<div id="feedbackBox" class="feedback-box"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/quiz.js"></script>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}✍️ Neue Zauberfrage einreichen{% endblock %}
{% block content %}
<div class="quiz-wrapper">
<div class="card vorschlag-card">
<h1 class="glow-title">✨ Neue Frage für das Quiz vorschlagen ✨</h1>
<p class="subtitle">Bereichere das magische Quiz mit kniffligen Harry-Potter- oder Escape-Rätseln!</p>
<div class="info-alert">
ℹ️ <strong>Hinweis zur Moderation:</strong> Eingereichte Fragen werden nicht sofort öffentlich sichtbar, sondern erhalten automatisch den Status <em>„Prüfung ausstehend“</em>. Unser Zaubereiministerium prüft und testet die Antwort, bevor die Frage freigeschaltet wird.
</div>
<form id="suggestionForm" class="magic-form" onsubmit="submitSuggestion(event)">
<div class="form-group">
<label for="name">Dein Name / Zauberer-Alias (optional):</label>
<input type="text" id="name" name="name" class="input-text" placeholder="z. B. Hermine Granger">
</div>
<div class="form-group">
<label for="frage">Deine Frage: <span class="required">*</span></label>
<textarea id="frage" name="frage" class="textarea" rows="3" placeholder="z. B. Wie heißt der Schulleiter von Hogwarts?" required></textarea>
</div>
<div class="form-group">
<label for="antwort">Richtige Antwort: <span class="required">*</span></label>
<input type="text" id="antwort" name="antwort" class="input-text" placeholder="z. B. Albus Dumbledore" required>
<small class="field-help">Unser System gleicht Tippfehler und Groß-/Kleinschreibung automatisch ab.</small>
</div>
<div class="form-group">
<label for="tipp">Tipp / Hinweis (optional):</label>
<input type="text" id="tipp" name="tipp" class="input-text" placeholder="z. B. Er trägt eine Halbmondbrille.">
</div>
<button type="submit" id="submitBtn" class="btn btn-primary btn-large">
📜 Vorschlag an das Ministerium senden
</button>
</form>
<div id="resultMessage" class="feedback-box hidden"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/vorschlag.js"></script>
{% endblock %}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Fragen-Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/style.css">
</head>
<body class="light-theme">
<h1>Fragen-Editor</h1>
<div id="fragen"></div>
<h2>Neue Frage hinzufügen</h2>
<div class="box">
<input id="newFrage" placeholder="Frage">
<input id="newAntwort" placeholder="Antwort">
<input id="newTipp" placeholder="Tipp">
<button onclick="addFrage()">Hinzufügen</button>
</div>
<script src="js/admin.js"></script>
</body>
</html>
+160
View File
@@ -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;
}
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>400 – Verirrter Zauber</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { background:#0b0b15;color:#f5f5f5;font-family:Georgia,serif;text-align:center;padding:20px; }
.box { border:2px solid #c9a86a;padding:20px;margin:20px auto;width:90%;max-width:600px;background:rgba(255,255,255,0.05);border-radius:10px; }
a { color:#c9a86a;text-decoration:none;font-weight:bold; }
</style>
</head>
<body>
<div class="box">
<h1>400 – Verirrter Zauber</h1>
<p>Der letzte Zauberspruch war etwas durcheinander geraten.</p>
<p><a href="index.html">Zurück zum magischen Quiz</a></p>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>401 – Zutritt nur für Zauberer</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { background:#0b0b15;color:#f5f5f5;font-family:Georgia,serif;text-align:center;padding:20px; }
.box { border:2px solid #c9a86a;padding:20px;margin:20px auto;width:90%;max-width:600px;background:rgba(255,255,255,0.05);border-radius:10px; }
a { color:#c9a86a;text-decoration:none;font-weight:bold; }
</style>
</head>
<body>
<div class="box">
<h1>401 – Zutritt nur für Zauberer</h1>
<p>Für diesen Bereich brauchst du noch eine magische Legitimation.</p>
<p><a href="index.html">Zurück zum magischen Quiz</a></p>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>403 – Verbotener Bereich</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { background:#0b0b15;color:#f5f5f5;font-family:Georgia,serif;text-align:center;padding:20px; }
.box { border:2px solid #c9a86a;padding:20px;margin:20px auto;width:90%;max-width:600px;background:rgba(255,255,255,0.05);border-radius:10px; }
a { color:#c9a86a;text-decoration:none;font-weight:bold; }
</style>
</head>
<body>
<div class="box">
<h1>403 – Verbotener Bereich</h1>
<p>Dieser Teil der Zauberwelt ist streng geschützt.</p>
<p><a href="index.html">Zurück zum magischen Quiz</a></p>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>404 – Karte des Rumtreibers leer</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { background:#0b0b15;color:#f5f5f5;font-family:Georgia,serif;text-align:center;padding:20px; }
.box { border:2px solid #c9a86a;padding:20px;margin:20px auto;width:90%;max-width:600px;background:rgba(255,255,255,0.05);border-radius:10px; }
a { color:#c9a86a;text-decoration:none;font-weight:bold; }
</style>
</head>
<body>
<div class="box">
<h1>404 – Seite nicht gefunden</h1>
<p>Die Karte des Rumtreibers zeigt hier nichts an.</p>
<p><a href="index.html">Zurück zum magischen Quiz</a></p>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>405 – Unzulässiger Zauber</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { background:#0b0b15;color:#f5f5f5;font-family:Georgia,serif;text-align:center;padding:20px; }
.box { border:2px solid #c9a86a;padding:20px;margin:20px auto;width:90%;max-width:600px;background:rgba(255,255,255,0.05);border-radius:10px; }
a { color:#c9a86a;text-decoration:none;font-weight:bold; }
</style>
</head>
<body>
<div class="box">
<h1>405 – Unzulässiger Zauber</h1>
<p>Diese Art von Zauberspruch ist hier nicht erlaubt.</p>
<p><a href="index.html">Zurück zum magischen Quiz</a></p>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>500 – Explosion im Zaubertrank-Labor</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { background:#0b0b15;color:#f5f5f5;font-family:Georgia,serif;text-align:center;padding:20px; }
.box { border:2px solid #c9a86a;padding:20px;margin:20px auto;width:90%;max-width:600px;background:rgba(255,255,255,0.05);border-radius:10px; }
a { color:#c9a86a;text-decoration:none;font-weight:bold; }
</style>
</head>
<body>
<div class="box">
<h1>500 – Explosion im Zaubertrank-Labor</h1>
<p>Irgendetwas ist gründlich schiefgegangen. Die Hauselfen räumen schon auf.</p>
<p><a href="index.html">Zurück zum magischen Quiz</a></p>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>502 – Fehlgeleiteter Eulenflug</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { background:#0b0b15;color:#f5f5f5;font-family:Georgia,serif;text-align:center;padding:20px; }
.box { border:2px solid #c9a86a;padding:20px;margin:20px auto;width:90%;max-width:600px;background:rgba(255,255,255,0.05);border-radius:10px; }
a { color:#c9a86a;text-decoration:none;font-weight:bold; }
</style>
</head>
<body>
<div class="box">
<h1>502 – Fehlgeleiteter Eulenflug</h1>
<p>Die Nachricht hat das Ziel nicht richtig erreicht.</p>
<p><a href="index.html">Zurück zum magischen Quiz</a></p>
</div>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>503 – Zaubereiministerium beschäftigt</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { background:#0b0b15;color:#f5f5f5;font-family:Georgia,serif;text-align:center;padding:20px; }
.box { border:2px solid #c9a86a;padding:20px;margin:20px auto;width:90%;max-width:600px;background:rgba(255,255,255,0.05);border-radius:10px; }
a { color:#c9a86a;text-decoration:none;font-weight:bold; }
</style>
</head>
<body>
<div class="box">
<h1>503 – Zaubereiministerium beschäftigt</h1>
<p>Die magischen Dienste sind gerade ausgelastet. Versuche es später erneut.</p>
<p><a href="index.html">Zurück zum magischen Quiz</a></p>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+28
View File
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Finale – Magisches Quiz</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="box">
<h1>🎉 Happy Birthday Schatz 🎉</h1>
<p id="scoreText"></p>
<h2>💛 Der Gewinn ist ein schöner Abend Zuhause 💛</h2>
<p>
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.
</p>
<p>Ich freue mich auf unseren Abend.</p>
<button onclick="window.location.href='index.html'">Erneut spielen</button>
</div>
<script src="js/finale.js"></script>
</body>
</html>
+77
View File
@@ -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"
}
]
+7
View File
@@ -0,0 +1,7 @@
[
{
"frage": "Hagrid schlüsselbund hat wie viele Schlüssel?",
"antwort": "7",
"tipp": "weil es immer 3,5 oder 7 sind."
}
]
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Magisches Harry-Potter-Escape-Quiz</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<audio id="bgm" src="music.mp3" loop></audio>
<h1>✨ Magisches Spatzi-Quiz ✨</h1>
<div class="progress-container">
<div id="progressBar" class="progress-bar"></div>
</div>
<div id="progressLabel">Hauspunkte: 0</div>
<div id="intro" class="box">
<div class="intro-envelope" id="envelope">
<div class="envelope-body">
<div class="envelope-flap"></div>
</div>
</div>
<p>Ein verzauberter Briefumschlag ist vor dir erschienen.</p>
<button onclick="startGame()">Brief öffnen</button>
</div>
<div id="questions"></div>
<script src="js/index.js"></script>
</body>
</html>
+54
View File
@@ -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 += `
<div class="box">
<strong>Frage ${i+1}</strong>
<input value="${f.frage}" onchange="edit(${i}, 'frage', this.value)">
<input value="${f.antwort}" onchange="edit(${i}, 'antwort', this.value)">
<input value="${f.tipp}" onchange="edit(${i}, 'tipp', this.value)">
<button onclick="removeFrage(${i})">Löschen</button>
</div>
`;
});
}
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();
+10
View File
@@ -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.`;
+110
View File
@@ -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 = `
<div class="box" id="frageBox">
<h2>Frage ${index + 1}</h2>
<p>${f.frage}</p>
<input id="antwort">
<button onclick="check()">Prüfen</button>
<button onclick="showTip()">Tipp</button>
<p class="tip hidden" id="tipp">${f.tipp}</p>
<p id="versuche">Versuche: 0 / 3</p>
<p id="feedback"></p>
</div>
`;
}
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}`;
}
+17
View File
@@ -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 += `
<div class="box">
${i + 1}. ${f.frage}<br>
<strong>${f.antwort}</strong>
</div>
`;
});
}
load();
+31
View File
@@ -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.");
}
}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Lösungen – Magisches Quiz</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<h1>🔐 Lösungen zum Harry-Potter-Escape-Quiz</h1>
<div id="loesungen" class="loesungen-list"></div>
<script src="js/loesungen.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
User-agent: *
+5
View File
@@ -0,0 +1,5 @@
<?php
$data = file_get_contents("php://input");
file_put_contents("fragen.json", $data);
echo "OK";
?>
+37
View File
@@ -0,0 +1,37 @@
<?php
header("Content-Type: application/json");
// Datei, in die gespeichert wird
$file = "fragen_vorschlaege.json";
// Eingehende Daten lesen
$input = file_get_contents("php://input");
$data = json_decode($input, true);
if (!$data || !isset($data["frage"]) || !isset($data["antwort"])) {
http_response_code(400);
echo json_encode(["error" => "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"]);
}
?>
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Frage vorschlagen</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/style.css">
</head>
<body class="light-theme">
<h1>✨ Frage vorschlagen ✨</h1>
<p>Hier kannst du eine neue Frage für das Quiz vorschlagen.
Die Frage wird gespeichert und später geprüft.</p>
<div class="box">
<label>Frage:</label>
<textarea id="frage" rows="2" placeholder="Deine Frage..."></textarea>
<label>Antwort:</label>
<input id="antwort" placeholder="Die richtige Antwort...">
<label>Tipp (optional):</label>
<input id="tipp" placeholder="Hinweis für die Spieler...">
<button onclick="send()">Vorschlag einreichen</button>
</div>
<script src="js/vorschlag.js"></script>
</body>
</html>
+39
View File
@@ -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;
}
}
+15
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
User-agent: *
+78
View File
@@ -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()