Feature: In-game timer, anonymous leaderboard, witty tiered finale results, legal pages, button renames
This commit is contained in:
+203
-9
@@ -12,11 +12,11 @@ from sqlalchemy.orm import Session
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Question, QuizSession, AdminUser
|
from app.models import Question, QuizSession, AdminUser, LeaderboardEntry
|
||||||
from app.matcher import is_answer_correct, levenshtein_distance, normalize_text
|
from app.matcher import is_answer_correct, levenshtein_distance, normalize_text
|
||||||
from app.auth import verify_password
|
from app.auth import verify_password
|
||||||
from app.seed import seed_db
|
from app.seed import seed_db
|
||||||
from app.schemas import GameCheckRequest, SuggestionCreateRequest, GameStartRequest
|
from app.schemas import GameCheckRequest, SuggestionCreateRequest, GameStartRequest, LeaderboardSubmitRequest
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -58,6 +58,81 @@ def format_question_for_client(q: Question, number: int, total: int, lang: str =
|
|||||||
"difficulty": q.difficulty or "medium"
|
"difficulty": q.difficulty or "medium"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_tier_evaluation(score: int, total: int = 10, lang: str = "de") -> dict:
|
||||||
|
pct = (score / total) if total > 0 else 0
|
||||||
|
is_de = (lang != "en")
|
||||||
|
|
||||||
|
if score >= 10 or pct >= 1.0:
|
||||||
|
grade = "O"
|
||||||
|
title = "🧙♂️ Großmeister der Zauberei" if is_de else "🧙♂️ Grandmaster of Wizardry"
|
||||||
|
subtitle = "Note: O (Ohnegleichen)" if is_de else "Grade: O (Outstanding)"
|
||||||
|
desc = (
|
||||||
|
"Unglaublich! Albus Dumbledore überlegt bereits, dir das Büro des Schulleiters zu überlassen. "
|
||||||
|
"Du kennst selbst die geheimsten Winkel von Hogwarts besser als der Fast Kopflose Nick. "
|
||||||
|
"Das Zaubereiministerium verneigt sich in Ehrfurcht!"
|
||||||
|
if is_de else
|
||||||
|
"Incredible! Albus Dumbledore is already considering handing you the headmaster's office. "
|
||||||
|
"You know the secret passages of Hogwarts better than Nearly Headless Nick. "
|
||||||
|
"The Ministry of Magic bows in deep awe!"
|
||||||
|
)
|
||||||
|
badge_class = "tier-outstanding"
|
||||||
|
elif score >= 8 or pct >= 0.8:
|
||||||
|
grade = "E"
|
||||||
|
title = "⚡ Meister-Hexe / Zauberer" if is_de else "⚡ Master Witch / Wizard"
|
||||||
|
subtitle = "Note: E (Erwartungen übertroffen)" if is_de else "Grade: E (Exceeds Expectations)"
|
||||||
|
desc = (
|
||||||
|
"Hermine Granger nickt anerkennend und Professor McGonagall vergibt 50 Punkte für dein Haus! "
|
||||||
|
"Dein Zauberstab zuckt blitzschnell und dein Wissen lässt selbst Ravenclaws erblassen."
|
||||||
|
if is_de else
|
||||||
|
"Hermione Granger nods in deep approval and Professor McGonagall awards 50 points to your House! "
|
||||||
|
"Your wandwork is razor-sharp and your knowledge makes even Ravenclaws jealous."
|
||||||
|
)
|
||||||
|
badge_class = "tier-exceeds"
|
||||||
|
elif score >= 5 or pct >= 0.5:
|
||||||
|
grade = "A"
|
||||||
|
title = "📜 Solider Hogwarts-Schüler" if is_de else "📜 Solid Hogwarts Student"
|
||||||
|
subtitle = "Note: A (Annehmbar)" if is_de else "Grade: A (Acceptable)"
|
||||||
|
desc = (
|
||||||
|
"Bestanden! Ein paar Kessel sind zwar im Zaubertränke-Unterricht explodiert und Peeves hat dich mit Kreide beworfen, "
|
||||||
|
"aber du hast dich tapfer durchgeschlagen. Eine solide magische Leistung!"
|
||||||
|
if is_de else
|
||||||
|
"Passed! A few cauldrons blew up during Potions class and Peeves threw chalk at your head, "
|
||||||
|
"but you bravely fought your way through. A solid magical performance!"
|
||||||
|
)
|
||||||
|
badge_class = "tier-acceptable"
|
||||||
|
elif score >= 2 or pct >= 0.2:
|
||||||
|
grade = "M"
|
||||||
|
title = "🧹 Quaffel-Fänger in Not" if is_de else "🧹 Quaffle Catcher in Distress"
|
||||||
|
subtitle = "Note: M (Mies)" if is_de else "Grade: P (Poor)"
|
||||||
|
desc = (
|
||||||
|
"Au weia... Hat Ron Weasley dir seinen mit Tesafilm geklebten Zauberstab geliehen? "
|
||||||
|
"Deine Eule hat sich wohl im Nebel verflogen. Zeit für ein paar Extrastunden in der Bibliothek bei Madam Pince!"
|
||||||
|
if is_de else
|
||||||
|
"Yikes... Did Ron Weasley lend you his Spellotaped wand? "
|
||||||
|
"Your owl must have gotten lost in the fog. Time for extra study sessions in the library with Madam Pince!"
|
||||||
|
)
|
||||||
|
badge_class = "tier-poor"
|
||||||
|
else:
|
||||||
|
grade = "T"
|
||||||
|
title = "🧌 Bergtroll im Kerker" if is_de else "🧌 Mountain Troll in the Dungeon"
|
||||||
|
subtitle = "Note: T (Troll)" if is_de else "Grade: T (Troll)"
|
||||||
|
desc = (
|
||||||
|
"Alarm im Schloss! Du hast mit der Holzkeule auf die Fragen eingeschlagen. "
|
||||||
|
"Schnell weg, bevor Hausmeister Filch dich beim lebenslangen Nachsitzen in den Kerkern erwischt!"
|
||||||
|
if is_de else
|
||||||
|
"Troll in the dungeon! You smashed the questions with a wooden club. "
|
||||||
|
"Run quickly before caretaker Filch catches you for lifelong detention in the dungeons!"
|
||||||
|
)
|
||||||
|
badge_class = "tier-troll"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"grade": grade,
|
||||||
|
"title": title,
|
||||||
|
"subtitle": subtitle,
|
||||||
|
"description": desc,
|
||||||
|
"badge_class": badge_class
|
||||||
|
}
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup_event():
|
def startup_event():
|
||||||
seed_db()
|
seed_db()
|
||||||
@@ -78,19 +153,16 @@ async def start_game(payload: GameStartRequest = None, db: Session = Depends(get
|
|||||||
lang = payload.lang if payload and payload.lang in ["de", "en"] else "de"
|
lang = payload.lang if payload and payload.lang in ["de", "en"] else "de"
|
||||||
difficulty = payload.difficulty if payload and payload.difficulty in ["easy", "medium", "hard"] else "all"
|
difficulty = payload.difficulty if payload and payload.difficulty in ["easy", "medium", "hard"] else "all"
|
||||||
|
|
||||||
# Fetch active questions matching difficulty (or all active)
|
|
||||||
query = db.query(Question).filter(Question.status == "active")
|
query = db.query(Question).filter(Question.status == "active")
|
||||||
if difficulty != "all":
|
if difficulty != "all":
|
||||||
query = query.filter(Question.difficulty == difficulty)
|
query = query.filter(Question.difficulty == difficulty)
|
||||||
|
|
||||||
pool = query.all()
|
pool = query.all()
|
||||||
if not pool:
|
if not pool:
|
||||||
# Fallback to all active if filtered pool empty
|
|
||||||
pool = db.query(Question).filter(Question.status == "active").all()
|
pool = db.query(Question).filter(Question.status == "active").all()
|
||||||
if not pool:
|
if not pool:
|
||||||
raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.")
|
raise HTTPException(status_code=400, detail="Keine aktiven Fragen vorhanden.")
|
||||||
|
|
||||||
# Randomly sample 10 questions (or all if fewer)
|
|
||||||
sample_size = min(10, len(pool))
|
sample_size = min(10, len(pool))
|
||||||
selected_questions = random.sample(pool, sample_size)
|
selected_questions = random.sample(pool, sample_size)
|
||||||
question_ids = [q.id for q in selected_questions]
|
question_ids = [q.id for q in selected_questions]
|
||||||
@@ -104,7 +176,8 @@ async def start_game(payload: GameStartRequest = None, db: Session = Depends(get
|
|||||||
attempts_current=0,
|
attempts_current=0,
|
||||||
is_finished=False,
|
is_finished=False,
|
||||||
lang=lang,
|
lang=lang,
|
||||||
difficulty=difficulty
|
difficulty=difficulty,
|
||||||
|
created_at=datetime.datetime.utcnow()
|
||||||
)
|
)
|
||||||
db.add(new_session)
|
db.add(new_session)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -131,6 +204,9 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
|||||||
question_ids = json.loads(session.question_ids)
|
question_ids = json.loads(session.question_ids)
|
||||||
if session.current_question_idx >= len(question_ids):
|
if session.current_question_idx >= len(question_ids):
|
||||||
session.is_finished = True
|
session.is_finished = True
|
||||||
|
session.finished_at = datetime.datetime.utcnow()
|
||||||
|
if session.created_at:
|
||||||
|
session.duration_seconds = max(1, int((session.finished_at - session.created_at).total_seconds()))
|
||||||
db.commit()
|
db.commit()
|
||||||
return {
|
return {
|
||||||
"correct": False,
|
"correct": False,
|
||||||
@@ -138,6 +214,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
|||||||
"attempts_used": session.attempts_current,
|
"attempts_used": session.attempts_current,
|
||||||
"attempts_left": 0,
|
"attempts_left": 0,
|
||||||
"is_finished": True,
|
"is_finished": True,
|
||||||
|
"duration_seconds": session.duration_seconds,
|
||||||
"next_question": None
|
"next_question": None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +234,8 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
|||||||
if is_finished:
|
if is_finished:
|
||||||
session.is_finished = True
|
session.is_finished = True
|
||||||
session.finished_at = datetime.datetime.utcnow()
|
session.finished_at = datetime.datetime.utcnow()
|
||||||
|
if session.created_at:
|
||||||
|
session.duration_seconds = max(1, int((session.finished_at - session.created_at).total_seconds()))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -172,6 +251,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
|||||||
"attempts_used": 0,
|
"attempts_used": 0,
|
||||||
"attempts_left": 3,
|
"attempts_left": 3,
|
||||||
"is_finished": is_finished,
|
"is_finished": is_finished,
|
||||||
|
"duration_seconds": session.duration_seconds,
|
||||||
"revealed_answer": None,
|
"revealed_answer": None,
|
||||||
"next_question": next_q_data
|
"next_question": next_q_data
|
||||||
}
|
}
|
||||||
@@ -180,7 +260,6 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
|||||||
attempts_left = max(0, 3 - session.attempts_current)
|
attempts_left = max(0, 3 - session.attempts_current)
|
||||||
|
|
||||||
if attempts_left == 0:
|
if attempts_left == 0:
|
||||||
# 3 failed attempts: move to next question, reveal answer
|
|
||||||
if session_lang == "en" and current_q.answer_en:
|
if session_lang == "en" and current_q.answer_en:
|
||||||
revealed = current_q.answer_en
|
revealed = current_q.answer_en
|
||||||
else:
|
else:
|
||||||
@@ -193,6 +272,8 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
|||||||
if is_finished:
|
if is_finished:
|
||||||
session.is_finished = True
|
session.is_finished = True
|
||||||
session.finished_at = datetime.datetime.utcnow()
|
session.finished_at = datetime.datetime.utcnow()
|
||||||
|
if session.created_at:
|
||||||
|
session.duration_seconds = max(1, int((session.finished_at - session.created_at).total_seconds()))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -208,6 +289,7 @@ async def check_answer(payload: GameCheckRequest, db: Session = Depends(get_db))
|
|||||||
"attempts_used": 3,
|
"attempts_used": 3,
|
||||||
"attempts_left": 0,
|
"attempts_left": 0,
|
||||||
"is_finished": is_finished,
|
"is_finished": is_finished,
|
||||||
|
"duration_seconds": session.duration_seconds,
|
||||||
"revealed_answer": revealed,
|
"revealed_answer": revealed,
|
||||||
"next_question": next_q_data
|
"next_question": next_q_data
|
||||||
}
|
}
|
||||||
@@ -247,13 +329,125 @@ async def get_hint(session_id: str, db: Session = Depends(get_db)):
|
|||||||
return {"hint": hint_text}
|
return {"hint": hint_text}
|
||||||
|
|
||||||
@app.get("/finale", response_class=HTMLResponse)
|
@app.get("/finale", response_class=HTMLResponse)
|
||||||
async def finale(request: Request, score: int = 0, total: int = 10, lang: str = "de"):
|
async def finale(request: Request, score: int = 0, total: int = 10, time: int = 0, lang: str = "de"):
|
||||||
|
tier = get_tier_evaluation(score, total, lang)
|
||||||
|
if time >= 60:
|
||||||
|
minutes = time // 60
|
||||||
|
secs = time % 60
|
||||||
|
time_str = f"{minutes} Min. {secs} Sek." if lang != "en" else f"{minutes} min. {secs} sec."
|
||||||
|
else:
|
||||||
|
time_str = f"{time} Sek." if lang != "en" else f"{time} sec."
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="finale.html",
|
name="finale.html",
|
||||||
context={"score": score, "total": total, "lang": lang}
|
context={
|
||||||
|
"score": score,
|
||||||
|
"total": total,
|
||||||
|
"time_seconds": time,
|
||||||
|
"time_formatted": time_str,
|
||||||
|
"lang": lang,
|
||||||
|
"tier": tier
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.get("/bestenliste", response_class=HTMLResponse)
|
||||||
|
async def bestenliste_page(request: Request, lang: str = "de"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="bestenliste.html",
|
||||||
|
context={"lang": lang}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/impressum", response_class=HTMLResponse)
|
||||||
|
async def impressum_page(request: Request, lang: str = "de"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="impressum.html",
|
||||||
|
context={"lang": lang}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/datenschutz", response_class=HTMLResponse)
|
||||||
|
async def datenschutz_page(request: Request, lang: str = "de"):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="datenschutz.html",
|
||||||
|
context={"lang": lang}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/leaderboard")
|
||||||
|
async def submit_leaderboard(payload: LeaderboardSubmitRequest, db: Session = Depends(get_db)):
|
||||||
|
nick = payload.nickname.strip()
|
||||||
|
if not nick:
|
||||||
|
wizard_adjectives = ["Mutiger", "Schlauer", "Weiser", "Flinke", "Listiger", "Fliegender"]
|
||||||
|
wizard_nouns = ["Greif", "Phönix", "Zauberer", "Drache", "Niffler", "Thestral"]
|
||||||
|
nick = f"{random.choice(wizard_adjectives)}_{random.choice(wizard_nouns)}_{random.randint(10, 99)}"
|
||||||
|
nick = nick[:30]
|
||||||
|
|
||||||
|
time_sec = max(1, payload.time_seconds)
|
||||||
|
diff = payload.difficulty if payload.difficulty in ["easy", "medium", "hard"] else "all"
|
||||||
|
|
||||||
|
entry = LeaderboardEntry(
|
||||||
|
nickname=nick,
|
||||||
|
score=payload.score,
|
||||||
|
total_questions=payload.total_questions or 10,
|
||||||
|
time_seconds=time_sec,
|
||||||
|
difficulty=diff,
|
||||||
|
lang=payload.lang or "de",
|
||||||
|
session_id=payload.session_id,
|
||||||
|
created_at=datetime.datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(entry)
|
||||||
|
|
||||||
|
better_count = db.query(LeaderboardEntry).filter(
|
||||||
|
(LeaderboardEntry.score > entry.score) |
|
||||||
|
((LeaderboardEntry.score == entry.score) & (LeaderboardEntry.time_seconds < entry.time_seconds)) |
|
||||||
|
((LeaderboardEntry.score == entry.score) & (LeaderboardEntry.time_seconds == entry.time_seconds) & (LeaderboardEntry.id < entry.id))
|
||||||
|
).count()
|
||||||
|
rank = better_count + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"entry_id": entry.id,
|
||||||
|
"rank": rank,
|
||||||
|
"nickname": entry.nickname,
|
||||||
|
"score": entry.score,
|
||||||
|
"total_questions": entry.total_questions,
|
||||||
|
"time_seconds": entry.time_seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/leaderboard")
|
||||||
|
async def get_leaderboard(difficulty: str = None, limit: int = 20, db: Session = Depends(get_db)):
|
||||||
|
query = db.query(LeaderboardEntry)
|
||||||
|
if difficulty and difficulty != "all":
|
||||||
|
query = query.filter(LeaderboardEntry.difficulty == difficulty)
|
||||||
|
|
||||||
|
entries = query.order_by(
|
||||||
|
LeaderboardEntry.score.desc(),
|
||||||
|
LeaderboardEntry.time_seconds.asc(),
|
||||||
|
LeaderboardEntry.created_at.asc()
|
||||||
|
).limit(min(100, limit)).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for idx, e in enumerate(entries, 1):
|
||||||
|
m = e.time_seconds // 60
|
||||||
|
s = e.time_seconds % 60
|
||||||
|
time_str = f"{m:02d}:{s:02d}"
|
||||||
|
result.append({
|
||||||
|
"rank": idx,
|
||||||
|
"id": e.id,
|
||||||
|
"nickname": e.nickname,
|
||||||
|
"score": e.score,
|
||||||
|
"total_questions": e.total_questions,
|
||||||
|
"time_seconds": e.time_seconds,
|
||||||
|
"time_formatted": time_str,
|
||||||
|
"difficulty": e.difficulty,
|
||||||
|
"date": e.created_at.strftime("%d.%m.%Y") if e.created_at else ""
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
@app.get("/vorschlag", response_class=HTMLResponse)
|
@app.get("/vorschlag", response_class=HTMLResponse)
|
||||||
async def vorschlag_page(request: Request):
|
async def vorschlag_page(request: Request):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
|||||||
@@ -41,9 +41,23 @@ class QuizSession(Base):
|
|||||||
is_finished = Column(Boolean, default=False)
|
is_finished = Column(Boolean, default=False)
|
||||||
lang = Column(String(10), default="de") # 'de' or 'en'
|
lang = Column(String(10), default="de") # 'de' or 'en'
|
||||||
difficulty = Column(String(20), default="all") # 'all', 'easy', 'medium', 'hard'
|
difficulty = Column(String(20), default="all") # 'all', 'easy', 'medium', 'hard'
|
||||||
|
duration_seconds = Column(Integer, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
||||||
finished_at = Column(DateTime, nullable=True)
|
finished_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
class LeaderboardEntry(Base):
|
||||||
|
__tablename__ = "leaderboard_entries"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
nickname = Column(String(50), nullable=False, index=True)
|
||||||
|
score = Column(Integer, nullable=False, index=True)
|
||||||
|
total_questions = Column(Integer, default=10)
|
||||||
|
time_seconds = Column(Integer, nullable=False, index=True)
|
||||||
|
difficulty = Column(String(20), default="all", index=True)
|
||||||
|
lang = Column(String(10), default="de")
|
||||||
|
session_id = Column(String(36), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.datetime.utcnow, index=True)
|
||||||
|
|
||||||
class AdminUser(Base):
|
class AdminUser(Base):
|
||||||
__tablename__ = "admin_users"
|
__tablename__ = "admin_users"
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ class SuggestionCreateRequest(BaseModel):
|
|||||||
falsche_antworten: Optional[List[str]] = None
|
falsche_antworten: Optional[List[str]] = None
|
||||||
difficulty: Optional[str] = "medium"
|
difficulty: Optional[str] = "medium"
|
||||||
|
|
||||||
|
class LeaderboardSubmitRequest(BaseModel):
|
||||||
|
nickname: str
|
||||||
|
score: int
|
||||||
|
total_questions: int = 10
|
||||||
|
time_seconds: int
|
||||||
|
difficulty: Optional[str] = "all"
|
||||||
|
lang: Optional[str] = "de"
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
|
||||||
class QuestionAdminCreate(BaseModel):
|
class QuestionAdminCreate(BaseModel):
|
||||||
category: str = "harry_potter"
|
category: str = "harry_potter"
|
||||||
question: str
|
question: str
|
||||||
|
|||||||
@@ -604,6 +604,15 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timer-badge {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background: rgba(243, 156, 18, 0.15);
|
||||||
|
border: 1px solid #f39c12;
|
||||||
|
color: #ffd700;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.question-text {
|
.question-text {
|
||||||
font-size: clamp(1.15rem, 4.5vw, 1.45rem);
|
font-size: clamp(1.15rem, 4.5vw, 1.45rem);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -847,6 +856,172 @@ body {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-highlight {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: clamp(0.9rem, 3.2vw, 1.05rem);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tiered evaluation styling */
|
||||||
|
.tier-box {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px 18px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border: 2px solid var(--border-gold);
|
||||||
|
background: radial-gradient(circle at top, rgba(201, 168, 106, 0.12) 0%, rgba(15, 17, 30, 0.85) 100%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-badge-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-grade-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.6);
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||||
|
border: 3px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-outstanding {
|
||||||
|
background: radial-gradient(circle, #ffd700, #b8860b);
|
||||||
|
color: #0b0b16;
|
||||||
|
border-color: #fff275;
|
||||||
|
box-shadow: 0 0 20px rgba(255, 215, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-exceeds {
|
||||||
|
background: radial-gradient(circle, #2ecc71, #27ae60);
|
||||||
|
color: #0b0b16;
|
||||||
|
border-color: #a8f5cc;
|
||||||
|
box-shadow: 0 0 20px rgba(46, 204, 113, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-acceptable {
|
||||||
|
background: radial-gradient(circle, #3498db, #2980b9);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #a0d3f8;
|
||||||
|
box-shadow: 0 0 20px rgba(52, 152, 219, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-poor {
|
||||||
|
background: radial-gradient(circle, #e67e22, #d35400);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #f7b278;
|
||||||
|
box-shadow: 0 0 20px rgba(230, 126, 34, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-troll {
|
||||||
|
background: radial-gradient(circle, #7f8c8d, #34495e);
|
||||||
|
color: #ff7675;
|
||||||
|
border-color: #e74c3c;
|
||||||
|
box-shadow: 0 0 20px rgba(231, 76, 60, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-subtitle {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--gold-soft);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaderboard entry card & form on finale */
|
||||||
|
.leaderboard-entry-card {
|
||||||
|
background: rgba(14, 16, 32, 0.85);
|
||||||
|
border: 1px dashed var(--gold);
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-btn {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-btn .input-text {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaderboard tables & filters */
|
||||||
|
.diff-filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-filter-tabs .tab-btn {
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-gold);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-filter-tabs .tab-btn.active {
|
||||||
|
background: rgba(201, 168, 106, 0.25);
|
||||||
|
border-color: var(--gold);
|
||||||
|
color: var(--gold-bright);
|
||||||
|
box-shadow: 0 0 10px rgba(201, 168, 106, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-table th {
|
||||||
|
background: rgba(201, 168, 106, 0.12);
|
||||||
|
color: var(--gold-soft);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 2px solid var(--border-gold);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid rgba(201, 168, 106, 0.15);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-table tr:hover td {
|
||||||
|
background: rgba(201, 168, 106, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-rank {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lb-rank-1 { color: #ffd700; font-size: 1.1rem; }
|
||||||
|
.lb-rank-2 { color: #c0c0c0; font-size: 1.05rem; }
|
||||||
|
.lb-rank-3 { color: #cd7f32; font-size: 1.05rem; }
|
||||||
|
|
||||||
/* =========================================
|
/* =========================================
|
||||||
FORMS & INPUTS
|
FORMS & INPUTS
|
||||||
========================================= */
|
========================================= */
|
||||||
@@ -1069,6 +1244,69 @@ body {
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: var(--gold-bright);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-sep {
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-card {
|
||||||
|
text-align: left;
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content h3 {
|
||||||
|
color: var(--gold-soft);
|
||||||
|
margin-top: 22px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
border-bottom: 1px solid rgba(201, 168, 106, 0.2);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content p, .legal-content ul {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legal-content code {
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--gold-bright);
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-5
@@ -6,6 +6,9 @@ let attemptsUsed = 0;
|
|||||||
const maxAttempts = 3;
|
const maxAttempts = 3;
|
||||||
let selectedDifficulty = "all";
|
let selectedDifficulty = "all";
|
||||||
let lastQuestionData = null;
|
let lastQuestionData = null;
|
||||||
|
let timerStartTime = null;
|
||||||
|
let timerInterval = null;
|
||||||
|
let elapsedSeconds = 0;
|
||||||
|
|
||||||
const UI_TEXT = {
|
const UI_TEXT = {
|
||||||
de: {
|
de: {
|
||||||
@@ -24,8 +27,8 @@ const UI_TEXT = {
|
|||||||
questionLabel: "Frage",
|
questionLabel: "Frage",
|
||||||
ofLabel: "von",
|
ofLabel: "von",
|
||||||
attemptLabel: "Versuch",
|
attemptLabel: "Versuch",
|
||||||
checkBtn: "✨ Zauber prüfen",
|
checkBtn: "✨ Antwort prüfen",
|
||||||
hintBtn: "💡 Zaubertipp",
|
hintBtn: "💡 Hinweis anzeigen",
|
||||||
hintHeading: "📜 Notiz aus der verbotenen Abteilung:",
|
hintHeading: "📜 Notiz aus der verbotenen Abteilung:",
|
||||||
placeholder: "Deine Antwort hier eingeben...",
|
placeholder: "Deine Antwort hier eingeben...",
|
||||||
noHint: "Kein Tipp vorhanden.",
|
noHint: "Kein Tipp vorhanden.",
|
||||||
@@ -53,8 +56,8 @@ const UI_TEXT = {
|
|||||||
questionLabel: "Question",
|
questionLabel: "Question",
|
||||||
ofLabel: "of",
|
ofLabel: "of",
|
||||||
attemptLabel: "Attempt",
|
attemptLabel: "Attempt",
|
||||||
checkBtn: "✨ Check Spell",
|
checkBtn: "✨ Check Answer",
|
||||||
hintBtn: "💡 Magic Hint",
|
hintBtn: "💡 Show Hint",
|
||||||
hintHeading: "📜 Note from the Restricted Section:",
|
hintHeading: "📜 Note from the Restricted Section:",
|
||||||
placeholder: "Type your answer here...",
|
placeholder: "Type your answer here...",
|
||||||
noHint: "No hint available.",
|
noHint: "No hint available.",
|
||||||
@@ -203,6 +206,7 @@ async function startGame() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById("introCard").classList.add("hidden");
|
document.getElementById("introCard").classList.add("hidden");
|
||||||
document.getElementById("gameCard").classList.remove("hidden");
|
document.getElementById("gameCard").classList.remove("hidden");
|
||||||
|
startTimer();
|
||||||
renderQuestion(data);
|
renderQuestion(data);
|
||||||
}, 1100);
|
}, 1100);
|
||||||
|
|
||||||
@@ -214,6 +218,25 @@ async function startGame() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
timerStartTime = Date.now();
|
||||||
|
elapsedSeconds = 0;
|
||||||
|
if (timerInterval) clearInterval(timerInterval);
|
||||||
|
timerInterval = setInterval(updateTimer, 1000);
|
||||||
|
updateTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimer() {
|
||||||
|
if (!timerStartTime) return;
|
||||||
|
elapsedSeconds = Math.floor((Date.now() - timerStartTime) / 1000);
|
||||||
|
const m = Math.floor(elapsedSeconds / 60);
|
||||||
|
const s = elapsedSeconds % 60;
|
||||||
|
const badge = document.getElementById("timerBadge");
|
||||||
|
if (badge) {
|
||||||
|
badge.innerText = `⏱️ ${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderQuestion(qData) {
|
function renderQuestion(qData) {
|
||||||
lastQuestionData = qData;
|
lastQuestionData = qData;
|
||||||
const t = UI_TEXT[currentLang] || UI_TEXT.de;
|
const t = UI_TEXT[currentLang] || UI_TEXT.de;
|
||||||
@@ -424,8 +447,9 @@ async function requestHint() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function finishGame() {
|
function finishGame() {
|
||||||
|
if (timerInterval) clearInterval(timerInterval);
|
||||||
document.getElementById("progressBar").style.width = "100%";
|
document.getElementById("progressBar").style.width = "100%";
|
||||||
window.location.href = `/finale?score=${currentScore}&total=${totalQuestions}&lang=${currentLang}`;
|
window.location.href = `/finale?score=${currentScore}&total=${totalQuestions}&time=${elapsedSeconds}&lang=${currentLang}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter key to submit
|
// Enter key to submit
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
|
|
||||||
<nav id="navLinks" class="nav-links">
|
<nav id="navLinks" class="nav-links">
|
||||||
<a href="/" class="nav-link" id="navQuiz" onclick="closeMobileMenu()">✨ Zum Quiz</a>
|
<a href="/" class="nav-link" id="navQuiz" onclick="closeMobileMenu()">✨ Zum Quiz</a>
|
||||||
|
<a href="/bestenliste" class="nav-link" id="navLeaderboard" onclick="closeMobileMenu()">🏆 Bestenliste</a>
|
||||||
<a href="/vorschlag" class="nav-link" id="navSuggest" onclick="closeMobileMenu()">✍️ Frage einreichen</a>
|
<a href="/vorschlag" class="nav-link" id="navSuggest" onclick="closeMobileMenu()">✍️ Frage einreichen</a>
|
||||||
<a href="/admin" class="nav-link" id="navAdmin" onclick="closeMobileMenu()">🔒 Admin-Cockpit</a>
|
<a href="/admin" class="nav-link" id="navAdmin" onclick="closeMobileMenu()">🔒 Admin-Cockpit</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -48,6 +49,11 @@
|
|||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p id="footerText">⚡ Magisches Hogwarts-Escape-Quiz · Betrieben mit Liebe & Zauberkraft ⚡</p>
|
<p id="footerText">⚡ Magisches Hogwarts-Escape-Quiz · Betrieben mit Liebe & Zauberkraft ⚡</p>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="/impressum" class="footer-link" id="footerImpressum">⚖️ Impressum</a>
|
||||||
|
<span class="footer-sep">·</span>
|
||||||
|
<a href="/datenschutz" class="footer-link" id="footerDatenschutz">🛡️ Datenschutz</a>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -79,11 +85,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const navQuiz = document.getElementById("navQuiz");
|
const navQuiz = document.getElementById("navQuiz");
|
||||||
|
const navLeaderboard = document.getElementById("navLeaderboard");
|
||||||
const navSuggest = document.getElementById("navSuggest");
|
const navSuggest = document.getElementById("navSuggest");
|
||||||
const navAdmin = document.getElementById("navAdmin");
|
const navAdmin = document.getElementById("navAdmin");
|
||||||
const footerText = document.getElementById("footerText");
|
const footerText = document.getElementById("footerText");
|
||||||
|
const footerImpressum = document.getElementById("footerImpressum");
|
||||||
|
const footerDatenschutz = document.getElementById("footerDatenschutz");
|
||||||
|
|
||||||
if (navQuiz) navQuiz.innerText = isDe ? "✨ Zum Quiz" : "✨ To Quiz";
|
if (navQuiz) navQuiz.innerText = isDe ? "✨ Zum Quiz" : "✨ To Quiz";
|
||||||
|
if (navLeaderboard) navLeaderboard.innerText = isDe ? "🏆 Bestenliste" : "🏆 Leaderboard";
|
||||||
if (navSuggest) navSuggest.innerText = isDe ? "✍️ Frage einreichen" : "✍️ Submit Question";
|
if (navSuggest) navSuggest.innerText = isDe ? "✍️ Frage einreichen" : "✍️ Submit Question";
|
||||||
if (navAdmin) navAdmin.innerText = isDe ? "🔒 Admin-Cockpit" : "🔒 Admin Cockpit";
|
if (navAdmin) navAdmin.innerText = isDe ? "🔒 Admin-Cockpit" : "🔒 Admin Cockpit";
|
||||||
if (footerText) {
|
if (footerText) {
|
||||||
@@ -91,6 +101,8 @@
|
|||||||
? "⚡ Magisches Hogwarts-Escape-Quiz · Betrieben mit Liebe & Zauberkraft ⚡"
|
? "⚡ Magisches Hogwarts-Escape-Quiz · Betrieben mit Liebe & Zauberkraft ⚡"
|
||||||
: "⚡ Magical Hogwarts Escape Quiz · Powered by Love & Wizardry ⚡";
|
: "⚡ Magical Hogwarts Escape Quiz · Powered by Love & Wizardry ⚡";
|
||||||
}
|
}
|
||||||
|
if (footerImpressum) footerImpressum.innerText = isDe ? "⚖️ Impressum" : "⚖️ Legal Notice";
|
||||||
|
if (footerDatenschutz) footerDatenschutz.innerText = isDe ? "🛡️ Datenschutz" : "🛡️ Privacy Policy";
|
||||||
if (audioText) {
|
if (audioText) {
|
||||||
const isPlaying = bgm && !bgm.paused;
|
const isPlaying = bgm && !bgm.paused;
|
||||||
if (isDe) {
|
if (isDe) {
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'en' %}🏆 Hogwarts Hall of Fame – Leaderboard{% else %}🏆 Die Große Hogwarts-Ruhmeshalle – Bestenliste{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="quiz-wrapper">
|
||||||
|
<div class="card leaderboard-card">
|
||||||
|
<div class="leaderboard-header" style="text-align: center; margin-bottom: 20px;">
|
||||||
|
<span class="sparkle-icon">🏆</span>
|
||||||
|
<h1 class="glow-title" id="lbPageTitle">
|
||||||
|
{% if lang == 'en' %}🏆 The Grand Hogwarts Hall of Fame 🏆{% else %}🏆 Die Große Hogwarts-Ruhmeshalle 🏆{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle" id="lbPageSubtitle">
|
||||||
|
{% if lang == 'en' %}The most brilliant witches and wizards of all time{% else %}Die schlauesten Hexen und Zauberer aller Zeiten{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter tabs for difficulty -->
|
||||||
|
<div class="diff-filter-tabs">
|
||||||
|
<button type="button" class="tab-btn active" data-diff="all" onclick="filterLeaderboard('all', this)" id="tabAll">
|
||||||
|
🎲 {% if lang == 'en' %}All Mixed{% else %}Alle gemischt{% endif %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tab-btn" data-diff="easy" onclick="filterLeaderboard('easy', this)" id="tabEasy">
|
||||||
|
🟢 {% if lang == 'en' %}Easy{% else %}Leicht{% endif %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tab-btn" data-diff="medium" onclick="filterLeaderboard('medium', this)" id="tabMed">
|
||||||
|
🟡 {% if lang == 'en' %}Medium{% else %}Mittel{% endif %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="tab-btn" data-diff="hard" onclick="filterLeaderboard('hard', this)" id="tabHard">
|
||||||
|
🔴 {% if lang == 'en' %}Hard{% else %}Schwer{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive" style="margin-top: 20px;">
|
||||||
|
<table class="lb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px;">#</th>
|
||||||
|
<th id="thWizard">Zauberer</th>
|
||||||
|
<th id="thScore">Punkte</th>
|
||||||
|
<th id="thDuration">Zeit</th>
|
||||||
|
<th id="thDiff">Schwierigkeit</th>
|
||||||
|
<th id="thDate">Datum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="leaderboardBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" style="text-align: center; padding: 25px; color: var(--text-muted);">
|
||||||
|
✨ Lade die Pergamente der Ruhmeshalle...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group" style="margin-top: 30px;">
|
||||||
|
<a href="/" class="btn btn-primary" id="btnPlayNow">
|
||||||
|
✨ {% if lang == 'en' %}Play Now & Secure Rank{% else %}Jetzt spielen & Platz sichern{% endif %}
|
||||||
|
</a>
|
||||||
|
<a href="/vorschlag" class="btn btn-secondary" id="btnSuggestQ">
|
||||||
|
✍️ {% if lang == 'en' %}Submit Question{% else %}Frage einreichen{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let currentFilter = "all";
|
||||||
|
|
||||||
|
const LB_I18N = {
|
||||||
|
de: {
|
||||||
|
title: "🏆 Die Große Hogwarts-Ruhmeshalle 🏆",
|
||||||
|
sub: "Die schlauesten Hexen und Zauberer aller Zeiten",
|
||||||
|
tabAll: "🎲 Alle gemischt",
|
||||||
|
tabEasy: "🟢 Leicht",
|
||||||
|
tabMed: "🟡 Mittel",
|
||||||
|
tabHard: "🔴 Schwer",
|
||||||
|
thWizard: "Zauberer",
|
||||||
|
thScore: "Punkte",
|
||||||
|
thDuration: "Zeit",
|
||||||
|
thDiff: "Schwierigkeit",
|
||||||
|
thDate: "Datum",
|
||||||
|
btnPlay: "✨ Jetzt spielen & Platz sichern",
|
||||||
|
btnSuggest: "✍️ Frage einreichen",
|
||||||
|
empty: "Noch keine Einträge vorhanden. Sei der Erste, der sich verewigt!",
|
||||||
|
loading: "✨ Lade die Pergamente der Ruhmeshalle..."
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "🏆 The Grand Hogwarts Hall of Fame 🏆",
|
||||||
|
sub: "The most brilliant witches and wizards of all time",
|
||||||
|
tabAll: "🎲 All Mixed",
|
||||||
|
tabEasy: "🟢 Easy",
|
||||||
|
tabMed: "🟡 Medium",
|
||||||
|
tabHard: "🔴 Hard",
|
||||||
|
thWizard: "Wizard",
|
||||||
|
thScore: "Points",
|
||||||
|
thDuration: "Time",
|
||||||
|
thDiff: "Difficulty",
|
||||||
|
thDate: "Date",
|
||||||
|
btnPlay: "✨ Play Now & Secure Rank",
|
||||||
|
btnSuggest: "✍️ Submit Question",
|
||||||
|
empty: "No entries yet. Be the first to enter the Hall of Fame!",
|
||||||
|
loading: "✨ Unrolling the scrolls of the Hall of Fame..."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function onLanguageChange(lang) {
|
||||||
|
const isDe = (lang !== "en");
|
||||||
|
const t = isDe ? LB_I18N.de : LB_I18N.en;
|
||||||
|
|
||||||
|
const title = document.getElementById("lbPageTitle");
|
||||||
|
const sub = document.getElementById("lbPageSubtitle");
|
||||||
|
const tabAll = document.getElementById("tabAll");
|
||||||
|
const tabEasy = document.getElementById("tabEasy");
|
||||||
|
const tabMed = document.getElementById("tabMed");
|
||||||
|
const tabHard = document.getElementById("tabHard");
|
||||||
|
const thWizard = document.getElementById("thWizard");
|
||||||
|
const thScore = document.getElementById("thScore");
|
||||||
|
const thDuration = document.getElementById("thDuration");
|
||||||
|
const thDiff = document.getElementById("thDiff");
|
||||||
|
const thDate = document.getElementById("thDate");
|
||||||
|
const btnPlay = document.getElementById("btnPlayNow");
|
||||||
|
const btnSuggest = document.getElementById("btnSuggestQ");
|
||||||
|
|
||||||
|
if (title) title.innerText = t.title;
|
||||||
|
if (sub) sub.innerText = t.sub;
|
||||||
|
if (tabAll) tabAll.innerText = t.tabAll;
|
||||||
|
if (tabEasy) tabEasy.innerText = t.tabEasy;
|
||||||
|
if (tabMed) tabMed.innerText = t.tabMed;
|
||||||
|
if (tabHard) tabHard.innerText = t.tabHard;
|
||||||
|
if (thWizard) thWizard.innerText = t.thWizard;
|
||||||
|
if (thScore) thScore.innerText = t.thScore;
|
||||||
|
if (thDuration) thDuration.innerText = t.thDuration;
|
||||||
|
if (thDiff) thDiff.innerText = t.thDiff;
|
||||||
|
if (thDate) thDate.innerText = t.thDate;
|
||||||
|
if (btnPlay) btnPlay.innerText = t.btnPlay;
|
||||||
|
if (btnSuggest) btnSuggest.innerText = t.btnSuggest;
|
||||||
|
|
||||||
|
loadLeaderboard(currentFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterLeaderboard(diff, btn) {
|
||||||
|
currentFilter = diff;
|
||||||
|
document.querySelectorAll(".diff-filter-tabs .tab-btn").forEach(b => b.classList.remove("active"));
|
||||||
|
if (btn) btn.classList.add("active");
|
||||||
|
loadLeaderboard(diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLeaderboard(diff) {
|
||||||
|
const tbody = document.getElementById("leaderboardBody");
|
||||||
|
const isDe = (currentLang !== "en");
|
||||||
|
const t = isDe ? LB_I18N.de : LB_I18N.en;
|
||||||
|
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; padding: 25px; color: var(--text-muted);">${t.loading}</td></tr>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = (diff && diff !== "all") ? `/api/leaderboard?difficulty=${diff}` : "/api/leaderboard";
|
||||||
|
const res = await fetch(url);
|
||||||
|
const entries = await res.json();
|
||||||
|
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; padding: 30px; color: var(--text-muted);">${t.empty}</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
const medals = ["🥇", "🥈", "🥉"];
|
||||||
|
entries.forEach(e => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const rankDisplay = medals[e.rank - 1] || `${e.rank}.`;
|
||||||
|
const diffBadgeClass = `badge-diff-${e.difficulty || 'medium'}`;
|
||||||
|
|
||||||
|
let diffLabel = e.difficulty;
|
||||||
|
if (e.difficulty === "easy") diffLabel = isDe ? "🟢 Leicht" : "🟢 Easy";
|
||||||
|
else if (e.difficulty === "medium") diffLabel = isDe ? "🟡 Mittel" : "🟡 Medium";
|
||||||
|
else if (e.difficulty === "hard") diffLabel = isDe ? "🔴 Schwer" : "🔴 Hard";
|
||||||
|
else diffLabel = isDe ? "🎲 Gemischt" : "🎲 Mixed";
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><strong>${rankDisplay}</strong></td>
|
||||||
|
<td><strong>${escapeHtml(e.nickname)}</strong></td>
|
||||||
|
<td><span class="gold-text font-weight-bold">${e.score}/${e.total_questions}</span></td>
|
||||||
|
<td>${e.time_formatted}</td>
|
||||||
|
<td><span class="badge badge-diff ${diffBadgeClass}">${diffLabel}</span></td>
|
||||||
|
<td class="text-muted" style="font-size: 0.85rem;">${e.date}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color: #ff7675; padding: 20px;">Fehler beim Laden: ${err.message}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
onLanguageChange(currentLang);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'en' %}🛡️ Privacy Policy (Datenschutz){% else %}🛡️ Datenschutzerklärung{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="quiz-wrapper">
|
||||||
|
<div class="card legal-card">
|
||||||
|
<h1 class="glow-title" id="dsTitle">
|
||||||
|
{% if lang == 'en' %}🛡️ Privacy Policy{% else %}🛡️ Datenschutzerklärung{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle" id="dsSub">
|
||||||
|
{% if lang == 'en' %}Information pursuant to the EU General Data Protection Regulation (GDPR / DSGVO){% else %}Informationen zur Datenverarbeitung gemäß Art. 13 DSGVO{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="legal-content" id="dsContent">
|
||||||
|
{% if lang == 'en' %}
|
||||||
|
<h3>1. Responsible Controller</h3>
|
||||||
|
<p>
|
||||||
|
Responsible for data processing on <code>dev.waiswuaschd.de</code>:<br>
|
||||||
|
Maximilian R. (Waiswuaschd Project)<br>
|
||||||
|
Email: <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">2. General Principles: Data Minimization & Privacy by Design</h3>
|
||||||
|
<p>
|
||||||
|
We treat your data with the utmost care. This portal is built with strict data minimization in mind:
|
||||||
|
<strong>No user accounts are required, no tracking tools (e.g. Google Analytics, Meta Pixel) are used, and no data is sold to third parties.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">3. Server Log Files</h3>
|
||||||
|
<p>
|
||||||
|
When accessing our website, the web server automatically records standard access data (server logs):
|
||||||
|
</p>
|
||||||
|
<ul style="margin-left: 20px; color: var(--text-muted);">
|
||||||
|
<li>IP address of the requesting device</li>
|
||||||
|
<li>Date and time of access</li>
|
||||||
|
<li>Requested URL and HTTP status code</li>
|
||||||
|
<li>Browser type and version</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 8px;">
|
||||||
|
This data is processed pursuant to Art. 6(1)(f) GDPR to ensure technical system stability and IT security (e.g. DDoS protection). Log files are automatically deleted after standard server retention periods.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">4. Cookies & Local Browser Storage</h3>
|
||||||
|
<p>
|
||||||
|
We use strictly necessary technical storage only:
|
||||||
|
</p>
|
||||||
|
<ul style="margin-left: 20px; color: var(--text-muted);">
|
||||||
|
<li><strong>Session Cookie (<code>quiz_session</code>):</strong> A temporary identifier required to coordinate your 10 quiz questions. It expires when you close your browser session.</li>
|
||||||
|
<li><strong>Browser LocalStorage:</strong> Used solely to remember your chosen language (<code>quiz_lang</code>) and background music setting (<code>bgm_enabled</code>) between pages.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">5. Anonymous Leaderboard & Question Suggestions</h3>
|
||||||
|
<p>
|
||||||
|
Entering your name into the <em>Hogwarts Hall of Fame (Leaderboard)</em> or submitting a quiz question is completely voluntary.
|
||||||
|
We explicitly ask you <strong>not</strong> to provide your real name, but rather a playful wizard alias or nickname (e.g. <code>Golden_Snitch_99</code>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">6. Your Rights under the GDPR</h3>
|
||||||
|
<p>
|
||||||
|
Under EU GDPR, you have the right to information (Art. 15), correction (Art. 16), deletion (Art. 17), and restriction of processing (Art. 18).
|
||||||
|
For any data privacy inquiries, please contact us at <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<h3>1. Verantwortliche Stelle</h3>
|
||||||
|
<p>
|
||||||
|
Verantwortlich für die Datenverarbeitung auf <code>dev.waiswuaschd.de</code>:<br>
|
||||||
|
Maximilian R. (Waiswuaschd Projekt)<br>
|
||||||
|
E-Mail: <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">2. Grundsatz: Datensparsamkeit & Privatsphäre</h3>
|
||||||
|
<p>
|
||||||
|
Der Schutz deiner persönlichen Daten ist uns ein wichtiges Anliegen. Unser Quiz-Portal ist nach dem Prinzip der <strong>Datensparsamkeit</strong> konzipiert:
|
||||||
|
Es gibt <strong>keine Pflicht zur Registrierung, keine Tracking-Tools (wie Google Analytics oder Facebook-Pixel) und keine Weitergabe von Daten an Werbedienstleister</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">3. Server-Logfiles</h3>
|
||||||
|
<p>
|
||||||
|
Beim Aufrufen unserer Webseiten erfasst der Webserver automatisch technische Informationen (Server-Logfiles):
|
||||||
|
</p>
|
||||||
|
<ul style="margin-left: 20px; color: var(--text-muted);">
|
||||||
|
<li>IP-Adresse des anfragenden Geräts</li>
|
||||||
|
<li>Datum und Uhrzeit des Zugriffs</li>
|
||||||
|
<li>Abgerufene URL und HTTP-Statuscode</li>
|
||||||
|
<li>Browsertyp und Betriebssystem</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 8px;">
|
||||||
|
Die Verarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO zur Gewährleistung der Systemsicherheit, Stabilität und Abwehr von Cyberangriffen. Logfiles werden nach Ablauf der regulären Server-Aufbewahrungsfrist automatisch gelöscht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">4. Cookies & Lokale Speicherung (LocalStorage)</h3>
|
||||||
|
<p>
|
||||||
|
Wir setzen ausschließlich technisch notwendige Hilfsmittel ein:
|
||||||
|
</p>
|
||||||
|
<ul style="margin-left: 20px; color: var(--text-muted);">
|
||||||
|
<li><strong>Session-Cookie (<code>quiz_session</code>):</strong> Wird benötigt, um deinen Spielstand und deine 10 Fragen während der laufenden Quizrunde zuzuordnen.</li>
|
||||||
|
<li><strong>Lokaler Browser-Speicher (LocalStorage):</strong> Speichert lediglich deine Spracheinstellung (<code>quiz_lang</code>) und Musikpräferenz (<code>bgm_enabled</code>), damit du diese beim nächsten Seitenaufruf nicht neu einstellen musst.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">5. Anonyme Bestenliste & Frage-Vorschläge</h3>
|
||||||
|
<p>
|
||||||
|
Der Eintrag in die <em>Hogwarts-Ruhmeshalle (Bestenliste)</em> sowie das Einreichen eigener Quizfragen erfolgt auf rein freiwilliger Basis.
|
||||||
|
Wir bitten dich ausdrücklich darum, <strong>keine Klarnamen</strong> einzugeben, sondern ein magisches Pseudonym bzw. einen Alias (z. B. <code>Zauberlehrling_42</code>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">6. Deine Rechte gemäß DSGVO</h3>
|
||||||
|
<p>
|
||||||
|
Du hast nach der DSGVO jederzeit das Recht auf unentgeltliche Auskunft über deine gespeicherten Daten (Art. 15), Berichtigung (Art. 16) oder Löschung (Art. 17). Wende dich bei Fragen gerne direkt per E-Mail an uns.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group" style="margin-top: 30px;">
|
||||||
|
<a href="/" class="btn btn-primary" id="btnBackQuizDs">
|
||||||
|
✨ {% if lang == 'en' %}Back to Quiz{% else %}Zurück zum Quiz{% endif %}
|
||||||
|
</a>
|
||||||
|
<a href="/impressum" class="btn btn-secondary" id="btnToImp">
|
||||||
|
⚖️ {% if lang == 'en' %}Legal Notice{% else %}Zum Impressum{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const DS_TEXTS = {
|
||||||
|
de: {
|
||||||
|
title: "🛡️ Datenschutzerklärung",
|
||||||
|
sub: "Informationen zur Datenverarbeitung gemäß Art. 13 DSGVO",
|
||||||
|
btnBack: "✨ Zurück zum Quiz",
|
||||||
|
btnToImp: "⚖️ Zum Impressum",
|
||||||
|
html: `
|
||||||
|
<h3>1. Verantwortliche Stelle</h3>
|
||||||
|
<p>
|
||||||
|
Verantwortlich für die Datenverarbeitung auf <code>dev.waiswuaschd.de</code>:<br>
|
||||||
|
Maximilian R. (Waiswuaschd Projekt)<br>
|
||||||
|
E-Mail: <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">2. Grundsatz: Datensparsamkeit & Privatsphäre</h3>
|
||||||
|
<p>
|
||||||
|
Der Schutz deiner persönlichen Daten ist uns ein wichtiges Anliegen. Unser Quiz-Portal ist nach dem Prinzip der <strong>Datensparsamkeit</strong> konzipiert:
|
||||||
|
Es gibt <strong>keine Pflicht zur Registrierung, keine Tracking-Tools (wie Google Analytics oder Facebook-Pixel) und keine Weitergabe von Daten an Werbedienstleister</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">3. Server-Logfiles</h3>
|
||||||
|
<p>
|
||||||
|
Beim Aufrufen unserer Webseiten erfasst der Webserver automatisch technische Informationen (Server-Logfiles):
|
||||||
|
</p>
|
||||||
|
<ul style="margin-left: 20px; color: var(--text-muted);">
|
||||||
|
<li>IP-Adresse des anfragenden Geräts</li>
|
||||||
|
<li>Datum und Uhrzeit des Zugriffs</li>
|
||||||
|
<li>Abgerufene URL und HTTP-Statuscode</li>
|
||||||
|
<li>Browsertyp und Betriebssystem</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 8px;">
|
||||||
|
Die Verarbeitung erfolgt auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO zur Gewährleistung der Systemsicherheit, Stabilität und Abwehr von Cyberangriffen. Logfiles werden nach Ablauf der regulären Server-Aufbewahrungsfrist automatisch gelöscht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">4. Cookies & Lokale Speicherung (LocalStorage)</h3>
|
||||||
|
<p>
|
||||||
|
Wir setzen ausschließlich technisch notwendige Hilfsmittel ein:
|
||||||
|
</p>
|
||||||
|
<ul style="margin-left: 20px; color: var(--text-muted);">
|
||||||
|
<li><strong>Session-Cookie (<code>quiz_session</code>):</strong> Wird benötigt, um deinen Spielstand und deine 10 Fragen während der laufenden Quizrunde zuzuordnen.</li>
|
||||||
|
<li><strong>Lokaler Browser-Speicher (LocalStorage):</strong> Speichert lediglich deine Spracheinstellung (<code>quiz_lang</code>) und Musikpräferenz (<code>bgm_enabled</code>), damit du diese beim nächsten Seitenaufruf nicht neu einstellen musst.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">5. Anonyme Bestenliste & Frage-Vorschläge</h3>
|
||||||
|
<p>
|
||||||
|
Der Eintrag in die <em>Hogwarts-Ruhmeshalle (Bestenliste)</em> sowie das Einreichen eigener Quizfragen erfolgt auf rein freiwilliger Basis.
|
||||||
|
Wir bitten dich ausdrücklich darum, <strong>keine Klarnamen</strong> einzugeben, sondern ein magisches Pseudonym bzw. einen Alias (z. B. <code>Zauberlehrling_42</code>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">6. Deine Rechte gemäß DSGVO</h3>
|
||||||
|
<p>
|
||||||
|
Du hast nach der DSGVO jederzeit das Recht auf unentgeltliche Auskunft über deine gespeicherten Daten (Art. 15), Berichtigung (Art. 16) oder Löschung (Art. 17). Wende dich bei Fragen gerne direkt per E-Mail an uns.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "🛡️ Privacy Policy",
|
||||||
|
sub: "Information pursuant to the EU General Data Protection Regulation (GDPR / DSGVO)",
|
||||||
|
btnBack: "✨ Back to Quiz",
|
||||||
|
btnToImp: "⚖️ Legal Notice",
|
||||||
|
html: `
|
||||||
|
<h3>1. Responsible Controller</h3>
|
||||||
|
<p>
|
||||||
|
Responsible for data processing on <code>dev.waiswuaschd.de</code>:<br>
|
||||||
|
Maximilian R. (Waiswuaschd Project)<br>
|
||||||
|
Email: <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">2. General Principles: Data Minimization & Privacy by Design</h3>
|
||||||
|
<p>
|
||||||
|
We treat your data with the utmost care. This portal is built with strict data minimization in mind:
|
||||||
|
<strong>No user accounts are required, no tracking tools (e.g. Google Analytics, Meta Pixel) are used, and no data is sold to third parties.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">3. Server Log Files</h3>
|
||||||
|
<p>
|
||||||
|
When accessing our website, the web server automatically records standard access data (server logs):
|
||||||
|
</p>
|
||||||
|
<ul style="margin-left: 20px; color: var(--text-muted);">
|
||||||
|
<li>IP address of the requesting device</li>
|
||||||
|
<li>Date and time of access</li>
|
||||||
|
<li>Requested URL and HTTP status code</li>
|
||||||
|
<li>Browser type and version</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin-top: 8px;">
|
||||||
|
This data is processed pursuant to Art. 6(1)(f) GDPR to ensure technical system stability and IT security (e.g. DDoS protection). Log files are automatically deleted after standard server retention periods.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">4. Cookies & Local Browser Storage</h3>
|
||||||
|
<p>
|
||||||
|
We use strictly necessary technical storage only:
|
||||||
|
</p>
|
||||||
|
<ul style="margin-left: 20px; color: var(--text-muted);">
|
||||||
|
<li><strong>Session Cookie (<code>quiz_session</code>):</strong> A temporary identifier required to coordinate your 10 quiz questions. It expires when you close your browser session.</li>
|
||||||
|
<li><strong>Browser LocalStorage:</strong> Used solely to remember your chosen language (<code>quiz_lang</code>) and background music setting (<code>bgm_enabled</code>) between pages.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">5. Anonymous Leaderboard & Question Suggestions</h3>
|
||||||
|
<p>
|
||||||
|
Entering your name into the <em>Hogwarts Hall of Fame (Leaderboard)</em> or submitting a quiz question is completely voluntary.
|
||||||
|
We explicitly ask you <strong>not</strong> to provide your real name, but rather a playful wizard alias or nickname (e.g. <code>Golden_Snitch_99</code>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">6. Your Rights under the GDPR</h3>
|
||||||
|
<p>
|
||||||
|
Under EU GDPR, you have the right to information (Art. 15), correction (Art. 16), deletion (Art. 17), and restriction of processing (Art. 18).
|
||||||
|
For any data privacy inquiries, please contact us at <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function onLanguageChange(lang) {
|
||||||
|
const isDe = (lang !== "en");
|
||||||
|
const t = isDe ? DS_TEXTS.de : DS_TEXTS.en;
|
||||||
|
document.getElementById("dsTitle").innerText = t.title;
|
||||||
|
document.getElementById("dsSub").innerText = t.sub;
|
||||||
|
document.getElementById("dsContent").innerHTML = t.html;
|
||||||
|
document.getElementById("btnBackQuizDs").innerText = t.btnBack;
|
||||||
|
document.getElementById("btnToImp").innerText = t.btnToImp;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
onLanguageChange(currentLang);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+323
-56
@@ -1,58 +1,86 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{% if lang == 'en' %}🎉 The Grand Finale – Magical Quiz{% else %}🎉 Das große Finale – Magisches Quiz{% endif %}{% endblock %}
|
{% block title %}{% if lang == 'en' %}⚡ Hogwarts Report Card – Grand Finale{% else %}⚡ Das große Hogwarts-Zeugnis{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="quiz-wrapper">
|
<div class="quiz-wrapper">
|
||||||
<div class="card finale-card">
|
<div class="card finale-card">
|
||||||
<div class="finale-header">
|
<div class="finale-header">
|
||||||
<span class="sparkle-icon">✨</span>
|
<span class="sparkle-icon">✨</span>
|
||||||
<h1 class="glow-title" id="finaleTitle">🎉 Happy Birthday Schatz! 🎉</h1>
|
<h1 class="glow-title" id="finaleTitle">
|
||||||
|
{% if lang == 'en' %}⚡ The Great Hogwarts Report Card ⚡{% else %}⚡ Das große Hogwarts-Zeugnis ⚡{% endif %}
|
||||||
|
</h1>
|
||||||
<span class="sparkle-icon">✨</span>
|
<span class="sparkle-icon">✨</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Score & Time Stats -->
|
||||||
<div class="score-banner">
|
<div class="score-banner">
|
||||||
<p class="score-highlight" id="finaleScoreText">
|
<p class="score-highlight" id="finaleScoreText">
|
||||||
{% if lang == 'en' %}
|
{% if lang == 'en' %}
|
||||||
You secured <span class="gold-text">{{ score }}</span> out of <span class="gold-text">{{ total }}</span> possible House Points for your House!
|
You achieved <span class="gold-text">{{ score }}</span> out of <span class="gold-text">{{ total }}</span> House Points!
|
||||||
{% else %}
|
{% else %}
|
||||||
Du hast <span class="gold-text">{{ score }}</span> von <span class="gold-text">{{ total }}</span> möglichen Hauspunkten für dein Haus gesichert!
|
Du hast <span class="gold-text">{{ score }}</span> von <span class="gold-text">{{ total }}</span> Hauspunkten erzielt!
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="time-highlight" id="finaleTimeText">
|
||||||
|
⏱️ {% if lang == 'en' %}Time needed:{% else %}Benötigte Zeit:{% endif %} <strong class="gold-text">{{ time_formatted }}</strong>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reward-box">
|
<!-- Tiered Wizard Evaluation -->
|
||||||
<h2 id="rewardTitle">
|
<div class="reward-box tier-box" id="tierBox">
|
||||||
{% if lang == 'en' %}
|
<div class="tier-badge-container">
|
||||||
💛 Your Magical Reward: An Unforgettable Evening at Home 💛
|
<span id="tierGrade" class="tier-grade-badge {{ tier.badge_class }}">{{ tier.grade }}</span>
|
||||||
{% else %}
|
</div>
|
||||||
💛 Dein magischer Gewinn: Ein unvergesslicher Abend Zuhause 💛
|
<h2 id="tierTitle">{{ tier.title }}</h2>
|
||||||
{% endif %}
|
<p class="tier-subtitle" id="tierSubtitle">{{ tier.subtitle }}</p>
|
||||||
</h2>
|
<p class="reward-description" id="tierDesc">{{ tier.description }}</p>
|
||||||
<p class="reward-description" id="rewardDesc">
|
</div>
|
||||||
{% if lang == 'en' %}
|
|
||||||
We'll order delicious food and make ourselves totally comfortable: relax, laugh, and enjoy quality time together.<br><br>
|
<!-- Leaderboard Submission Card -->
|
||||||
Plus, we'll play a brand new <strong>Online Escape Game</strong> of your choice!
|
<div class="card leaderboard-entry-card" id="lbFormCard">
|
||||||
{% else %}
|
<h3 id="lbCardTitle">🏆 Trage dich in die Ruhmeshalle ein!</h3>
|
||||||
Wir holen uns lecker Essen und machen es uns so richtig gemütlich: entspannen, lachen und die gemeinsame Zeit genießen.<br><br>
|
<p class="subtitle" id="lbCardSub">Völlig anonym – wähle deinen Zauberer-Spitznamen:</p>
|
||||||
Dazu spielen wir ein brandneues <strong>Online Escape Game</strong> deiner Wahl!
|
|
||||||
{% endif %}
|
<form id="leaderboardForm" class="magic-form" onsubmit="submitScoreToLeaderboard(event)" style="margin-top: 12px;">
|
||||||
</p>
|
<div class="input-with-btn">
|
||||||
<p class="love-note" id="loveNote">
|
<input type="text" id="nicknameInput" class="input-text" placeholder="z. B. Fliegender_Besen oder Lord_Voldi" maxlength="30" required autocomplete="off">
|
||||||
{% if lang == 'en' %}
|
<button type="submit" id="lbSubmitBtn" class="btn btn-primary">
|
||||||
I'm so excited for our wonderful evening together! 💫
|
📜 Verewigen
|
||||||
{% else %}
|
</button>
|
||||||
Ich freue mich riesig auf unseren gemeinsamen Abend! 💫
|
</div>
|
||||||
{% endif %}
|
</form>
|
||||||
</p>
|
|
||||||
|
<div id="lbFeedback" class="feedback-box hidden" style="margin-top: 10px;"></div>
|
||||||
|
|
||||||
|
<!-- Mini Leaderboard Table -->
|
||||||
|
<div id="miniLbContainer" class="mini-lb-table-wrapper hidden" style="margin-top: 15px;">
|
||||||
|
<h4 id="miniLbTitle" style="color: var(--gold); margin-bottom: 8px;">Top Zauberer heute:</h4>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="lb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th id="thNick">Zauberer</th>
|
||||||
|
<th id="thPoints">Punkte</th>
|
||||||
|
<th id="thTime">Zeit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="miniLbBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-group finale-actions">
|
<div class="btn-group finale-actions">
|
||||||
<a href="/" class="btn btn-primary" id="playAgainBtn">
|
<a href="/" class="btn btn-primary" id="playAgainBtn">
|
||||||
{% if lang == 'en' %}🔄 Play Again{% else %}🔄 Erneut spielen{% endif %}
|
{% if lang == 'en' %}🔄 Play Again{% else %}🔄 Noch einmal spielen{% endif %}
|
||||||
|
</a>
|
||||||
|
<a href="/bestenliste" class="btn btn-secondary" id="hallOfFameBtn">
|
||||||
|
{% if lang == 'en' %}🏆 Hall of Fame{% else %}🏆 Zur Bestenliste{% endif %}
|
||||||
</a>
|
</a>
|
||||||
<a href="/vorschlag" class="btn btn-secondary" id="suggestBtn">
|
<a href="/vorschlag" class="btn btn-secondary" id="suggestBtn">
|
||||||
{% if lang == 'en' %}✍️ Suggest Your Own Question{% else %}✍️ Eigene Frage vorschlagen{% endif %}
|
{% if lang == 'en' %}✍️ Submit Question{% else %}✍️ Frage vorschlagen{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,46 +89,285 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
const finaleScore = {{ score }};
|
const score = {{ score }};
|
||||||
const finaleTotal = {{ total }};
|
const total = {{ total }};
|
||||||
|
const timeSec = {{ time_seconds }};
|
||||||
|
let scoreAlreadySubmitted = false;
|
||||||
|
|
||||||
|
const FINALE_I18N = {
|
||||||
|
de: {
|
||||||
|
title: "⚡ Das große Hogwarts-Zeugnis ⚡",
|
||||||
|
scoreText: `Du hast <span class="gold-text">${score}</span> von <span class="gold-text">${total}</span> Hauspunkten erzielt!`,
|
||||||
|
timeText: `⏱️ Benötigte Zeit: <strong class="gold-text">${formatSecondsDe(timeSec)}</strong>`,
|
||||||
|
lbTitle: "🏆 Trage dich in die Ruhmeshalle ein!",
|
||||||
|
lbSub: "Völlig anonym – wähle deinen Zauberer-Spitznamen:",
|
||||||
|
lbPlaceholder: "z. B. Fliegender_Besen oder Lord_Voldi",
|
||||||
|
lbBtn: "📜 Verewigen",
|
||||||
|
lbBtnSending: "✨ Wird verewigt...",
|
||||||
|
miniLbTitle: "Top Zauberer:",
|
||||||
|
thNick: "Zauberer",
|
||||||
|
thPoints: "Punkte",
|
||||||
|
thTime: "Zeit",
|
||||||
|
playAgain: "🔄 Noch einmal spielen",
|
||||||
|
hallOfFame: "🏆 Zur Bestenliste",
|
||||||
|
suggest: "✍️ Frage vorschlagen",
|
||||||
|
rankSuccess: (rank) => `✨ Glückwunsch! Du stehst auf **Platz #${rank}** der Ruhmeshalle!`
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "⚡ The Great Hogwarts Report Card ⚡",
|
||||||
|
scoreText: `You achieved <span class="gold-text">${score}</span> out of <span class="gold-text">${total}</span> House Points!`,
|
||||||
|
timeText: `⏱️ Time needed: <strong class="gold-text">${formatSecondsEn(timeSec)}</strong>`,
|
||||||
|
lbTitle: "🏆 Enter the Hogwarts Hall of Fame!",
|
||||||
|
lbSub: "Completely anonymous – choose your wizard nickname:",
|
||||||
|
lbPlaceholder: "e.g. Flying_Broom or Lord_Voldi",
|
||||||
|
lbBtn: "📜 Enter Score",
|
||||||
|
lbBtnSending: "✨ Entering score...",
|
||||||
|
miniLbTitle: "Top Wizards:",
|
||||||
|
thNick: "Wizard",
|
||||||
|
thPoints: "Points",
|
||||||
|
thTime: "Time",
|
||||||
|
playAgain: "🔄 Play Again",
|
||||||
|
hallOfFame: "🏆 Hall of Fame",
|
||||||
|
suggest: "✍️ Submit Question",
|
||||||
|
rankSuccess: (rank) => `✨ Congratulations! You are **Rank #${rank}** in the Hall of Fame!`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatSecondsDe(sec) {
|
||||||
|
if (sec >= 60) {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${m} Min. ${s} Sek.`;
|
||||||
|
}
|
||||||
|
return `${sec} Sek.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSecondsEn(sec) {
|
||||||
|
if (sec >= 60) {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${m} min. ${s} sec.`;
|
||||||
|
}
|
||||||
|
return `${sec} sec.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIERS_DATA = {
|
||||||
|
10: {
|
||||||
|
grade: "O",
|
||||||
|
badge_class: "tier-outstanding",
|
||||||
|
de: {
|
||||||
|
title: "🧙♂️ Großmeister der Zauberei",
|
||||||
|
sub: "Note: O (Ohnegleichen)",
|
||||||
|
desc: "Unglaublich! Albus Dumbledore überlegt bereits, dir das Büro des Schulleiters zu überlassen. Du kennst selbst die geheimsten Winkel von Hogwarts besser als der Fast Kopflose Nick. Das Zaubereiministerium verneigt sich in Ehrfurcht!"
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "🧙♂️ Grandmaster of Wizardry",
|
||||||
|
sub: "Grade: O (Outstanding)",
|
||||||
|
desc: "Incredible! Albus Dumbledore is already considering handing you the headmaster's office. You know the secret passages of Hogwarts better than Nearly Headless Nick. The Ministry of Magic bows in deep awe!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
8: {
|
||||||
|
grade: "E",
|
||||||
|
badge_class: "tier-exceeds",
|
||||||
|
de: {
|
||||||
|
title: "⚡ Meister-Hexe / Zauberer",
|
||||||
|
sub: "Note: E (Erwartungen übertroffen)",
|
||||||
|
desc: "Hermine Granger nickt anerkennend und Professor McGonagall vergibt 50 Punkte für dein Haus! Dein Zauberstab zuckt blitzschnell und dein Wissen lässt selbst Ravenclaws erblassen."
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "⚡ Master Witch / Wizard",
|
||||||
|
sub: "Grade: E (Exceeds Expectations)",
|
||||||
|
desc: "Hermione Granger nods in deep approval and Professor McGonagall awards 50 points to your House! Your wandwork is razor-sharp and your knowledge makes even Ravenclaws jealous."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
grade: "A",
|
||||||
|
badge_class: "tier-acceptable",
|
||||||
|
de: {
|
||||||
|
title: "📜 Solider Hogwarts-Schüler",
|
||||||
|
sub: "Note: A (Annehmbar)",
|
||||||
|
desc: "Bestanden! Ein paar Kessel sind zwar im Zaubertränke-Unterricht explodiert und Peeves hat dich mit Kreide beworfen, aber du hast dich tapfer durchgeschlagen. Eine solide magische Leistung!"
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "📜 Solid Hogwarts Student",
|
||||||
|
sub: "Grade: A (Acceptable)",
|
||||||
|
desc: "Passed! A few cauldrons blew up during Potions class and Peeves threw chalk at your head, but you bravely fought your way through. A solid magical performance!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
grade: "M",
|
||||||
|
badge_class: "tier-poor",
|
||||||
|
de: {
|
||||||
|
title: "🧹 Quaffel-Fänger in Not",
|
||||||
|
sub: "Note: M (Mies)",
|
||||||
|
desc: "Au weia... Hat Ron Weasley dir seinen mit Tesafilm geklebten Zauberstab geliehen? Deine Eule hat sich wohl im Nebel verflogen. Zeit für ein paar Extrastunden in der Bibliothek bei Madam Pince!"
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "🧹 Quaffle Catcher in Distress",
|
||||||
|
sub: "Grade: P (Poor)",
|
||||||
|
desc: "Yikes... Did Ron Weasley lend you his Spellotaped wand? Your owl must have gotten lost in the fog. Time for extra study sessions in the library with Madam Pince!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
0: {
|
||||||
|
grade: "T",
|
||||||
|
badge_class: "tier-troll",
|
||||||
|
de: {
|
||||||
|
title: "🧌 Bergtroll im Kerker",
|
||||||
|
sub: "Note: T (Troll)",
|
||||||
|
desc: "Alarm im Schloss! Du hast mit der Holzkeule auf die Fragen eingeschlagen. Schnell weg, bevor Hausmeister Filch dich beim lebenslangen Nachsitzen in den Kerkern erwischt!"
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "🧌 Mountain Troll in the Dungeon",
|
||||||
|
sub: "Grade: T (Troll)",
|
||||||
|
desc: "Troll in the dungeon! You smashed the questions with a wooden club. Run quickly before caretaker Filch catches you for lifelong detention in the dungeons!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTierForScore(s) {
|
||||||
|
if (s >= 10) return TIERS_DATA[10];
|
||||||
|
if (s >= 8) return TIERS_DATA[8];
|
||||||
|
if (s >= 5) return TIERS_DATA[5];
|
||||||
|
if (s >= 2) return TIERS_DATA[2];
|
||||||
|
return TIERS_DATA[0];
|
||||||
|
}
|
||||||
|
|
||||||
function onLanguageChange(lang) {
|
function onLanguageChange(lang) {
|
||||||
const isDe = lang === "de";
|
const isDe = (lang !== "en");
|
||||||
|
const t = isDe ? FINALE_I18N.de : FINALE_I18N.en;
|
||||||
|
|
||||||
const title = document.getElementById("finaleTitle");
|
const title = document.getElementById("finaleTitle");
|
||||||
const scoreText = document.getElementById("finaleScoreText");
|
const scoreText = document.getElementById("finaleScoreText");
|
||||||
const rewardTitle = document.getElementById("rewardTitle");
|
const timeText = document.getElementById("finaleTimeText");
|
||||||
const rewardDesc = document.getElementById("rewardDesc");
|
const lbTitle = document.getElementById("lbCardTitle");
|
||||||
const loveNote = document.getElementById("loveNote");
|
const lbSub = document.getElementById("lbCardSub");
|
||||||
|
const nicknameInput = document.getElementById("nicknameInput");
|
||||||
|
const lbSubmitBtn = document.getElementById("lbSubmitBtn");
|
||||||
|
const miniLbTitle = document.getElementById("miniLbTitle");
|
||||||
|
const thNick = document.getElementById("thNick");
|
||||||
|
const thPoints = document.getElementById("thPoints");
|
||||||
|
const thTime = document.getElementById("thTime");
|
||||||
const playAgain = document.getElementById("playAgainBtn");
|
const playAgain = document.getElementById("playAgainBtn");
|
||||||
|
const hallOfFame = document.getElementById("hallOfFameBtn");
|
||||||
const suggest = document.getElementById("suggestBtn");
|
const suggest = document.getElementById("suggestBtn");
|
||||||
|
|
||||||
if (title) title.innerText = isDe ? "🎉 Happy Birthday Schatz! 🎉" : "🎉 Happy Birthday Darling! 🎉";
|
if (title) title.innerText = t.title;
|
||||||
if (scoreText) {
|
if (scoreText) scoreText.innerHTML = t.scoreText;
|
||||||
scoreText.innerHTML = isDe
|
if (timeText) timeText.innerHTML = t.timeText;
|
||||||
? `Du hast <span class="gold-text">${finaleScore}</span> von <span class="gold-text">${finaleTotal}</span> möglichen Hauspunkten für dein Haus gesichert!`
|
if (lbTitle) lbTitle.innerText = t.lbTitle;
|
||||||
: `You secured <span class="gold-text">${finaleScore}</span> out of <span class="gold-text">${finaleTotal}</span> possible House Points for your House!`;
|
if (lbSub) lbSub.innerText = t.lbSub;
|
||||||
|
if (nicknameInput) nicknameInput.placeholder = t.lbPlaceholder;
|
||||||
|
if (lbSubmitBtn && !lbSubmitBtn.disabled) lbSubmitBtn.innerText = t.lbBtn;
|
||||||
|
if (miniLbTitle) miniLbTitle.innerText = t.miniLbTitle;
|
||||||
|
if (thNick) thNick.innerText = t.thNick;
|
||||||
|
if (thPoints) thPoints.innerText = t.thPoints;
|
||||||
|
if (thTime) thTime.innerText = t.thTime;
|
||||||
|
if (playAgain) playAgain.innerText = t.playAgain;
|
||||||
|
if (hallOfFame) hallOfFame.innerText = t.hallOfFame;
|
||||||
|
if (suggest) suggest.innerText = t.suggest;
|
||||||
|
|
||||||
|
// Tier evaluation
|
||||||
|
const tier = getTierForScore(score);
|
||||||
|
const tierContent = isDe ? tier.de : tier.en;
|
||||||
|
const tierGrade = document.getElementById("tierGrade");
|
||||||
|
const tierTitle = document.getElementById("tierTitle");
|
||||||
|
const tierSubtitle = document.getElementById("tierSubtitle");
|
||||||
|
const tierDesc = document.getElementById("tierDesc");
|
||||||
|
|
||||||
|
if (tierGrade) {
|
||||||
|
tierGrade.className = `tier-grade-badge ${tier.badge_class}`;
|
||||||
|
tierGrade.innerText = tier.grade;
|
||||||
}
|
}
|
||||||
if (rewardTitle) {
|
if (tierTitle) tierTitle.innerText = tierContent.title;
|
||||||
rewardTitle.innerText = isDe
|
if (tierSubtitle) tierSubtitle.innerText = tierContent.sub;
|
||||||
? "💛 Dein magischer Gewinn: Ein unvergesslicher Abend Zuhause 💛"
|
if (tierDesc) tierDesc.innerText = tierContent.desc;
|
||||||
: "💛 Your Magical Reward: An Unforgettable Evening at Home 💛";
|
}
|
||||||
|
|
||||||
|
async function submitScoreToLeaderboard(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (scoreAlreadySubmitted) return;
|
||||||
|
|
||||||
|
const isDe = (currentLang !== "en");
|
||||||
|
const t = isDe ? FINALE_I18N.de : FINALE_I18N.en;
|
||||||
|
const nickname = document.getElementById("nicknameInput").value.trim();
|
||||||
|
const submitBtn = document.getElementById("lbSubmitBtn");
|
||||||
|
const feedback = document.getElementById("lbFeedback");
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerText = t.lbBtnSending;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/leaderboard", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
nickname: nickname,
|
||||||
|
score: score,
|
||||||
|
total_questions: total,
|
||||||
|
time_seconds: timeSec,
|
||||||
|
difficulty: "all",
|
||||||
|
lang: currentLang
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
scoreAlreadySubmitted = true;
|
||||||
|
feedback.className = "feedback-box feedback-success";
|
||||||
|
feedback.innerHTML = `
|
||||||
|
<div style="background: rgba(46, 204, 113, 0.2); border: 1px solid #2ecc71; color: #55efc4; padding: 12px; border-radius: 8px;">
|
||||||
|
✨ <strong>${data.nickname}</strong>: ${t.rankSuccess(data.rank)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
feedback.classList.remove("hidden");
|
||||||
|
document.getElementById("nicknameInput").disabled = true;
|
||||||
|
submitBtn.innerText = isDe ? "✅ Eingetragen" : "✅ Entered";
|
||||||
|
loadMiniLeaderboard();
|
||||||
|
} else {
|
||||||
|
feedback.className = "feedback-box alert alert-danger";
|
||||||
|
feedback.innerText = data.detail || (isDe ? "Fehler beim Eintragen." : "Error entering score.");
|
||||||
|
feedback.classList.remove("hidden");
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerText = t.lbBtn;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
feedback.className = "feedback-box alert alert-danger";
|
||||||
|
feedback.innerText = "Netzwerkfehler: " + err.message;
|
||||||
|
feedback.classList.remove("hidden");
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerText = t.lbBtn;
|
||||||
}
|
}
|
||||||
if (rewardDesc) {
|
}
|
||||||
rewardDesc.innerHTML = isDe
|
|
||||||
? "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!"
|
async function loadMiniLeaderboard() {
|
||||||
: "We'll order delicious food and make ourselves totally comfortable: relax, laugh, and enjoy quality time together.<br><br>Plus, we'll play a brand new <strong>Online Escape Game</strong> of your choice!";
|
try {
|
||||||
|
const res = await fetch("/api/leaderboard?limit=5");
|
||||||
|
const entries = await res.json();
|
||||||
|
const body = document.getElementById("miniLbBody");
|
||||||
|
const container = document.getElementById("miniLbContainer");
|
||||||
|
|
||||||
|
body.innerHTML = "";
|
||||||
|
const medals = ["🥇", "🥈", "🥉"];
|
||||||
|
entries.forEach(e => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const rankDisplay = medals[e.rank - 1] || `${e.rank}.`;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><strong>${rankDisplay}</strong></td>
|
||||||
|
<td>${e.nickname}</td>
|
||||||
|
<td><span class="gold-text">${e.score}/${e.total_questions}</span></td>
|
||||||
|
<td>${e.time_formatted}</td>
|
||||||
|
`;
|
||||||
|
body.appendChild(tr);
|
||||||
|
});
|
||||||
|
container.classList.remove("hidden");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not load mini leaderboard", err);
|
||||||
}
|
}
|
||||||
if (loveNote) {
|
|
||||||
loveNote.innerText = isDe
|
|
||||||
? "Ich freue mich riesig auf unseren gemeinsamen Abend! 💫"
|
|
||||||
: "I'm so excited for our wonderful evening together! 💫";
|
|
||||||
}
|
|
||||||
if (playAgain) playAgain.innerText = isDe ? "🔄 Erneut spielen" : "🔄 Play Again";
|
|
||||||
if (suggest) suggest.innerText = isDe ? "✍️ Eigene Frage vorschlagen" : "✍️ Suggest Your Own Question";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
onLanguageChange(currentLang);
|
onLanguageChange(currentLang);
|
||||||
|
loadMiniLeaderboard();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'en' %}⚖️ Legal Notice (Impressum){% else %}⚖️ Impressum{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="quiz-wrapper">
|
||||||
|
<div class="card legal-card">
|
||||||
|
<h1 class="glow-title" id="impTitle">
|
||||||
|
{% if lang == 'en' %}⚖️ Legal Notice (Impressum){% else %}⚖️ Impressum{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle" id="impSub">
|
||||||
|
{% if lang == 'en' %}Information according to § 5 DDG{% else %}Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG){% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="legal-content" id="impContent">
|
||||||
|
{% if lang == 'en' %}
|
||||||
|
<h3>Website Operator & Contact</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Magisches Hogwarts Escape Quiz</strong><br>
|
||||||
|
A private, non-commercial web project on <code>dev.waiswuaschd.de</code>.<br>
|
||||||
|
Operator: Maximilian R. (Waiswuaschd Project)<br>
|
||||||
|
Contact: <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Non-Commercial Fan Project Disclaimer</h3>
|
||||||
|
<p>
|
||||||
|
This website is a purely non-commercial, private fan project created for educational, puzzle, and entertainment purposes.
|
||||||
|
No commercial profit or advertising revenue is generated through this service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Copyright & Trademarks</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Harry Potter</strong> characters, names, and related indicia are trademarks of and © Warner Bros. Entertainment Inc. and J.K. Rowling.
|
||||||
|
Harry Potter publishing rights © Bloomsbury Publishing Plc and Carlsen Verlag.
|
||||||
|
All rights belong to their respective copyright and trademark owners. No infringement is intended.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Liability for Content & External Links</h3>
|
||||||
|
<p>
|
||||||
|
As a service provider, we are responsible for our own content on these pages under general laws. We make every effort to keep information up to date and accurate, but cannot accept liability for external links to third-party websites over whose content we have no control.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<h3>Diensteanbieter & Kontakt</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Magisches Hogwarts-Escape-Quiz</strong><br>
|
||||||
|
Ein rein privates, nicht-kommerzielles Projekt auf <code>dev.waiswuaschd.de</code>.<br>
|
||||||
|
Betreiber: Maximilian R. (Waiswuaschd Projekt)<br>
|
||||||
|
E-Mail-Kontakt: <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Nicht-kommerzielles Fan-Projekt</h3>
|
||||||
|
<p>
|
||||||
|
Diese Webseite ist ein reines Fan-Projekt, das unentgeltlich und aus Freude an Zauberrätseln und Harry Potter betrieben wird. Es werden keinerlei kommerzielle Interessen verfolgt, keine Werbung geschaltet und keine Einnahmen erzielt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Urheberrecht & Markenzeichen</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Harry Potter</strong>, Charaktere, Namen und zugehörige Kennzeichen sind Marken von und © Warner Bros. Entertainment Inc. und J.K. Rowling.<br>
|
||||||
|
Die Verlagsrechte liegen bei Bloomsbury Publishing Plc sowie Carlsen Verlag GmbH. Alle Rechte verbleiben bei den jeweiligen Rechteinhabern. Es besteht keinerlei Absicht zur Verletzung von Marken- oder Urheberrechten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Haftung für Inhalte und Links</h3>
|
||||||
|
<p>
|
||||||
|
Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Verlinkungen zu externen Webseiten Dritter unterliegen der Haftung der jeweiligen Betreiber.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group" style="margin-top: 30px;">
|
||||||
|
<a href="/" class="btn btn-primary" id="btnBackQuiz">
|
||||||
|
✨ {% if lang == 'en' %}Back to Quiz{% else %}Zurück zum Quiz{% endif %}
|
||||||
|
</a>
|
||||||
|
<a href="/datenschutz" class="btn btn-secondary" id="btnToPrivacy">
|
||||||
|
🛡️ {% if lang == 'en' %}Privacy Policy{% else %}Zur Datenschutzerklärung{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const IMP_TEXTS = {
|
||||||
|
de: {
|
||||||
|
title: "⚖️ Impressum",
|
||||||
|
sub: "Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG)",
|
||||||
|
btnBack: "✨ Zurück zum Quiz",
|
||||||
|
btnToPrivacy: "🛡️ Zur Datenschutzerklärung",
|
||||||
|
html: `
|
||||||
|
<h3>Diensteanbieter & Kontakt</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Magisches Hogwarts-Escape-Quiz</strong><br>
|
||||||
|
Ein rein privates, nicht-kommerzielles Projekt auf <code>dev.waiswuaschd.de</code>.<br>
|
||||||
|
Betreiber: Maximilian R. (Waiswuaschd Projekt)<br>
|
||||||
|
E-Mail-Kontakt: <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Nicht-kommerzielles Fan-Projekt</h3>
|
||||||
|
<p>
|
||||||
|
Diese Webseite ist ein reines Fan-Projekt, das unentgeltlich und aus Freude an Zauberrätseln und Harry Potter betrieben wird. Es werden keinerlei kommerzielle Interessen verfolgt, keine Werbung geschaltet und keine Einnahmen erzielt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Urheberrecht & Markenzeichen</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Harry Potter</strong>, Charaktere, Namen und zugehörige Kennzeichen sind Marken von und © Warner Bros. Entertainment Inc. und J.K. Rowling.<br>
|
||||||
|
Die Verlagsrechte liegen bei Bloomsbury Publishing Plc sowie Carlsen Verlag GmbH. Alle Rechte verbleiben bei den jeweiligen Rechteinhabern. Es besteht keinerlei Absicht zur Verletzung von Marken- oder Urheberrechten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Haftung für Inhalte und Links</h3>
|
||||||
|
<p>
|
||||||
|
Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen. Verlinkungen zu externen Webseiten Dritter unterliegen der Haftung der jeweiligen Betreiber.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "⚖️ Legal Notice (Impressum)",
|
||||||
|
sub: "Information according to § 5 DDG",
|
||||||
|
btnBack: "✨ Back to Quiz",
|
||||||
|
btnToPrivacy: "🛡️ Privacy Policy",
|
||||||
|
html: `
|
||||||
|
<h3>Website Operator & Contact</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Magical Hogwarts Escape Quiz</strong><br>
|
||||||
|
A private, non-commercial web project on <code>dev.waiswuaschd.de</code>.<br>
|
||||||
|
Operator: Maximilian R. (Waiswuaschd Project)<br>
|
||||||
|
Contact: <a href="mailto:kontakt@waiswuaschd.de" style="color: var(--gold);">kontakt@waiswuaschd.de</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Non-Commercial Fan Project Disclaimer</h3>
|
||||||
|
<p>
|
||||||
|
This website is a purely non-commercial, private fan project created for educational, puzzle, and entertainment purposes.
|
||||||
|
No commercial profit or advertising revenue is generated through this service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Copyright & Trademarks</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Harry Potter</strong> characters, names, and related indicia are trademarks of and © Warner Bros. Entertainment Inc. and J.K. Rowling.
|
||||||
|
Harry Potter publishing rights © Bloomsbury Publishing Plc and Carlsen Verlag.
|
||||||
|
All rights belong to their respective copyright and trademark owners. No infringement is intended.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 20px;">Liability for Content & External Links</h3>
|
||||||
|
<p>
|
||||||
|
As a service provider, we are responsible for our own content on these pages under general laws. We make every effort to keep information up to date and accurate, but cannot accept liability for external links to third-party websites over whose content we have no control.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function onLanguageChange(lang) {
|
||||||
|
const isDe = (lang !== "en");
|
||||||
|
const t = isDe ? IMP_TEXTS.de : IMP_TEXTS.en;
|
||||||
|
document.getElementById("impTitle").innerText = t.title;
|
||||||
|
document.getElementById("impSub").innerText = t.sub;
|
||||||
|
document.getElementById("impContent").innerHTML = t.html;
|
||||||
|
document.getElementById("btnBackQuiz").innerText = t.btnBack;
|
||||||
|
document.getElementById("btnToPrivacy").innerText = t.btnToPrivacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
onLanguageChange(currentLang);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -53,10 +53,11 @@
|
|||||||
|
|
||||||
<!-- Spielbereich (initial versteckt) -->
|
<!-- Spielbereich (initial versteckt) -->
|
||||||
<div id="gameCard" class="game-area hidden">
|
<div id="gameCard" class="game-area hidden">
|
||||||
<!-- Fortschritt & Punkte -->
|
<!-- Fortschritt & Punkte & Zeit -->
|
||||||
<div class="status-panel">
|
<div class="status-panel">
|
||||||
<div class="progress-info">
|
<div class="progress-info">
|
||||||
<span id="questionCounter">Frage 1 von 10</span>
|
<span id="questionCounter">Frage 1 von 10</span>
|
||||||
|
<span id="timerBadge" class="timer-badge">⏱️ 00:00</span>
|
||||||
<span id="pointsCounter" class="points-badge">🏆 Hauspunkte: 0</span>
|
<span id="pointsCounter" class="points-badge">🏆 Hauspunkte: 0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-container">
|
<div class="progress-container">
|
||||||
@@ -82,10 +83,10 @@
|
|||||||
|
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button id="checkBtn" class="btn btn-primary" onclick="checkAnswer()">
|
<button id="checkBtn" class="btn btn-primary" onclick="checkAnswer()">
|
||||||
✨ Zauber prüfen
|
✨ Antwort prüfen
|
||||||
</button>
|
</button>
|
||||||
<button id="hintBtn" class="btn btn-secondary" onclick="requestHint()">
|
<button id="hintBtn" class="btn btn-secondary" onclick="requestHint()">
|
||||||
💡 Zaubertipp
|
💡 Hinweis anzeigen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
<div id="choiceOptionsContainer" class="choice-grid"></div>
|
<div id="choiceOptionsContainer" class="choice-grid"></div>
|
||||||
<div class="btn-group" style="margin-top: 15px;">
|
<div class="btn-group" style="margin-top: 15px;">
|
||||||
<button id="hintBtnMC" class="btn btn-secondary" onclick="requestHint()">
|
<button id="hintBtnMC" class="btn btn-secondary" onclick="requestHint()">
|
||||||
💡 Zaubertipp
|
💡 Hinweis anzeigen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
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}"
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
|
||||||
|
def run_migration():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
print("Running migrate_v4...")
|
||||||
|
|
||||||
|
# 1. Add duration_seconds to quiz_sessions
|
||||||
|
res = conn.execute(text("SHOW COLUMNS FROM quiz_sessions LIKE 'duration_seconds'")).fetchall()
|
||||||
|
if not res:
|
||||||
|
print("Adding duration_seconds to quiz_sessions...")
|
||||||
|
conn.execute(text("ALTER TABLE quiz_sessions ADD COLUMN duration_seconds INT NULL"))
|
||||||
|
conn.commit()
|
||||||
|
print("Added duration_seconds column.")
|
||||||
|
else:
|
||||||
|
print("Column duration_seconds already exists.")
|
||||||
|
|
||||||
|
# 2. Create leaderboard_entries table
|
||||||
|
print("Ensuring leaderboard_entries table exists...")
|
||||||
|
conn.execute(text("""
|
||||||
|
CREATE TABLE IF NOT EXISTS leaderboard_entries (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
nickname VARCHAR(50) NOT NULL,
|
||||||
|
score INT NOT NULL,
|
||||||
|
total_questions INT NOT NULL DEFAULT 10,
|
||||||
|
time_seconds INT NOT NULL,
|
||||||
|
difficulty VARCHAR(20) NOT NULL DEFAULT 'all',
|
||||||
|
lang VARCHAR(10) NOT NULL DEFAULT 'de',
|
||||||
|
session_id VARCHAR(36) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_leaderboard_score_time (score DESC, time_seconds ASC),
|
||||||
|
INDEX idx_leaderboard_diff (difficulty),
|
||||||
|
INDEX idx_leaderboard_created (created_at DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
"""))
|
||||||
|
conn.commit()
|
||||||
|
print("leaderboard_entries table ready.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_migration()
|
||||||
Reference in New Issue
Block a user