eis-website/app.py

613 lines
26 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 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; }
.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> 🦈⚡.</p>
<p class="adv-desc">Jede Etage ist ein Gang, der sich gabelt. <strong>Lies die
Hinweise genau!</strong> Der <strong>richtige Weg</strong> stellt dir ein paar
thematische Fragen und führt direkt eine Etage höher. Der <strong>falsche Weg</strong>
endet nach einem Haufen wirrer Trivia in einer <strong>Sackgasse</strong> dann
heißt es: umkehren! 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/")
@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"]
# ═══ Räume mit Fragen: thema (richtig) · sackgasse (falsch) · ziel (Finale) ═══
if typ in ("thema", "sackgasse", "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)
if typ == "sackgasse":
feedback = ('<div class="feedback-bad">❌ Falsch und der '
'Prüf-Automat fragt einfach stur weiter. Nochmal!</div>')
else:
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 == "sackgasse":
label = "🚧 Falscher Weg · %s" % esc(r["name"])
elif 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)
# SACKGASSE → "Haha, Sackgasse!" + zurück zum Gang
if typ == "sackgasse":
zurueck = ausgaenge[0] if ausgaenge else None
back_btn = (
'<a class="exit-link" href="/app/raum/%d">%s</a>'
% (zurueck["nach_raum"], esc(zurueck["richtung"]))
if zurueck else
'<a class="exit-link" href="/app/">Zurück zum Start</a>')
inhalt = ("""
<div class="adv-card">
<div class="adv-floor">🚧 Sackgasse</div>
<h1>Haha Sackgasse!</h1>
<p class="adv-desc">Du hast dich durch einen ganzen Haufen Trivia gequält …
und stehst jetzt vor einer kahlen Wand. <strong>Das war der falsche Weg!</strong>
Geh zurück in den Gang und nimm die andere Richtung.</p>
%s
</div>""" % back_btn)
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