fc07c991cc
- .mov-Dateien werden beim Upload automatisch per ffmpeg in .mp4 (H.264) konvertiert - .mov in allen drei mime_maps ergänzt (api_submit, Frontend, Detail-Seite) - Existierende .mov-Datei (Beitrag #49) konvertiert + DB aktualisiert - Logo: Flood-Fill-basierte Hintergrund-Transparenz (nur außen, Schrift-Innenraum bleibt weiß)
2738 lines
219 KiB
Python
2738 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", ".mov": "video/mp4",
|
||
".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", ".mov": "video/mp4",
|
||
".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:#20228a; 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', '.mov': '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)
|
||
|
||
# Convert .mov to .mp4 for browser compatibility
|
||
if ext == '.mov':
|
||
mp4_path = save_path.with_suffix('.mp4')
|
||
import subprocess
|
||
result = subprocess.run(
|
||
['ffmpeg', '-y', '-i', str(save_path),
|
||
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
|
||
'-c:a', 'aac', '-b:a', '128k',
|
||
'-movflags', '+faststart',
|
||
str(mp4_path)],
|
||
capture_output=True, text=True, timeout=120
|
||
)
|
||
if result.returncode == 0 and mp4_path.exists():
|
||
os.remove(str(save_path)) # Remove original .mov
|
||
media_path = str(mp4_path)
|
||
msg_type = 'video'
|
||
# If conversion fails, keep .mov as-is
|
||
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", ".mov": "video/mp4",
|
||
".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:#20228a; 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)
|