03496ab2ff
- Drei große Touch-Buttons (min 64px, flex-row) statt einem Dateifeld - capture='environment' für Foto/Video → Kamera-App öffnet direkt - capture (ohne Wert) für Audio → Sprachrekorder öffnet direkt - 'Choose File' als Galerie-Fallback für bestehende Dateien - Overlay max-height erhöht (85vh Desktop, 90vh Mobile) damit alle Buttons ohne Scrollen sichtbar sind - JavaScript prüft alle 4 Inputs auf Dateien
2584 lines
129 KiB
Python
2584 lines
129 KiB
Python
#!/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.5rem; margin-top:0.3rem; }
|
||
.capture-btn { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0.15rem; background:#fff; border:2px solid #ddd; border-radius:12px; padding:0.7rem 0.3rem; cursor:pointer; transition:all 0.15s; min-height:64px; -webkit-tap-highlight-color:transparent; touch-action:manipulation; }
|
||
.capture-btn:active { transform:scale(0.95); background:#f0f0f0; border-color:#20228a; }
|
||
.capture-icon { font-size:1.6rem; line-height:1; }
|
||
.capture-label { font-size:0.7rem; 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>
|
||
<span class="capture-icon">📸</span>
|
||
<span class="capture-label">Foto</span>
|
||
</label>
|
||
<label class="capture-btn" id="captureVideoBtn">
|
||
<input type="file" id="subVideo" accept="video/*" capture="environment" hidden>
|
||
<span class="capture-icon">🎬</span>
|
||
<span class="capture-label">Video</span>
|
||
</label>
|
||
<label class="capture-btn" id="captureAudioBtn">
|
||
<input type="file" id="subAudio" accept="audio/*" capture hidden>
|
||
<span class="capture-icon">🎤</span>
|
||
<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:var(--text3);">
|
||
<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('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';
|
||
});
|
||
})();
|
||
</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)
|