Files
BSN-Chatsystem/app.py
T
Hermes Agent ee2b7512e6 Capture-Buttons: rund, SVG-Icons, Datei-Vorschau
- Runde Buttons (68×68px, border-radius:50%) statt rechteckig
- Monochrome SVG-Icons in BSN-Blau (#20228a) statt bunter Emojis
- Alle drei gleich groß, zentriert in der Reihe
- Datei-Vorschau-Balken unter den Buttons:
  '🖼️ foto.jpg (2.3 MB) — wird mitgesendet'
  erscheint sofort nach Auswahl/Aufnahme, verschwindet beim Absenden
- onchange-Listener auf allen 4 Inputs
2026-06-17 15:48:33 +02:00

2610 lines
131 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.
#!/usr/bin/env python3
"""
BSN Chatbot — Multi-Channel Intake System
==========================================
WhatsApp webhook receiver + admin dashboard + knowledge query API.
Architecture:
WhatsApp → Meta Cloud API → POST /whatsapp/webhook → SQLite
Admin: GET /admin → filterable table of all submissions
Query: POST /query → RAG-style answer from knowledge table
Usage:
source .env && python3 app.py
"""
import json
import os
import re
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
import requests # type: ignore
from flask import Flask, request, jsonify, g # type: ignore
# ── Config ──────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).parent
# Load .env file if it exists (for tokens that can't be passed via env)
_ENV_FILE = BASE_DIR / ".env"
if _ENV_FILE.exists():
with open(_ENV_FILE) as _f:
for _line in _f:
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_key, _, _val = _line.partition("=")
_key = _key.strip()
_val = _val.strip().strip('"').strip("'")
if _key not in os.environ or not os.environ[_key]:
os.environ[_key] = _val
DB_PATH = os.environ.get("DATABASE_PATH", str(BASE_DIR / "bsn_intake.db"))
VERIFY_TOKEN = os.environ.get("VERIFY_TOKEN", "bsn_verify_2026")
META_TOKEN = os.environ.get("META_TOKEN", "")
PHONE_NUMBER_ID = os.environ.get("META_PHONE_NUMBER_ID", "1152708791258718")
PORT = int(os.environ.get("PORT", "5002"))
MEDIA_DIR = BASE_DIR / "media"
MEDIA_DIR.mkdir(exist_ok=True)
app = Flask(__name__)
# ── Hall extraction ──────────────────────────────────────────────────
def extract_halle(text: str) -> str | None:
"""Extract SPIEL Essen hall+stand from text. Returns e.g. 'H3 B123' or None."""
import re
if not text:
return None
# Pattern: H[1-7] optionally followed by stand letter+number
# Matches: "H3", "Halle 3", "H 3", "H3 B123", "Halle 6 Stand A42"
patterns = [
# Halle with stand: H3 B123, Halle 6 A42, H 4 C7
r'(?:H|Halle)\s*([1-7])\s*[,\-]?\s*(?:Stand\s*)?([A-Z]\d{1,4})',
# Just hall: H3, Halle 6, H 7
r'(?:H|Halle)\s*([1-7])\b',
# Stand alone: B123 (only if preceded by "Stand" or after hall context)
r'Stand\s*([A-Z]\d{1,4})',
]
for pat in patterns:
m = re.search(pat, text, re.IGNORECASE)
if m:
groups = m.groups()
if len(groups) == 2 and groups[1]:
return f"H{groups[0]} {groups[1]}"
elif groups[0]:
return f"H{groups[0]}"
return None
# ── Game title extraction from cover images ──────────────────────────
def extract_game_title_from_image(image_path: str) -> str | None:
"""Use Gemini Flash vision to read the board game title from a cover image."""
import base64
import openai
if not os.path.exists(image_path):
return None
try:
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
except Exception:
return None
# Read API config
config_path = os.path.expanduser("~/.hermes/config.yaml")
try:
with open(config_path) as f:
import yaml
cfg = yaml.safe_load(f)
api_key = cfg["providers"]["datenhimmel"]["api_key"]
base_url = cfg["providers"]["datenhimmel"]["base_url"]
except Exception:
return None
client = openai.OpenAI(base_url=base_url, api_key=api_key)
try:
r = client.chat.completions.create(
model="gemini-3-flash-preview:cloud",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": (
"This is a board game cover. What is the EXACT game title?\n"
"Reply with ONLY the title, nothing else. "
"If it's a known game, use the official German title if it exists.\n"
"Examples: 'Cascadia', 'Flügelschlag', 'Die Siedler von Catan', 'Azul'"
)},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}},
]
}],
max_tokens=50,
temperature=0.1,
)
title = r.choices[0].message.content.strip()
# Clean up: remove quotes, extra text, limit length
title = title.strip('"').strip("'").strip()
if len(title) > 120:
title = title[:120]
return title if title else None
except Exception as e:
print(f"[vision error] {e}", file=sys.stderr)
return None
# ── Database helpers ────────────────────────────────────────────────
def get_db() -> sqlite3.Connection:
if "db" not in g:
g.db = sqlite3.connect(DB_PATH)
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA journal_mode=WAL")
return g.db
@app.teardown_appcontext
def close_db(_exception=None):
db = g.pop("db", None)
if db:
db.close()
# ── WhatsApp API helpers ────────────────────────────────────────────
def _gdpr_delete_sender(db: sqlite3.Connection, sender_id: str):
"""GDPR: Delete all submissions from a sender, leave a placeholder."""
# Delete media files (supports ||-separated paths)
rows = db.execute(
"SELECT media_path FROM submissions WHERE sender_id = ? AND channel = 'whatsapp'",
(sender_id,),
).fetchall()
for r in rows:
if r["media_path"]:
for p in r["media_path"].split("||"):
if os.path.exists(p):
os.remove(p)
# Delete all submissions from this sender
db.execute(
"DELETE FROM submissions WHERE sender_id = ? AND channel = 'whatsapp'",
(sender_id,),
)
# Insert GDPR placeholder
db.execute(
"""INSERT INTO submissions (channel, sender_id, type, content, status, category)
VALUES ('whatsapp', ?, 'text', ?, 'gdpr_deleted', 'dsgvo')""",
(sender_id, "Nutzer hat die Löschung verlangt"),
)
def send_whatsapp_message(to: str, text: str) -> dict:
"""Send a text message back via WhatsApp Cloud API."""
url = f"https://graph.facebook.com/v25.0/{PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {META_TOKEN}",
"Content-Type": "application/json",
}
payload = {
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": text},
}
r = requests.post(url, headers=headers, json=payload, timeout=15)
return r.json()
def generate_thorsten_tts(text: str) -> str | None:
"""Generate German TTS using Piper Thorsten voice. Returns path to OGG file or None."""
import subprocess
import tempfile
PIPER = "/home/hermes/.local/bin/piper"
MODEL = "/home/hermes/.hermes/cache/piper-voices/de_DE-thorsten-medium.onnx"
FFMPEG = "/home/hermes/.local/bin/ffmpeg"
LD_PATH = "/home/hermes/.local/lib"
ESPEAK_PATH = "/home/hermes/.local/share/espeak-ng-data"
ogg_path = str(MEDIA_DIR / f"onboarding_{datetime.now().strftime('%Y%m%d%H%M%S')}.ogg")
try:
p1 = subprocess.Popen(
[PIPER, "--model", MODEL, "--output-raw", "-"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
env={"LD_LIBRARY_PATH": LD_PATH, "ESPEAK_DATA_PATH": ESPEAK_PATH, "PATH": os.environ.get("PATH", "")},
)
p2 = subprocess.Popen(
[FFMPEG, "-f", "s16le", "-ar", "22050", "-ac", "1", "-i", "-",
"-c:a", "libopus", "-b:a", "16k", "-f", "ogg", ogg_path, "-y"],
stdin=p1.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
p1.stdin.write(text.encode("utf-8"))
p1.stdin.close()
p1.stdout.close()
p2.communicate(timeout=30)
p1.wait(timeout=10)
if os.path.exists(ogg_path) and os.path.getsize(ogg_path) > 100:
return ogg_path
except Exception as e:
print(f"[tts error] {e}", file=sys.stderr)
return None
def upload_whatsapp_media(file_path: str, mime_type: str = "audio/ogg") -> str | None:
"""Upload media to WhatsApp Cloud API. Returns media_id or None."""
url = f"https://graph.facebook.com/v25.0/{PHONE_NUMBER_ID}/media"
headers = {"Authorization": f"Bearer {META_TOKEN}"}
try:
with open(file_path, "rb") as f:
files = {
"file": (os.path.basename(file_path), f, mime_type),
}
data = {"messaging_product": "whatsapp"}
r = requests.post(url, headers=headers, files=files, data=data, timeout=20)
result = r.json()
return result.get("id")
except Exception as e:
print(f"[upload error] {e}", file=sys.stderr)
return None
def send_whatsapp_audio(to: str, media_id: str) -> dict:
"""Send an audio voice message via WhatsApp Cloud API."""
url = f"https://graph.facebook.com/v25.0/{PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {META_TOKEN}",
"Content-Type": "application/json",
}
payload = {
"messaging_product": "whatsapp",
"to": to,
"type": "audio",
"audio": {"id": media_id},
}
r = requests.post(url, headers=headers, json=payload, timeout=15)
return r.json()
def download_media(media_id: str) -> tuple[str | None, str | None]:
"""Download media from WhatsApp. Returns (local_path, mime_type) or (None, None)."""
url = f"https://graph.facebook.com/v25.0/{media_id}"
headers = {"Authorization": f"Bearer {META_TOKEN}"}
r = requests.get(url, headers=headers, timeout=10)
if r.status_code != 200:
print(f"[download_media] Meta API returned {r.status_code}: {r.text[:200]}", file=sys.stderr)
return None, None
data = r.json()
media_url = data.get("url")
mime_type = data.get("mime_type", "unknown")
if not media_url:
print(f"[download_media] No URL in Meta response for media {media_id}", file=sys.stderr)
return None, None
r2 = requests.get(media_url, headers=headers, timeout=30)
if r2.status_code != 200:
print(f"[download_media] Media download returned {r2.status_code} for {media_id}", file=sys.stderr)
return None, None
ext = mime_type.split("/")[-1] if "/" in mime_type else "bin"
filename = f"{media_id}.{ext}"
local_path = MEDIA_DIR / filename
local_path.write_bytes(r2.content)
return str(local_path), mime_type
def auto_process_bg(db_path: str, submission_id: int):
"""Background: after transcription, run LLM to summarize + extract halle/stand."""
import yaml as _yaml
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT content, halle FROM submissions WHERE id=?", (submission_id,)).fetchone()
if not row or not row["content"]:
conn.close()
return
text = (row["content"] or "").strip()
# Skip if text is too short or placeholder
if len(text) < 20:
conn.close()
return
# Read API key
config_path = os.path.expanduser("~/.hermes/config.yaml")
api_key = ""
try:
with open(config_path) as f:
cfg = _yaml.safe_load(f)
api_key = cfg.get("providers", {}).get("datenhimmel", {}).get("api_key", "")
except Exception:
pass
if not api_key:
conn.close()
return
prompt = (
"Du bist Redaktionsassistent für brettspiel-news.de. Analysiere folgende Community-Nachricht "
"und antworte NUR mit diesem JSON-Format, kein weiterer Text:\n"
'{"summary": "Kurze Zusammenfassung in 1-2 Sätzen, max 250 Zeichen, journalistisch, Präsens", '
'"halle": "Halle und Stand im Format z.B. H3 B123, oder null wenn nichts erkannt", '
'"spiel_titel": "Name des Brettspiels falls genannt, sonst null"}\n\n'
f"Nachricht:\n{text[:2000]}"
)
import requests as _req
r = _req.post(
"https://ui.datenhimmel.work/api/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": "deepseek-v4-flash:cloud", "messages": [{"role": "user", "content": prompt}], "max_tokens": 300, "temperature": 0.2},
timeout=20,
)
if r.status_code != 200:
conn.close()
return
import json as _json
data = r.json()
result_text = data["choices"][0]["message"]["content"].strip()
# Parse JSON from response (may have markdown wrapping)
if "```" in result_text:
result_text = result_text.split("```")[1]
if result_text.startswith("json"):
result_text = result_text[4:]
result_text = result_text.strip()
parsed = _json.loads(result_text)
summary = parsed.get("summary", "").strip()
llm_halle = parsed.get("halle", "").strip() if parsed.get("halle") and parsed["halle"] != "null" else None
spiel_titel = parsed.get("spiel_titel", "").strip() if parsed.get("spiel_titel") and parsed["spiel_titel"] != "null" else None
# Build update fields
existing = conn.execute("SELECT halle, spiel_titel, summary FROM submissions WHERE id=?", (submission_id,)).fetchone()
updates = []
params = []
if summary:
updates.append("summary=?")
params.append(summary[:500])
if llm_halle and not existing["halle"]:
updates.append("halle=?")
params.append(llm_halle)
if spiel_titel and not existing["spiel_titel"]:
updates.append("spiel_titel=?")
params.append(spiel_titel)
if updates:
params.append(submission_id)
conn.execute(f"UPDATE submissions SET {', '.join(updates)} WHERE id=?", params)
conn.commit()
conn.close()
except Exception as e:
print(f"[auto_process error] {e}", file=sys.stderr)
def transcribe_audio(audio_path: str) -> str | None:
"""Transcribe an audio file using faster-whisper (local). Returns transcription text or None."""
import subprocess
import json
WHISPER_PYTHON = "/home/hermes/.hermes/venvs/speech/bin/python"
script = (
"from faster_whisper import WhisperModel\n"
"import sys, json\n"
"model = WhisperModel('base', device='cpu', compute_type='int8')\n"
"segments, info = model.transcribe(sys.argv[1])\n"
"text = ' '.join(seg.text.strip() for seg in segments)\n"
"print(json.dumps({'text': text, 'lang': info.language}))\n"
)
try:
result = subprocess.run(
[WHISPER_PYTHON, "-c", script, audio_path],
capture_output=True, text=True, timeout=120,
)
if result.returncode == 0 and result.stdout.strip():
data = json.loads(result.stdout.strip())
return data.get("text", "")
else:
print(f"[transcribe error] {result.stderr[:200]}", file=sys.stderr)
return None
except Exception as e:
print(f"[transcribe exception] {e}", file=sys.stderr)
return None
def _transcribe_bg(db_path: str, submission_id: int, audio_path: str):
"""Background thread: transcribe audio and update the DB record."""
text = transcribe_audio(audio_path)
if text:
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT content FROM submissions WHERE id=?", (submission_id,)
).fetchone()
if row:
old = row["content"] or ""
new_content = old.replace(
"[🎤 Sprachnachricht — Transkription läuft...]",
f"🎤 {text}",
)
if new_content == old: # fallback if placeholder not found (e.g. old records)
new_content = old + f"\n🎤 Transkription: {text}"
conn.execute(
"UPDATE submissions SET content=? WHERE id=?",
(new_content, submission_id),
)
conn.commit()
conn.close()
except Exception as e:
print(f"[transcribe bg error] {e}", file=sys.stderr)
# Auto-summarize + hall extraction after transcription
auto_process_bg(db_path, submission_id)
def transcribe_video(video_path: str) -> str | None:
"""Extract audio from video, then transcribe. Returns text or None."""
import subprocess
import tempfile
import json
WHISPER_PYTHON = "/home/hermes/.hermes/venvs/speech/bin/python"
FFMPEG = "/home/hermes/.local/bin/ffmpeg"
LD_PATH = "/home/hermes/.local/lib"
wav_path = None
try:
# Extract audio to temp WAV file
fd, wav_path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
result = subprocess.run(
[FFMPEG, "-i", video_path, "-vn", "-acodec", "pcm_s16le",
"-ar", "16000", "-ac", "1", wav_path, "-y"],
capture_output=True, text=True, timeout=60,
env={"LD_LIBRARY_PATH": LD_PATH, "PATH": os.environ.get("PATH", "")},
)
if not os.path.exists(wav_path) or os.path.getsize(wav_path) < 100:
return None
# Now transcribe the extracted audio
return transcribe_audio(wav_path)
except Exception as e:
print(f"[video transcribe error] {e}", file=sys.stderr)
return None
finally:
if wav_path and os.path.exists(wav_path):
os.unlink(wav_path)
def generate_video_thumbnail(video_path: str) -> str | None:
"""Extract a thumbnail frame from a video at 1 second. Returns path to .jpg or None."""
import subprocess
FFMPEG = "/home/hermes/.local/bin/ffmpeg"
thumb_path = video_path + ".thumb.jpg"
# Don't regenerate if exists and is not empty
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 100:
return thumb_path
try:
subprocess.run(
[FFMPEG, "-i", video_path, "-ss", "00:00:01", "-vframes", "1",
"-q:v", "5", thumb_path, "-y"],
capture_output=True, timeout=30, check=True
)
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 100:
return thumb_path
except Exception:
pass
return None
def _transcribe_video_bg(db_path: str, submission_id: int, video_path: str):
"""Background thread: extract audio from video, transcribe, update DB."""
text = transcribe_video(video_path)
if text:
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"SELECT content FROM submissions WHERE id=?", (submission_id,)
).fetchone()
if row:
old = row["content"] or ""
new_content = old.replace(
"[🎬 Video — Transkription läuft...]",
f"🎬 {text}",
)
if new_content == old:
new_content = old + f"\n🎬 Transkription: {text}"
conn.execute(
"UPDATE submissions SET content=? WHERE id=?",
(new_content, submission_id),
)
conn.commit()
conn.close()
except Exception as e:
print(f"[video transcribe bg error] {e}", file=sys.stderr)
# Auto-summarize + hall extraction after transcription
auto_process_bg(db_path, submission_id)
# ── Routes ──────────────────────────────────────────────────────────
@app.route("/whatsapp/webhook", methods=["GET", "POST"])
def whatsapp_webhook():
"""Meta WhatsApp Webhook. GET=verification, POST=incoming messages."""
if request.method == "GET":
mode = request.args.get("hub.mode")
token = request.args.get("hub.verify_token")
challenge = request.args.get("hub.challenge")
if mode == "subscribe" and token == VERIFY_TOKEN:
return challenge, 200
return "Forbidden", 403
# POST: Incoming message
body = request.get_json(force=True, silent=True)
if not body:
return "OK", 200
try:
entries = body.get("entry", [])
db = get_db()
for entry in entries:
changes = entry.get("changes", [])
for change in changes:
value = change.get("value", {})
contacts = value.get("contacts", [{}])
messages = value.get("messages", [])
for msg in messages:
sender_id = msg.get("from", "unknown")
sender_name = contacts[0].get("profile", {}).get("name", "unknown")
msg_type = msg.get("type", "text")
content = ""
media_path = None
spiel_titel = None
category_override = None # set to "ausverkauft" for #out
if msg_type == "text":
content = msg.get("text", {}).get("body", "")
elif msg_type == "image":
content = msg.get("image", {}).get("caption", "[Bild ohne Text]")
media_id = msg.get("image", {}).get("id")
if media_id:
media_path, _ = download_media(media_id)
elif msg_type == "audio":
content = "[🎤 Sprachnachricht — Transkription läuft...]"
media_id = msg.get("audio", {}).get("id")
if media_id:
media_path, _ = download_media(media_id)
elif msg_type == "video":
content = msg.get("video", {}).get(
"caption", "[🎬 Video — Transkription läuft...]"
)
media_id = msg.get("video", {}).get("id")
if media_id:
media_path, _ = download_media(media_id)
elif msg_type == "document":
content = msg.get("document", {}).get(
"caption", "[Dokument]"
)
media_id = msg.get("document", {}).get("id")
if media_id:
media_path, _ = download_media(media_id)
else:
content = f"[{msg_type}]"
# GDPR: "Meine Daten löschen" trigger
if msg_type == "text" and content.strip().lower() == "meine daten löschen":
_gdpr_delete_sender(db, sender_id)
if META_TOKEN:
try:
send_whatsapp_message(
sender_id,
"✅ Alle deine Daten wurden vollständig und endgültig vom Server gelöscht. "
"Es befinden sich keinerlei personenbezogene Daten mehr von dir bei uns. "
"Lediglich ein anonymer Vermerk über die erfolgte Löschung bleibt für "
"unsere Dokumentationspflicht.",
)
except Exception:
pass
continue
# Check for existing thread from this sender (5-min window)
recent = db.execute(
"""SELECT id, content, media_path, halle FROM submissions
WHERE channel='whatsapp' AND sender_id=? AND status != 'gdpr_deleted'
ORDER BY created_at DESC LIMIT 1""",
(sender_id,),
).fetchone()
is_new_user = db.execute(
"SELECT COUNT(*) FROM submissions WHERE channel='whatsapp' AND sender_id=? AND status != 'gdpr_deleted'",
(sender_id,),
).fetchone()[0] == 0
THREAD_WINDOW_MINUTES = 5
thread_found = False
if recent:
age_check = db.execute(
"SELECT (strftime('%s','now') - strftime('%s',created_at)) < ? FROM submissions WHERE id=?",
(THREAD_WINDOW_MINUTES * 60, recent["id"]),
).fetchone()
if age_check and age_check[0]:
thread_found = True
if thread_found:
# Append to existing thread
prefix = {
"text": "", "image": "\n[🖼️ Bild] ",
"audio": "\n[🎤 Sprachnachricht]",
"video": "\n[🎬 Video] ", "document": "\n[📎 Dokument] ",
}.get(msg_type, f"\n[{msg_type}] ")
new_content = (recent["content"] or "") + prefix + content
# Re-extract halle from combined content
new_halle = extract_halle(new_content) or recent["halle"]
db.execute(
"UPDATE submissions SET content=?, halle=?, created_at=CURRENT_TIMESTAMP WHERE id=?",
(new_content, new_halle, recent["id"]),
)
# Merge media paths (keep all, separated by ||)
if media_path:
merged_media = media_path
if recent["media_path"]:
merged_media = recent["media_path"] + "||" + media_path
db.execute(
"UPDATE submissions SET media_path=? WHERE id=?",
(merged_media, recent["id"]),
)
else:
# New thread
halle = extract_halle(content)
cat = category_override or None
db.execute(
"""INSERT INTO submissions
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel)
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)""",
("whatsapp", sender_id, sender_name, msg_type, content, media_path, halle, cat, spiel_titel),
)
# Auto-reply: only on NEW threads, not appends
if not thread_found and META_TOKEN:
try:
if is_new_user:
reply = (
f"🎲 Hey {sender_name}! Cool, dass du da bist — willkommen in der "
f"brettspiel-news.de Community!\n\n"
f"Schick uns einfach, was dich bewegt: 💬 Text, 🎤 Sprachnachrichten, "
f"🖼️ Fotos oder 🎬 Videos — alles rund um Brettspiele. Wir schauen uns alles an!\n\n"
f"✍️ Nenn uns deinen Vornamen, wenn du magst — das ist deine Einwilligung, "
f"dass wir dich als Quelle nennen, falls wir deinen Beitrag verwenden. "
f"Ohne Namen schreiben wir „anonyme Quelle\".\n\n"
f"🔒 „Meine Daten löschen\" genügt — und alles ist sofort und vollständig weg."
)
send_whatsapp_message(sender_id, reply)
# Thorsten voice: same text but without emojis
import re
tts_text = re.sub(r'[^\x00-\x7F\u00C0-\u00FF\u0100-\u017F\u0180-\u024F\u1E00-\u1EFF\u2018-\u201F\u2013\u2014\u2026\u00A0\u00AB\u00BB\u20AC]', '', reply)
tts_text = re.sub(r' +', ' ', tts_text).strip()
audio_path = generate_thorsten_tts(tts_text)
if audio_path:
media_id = upload_whatsapp_media(audio_path)
if media_id:
send_whatsapp_audio(sender_id, media_id)
else:
reply = (
f"📨 Danke {sender_name}! Ist angekommen — "
f"wir kümmern uns drum."
)
send_whatsapp_message(sender_id, reply)
except Exception:
pass
# Transcribe audio in background (non-blocking)
if msg_type == "audio" and media_path:
import threading
sub_id = recent["id"] if thread_found else db.execute(
"SELECT last_insert_rowid()"
).fetchone()[0]
threading.Thread(
target=_transcribe_bg,
args=(DB_PATH, sub_id, media_path),
daemon=True,
).start()
# Transcribe video in background
if msg_type == "video" and media_path:
import threading
sub_id = recent["id"] if thread_found else db.execute(
"SELECT last_insert_rowid()"
).fetchone()[0]
threading.Thread(
target=_transcribe_video_bg,
args=(DB_PATH, sub_id, media_path),
daemon=True,
).start()
# Auto-summarize text/image submissions (LLM hall + summary)
if msg_type in ("text", "image") and not thread_found and content and len(content.strip()) > 20:
import threading
sub_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
# Only if regex didn't already find a halle
if not halle:
threading.Thread(
target=auto_process_bg,
args=(DB_PATH, sub_id),
daemon=True,
).start()
db.commit()
except Exception as e:
print(f"[webhook error] {e}", file=sys.stderr)
return "OK", 200
return "OK", 200
@app.route("/admin")
def admin_dashboard():
"""Internal admin dashboard — filterable table of all submissions."""
db = get_db()
channel_filter = request.args.get("channel", "")
status_filter = request.args.get("status", "")
category_filter = request.args.get("category", "")
search = request.args.get("q", "")
halle_filter = request.args.get("halle", "")
query = "SELECT * FROM submissions WHERE deleted_at IS NULL"
params: list = []
if channel_filter:
query += " AND channel = ?"
params.append(channel_filter)
if status_filter:
query += " AND status = ?"
params.append(status_filter)
if category_filter:
query += " AND category = ?"
params.append(category_filter)
if halle_filter:
query += " AND halle = ?"
params.append(halle_filter)
if search:
query += " AND (content LIKE ? OR sender_id LIKE ?)"
params.extend([f"%{search}%", f"%{search}%"])
query += " ORDER BY created_at DESC LIMIT 200"
rows = db.execute(query, params).fetchall()
channels = [
r[0]
for r in db.execute("SELECT DISTINCT channel FROM submissions").fetchall()
]
statuses = [
r[0]
for r in db.execute("SELECT DISTINCT status FROM submissions").fetchall()
]
categories = [
r[0]
for r in db.execute(
"SELECT DISTINCT category FROM submissions WHERE category IS NOT NULL"
).fetchall()
]
# Channel totals
totals_html = "".join(
f'<div class="total-box"><strong>'
f'{db.execute("SELECT COUNT(*) FROM submissions WHERE channel=?", (c,)).fetchone()[0]}'
f"</strong> {c}</div>"
for c in channels
)
# Get distinct halle values
hallen = [r[0] for r in db.execute("SELECT DISTINCT halle FROM submissions WHERE halle IS NOT NULL ORDER BY halle").fetchall()]
# Filter dropdowns
filter_html = f"""
<form method="get" style="margin-bottom:1.5rem;display:flex;gap:0.5rem;flex-wrap:wrap;align-items:end;">
<label>Channel: <select name="channel" onchange="this.form.submit()">
<option value="">Alle</option>
{''.join(f'<option value="{c}" {"selected" if c==channel_filter else ""}>{c}</option>' for c in channels)}
</select></label>
<label>Status: <select name="status" onchange="this.form.submit()">
<option value="">Alle</option>
{''.join(f'<option value="{s}" {"selected" if s==status_filter else ""}>{s}</option>' for s in statuses)}
</select></label>
<label>Kategorie: <select name="category" onchange="this.form.submit()">
<option value="">Alle</option>
{''.join(f'<option value="{c}" {"selected" if c==category_filter else ""}>{c}</option>' for c in categories)}
</select></label>
<label>Halle: <select name="halle" onchange="this.form.submit()">
<option value="">Alle</option>
{"".join(f'<option value="{h}" {"selected" if h==halle_filter else ""}>{h}</option>' for h in hallen)}
</select></label>
<label>Suche: <input type="text" name="q" value="{search}" placeholder="Suchtext..." style="padding:0.3rem;"></label>
<button type="submit" style="padding:0.3rem 0.8rem;">Filtern</button>
<a href="/admin" style="padding:0.3rem 0.8rem;color:#4ecdc4;">Reset</a>
</form>
"""
# Status display names (German)
status_names = {
"new": "Neu", "processing": "In Bearbeitung", "done": "Erledigt",
"flagged": "Markiert", "gdpr_deleted": "DSGVO-Gelöscht", "published": "Veröffentlicht",
}
table_rows = ""
for r in rows:
content_snippet = (r["content"] or "")[:120]
media_icon = {"image": "🖼️", "audio": "🎤", "video": "🎬", "document": "📎"}.get(r["type"], "")
st_label = status_names.get(r["status"], r["status"])
table_rows += f"""
<tr class="row-{r['status']}" style="cursor:pointer;" onclick="window.location='/submission/{r['id']}'">
<td>{r['id']}</td>
<td>{r['channel']}</td>
<td style="max-width:120px;overflow:hidden;text-overflow:ellipsis;">{r['sender_id']}</td>
<td>{media_icon} {r['type']}</td>
<td style="color:#4ecdc4;font-weight:600;white-space:nowrap;">{r['halle'] or '-'}</td>
<td style="color:#ff6b6b;font-weight:600;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{r['spiel_titel'] or '-'}</td>
<td style="max-width:240px;word-break:break-word;">{content_snippet}</td>
<td><span class="badge badge-{r['status']}">{st_label}</span></td>
<td>{r['category'] or '-'}</td>
<td style="font-size:0.75rem;white-space:nowrap;">{r['created_at']}</td>
</tr>
"""
# Mobile card rows
mobile_cards = ""
for r in rows:
content_snippet = (r["content"] or "")[:80]
media_icon = {"image": "🖼️", "audio": "🎤", "video": "🎬", "document": "📎"}.get(r["type"], "")
st_label = status_names.get(r["status"], r["status"])
mobile_cards += f"""<a href="/submission/{r['id']}" class="mobile-card row-{r['status']}">
<div class="mc-top"><span class="mc-id">#{r['id']}</span><span class="mc-badge badge badge-{r['status']}">{st_label}</span></div>
<div class="mc-body"><b>{media_icon} {r['type']}</b> · {r['channel']}</div>
<div class="mc-meta">{'📍 ' + r['halle'] if r['halle'] else ''}{' 🎯 ' + r['spiel_titel'] if r['spiel_titel'] else ''}</div>
<div class="mc-snippet">{content_snippet}</div>
<div class="mc-time">{r['created_at']}</div>
</a>"""
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>BSN Intake — Admin</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
:root {{ --accent:#20228a; --bg:#000000; --surface:#1c1c1e; --surface2:#2c2c2e; --text:#ffffff; --text2:rgba(255,255,255,0.6); --text3:rgba(255,255,255,0.38); --border:rgba(255,255,255,0.08); --radius:16px; --radius-sm:10px; --shadow:0 4px 24px rgba(0,0,0,0.4); }}
body.light {{ --bg:#f2f2f7; --surface:#ffffff; --surface2:#f2f2f7; --text:#000000; --text2:rgba(0,0,0,0.55); --text3:rgba(0,0,0,0.3); --border:rgba(0,0,0,0.08); --shadow:0 2px 16px rgba(0,0,0,0.08); }}
body {{ font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','SF Pro Text','Helvetica Neue',sans-serif; background:var(--bg); color:var(--text); min-height:100vh; min-height:100dvh; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }}
/* Glass header */
.admin-header {{ position:sticky; top:0; z-index:100; background:rgba(28,28,30,0.72); backdrop-filter:saturate(180%) blur(20px); -webkit-backdrop-filter:saturate(180%) blur(20px); border-bottom:1px solid var(--border); padding:0.75rem 1.25rem; }}
body.light .admin-header {{ background:rgba(242,242,247,0.72); }}
.admin-header-inner {{ max-width:1200px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:0.5rem; }}
.admin-header h1 {{ font-size:1.25rem; font-weight:700; letter-spacing:-0.02em; color:var(--text); }}
.admin-header .meta {{ font-size:0.75rem; color:var(--text2); display:flex; gap:0.35rem; flex-wrap:wrap; align-items:center; }}
.count-badge {{ background:var(--surface2); color:var(--text2); padding:0.3rem 0.7rem; border-radius:999px; font-size:0.7rem; font-weight:600; white-space:nowrap; }}
.admin-btn {{ display:inline-block; padding:0.35rem 0.85rem; border-radius:999px; font-size:0.72rem; font-weight:600; text-decoration:none; transition:all 0.15s; white-space:nowrap; background:var(--surface2); color:var(--text2); border:1.5px solid transparent; }}
.admin-btn:hover {{ background:var(--surface); color:var(--text); border-color:var(--border); }}
.admin-btn.active {{ background:var(--accent); color:#fff; border-color:var(--accent); }}
.admin-btn.secondary {{ background:transparent; color:var(--text3); }}
.admin-btn.secondary:hover {{ color:var(--text); background:var(--surface2); }}
.admin-header a {{ color:var(--accent); text-decoration:none; font-weight:500; }}
.admin-header a:hover {{ opacity:0.8; }}
/* Main */
.admin-main {{ max-width:1200px; margin:0 auto; padding:1rem 1.25rem 3rem; }}
/* Totals */
.totals {{ display:flex; gap:0.5rem; margin-bottom:1rem; flex-wrap:wrap; }}
.total-box {{ background:var(--surface); border-radius:var(--radius-sm); padding:0.5rem 1rem; font-size:0.82rem; font-weight:500; border:1px solid var(--border); }}
.total-box strong {{ color:var(--accent); font-size:1rem; }}
/* Filter */
.filter-bar {{ display:flex; gap:0.4rem; margin-bottom:1rem; flex-wrap:wrap; align-items:end; }}
.filter-bar label {{ font-size:0.7rem; color:var(--text2); display:flex; flex-direction:column; gap:0.15rem; font-weight:500; }}
.filter-bar select, .filter-bar input {{ background:var(--surface); color:var(--text); border:1px solid var(--border); border-radius:999px; padding:0.45rem 0.9rem; font-size:0.8rem; font-family:inherit; -webkit-appearance:none; appearance:none; }}
.filter-bar select {{ padding-right:2rem; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'%3E%3C/path%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 0.6rem center; }}
.filter-bar button {{ background:var(--accent); color:#fff; border:none; border-radius:999px; padding:0.45rem 1.2rem; font-size:0.8rem; font-weight:600; cursor:pointer; font-family:inherit; }}
/* Desktop table */
.desktop-table {{ display:none; }}
@media(min-width:768px){{ .desktop-table {{ display:block; }} .mobile-cards {{ display:none; }} }}
.desktop-table table {{ width:100%; border-collapse:separate; border-spacing:0; font-size:0.82rem; }}
.desktop-table th {{ text-align:left; padding:0.6rem 0.5rem; color:var(--text2); font-size:0.68rem; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; border-bottom:1px solid var(--border); position:sticky; top:56px; background:var(--bg); }}
.desktop-table td {{ padding:0.55rem 0.5rem; border-bottom:1px solid var(--border); font-size:0.8rem; }}
.desktop-table tr {{ cursor:pointer; transition:background 0.12s; border-radius:var(--radius-sm); }}
.desktop-table tbody tr:hover {{ background:var(--surface); }}
.desktop-table tbody tr.row-new {{ border-left:3px solid var(--text3); }}
.desktop-table tbody tr.row-published {{ border-left:3px solid var(--accent); }}
.desktop-table tbody tr.row-flagged {{ border-left:3px solid #dc3545; }}
/* Mobile cards */
.mobile-cards {{ display:flex; flex-direction:column; gap:0.5rem; }}
.mobile-card {{ display:block; background:var(--surface); border-radius:var(--radius-sm); padding:0.8rem 1rem; text-decoration:none; color:var(--text); border-left:4px solid var(--text3); transition:transform 0.12s; }}
.mobile-card:active {{ transform:scale(0.985); }}
.mobile-card.row-published {{ border-left-color:var(--accent); }}
.mobile-card.row-flagged {{ border-left-color:#dc3545; }}
.mc-top {{ display:flex; justify-content:space-between; align-items:center; margin-bottom:0.3rem; }}
.mc-id {{ font-weight:700; font-size:0.85rem; color:var(--accent); }}
.mc-body {{ font-size:0.82rem; font-weight:500; }}
.mc-meta {{ font-size:0.72rem; color:var(--text2); margin-top:0.15rem; }}
.mc-snippet {{ font-size:0.75rem; color:var(--text3); margin-top:0.25rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }}
.mc-time {{ font-size:0.65rem; color:var(--text3); margin-top:0.2rem; }}
/* Badges */
.badge {{ display:inline-block; padding:0.15rem 0.5rem; border-radius:999px; font-size:0.65rem; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }}
.badge-new {{ background:rgba(255,255,255,0.08); color:var(--text2); }}
.badge-processing {{ background:rgba(255,193,7,0.18); color:#ffc107; }}
.badge-done {{ background:rgba(40,167,69,0.18); color:#30d158; }}
.badge-flagged {{ background:rgba(220,53,69,0.18); color:#ff453a; }}
.badge-gdpr_deleted {{ background:rgba(255,0,0,0.18); color:#ff453a; }}
.badge-published {{ background:rgba(32,34,138,0.2); color:#5e5ce6; }}
/* Theme toggle */
.theme-toggle {{ position:fixed; bottom:1.25rem; right:1.25rem; z-index:999; width:48px; height:48px; border-radius:50%; cursor:pointer; font-size:1.2rem; border:1px solid var(--border); background:var(--surface); color:var(--text); box-shadow:var(--shadow); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); display:flex; align-items:center; justify-content:center; }}
body.light .theme-toggle {{ background:rgba(255,255,255,0.85); }}
</style>
<script>setTimeout(function(){{location.reload()}},30000);
function toggleTheme(){{document.body.classList.toggle('light');localStorage.setItem('theme',document.body.classList.contains('light')?'light':'dark');}}
</script>
</head>
<body>
<header class="admin-header">
<div class="admin-header-inner">
<h1>📨 BSN Intake</h1>
<div class="meta">
<span class="count-badge">{len(rows)} Submissions</span>
<a href="/admin" class="admin-btn{' active' if not status_filter else ''}">📋 Alle</a>
<a href="/admin?status=new" class="admin-btn{' active' if status_filter=='new' else ''}">🆕 Neu</a>
<a href="/admin?status=published" class="admin-btn{' active' if status_filter=='published' else ''}">✅ Publiziert</a>
<a href="/admin?status=flagged" class="admin-btn{' active' if status_filter=='flagged' else ''}">🚩 Markiert</a>
<a href="/test" class="admin-btn secondary">🧪 Test</a>
<a href="/" class="admin-btn secondary">🌐 Frontend</a>
</div>
</div>
</header>
<main class="admin-main">
<div class="totals">{totals_html}</div>
<div class="filter-bar">{filter_html.replace('<form method="get"','<form method="get" class="filter-bar-inner"').replace('</form>','').replace('style="padding:0.3rem 0.8rem;"','').replace('style="padding:0.3rem;"','').replace('onchange="this.form.submit()"','onchange="this.form.submit()"')}<div style="display:inline"><a href="/admin" style="color:var(--accent);text-decoration:none;font-size:0.8rem;font-weight:500;padding:0.45rem 0.8rem;">Reset</a></div></form></div>
<div class="mobile-cards">{mobile_cards}</div>
<div class="desktop-table">
<table><thead><tr><th>ID</th><th>Sender</th><th>Typ</th><th>📍 Halle</th><th>🎯 Spiel</th><th>Inhalt</th><th>Status</th><th>Zeit</th></tr></thead><tbody>{table_rows}</tbody></table>
</div>
</main>
<button class="theme-toggle" onclick="toggleTheme()" title="Hell/Dunkel">🌓</button>
<script>(function(){{if(localStorage.getItem('theme')==='light')document.body.classList.add('light');}})();</script>
</body>
</html>"""
@app.route("/test")
def test_page():
"""Page with a curl snippet for simulated testing."""
return """<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>Simulierter Test</title>
<style>
body { font-family:-apple-system,sans-serif; background:#0f0f1a; color:#e0e0e0; padding:2rem; }
h1 { color:#4ecdc4; }
pre { background:#16213e; padding:1rem; border-radius:8px; overflow-x:auto; font-size:0.8rem; line-height:1.5; }
a { color:#4ecdc4; }
</style></head>
<body>
<h1>🧪 Simulierter WhatsApp Test</h1>
<p>Dieser curl-Befehl sendet eine Fake-WhatsApp-Nachricht an den lokalen Webhook:</p>
<pre>curl -X POST http://127.0.0.1:5002/whatsapp/webhook \\
-H "Content-Type: application/json" \\
-d '{
"object": "whatsapp_business_account",
"entry": [{
"id": "TEST",
"changes": [{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "TEST",
"phone_number_id": "TEST"
},
"contacts": [{
"profile": {"name": "Max Mustermann"}
}],
"messages": [{
"from": "+491234567890",
"id": "wamid.test123",
"timestamp": "1718550000",
"type": "text",
"text": {"body": "Hallo! Gibt es Catan noch in Halle 5?"}
}]
}
}]
}]
}'</pre>
<p><a href="/admin">← Zurück zum Dashboard</a></p>
</body>
</html>"""
@app.route("/health")
def health():
return jsonify(
{
"status": "ok",
"db": str(DB_PATH),
"whatsapp_configured": bool(META_TOKEN),
"phone_number_id": PHONE_NUMBER_ID,
}
)
@app.route("/submission/<int:sub_id>")
def submission_detail(sub_id: int):
"""Detail view for a single submission."""
db = get_db()
row = db.execute("SELECT * FROM submissions WHERE id = ?", (sub_id,)).fetchone()
if not row:
return "<h1>Nicht gefunden</h1><p><a href='/admin'>← Zurück</a></p>", 404
import base64
# Media preview with per-item publish checkboxes
media_html = ""
if row["media_path"]:
paths = row["media_path"].split("||")
# Parse existing publish flags
flags_str = row["media_publish_flags"] or ""
flags = flags_str.split(",") if flags_str else ["1"] * len(paths)
# Pad flags if needed
while len(flags) < len(paths):
flags.append("1")
mime_map = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif",
".mp4": "video/mp4", ".webm": "video/webm",
".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav",
}
for i, path in enumerate(paths):
checked = "checked" if flags[i] == "1" else ""
if not os.path.exists(path):
media_html += f'<p style="color:#888;">📁 Datei nicht gefunden: {os.path.basename(path)}</p>'
continue
_, ext = os.path.splitext(path)
mime = mime_map.get(ext.lower(), "application/octet-stream")
try:
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
src = f"data:{mime};base64,{b64}"
media_html += f'<div class="media-item" style="margin:0.5rem 0;background:var(--surface);border-radius:var(--radius-sm);overflow:hidden;">'
# Media element
if mime.startswith("image/"):
media_html += f'<img src="{src}" style="width:100%;max-height:400px;object-fit:contain;display:block;background:#000;">'
elif mime.startswith("video/"):
# Generate thumbnail for poster
thumb = generate_video_thumbnail(path)
poster_attr = ""
if thumb:
try:
with open(thumb, "rb") as tf:
thumb_b64 = base64.b64encode(tf.read()).decode()
poster_attr = f' poster="data:image/jpeg;base64,{thumb_b64}"'
except Exception:
pass
media_html += f'<video controls preload="metadata" {poster_attr} style="width:100%;max-height:400px;background:#000;"><source src="{src}" type="{mime}"></video>'
elif mime.startswith("audio/"):
media_html += f'<audio controls style="width:100%;padding:0.8rem;"><source src="{src}" type="{mime}"></audio>'
else:
media_html += f'<p style="padding:0.8rem;">📎 <a href="{src}" download>{os.path.basename(path)}</a></p>'
# Toggle bar
media_html += f'<div class="publish-bar" style="display:flex;align-items:center;gap:0.6rem;padding:0.5rem 0.8rem;border-top:1px solid var(--border);">'
media_html += f'<label class="toggle-switch">'
media_html += f'<input type="checkbox" name="media_publish_{i}" value="1" {checked}>'
media_html += f'<span class="toggle-slider"></span>'
media_html += f'</label>'
media_html += f'<span style="font-size:0.78rem;font-weight:500;color:var(--text2);">Veröffentlichen</span>'
media_html += f'</div>'
media_html += f'</div>'
except Exception as e:
media_html += f'<p style="color:#dc3545;">Fehler: {e}</p>'
# SVG type icons (16x16, BSN blue)
_svg_icons = {
"text": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><rect x="1.5" y="2" width="13" height="10" rx="2" stroke="#20228a" stroke-width="1.5"/><path d="M11 11l2 3M5 11l-2 3" stroke="#20228a" stroke-width="1.5" stroke-linecap="round"/></svg>',
"image": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><rect x="1.5" y="1.5" width="13" height="13" rx="2" stroke="#20228a" stroke-width="1.5"/><circle cx="5" cy="5.5" r="1.5" fill="#20228a"/><path d="M1.5 11.5l3.5-3 2.5 2 3-3.5 4 4.5" stroke="#20228a" stroke-width="1.5" stroke-linejoin="round"/></svg>',
"audio": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><rect x="1.5" y="4" width="3.5" height="8" rx="1" fill="#20228a"/><path d="M5 6.5l4-4v11l-4-4" stroke="#20228a" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.5 4.5c1.5.8 1.5 4.2 0 5" stroke="#20228a" stroke-width="1.5" stroke-linecap="round"/></svg>',
"video": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="#20228a" stroke-width="1.5"/><path d="M6 5l4 3-4 3V5z" fill="#20228a"/></svg>',
"document": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><path d="M3 1.5h6l4 4v9a1 1 0 01-1 1H3a1 1 0 01-1-1v-12a1 1 0 011-1z" stroke="#20228a" stroke-width="1.5"/><path d="M9 1.5V6h4" stroke="#20228a" stroke-width="1.5"/></svg>',
}
type_emoji = _svg_icons.get(row["type"], _svg_icons["document"])
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>#{row['id']} — BSN</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
:root {{ --accent:#20228a; --accent2:#5e5ce6; --bg:#000000; --surface:#1c1c1e; --surface2:#2c2c2e; --text:#ffffff; --text2:rgba(255,255,255,0.6); --text3:rgba(255,255,255,0.38); --border:rgba(255,255,255,0.08); --radius:16px; --radius-sm:10px; --radius-pill:999px; }}
body.light {{ --bg:#f2f2f7; --surface:#ffffff; --surface2:#f2f2f7; --text:#000000; --text2:rgba(0,0,0,0.55); --text3:rgba(0,0,0,0.3); --border:rgba(0,0,0,0.08); }}
body {{ font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','SF Pro Text','Helvetica Neue',sans-serif; background:var(--bg); color:var(--text); min-height:100vh; min-height:100dvh; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }}
/* Glass header */
.detail-header {{ position:sticky; top:0; z-index:100; background:rgba(28,28,30,0.72); backdrop-filter:saturate(180%) blur(20px); -webkit-backdrop-filter:saturate(180%) blur(20px); border-bottom:1px solid var(--border); padding:0.75rem 1.25rem; }}
body.light .detail-header {{ background:rgba(242,242,247,0.72); }}
.detail-header-inner {{ max-width:720px; margin:0 auto; display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap; }}
.detail-header h1 {{ font-size:1.1rem; font-weight:700; letter-spacing:-0.02em; }}
.detail-header .subtitle {{ font-size:0.7rem; color:var(--text2); }}
/* Main */
.detail-main {{ max-width:720px; margin:0 auto; padding:1rem 1.25rem 6rem; }}
/* Toolbar */
.toolbar {{ display:flex; gap:0.5rem; margin-bottom:1rem; flex-wrap:wrap; align-items:center; }}
.toolbar form {{ margin:0; display:flex; gap:0.5rem; flex-wrap:wrap; }}
/* Buttons — iOS pill style */
.btn {{ padding:0.55rem 1.2rem; border-radius:var(--radius-pill); text-decoration:none; font-weight:600; font-size:0.82rem; border:none; cursor:pointer; transition:all 0.15s ease; display:inline-flex; align-items:center; gap:0.3rem; font-family:inherit; -webkit-tap-highlight-color:transparent; touch-action:manipulation; min-height:44px; }}
.btn:active {{ transform:scale(0.96); }}
.btn-back {{ background:var(--surface); color:var(--text2); border:1px solid var(--border); }}
.btn-back:hover {{ background:var(--surface2); }}
.btn-done {{ background:rgba(48,209,88,0.18); color:#30d158; }}
.btn-done:hover {{ background:rgba(48,209,88,0.28); }}
.btn-delete {{ background:rgba(255,69,58,0.12); color:#ff453a; border:1px solid rgba(255,69,58,0.2); }}
.btn-delete:hover {{ background:rgba(255,69,58,0.2); }}
/* Toggle switch — publish bar */
.toggle-switch {{ position:relative; display:inline-block; width:40px; height:22px; flex-shrink:0; }}
.toggle-switch input {{ opacity:0; width:0; height:0; }}
.toggle-slider {{ position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background:var(--surface2); border-radius:22px; transition:all 0.25s ease; border:1.5px solid var(--border); }}
.toggle-slider:before {{ content:""; position:absolute; height:16px; width:16px; left:2px; bottom:2px; background:#fff; border-radius:50%; transition:all 0.25s ease; box-shadow:0 1px 3px rgba(0,0,0,0.2); }}
.toggle-switch input:checked + .toggle-slider {{ background:var(--accent); border-color:var(--accent); }}
.toggle-switch input:checked + .toggle-slider:before {{ transform:translateX(18px); }}
.btn-flag {{ background:rgba(255,69,58,0.1); color:#ff453a; }}
.btn-flag:hover {{ background:rgba(255,69,58,0.2); }}
.btn-publish {{ background:rgba(32,34,138,0.2); color:var(--accent2); border:1px solid var(--accent); }}
.btn-publish:hover {{ background:var(--accent); color:#fff; }}
.btn-publish-big {{ background:var(--accent) !important; color:#fff !important; padding:0.7rem 2rem !important; font-size:0.95rem !important; font-weight:700 !important; letter-spacing:-0.01em !important; border:none !important; box-shadow:0 4px 16px rgba(32,34,138,0.4); }}
.btn-publish-big:hover {{ background:var(--accent2) !important; box-shadow:0 6px 20px rgba(32,34,138,0.6); }}
.btn:disabled {{ opacity:0.35; cursor:not-allowed; }}
body.light .btn-publish-big {{ color:#fff !important; }}
/* Cards */
.card {{ background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:1.25rem; margin-bottom:0.75rem; }}
.card-header {{ font-size:0.78rem; font-weight:600; color:var(--accent2); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:1rem; }}
/* Fields */
.field {{ margin-bottom:1rem; }}
.field:last-child {{ margin-bottom:0; }}
.field-label {{ font-size:0.7rem; color:var(--text2); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.3rem; font-weight:600; }}
.field-value {{ font-size:0.9rem; line-height:1.5; word-break:break-word; }}
.field-value pre {{ white-space:pre-wrap; word-break:break-word; font-family:'SF Mono',ui-monospace,'JetBrains Mono',monospace; font-size:0.82rem; background:var(--surface2); padding:0.8rem; border-radius:var(--radius-sm); line-height:1.5; }}
/* Forms — iOS style */
input, textarea {{ width:100%; background:var(--surface2); color:var(--text); border:2px solid var(--border); border-radius:var(--radius-sm); padding:0.65rem 0.85rem; font-family:inherit; font-size:0.88rem; transition:border-color 0.15s; -webkit-appearance:none; appearance:none; }}
input:focus, textarea:focus {{ outline:none; border-color:var(--accent); }}
textarea {{ resize:vertical; min-height:80px; }}
/* Badges */
.badge {{ display:inline-block; padding:0.18rem 0.6rem; border-radius:var(--radius-pill); font-size:0.65rem; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; }}
.badge-new {{ background:rgba(255,255,255,0.08); color:var(--text2); }}
.badge-processing {{ background:rgba(255,193,7,0.18); color:#ffc107; }}
.badge-done {{ background:rgba(48,209,88,0.18); color:#30d158; }}
.badge-flagged {{ background:rgba(255,69,58,0.18); color:#ff453a; }}
.badge-gdpr_deleted {{ background:rgba(255,0,0,0.18); color:#ff453a; }}
.badge-published {{ background:rgba(32,34,138,0.2); color:var(--accent2); }}
/* Media items */
.media-item {{ position:relative; margin:0.5rem 0; }}
.media-item label {{ position:absolute; top:8px; right:8px; z-index:10; background:rgba(0,0,0,0.6); padding:0.3rem 0.6rem; border-radius:var(--radius-pill); display:flex; align-items:center; gap:0.3rem; cursor:pointer; font-size:0.72rem; color:var(--accent2); backdrop-filter:blur(8px); }}
.media-item img, .media-item video {{ max-width:100%; border-radius:var(--radius-sm); display:block; }}
.media-item audio {{ width:100%; margin:0.3rem 0; }}
/* Halle badge */
.halle-badge {{ display:inline-flex; align-items:center; gap:0.3rem; background:var(--accent); color:#fff; padding:0.35rem 0.8rem; border-radius:var(--radius-pill); font-size:0.82rem; font-weight:700; letter-spacing:-0.01em; }}
/* Danger zone card */
.danger-card {{ border-color:rgba(255,69,58,0.3) !important; }}
/* Mobile bottom bar */
.bottom-bar {{ display:none; position:fixed; bottom:0; left:0; right:0; z-index:99; padding:0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom,0px)); background:rgba(28,28,30,0.88); backdrop-filter:saturate(180%) blur(20px); -webkit-backdrop-filter:saturate(180%) blur(20px); border-top:1px solid var(--border); }}
body.light .bottom-bar {{ background:rgba(242,242,247,0.88); }}
@media(max-width:767px){{ .bottom-bar {{ display:flex; gap:0.5rem; justify-content:center; flex-wrap:wrap; }} }}
/* Theme toggle */
.theme-toggle {{ position:fixed; bottom:1.25rem; right:1.25rem; z-index:999; width:48px; height:48px; border-radius:50%; cursor:pointer; font-size:1.2rem; border:1px solid var(--border); background:var(--surface); color:var(--text); box-shadow:0 4px 24px rgba(0,0,0,0.4); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); display:flex; align-items:center; justify-content:center; }}
@media(max-width:767px){{ .theme-toggle {{ bottom:5rem; }} }}
body.light .theme-toggle {{ background:rgba(255,255,255,0.85); }}
img, video {{ border-radius:var(--radius-sm); }}
audio {{ width:100%; margin:0.3rem 0; }}
</style>
</head>
<body>
<header class="detail-header">
<div class="detail-header-inner">
<a href="/admin" class="btn btn-back" style="min-height:36px;padding:0.35rem 0.8rem;font-size:0.78rem;">← Zurück</a>
<div><h1>{type_emoji} #{row['id']}</h1><div class="subtitle">{row['channel']} · {row['created_at']}</div></div>
{f'<div style="margin-left:auto;" class="halle-badge">📍 {row["halle"]}</div>' if row['halle'] else ''}
<form method="post" action="/submission/{row['id']}/update-summary" style="margin-left:0.5rem;">
<button type="submit" class="btn btn-publish-big" name="action" value="publish" {'disabled' if row['status'] in ('published','gdpr_deleted') else ''} style="padding:0.4rem 1.2rem;font-size:0.8rem;white-space:nowrap;">📢 Veröffentlichen</button>
</form>
</div>
</header>
<main class="detail-main">
<div class="card">
<div class="field"><div class="field-label">Absender</div><div class="field-value">{row['sender_name'] or row['sender_id']}<span style="color:var(--text3);font-size:0.75rem;margin-left:0.5rem;">({row['sender_id']})</span></div></div>
<div class="field"><div class="field-label">Typ</div><div class="field-value">{type_emoji} {row['type']}</div></div>
<div class="field"><div class="field-label">Inhalt</div><div class="field-value"><pre>{row['content'] or '(leer)'}</pre></div></div>
<div class="field"><div class="field-label">Status</div><div class="field-value"><span class="badge badge-{row['status']}">{row['status']}</span></div></div>
<div class="field"><div class="field-label">Kategorie</div><div class="field-value">{row['category'] or '-'}</div></div>
<div class="field"><div class="field-label">📍 Halle / Stand</div><div class="field-value"><span style="color:var(--accent2);font-weight:600;">{row['halle'] or ' nicht erkannt '}</span></div></div>
<div class="field"><div class="field-label">🎯 Spieltitel</div><div class="field-value" style="display:flex;gap:0.4rem;align-items:center;">
<input type="text" id="spielTitelInline" value="{row['spiel_titel'] or ''}" placeholder="Spieltitel eingeben..." style="flex:1;max-width:300px;">
<button id="saveSpielTitelBtn" class="btn btn-publish" style="padding:0.35rem 0.8rem;font-size:0.72rem;white-space:nowrap;">💾 Speichern</button>
<span id="spielTitelStatus" style="font-size:0.7rem;color:#30d158;display:none;">✓</span>
</div></div>
</div>
<div class="card">
<div class="card-header">✍️ Für Frontend-Veröffentlichung</div>
<form method="post" action="/submission/{row['id']}/update-summary">
<div class="field">
<div class="field-label">📍 Halle / Stand</div>
<input type="text" name="halle" value="{row['halle'] or ''}" placeholder="z.B. H3 B123">
</div>
<div class="field">
<div class="field-label">🎯 Spieltitel</div>
<input type="text" name="spiel_titel" value="{row['spiel_titel'] or ''}" placeholder="Automatisch erkannt oder manuell">
</div>
<div class="field">
<div class="field-label">Zusammenfassung (LLM/Mensch)</div>
<textarea name="summary" rows="3">{row['summary'] or ''}</textarea>
</div>
<div class="field">
<div class="field-label">Name zur Veröffentlichung</div>
<input type="text" name="publish_name" value="{row['publish_name'] or (row['sender_name'] if row['sender_name'] and row['sender_name'] != 'unknown' else '')}" placeholder="anonyme Quelle (keine Einwilligung)">
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.5rem;">
<button type="submit" name="action" value="save" class="btn btn-flag">💾 Speichern</button>
<button type="button" id="llm-summary-btn" class="btn btn-publish">🤖 LLM-Zusammenfassung</button>
</div>
</form>
</div>
<div class="card">
<div class="card-header">🖼️ Medien verwalten</div>
<p style="color:var(--text3);font-size:0.75rem;margin-bottom:0.8rem;">Nur Medien mit Haken erscheinen im Frontend.</p>
<form method="post" action="/submission/{row['id']}/update-summary">
<input type="hidden" name="action" value="save_media">
{media_html}
<button type="submit" name="action" value="save_media" class="btn btn-flag" style="margin-top:0.8rem;">💾 Medien-Auswahl speichern</button>
</form>
</div>
<div class="card danger-card">
<div class="card-header" style="color:#ff453a;">🗑️ Gefahrenzone</div>
<p style="color:var(--text3);font-size:0.75rem;margin-bottom:0.8rem;">Löscht den Eintrag und alle Mediendateien unwiderruflich.</p>
<button type="button" id="delete-btn" class="btn btn-delete">🗑️ Beitrag löschen</button>
<span id="delete-status" style="margin-left:0.8rem;font-size:0.75rem;display:none;color:var(--text2);"></span>
</div>
</main>
<div class="bottom-bar">
<form method="post" action="/submission/{row['id']}/update-summary">
<button type="submit" class="btn btn-publish-big" name="action" value="publish" {'disabled' if row['status'] in ('published','gdpr_deleted') else ''}>📢 VERÖFFENTLICHEN</button>
<button type="submit" class="btn btn-done" name="action" value="done">✅ Erledigt</button>
</form>
</div>
<button class="theme-toggle" onclick="document.body.classList.toggle('light');localStorage.setItem('theme',document.body.classList.contains('light')?'light':'dark');" title="Hell/Dunkel">🌓</button>
<script>
(function(){{if(localStorage.getItem('theme')==='light')document.body.classList.add('light');}})();
var _delBtn = document.getElementById('delete-btn');
if (_delBtn) {{
_delBtn.addEventListener('click', function() {{
if (!confirm('Beitrag #' + {row['id']} + ' wirklich löschen? Datei und Daten werden unwiderruflich gelöscht.')) return;
this.disabled = true;
this.textContent = '⏳ Lösche...';
var _status = document.getElementById('delete-status');
_status.style.display = 'inline';
_status.textContent = '';
fetch('/submission/' + {row['id']} + '/delete', {{method:'POST'}})
.then(function(r) {{
if (!r.ok) throw new Error('Server-Fehler');
// Redirect directly — the server now returns JSON
document.body.innerHTML = '<div style=\"display:flex;align-items:center;justify-content:center;min-height:100vh;background:#0f0f1a;\"><div style=\"background:rgba(220,53,69,0.15);color:#dc3545;padding:1.5rem 2.5rem;border-radius:8px;font-size:1.15rem;font-weight:600;text-align:center;box-shadow:0 4px 32px rgba(0,0,0,0.6);animation:fadeInOut 1.8s ease forwards;\">🗑️ Beitrag #' + {row['id']} + ' gelöscht</div></div>';
var style = document.createElement('style');
style.textContent = '@keyframes fadeInOut{{0%{{opacity:0;transform:scale(0.9)}}15%{{opacity:1;transform:scale(1)}}75%{{opacity:1;transform:scale(1)}}100%{{opacity:0;transform:scale(0.95)}}}}';
document.head.appendChild(style);
setTimeout(function(){{ window.location='/admin'; }}, 1800);
}})
.catch(function(err) {{
_status.textContent = 'Fehler: ' + err.message;
_delBtn.disabled = false;
_delBtn.textContent = '🗑️ Beitrag löschen';
}});
}});
}}
var _llmBtn = document.getElementById('llm-summary-btn');
if (_llmBtn) {{
_llmBtn.addEventListener('click', async function(e) {{
e.preventDefault();
this.disabled = true;
this.textContent = '⏳ Generiere...';
try {{
const r = await fetch('/submission/{row["id"]}/generate-summary', {{method:'POST'}});
const data = await r.json();
if (data.summary) {{ document.querySelector('textarea[name="summary"]').value = data.summary; }}
else if (data.error) {{ alert('Fehler: ' + data.error); }}
}} catch(err) {{ alert('Netzwerkfehler: ' + err.message); }}
this.disabled = false;
this.textContent = '🤖 LLM-Zusammenfassung';
}});
}}
var _spielBtn = document.getElementById('saveSpielTitelBtn');
var _spielInput = document.getElementById('spielTitelInline');
var _spielStatus = document.getElementById('spielTitelStatus');
if (_spielBtn && _spielInput) {{
_spielBtn.addEventListener('click', async function() {{
this.disabled = true;
_spielStatus.style.display = 'none';
try {{
const formData = new FormData();
formData.append('action', 'save');
formData.append('spiel_titel', _spielInput.value.trim());
const r = await fetch('/submission/{row["id"]}/update-summary', {{method:'POST', body:formData}});
if (r.ok) {{
_spielStatus.textContent = '✓ Gespeichert';
_spielStatus.style.display = 'inline';
setTimeout(() => {{ _spielStatus.style.display = 'none'; }}, 2000);
}}
}} catch(err) {{ _spielStatus.textContent = 'Fehler'; _spielStatus.style.display = 'inline'; }}
this.disabled = false;
}});
_spielInput.addEventListener('keydown', function(e) {{
if (e.key === 'Enter') {{ e.preventDefault(); _spielBtn.click(); }}
}});
}}
</script>
</body>
</html>"""
@app.route("/submission/<int:sub_id>/update-summary", methods=["POST"])
def submission_update_summary(sub_id: int):
"""Update summary, publish_name, and change status."""
db = get_db()
action = request.form.get("action", "save")
summary = request.form.get("summary", "").strip()
publish_name = request.form.get("publish_name", "").strip()
halle = request.form.get("halle", "").strip()
spiel_titel = request.form.get("spiel_titel", "").strip()
if action == "publish":
db.execute(
"UPDATE submissions SET status='published', published_at=CURRENT_TIMESTAMP WHERE id=?",
(sub_id,),
)
elif action == "done":
db.execute("UPDATE submissions SET status='done' WHERE id=?", (sub_id,))
elif action == "save":
# Partial update — only update fields that are provided
fields = []
params = []
if summary:
fields.append("summary=?")
params.append(summary)
if publish_name:
fields.append("publish_name=?")
params.append(publish_name)
if halle:
fields.append("halle=?")
params.append(halle)
if spiel_titel:
fields.append("spiel_titel=?")
params.append(spiel_titel)
# Clear fields explicitly set to empty
if request.form.get("summary") is not None and not summary:
fields.append("summary=NULL")
if request.form.get("publish_name") is not None and not publish_name:
fields.append("publish_name=NULL")
if request.form.get("halle") is not None and not halle:
fields.append("halle=NULL")
if request.form.get("spiel_titel") is not None and not spiel_titel:
fields.append("spiel_titel=NULL")
if fields:
params.append(sub_id)
db.execute(f"UPDATE submissions SET {', '.join(fields)} WHERE id=?", params)
elif action == "save_media":
# Collect media publish flags from form
import re
flags_dict = {}
for key in request.form:
m = re.match(r'media_publish_(\d+)', key)
if m:
flags_dict[int(m.group(1))] = "1"
# Get current media paths count
row = db.execute("SELECT media_path FROM submissions WHERE id=?", (sub_id,)).fetchone()
if row and row["media_path"]:
n = len(row["media_path"].split("||"))
flags = [flags_dict.get(i, "0") for i in range(n)]
db.execute(
"UPDATE submissions SET media_publish_flags=? WHERE id=?",
(",".join(flags), sub_id),
)
db.commit()
return """<script>window.location='/submission/""" + str(sub_id) + """';</script>"""
@app.route("/submission/<int:sub_id>/done", methods=["POST"])
def submission_done(sub_id: int):
"""Mark a submission as done/bearbeitet."""
db = get_db()
db.execute("UPDATE submissions SET status='done' WHERE id=?", (sub_id,))
db.commit()
return """<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,sans-serif; background:#0f0f1a; color:#e0e0e0; display:flex; align-items:center; justify-content:center; min-height:100vh; }
.toast { background:#1a3a1a; color:#28a745; padding:1.5rem 2.5rem; border-radius:8px; font-size:1.15rem; font-weight:600; text-align:center; box-shadow:0 4px 32px rgba(0,0,0,0.6); animation: fadeInOut 1.8s ease forwards; }
@keyframes fadeInOut {
0%% { opacity:0; transform:scale(0.9); }
15%% { opacity:1; transform:scale(1); }
75%% { opacity:1; transform:scale(1); }
100%% { opacity:0; transform:scale(0.95); }
}
</style></head>
<body>
<div class="toast">✅ Beitrag als <strong>erledigt</strong> markiert</div>
<script>setTimeout(function(){ window.location='/admin'; }, 1800);</script>
</body>
</html>"""
@app.route("/submission/<int:sub_id>/generate-summary", methods=["POST"])
def generate_llm_summary(sub_id: int):
"""Call DeepSeek V4 Flash to summarize the submission content."""
db = get_db()
row = db.execute("SELECT * FROM submissions WHERE id = ?", (sub_id,)).fetchone()
if not row:
return jsonify({"error": "Nicht gefunden"}), 404
content = (row["content"] or "").strip()
if not content:
return jsonify({"summary": ""})
# Truncate very long content
if len(content) > 3000:
content = content[:3000] + "..."
import requests as req
import yaml
# Read API key from Hermes config
config_path = os.path.expanduser("~/.hermes/config.yaml")
api_key = ""
try:
with open(config_path) as f:
cfg = yaml.safe_load(f)
api_key = cfg.get("providers", {}).get("datenhimmel", {}).get("api_key", "")
except Exception:
pass
if not api_key:
return jsonify({"error": "API-Key nicht konfiguriert"}), 500
prompt = (
"Du bist Redaktionsassistent für brettspiel-news.de. "
"Fasse folgende Community-Nachricht in 2-3 Sätzen zusammen. "
"Schreibe im Präsens, journalistisch, ohne Floskeln. "
"Kein 'Der Nutzer schreibt...' oder 'In dieser Nachricht...'. "
"Maximal 250 Zeichen.\n\n"
f"Nachricht:\n{content}"
)
try:
r = req.post(
"https://ui.datenhimmel.work/api/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "deepseek-v4-flash:cloud",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 300,
"temperature": 0.3,
},
timeout=30,
)
if r.status_code == 200:
data = r.json()
summary = data["choices"][0]["message"]["content"].strip()
return jsonify({"summary": summary})
else:
return jsonify({"error": f"API-Fehler: {r.status_code}"}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/submission/<int:sub_id>/delete", methods=["POST"])
def submission_delete(sub_id: int):
"""Delete a submission and its media file."""
db = get_db()
row = db.execute("SELECT media_path FROM submissions WHERE id = ?", (sub_id,)).fetchone()
if row and row["media_path"]:
for p in row["media_path"].split("||"):
if os.path.exists(p):
os.remove(p)
db.execute("DELETE FROM submissions WHERE id = ?", (sub_id,))
db.commit()
return f"""<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family:-apple-system,sans-serif; background:#0f0f1a; color:#e0e0e0; display:flex; align-items:center; justify-content:center; min-height:100vh; }}
.toast {{ background:rgba(220,53,69,0.15); color:#dc3545; padding:1.5rem 2.5rem; border-radius:8px; font-size:1.15rem; font-weight:600; text-align:center; box-shadow:0 4px 32px rgba(0,0,0,0.6); animation: fadeInOut 1.8s ease forwards; }}
@keyframes fadeInOut {{
0% {{ opacity:0; transform:scale(0.9); }}
15% {{ opacity:1; transform:scale(1); }}
75% {{ opacity:1; transform:scale(1); }}
100% {{ opacity:0; transform:scale(0.95); }}
}}
</style></head>
<body>
<div class="toast">🗑️ Beitrag #{sub_id} gelöscht</div>
<script>setTimeout(function(){{ window.location='/admin'; }}, 1800);</script>
</body>
</html>"""
@app.route("/")
def public_frontend():
"""Public frontend showing published submissions — SPIEL Essen inspired."""
import base64
db = get_db()
# Filters
halle_filter = request.args.get("halle", "").strip()
cat_filter = request.args.get("cat", "").strip()
query = "SELECT * FROM submissions WHERE status='published' AND deleted_at IS NULL"
params = []
if halle_filter:
query += " AND halle LIKE ?"
params.append(f"{halle_filter}%")
if cat_filter:
query += " AND category = ?"
params.append(cat_filter)
query += " ORDER BY published_at DESC LIMIT 50"
rows = db.execute(query, params).fetchall()
# Get available filter values — group by hall number (H1-H7)
hallen = []
hall_rows = db.execute(
"SELECT DISTINCT halle FROM submissions WHERE halle IS NOT NULL AND status='published' ORDER BY halle"
).fetchall()
seen = set()
for r in hall_rows:
hnum = r[0][:2] if r[0] else ""
if hnum and hnum[0] == 'H' and hnum[1:].isdigit() and hnum not in seen:
seen.add(hnum)
hallen.append(hnum)
categories = [r[0] for r in db.execute(
"SELECT DISTINCT category FROM submissions WHERE category IS NOT NULL AND status='published' ORDER BY category"
).fetchall()]
empty_state = """<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BSN Community — Stimmen aus der Szene</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#FCE812; color:#1a1a2e; display:flex; flex-direction:column; align-items:center; justify-content:center; min-height:100vh; }
.empty { text-align:center; padding:3rem; }
.empty h1 { color:#20228a; font-size:2rem; font-weight:800; margin-bottom:0.8rem; }
.empty p { color:#666; font-size:1.1rem; max-width:400px; line-height:1.6; }
.empty .logo { font-size:3rem; margin-bottom:1.5rem; }
</style></head>
<body><div class="empty"><div class="logo">\U0001f3b2</div><h1>Bald geht's los!</h1><p>Noch keine ver\u00f6ffentlichten Beitr\u00e4ge. Komm bald wieder — die Community f\u00fcllt diesen Ort mit spannenden Geschichten.</p></div></body>
</html>"""
if not rows:
return empty_state
# Build filter bar HTML — hallen as dropdown
filter_html = '<div class="frontend-filter"><span class="filter-label">📍 Halle:</span> '
filter_html += '<form method="get" class="filter-form-inline" style="display:inline;">'
if cat_filter:
filter_html += f'<input type="hidden" name="cat" value="{cat_filter}">'
filter_html += '<select name="halle" onchange="this.form.submit()" class="hall-select">'
filter_html += f'<option value="" {"selected" if not halle_filter else ""}>Alle Hallen</option>'
for hnum in hallen:
selected = " selected" if halle_filter == hnum else ""
filter_html += f'<option value="{hnum}"{selected}>Halle {hnum[1:]}</option>'
filter_html += '</select></form> '
if categories:
filter_html += '<span class="filter-label" style="margin-left:0.8rem;">📂 Kategorie:</span> '
filter_html += '<form method="get" class="filter-form-inline" style="display:inline;">'
if halle_filter:
filter_html += f'<input type="hidden" name="halle" value="{halle_filter}">'
filter_html += '<select name="cat" onchange="this.form.submit()" class="hall-select">'
filter_html += f'<option value="" {"selected" if not cat_filter else ""}>Alle Kategorien</option>'
for c in categories:
selected = " selected" if cat_filter == c else ""
filter_html += f'<option value="{c}"{selected}>{c}</option>'
filter_html += '</select></form> '
if halle_filter or cat_filter:
filter_html += '<a href="/" class="filter-reset">✕ Filter zurücksetzen</a>'
filter_html += "</div>"
cards = ""
for r in rows:
media_html = ""
media_first = ""
if r["media_path"]:
paths = r["media_path"].split("||")
# Parse publish flags
flags_str = r["media_publish_flags"] or ""
flags = flags_str.split(",") if flags_str else ["1"] * len(paths)
while len(flags) < len(paths):
flags.append("1")
mime_map = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif",
".mp4": "video/mp4", ".webm": "video/webm",
".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav",
}
for i, path in enumerate(paths):
# Skip media not marked for publishing
if flags[i] != "1":
continue
if not os.path.exists(path):
continue
_, ext = os.path.splitext(path)
mime = mime_map.get(ext.lower(), "application/octet-stream")
try:
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
src = "data:" + mime + ";base64," + b64
if mime.startswith("image/"):
img_tag = (
'<a href="/beitrag/' + str(r["id"]) + '" class="media-preview-link">'
'<div class="media-crop" style="width:100%;height:160px;overflow:hidden;border-radius:8px;">'
'<img src="' + src + '" alt="Beitragsbild" loading="lazy" style="width:100%;height:100%;object-fit:cover;">'
'</div></a>'
)
if i == 0 and not media_first:
media_first = img_tag
else:
media_html += img_tag
elif mime.startswith("video/"):
thumb = generate_video_thumbnail(path)
if thumb:
with open(thumb, "rb") as f:
thumb_b64 = base64.b64encode(f.read()).decode()
thumb_src = "data:image/jpeg;base64," + thumb_b64
else:
thumb_src = ""
vid_tag = (
'<a href="/beitrag/' + str(r["id"]) + '" class="video-thumb-link">'
'<div class="video-thumb" style="position:relative;background:#000;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;overflow:hidden;">'
+ (f'<img src="{thumb_src}" alt="Video-Vorschau" style="width:100%;height:100%;object-fit:cover;opacity:0.7;">' if thumb_src else '<div style="color:#555;font-size:3rem;">🎬</div>')
+ '<div class="play-btn" style="position:absolute;width:60px;height:60px;background:rgba(255,255,255,0.9);border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(0,0,0,0.4);">'
+ '<svg width="24" height="28" viewBox="0 0 24 28"><path d="M0 0v28l24-14z" fill="#20228a"/></svg>'
+ '</div></div></a>'
)
if i == 0 and not media_first:
media_first = vid_tag
else:
media_html += vid_tag
elif mime.startswith("audio/"):
aud_tag = '<a href="/beitrag/' + str(r["id"]) + '" class="media-preview-link"><div class="audio-preview" style="background:#f0ebe0;padding:1rem;border-radius:8px;text-align:center;color:#20228a;font-weight:600;">🎤 Sprachnachricht anhören →</div></a>'
media_html += aud_tag
except Exception:
pass
_fe_svg_icons = {
"text": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><rect x="1.5" y="2" width="13" height="10" rx="2" stroke="#20228a" stroke-width="1.5"/><path d="M11 11l2 3M5 11l-2 3" stroke="#20228a" stroke-width="1.5" stroke-linecap="round"/></svg>',
"image": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><rect x="1.5" y="1.5" width="13" height="13" rx="2" stroke="#20228a" stroke-width="1.5"/><circle cx="5" cy="5.5" r="1.5" fill="#20228a"/><path d="M1.5 11.5l3.5-3 2.5 2 3-3.5 4 4.5" stroke="#20228a" stroke-width="1.5" stroke-linejoin="round"/></svg>',
"audio": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><rect x="1.5" y="4" width="3.5" height="8" rx="1" fill="#20228a"/><path d="M5 6.5l4-4v11l-4-4" stroke="#20228a" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.5 4.5c1.5.8 1.5 4.2 0 5" stroke="#20228a" stroke-width="1.5" stroke-linecap="round"/></svg>',
"video": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="#20228a" stroke-width="1.5"/><path d="M6 5l4 3-4 3V5z" fill="#20228a"/></svg>',
"document": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:3px;"><path d="M3 1.5h6l4 4v9a1 1 0 01-1 1H3a1 1 0 01-1-1v-12a1 1 0 011-1z" stroke="#20228a" stroke-width="1.5"/><path d="M9 1.5V6h4" stroke="#20228a" stroke-width="1.5"/></svg>',
}
type_emoji = _fe_svg_icons.get(r["type"], _fe_svg_icons["document"])
name = (r["publish_name"] or r["sender_name"] or "Anonym").strip()
if not name or name.lower() in ("unknown", "anonym"):
name = "Anonym"
summary_text = (r["summary"] or r["content"] or "")
# Truncate for card preview
preview = summary_text[:200] + ("..." if len(summary_text) > 200 else "")
date_str = (r["published_at"] or r["created_at"] or "")
# Format with relative + absolute time
time_html = ""
try:
from datetime import datetime
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
now = datetime.now()
diff = now - dt
if diff.days == 0:
if diff.seconds < 3600:
time_html = f'<span class="story-time-rel">vor {diff.seconds // 60} Min.</span>'
else:
time_html = f'<span class="story-time-rel">heute</span> {dt.strftime("%H:%M")} Uhr'
elif diff.days == 1:
time_html = f'<span class="story-time-rel">gestern</span> {dt.strftime("%H:%M")} Uhr'
elif diff.days < 7:
time_html = f'<span class="story-time-rel">vor {diff.days} Tagen</span> {dt.strftime("%H:%M")}'
else:
time_html = dt.strftime("%d.%m.%y %H:%M") + " Uhr"
date_str = dt.strftime("%d. %B %Y")
except:
pass
text_only_class = " text-only" if not media_first else ""
time_block = ""
if time_html:
time_overlay = f'<div class="story-time-overlay">{time_html}</div>'
if media_first:
time_block = time_overlay
else:
time_block = f'<div class="story-time">{time_html}</div>'
elif not media_first:
time_block = f'<div class="story-time">{date_str}</div>'
cards += f"""<article class="story-card{text_only_class}">
""" + media_first + time_block + """
""" + (f'<div class="story-halle-badge">📍 {r["halle"]}</div>' if r["halle"] else '') + """
""" + (f'<div class="story-ausverkauft-badge">🚫 AUSVERKAUFT: {r["spiel_titel"]}</div>' if r["category"] == "ausverkauft" and r["spiel_titel"] else '') + """
<div class="card-content">
<div class="story-meta">
<span class="story-type">""" + type_emoji + """</span>
<span class="story-author">""" + name + """</span>
</div>
<p class="story-preview">""" + preview + """</p>
</div>
</article>"""
return """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BSN Community — Stimmen aus der Szene</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700;14..32,800&display=swap" rel="stylesheet">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#FCE812; color:#1a1a2e; min-height:100vh; }
/* Header — dark bar like SPIEL Essen */
.site-header { background:#12121c; padding:1rem 2rem; display:flex; align-items:center; gap:1rem; position:sticky; top:0; z-index:100; box-shadow:0 2px 12px rgba(0,0,0,0.15); }
.site-logo { display:flex; align-items:center; gap:0.6rem; text-decoration:none; color:#fff; }
.site-logo .logo-icon { width:40px; height:40px; background:#20228a; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:1.3rem; }
.site-logo .logo-text { font-weight:800; font-size:1.15rem; letter-spacing:-0.01em; color:#fff; }
.site-logo .logo-sub { font-weight:400; color:#888; font-size:0.75rem; }
.site-header .header-right { margin-left:auto; }
.site-header .header-right a { color:#aaa; text-decoration:none; font-size:0.8rem; transition:color 0.2s; }
.site-header .header-right a:hover { color:#fff; }
/* Main grid — masonry (CSS columns) */
.main-grid { max-width:1300px; margin:1.5rem auto; padding:0 1rem; column-count:4; column-gap:1.2rem; }
/* Card — masonry tiles, compact */
.story-card { background:#fff; border-radius:14px; overflow:hidden; box-shadow:0 4px 18px rgba(0,0,0,0.10),0 1px 4px rgba(0,0,0,0.05); transition:box-shadow 0.25s ease,transform 0.2s ease; display:inline-block; width:100%; margin-bottom:1.2rem; break-inside:avoid; }
.story-card:hover { transform:translateY(-3px); box-shadow:0 8px 32px rgba(0,0,0,0.18),0 3px 10px rgba(0,0,0,0.08); }
.story-card img { width:100%; height:100%; object-fit:cover; display:block; }
.story-card video { width:100%; height:auto; max-height:600px; object-fit:contain; display:block; background:#000; }
.story-card { position:relative; }
.story-halle-badge { position:absolute; top:10px; right:10px; background:#20228a; color:#fff; padding:0.25rem 0.65rem; border-radius:5px; font-size:0.78rem; font-weight:700; letter-spacing:0.03em; z-index:5; box-shadow:0 2px 6px rgba(0,0,0,0.3); }
.story-ausverkauft-badge { position:absolute; top:10px; left:10px; background:#dc3545; color:#fff; padding:0.3rem 0.7rem; border-radius:5px; font-size:0.78rem; font-weight:700; letter-spacing:0.02em; z-index:5; box-shadow:0 2px 6px rgba(0,0,0,0.3); }
.card-content { padding:0.45rem 0.85rem 0.55rem; display:flex; flex-direction:column; gap:0.2rem; }
.story-meta { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; }
.story-type { font-size:0.8rem; }
.story-author { font-weight:600; color:#20228a; font-size:0.78rem; }
.story-date { color:#999; font-size:0.7rem; margin-left:auto; }
.story-time { font-size:0.7rem; color:#666; font-weight:500; }
.story-time-rel { color:#dc3545; font-weight:700; }
.story-time-overlay { position:absolute; bottom:8px; left:8px; z-index:5; background:rgba(255,255,255,0.28); backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px); color:#dc3545; padding:0.3rem 0.65rem; border-radius:10px; font-size:0.8rem; font-weight:700; letter-spacing:0.02em; line-height:1.3; box-shadow:0 2px 8px rgba(0,0,0,0.08); }
.story-time-overlay .story-time-rel { color:#dc3545; }
.story-preview { display:none; }
.text-only .story-preview { display:block; font-size:0.8rem; line-height:1.4; color:#444; }
.text-only .card-content { padding-top:0.4rem; }
/* Frontend filter bar */
.frontend-filter { max-width:1100px; margin:1rem auto 0; padding:0 1.5rem; display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; }
.frontend-filter .filter-label { font-size:0.72rem; color:#888; font-weight:600; text-transform:uppercase; letter-spacing:0.05em; }
.frontend-filter a { display:inline-block; padding:0.35rem 0.8rem; border-radius:999px; text-decoration:none; font-size:0.78rem; font-weight:500; transition:all 0.15s; border:1.5px solid transparent; }
.frontend-filter a.filter-pill { background:#fff; color:#555; border-color:#ddd; }
.frontend-filter a.filter-pill:hover { border-color:#20228a; color:#20228a; }
.frontend-filter a.filter-pill.active { background:#20228a; color:#fff; border-color:#20228a; font-weight:700; }
.frontend-filter a.filter-reset { color:#999; font-size:0.75rem; }
.hall-select { appearance:none; -webkit-appearance:none; background:#fff; color:#20228a; border:1.5px solid #ddd; border-radius:999px; padding:0.35rem 2rem 0.35rem 0.9rem; font-size:0.78rem; font-weight:600; font-family:inherit; cursor:pointer; transition:all 0.15s; background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2320228a' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'%3E%3C/path%3E%3C/svg%3E"); background-repeat:no-repeat; background-position:right 0.55rem center; }
.hall-select:hover { border-color:#20228a; }
.hall-select:focus { outline:none; border-color:#20228a; box-shadow:0 0 0 3px rgba(32,34,138,0.12); }
.story-card audio { width:100%; margin-top:0.8rem; }
.story-card img:not(:first-child) { height:auto; max-height:300px; object-fit:contain; margin-top:0.8rem; border-radius:8px; }
.story-card video:not(:first-child) { height:auto; max-height:300px; margin-top:0.8rem; border-radius:8px; }
/* Video thumbnail */
.video-thumb-link { display:block; text-decoration:none; }
.video-thumb-link:hover .play-btn { transform:scale(1.1); transition:transform 0.2s ease; background:#fff; }
.video-thumb-link:hover .video-thumb img { opacity:0.85; transition:opacity 0.2s ease; }
/* Media preview links (image/audio) */
.media-preview-link { display:block; text-decoration:none; }
.media-preview-link:hover .media-crop img { opacity:0.85; transition:opacity 0.2s ease; }
.media-preview-link:hover .audio-preview { background:#e8dfcf; transition:background 0.2s ease; }
/* Empty state */
.empty-state { text-align:center; padding:4rem 2rem; }
.empty-state .empty-icon { font-size:3rem; margin-bottom:1rem; }
.empty-state h2 { color:#20228a; font-size:1.8rem; font-weight:800; margin-bottom:0.5rem; }
.empty-state p { color:#888; max-width:400px; margin:0 auto; line-height:1.6; }
/* Footer */
.site-footer { text-align:center; padding:2rem; border-top:1px solid #eee; margin-top:2rem; }
.site-footer p { color:#999; font-size:0.8rem; }
.site-footer a { color:#20228a; text-decoration:none; font-weight:600; }
.site-footer a:hover { text-decoration:underline; }
/* Responsive — masonry column counts */
@media(max-width:1100px) {
.main-grid { column-count:3; }
}
@media(max-width:750px) {
.main-grid { column-count:2; padding:0 0.7rem; column-gap:0.8rem; }
.story-card { margin-bottom:0.8rem; }
}
@media(max-width:500px) {
.main-grid { column-count:1; padding:0 0.8rem; }
.story-card { margin-bottom:1rem; }
.site-header { padding:0.8rem 1rem; }
.submit-bubble { bottom:1rem; right:5rem; }
.chat-overlay { bottom:5rem; right:0.5rem; width:calc(100vw - 1rem); max-height:60vh; }
.chat-bubble { bottom:1rem; right:1rem; }
.submit-overlay { bottom:4rem; right:0.5rem; width:calc(100vw - 1rem); max-height:90vh; }
}
.chat-bubble { position:fixed; bottom:1.5rem; right:1.5rem; z-index:9999;
width:56px; height:56px; border-radius:50%; background:#20228a;
color:#fff; border:none; cursor:pointer; font-size:1.6rem;
box-shadow:0 6px 24px rgba(0,0,0,0.25); transition:transform 0.2s,box-shadow 0.2s;
display:flex; align-items:center; justify-content:center; }
.chat-bubble:hover { transform:scale(1.08); box-shadow:0 8px 32px rgba(0,0,0,0.35); }
.chat-bubble:active { transform:scale(0.95); }
.chat-bubble .bubble-dot { position:absolute; top:6px; right:6px; width:12px; height:12px;
background:#28a745; border-radius:50%; border:2px solid #fff; }
.chat-overlay { position:fixed; bottom:5.5rem; right:1.5rem; z-index:9998;
width:380px; max-width:calc(100vw - 2rem); max-height:520px;
background:#fff; border-radius:18px; box-shadow:0 12px 48px rgba(0,0,0,0.22);
display:none; flex-direction:column; overflow:hidden; }
.chat-overlay.open { display:flex; }
.chat-header { background:#20228a; color:#fff; padding:0.9rem 1.2rem;
font-weight:700; font-size:0.9rem; display:flex; align-items:center; justify-content:space-between; }
.chat-header button { background:none; border:none; color:#fff; font-size:1.3rem;
cursor:pointer; opacity:0.8; transition:opacity 0.15s; padding:0; line-height:1; }
.chat-header button:hover { opacity:1; }
.chat-body { flex:1; overflow-y:auto; padding:1rem; display:flex; flex-direction:column; gap:0.6rem;
max-height:320px; background:#fafafa; }
.chat-msg { max-width:82%; padding:0.55rem 0.85rem; border-radius:14px;
font-size:0.82rem; line-height:1.45; word-break:break-word; animation:fadeUp 0.25s ease; }
.chat-msg.user { align-self:flex-end; background:#20228a; color:#fff;
border-bottom-right-radius:4px; }
.chat-msg.assistant { align-self:flex-start; background:#e9e9eb; color:#1a1a2e;
border-bottom-left-radius:4px; }
.chat-msg.typing { background:#e9e9eb; color:#999; font-style:italic; }
@keyframes fadeUp { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} }
.chat-footer { padding:0.7rem 0.9rem; border-top:1px solid #eee; display:flex; gap:0.5rem; }
.chat-footer input { flex:1; border:1.5px solid #ddd; border-radius:999px;
padding:0.55rem 1rem; font-size:0.82rem; font-family:inherit; outline:none;
transition:border-color 0.15s; }
.chat-footer input:focus { border-color:#20228a; }
.chat-footer button { background:#20228a; color:#fff; border:none; border-radius:999px;
padding:0.55rem 1rem; font-weight:600; font-size:0.82rem; cursor:pointer;
transition:background 0.15s; font-family:inherit; }
.chat-footer button:hover { background:#161e6e; }
.chat-footer button:disabled { opacity:0.5; cursor:default; }
/* ── Submit Overlay ────────────────────────────────────────── */
.submit-bubble { position:fixed; bottom:1.5rem; right:5.5rem; z-index:9999;
width:56px; height:56px; border-radius:50%; background:#20228a;
color:#fff; border:none; cursor:pointer; font-size:1.8rem; font-weight:300; line-height:1;
box-shadow:0 6px 24px rgba(0,0,0,0.25); transition:transform 0.2s,box-shadow 0.2s;
display:flex; align-items:center; justify-content:center; }
.submit-bubble:hover { transform:scale(1.08); box-shadow:0 8px 32px rgba(0,0,0,0.35); }
.submit-bubble:active { transform:scale(0.95); }
.submit-overlay { position:fixed; bottom:5.5rem; right:1.5rem; z-index:9998;
width:400px; max-width:calc(100vw - 2rem); max-height:85vh;
background:#fff; border-radius:18px; box-shadow:0 12px 48px rgba(0,0,0,0.22);
display:none; flex-direction:column; overflow:hidden; }
.submit-overlay.open { display:flex; }
.submit-header { background:#20228a; color:#fff; padding:0.9rem 1.2rem;
font-weight:700; font-size:0.9rem; display:flex; align-items:center; justify-content:space-between; }
.submit-header button { background:none; border:none; color:#fff; font-size:1.3rem;
cursor:pointer; opacity:0.8; padding:0; line-height:1; }
.submit-header button:hover { opacity:1; }
.submit-body { flex:1; overflow-y:auto; padding:1rem; max-height:75vh; background:#fafafa; }
.submit-body .hint { font-size:0.75rem; color:#888; margin-bottom:1rem; line-height:1.5; }
.submit-body label { display:block; font-size:0.7rem; font-weight:600; color:#555;
text-transform:uppercase; letter-spacing:0.03em; margin-bottom:0.2rem; margin-top:0.8rem; }
.submit-body label:first-of-type { margin-top:0; }
.submit-body input, .submit-body textarea { width:100%; border:1.5px solid #ddd;
border-radius:10px; padding:0.55rem 0.8rem; font-size:0.82rem; font-family:inherit;
outline:none; transition:border-color 0.15s; }
.submit-body textarea { resize:vertical; min-height:70px; }
.submit-body input:focus, .submit-body textarea:focus { border-color:#20228a; }
.submit-body input[type="file"] { padding:0.4rem 0; border:none; font-size:0.78rem; }
.submit-body .submit-btn { width:100%; background:#20228a; color:#fff; border:none;
border-radius:10px; padding:0.7rem; font-weight:700; font-size:0.88rem;
cursor:pointer; margin-top:1rem; transition:background 0.15s; font-family:inherit; }
.submit-body .submit-btn:hover { background:#161e6e; }
.submit-body .submit-btn:disabled { opacity:0.5; cursor:default; }
.submit-body .status-msg { text-align:center; font-size:0.8rem; margin-top:0.6rem;
min-height:1.2rem; }
.capture-row { display:flex; gap:0.8rem; margin-top:0.3rem; justify-content:center; }
.capture-btn { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0.2rem; background:#fff; border:2px solid #ddd; border-radius:50%; width:68px; height:68px; cursor:pointer; transition:all 0.15s; -webkit-tap-highlight-color:transparent; touch-action:manipulation; padding:0; }
.capture-btn:active { transform:scale(0.92); background:#f0f0f0; border-color:#20228a; }
.capture-label { font-size:0.6rem; font-weight:600; color:#888; text-transform:uppercase; letter-spacing:0.04em; }
</style>
</head>
<body>
<header class="site-header">
<a href="/" class="site-logo">
<div class="logo-icon">\U0001f3b2</div>
<div>
<div class="logo-text">BSN Community</div>
<div class="logo-sub">Stimmen aus der Szene</div>
</div>
</a>
<div class="header-right">
<a href="https://brettspiel-news.de">brettspiel-news.de</a>
</div>
</header>
""" + filter_html + """
<main class="main-grid">
""" + cards + """
</main>
<footer class="site-footer">
<p>Powered by <a href="https://brettspiel-news.de">brettspiel-news.de</a> — Die Stimme der Brettspiel-Community</p>
</footer>
<!-- ── Chat Bubble ──────────────────────────────────────────── -->
<button class="chat-bubble" id="chatBubble" title="BSN Community Assistant" aria-label="Chat öffnen">
💬<span class="bubble-dot"></span>
</button>
<!-- ── Submit Bubble ────────────────────────────────────────── -->
<button class="submit-bubble" id="submitBubble" title="Etwas einreichen" aria-label="Einreich-Formular öffnen">+</button>
<!-- ── Chat Overlay ─────────────────────────────────────────── -->
<div class="chat-overlay" id="chatOverlay">
<div class="chat-header">
<span>💬 BSN Assistant</span>
<button id="chatClose" aria-label="Schließen">✕</button>
</div>
<div class="chat-body" id="chatBody">
<div style="display:flex;align-items:flex-end;gap:0.3rem;align-self:flex-start;">
<div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div>
<button onclick="(function(t){var u=new SpeechSynthesisUtterance(t);u.lang='de-DE';u.rate=0.95;var v=speechSynthesis.getVoices();var d=v.find(function(x){return x.lang.startsWith('de')});if(d)u.voice=d;speechSynthesis.cancel();speechSynthesis.speak(u)})('Hey! Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!')" title="Vorlesen" style="background:none;border:none;cursor:pointer;font-size:0.85rem;opacity:0.5;padding:0 0 0.15rem 0;transition:opacity 0.15s;flex-shrink:0;" onmouseenter="this.style.opacity='1'" onmouseleave="this.style.opacity='0.5'">🔊</button>
</div>
</div>
<div class="chat-footer">
<input type="text" id="chatInput" placeholder="Frag was..." aria-label="Chat-Nachricht">
<button id="chatSend">Senden</button>
</div>
</div>
<!-- ── Submit Overlay ───────────────────────────────────────── -->
<div class="submit-overlay" id="submitOverlay">
<div class="submit-header">
<span>📨 Etwas einreichen</span>
<button id="submitClose" aria-label="Schließen">✕</button>
</div>
<div class="submit-body">
<div class="hint">
🎲 <strong>Schick uns, was dich bewegt!</strong> Texte, Fotos, Videos oder Sprachnachrichten —
alles rund um Brettspiele und die SPIEL Essen. Wir schauen uns alles an und
veröffentlichen die spannendsten Beiträge.<br>
<span style="color:#888;">Nur Text oder nur eine Datei reicht völlig.</span>
</div>
<label>Dein Name (optional)</label>
<input type="text" id="subName" placeholder="Vorname oder Spitzname" maxlength="100">
<label>Nachricht (optional, reicht auch ohne Text)</label>
<textarea id="subText" placeholder="Was möchtest du mitteilen? ..." maxlength="5000"></textarea>
<label>📎 Medien aufnehmen oder auswählen</label>
<div class="capture-row">
<label class="capture-btn" id="capturePhotoBtn">
<input type="file" id="subPhoto" accept="image/*" capture="environment" hidden>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><rect x="2" y="5" width="20" height="16" rx="3" stroke="#20228a" stroke-width="2"/><circle cx="8.5" cy="10.5" r="2.5" fill="#20228a"/><path d="M2 17l5-5 4 4 4-6 7 7" stroke="#20228a" stroke-width="2" stroke-linejoin="round"/></svg>
<span class="capture-label">Foto</span>
</label>
<label class="capture-btn" id="captureVideoBtn">
<input type="file" id="subVideo" accept="video/*" capture="environment" hidden>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><rect x="2" y="4" width="20" height="16" rx="3" stroke="#20228a" stroke-width="2"/><path d="M10 8.5l5 3.5-5 3.5V8.5z" fill="#20228a"/></svg>
<span class="capture-label">Video</span>
</label>
<label class="capture-btn" id="captureAudioBtn">
<input type="file" id="subAudio" accept="audio/*" capture hidden>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><rect x="3" y="6" width="7" height="12" rx="2" fill="#20228a"/><path d="M10 9.5l7-6v17l-7-6" stroke="#20228a" stroke-width="2" stroke-linejoin="round"/><path d="M19 7c2.5 1.5 2.5 5.5 0 7" stroke="#20228a" stroke-width="2" stroke-linecap="round"/></svg>
<span class="capture-label">Audio</span>
</label>
</div>
<input type="file" id="subGallery" accept="image/*,video/*,audio/*" style="margin-top:0.5rem;font-size:0.72rem;color:#888;">
<div id="filePreview" style="display:none;margin-top:0.5rem;padding:0.5rem 0.7rem;background:#f0f4ff;border-radius:8px;font-size:0.75rem;color:#20228a;font-weight:500;"></div>
<label>📍 Halle / Stand (optional)</label>
<input type="text" id="subHalle" placeholder="z.B. H3 B123" maxlength="20">
<label>🎯 Spieltitel (optional)</label>
<input type="text" id="subSpiel" placeholder="Name des Brettspiels" maxlength="200">
<button class="submit-btn" id="submitSend">📨 Absenden</button>
<div class="status-msg" id="submitStatus"></div>
</div>
</div>
<script>
(function() {
const sBubble = document.getElementById('submitBubble');
const sOverlay = document.getElementById('submitOverlay');
const sClose = document.getElementById('submitClose');
const sSend = document.getElementById('submitSend');
const sStatus = document.getElementById('submitStatus');
let sOpen = false;
sBubble.addEventListener('click', () => {
sOpen = !sOpen;
sOverlay.classList.toggle('open', sOpen);
const chatO = document.getElementById('chatOverlay');
if (chatO) chatO.classList.remove('open');
});
sClose.addEventListener('click', () => { sOpen = false; sOverlay.classList.remove('open'); });
sSend.addEventListener('click', async () => {
const name = document.getElementById('subName').value.trim();
const text = document.getElementById('subText').value.trim();
const fileInput = document.getElementById('subPhoto');
const file = fileInput.files[0] || document.getElementById('subVideo').files[0] || document.getElementById('subAudio').files[0] || document.getElementById('subGallery').files[0];
const halle = document.getElementById('subHalle').value.trim();
const spiel = document.getElementById('subSpiel').value.trim();
if (!text && !file) {
sStatus.textContent = 'Bitte Text oder eine Datei angeben.';
sStatus.style.color = '#dc3545';
return;
}
sSend.disabled = true;
sSend.textContent = '⏳ Wird gesendet...';
sStatus.textContent = '';
const formData = new FormData();
if (name) formData.append('name', name);
if (text) formData.append('text', text);
if (file) formData.append('media', file);
if (halle) formData.append('halle', halle);
if (spiel) formData.append('spiel_titel', spiel);
try {
const r = await fetch('/api/submit', { method:'POST', body:formData });
const data = await r.json();
if (r.ok) {
sStatus.textContent = data.message;
sStatus.style.color = '#20228a';
document.getElementById('subName').value = '';
document.getElementById('subText').value = '';
fileInput.value = '';
document.getElementById('subVideo').value = '';
document.getElementById('subAudio').value = '';
document.getElementById('subGallery').value = '';
document.getElementById('filePreview').style.display = 'none';
document.getElementById('subHalle').value = '';
document.getElementById('subSpiel').value = '';
setTimeout(() => { sOverlay.classList.remove('open'); sOpen = false; sStatus.textContent = ''; }, 2500);
} else {
sStatus.textContent = data.error || 'Fehler beim Senden.';
sStatus.style.color = '#dc3545';
}
} catch(e) {
sStatus.textContent = 'Netzwerkfehler. Versuch es später nochmal.';
sStatus.style.color = '#dc3545';
}
sSend.disabled = false;
sSend.textContent = '📨 Absenden';
});
})();
// File preview — show selected file name after capture/gallery pick
(function() {
var preview = document.getElementById('filePreview');
var inputs = ['subPhoto','subVideo','subAudio','subGallery'];
function updatePreview() {
var file = null, label = '';
for (var i = 0; i < inputs.length; i++) {
var f = document.getElementById(inputs[i]);
if (f && f.files[0]) { file = f.files[0]; label = inputs[i]; break; }
}
if (file) {
var typeIcon = label === 'subPhoto' ? '🖼️' : label === 'subVideo' ? '🎬' : label === 'subAudio' ? '🎤' : '📎';
var sizeMB = (file.size / (1024*1024)).toFixed(1);
preview.innerHTML = typeIcon + ' <strong>' + file.name + '</strong> (' + sizeMB + ' MB) — wird mitgesendet';
preview.style.display = 'block';
} else {
preview.style.display = 'none';
}
}
for (var i = 0; i < inputs.length; i++) {
var el = document.getElementById(inputs[i]);
if (el) el.addEventListener('change', updatePreview);
}
})();
</script>
<script>
(function() {
const bubble = document.getElementById('chatBubble');
const overlay = document.getElementById('chatOverlay');
const closeBtn = document.getElementById('chatClose');
const body = document.getElementById('chatBody');
const input = document.getElementById('chatInput');
const sendBtn = document.getElementById('chatSend');
let history = [];
let open = false;
function toggle() {
open = !open;
overlay.classList.toggle('open', open);
if (open) setTimeout(() => input.focus(), 100);
}
bubble.addEventListener('click', toggle);
closeBtn.addEventListener('click', () => { open = false; overlay.classList.remove('open'); });
function addMsg(role, text) {
const wrapper = document.createElement('div');
wrapper.style.display = 'flex';
wrapper.style.alignItems = 'flex-end';
wrapper.style.gap = '0.3rem';
if (role === 'assistant') wrapper.style.alignSelf = 'flex-start';
else wrapper.style.alignSelf = 'flex-end';
const div = document.createElement('div');
div.className = 'chat-msg ' + role;
div.textContent = text;
wrapper.appendChild(div);
if (role === 'assistant') {
const btn = document.createElement('button');
btn.innerHTML = '🔊';
btn.title = 'Vorlesen';
btn.style.cssText = 'background:none;border:none;cursor:pointer;font-size:0.85rem;opacity:0.5;padding:0 0 0.15rem 0;transition:opacity 0.15s;flex-shrink:0;';
btn.onmouseenter = () => btn.style.opacity = '1';
btn.onmouseleave = () => btn.style.opacity = '0.5';
btn.onclick = () => speak(text);
wrapper.appendChild(btn);
}
body.appendChild(wrapper);
body.scrollTop = body.scrollHeight;
return div;
}
function speak(text) {
if (!window.speechSynthesis) return;
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = 'de-DE';
u.rate = 0.95;
// Prefer German voice
const voices = speechSynthesis.getVoices();
const de = voices.find(v => v.lang.startsWith('de'));
if (de) u.voice = de;
speechSynthesis.speak(u);
}
// Preload voices
if (window.speechSynthesis) speechSynthesis.getVoices();
function addTyping() {
const div = document.createElement('div');
div.className = 'chat-msg assistant typing';
div.textContent = '...';
div.id = 'typingIndicator';
body.appendChild(div);
body.scrollTop = body.scrollHeight;
return div;
}
function removeTyping() {
const t = document.getElementById('typingIndicator');
if (t) t.remove();
}
async function send() {
const msg = input.value.trim();
if (!msg) return;
input.value = '';
sendBtn.disabled = true;
addMsg('user', msg);
history.push({role:'user', content:msg});
const typing = addTyping();
try {
const r = await fetch('/api/chat', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:msg, history:history})
});
const data = await r.json();
removeTyping();
const reply = data.reply || 'Hmm, da ist was schiefgegangen.';
addMsg('assistant', reply);
history.push({role:'assistant', content:reply});
if (history.length > 20) history = history.slice(-20);
} catch(e) {
removeTyping();
addMsg('assistant', 'Der Chat-Assistent ist gerade nicht erreichbar. Versuch es später nochmal.');
}
sendBtn.disabled = false;
input.focus();
}
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
})();
</script>
</body>
</html>"""
@app.route('/api/query')
def api_query():
'''Instant lookup: find submissions by hall, keyword, or sender.'''
db = get_db()
q = request.args.get('q', '').strip()
halle = request.args.get('halle', '').strip()
limit = min(int(request.args.get('limit', 5)), 20)
query = 'SELECT id, halle, content, summary, type, sender_name, status FROM submissions WHERE status="published"'
params = []
if q:
query += ' AND (content LIKE ? OR summary LIKE ?)'
params.extend([f'%{q}%', f'%{q}%'])
if halle:
query += ' AND halle = ?'
params.append(halle)
query += ' ORDER BY published_at DESC LIMIT ?'
params.append(limit)
rows = db.execute(query, params).fetchall()
results = []
for r in rows:
results.append({
'id': r['id'],
'halle': r['halle'],
'content': (r['summary'] or r['content'] or '')[:200],
'type': r['type'],
'sender_name': r['sender_name'] or 'Anonym',
'status': r['status'],
})
return jsonify({'results': results, 'count': len(results)})
@app.route('/api/chat', methods=['POST'])
def api_chat():
"""Chat agent with read-only access to published submissions."""
db = get_db()
data = request.get_json(force=True, silent=True) or {}
user_message = (data.get('message') or '').strip()
if not user_message:
return jsonify({'reply': 'Leere Nachricht. Was möchtest du wissen?'})
# Build context from published submissions
rows = db.execute(
"SELECT id, halle, content, summary, type, sender_name, category, "
"spiel_titel, published_at "
"FROM submissions WHERE status='published' AND deleted_at IS NULL "
"ORDER BY published_at DESC LIMIT 60"
).fetchall()
if not rows:
return jsonify({'reply': 'Noch keine Beiträge veröffentlicht. Schau später wieder vorbei!'})
# Build compact context string
context_parts = []
for i, r in enumerate(rows, 1):
text = (r['summary'] or r['content'] or '').strip()[:400]
halle = r['halle'] or ''
spiel = r['spiel_titel'] or ''
cat = r['category'] or ''
name = (r['sender_name'] or 'Anonym').strip()
context_parts.append(
f"[{i}] {text}"
+ (f" | 📍{halle}" if halle else '')
+ (f" | 🎲{spiel}" if spiel else '')
+ (f" | #{cat}" if cat else '')
+ (f" | von {name}" if name else '')
)
context = '\n'.join(context_parts)
# System prompt
system = (
"Du bist der BSN Community Assistant von brettspiel-news.de. "
"Du hilfst Besuchern, Informationen über die Brettspiel-Community auf der SPIEL Essen zu finden.\n\n"
"WICHTIGE REGELN:\n"
"- Beantworte Fragen NUR mit Informationen aus dem Kontext unten.\n"
"- Wenn die Antwort nicht im Kontext steht, sage ehrlich: 'Dazu habe ich leider keine Informationen.'\n"
"- Erfinde NIEMALS Informationen, Namen, Stände oder Spieletitel.\n"
"- Antworte immer auf Deutsch, freundlich und hilfsbereit.\n"
"- Halte Antworten kurz und präzise (2-4 Sätze).\n"
"- Bei Standortfragen nenne immer die Hallen-Nummer.\n"
"- Der Kontext enthält nummerierte Beiträge [1], [2] usw. — du kannst darauf verweisen.\n\n"
f"KONTEXT (veröffentlichte Community-Beiträge):\n{context[:8000]}"
)
# Read API key
config_path = os.path.expanduser("~/.hermes/config.yaml")
api_key = ""
try:
with open(config_path) as f:
import yaml as _yaml
cfg = _yaml.safe_load(f)
api_key = cfg.get("providers", {}).get("datenhimmel", {}).get("api_key", "")
except Exception:
pass
if not api_key:
return jsonify({'reply': 'Chat-Assistent ist aktuell nicht verfügbar (API-Key fehlt).'})
# Build messages with conversation history
messages = [{"role": "system", "content": system}]
history = data.get('history') or []
if isinstance(history, list):
for h in history[-10:]: # last 10 messages max
if isinstance(h, dict) and 'role' in h and 'content' in h:
messages.append({"role": h['role'], "content": h['content']})
messages.append({"role": "user", "content": user_message})
try:
r = requests.post(
"https://ui.datenhimmel.work/api/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": "deepseek-v4-flash:cloud", "messages": messages, "max_tokens": 400, "temperature": 0.4},
timeout=15,
)
if r.status_code != 200:
return jsonify({'reply': 'Der Chat-Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'})
reply = r.json()["choices"][0]["message"]["content"].strip()
except Exception:
return jsonify({'reply': 'Der Chat-Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'})
return jsonify({'reply': reply})
@app.route('/api/submit', methods=['POST'])
def api_submit():
"""Public submission endpoint — text + optional media upload."""
db = get_db()
name = (request.form.get('name') or '').strip()[:100]
text = (request.form.get('text') or '').strip()[:5000]
halle = (request.form.get('halle') or '').strip()[:20]
spiel_titel = (request.form.get('spiel_titel') or '').strip()[:200]
# Require at least text or a file
file = request.files.get('media')
if not text and (not file or not file.filename):
return jsonify({'error': 'Bitte Text oder eine Datei (Foto/Video/Audio) angeben.'}), 400
media_path = None
msg_type = 'text'
if file and file.filename:
# Determine type from extension
ext = os.path.splitext(file.filename)[1].lower()
mime_map = {
'.jpg': 'image', '.jpeg': 'image', '.png': 'image', '.webp': 'image', '.gif': 'image',
'.mp4': 'video', '.webm': 'video',
'.ogg': 'audio', '.mp3': 'audio', '.wav': 'audio',
}
msg_type = mime_map.get(ext, 'document')
# Add type prefix to content
prefix = {
'image': '\n[🖼️ Bild]',
'video': '\n[🎬 Video]',
'audio': '\n[🎤 Sprachnachricht]',
'document': '\n[📎 Dokument]',
}.get(msg_type, f'\n[{msg_type}]')
if text:
text = text + prefix
else:
text = prefix.strip()
# Save file
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_name = f"web_{ts}{ext}"
save_path = MEDIA_DIR / safe_name
try:
file.save(str(save_path))
media_path = str(save_path)
except Exception:
return jsonify({'error': 'Datei konnte nicht gespeichert werden.'}), 500
# Validate halle
halle_valid = None
if halle:
import re
if re.match(r'^H[1-7]\s*[A-Z]\d{1,4}$', halle, re.IGNORECASE):
halle_valid = halle.upper()
sender_id = 'web_' + (name.replace(' ', '_') if name else 'anonym')
sender_name = name or None
db.execute(
"""INSERT INTO submissions
(channel, sender_id, sender_name, type, content, media_path, status, halle, spiel_titel)
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?)""",
('web', sender_id, sender_name, msg_type, text or None, media_path, halle_valid, spiel_titel or None),
)
db.commit()
return jsonify({'ok': True, 'message': 'Danke für deine Einreichung! Wir schauen sie uns an. 🎲'})
@app.route("/beitrag/<int:sub_id>")
def public_detail(sub_id: int):
"""Public detail view — full media, text, and metadata."""
import base64
db = get_db()
row = db.execute(
"SELECT * FROM submissions WHERE id = ? AND status='published' AND deleted_at IS NULL",
(sub_id,),
).fetchone()
if not row:
return "<h1>Nicht gefunden</h1><p><a href='/'>← Zurück</a></p>", 404
# Build media HTML — videos are playable, images full-size
media_html = ""
if row["media_path"]:
paths = row["media_path"].split("||")
flags_str = row["media_publish_flags"] or ""
flags = flags_str.split(",") if flags_str else ["1"] * len(paths)
while len(flags) < len(paths):
flags.append("1")
mime_map = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif",
".mp4": "video/mp4", ".webm": "video/webm",
".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav",
}
for i, path in enumerate(paths):
if flags[i] != "1":
continue
if not os.path.exists(path):
continue
_, ext = os.path.splitext(path)
mime = mime_map.get(ext.lower(), "application/octet-stream")
try:
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
src = "data:" + mime + ";base64," + b64
if mime.startswith("image/"):
media_html += f'<img src="{src}" alt="Bild" style="width:100%;height:auto;border-radius:12px;margin:0.5rem 0;">'
elif mime.startswith("video/"):
media_html += (
f'<video controls playsinline style="width:100%;max-height:80vh;border-radius:12px;margin:0.5rem 0;background:#000;">'
f'<source src="{src}" type="{mime}"></video>'
)
elif mime.startswith("audio/"):
media_html += f'<audio controls style="width:100%;margin:0.5rem 0;"><source src="{src}" type="{mime}"></audio>'
except Exception:
pass
type_emoji = {"text": "💬", "image": "🖼️", "audio": "🎤", "video": "🎬", "document": "📎"}.get(row["type"], "📌")
name = (row["publish_name"] or row["sender_name"] or "Anonym").strip()
if not name or name.lower() in ("unknown", "anonym"):
name = "Anonym"
date_str = row["published_at"] or row["created_at"] or ""
try:
from datetime import datetime as dt
d = dt.strptime(date_str, "%Y-%m-%d %H:%M:%S")
date_str = d.strftime("%d. %B %Y")
except Exception:
pass
content = (row["summary"] or row["content"] or "").strip()
return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Beitrag — BSN Community</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700;14..32,800&display=swap" rel="stylesheet">
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; background:#F3EDE4; color:#1a1a2e; min-height:100vh; }}
.site-header {{ background:#12121c; padding:1rem 2rem; display:flex; align-items:center; gap:1rem; position:sticky; top:0; z-index:100; }}
.site-logo {{ display:flex; align-items:center; gap:0.6rem; text-decoration:none; color:#fff; }}
.site-logo .logo-icon {{ width:40px; height:40px; background:#20228a; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:1.3rem; }}
.site-logo .logo-text {{ font-weight:800; font-size:1.15rem; color:#fff; }}
.detail-container {{ max-width:720px; margin:2rem auto; padding:0 1.5rem; }}
.back-link {{ display:inline-block; color:#20228a; text-decoration:none; font-weight:600; margin-bottom:1.5rem; font-size:0.9rem; }}
.back-link:hover {{ text-decoration:underline; }}
.detail-card {{ background:#fff; border-radius:16px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.06); }}
.detail-meta {{ display:flex; align-items:center; gap:0.8rem; margin-bottom:1.5rem; flex-wrap:wrap; }}
.detail-type {{ font-size:1.5rem; }}
.detail-author {{ font-weight:700; color:#20228a; font-size:1rem; }}
.detail-date {{ color:#999; font-size:0.8rem; margin-left:auto; }}
.detail-halle {{ display:inline-block; background:#20228a; color:#fff; padding:0.35rem 0.8rem; border-radius:6px; font-size:0.85rem; font-weight:700; margin-bottom:1rem; }}
.detail-content {{ font-size:1.05rem; line-height:1.7; color:#333; white-space:pre-wrap; word-break:break-word; }}
.detail-media {{ margin-top:1.5rem; }}
.site-footer {{ text-align:center; padding:2rem; border-top:1px solid #eee; margin-top:3rem; }}
.site-footer p {{ color:#999; font-size:0.8rem; }}
.site-footer a {{ color:#20228a; text-decoration:none; font-weight:600; }}
@media(max-width:600px) {{
.detail-card {{ padding:1.2rem; }}
.site-header {{ padding:0.8rem 1rem; }}
}}
</style>
</head>
<body>
<header class="site-header">
<a href="/" class="site-logo">
<div class="logo-icon">🎲</div>
<div class="logo-text">BSN Community</div>
</a>
</header>
<main class="detail-container">
<a href="/" class="back-link">← Zurück zur Übersicht</a>
<article class="detail-card">
{"".join([f'<div class="detail-halle">📍 {row["halle"]}</div>' if row["halle"] else '', f'<div style="background:#dc3545;color:#fff;padding:0.35rem 0.8rem;border-radius:6px;font-size:0.85rem;font-weight:700;margin-bottom:1rem;margin-left:0.5rem;">🚫 AUSVERKAUFT: {row["spiel_titel"]}</div>' if row["category"] == "ausverkauft" and row["spiel_titel"] else ''])}
<div class="detail-meta">
<span class="detail-type">{type_emoji}</span>
<span class="detail-author">{name}</span>
<span class="detail-date">{date_str}</span>
</div>
<div class="detail-media">{media_html}</div>
{"".join([f'<div class="detail-content">{content}</div>' if content else ''])}
</article>
</main>
<footer class="site-footer">
<p>Powered by <a href="https://brettspiel-news.de">brettspiel-news.de</a></p>
</footer>
</body>
</html>"""
if __name__ == "__main__":
import threading
def keepalive():
"""Prevent process from being killed by inactivity timeout."""
import time
while True:
time.sleep(60)
import sys
sys.stdout.write("[keepalive]\n")
sys.stdout.flush()
threading.Thread(target=keepalive, daemon=True).start()
print(f"🚀 BSN Intake starting on http://127.0.0.1:{PORT}")
print(f" DB: {DB_PATH}")
print(f" Media: {MEDIA_DIR}")
print(f" Meta API: {'✅ configured' if META_TOKEN else '❌ no token'}")
print(f" Admin: http://127.0.0.1:{PORT}/admin")
print(f" Webhook: http://127.0.0.1:{PORT}/whatsapp/webhook")
app.run(host="127.0.0.1", port=PORT, debug=True)