eis-website/app.py

786 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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 time
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; }
.pfade { display:flex; flex-direction:column; gap:14px; margin-top:8px; }
.pfad { background:#0f172a; border:1px solid #334155; border-radius:12px;
padding:14px 16px; transition:.15s; }
.pfad:hover { border-color:ACCENT; transform:translateY(-1px); }
.pfad .exit-link { margin:0 0 6px 0; }
.pfad-hinweis { display:block; color:#94a3b8; font-size:14px; }
.deal { display:flex; flex-wrap:wrap; gap:10px; margin:14px 0 22px; }
.deal span { background:#0f172a; border:1px solid #334155; border-radius:8px;
padding:8px 12px; font-size:14px; }
.timer-wrap { margin:2px 0 18px; }
.timer { font-size:42px; font-weight:800; color:#f87171; line-height:1;
font-variant-numeric:tabular-nums; }
.timer small { font-size:16px; color:#94a3b8; font-weight:600; }
.timer-bar { height:8px; background:#0f172a; border:1px solid #334155;
border-radius:6px; overflow:hidden; margin-top:10px; }
.timer-bar > div { height:100%; background:#f87171; width:100%;
transition:width .12s linear; }
.exit-link.danger { background:#f87171; }
.exit-link.danger:hover { filter:brightness(1.08); }
.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 list(session.keys())
if k.startswith("raum_") or k.startswith("chal_")]:
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> 🦈⚡.</p>
<p class="adv-desc">Jede Etage ist ein Gang, der sich gabelt. <strong>Lies die
Hinweise genau!</strong> Der <strong>sichere Prüf-Weg</strong> stellt dir ein paar
thematische Fragen und führt gemütlich eine Etage höher. Der andere Weg ist eine
<strong>riskante Abkürzung</strong>: Dort wartet jemand mit einem Deal schaffst
du seine Fragen <strong>gegen die Uhr</strong>, springst du weit nach oben. Ein
Fehler oder zu langsam? <strong>Mission vorbei.</strong> Ganz oben wartet die
letzte Frage zu Präsident Business.</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/")
# ─── Adventure-Helfer: Sieg / Game Over / Challenge ──────────────────────────
def _clear_challenge(rid):
"""Timer- und Fortschritts-Keys einer Challenge aus der Session entfernen."""
session.pop("chal_%d_deadline" % rid, None)
session.pop("chal_%d_idx" % rid, None)
session.modified = True
def sieg_screen(text=None):
body = text or ("Du hast Präsident Business gestoppt und das Kragle gesichert. "
"Die Welt bleibt frei und kreativ!")
inhalt = ("""
<div class="adv-card" style="text-align:center">
<div class="win-emoji">🎉🧱🎉</div>
<div class="adv-floor">Mission erfüllt</div>
<h1>Befreit!</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(body))
return adventure_seite(inhalt)
def gameover_screen(grund):
inhalt = ("""
<div class="adv-card" style="text-align:center">
<div class="win-emoji">💥🤖💥</div>
<div class="adv-floor" style="color:#f87171">Mission gescheitert</div>
<h1>Erwischt!</h1>
<p class="adv-desc">%s</p>
<a href="/app/" class="exit-link">Nochmal von vorn ▶</a>
<a href="/app/pinnwand" class="ghost-link">Frust in den Kommentaren lassen 💬</a>
</div>""" % esc(grund))
return adventure_seite(inhalt, akzent="#f87171")
def challenge_raum(db, r):
"""Risk/Reward-Abkürzung mit Zeitlimit (Biologe, Elektriker, Cyborgs)."""
rid = r["id"]
limit = int(r["zeit_limit"] or 40)
ziel = int(r["ziel_anzahl"] or 10)
erfolg_raum = r["erfolg_raum"] or 0
erfolg_text = r["erfolg_text"] or ""
dl_key = "chal_%d_deadline" % rid
idx_key = "chal_%d_idx" % rid
fragen = db.execute(
"SELECT * FROM fragen WHERE raum_id=? ORDER BY id", (rid,)
).fetchall()
back = db.execute(
"SELECT nach_raum FROM ausgaenge WHERE von_raum=? ORDER BY rowid LIMIT 1",
(rid,)
).fetchone()
db.close()
back_raum = back["nach_raum"] if back else 1
gesamt = min(ziel, len(fragen)) if fragen else ziel
now = time.time()
# ── Noch nicht gestartet → Intro (die Entscheidung: Risk vs. Reward) ──
if dl_key not in session:
if request.args.get("start") == "1" and fragen:
session[dl_key] = now + limit
session[idx_key] = 0
session.modified = True
return redirect("/app/raum/%d" % rid)
reward = ("🏆 Bei Erfolg: sofort gewonnen!" if not erfolg_raum
else "🏆 Bei Erfolg: Express ganz nach oben")
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">⚠️ Risiko-Abkürzung · Etage %d</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
<div class="deal">
<span>🎯 %d Fragen richtig</span>
<span>⏱️ %d Sekunden</span>
<span>%s</span>
<span>💀 Ein Fehler = Aus</span>
</div>
<a href="/app/raum/%d?start=1" class="exit-link danger">Herausforderung annehmen ⏱️</a>
<a href="/app/raum/%d" class="ghost-link">Lieber den sicheren Prüf-Weg</a>
</div>""" % (r["etage"] or 0, esc(r["name"]), esc(r["beschreibung"]),
gesamt, limit, reward, rid, back_raum))
return adventure_seite(inhalt, akzent="#f87171")
# ── Challenge läuft ──
deadline = session.get(dl_key, 0)
idx = session.get(idx_key, 0)
# Zeit abgelaufen? (Server ist die Wahrheit; JS meldet Timeout per ?timeout=1)
if now > deadline or request.args.get("timeout") == "1":
_clear_challenge(rid)
return gameover_screen(
"⏱️ Zeit abgelaufen! Die Wachen kommen näher du musst fliehen. "
"Der riskante Weg hat dich diesmal erwischt.")
# Antwort verarbeiten
if request.method == "POST":
gewaehlt = request.form.get("antwort", "").strip()
if idx < len(fragen) and gewaehlt.lower() == fragen[idx]["antwort"].strip().lower():
idx += 1
session[idx_key] = idx
session.modified = True
if idx >= gesamt:
_clear_challenge(rid)
if erfolg_raum and erfolg_raum > 0:
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">✅ Abkürzung geschafft!</div>
<h1>Geschafft in letzter Sekunde!</h1>
<p class="adv-desc">%s</p>
<a href="/app/raum/%d" class="exit-link">Weiter ▶</a>
</div>""" % (esc(erfolg_text), erfolg_raum))
return adventure_seite(inhalt)
return sieg_screen(erfolg_text)
return redirect("/app/raum/%d" % rid)
# falsche Antwort → sofortiges Game Over
_clear_challenge(rid)
return gameover_screen(
"❌ Falsch! Dein Gegenüber durchschaut dich sofort und schlägt Alarm. "
"So knapp aber der riskante Weg verzeiht keinen einzigen Fehler.")
# GET → aktuelle Frage mit Countdown zeigen
if idx >= len(fragen):
_clear_challenge(rid)
return sieg_screen(erfolg_text)
aktuelle = fragen[idx]
remaining = deadline - now
buttons = ""
for opt in aktuelle["optionen"].split("|"):
buttons += ('<button class="opt-btn" name="antwort" value="%s">%s</button>'
% (esc(opt), esc(opt)))
tpl = """
<div class="adv-card">
<div class="adv-floor">⏱️ Risiko-Challenge · __NAME__</div>
<h1>__NAME__</h1>
<div class="timer-wrap">
<div class="timer"><span id="tval">__REMAIN__</span><small> s</small></div>
<div class="timer-bar"><div id="tbar"></div></div>
</div>
<p class="adv-progress">Frage __NR__ von __GES__ · schon richtig: __IDX__</p>
<p class="adv-frage">__FRAGE__</p>
<form method="post" class="opt-form">__BTN__</form>
</div>
<script>
(function(){
var end = Date.now() + __REMAINMS__;
var full = __LIMIT__;
var v = document.getElementById('tval');
var bar = document.getElementById('tbar');
function tick(){
var rem = (end - Date.now())/1000;
if(rem < 0){ rem = 0; }
v.textContent = rem.toFixed(1);
bar.style.width = Math.max(0, Math.min(100, (rem/full)*100)) + '%';
if(rem <= 0){ window.location = '/app/raum/__ID__?timeout=1'; return; }
requestAnimationFrame(tick);
}
tick();
})();
</script>"""
html = (tpl
.replace("__NAME__", esc(r["name"]))
.replace("__REMAINMS__", str(int(max(0.0, remaining) * 1000)))
.replace("__REMAIN__", "%.1f" % max(0.0, remaining))
.replace("__LIMIT__", str(limit))
.replace("__NR__", str(idx + 1))
.replace("__GES__", str(gesamt))
.replace("__IDX__", str(idx))
.replace("__FRAGE__", esc(aktuelle["frage"]))
.replace("__BTN__", buttons)
.replace("__ID__", str(rid)))
return adventure_seite(html, akzent="#f87171")
@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>')
typ = r["typ"]
# ═══ Risk/Reward-Abkürzung mit Timer (Biologe / Elektriker / Cyborgs) ═══
if typ == "challenge":
return challenge_raum(db, r)
# ═══ Räume mit Fragen: thema (Prüf-Weg) · ziel (Finale) ═══
if typ in ("thema", "ziel"):
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)
# ── Noch offene Fragen → nächste Frage zeigen ──
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 werden fast auf dich aufmerksam. 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)))
if typ == "ziel":
label = "🧱 FINALE · %s" % esc(r["name"])
else:
label = "🔐 Sicherheitsprüfung · %s" % esc(r["name"])
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">%s</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
<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>""" % (label, esc(r["name"]), esc(r["beschreibung"]),
idx + 1, len(fragen), feedback,
esc(aktuelle["frage"]), buttons))
return adventure_seite(inhalt)
# ── Alle Fragen dieses Raums beantwortet ──
ausgaenge = db.execute(
"SELECT richtung, nach_raum, hinweis FROM ausgaenge "
"WHERE von_raum=? ORDER BY rowid", (id,)
).fetchall()
db.close()
# ZIEL erreicht → Sieg
if typ == "ziel":
inhalt = ("""
<div class="adv-card" style="text-align:center">
<div class="win-emoji">🎉🧱🎉</div>
<div class="adv-floor">Mission erfüllt</div>
<h1>Befreit!</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["beschreibung"]))
return adventure_seite(inhalt)
# THEMA gelöst → Erfolg + Ausgang zur nächsten Etage
weiter = ausgaenge[0] if ausgaenge else None
weiter_btn = (
'<a class="exit-link" href="/app/raum/%d">%s</a>'
% (weiter["nach_raum"], esc(weiter["richtung"]))
if weiter else
'<a class="exit-link" href="/app/">Zurück zum Start</a>')
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">✅ Richtiger Weg</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
<p class="adv-progress">✅ Alle Fragen geknackt der Weg nach oben ist frei!</p>
%s
</div>""" % (esc(r["name"]), esc(r["beschreibung"]), weiter_btn))
return adventure_seite(inhalt)
# ═══ Gang / Start: Beschreibung + Wege (Navigations-Entscheidung) ═══
ausgaenge = db.execute(
"SELECT richtung, nach_raum, hinweis FROM ausgaenge "
"WHERE von_raum=? ORDER BY rowid", (id,)
).fetchall()
db.close()
wege_html = ""
for a in ausgaenge:
wege_html += ("""
<div class="pfad">
<a class="exit-link" href="/app/raum/%d">%s</a>
<span class="pfad-hinweis">%s</span>
</div>""" % (a["nach_raum"], esc(a["richtung"]), esc(a["hinweis"] or "")))
if not ausgaenge:
wege_html = '<a class="exit-link" href="/app/">Zurück zum Start</a>'
if r["etage"] and r["etage"] > 0:
etage_label = "🏢 Etage %d" % r["etage"]
else:
etage_label = "🚪 Eingang"
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">%s</div>
<h1>%s</h1>
<p class="adv-desc">%s</p>
<div class="pfade">%s</div>
</div>""" % (etage_label, esc(r["name"]), esc(r["beschreibung"]), wege_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