0d5a3b787c
- JPEG-Logo mit PIL in transparentes PNG konvertiert (Weiß→Alpha) - LOGO_B64 als Modul-Level-Konstante (PNG, ~83KB base64) - Logo in public_frontend(), public_detail(), admin_dashboard(), test_page() - .replace()-Methode für Nicht-f-String-Kontexte (Frontend, Test)
2720 lines
219 KiB
Python
2720 lines
219 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)
|
||
|
||
# Brettspiel News logo (base64-encoded JPEG, 993x217)
|
||
LOGO_B64 = """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"""
|
||
|
||
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">
|
||
<img src="data:image/png;base64,{LOGO_B64}" alt="Brettspiel News" style="height:34px;width:auto;">
|
||
<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>""".replace("{LOGO_B64}", LOGO_B64)
|
||
|
||
|
||
@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>
|
||
<img src="data:image/png;base64,{LOGO_B64}" alt="Brettspiel News" style="height:34px;width:auto;margin-bottom:1.5rem;display:block;"><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>""".replace("{LOGO_B64}", LOGO_B64)
|
||
|
||
|
||
@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>
|
||
{"".join([f'<button type="button" id="unpublish-btn" class="btn btn-publish" style="padding:0.35rem 0.8rem;font-size:0.75rem;white-space:nowrap;background:rgba(255,193,7,0.18);color:#ffc107;border:1px solid rgba(255,193,7,0.3);">\u2b05\ufe0f Ver\u00f6ffentlichung zur\u00fccknehmen</button>' if row['status'] == 'published' else ''])}</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 _unpBtn = document.getElementById('unpublish-btn');
|
||
if (_unpBtn) {{
|
||
_unpBtn.addEventListener('click', function() {{
|
||
if (!confirm('Veroffentlichung von Beitrag #' + {row['id']} + ' zurucknehmen? Der Beitrag verschwindet vom Frontend, bleibt aber in der Datenbank.')) return;
|
||
this.disabled = true;
|
||
this.textContent = 'Nehme zuruck...';
|
||
fetch('/submission/' + {row['id']} + '/unpublish', {{method:'POST'}})
|
||
.then(function(r) {{ return r.json(); }})
|
||
.then(function(data) {{
|
||
if (data.ok) {{
|
||
document.body.innerHTML = '<div style=\"display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg);\"><div style=\"background:rgba(255,193,7,0.12);color:#ffc107;padding:1.5rem 2.5rem;border-radius:12px;font-size:1.15rem;font-weight:600;text-align:center;animation:fadeInOut 1.8s ease forwards;\">Beitrag #' + {row['id']} + ' nicht mehr veroffentlicht</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='/submission/' + {row['id']}; }}, 1800);
|
||
}} else {{
|
||
alert(data.error || 'Fehler');
|
||
_unpBtn.disabled = false;
|
||
_unpBtn.textContent = 'Veroffentlichung zurucknehmen';
|
||
}}
|
||
}})
|
||
.catch(function(err) {{
|
||
alert('Fehler: ' + err.message);
|
||
_unpBtn.disabled = false;
|
||
_unpBtn.textContent = 'Veroffentlichung zurucknehmen';
|
||
}});
|
||
}});
|
||
}}
|
||
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>/unpublish", methods=["POST"])
|
||
def submission_unpublish(sub_id: int):
|
||
"""Unpublish a submission - set status back to 'new' and clear published_at."""
|
||
db = get_db()
|
||
row = db.execute("SELECT id, status FROM submissions WHERE id = ?", (sub_id,)).fetchone()
|
||
if not row:
|
||
return jsonify({"error": "Nicht gefunden"}), 404
|
||
if row["status"] != "published":
|
||
return jsonify({"error": "Beitrag ist nicht veroffentlicht"}), 400
|
||
db.execute("UPDATE submissions SET status='new', published_at=NULL WHERE id=?", (sub_id,))
|
||
db.commit()
|
||
return jsonify({"ok": True, "new_status": "new"})
|
||
|
||
@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>'
|
||
time_block = time_overlay # Always overlay now
|
||
elif not media_first:
|
||
time_block = ''
|
||
|
||
if not media_first:
|
||
# Text-only card: link-wrapped, time overlay inside card-content
|
||
cards += f"""<a href="/beitrag/{r['id']}" class="card-link">
|
||
<article class="story-card text-only">
|
||
""" + (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">
|
||
""" + time_block + """
|
||
<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>
|
||
</a>"""
|
||
else:
|
||
# Media card: existing structure
|
||
cards += f"""<article class="story-card">
|
||
""" + 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 link wrapper (text-only) */
|
||
.card-link { text-decoration:none; color:inherit; display:inline-block; width:100%; margin-bottom:1.2rem; break-inside:avoid; }
|
||
.card-link:hover .story-card { transform:translateY(-3px); box-shadow:0 8px 32px rgba(0,0,0,0.18),0 3px 10px rgba(0,0,0,0.08); }
|
||
|
||
/* 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); }
|
||
a.card-link .story-card { margin-bottom:0; }
|
||
.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; top:8px; left:8px; z-index:5; background:rgba(255,255,255,0.78); 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.12); }
|
||
.story-time-overlay .story-time-rel { color:#dc3545; }
|
||
.story-preview { display:none; }
|
||
.text-only .story-preview { display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden; text-overflow:ellipsis; font-size:0.8rem; line-height:1.4; color:#444; margin-bottom:0.35rem; }
|
||
.text-only .card-content { position:relative; padding:0.55rem 0.85rem 0.65rem; }
|
||
.text-only .story-time-overlay { position:absolute; top:0.45rem; right:0.6rem; left:auto; z-index:5; }
|
||
|
||
/* 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:1.4rem; margin-top:0.3rem; justify-content:center; align-items:flex-start; }
|
||
.capture-item { display:flex; flex-direction:column; align-items:center; gap:0.4rem; }
|
||
.capture-btn, label.capture-btn { display:flex; align-items:center; justify-content:center; background:#fff; border:2px solid #ddd; border-radius:50%; width:68px; height:68px; cursor:pointer; transition:all 0.15s; -webkit-tap-highlight-color:transparent; touch-action:manipulation; padding:0; }
|
||
.capture-btn svg { display:block; }
|
||
.capture-btn:active { transform:scale(0.92); background:#f0f0f0; border-color:#20228a; }
|
||
.capture-label { font-size:0.62rem; font-weight:600; color:#888; text-transform:uppercase; letter-spacing:0.04em; text-align:center; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="site-header">
|
||
<a href="/" class="site-logo">
|
||
<img src="data:image/png;base64,{LOGO_B64}" alt="Brettspiel News" style="height:38px;width:auto;display:block;">
|
||
</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">
|
||
<div class="capture-item">
|
||
<label class="capture-btn">
|
||
<input type="file" id="subPhoto" accept="image/*" capture="environment" hidden>
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><rect x="2" y="5" width="20" height="16" rx="3" stroke="#20228a" stroke-width="2"/><circle cx="8.5" cy="10.5" r="2.5" fill="#20228a"/><path d="M2 17l5-5 4 4 4-6 7 7" stroke="#20228a" stroke-width="2" stroke-linejoin="round"/></svg>
|
||
</label>
|
||
<span class="capture-label">Foto</span>
|
||
</div>
|
||
<div class="capture-item">
|
||
<label class="capture-btn">
|
||
<input type="file" id="subVideo" accept="video/*" capture="environment" hidden>
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><rect x="2" y="4" width="20" height="16" rx="3" stroke="#20228a" stroke-width="2"/><path d="M10 8.5l5 3.5-5 3.5V8.5z" fill="#20228a"/></svg>
|
||
</label>
|
||
<span class="capture-label">Video</span>
|
||
</div>
|
||
<div class="capture-item">
|
||
<label class="capture-btn">
|
||
<input type="file" id="subAudio" accept="audio/*" capture hidden>
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"><rect x="3" y="6" width="7" height="12" rx="2" fill="#20228a"/><path d="M10 9.5l7-6v17l-7-6" stroke="#20228a" stroke-width="2" stroke-linejoin="round"/><path d="M19 7c2.5 1.5 2.5 5.5 0 7" stroke="#20228a" stroke-width="2" stroke-linecap="round"/></svg>
|
||
</label>
|
||
<span class="capture-label">Audio</span>
|
||
</div>
|
||
</div>
|
||
<input type="file" id="subGallery" accept="image/*,video/*,audio/*" style="margin-top:0.5rem;font-size:0.72rem;color:#888;">
|
||
<div id="filePreview" style="display:none;margin-top:0.5rem;padding:0.5rem 0.7rem;background:#f0f4ff;border-radius:8px;font-size:0.75rem;color:#20228a;font-weight:500;"></div>
|
||
<label>📍 Halle / Stand (optional)</label>
|
||
<input type="text" id="subHalle" placeholder="z.B. H3 B123" maxlength="20">
|
||
<label>🎯 Spieltitel (optional)</label>
|
||
<input type="text" id="subSpiel" placeholder="Name des Brettspiels" maxlength="200">
|
||
<button class="submit-btn" id="submitSend">📨 Absenden</button>
|
||
<div class="status-msg" id="submitStatus"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function() {
|
||
const sBubble = document.getElementById('submitBubble');
|
||
const sOverlay = document.getElementById('submitOverlay');
|
||
const sClose = document.getElementById('submitClose');
|
||
const sSend = document.getElementById('submitSend');
|
||
const sStatus = document.getElementById('submitStatus');
|
||
let sOpen = false;
|
||
|
||
sBubble.addEventListener('click', () => {
|
||
sOpen = !sOpen;
|
||
sOverlay.classList.toggle('open', sOpen);
|
||
const chatO = document.getElementById('chatOverlay');
|
||
if (chatO) chatO.classList.remove('open');
|
||
});
|
||
sClose.addEventListener('click', () => { sOpen = false; sOverlay.classList.remove('open'); });
|
||
|
||
sSend.addEventListener('click', async () => {
|
||
const name = document.getElementById('subName').value.trim();
|
||
const text = document.getElementById('subText').value.trim();
|
||
const fileInput = document.getElementById('subPhoto');
|
||
const file = fileInput.files[0] || document.getElementById('subVideo').files[0] || document.getElementById('subAudio').files[0] || document.getElementById('subGallery').files[0];
|
||
const halle = document.getElementById('subHalle').value.trim();
|
||
const spiel = document.getElementById('subSpiel').value.trim();
|
||
|
||
if (!text && !file) {
|
||
sStatus.textContent = 'Bitte Text oder eine Datei angeben.';
|
||
sStatus.style.color = '#dc3545';
|
||
return;
|
||
}
|
||
|
||
sSend.disabled = true;
|
||
sSend.textContent = '⏳ Wird gesendet...';
|
||
sStatus.textContent = '';
|
||
|
||
const formData = new FormData();
|
||
if (name) formData.append('name', name);
|
||
if (text) formData.append('text', text);
|
||
if (file) formData.append('media', file);
|
||
if (halle) formData.append('halle', halle);
|
||
if (spiel) formData.append('spiel_titel', spiel);
|
||
|
||
try {
|
||
const r = await fetch('/api/submit', { method:'POST', body:formData });
|
||
const data = await r.json();
|
||
if (r.ok) {
|
||
const subId = data.submission_id;
|
||
const hint = data.delete_hint || 'Notiere dir diese ID — mit ihr kannst du den Beitrag später löschen lassen.';
|
||
sStatus.innerHTML = '<div style="background:#e8f5e9;border-radius:12px;padding:1rem;margin-top:0.5rem;text-align:center;">'
|
||
+ '<div style="font-size:1.1rem;font-weight:700;color:#20228a;margin-bottom:0.4rem;">✅ ' + data.message + '</div>'
|
||
+ '<div style="display:flex;align-items:center;justify-content:center;gap:0.5rem;margin:0.6rem 0;">'
|
||
+ '<code id="refId" style="background:#fff;padding:0.4rem 0.8rem;border-radius:6px;font-size:1.2rem;font-weight:700;color:#20228a;border:1.5px solid #20228a;">#' + subId + '</code>'
|
||
+ '<button id="copyIdBtn" style="background:#20228a;color:#fff;border:none;border-radius:6px;padding:0.4rem 0.8rem;font-size:0.78rem;font-weight:600;cursor:pointer;font-family:inherit;">📋 Kopieren</button>'
|
||
+ '</div>'
|
||
+ '<p style="font-size:0.72rem;color:#666;margin:0.4rem 0 0 0;">' + hint + '</p>'
|
||
+ '</div>';
|
||
sStatus.style.color = '#20228a';
|
||
document.getElementById('subName').value = '';
|
||
document.getElementById('subText').value = '';
|
||
fileInput.value = '';
|
||
document.getElementById('subVideo').value = '';
|
||
document.getElementById('subAudio').value = '';
|
||
document.getElementById('subGallery').value = '';
|
||
document.getElementById('filePreview').style.display = 'none';
|
||
document.getElementById('subHalle').value = '';
|
||
document.getElementById('subSpiel').value = '';
|
||
// Kopier-Button aktivieren
|
||
setTimeout(function() {
|
||
var copyBtn = document.getElementById('copyIdBtn');
|
||
if (copyBtn) {
|
||
copyBtn.addEventListener('click', function() {
|
||
navigator.clipboard.writeText('#' + subId).then(function() {
|
||
copyBtn.textContent = '✓ Kopiert!';
|
||
setTimeout(function() { copyBtn.textContent = '📋 Kopieren'; }, 2000);
|
||
}).catch(function() {
|
||
copyBtn.textContent = 'Fehler';
|
||
});
|
||
});
|
||
}
|
||
}, 50);
|
||
// Overlay schließt automatisch nach 8 Sekunden
|
||
setTimeout(function() { sOverlay.classList.remove('open'); sOpen = false; sStatus.innerHTML = ''; }, 8000);
|
||
} else {
|
||
sStatus.textContent = data.error || 'Fehler beim Senden.';
|
||
sStatus.style.color = '#dc3545';
|
||
}
|
||
} catch(e) {
|
||
sStatus.textContent = 'Netzwerkfehler. Versuch es später nochmal.';
|
||
sStatus.style.color = '#dc3545';
|
||
}
|
||
sSend.disabled = false;
|
||
sSend.textContent = '📨 Absenden';
|
||
});
|
||
})();
|
||
|
||
// File preview — show selected file name after capture/gallery pick
|
||
(function() {
|
||
var preview = document.getElementById('filePreview');
|
||
var inputs = ['subPhoto','subVideo','subAudio','subGallery'];
|
||
function updatePreview() {
|
||
var file = null, label = '';
|
||
for (var i = 0; i < inputs.length; i++) {
|
||
var f = document.getElementById(inputs[i]);
|
||
if (f && f.files[0]) { file = f.files[0]; label = inputs[i]; break; }
|
||
}
|
||
if (file) {
|
||
var typeIcon = label === 'subPhoto' ? '🖼️' : label === 'subVideo' ? '🎬' : label === 'subAudio' ? '🎤' : '📎';
|
||
var sizeMB = (file.size / (1024*1024)).toFixed(1);
|
||
preview.innerHTML = typeIcon + ' <strong>' + file.name + '</strong> (' + sizeMB + ' MB) — wird mitgesendet';
|
||
preview.style.display = 'block';
|
||
} else {
|
||
preview.style.display = 'none';
|
||
}
|
||
}
|
||
for (var i = 0; i < inputs.length; i++) {
|
||
var el = document.getElementById(inputs[i]);
|
||
if (el) el.addEventListener('change', updatePreview);
|
||
}
|
||
})();
|
||
</script>
|
||
|
||
<script>
|
||
(function() {
|
||
const bubble = document.getElementById('chatBubble');
|
||
const overlay = document.getElementById('chatOverlay');
|
||
const closeBtn = document.getElementById('chatClose');
|
||
const body = document.getElementById('chatBody');
|
||
const input = document.getElementById('chatInput');
|
||
const sendBtn = document.getElementById('chatSend');
|
||
let history = [];
|
||
let open = false;
|
||
|
||
function toggle() {
|
||
open = !open;
|
||
overlay.classList.toggle('open', open);
|
||
if (open) setTimeout(() => input.focus(), 100);
|
||
}
|
||
|
||
bubble.addEventListener('click', toggle);
|
||
closeBtn.addEventListener('click', () => { open = false; overlay.classList.remove('open'); });
|
||
|
||
function addMsg(role, text) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.style.display = 'flex';
|
||
wrapper.style.alignItems = 'flex-end';
|
||
wrapper.style.gap = '0.3rem';
|
||
if (role === 'assistant') wrapper.style.alignSelf = 'flex-start';
|
||
else wrapper.style.alignSelf = 'flex-end';
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'chat-msg ' + role;
|
||
div.textContent = text;
|
||
wrapper.appendChild(div);
|
||
|
||
if (role === 'assistant') {
|
||
const btn = document.createElement('button');
|
||
btn.innerHTML = '🔊';
|
||
btn.title = 'Vorlesen';
|
||
btn.style.cssText = 'background:none;border:none;cursor:pointer;font-size:0.85rem;opacity:0.5;padding:0 0 0.15rem 0;transition:opacity 0.15s;flex-shrink:0;';
|
||
btn.onmouseenter = () => btn.style.opacity = '1';
|
||
btn.onmouseleave = () => btn.style.opacity = '0.5';
|
||
btn.onclick = () => speak(text);
|
||
wrapper.appendChild(btn);
|
||
}
|
||
|
||
body.appendChild(wrapper);
|
||
body.scrollTop = body.scrollHeight;
|
||
return div;
|
||
}
|
||
|
||
function speak(text) {
|
||
if (!window.speechSynthesis) return;
|
||
window.speechSynthesis.cancel();
|
||
const u = new SpeechSynthesisUtterance(text);
|
||
u.lang = 'de-DE';
|
||
u.rate = 0.95;
|
||
// Prefer German voice
|
||
const voices = speechSynthesis.getVoices();
|
||
const de = voices.find(v => v.lang.startsWith('de'));
|
||
if (de) u.voice = de;
|
||
speechSynthesis.speak(u);
|
||
}
|
||
|
||
// Preload voices
|
||
if (window.speechSynthesis) speechSynthesis.getVoices();
|
||
|
||
function addTyping() {
|
||
const div = document.createElement('div');
|
||
div.className = 'chat-msg assistant typing';
|
||
div.textContent = '...';
|
||
div.id = 'typingIndicator';
|
||
body.appendChild(div);
|
||
body.scrollTop = body.scrollHeight;
|
||
return div;
|
||
}
|
||
|
||
function removeTyping() {
|
||
const t = document.getElementById('typingIndicator');
|
||
if (t) t.remove();
|
||
}
|
||
|
||
async function send() {
|
||
const msg = input.value.trim();
|
||
if (!msg) return;
|
||
input.value = '';
|
||
sendBtn.disabled = true;
|
||
addMsg('user', msg);
|
||
history.push({role:'user', content:msg});
|
||
const typing = addTyping();
|
||
|
||
try {
|
||
const r = await fetch('/api/chat', {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({message:msg, history:history})
|
||
});
|
||
const data = await r.json();
|
||
removeTyping();
|
||
const reply = data.reply || 'Hmm, da ist was schiefgegangen.';
|
||
addMsg('assistant', reply);
|
||
history.push({role:'assistant', content:reply});
|
||
if (history.length > 20) history = history.slice(-20);
|
||
} catch(e) {
|
||
removeTyping();
|
||
addMsg('assistant', 'Der Chat-Assistent ist gerade nicht erreichbar. Versuch es später nochmal.');
|
||
}
|
||
sendBtn.disabled = false;
|
||
input.focus();
|
||
}
|
||
|
||
sendBtn.addEventListener('click', send);
|
||
input.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>""").replace("{LOGO_B64}", LOGO_B64)
|
||
|
||
|
||
|
||
|
||
@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()
|
||
sub_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||
|
||
# Auto-summarize via LLM (only if no halle was provided by user)
|
||
if not halle_valid and text and len(text.strip()) > 20:
|
||
import threading
|
||
threading.Thread(
|
||
target=auto_process_bg,
|
||
args=(DB_PATH, sub_id),
|
||
daemon=True,
|
||
).start()
|
||
|
||
return jsonify({
|
||
'ok': True,
|
||
'message': f'Beitrag #{sub_id} erfolgreich übermittelt! 🎲',
|
||
'submission_id': sub_id,
|
||
'delete_hint': 'Notiere dir diese ID — mit ihr kannst du den Beitrag später löschen lassen.',
|
||
})
|
||
|
||
|
||
@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">
|
||
<img src="data:image/png;base64,{LOGO_B64}" alt="Brettspiel News" style="height:30px;width:auto;display:block;">
|
||
</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>
|
||
<div style="text-align:center;padding:1rem 1.5rem 2.5rem;font-size:0.7rem;color:#aaa;max-width:720px;margin:0 auto;">
|
||
Beitrags-ID: <strong style="color:#20228a;">#{sub_id}</strong> \u2014 Bei Fragen oder Löschwunsch bitte diese ID angeben.
|
||
</div>
|
||
</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)
|