Initial commit: Modernes Harry Potter Escape Quiz Portal (FastAPI & MariaDB)
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
setup_gitea.py
|
||||||
|
legacy/*.mp3
|
||||||
Executable
+16
@@ -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
|
||||||
Executable
+32
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
Executable
BIN
Binary file not shown.
Executable
+769
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Executable
+128
@@ -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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+249
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Executable
+61
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+236
@@ -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 %}
|
||||||
Executable
+34
@@ -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 %}
|
||||||
Executable
+69
@@ -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>
|
||||||
Executable
+35
@@ -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 %}
|
||||||
Executable
+82
@@ -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 %}
|
||||||
Executable
+49
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"frage": "Hagrid schlüsselbund hat wie viele Schlüssel?",
|
||||||
|
"antwort": "7",
|
||||||
|
"tipp": "weil es immer 3,5 oder 7 sind."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -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.`;
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
User-agent: *
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
$data = file_get_contents("php://input");
|
||||||
|
file_put_contents("fragen.json", $data);
|
||||||
|
echo "OK";
|
||||||
|
?>
|
||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
User-agent: *
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user