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