eis-website/app.py

541 lines
23 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import sqlite3
from flask import Flask, request, redirect, session
from datetime import datetime, timezone, timedelta
app = Flask(__name__)
app.secret_key = "octan-tower-kragle-everything-is-awesome-2026" # für session (Adventure-Fortschritt)
# ─── SQLite-Datenbank (U18) ──────────────────────────────────────────────────
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "adventure.db")
def get_db():
db = sqlite3.connect(DB_PATH)
db.row_factory = sqlite3.Row # Spalten per Name ansprechen
return db
# ─── Zeitzone: CEST (UTC+2) ──────────────────────────────────────────────────
BERLIN = timezone(timedelta(hours=2))
def jetzt():
return datetime.now(BERLIN).strftime("%d.%m.%Y, %H:%M Uhr")
# ─── Kommentare im Arbeitsspeicher ───────────────────────────────────────────
# Schema: {"name": str, "text": str, "zeit": str,
# "antworten": [{"name": str, "text": str, "zeit": str}]}
eintraege = []
# ─────────────────────────────────────────────────────────────────────────────
# Hilfsfunktion: gemeinsames HTML-Gerüst
# ─────────────────────────────────────────────────────────────────────────────
def seite(inhalt):
return """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flask-App David & Karo</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, sans-serif;
background: #f9f9f9;
color: #333;
min-height: 100vh;
}
.topbar {
background: #fff;
border-bottom: 1px solid #e0e0e0;
padding: 0 20px;
height: 56px;
display: flex;
align-items: center;
gap: 16px;
position: sticky;
top: 0;
z-index: 100;
}
.topbar-logo {
font-size: 20px;
font-weight: bold;
color: #cc181e;
letter-spacing: -1px;
text-decoration: none;
}
.topbar nav { display: flex; gap: 12px; margin-left: 16px; }
.topbar nav a {
font-size: 13px;
color: #555;
text-decoration: none;
padding: 4px 10px;
border-radius: 2px;
border: 1px solid transparent;
transition: border-color 0.15s;
}
.topbar nav a:hover { border-color: #ccc; background: #f5f5f5; }
.topbar nav a.aktiv { color: #cc181e; border-color: #cc181e; }
.topbar-back {
margin-left: auto;
font-size: 13px;
color: #167ac6;
text-decoration: none;
padding: 6px 14px;
border: 1px solid #167ac6;
border-radius: 2px;
font-weight: bold;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.topbar-back:hover { background: #167ac6; color: #fff; }
.container {
max-width: 900px;
margin: 30px auto;
padding: 0 20px;
}
</style>
</head>
<body>
<div class="topbar">
<a href="/app/" class="topbar-logo">&#9654; Flask-App</a>
<nav>
<a href="/app/">🧱 Adventure</a>
<a href="/app/pinnwand" class="aktiv">Kommentare</a>
</nav>
<a href="https://isa2.edumake.de" class="topbar-back">&#8592; Zurück zur Website</a>
</div>
<div class="container">""" + inhalt + """
</div>
</body>
</html>"""
# ─────────────────────────────────────────────────────────────────────────────
# U18 Klick-Adventure "Einbruch im Octan-Tower" (The-Lego-Movie-Style)
# Räume, Ausgänge und Trivia-Fragen kommen aus der SQLite-Datenbank adventure.db
# ─────────────────────────────────────────────────────────────────────────────
def adventure_seite(inhalt, akzent="#f59e0b"):
kopf = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einbruch im Octan-Tower Klick-Adventure</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background:#0f172a; color:#e5e7eb;
font-family: system-ui, "Segoe UI", Arial, sans-serif;
min-height:100vh; line-height:1.6; }
.adv-topbar { display:flex; align-items:center; gap:10px; padding:12px 20px;
background:#111827; border-bottom:2px solid ACCENT; }
.adv-logo { font-weight:800; letter-spacing:-.5px; color:ACCENT;
text-decoration:none; font-size:18px; }
.adv-topbar .link { color:#93c5fd; text-decoration:none; font-size:14px;
border:1px solid #334155; border-radius:8px; padding:6px 12px; }
.adv-topbar .link:hover { background:#1e293b; }
.adv-topbar .spacer { margin-left:auto; }
.adv-wrap { max-width:680px; margin:44px auto; padding:0 18px; }
.adv-card { background:#1e293b; border:1px solid #334155; border-radius:16px;
padding:30px; box-shadow:0 10px 30px rgba(0,0,0,.35); }
.adv-card h1 { font-size:26px; margin-bottom:6px; }
.adv-floor { font-size:12px; text-transform:uppercase; letter-spacing:2px;
color:ACCENT; font-weight:700; }
.adv-desc { color:#cbd5e1; margin:14px 0 22px; }
.adv-progress { font-size:13px; color:#94a3b8; margin-bottom:16px; }
.adv-frage { font-size:19px; font-weight:600; margin-bottom:18px; }
.opt-form { display:flex; flex-direction:column; gap:12px; }
.opt-btn { display:block; width:100%; text-align:left; background:#0f172a;
color:#e5e7eb; border:1px solid #334155; border-radius:10px;
padding:14px 16px; font-size:16px; cursor:pointer;
transition:.15s; font-family:inherit; }
.opt-btn:hover { border-color:ACCENT; background:#172033; transform:translateY(-1px); }
.exit-link { display:inline-block; background:ACCENT; color:#111827; font-weight:700;
text-decoration:none; border-radius:10px; padding:12px 18px;
margin:6px 8px 0 0; }
.exit-link:hover { filter:brightness(1.08); }
.ghost-link { display:inline-block; color:#93c5fd; text-decoration:none;
border:1px solid #334155; border-radius:10px; padding:12px 18px;
margin:6px 8px 0 0; }
.ghost-link:hover { background:#0f172a; }
.feedback-bad { background:#7f1d1d; border:1px solid #b91c1c; color:#fecaca;
border-radius:10px; padding:12px 14px; margin-bottom:16px; font-weight:600; }
.win-emoji { font-size:54px; text-align:center; margin-bottom:8px; }
.muted { color:#94a3b8; font-size:14px; margin-top:20px; }
.muted a { color:#93c5fd; }
</style>
</head>
<body>
<div class="adv-topbar">
<a href="/app/" class="adv-logo">🧱 Octan-Tower</a>
<a href="/app/pinnwand" class="link spacer">💬 Kommentare</a>
<a href="https://isa2.edumake.de" class="link">← Website</a>
</div>
<div class="adv-wrap">""".replace("ACCENT", akzent)
fuss = """</div>
</body>
</html>"""
return kopf + inhalt + fuss
@app.route("/")
def start():
# Fortschritt für einen frischen Durchlauf zurücksetzen
for key in [k for k in session.keys() if k.startswith("raum_")]:
session.pop(key, None)
inhalt = """
<div class="adv-card">
<div class="adv-floor">LEGO • Geheimmission</div>
<h1>Einbruch im Octan-Tower 🧱</h1>
<p class="adv-desc">Präsident Business hortet ganz oben in seinem Wolkenkratzer
das <strong>Kragle</strong> und will damit die ganze Welt für immer festkleben.
Nur du kannst ihn stoppen! Kämpf dich Etage für Etage nach oben: vorbei an
<strong>Haien</strong> 🦈, <strong>Lasern</strong> 🔴 und natürlich
<strong>Laserhaien</strong> 🦈⚡. Auf jeder Etage prüfen dich die Sicherheits­systeme
mit Trivia-Fragen. Nur wer alle knackt, kommt weiter.</p>
<a href="/app/raum/1" class="exit-link">Abenteuer starten ▶</a>
<p class="muted">Lieber quatschen statt einbrechen?
<a href="/app/pinnwand">Zu den Kommentaren →</a></p>
</div>"""
return adventure_seite(inhalt)
@app.route("/frage")
def frage():
return redirect("/app/")
@app.route("/raum/<int:id>", methods=["GET", "POST"])
def raum(id):
db = get_db()
r = db.execute("SELECT * FROM raeume WHERE id=?", (id,)).fetchone()
if r is None:
db.close()
return adventure_seite(
'<div class="adv-card"><div class="adv-floor">Fehler</div>'
'<h1>Diese Etage existiert nicht 🤖</h1>'
'<p class="adv-desc">Da hat sich wohl ein Bauplan verheddert.</p>'
'<a href="/app/" class="exit-link">Zurück zum Start</a></div>')
fragen = db.execute(
"SELECT * FROM fragen WHERE raum_id=? ORDER BY id", (id,)
).fetchall()
prog_key = "raum_%d_idx" % id
idx = session.get(prog_key, 0)
# ── Es sind noch Sicherheitsfragen offen ──
if fragen and idx < len(fragen):
aktuelle = fragen[idx]
feedback = ""
if request.method == "POST":
gewaehlt = request.form.get("antwort", "").strip()
if gewaehlt.lower() == aktuelle["antwort"].strip().lower():
session[prog_key] = idx + 1
session.modified = True
db.close()
return redirect("/app/raum/%d" % id)
feedback = ('<div class="feedback-bad">🚨 Alarm! Falsche Antwort '
'die Wächter wurden fast auf dich aufmerksam. Versuch es nochmal.</div>')
optionen = aktuelle["optionen"].split("|")
db.close()
buttons = ""
for opt in optionen:
buttons += ('<button class="opt-btn" name="antwort" value="%s">%s</button>'
% (esc(opt), esc(opt)))
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">🔐 Sicherheitsprüfung · %s</div>
<h1>%s</h1>
<p class="adv-progress">Frage %d von %d</p>
%s
<p class="adv-frage">%s</p>
<form method="post" class="opt-form">%s</form>
</div>""" % (esc(r["name"]), esc(r["name"]), idx + 1, len(fragen),
feedback, esc(aktuelle["frage"]), buttons))
return adventure_seite(inhalt)
# ── Alle Fragen gelöst (oder keine) → Beschreibung + Ausgänge / Sieg ──
ausgaenge = db.execute(
"SELECT richtung, nach_raum FROM ausgaenge WHERE von_raum=? ORDER BY rowid", (id,)
).fetchall()
db.close()
if r["ist_ziel"]:
inhalt = ("""
<div class="adv-card" style="text-align:center">
<div class="win-emoji">🎉🧱🎉</div>
<div class="adv-floor">Mission erfüllt</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
<p class="adv-frage" style="color:#f59e0b">„Everything is awesome!“ 🎵</p>
<a href="/app/" class="exit-link">Nochmal spielen 🔁</a>
<a href="/app/pinnwand" class="ghost-link">Sieg in den Kommentaren feiern 💬</a>
</div>""" % (esc(r["name"]), esc(r["beschreibung"])))
return adventure_seite(inhalt)
exits_html = ""
for a in ausgaenge:
exits_html += ('<a class="exit-link" href="/app/raum/%d">%s</a>'
% (a["nach_raum"], esc(a["richtung"])))
if not ausgaenge:
exits_html = '<a class="exit-link" href="/app/">Zurück zum Start</a>'
geschafft = ""
if fragen: # Etage hatte Fragen und alle wurden gelöst
geschafft = ('<p class="adv-progress">✅ Alle Sicherheitsfragen geknackt '
'der Weg ist frei!</p>')
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">🏢 %s</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
%s
<div>%s</div>
</div>""" % (esc(r["name"]), esc(r["name"]), esc(r["beschreibung"]),
geschafft, exits_html))
return adventure_seite(inhalt)
# ─────────────────────────────────────────────────────────────────────────────
# Route 3: Pinnwand YouTube-Kommentar-Style mit Antwort-Funktion
# ─────────────────────────────────────────────────────────────────────────────
PINNWAND_CSS = """
<style>
.pw-header { font-size:14px; color:#333; margin-bottom:18px;
border-bottom:1px solid #e0e0e0; padding-bottom:10px; }
.pw-header strong { font-size:16px; }
/* ── Eingabe-Bereich ── */
.pw-input-row { display:flex; gap:12px; align-items:flex-start; margin-bottom:22px; }
.pw-avatar { width:40px; height:40px; border-radius:50%; color:#fff;
display:flex; align-items:center; justify-content:center;
font-size:18px; font-weight:bold; flex-shrink:0; }
.pw-input-wrap { flex:1; }
.pw-input-wrap input[type=text] {
width:100%; border:none; border-bottom:1px solid #bbb;
background:transparent; padding:6px 2px; font-size:14px; color:#333;
outline:none; transition:border-color .2s; margin-bottom:8px; }
.pw-input-wrap input[type=text]:focus { border-bottom-color:#1155cc; }
.pw-input-wrap input[type=text]::placeholder { color:#aaa; }
.pw-btn-row { display:flex; justify-content:flex-end; }
.pw-btn { padding:6px 14px; border:none; border-radius:2px; font-size:13px;
cursor:pointer; font-weight:bold; background:#167ac6; color:#fff; }
.pw-btn:hover { background:#1266a8; }
/* ── Kommentar-Liste ── */
.pw-count { font-size:14px; color:#555; margin-bottom:12px; }
.pw-comment { display:flex; gap:12px; padding:12px 0; border-bottom:1px solid #f0f0f0; }
.pw-comment:last-child { border-bottom:none; }
.pw-comment-body { flex:1; min-width:0; }
.pw-meta { display:flex; align-items:baseline; gap:8px; margin-bottom:4px; flex-wrap:wrap; }
.pw-username { font-size:13px; font-weight:bold; color:#167ac6; }
.pw-time { font-size:12px; color:#999; }
.pw-text { font-size:14px; color:#333; line-height:1.5; word-break:break-word; }
.pw-actions { display:flex; gap:10px; margin-top:6px; align-items:center; }
.pw-action-btn { font-size:12px; color:#888; background:none; border:none;
cursor:pointer; padding:0; }
.pw-action-btn:hover { color:#333; }
.pw-thumb { font-size:13px; }
/* ── Antwort-Formular (toggle per JS) ── */
.pw-reply-form { display:none; margin-top:10px; padding:10px 0 4px 0;
border-top:1px solid #f0f0f0; }
.pw-reply-form input[type=text] {
width:100%; border:none; border-bottom:1px solid #ccc; background:transparent;
padding:5px 2px; font-size:13px; color:#333; outline:none; margin-bottom:6px;
transition:border-color .2s; }
.pw-reply-form input[type=text]:focus { border-bottom-color:#1155cc; }
.pw-reply-form input[type=text]::placeholder { color:#bbb; }
.pw-reply-btn-row { display:flex; justify-content:flex-end; gap:8px; }
.pw-reply-cancel { font-size:12px; color:#888; background:none; border:none;
cursor:pointer; padding:4px 10px; font-weight:bold; }
.pw-reply-cancel:hover { color:#333; }
.pw-reply-submit { padding:5px 12px; border:none; border-radius:2px; font-size:12px;
cursor:pointer; font-weight:bold; background:#167ac6; color:#fff; }
.pw-reply-submit:hover { background:#1266a8; }
/* ── Antworten unter Kommentar ── */
.pw-replies { margin-left:52px; margin-top:6px; }
.pw-reply-item { display:flex; gap:10px; padding:8px 0; border-top:1px solid #f5f5f5; }
.pw-reply-avatar { width:28px; height:28px; border-radius:50%; color:#fff;
display:flex; align-items:center; justify-content:center;
font-size:12px; font-weight:bold; flex-shrink:0; }
.pw-reply-body { flex:1; min-width:0; }
.pw-show-replies-btn { font-size:12px; color:#167ac6; background:none; border:none;
cursor:pointer; padding:4px 0; margin-top:4px; font-weight:bold; }
.pw-show-replies-btn:hover { text-decoration:underline; }
.pw-leer { color:#aaa; font-size:14px; text-align:center; padding:30px 0; }
</style>
<script>
function toggleReplyForm(idx) {
var f = document.getElementById('rf-' + idx);
if (!f) return;
if (f.style.display === 'none' || f.style.display === '') {
f.style.display = 'block';
f.querySelector('input[name="nachricht"]').focus();
} else {
f.style.display = 'none';
}
}
function toggleReplies(idx) {
var box = document.getElementById('replies-' + idx);
var btn = document.getElementById('sb-' + idx);
if (!box || !btn) return;
if (box.style.display === 'none' || box.style.display === '') {
box.style.display = 'block';
btn.textContent = btn.textContent.replace('','').replace('anzeigen','ausblenden');
} else {
box.style.display = 'none';
btn.textContent = btn.textContent.replace('','').replace('ausblenden','anzeigen');
}
}
</script>
"""
def esc(s):
return (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
def avatar_farbe(name):
farben = ["#e53935", "#8e24aa", "#1e88e5", "#00897b",
"#43a047", "#fb8c00", "#6d4c41", "#546e7a"]
idx = sum(ord(c) for c in name) % len(farben)
return farben[idx]
def render_antworten(antworten):
html = ""
for a in antworten:
farbe = avatar_farbe(a["name"])
initial = a["name"][0].upper()
html += f"""
<div class="pw-reply-item">
<div class="pw-reply-avatar" style="background:{farbe}">{initial}</div>
<div class="pw-reply-body">
<div class="pw-meta">
<span class="pw-username">{esc(a['name'])}</span>
<span class="pw-time">{esc(a['zeit'])}</span>
</div>
<div class="pw-text">{esc(a['text'])}</div>
</div>
</div>"""
return html
@app.route("/pinnwand", methods=["GET", "POST"])
def pinnwand():
if request.method == "POST":
name = request.form.get("name", "").strip() or "Anonym"
text = request.form.get("nachricht", "").strip()
parent_raw = request.form.get("parent_id", "").strip()
if text:
if parent_raw.isdigit():
idx = int(parent_raw)
if 0 <= idx < len(eintraege):
eintraege[idx]["antworten"].append(
{"name": name, "text": text, "zeit": jetzt()}
)
else:
eintraege.append(
{"name": name, "text": text, "zeit": jetzt(), "antworten": []}
)
return redirect("/app/pinnwand")
# ── Kommentare rendern (neueste zuerst, echter Index für parent_id) ──
komm_html = ""
for real_idx, e in reversed(list(enumerate(eintraege))):
farbe = avatar_farbe(e["name"])
initial = e["name"][0].upper()
n_ant = len(e["antworten"])
# Block: Antworten anzeigen/verbergen
antworten_block = ""
if n_ant > 0:
label = f"{n_ant} Antwort{'en' if n_ant != 1 else ''}"
antworten_block = f"""
<button class="pw-show-replies-btn" id="sb-{real_idx}"
onclick="toggleReplies({real_idx})">
{label} anzeigen
</button>
<div class="pw-replies" id="replies-{real_idx}" style="display:none">
{render_antworten(e['antworten'])}
</div>"""
komm_html += f"""
<div class="pw-comment" id="c-{real_idx}">
<div class="pw-avatar" style="background:{farbe}">{initial}</div>
<div class="pw-comment-body">
<div class="pw-meta">
<span class="pw-username">{esc(e['name'])}</span>
<span class="pw-time">{esc(e['zeit'])}</span>
</div>
<div class="pw-text">{esc(e['text'])}</div>
<div class="pw-actions">
<button class="pw-action-btn pw-thumb">&#128077;</button>
<button class="pw-action-btn pw-thumb">&#128078;</button>
<button class="pw-action-btn"
onclick="toggleReplyForm({real_idx})">Antworten</button>
</div>
<!-- Antwort-Formular (initial versteckt) -->
<div class="pw-reply-form" id="rf-{real_idx}">
<form method="post">
<input type="hidden" name="parent_id" value="{real_idx}">
<input type="text" name="name"
placeholder="Dein Name (optional)">
<input type="text" name="nachricht"
placeholder="Antwort hinzufügen ..." required>
<div class="pw-reply-btn-row">
<button type="button" class="pw-reply-cancel"
onclick="toggleReplyForm({real_idx})">Abbrechen</button>
<button type="submit" class="pw-reply-submit">Antworten</button>
</div>
</form>
</div>
{antworten_block}
</div>
</div>"""
if not eintraege:
komm_html = '<p class="pw-leer">Noch keine Kommentare. Sei der Erste! 💬</p>'
n = len(eintraege)
anzahl_text = f"{n} Kommentar{'e' if n != 1 else ''}"
return seite(PINNWAND_CSS + f"""
<div class="pw-header"><strong>Kommentare</strong></div>
<form method="post">
<div class="pw-input-row">
<div class="pw-avatar" style="background:#888; font-size:22px">&#128100;</div>
<div class="pw-input-wrap">
<input type="text" name="name"
placeholder="Dein Name (optional)">
<input type="text" name="nachricht"
placeholder="Öffentlichen Kommentar hinzufügen ..." autofocus required>
<div class="pw-btn-row">
<button type="submit" class="pw-btn">Kommentieren</button>
</div>
</div>
</div>
</form>
<p class="pw-count">{anzahl_text}</p>
<div id="kommentare">{komm_html}</div>
""")
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
app.run(host="0.0.0.0", port=9002) # isa2 → Port 9002