6600d5e346
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 12m19s
- SVG fill="none" → fill="#EF4444" (solid red heart) - CSS default: stroke + fill #EF4444 - CSS liked: stroke + fill #fff (white heart on red button) - stroke-width 1.5 → 0.5 für cleaner Look
5342 lines
356 KiB
Python
5342 lines
356 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
BSN Assistent — 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 secrets
|
||
import sqlite3
|
||
import sys
|
||
import uuid
|
||
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"))
|
||
TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN", "")
|
||
TELEGRAM_API = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}" if TELEGRAM_TOKEN else ""
|
||
ADMIN_PHONE = os.environ.get("ADMIN_PHONE", "4915227909535")
|
||
|
||
VERIFICATION_REQUIRED = os.environ.get("VERIFICATION_REQUIRED", "").lower() in ("1", "true", "yes")
|
||
# Test system: verification is OFF by default (set VERIFICATION_REQUIRED=1 in .env to enable)
|
||
|
||
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
|
||
|
||
|
||
def moderate_comment(content: str) -> tuple[str, str | None]:
|
||
"""Run LLM moderation on a comment. Returns (status, reason)."""
|
||
import yaml as _yaml
|
||
import openai
|
||
config_path = os.path.expanduser("~/.hermes/config.yaml")
|
||
try:
|
||
with open(config_path) as f:
|
||
cfg = _yaml.safe_load(f)
|
||
api_key = cfg["providers"]["datenhimmel"]["api_key"]
|
||
base_url = cfg["providers"]["datenhimmel"]["base_url"]
|
||
except Exception:
|
||
print("[moderate] Cannot read API config", file=sys.stderr)
|
||
return "published", None
|
||
|
||
client = openai.OpenAI(base_url=base_url, api_key=api_key)
|
||
prompt = f"""Prüfe folgenden Kommentar auf einer Brettspiel-Community-Seite auf Verstöße gegen deutsches Recht und unsere Community-Regeln.
|
||
|
||
KOMMENTAR:
|
||
\"{content}\"
|
||
|
||
PRÜFE AUF:
|
||
1. Beleidigungen, persönliche Angriffe, Hassrede
|
||
2. Volksverhetzung, extremistische Inhalte
|
||
3. Gewaltandrohungen, Bedrohungen
|
||
4. Diskriminierung (Rasse, Religion, Geschlecht, sexuelle Orientierung, Behinderung)
|
||
5. Schwere Obszönitäten oder pornografische Inhalte
|
||
6. Illegale Inhalte nach deutschem Strafrecht
|
||
7. Spam oder offensichtlicher Vandalismus
|
||
|
||
Normale Kritik an Spielen, kontroverse Meinungen zu Brettspielen oder lebhafte Diskussionen sind ERLAUBT.
|
||
Nur klare Rechtsverstöße oder extreme Toxizität sollen geblockt werden.
|
||
|
||
Antworte NUR mit einem JSON-Objekt:
|
||
{{"status": "published", "reason": null}} — wenn der Kommentar in Ordnung ist
|
||
{{"status": "blocked", "reason": "Kurze Begründung auf Deutsch"}} — wenn der Kommentar gegen Regeln verstößt"""
|
||
|
||
try:
|
||
r = client.chat.completions.create(
|
||
model="deepseek-v4-flash:cloud",
|
||
messages=[{"role": "user", "content": prompt}],
|
||
max_tokens=150,
|
||
temperature=0.0,
|
||
)
|
||
raw = r.choices[0].message.content.strip()
|
||
# Extract JSON from potential markdown fences
|
||
if "```" in raw:
|
||
raw = raw.split("```")[1]
|
||
if raw.startswith("json"):
|
||
raw = raw[4:]
|
||
data = json.loads(raw.strip())
|
||
status = data.get("status", "published")
|
||
reason = data.get("reason") or None
|
||
if status not in ("published", "blocked"):
|
||
status = "published"
|
||
print(f"[moderate] status={status} reason={reason}", file=sys.stderr)
|
||
return status, reason
|
||
except Exception as e:
|
||
print(f"[moderate] error: {e}", file=sys.stderr)
|
||
return "published", None # Fail open — don't block on API errors
|
||
|
||
|
||
# ── LLM Intake Triage ────────────────────────────────────────────────
|
||
|
||
TRIAGE_PROMPT = """Du bist der automatisierte Eingangs-Prüfer für brettspiel-news.de, ein deutsches Nachrichtenportal für Brettspiele, Tabletop und Trading Cards.
|
||
|
||
Ein Nutzer hat folgende Nachricht eingereicht:
|
||
|
||
---
|
||
{content}
|
||
---
|
||
|
||
Bewerte diese Einreichung und antworte NUR mit diesem JSON (kein Markdown, kein ```json):
|
||
|
||
{{
|
||
"tier": <1, 2 oder 3>,
|
||
"reasons": ["Grund 1", "Grund 2"],
|
||
"is_boardgame_related": <true|false>,
|
||
"language": "<german|english|mixed|other>",
|
||
"has_spam": <true|false>,
|
||
"display_text": "<Der Text, der auf der öffentlichen Pinnwand angezeigt werden soll>",
|
||
"summary_de": "<Ein-Satz-Zusammenfassung auf Deutsch, max 20 Wörter>"
|
||
}}
|
||
|
||
WICHTIG zu display_text:
|
||
- Wenn der Original-Text GUT und AUSSAGEKRÄFTIG ist (≥100 Zeichen, klarer Inhalt, deutsch, Brettspiel-Bezug):
|
||
kopiere ihn 1:1 als display_text.
|
||
- Wenn der Text kurz, unstrukturiert oder noisy ist:
|
||
formuliere einen sauberen, lesbaren Display-Text (2-4 Sätze), der die Kernaussage wiedergibt.
|
||
- 🎤 SPRACHNACHRICHTEN (erkennbar an \"🎤\" am Anfang):
|
||
- Wenn der Inhalt REICHHALTIG ist (≥200 Zeichen, mehrere Fakten, klare News):
|
||
Transkription 1:1 als display_text übernehmen.
|
||
- Wenn der Inhalt DÜNN ist (nur \"Spiel X gibt's an Stand Y für €Z\"):
|
||
Kurze LLM-Zusammenfassung (1 Satz), nicht die ganze Transkription.
|
||
Grund: Die Sprachnachricht selbst wird NICHT veröffentlicht (nur der Text).
|
||
- 🖼️ FOTO + 🎤 SPRACHNACHRICHT kombiniert (erkennbar an \"[Bild ohne Text]\" + \"🎤\"):
|
||
display_text = Transkription der Sprachnachricht (mit Foto-Regeln wie oben).
|
||
Das Foto wird automatisch mitveröffentlicht, die Audio-Datei NICHT.
|
||
- Entferne dabei: Füllwörter, Emoji-Overload, unhöfliche Formulierungen, irrelevante Anhängsel.
|
||
- Der display_text MUSS auf Deutsch sein (englische Eigennamen/Fachbegriffe sind OK).
|
||
- Maximal 500 Zeichen.
|
||
|
||
TIER 1 (🟢 publish_ready) — ALLE Bedingungen erfüllt:
|
||
- Brettspiel/Tabletop-Bezug ist EINDEUTIG (Spielname, Verlag, Messe, Mechanik, Event, Community)
|
||
- Text ist ≥ 80% deutsch (Eigennamen und Fachbegriffe ausgenommen)
|
||
- Mindestens 150 Zeichen nutzbarer, substanzieller Text
|
||
- Nachricht ist klar und verständlich (vollständige Sätze oder klare Stichpunkte)
|
||
- Keine Beleidigungen, Spam, Werbung, Hassrede
|
||
- News-würdig oder community-relevant: Neuerscheinung, Event, Angebot, Frage, Erfahrungsbericht, Tipp
|
||
|
||
TIER 2 (🟡 needs_review) — MINDESTENS ein Mangel, aber KEIN kritisches Problem:
|
||
- MÖGLICHER Brettspiel-Bezug, aber nicht eindeutig ("heute Abend zocken", "mein neues Spiel")
|
||
- Text zwischen 30 und 149 Zeichen (kurz, aber nicht leer)
|
||
- 50-80% deutsch mit englischen Anteilen
|
||
- Unklare Struktur, Telegrammstil, einzelne Wörter
|
||
- #out-Meldung ohne Quelle
|
||
- Reine Frage ("Welches Spiel ist das?") — community-relevant, aber nicht publizierbar
|
||
- Bild/Video ohne oder mit nichtssagendem Text ("schaut mal")
|
||
- Sanfte Eigenwerbung oder Crowdfunding-Link (könnte legitim sein)
|
||
|
||
TIER 3 (🔴 blocked) — MINDESTENS ein kritisches Problem:
|
||
- Beleidigungen, Hassrede, Diskriminierung, Volksverhetzung
|
||
- Harter Spam ("Kauft!", Casino, MLM, Scam, "5000€/Tag")
|
||
- KEIN Brettspiel-Bezug (Fußball, Politik, Wetter, völlig fremdes Thema)
|
||
- < 50% deutsch UND kein Brettspiel-Bezug erkennbar
|
||
- < 30 Zeichen Nutztext (nur "hallo", "test", "👍", Emojis)
|
||
- Illegale Inhalte, Gewalt, Pornografie
|
||
- Private Daten Dritter (Telefonnummern, Adressen)
|
||
- Offensichtlicher Urheberrechtsverstoß (Volltext-Kopie)
|
||
- Verschwörungstheorien, Desinformation
|
||
|
||
WICHTIG:
|
||
- Im Zweifel TIER 2 (lieber redaktionell prüfen als falsch publizieren)
|
||
- Bei TIER 3: Nenne den KRITISCHEN Grund an erster Stelle in reasons
|
||
- is_boardgame_related: true NUR wenn Brettspiel-Bezug eindeutig ist
|
||
- summary_de: Max 20 Wörter, deutsch
|
||
- display_text: Immer liefern, auch bei TIER 3 (dann leerer String "")
|
||
"""
|
||
|
||
|
||
def _check_image_safety(media_path: str) -> list[str]:
|
||
"""Prüft ein Bild via Gemini Flash Vision auf problematische Inhalte.
|
||
Returns: Liste von Problemen (leer = ok)"""
|
||
if not media_path:
|
||
return []
|
||
|
||
import yaml as _yaml
|
||
import openai as _openai
|
||
|
||
for path in media_path.split("||"):
|
||
path = path.strip()
|
||
if not path or not os.path.exists(path):
|
||
continue
|
||
|
||
try:
|
||
import base64 as _b64
|
||
with open(path, "rb") as f:
|
||
img_b64 = _b64.b64encode(f.read()).decode()
|
||
|
||
config_path = os.path.expanduser("~/.hermes/config.yaml")
|
||
with open(config_path) as f:
|
||
cfg = _yaml.safe_load(f)
|
||
api_key = cfg["providers"]["datenhimmel"]["api_key"]
|
||
base_url = cfg["providers"]["datenhimmel"]["base_url"]
|
||
|
||
client = _openai.OpenAI(base_url=base_url, api_key=api_key)
|
||
|
||
resp = client.chat.completions.create(
|
||
model="gemini-3-flash-preview:cloud",
|
||
messages=[{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "text", "text": (
|
||
"Prüfe dieses Bild auf PROBLEMATISCHE Inhalte. "
|
||
"Antworte NUR mit 'OK' oder einer Liste von Problemen (deutsch, kommagetrennt):\n"
|
||
"- Gewalt, Blut, Waffen, Verletzungen\n"
|
||
"- Pornografie, Nacktheit, sexuelle Darstellungen\n"
|
||
"- Nazi-Symbole, Hakenkreuze, SS-Runen, Reichsadler, verfassungsfeindliche Kennzeichen\n"
|
||
"- Illegale Drogen, Drogenkonsum\n"
|
||
"- Persönliche Dokumente (Ausweise, Kreditkarten, Pässe)\n"
|
||
"- Kindeswohlgefährdende Inhalte\n"
|
||
"- Extreme Obszönitäten\n"
|
||
"Wenn das Bild ein normales Brettspiel-Foto, Cover, Messe-Schnappschuss, "
|
||
"Spieletisch oder harmloses Alltagsbild ist, antworte 'OK'.\n"
|
||
"Bei Nazi-Symbolen: Nenne EXPLIZIT, welches Symbol du siehst."
|
||
)},
|
||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
|
||
]
|
||
}],
|
||
temperature=0,
|
||
max_tokens=100,
|
||
)
|
||
|
||
result = resp.choices[0].message.content.strip()
|
||
if result.upper() != "OK":
|
||
return [f"🚨 Bild-Problem: {result}"]
|
||
except Exception as e:
|
||
print(f"[image_safety] Gemini check failed for {path}: {e}", file=sys.stderr)
|
||
return ["⚠️ Bild-Sicherheitsprüfung fehlgeschlagen — manuelle Prüfung nötig"]
|
||
|
||
return []
|
||
|
||
|
||
def triage_submission(db, submission_id: int) -> dict:
|
||
"""Führt die 3-Stufen-Triage für eine Submission durch.
|
||
|
||
Returns: {"tier": int, "reasons": list, "action": str,
|
||
"display_text": str, "auto_response": str|None}
|
||
"""
|
||
import yaml as _yaml
|
||
import openai as _openai
|
||
|
||
row = db.execute(
|
||
"SELECT id, content, media_path, type, channel, sender_id FROM submissions WHERE id=?",
|
||
(submission_id,)
|
||
).fetchone()
|
||
|
||
if not row or not row["content"]:
|
||
return {"tier": 3, "reasons": ["Kein Inhalt"], "action": "block",
|
||
"display_text": "", "auto_response": None}
|
||
|
||
content = row["content"]
|
||
media_path = row["media_path"]
|
||
|
||
# ── Bild-Sicherheitsprüfung ──────────────────────────────────────
|
||
if media_path and row["type"] in ("image", "video"):
|
||
concerns = _check_image_safety(media_path)
|
||
if concerns:
|
||
db.execute(
|
||
"UPDATE submissions SET triage_tier=3, triage_reasons=?, triage_at=CURRENT_TIMESTAMP, public=0, status='blocked' WHERE id=?",
|
||
(json.dumps(concerns, ensure_ascii=False), submission_id),
|
||
)
|
||
db.commit()
|
||
return {"tier": 3, "reasons": concerns, "action": "block",
|
||
"display_text": "", "auto_response": None}
|
||
|
||
# ── API-Client ──────────────────────────────────────────────────
|
||
config_path = os.path.expanduser("~/.hermes/config.yaml")
|
||
try:
|
||
with open(config_path) as f:
|
||
cfg = _yaml.safe_load(f)
|
||
api_key = cfg["providers"]["datenhimmel"]["api_key"]
|
||
base_url = cfg["providers"]["datenhimmel"]["base_url"]
|
||
except Exception:
|
||
print("[triage] Cannot read API config", file=sys.stderr)
|
||
return {"tier": 2, "reasons": ["KI-Prüfung nicht verfügbar"],
|
||
"action": "review", "display_text": content[:500], "auto_response": None}
|
||
|
||
client = _openai.OpenAI(base_url=base_url, api_key=api_key)
|
||
|
||
# ── LLM-Aufruf ──────────────────────────────────────────────────
|
||
prompt = TRIAGE_PROMPT.format(content=content[:3000])
|
||
|
||
try:
|
||
resp = client.chat.completions.create(
|
||
model="deepseek-v4-flash:cloud",
|
||
messages=[{"role": "user", "content": prompt}],
|
||
temperature=0.1,
|
||
max_tokens=800,
|
||
)
|
||
raw = resp.choices[0].message.content.strip()
|
||
except Exception as e:
|
||
print(f"[triage] LLM error: {e}", file=sys.stderr)
|
||
return {"tier": 2, "reasons": [f"KI-Fehler: {str(e)[:100]}"],
|
||
"action": "review", "display_text": content[:500], "auto_response": None}
|
||
|
||
# ── JSON parsen ─────────────────────────────────────────────────
|
||
if "```" in raw:
|
||
raw = raw.split("```")[1]
|
||
if raw.startswith("json"):
|
||
raw = raw[4:]
|
||
|
||
try:
|
||
data = json.loads(raw.strip())
|
||
except json.JSONDecodeError:
|
||
print(f"[triage] JSON parse error, raw: {raw[:200]}", file=sys.stderr)
|
||
return {"tier": 2, "reasons": ["KI-Antwort nicht parsebar"],
|
||
"action": "review", "display_text": content[:500], "auto_response": None}
|
||
|
||
# ── Validieren ──────────────────────────────────────────────────
|
||
tier = data.get("tier", 2)
|
||
if tier not in (1, 2, 3):
|
||
tier = 2
|
||
|
||
reasons = data.get("reasons", [])
|
||
display_text = data.get("display_text", "") or ""
|
||
summary_de = data.get("summary_de", "")
|
||
|
||
# display_text einkürzen falls zu lang
|
||
if len(display_text) > 1000:
|
||
display_text = display_text[:997] + "..."
|
||
|
||
# ── Media-Publish-Flags: Audio nicht veröffentlichen ────────────
|
||
# Regel: Sprachnachrichten (.ogg/.mp3/.wav) sollen NIE auf der
|
||
# Frontpage als Audio-Player erscheinen. Nur die Transkription
|
||
# (display_text) wird gezeigt. Bilder/Videos bleiben sichtbar.
|
||
media_flags = None
|
||
if row["media_path"]:
|
||
paths = row["media_path"].split("||")
|
||
flags = []
|
||
for p in paths:
|
||
ext = os.path.splitext(p)[1].lower()
|
||
if ext in (".ogg", ".mp3", ".wav"):
|
||
flags.append("0") # Audio: nicht veröffentlichen
|
||
else:
|
||
flags.append("1") # Bild/Video: veröffentlichen
|
||
media_flags = ",".join(flags)
|
||
|
||
# ── DB-Update ───────────────────────────────────────────────────
|
||
db.execute(
|
||
"""UPDATE submissions
|
||
SET triage_tier=?, triage_reasons=?, triage_at=CURRENT_TIMESTAMP,
|
||
public=?, status=?, display_text=?, media_publish_flags=?,
|
||
published_at=CASE WHEN ? = 1 THEN COALESCE(published_at, CURRENT_TIMESTAMP) ELSE published_at END
|
||
WHERE id=?""",
|
||
(
|
||
tier,
|
||
json.dumps(reasons, ensure_ascii=False),
|
||
1 if tier == 1 else 0,
|
||
"published" if tier == 1 else ("blocked" if tier == 3 else "pending"),
|
||
display_text,
|
||
media_flags,
|
||
tier,
|
||
submission_id,
|
||
),
|
||
)
|
||
db.commit()
|
||
|
||
# ── Auto-Antwort ────────────────────────────────────────────────
|
||
auto_responses = {
|
||
1: "✅ Danke für deinen Beitrag! Er ist jetzt auf unserer Pinnwand sichtbar.",
|
||
2: "📝 Danke für deine Nachricht! Unser Redaktionsteam prüft sie und schaltet sie ggf. frei.",
|
||
3: None,
|
||
}
|
||
|
||
print(f"[triage] #{submission_id} tier={tier} reasons={reasons} display_len={len(display_text)}", file=sys.stderr)
|
||
|
||
# ── Admin-Benachrichtigung bei Veröffentlichung (Tier 1) ─────────
|
||
if tier == 1:
|
||
sender = row["sender_name"] or row["sender_id"]
|
||
preview = (display_text or content)[:80]
|
||
notify_admin(
|
||
f"✅ Veröffentlicht: {sender}\n"
|
||
f"→ https://chat.datenhimmel.work/admin/{submission_id}"
|
||
)
|
||
|
||
return {
|
||
"tier": tier,
|
||
"reasons": reasons,
|
||
"summary_de": summary_de,
|
||
"display_text": display_text,
|
||
"action": {1: "publish", 2: "review", 3: "block"}[tier],
|
||
"auto_response": auto_responses[tier],
|
||
}
|
||
|
||
|
||
def _run_triage_async(submission_id: int):
|
||
"""Führt die Triage VERZÖGERT aus — wartet 6 Minuten (Thread-Window + Puffer).
|
||
|
||
Das stellt sicher, dass Foto + Sprachnachricht + Text alle im selben
|
||
Thread landen, BEVOR die KI den Inhalt bewertet.
|
||
"""
|
||
import time as _time
|
||
import sqlite3 as _sqlite3
|
||
|
||
THREAD_WINDOW_SEC = int(os.environ.get("TRIAGE_DELAY_SECONDS", "360")) # default: 6 Min
|
||
|
||
print(f"[triage] #{submission_id} geplant in {THREAD_WINDOW_SEC}s", file=sys.stderr)
|
||
_time.sleep(THREAD_WINDOW_SEC)
|
||
|
||
# Direkter SQLite-Connection (ohne Flask-Context, da im Hintergrund-Thread)
|
||
conn = _sqlite3.connect(DB_PATH)
|
||
conn.row_factory = _sqlite3.Row
|
||
try:
|
||
# Prüfen ob der Eintrag inzwischen gelöscht oder schon triagiert wurde
|
||
row = conn.execute(
|
||
"SELECT triage_tier, deleted_at FROM submissions WHERE id=?",
|
||
(submission_id,)
|
||
).fetchone()
|
||
if not row or row["deleted_at"]:
|
||
print(f"[triage] #{submission_id} übersprungen (gelöscht)", file=sys.stderr)
|
||
return
|
||
if row["triage_tier"] is not None:
|
||
print(f"[triage] #{submission_id} übersprungen (schon triagiert)", file=sys.stderr)
|
||
return
|
||
|
||
result = triage_submission(conn, submission_id)
|
||
|
||
# Auto-Antwort senden
|
||
if result.get("auto_response"):
|
||
row2 = conn.execute(
|
||
"SELECT channel, sender_id FROM submissions WHERE id=?",
|
||
(submission_id,)
|
||
).fetchone()
|
||
if row2:
|
||
if row2["channel"] == "whatsapp" and META_TOKEN:
|
||
try:
|
||
send_whatsapp_message(row2["sender_id"], result["auto_response"])
|
||
except Exception as e:
|
||
print(f"[triage] WhatsApp reply failed: {e}", file=sys.stderr)
|
||
elif row2["channel"] == "telegram" and TELEGRAM_TOKEN:
|
||
try:
|
||
requests.post(
|
||
f"{TELEGRAM_API}/sendMessage",
|
||
json={"chat_id": int(row2["sender_id"]),
|
||
"text": result["auto_response"]},
|
||
timeout=10,
|
||
)
|
||
except Exception as e:
|
||
print(f"[triage] Telegram reply failed: {e}", file=sys.stderr)
|
||
|
||
conn.execute(
|
||
"UPDATE submissions SET auto_response_sent=1 WHERE id=?",
|
||
(submission_id,)
|
||
)
|
||
conn.commit()
|
||
except Exception as e:
|
||
print(f"[triage] Async triage failed for {submission_id}: {e}", file=sys.stderr)
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@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 notify_admin(text: str):
|
||
"""Admin-Benachrichtigung per WhatsApp an Daniel senden (non-blocking)."""
|
||
if not META_TOKEN or not ADMIN_PHONE:
|
||
return
|
||
try:
|
||
send_whatsapp_message(ADMIN_PHONE, text)
|
||
print(f"[admin-notify] Gesendet: {text[:80]}", file=sys.stderr)
|
||
except Exception as e:
|
||
print(f"[admin-notify] Fehler: {e}", file=sys.stderr)
|
||
|
||
|
||
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 _send_email(to: str, subject: str, body: str) -> None:
|
||
"""Send an email notification using sendmail."""
|
||
import subprocess
|
||
from email.message import EmailMessage
|
||
msg = EmailMessage()
|
||
msg["From"] = "noreply@brettspiel-news.de"
|
||
msg["To"] = to
|
||
msg["Subject"] = subject
|
||
msg.set_content(body)
|
||
try:
|
||
subprocess.run(
|
||
["/usr/sbin/sendmail", "-t", "-oi"],
|
||
input=msg.as_bytes(),
|
||
timeout=10,
|
||
check=True,
|
||
)
|
||
except Exception:
|
||
pass # silently ignore email failures
|
||
|
||
|
||
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 BSN Assistent 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)
|
||
|
||
|
||
def mark_ausverkauft_in_neuheiten(db, spiel_titel):
|
||
"""Cross-reference ausverkauft submission with neuheiten table.
|
||
Sets bit 0 of neuheiten.flags to mark the game as sold out.
|
||
Returns number of matched neuheiten rows."""
|
||
if not spiel_titel:
|
||
return 0
|
||
try:
|
||
# Use LIKE for fuzzy matching (handles minor spelling variations)
|
||
result = db.execute(
|
||
"UPDATE neuheiten SET flags = flags | 1 WHERE LOWER(titel) LIKE ?",
|
||
(f"%{spiel_titel.lower().strip()}%",),
|
||
)
|
||
db.commit()
|
||
return result.rowcount
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
# ── 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:
|
||
import threading
|
||
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}]"
|
||
|
||
# #out detection: set category to "ausverkauft" and extract game title
|
||
if "#out" in content.lower():
|
||
category_override = "ausverkauft"
|
||
idx = content.lower().find("#out")
|
||
after = content[idx + 4:].strip()
|
||
if after:
|
||
spiel_titel = re.split(r'[.!?\n]| - | – | — | ist | war | in ', after, maxsplit=1)[0].strip()[:200]
|
||
|
||
# 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
|
||
|
||
# FAQ-Trigger: "Keine Antwort" / "Warum keine Nachricht"
|
||
faq_keywords = [
|
||
"keine antwort", "bekomme keine", "keine nachricht",
|
||
"antwort kommt nicht", "warum keine", "keine rückmeldung",
|
||
"why no reply", "not receiving", "save contact", "nummer speichern"
|
||
]
|
||
if msg_type == "text" and any(kw in content.lower() for kw in faq_keywords):
|
||
if META_TOKEN:
|
||
try:
|
||
send_whatsapp_message(
|
||
sender_id,
|
||
"💡 *Tipp:* Damit unsere Antworten bei dir ankommen, "
|
||
"speichere bitte unsere Nummer in deinem Adressbuch. "
|
||
"WhatsApp filtert Nachrichten von unbekannten Business-Accounts "
|
||
"auf manchen Handys leider automatisch heraus.\n\n"
|
||
"Sobald du uns als Kontakt gespeichert hast, klappt's! ✅"
|
||
)
|
||
except Exception:
|
||
pass
|
||
continue
|
||
|
||
# ── Nutzer-Registrierung per WhatsApp ────────────────
|
||
pending_codes = getattr(app, '_pending_codes', {})
|
||
if msg_type == "text":
|
||
clean = content.strip().lower()
|
||
# "registrieren" → Code senden
|
||
if clean == "registrieren":
|
||
import random
|
||
code = str(random.randint(100000, 999999))
|
||
pending_codes[sender_id] = code
|
||
app._pending_codes = pending_codes
|
||
if META_TOKEN:
|
||
try:
|
||
send_whatsapp_message(sender_id,
|
||
f"🔐 Dein Verifikationscode: *{code}*\n\n"
|
||
f"Antworte einfach mit: code {code}")
|
||
except Exception:
|
||
pass
|
||
continue
|
||
# "code 123456" → verifizieren + User anlegen
|
||
if clean.startswith("code ") and len(clean) >= 10:
|
||
entered = clean[5:].strip()
|
||
if sender_id in pending_codes and pending_codes[sender_id] == entered:
|
||
del pending_codes[sender_id]
|
||
app._pending_codes = pending_codes
|
||
# User anlegen — phone_hash statt Klartext
|
||
existing = _lookup_user_by_phone(db, sender_id)
|
||
if not existing:
|
||
phone_hash_val, phone_last4_val = _hash_phone(sender_id)
|
||
db.execute(
|
||
"INSERT INTO users (phone, name, verified, phone_hash, phone_last4) VALUES (?,?,1,?,?)",
|
||
(phone_last4_val, sender_name, phone_hash_val, phone_last4_val),
|
||
)
|
||
db.commit()
|
||
if META_TOKEN:
|
||
try:
|
||
send_whatsapp_message(sender_id,
|
||
"✅ Du bist jetzt registriert! Dein Profil: "
|
||
"https://chat.datenhimmel.work/profile")
|
||
except Exception:
|
||
pass
|
||
else:
|
||
if META_TOKEN:
|
||
try:
|
||
send_whatsapp_message(sender_id,
|
||
"❌ Falscher Code. Schick \"registrieren\" für einen neuen.")
|
||
except Exception:
|
||
pass
|
||
continue
|
||
|
||
# ── Cross-Channel: User-Profil zuordnen (via phone_hash) ──
|
||
user_row = _lookup_user_by_phone(db, sender_id)
|
||
|
||
# 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"]),
|
||
)
|
||
# Check for #out in appended content — update category + spiel_titel
|
||
if "#out" in content.lower():
|
||
out_title = None
|
||
idx = content.lower().find("#out")
|
||
after = content[idx + 4:].strip()
|
||
if after:
|
||
out_title = re.split(r'[.!?\n]| - | – | — | ist | war | in ', after, maxsplit=1)[0].strip()[:200]
|
||
db.execute(
|
||
"UPDATE submissions SET category=?, spiel_titel=? WHERE id=?",
|
||
("ausverkauft", out_title, recent["id"]),
|
||
)
|
||
# Auto-mark matching neuheiten as sold out
|
||
if out_title:
|
||
mark_ausverkauft_in_neuheiten(db, out_title)
|
||
# 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, user_id)
|
||
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?)""",
|
||
("whatsapp", sender_id, sender_name, msg_type, content, media_path, halle, cat, spiel_titel,
|
||
user_row["id"] if user_row else None),
|
||
)
|
||
# Auto-mark matching neuheiten as sold out
|
||
if cat == "ausverkauft" and spiel_titel:
|
||
mark_ausverkauft_in_neuheiten(db, spiel_titel)
|
||
|
||
# ── Admin-Benachrichtigung: Neue Einsendung ──────────
|
||
sub_id = recent["id"] if thread_found else db.execute(
|
||
"SELECT last_insert_rowid()"
|
||
).fetchone()[0]
|
||
content_preview = content.replace("\n", " ")[:80]
|
||
type_emoji = {"text": "💬", "image": "🖼️", "audio": "🎤", "video": "🎬"}.get(msg_type, "📎")
|
||
notify_admin(
|
||
f"{type_emoji} {sender_name}: {content_preview}\n"
|
||
f"→ https://chat.datenhimmel.work/admin/{sub_id}"
|
||
)
|
||
|
||
# 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"💡 Speicher unsere Nummer im Adressbuch, "
|
||
f"damit unsere Antworten sicher ankommen.\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
|
||
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:
|
||
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:
|
||
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:
|
||
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()
|
||
|
||
# LLM-Triage: 3-Stufen-Klassifikation (nur für neue Threads)
|
||
if not thread_found:
|
||
sub_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||
threading.Thread(
|
||
target=_run_triage_async,
|
||
args=(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", "")
|
||
triage_filter = request.args.get("triage", "")
|
||
|
||
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 triage_filter:
|
||
query += " AND triage_tier = ?"
|
||
params.append(int(triage_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",
|
||
}
|
||
triage_emoji = {1: "🟢", 2: "🟡", 3: "🔴"}
|
||
|
||
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"])
|
||
|
||
# Triage badge
|
||
triage_badge = ""
|
||
if r["triage_tier"]:
|
||
emoji = triage_emoji.get(r["triage_tier"], "⚪")
|
||
reasons = json.loads(r["triage_reasons"] or "[]")
|
||
tooltip = " | ".join(reasons) if reasons else "Keine Details"
|
||
triage_badge = f'<span class="triage-badge" title="{tooltip}">{emoji}</span>'
|
||
else:
|
||
triage_badge = '<span class="triage-badge" title="Wartet auf KI-Prüfung" style="opacity:0.4">⏳</span>'
|
||
|
||
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;">{triage_badge}{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"])
|
||
# Triage badge for mobile
|
||
triage_mobile = ""
|
||
if r["triage_tier"]:
|
||
emoji = triage_emoji.get(r["triage_tier"], "⚪")
|
||
reasons = json.loads(r["triage_reasons"] or "[]")
|
||
tooltip = " | ".join(reasons) if reasons else ""
|
||
triage_mobile = f'<span class="triage-badge" title="{tooltip}">{emoji}</span>'
|
||
else:
|
||
triage_mobile = '<span class="triage-badge" title="Wartet auf KI-Prüfung" style="opacity:0.5">⏳</span>'
|
||
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>{triage_mobile}<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-logo-wrap {{ display:flex; align-items:center; gap:0.5rem; }}
|
||
.admin-logo-sub {{ font-size:0.7rem; font-weight:500; color:var(--text3); letter-spacing:0.03em; white-space:nowrap; }}
|
||
.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; }}
|
||
|
||
/* Triage badges */
|
||
.triage-badge {{ display:inline-block; font-size:1rem; cursor:help; margin-right:0.3rem; vertical-align:middle; transition:transform 0.15s; }}
|
||
.triage-badge:hover {{ transform:scale(1.2); }}
|
||
|
||
/* 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">
|
||
<div class="admin-logo-wrap">
|
||
<img src="/static/logo.png" alt="Brettspiel News" style="height:34px;width:auto;">
|
||
<span class="admin-logo-sub">boardgame.network</span>
|
||
</div>
|
||
<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="/admin?triage=1" class="admin-btn{' active' if triage_filter=='1' else ''}" style="background:#22c55e20;color:#22c55e;">🟢 Ready</a>
|
||
<a href="/admin?triage=2" class="admin-btn{' active' if triage_filter=='2' else ''}" style="background:#eab30820;color:#eab308;">🟡 Prüfen</a>
|
||
<a href="/admin?triage=3" class="admin-btn{' active' if triage_filter=='3' else ''}" style="background:#ef444420;color:#ef4444;">🔴 Blockiert</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("/admin/comments")
|
||
def admin_comments():
|
||
"""Admin comment moderation — view all comments, filter by status."""
|
||
db = get_db()
|
||
status_filter = request.args.get("status", "")
|
||
query = """SELECT c.*, s.spiel_titel, s.sender_name AS sub_author
|
||
FROM comments c
|
||
LEFT JOIN submissions s ON c.submission_id = s.id
|
||
WHERE 1=1"""
|
||
params: list = []
|
||
if status_filter:
|
||
query += " AND c.status = ?"
|
||
params.append(status_filter)
|
||
query += " ORDER BY c.created_at DESC LIMIT 300"
|
||
rows = db.execute(query, params).fetchall()
|
||
|
||
status_counts = {
|
||
"all": db.execute("SELECT COUNT(*) FROM comments").fetchone()[0],
|
||
"published": db.execute("SELECT COUNT(*) FROM comments WHERE status='published'").fetchone()[0],
|
||
"blocked": db.execute("SELECT COUNT(*) FROM comments WHERE status='blocked'").fetchone()[0],
|
||
}
|
||
|
||
tabs_html = '<div class="comment-tabs" style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;">'
|
||
for key, label in [("", "Alle"), ("published", "Freigegeben"), ("blocked", "Gesperrt")]:
|
||
active = " style='background:#20228a;color:#fff;'" if status_filter == key else ""
|
||
count = status_counts.get(key or "all", 0)
|
||
tabs_html += f'<a href="/admin/comments?status={key}"{active} class="comment-tab-btn" style="padding:0.4rem 0.9rem;border:1.5px solid #ddd;border-radius:8px;text-decoration:none;font-size:0.8rem;color:#333;">{label} ({count})</a>'
|
||
tabs_html += '</div>'
|
||
|
||
comment_rows = ""
|
||
for c in rows:
|
||
c_content = (c["content"] or "")[:150]
|
||
c_author = (c["author_name"] or "Anonym")
|
||
c_date = c["created_at"] or ""
|
||
sub_label = c["spiel_titel"] or c["sub_author"] or f"#{c['submission_id']}"
|
||
status_badge = '<span style="color:#2e7d32;font-weight:600;">✅ Frei</span>' if c["status"] == "published" else '<span style="color:#c62828;font-weight:600;">🚫 Gesperrt</span>'
|
||
reason_html = f'<br><small style="color:#c62828;">Grund: {c["moderation_reason"]}</small>' if (c["moderation_reason"] if c["moderation_reason"] else None) else ""
|
||
action = f'<a href="#" onclick="toggleComment({c["id"]},\'unblock\')" style="color:#2e7d32;">Freischalten</a>' if c["status"] == "blocked" else f'<a href="#" onclick="toggleComment({c["id"]},\'block\')" style="color:#c62828;">Sperren</a>'
|
||
comment_rows += f"""<tr>
|
||
<td>{c['id']}</td>
|
||
<td><a href="/beitrag/{c['submission_id']}" target="_blank" style="color:#20228a;">{sub_label}</a></td>
|
||
<td>{c_author}</td>
|
||
<td style="max-width:300px;overflow-wrap:break-word;">{c_content}{reason_html}</td>
|
||
<td>{status_badge}</td>
|
||
<td style="font-size:0.7rem;color:#999;">{c_date}</td>
|
||
<td>{action}</td>
|
||
</tr>"""
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||
<title>Kommentar-Moderation — BSN Admin</title>
|
||
<style>
|
||
* {{margin:0;padding:0;box-sizing:border-box;}}
|
||
body {{font-family:'Inter',sans-serif;background:#f5f3ee;color:#1a1a2e;padding:1rem;}}
|
||
h1 {{color:#20228a;margin-bottom:0.5rem;font-size:1.3rem;}}
|
||
table {{width:100%;border-collapse:collapse;font-size:0.85rem;}}
|
||
th {{background:#20228a;color:#fff;padding:0.6rem 0.5rem;text-align:left;font-size:0.75rem;}}
|
||
td {{padding:0.5rem;border-bottom:1px solid #eee;vertical-align:top;}}
|
||
tr:hover {{background:#f8f6f0;}}
|
||
.back-link {{display:inline-block;color:#20228a;text-decoration:none;font-weight:600;margin-bottom:1rem;}}
|
||
.admin-header {{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;}}
|
||
.admin-header img {{height:34px;width:auto;}}
|
||
@media(max-width:700px){{table,thead,tbody,th,td,tr{{display:block;}}th{{display:none;}}tr{{margin-bottom:1rem;border:1px solid #eee;border-radius:8px;padding:0.5rem;}}td{{border:none;padding:0.2rem 0;}}}}
|
||
</style></head>
|
||
<body>
|
||
<div class="admin-header">
|
||
<img src="/static/logo.png" alt="BSN">
|
||
<div><h1>💬 Kommentar-Moderation</h1><a href="/admin" class="back-link">← Zurück zum Admin</a></div>
|
||
</div>
|
||
{tabs_html}
|
||
<script>
|
||
function toggleComment(id, action) {{
|
||
if (!confirm('Kommentar #' + id + ' ' + (action === 'block' ? 'sperren' : 'freischalten') + '?')) return;
|
||
fetch('/admin/comment/' + id + '/' + action, {{method:'POST'}})
|
||
.then(function(r){{return r.json();}})
|
||
.then(function(d){{if(d.ok)location.reload();else alert(d.error);}});
|
||
}}
|
||
</script>
|
||
<table>
|
||
<thead><tr><th>ID</th><th>Beitrag</th><th>Autor</th><th>Inhalt</th><th>Status</th><th>Datum</th><th>Aktion</th></tr></thead>
|
||
<tbody>{comment_rows}</tbody>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
@app.route("/admin/comment/<int:comment_id>/<action>", methods=["POST"])
|
||
def admin_comment_action(comment_id: int, action: str):
|
||
"""Block or unblock a comment."""
|
||
if action not in ("block", "unblock"):
|
||
return jsonify({"error": "Ungültige Aktion"}), 400
|
||
db = get_db()
|
||
row = db.execute("SELECT id, status FROM comments WHERE id = ?", (comment_id,)).fetchone()
|
||
if not row:
|
||
return jsonify({"error": "Kommentar nicht gefunden"}), 404
|
||
new_status = "blocked" if action == "block" else "published"
|
||
db.execute("UPDATE comments SET status = ?, moderation_reason = ? WHERE id = ?",
|
||
(new_status, "Admin-Manuell" if action == "block" else None, comment_id))
|
||
db.commit()
|
||
return jsonify({"ok": True, "status": new_status})
|
||
|
||
|
||
@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="/static/logo.png" 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
|
||
|
||
triage_emoji = {1: "🟢", 2: "🟡", 3: "🔴"}
|
||
triage_labels = {1: "Sofort veröffentlichungsfähig", 2: "Prüfung nötig", 3: "NICHT veröffentlichbar"}
|
||
|
||
# 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:
|
||
basename = os.path.basename(path)
|
||
src = f"/media/{basename}"
|
||
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;" loading="lazy">'
|
||
elif mime.startswith("video/"):
|
||
thumb = generate_video_thumbnail(path)
|
||
poster_attr = f' poster="/media/{os.path.basename(thumb)}"' if thumb else ""
|
||
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>
|
||
{f'''<div class="field"><div class="field-label">🤖 KI-Bewertung</div><div class="field-value"><span style="font-size:1.2rem;">{triage_emoji.get(row['triage_tier'], '⚪')}</span> {'<span style="color:#ff453a;font-weight:600;">' if row['triage_tier']==3 else ''}{triage_labels.get(row['triage_tier'], 'Ungeprüft')}{'</span>' if row['triage_tier']==3 else ''}</div></div>''' if row['triage_tier'] else ''}
|
||
{f'''<div class="field"><div class="field-label">📋 Begründung</div><div class="field-value" style="color:var(--text2);">{'<br>'.join(json.loads(row['triage_reasons'] or '[]'))}</div></div>''' if row['triage_reasons'] else ''}
|
||
<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; if (data.halle) {{ var hi = document.querySelector('input[name="halle"]'); if (hi) hi.value = data.halle; }} }}
|
||
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', public=1, triage_tier=1, published_at=COALESCE(published_at, CURRENT_TIMESTAMP) WHERE id=?",
|
||
(sub_id,),
|
||
)
|
||
# Admin-Benachrichtigung
|
||
row = db.execute(
|
||
"SELECT sender_name, sender_id FROM submissions WHERE id=?", (sub_id,)
|
||
).fetchone()
|
||
if row:
|
||
sender = row["sender_name"] or row["sender_id"]
|
||
notify_admin(
|
||
f"📢 Manuell veröffentlicht: {sender}\n"
|
||
f"→ https://chat.datenhimmel.work/admin/{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 BSN Assistent 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()
|
||
# Extrahiere Halle aus dem Content und speichere in DB
|
||
halle = extract_halle(content)
|
||
if halle:
|
||
db.execute(
|
||
"UPDATE submissions SET summary=?, halle=? WHERE id=?",
|
||
(summary, halle, sub_id),
|
||
)
|
||
else:
|
||
db.execute(
|
||
"UPDATE submissions SET summary=? WHERE id=?",
|
||
(summary, sub_id),
|
||
)
|
||
db.commit()
|
||
return jsonify({"summary": summary, "halle": halle or ""})
|
||
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>"""
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# Nutzerprofil-System (Cross-Channel Identity)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
import hashlib as _hashlib
|
||
|
||
def _hash_password(password: str) -> str:
|
||
"""SHA-256 mit Salt."""
|
||
salt = secrets.token_hex(16)
|
||
h = _hashlib.sha256((salt + password).encode()).hexdigest()
|
||
return f"{salt}${h}"
|
||
|
||
def _check_password(password: str, stored: str) -> bool:
|
||
"""Vergleicht Passwort mit gespeichertem Hash."""
|
||
try:
|
||
salt, h = stored.split("$", 1)
|
||
return _hashlib.sha256((salt + password).encode()).hexdigest() == h
|
||
except Exception:
|
||
return False
|
||
|
||
def _hash_phone(phone: str) -> tuple:
|
||
"""Hash Telefonnummer für DB-Speicherung. Returns (hash, last4)."""
|
||
phone_clean = re.sub(r'[^0-9+]', '', phone)
|
||
salt = secrets.token_hex(8)
|
||
h = _hashlib.sha256((salt + phone_clean).encode()).hexdigest()
|
||
return (f"{salt}${h}", phone_clean[-4:])
|
||
|
||
def _match_phone_hash(phone: str, stored_hash: str) -> bool:
|
||
"""Prüft ob Telefonnummer zum gespeicherten Hash passt."""
|
||
phone_clean = re.sub(r'[^0-9+]', '', phone)
|
||
try:
|
||
salt, h = stored_hash.split("$", 1)
|
||
return _hashlib.sha256((salt + phone_clean).encode()).hexdigest() == h
|
||
except Exception:
|
||
return False
|
||
|
||
def _lookup_user_by_phone(db, phone: str) -> dict | None:
|
||
"""Findet User anhand Telefonnummer (via phone_hash Vergleich)."""
|
||
phone_clean = re.sub(r'[^0-9+]', '', phone)
|
||
rows = db.execute("SELECT * FROM users WHERE phone_hash IS NOT NULL").fetchall()
|
||
for row in rows:
|
||
if _match_phone_hash(phone_clean, row["phone_hash"]):
|
||
return dict(row)
|
||
return None
|
||
|
||
def _create_session(db, user_id: int, channel: str = "web") -> str:
|
||
"""Erstellt Session-Token (gültig 30 Tage)."""
|
||
token = secrets.token_urlsafe(32)
|
||
db.execute(
|
||
"INSERT INTO user_sessions (user_id, token, channel, expires_at) VALUES (?,?,?,datetime('now','+30 days'))",
|
||
(user_id, token, channel),
|
||
)
|
||
db.commit()
|
||
return token
|
||
|
||
def _get_user_from_session(db) -> dict | None:
|
||
"""Liest User aus Session-Cookie."""
|
||
token = request.cookies.get("bsn_session", "")
|
||
if not token:
|
||
return None
|
||
row = db.execute(
|
||
"SELECT u.* FROM users u JOIN user_sessions s ON u.id=s.user_id "
|
||
"WHERE s.token=? AND s.expires_at > datetime('now')",
|
||
(token,),
|
||
).fetchone()
|
||
return dict(row) if row else None
|
||
|
||
# ── HTML-Templates (plain strings, no f-string needed) ──────────────
|
||
|
||
_REGISTER_CONTENT = """<h1>📋 Registrieren</h1>
|
||
{error_html}
|
||
<form method="post">
|
||
<input name="email" placeholder="E-Mail (oder Telefon)" value="{email_val}" required>
|
||
<div class="hint">E-Mail oder Telefonnummer (+49...)</div>
|
||
<input name="name" placeholder="Dein Name" required>
|
||
<input name="password" type="password" placeholder="Passwort (min. 4 Zeichen)" required>
|
||
<button class="btn" type="submit">Registrieren</button>
|
||
</form>
|
||
<div class="link">Bereits registriert? <a href="/login">Einloggen</a></div>"""
|
||
|
||
_LOGIN_CONTENT = """<h1>🔐 Login</h1>
|
||
{error_html}
|
||
<form method="post">
|
||
<input name="login_id" placeholder="E-Mail (oder Telefon)" value="{login_val}" required>
|
||
<input name="password" type="password" placeholder="Passwort" required>
|
||
<button class="btn" type="submit">Einloggen</button>
|
||
</form>
|
||
<div class="link">Noch nicht registriert? <a href="/register">Registrieren</a></div>"""
|
||
|
||
_PROFILE_CONTENT = """<h1>👤 Profil</h1>
|
||
<p>Telefon: {phone}</p>
|
||
<p style="color:#9CA3AF;font-size:0.8rem;">(Letzte 4 Ziffern: {phone_display})</p>
|
||
<a href="/logout" class="btn btn-secondary" style="margin-top:1rem;">Abmelden</a>"""
|
||
|
||
|
||
@app.route("/register", methods=["GET", "POST"])
|
||
def user_register():
|
||
db = get_db()
|
||
error = ""
|
||
if request.method == "POST":
|
||
login_id = request.form.get("email", "").strip() # E-Mail ODER Telefon
|
||
name = request.form.get("name", "").strip()
|
||
password = request.form.get("password", "").strip()
|
||
if not login_id or not name or len(password) < 4:
|
||
error = "Bitte E-Mail/Telefon, Name und Passwort (min. 4 Zeichen) angeben."
|
||
return _REGISTER_TPL.replace("{email_val}", login_id).replace("{error_html}",
|
||
"<div class='error'>" + error + "</div>" if error else "")
|
||
# Prüfen ob E-Mail oder Telefon
|
||
is_email = "@" in login_id
|
||
if is_email:
|
||
if db.execute("SELECT id FROM users WHERE email=?", (login_id,)).fetchone():
|
||
error = "Diese E-Mail ist bereits registriert."
|
||
return _overlay_page("Registrieren", _REGISTER_CONTENT.replace("{email_val}", login_id).replace("{error_html}",
|
||
"<div class='error'>" + error + "</div>"), "400px")
|
||
else:
|
||
existing = _lookup_user_by_phone(db, login_id)
|
||
if existing:
|
||
error = "Diese Telefonnummer ist bereits registriert."
|
||
return _overlay_page("Registrieren", _REGISTER_CONTENT.replace("{email_val}", login_id).replace("{error_html}",
|
||
"<div class='error'>" + error + "</div>"), "400px")
|
||
# User anlegen
|
||
pw_hash = _hash_password(password)
|
||
phone_hash_val = None
|
||
phone_last4_val = None
|
||
email_val = None
|
||
if is_email:
|
||
email_val = login_id
|
||
else:
|
||
phone_hash_val, phone_last4_val = _hash_phone(login_id)
|
||
# phone column: NUR phone_last4 für nicht-Email-Registrierung, NULL für Email-Nutzer
|
||
phone_display = phone_last4_val if not is_email else None
|
||
db.execute(
|
||
"INSERT INTO users (phone, name, password_hash, verified, email, phone_hash, phone_last4) "
|
||
"VALUES (?,?,?,1,?,?,?)",
|
||
(phone_display, name, pw_hash, email_val, phone_hash_val, phone_last4_val),
|
||
)
|
||
db.commit()
|
||
user_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||
token = _create_session(db, user_id)
|
||
from flask import make_response, redirect as _redir
|
||
resp = make_response(_redir("/profile"))
|
||
resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True)
|
||
return resp
|
||
login_id = request.form.get("email", "")
|
||
return _overlay_page("Registrieren", _REGISTER_CONTENT.replace("{email_val}", login_id).replace("{error_html}", ""), "400px")
|
||
|
||
|
||
@app.route("/login", methods=["GET", "POST"])
|
||
def user_login():
|
||
db = get_db()
|
||
error = ""
|
||
if request.method == "POST":
|
||
login_id = request.form.get("login_id", "").strip()
|
||
password = request.form.get("password", "").strip()
|
||
user = None
|
||
is_email = "@" in login_id
|
||
if is_email:
|
||
user = db.execute("SELECT * FROM users WHERE email=?", (login_id,)).fetchone()
|
||
else:
|
||
user = _lookup_user_by_phone(db, login_id)
|
||
if not user or not _check_password(password, user["password_hash"] or ""):
|
||
error = "E-Mail/Telefon oder Passwort falsch."
|
||
else:
|
||
token = _create_session(db, user["id"])
|
||
from flask import make_response, redirect as _redir
|
||
resp = make_response(_redir("/profile"))
|
||
resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True)
|
||
return resp
|
||
login_val = request.form.get("login_id", "")
|
||
error_html = "<div class='error'>" + error + "</div>" if error else ""
|
||
return _overlay_page("Login", _LOGIN_CONTENT.replace("{login_val}", login_val).replace("{error_html}", error_html), "400px")
|
||
|
||
|
||
@app.route("/logout")
|
||
def user_logout():
|
||
token = request.cookies.get("bsn_session", "")
|
||
if token:
|
||
db = get_db()
|
||
db.execute("DELETE FROM user_sessions WHERE token=?", (token,))
|
||
db.commit()
|
||
from flask import make_response, redirect as _redir
|
||
resp = make_response(_redir("/"))
|
||
resp.set_cookie("bsn_session", "", max_age=0)
|
||
return resp
|
||
|
||
|
||
@app.route("/profile", methods=["GET", "POST"])
|
||
def user_profile():
|
||
db = get_db()
|
||
user = _get_user_from_session(db)
|
||
if not user:
|
||
from flask import redirect as _redir
|
||
return _redir("/login")
|
||
msg = ""
|
||
if request.method == "POST":
|
||
name = request.form.get("name", "").strip()
|
||
if name:
|
||
db.execute("UPDATE users SET name=? WHERE id=?", (name, user["id"]))
|
||
db.commit()
|
||
msg = "✅ Profil aktualisiert."
|
||
msg_html = "<div class='msg'>" + msg + "</div>" if msg else ""
|
||
phone_display = "✱✱✱✱" + (user.get("phone_last4") or "") if user.get("phone_last4") else "nicht hinterlegt"
|
||
email_display = user.get("email") or "nicht hinterlegt"
|
||
phone_display_str = "✱✱✱✱" + (user.get("phone_last4") or "") if user.get("phone_last4") else "nicht hinterlegt"
|
||
content = _PROFILE_CONTENT.replace("{phone}", phone_display_str).replace("{phone_display}", phone_display_str)
|
||
return _overlay_page("Profil", content, "400px")
|
||
|
||
|
||
@app.route("/")
|
||
def public_frontend():
|
||
"""Public frontend showing published submissions — SPIEL Essen inspired."""
|
||
import base64
|
||
db = get_db()
|
||
user = _get_user_from_session(db) # für Login-Dropdown in Nav
|
||
|
||
# Hilfsfunktion: Login-Dropdown-HTML bauen
|
||
def _build_login_dropdown(u):
|
||
"""Baut das Login-Dropdown + Filter-Toggle für den Header."""
|
||
h = '<div class="header-right">'
|
||
h += '<a href="https://brettspiel-news.de" class="header-bsn-link">brettspiel-news.de</a>'
|
||
h += '<button id="filterToggle" class="filter-toggle-btn" aria-label="Filter">Filter</button>'
|
||
if u:
|
||
name = (u.get("name") or u.get("email") or "User")[:14]
|
||
h += '<div class="login-dropdown">'
|
||
h += '<button id="loginToggle" class="login-toggle-btn" aria-label="Benutzermenü"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:4px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' + name + '</button>'
|
||
h += '<div class="login-dropdown-menu" id="loginDropdown">'
|
||
h += '<a href="/profile"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:6px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Profil</a>'
|
||
h += '<a href="/logout"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:6px;"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>Abmelden</a>'
|
||
h += '</div></div>'
|
||
else:
|
||
h += '<div class="login-dropdown">'
|
||
h += '<button id="loginToggle" class="login-toggle-btn" aria-label="Login"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>'
|
||
h += '<div class="login-dropdown-menu" id="loginDropdown">'
|
||
h += '<form method="post" action="/login" class="login-dropdown-form">'
|
||
h += '<input name="login_id" placeholder="E-Mail oder Telefon" required>'
|
||
h += '<input name="password" type="password" placeholder="Passwort" required>'
|
||
h += '<button type="submit">Einloggen</button>'
|
||
h += '</form>'
|
||
h += '<a href="/register" class="login-dropdown-register">📋 Neu hier? Registrieren</a>'
|
||
h += '</div></div>'
|
||
h += '</div>' # close header-right
|
||
return h
|
||
|
||
# Filters
|
||
halle_filter = request.args.get("halle", "").strip()
|
||
cat_filter = request.args.get("cat", "").strip()
|
||
sort = request.args.get("sort", "neu").strip()
|
||
|
||
query = ("SELECT s.*, (SELECT COUNT(*) FROM comments c WHERE c.submission_id = s.id AND c.status='published') AS comment_count "
|
||
"FROM submissions s WHERE s.status='published' AND s.deleted_at IS NULL AND s.public=1 AND s.triage_tier=1")
|
||
params = []
|
||
if halle_filter:
|
||
query += " AND s.halle LIKE ?"
|
||
params.append(f"{halle_filter}%")
|
||
if cat_filter:
|
||
query += " AND s.category = ?"
|
||
params.append(cat_filter)
|
||
if sort == "beliebt":
|
||
query += " ORDER BY likes DESC, views DESC, published_at DESC LIMIT 50"
|
||
elif sort == "gesehen":
|
||
query += " ORDER BY views DESC, published_at DESC LIMIT 50"
|
||
else:
|
||
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' AND public=1 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' AND public=1 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, maximum-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||
<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"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#20228a" stroke-width="2.5" stroke-linecap="round" style="vertical-align:middle;margin-right:2px;"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> 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> '
|
||
# Sort dropdown
|
||
filter_html += '<span class="filter-label" style="margin-left:0.8rem;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#20228a" stroke-width="2.5" stroke-linecap="round" style="vertical-align:middle;margin-right:2px;"><polyline points="6 9 12 15 18 9"/></svg> Sortierung:</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}">'
|
||
if cat_filter:
|
||
filter_html += f'<input type="hidden" name="cat" value="{cat_filter}">'
|
||
filter_html += '<select name="sort" onchange="this.form.submit()" class="hall-select">'
|
||
filter_html += f'<option value="neu" {"selected" if sort == "neu" else ""}>Neueste zuerst</option>'
|
||
filter_html += f'<option value="beliebt" {"selected" if sort == "beliebt" else ""}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:3px;"><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/><path d="M7 11h11.5a2.5 2.5 0 0 1 2.5 2.5v.5a2.5 2.5 0 0 1-2.5 2.5h-2.5"/><path d="M16 16.5V19a3 3 0 0 1-6 1.5L7 11"/></svg> Beliebteste</option>'
|
||
filter_html += f'<option value="gesehen" {"selected" if sort == "gesehen" else ""}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:3px;"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Meist gesehen</option>'
|
||
filter_html += '</select></form> '
|
||
|
||
if halle_filter or cat_filter or sort != "neu":
|
||
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
|
||
basename = os.path.basename(path)
|
||
_, ext = os.path.splitext(path)
|
||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
||
try:
|
||
if mime.startswith("image/"):
|
||
# Use thumbnail for frontend cards
|
||
thumb = generate_image_thumbnail(path)
|
||
if thumb:
|
||
img_src = f"/media/{os.path.basename(thumb)}"
|
||
else:
|
||
img_src = f"/media/{basename}"
|
||
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="' + img_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)
|
||
thumb_src = f"/media/{os.path.basename(thumb)}" if thumb else ""
|
||
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" loading="lazy" 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"
|
||
|
||
# Avatar: Initialen im Kreis
|
||
initials = "".join(w[0].upper() for w in name.split() if w)[:2] or "?"
|
||
avatar_html = f'<span class="author-avatar">{initials}</span>'
|
||
|
||
summary_text = (r["display_text"] or r["summary"] or r["content"] or "")
|
||
# Kurzer Teaser: ersten Satz oder max 80 Zeichen
|
||
first_sentence = summary_text.split(".")[0].strip()
|
||
if len(first_sentence) > 80:
|
||
first_sentence = summary_text[:77] + "..."
|
||
elif len(summary_text) > len(first_sentence) + 3:
|
||
first_sentence += "."
|
||
preview = first_sentence
|
||
date_str = (r["published_at"] or r["created_at"] or "")
|
||
# Zeitformat: dezent, dimmed gray
|
||
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'vor {diff.seconds // 60} Min.'
|
||
else:
|
||
time_html = f'heute {dt.strftime("%H:%M")}'
|
||
elif diff.days == 1:
|
||
time_html = f'gestern {dt.strftime("%H:%M")}'
|
||
elif diff.days < 7:
|
||
time_html = f'vor {diff.days} Tagen'
|
||
else:
|
||
time_html = dt.strftime("%d.%m.%y %H:%M")
|
||
except:
|
||
pass
|
||
|
||
# Metadata-Leiste: Avatar + Autor · Zeit · Views · Comments · Like
|
||
meta_html = f"""<div class="card-meta">
|
||
<div class="meta-author">{avatar_html} <span class="meta-author-name">{name}</span><span class="meta-date">{time_html}</span></div>
|
||
<div class="meta-spacer"></div>
|
||
<span class="card-meta-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="1.5" stroke-linecap="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>{r['views'] or 0}</span>
|
||
<span class="card-meta-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="1.5" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>{r['comment_count'] or 0}</span>
|
||
<button class="stat-like-btn" data-id="{r['id']}" data-likes="{r['likes'] or 0}"><svg width="14" height="14" viewBox="0 0 24 24" fill="#EF4444" stroke="#EF4444" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> <span class="like-count">{r['likes'] or 0}</span></button>
|
||
</div>"""
|
||
|
||
is_ausverkauft = r["category"] == "ausverkauft" and r["spiel_titel"]
|
||
|
||
# Badge-Titel vorbereiten (vermeidet Escaping-Probleme)
|
||
badge_title = f'<span class="badge-title"> · {r["spiel_titel"]}</span>' if r["spiel_titel"] else ""
|
||
|
||
if not media_first:
|
||
# Text-only card
|
||
cards += f"""<a href="/beitrag/{r['id']}" class="card-link">
|
||
<article class="story-card text-only">
|
||
{f'<div class="story-halle-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#D1D5DB" stroke-width="2" stroke-linecap="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> {r["halle"]}</div>' if r["halle"] else ''}
|
||
{f'<div class="story-ausverkauft-badge"><span class="badge-icon">🚫</span> AUSVERKAUFT{badge_title}</div>' if r["category"] == "ausverkauft" else ''}
|
||
<div class="card-content">
|
||
<p class="story-preview">{preview}</p>
|
||
{meta_html}
|
||
</div>
|
||
</article>
|
||
</a>"""
|
||
else:
|
||
# Media card
|
||
cards += f"""<article class="story-card">
|
||
{media_first}
|
||
{f'<div class="story-halle-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#D1D5DB" stroke-width="2" stroke-linecap="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> {r["halle"]}</div>' if r["halle"] else ''}
|
||
{f'<div class="story-ausverkauft-badge"><span class="badge-icon">🚫</span> AUSVERKAUFT{badge_title}</div>' if r["category"] == "ausverkauft" else ''}
|
||
<div class="card-content">
|
||
<p class="story-preview">{preview}</p>
|
||
{meta_html}
|
||
</div>
|
||
</article>"""
|
||
|
||
return ("""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||
<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:#F9FAFB; color:#1F2937; min-height:100vh; -webkit-font-smoothing:antialiased; }
|
||
|
||
/* Header — clean anthracite */
|
||
.site-header { background:#1F2937; padding:0.75rem 1.2rem; display:flex; align-items:center; flex-wrap:wrap; gap:0.5rem; position:sticky; top:0; z-index:100; box-shadow:0 1px 3px rgba(0,0,0,0.08); }
|
||
.site-logo { display:flex; align-items:center; gap:0.5rem; text-decoration:none; color:#F9FAFB; min-width:0; flex-shrink:1; }
|
||
.site-logo .logo-icon { width:36px; height:36px; background:#374151; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:1.3rem; flex-shrink:0; }
|
||
.site-logo .logo-text { font-weight:800; font-size:1.05rem; letter-spacing:-0.01em; color:#F9FAFB; white-space:nowrap; }
|
||
.site-logo .logo-sub { font-weight:400; color:#9CA3AF; font-size:0.75rem; }
|
||
.site-logo-sub { font-size:0.68rem; font-weight:500; color:#9CA3AF; letter-spacing:0.03em; margin-left:0.3rem; white-space:normal; word-break:break-word; }
|
||
.site-header .header-right { margin-left:auto; }
|
||
.site-header .header-right a { color:#9CA3AF; text-decoration:none; font-size:0.8rem; transition:color 0.2s; }
|
||
.site-header .header-right a:hover { color:#F9FAFB; }
|
||
.filter-toggle-btn { background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.12); border-radius:8px; color:#D1D5DB; font-size:0.78rem; font-weight:600; cursor:pointer; padding:0.35rem 0.7rem; line-height:1.3; transition:all 0.15s; margin-left:0.5rem; font-family:inherit; }
|
||
.filter-toggle-btn:hover { background:rgba(255,255,255,0.15); border-color:rgba(255,255,255,0.25); }
|
||
.filter-toggle-btn.active { background:rgba(255,255,255,0.2); border-color:#D1D5DB; }
|
||
.filter-collapsed { display:none; }
|
||
.filter-backdrop { position:fixed; inset:0; z-index:100000; background:rgba(0,0,0,0.2); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px); display:none; }
|
||
.filter-backdrop.open { display:flex; align-items:flex-start; justify-content:center; padding-top:4rem; }
|
||
.filter-overlay { background:#fff; border-radius:16px; box-shadow:0 12px 48px rgba(0,0,0,0.12); padding:1.2rem 1.5rem; width:90vw; max-width:600px; animation:fadeDown 0.2s ease; }
|
||
|
||
/* Offcanvas navigation */
|
||
.nav-toggle { background:none; border:none; color:#D1D5DB; font-size:1.3rem; cursor:pointer; padding:0.3rem 0.4rem; line-height:1; margin-right:0.5rem; transition:opacity 0.2s; }
|
||
.nav-toggle:hover { opacity:0.7; }
|
||
.offcanvas-backdrop { position:fixed; inset:0; background:rgba(0,0,0,0.3); z-index:200; opacity:0; pointer-events:none; transition:opacity 0.3s ease; }
|
||
.offcanvas-backdrop.open { opacity:1; pointer-events:auto; }
|
||
.offcanvas-nav { position:fixed; top:0; left:0; width:280px; height:100%; height:100dvh; background:#fff; z-index:201; transform:translateX(-100%); transition:transform 0.3s cubic-bezier(0.4,0,0.2,1); box-shadow:4px 0 24px rgba(0,0,0,0.1); display:flex; flex-direction:column; }
|
||
.offcanvas-nav.open { transform:translateX(0); }
|
||
.offcanvas-nav-header { background:#1F2937; color:#F9FAFB; padding:1.2rem 1.5rem; display:flex; align-items:center; justify-content:space-between; }
|
||
.offcanvas-nav-header .nav-logo { font-weight:800; font-size:1rem; }
|
||
.offcanvas-nav-header .nav-close { background:none; border:none; color:#D1D5DB; font-size:1.4rem; cursor:pointer; line-height:1; }
|
||
.offcanvas-nav-links { list-style:none; padding:1rem 0; flex:1; }
|
||
.offcanvas-nav-links li { }
|
||
.offcanvas-nav-links a { display:block; padding:0.75rem 1.5rem; color:#1F2937; text-decoration:none; font-size:0.95rem; font-weight:500; transition:background 0.15s; }
|
||
.offcanvas-nav-links a:hover { background:#F3F4F6; }
|
||
.offcanvas-nav-links .nav-section { padding:0.5rem 1.5rem 0.2rem; font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#9CA3AF; }
|
||
.offcanvas-nav-footer { padding:1rem 1.5rem; border-top:1px solid #E5E7EB; font-size:0.75rem; color:#9CA3AF; }
|
||
|
||
/* Main grid — single column (mobile-first) */
|
||
.main-grid { max-width:640px; margin:1rem auto; padding:0 0.8rem; display:flex; flex-direction:column; gap:1rem; }
|
||
|
||
/* Card — clean, more whitespace */
|
||
.story-card { background:#fff; border-radius:16px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.04),0 1px 2px rgba(0,0,0,0.03); transition:box-shadow 0.2s ease; position:relative; }
|
||
.story-card:hover { box-shadow:0 4px 16px rgba(0,0,0,0.06); }
|
||
.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 audio { width:100%; margin-top:0.5rem; }
|
||
|
||
/* Hall badge — anthracite, light text */
|
||
.story-halle-badge { position:absolute; top:0.5rem; right:0.5rem; background:#1F2937; color:#F9FAFB; padding:0.2rem 0.55rem; border-radius:6px; font-size:0.72rem; font-weight:600; letter-spacing:0.02em; z-index:5; }
|
||
.story-halle-badge svg { stroke:#D1D5DB !important; }
|
||
|
||
/* Ausverkauft badge — keep red but cleaner */
|
||
.story-ausverkauft-badge { position:absolute; top:0; left:0; right:0; z-index:7; background:linear-gradient(180deg,#EF4444 0%,#DC2626 100%); color:#fff; padding:0.4rem 1rem; font-size:0.75rem; font-weight:700; letter-spacing:0.08em; text-align:center; box-shadow:0 2px 8px rgba(220,38,38,0.3); display:flex; align-items:center; justify-content:center; gap:0.4rem; flex-wrap:wrap; }
|
||
.story-ausverkauft-badge .badge-icon { font-size:0.9rem; }
|
||
.story-ausverkauft-badge .badge-title { font-weight:500; letter-spacing:0.01em; font-size:0.7rem; opacity:0.85; }
|
||
|
||
/* Card content — more whitespace */
|
||
.card-content { padding:0.75rem 1rem 0.7rem; display:flex; flex-direction:column; gap:0.35rem; }
|
||
|
||
/* Time — at top, small, dimmed */
|
||
.story-time { font-size:0.7rem; color:#9CA3AF; font-weight:400; letter-spacing:0.01em; }
|
||
.story-time-rel { color:#9CA3AF; font-weight:400; }
|
||
|
||
/* Time overlay (on media cards) — subtle */
|
||
.story-time-overlay { position:absolute; top:0; left:0; padding:0.35rem 0.6rem; z-index:4; font-size:0.72rem; color:#6B7280; font-weight:500; letter-spacing:0.01em; }
|
||
|
||
/* Preview text */
|
||
.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.85rem; line-height:1.55; color:#4B5563; margin-bottom:0; }
|
||
.text-only .card-content { padding:0.8rem 1rem 0.7rem; }
|
||
|
||
/* Text-only card border */
|
||
.text-only { border:1px solid #F3F4F6; }
|
||
|
||
/* Card link */
|
||
.card-link { text-decoration:none; color:inherit; display:block; }
|
||
|
||
/* Author avatar */
|
||
.author-avatar { width:24px; height:24px; border-radius:50%; background:#E5E7EB; color:#6B7280; display:inline-flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:700; letter-spacing:0.02em; flex-shrink:0; }
|
||
|
||
/* Metadata row — ONE clean line at bottom */
|
||
.card-meta { display:flex; align-items:center; gap:0.5rem; padding-top:0.5rem; margin-top:0.35rem; border-top:1px solid #F3F4F6; flex-wrap:wrap; }
|
||
.card-meta-item { display:flex; align-items:center; gap:0.2rem; font-size:0.72rem; color:#9CA3AF; }
|
||
.card-meta-item svg { stroke:#9CA3AF; }
|
||
.meta-author { display:flex; align-items:center; gap:0.35rem; color:#6B7280; font-size:0.75rem; font-weight:500; }
|
||
.meta-author-name { font-weight:500; }
|
||
.meta-date { font-size:0.68rem; color:#9CA3AF; font-weight:400; margin-left:0.5rem; }
|
||
.meta-spacer { flex:1; }
|
||
|
||
/* Like button — subtle outline */
|
||
.stat-like-btn { background:none; border:1px solid #E5E7EB; border-radius:999px; padding:0.25rem 0.6rem; font-size:0.72rem; cursor:pointer; color:#9CA3AF; font-family:inherit; transition:all 0.15s; display:flex; align-items:center; gap:0.2rem; -webkit-tap-highlight-color:transparent; min-width:44px; min-height:36px; justify-content:center; }
|
||
.stat-like-btn svg, .stat-like-btn svg path { stroke:#EF4444 !important; fill:#EF4444 !important; }
|
||
.stat-like-btn:hover { border-color:#9CA3AF; color:#6B7280; }
|
||
.stat-like-btn.liked { background:#EF4444; color:#fff; border-color:#EF4444; }
|
||
.stat-like-btn.liked svg, .stat-like-btn.liked svg path { stroke:#fff !important; fill:#fff !important; }
|
||
.stat-like-btn:active { transform:scale(0.96); }
|
||
|
||
/* Filter bar */
|
||
.frontend-filter { max-width:640px; margin:0.5rem auto 0; padding:0 0.8rem; display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; }
|
||
.frontend-filter .filter-label { font-size:0.72rem; color:#9CA3AF; 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:#6B7280; border-color:#E5E7EB; }
|
||
.frontend-filter a.filter-pill:hover { border-color:#9CA3AF; color:#374151; }
|
||
.frontend-filter a.filter-pill.active { background:#1F2937; color:#fff; border-color:#1F2937; font-weight:700; }
|
||
.frontend-filter a.filter-reset { color:#9CA3AF; font-size:0.75rem; }
|
||
.hall-select { appearance:none; -webkit-appearance:none; background:#fff; color:#374151; border:1.5px solid #E5E7EB; 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='%239CA3AF' 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:#9CA3AF; }
|
||
.hall-select:focus { outline:none; border-color:#6B7280; box-shadow:0 0 0 3px rgba(107,114,128,0.1); }
|
||
|
||
/* Footer */
|
||
.site-footer { text-align:center; padding:2rem; border-top:1px solid #E5E7EB; margin-top:2rem; }
|
||
.site-footer p { color:#9CA3AF; font-size:0.8rem; }
|
||
.site-footer a { color:#6B7280; text-decoration:none; font-weight:600; }
|
||
.site-footer a:hover { text-decoration:underline; }
|
||
|
||
/* 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 */
|
||
.media-preview-link { display:block; text-decoration:none; }
|
||
.media-preview-link:hover .media-crop img { opacity:0.9; transition:opacity 0.2s ease; }
|
||
.media-preview-link:hover .audio-preview { background:#F3F4F6; transition:background 0.2s ease; }
|
||
.media-crop { border-radius:12px; }
|
||
|
||
/* Empty state */
|
||
.empty-state { text-align:center; padding:4rem 2rem; }
|
||
.empty-state .empty-icon { font-size:3rem; margin-bottom:1rem; }
|
||
.empty-state h2 { color:#1F2937; font-size:1.8rem; font-weight:800; margin-bottom:0.5rem; }
|
||
.empty-state p { color:#9CA3AF; max-width:400px; margin:0 auto; line-height:1.6; }
|
||
|
||
/* Desktop: multi-column masonry */
|
||
@media(min-width:768px) {
|
||
.main-grid { max-width:1300px; padding:0 1rem; column-count:4; column-gap:1.2rem; display:block; }
|
||
.story-card { break-inside:avoid; margin-bottom:1.2rem; display:inline-block; width:100%; }
|
||
.card-link { display:inline-block; width:100%; margin-bottom:1.2rem; break-inside:avoid; }
|
||
}
|
||
@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; } }
|
||
@media(max-width:500px) { .main-grid { column-count:1; padding:0 0.8rem; display:flex; flex-direction:column; gap:1rem; } }
|
||
|
||
/* Mobile header tweaks */
|
||
@media(max-width:500px) {
|
||
.site-header { padding:0.6rem 0.8rem; gap:0.4rem; }
|
||
.site-logo-sub { font-size:0.62rem; }
|
||
.header-right { display:flex; align-items:center; gap:0.3rem; }
|
||
.header-right a { font-size:0.7rem !important; }
|
||
/* Chat & Submit zentriert am unteren Rand */
|
||
.chat-bubble { bottom:1rem; right:auto; left:50%; transform:translateX(calc(-50% - 32px)); }
|
||
.submit-bubble { bottom:1rem; right:auto; left:50%; transform:translateX(calc(-50% + 32px)); }
|
||
.chat-overlay { bottom:5rem; right:0.5rem; left:0.5rem; width:auto; max-height:60vh; }
|
||
.submit-overlay { bottom:5rem; right:0.5rem; left:0.5rem; width:auto; max-height:85vh; }
|
||
}
|
||
.chat-bubble { position:fixed; bottom:1.5rem; right:1.5rem; z-index:9999;
|
||
width:56px; height:56px; border-radius:50%; background:#1F2937;
|
||
color:#F9FAFB; border:none; cursor:pointer; font-size:1.6rem;
|
||
box-shadow:0 6px 24px rgba(0,0,0,0.15); transition:transform 0.2s,box-shadow 0.2s,opacity 0.2s;
|
||
display:flex; align-items:center; justify-content:center; }
|
||
.chat-bubble.hidden { opacity:0; pointer-events:none; }
|
||
.chat-bubble:hover { transform:scale(1.08); box-shadow:0 8px 32px rgba(0,0,0,0.2); }
|
||
.chat-bubble:active { transform:scale(0.95); }
|
||
.chat-bubble .bubble-dot { position:absolute; top:6px; right:6px; width:12px; height:12px;
|
||
background:#10B981; border-radius:50%; border:2px solid #fff; }
|
||
|
||
.chat-overlay { position:fixed; bottom:5.5rem; right:1.5rem; z-index:10001;
|
||
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.12);
|
||
display:none; flex-direction:column; overflow:hidden; }
|
||
.chat-overlay.open { display:flex; }
|
||
.chat-backdrop { position:fixed; inset:0; z-index:10000; background:rgba(0,0,0,0.15); backdrop-filter:blur(4px); -webkit-backdrop-filter:blur(4px); display:none; height:100dvh; }
|
||
.chat-backdrop.open { display:block; }
|
||
.chat-header { background:#1F2937; color:#F9FAFB; 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:#D1D5DB; 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:#F9FAFB; }
|
||
.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:#1F2937; color:#F9FAFB;
|
||
border-bottom-right-radius:4px; }
|
||
.chat-msg.assistant { align-self:flex-start; background:#E5E7EB; color:#1F2937;
|
||
border-bottom-left-radius:4px; }
|
||
.chat-msg.typing { background:#E5E7EB; color:#9CA3AF; 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 #E5E7EB; display:flex; gap:0.5rem; }
|
||
.chat-footer input { flex:1; border:1.5px solid #E5E7EB; border-radius:999px;
|
||
padding:0.55rem 1rem; font-size:16px; font-family:inherit; outline:none;
|
||
transition:border-color 0.15s; background:#F9FAFB; }
|
||
.chat-footer input:focus { border-color:#6B7280; }
|
||
.chat-footer button { background:#1F2937; color:#F9FAFB; 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:#374151; }
|
||
.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:#1F2937;
|
||
color:#F9FAFB; border:none; cursor:pointer; font-size:1.8rem; font-weight:300; line-height:1;
|
||
box-shadow:0 6px 24px rgba(0,0,0,0.15); transition:transform 0.2s,box-shadow 0.2s,opacity 0.2s;
|
||
display:flex; align-items:center; justify-content:center; }
|
||
.submit-bubble.hidden { opacity:0; pointer-events:none; }
|
||
.submit-bubble:hover { transform:scale(1.08); box-shadow:0 8px 32px rgba(0,0,0,0.2); }
|
||
.submit-bubble:active { transform:scale(0.95); }
|
||
|
||
.submit-overlay { position:fixed; bottom:5.5rem; right:1.5rem; z-index:10001;
|
||
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.12);
|
||
display:none; flex-direction:column; overflow:hidden; }
|
||
.submit-overlay.open { display:flex; }
|
||
.submit-header { background:#1F2937; color:#F9FAFB; 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:#D1D5DB; 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:#F9FAFB; }
|
||
.submit-body .hint { font-size:0.75rem; color:#6B7280; margin-bottom:1rem; line-height:1.5; }
|
||
.submit-body label { display:block; font-size:0.7rem; font-weight:600; color:#6B7280;
|
||
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 #E5E7EB;
|
||
border-radius:10px; padding:0.55rem 0.8rem; font-size:16px; font-family:inherit;
|
||
outline:none; transition:border-color 0.15s; background:#fff; }
|
||
.submit-body textarea { resize:vertical; min-height:70px; }
|
||
.submit-body input:focus, .submit-body textarea:focus { border-color:#6B7280; }
|
||
.submit-body input[type="file"] { padding:0.4rem 0; border:none; font-size:0.78rem; }
|
||
.submit-body .submit-btn { width:100%; background:#1F2937; color:#F9FAFB; 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:#374151; }
|
||
.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 #E5E7EB; 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:#F3F4F6; border-color:#6B7280; }
|
||
.capture-label { font-size:0.62rem; font-weight:600; color:#9CA3AF; text-transform:uppercase; letter-spacing:0.04em; text-align:center; }
|
||
|
||
/* ── Login-Dropdown ─────────────────────── */
|
||
.login-dropdown { position:relative; display:inline-block; }
|
||
.login-toggle-btn { background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.12); border-radius:8px; color:#D1D5DB; padding:0.35rem 0.7rem; font-size:0.78rem; font-weight:600; cursor:pointer; font-family:inherit; transition:all 0.15s; white-space:nowrap; line-height:1.3; }
|
||
.login-toggle-btn:hover { background:rgba(255,255,255,0.15); border-color:rgba(255,255,255,0.25); }
|
||
.login-toggle-btn.active { background:rgba(255,255,255,0.2); border-color:#D1D5DB; }
|
||
.login-dropdown-menu { display:none; position:absolute; right:0; top:110%; background:#fff; border-radius:12px; box-shadow:0 8px 32px rgba(0,0,0,0.1); min-width:240px; z-index:300; overflow:hidden; }
|
||
.login-dropdown-menu.open { display:block; animation:fadeDown 0.2s ease; }
|
||
@keyframes fadeDown { from{opacity:0;transform:translateY(-8px);} to{opacity:1;transform:translateY(0);} }
|
||
.login-dropdown-menu a { display:block; padding:0.65rem 1.1rem; color:#1F2937; text-decoration:none; font-size:0.85rem; font-weight:500; transition:background 0.15s; }
|
||
.login-dropdown-menu a:hover { background:#F3F4F6; }
|
||
.login-dropdown-form { padding:0.8rem 1rem 0.5rem; }
|
||
.login-dropdown-form input { width:100%; padding:0.5rem 0.7rem; margin:0.3rem 0; border:1.5px solid #E5E7EB; border-radius:8px; font-size:0.82rem; font-family:inherit; outline:none; box-sizing:border-box; transition:border-color 0.15s; background:#fff; }
|
||
.login-dropdown-form input:focus { border-color:#6B7280; }
|
||
.login-dropdown-form button { width:100%; padding:0.55rem; background:#1F2937; color:#F9FAFB; border:none; border-radius:8px; font-size:0.82rem; font-weight:600; cursor:pointer; font-family:inherit; margin-top:0.4rem; transition:background 0.15s; }
|
||
.login-dropdown-form button:hover { background:#374151; }
|
||
.login-dropdown-register { border-top:1px solid #E5E7EB; font-size:0.78rem !important; color:#9CA3AF !important; text-align:center; }
|
||
.login-dropdown-register:hover { color:#6B7280 !important; }
|
||
.header-bsn-link { color:#9CA3AF; text-decoration:none; font-size:0.8rem; transition:color 0.2s; }
|
||
.header-bsn-link:hover { color:#F9FAFB; }
|
||
|
||
/* ── Welcome Overlay ──────────────────────────────────────── */
|
||
.welcome-backdrop { position:fixed; inset:0; z-index:100000; background:rgba(0,0,0,0.3); backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px); display:none; }
|
||
.welcome-backdrop.open { display:flex; align-items:center; justify-content:center; padding:1rem; }
|
||
.welcome-dialog { background:#fff; border-radius:18px; box-shadow:0 16px 64px rgba(0,0,0,0.15); max-width:480px; width:100%; max-height:90vh; overflow-y:auto; animation:fadeUp 0.3s ease; }
|
||
.welcome-header { background:#1F2937; color:#F9FAFB; padding:1.2rem 1.5rem; border-radius:18px 18px 0 0; font-weight:800; font-size:1.1rem; display:flex; align-items:center; gap:0.5rem; }
|
||
.welcome-body { padding:1.5rem; color:#374151; line-height:1.7; font-size:0.88rem; }
|
||
.welcome-body strong { color:#1F2937; }
|
||
.welcome-body .welcome-feature { display:flex; gap:0.6rem; align-items:flex-start; margin:0.7rem 0; }
|
||
.welcome-body .welcome-icon { font-size:1.2rem; flex-shrink:0; margin-top:0.1rem; }
|
||
.welcome-footer { padding:0.8rem 1.5rem 1.2rem; border-top:1px solid #E5E7EB; display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:0.5rem; }
|
||
.welcome-footer label { font-size:0.78rem; color:#9CA3AF; display:flex; align-items:center; gap:0.4rem; cursor:pointer; user-select:none; }
|
||
.welcome-footer label input { width:16px; height:16px; accent-color:#1F2937; cursor:pointer; }
|
||
.welcome-footer button { background:#1F2937; color:#F9FAFB; border:none; border-radius:10px; padding:0.55rem 1.4rem; font-size:0.85rem; font-weight:700; cursor:pointer; font-family:inherit; transition:background 0.15s; }
|
||
.welcome-footer button:hover { background:#374151; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ── Welcome Overlay ──────────────────────────────────────── -->
|
||
<div class="welcome-backdrop" id="welcomeBackdrop">
|
||
<div class="welcome-dialog">
|
||
<div class="welcome-header">🎲 Willkommen in der BSN Community!</div>
|
||
<div class="welcome-body">
|
||
<p>Hier teilen <strong>Brettspiel-Fans</strong> ihre Eindrücke direkt von der SPIEL Essen — und darüber hinaus.</p>
|
||
<div class="welcome-feature"><span class="welcome-icon">📸</span><span><strong>Beiträge durchstöbern</strong> — Fotos, Videos, Sprachnachrichten und Texte aus der Community, sortiert nach Halle, Kategorie oder Beliebtheit.</span></div>
|
||
<div class="welcome-feature"><span class="welcome-icon">➕</span><span><strong>Selbst einreichen</strong> — Über den <span style="background:#20228a;color:#fff;border-radius:50%;display:inline-block;width:22px;height:22px;text-align:center;line-height:22px;font-size:0.9rem;">+</span>-Button rechts unten kannst du eigene Fotos, Videos oder Texte beisteuern.</span></div>
|
||
<div class="welcome-feature"><span class="welcome-icon">💬</span><span><strong>BSN Assistent</strong> — Der Chat-Button rechts unten beantwortet Fragen zu Spielen und Ständen — mit KI.</span></div>
|
||
<div class="welcome-feature"><span class="welcome-icon">🔐</span><span><strong>Login</strong> — Oben rechts einloggen, um Benachrichtigungen zu erhalten und deine Beiträge zu verwalten.</span></div>
|
||
<p style="margin-top:0.8rem;font-size:0.78rem;color:#888;">✨ Diese Seite ist ein Community-Projekt von <a href="https://brettspiel-news.de" style="color:#20228a;">brettspiel-news.de</a>.</p>
|
||
</div>
|
||
<div class="welcome-footer">
|
||
<label><input type="checkbox" id="welcomeDismiss"> Nicht mehr anzeigen</label>
|
||
<button id="welcomeClose">Alles klar!</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<header class="site-header">
|
||
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
||
<a href="/" class="site-logo">
|
||
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;">
|
||
<span class="site-logo-sub">boardgame.network</span>
|
||
</a>
|
||
""" + _build_login_dropdown(user) + """
|
||
</header>
|
||
|
||
<div class="filter-backdrop" id="filterBackdrop">
|
||
<div class="filter-overlay" id="filterOverlay">
|
||
""" + filter_html + """
|
||
<button id="filterCloseBtn" style="display:block;width:100%;margin-top:0.8rem;padding:0.5rem;background:#f0ede6;border:none;border-radius:8px;font-size:0.82rem;font-weight:600;color:#555;cursor:pointer;font-family:inherit;">✕ Schließen</button>
|
||
</div>
|
||
</div>
|
||
<main class="main-grid">
|
||
""" + cards + """
|
||
</main>
|
||
|
||
<footer class="site-footer">
|
||
<nav style="display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;margin-bottom:0.5rem;">
|
||
<a href="/impressum">Impressum</a>
|
||
<a href="/datenschutz">Datenschutz</a>
|
||
<a href="/agb">AGB</a>
|
||
</nav>
|
||
<p>Powered by <a href="https://brettspiel-news.de">brettspiel-news.de</a> — Die Stimme der Brettspiel-Community</p>
|
||
</footer>
|
||
|
||
<script>
|
||
(function() {
|
||
var btn = document.getElementById('filterToggle');
|
||
var backdrop = document.getElementById('filterBackdrop');
|
||
var closeBtn = document.getElementById('filterCloseBtn');
|
||
if (!btn || !backdrop) return;
|
||
|
||
btn.addEventListener('click', function() {
|
||
backdrop.classList.add('open');
|
||
btn.classList.add('active');
|
||
});
|
||
|
||
function closeFilter() {
|
||
backdrop.classList.remove('open');
|
||
btn.classList.remove('active');
|
||
}
|
||
|
||
if (closeBtn) closeBtn.addEventListener('click', closeFilter);
|
||
backdrop.addEventListener('click', function(e) {
|
||
if (e.target === backdrop) closeFilter();
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
<!-- ── Chat Bubble ──────────────────────────────────────────── -->
|
||
<button class="chat-bubble" id="chatBubble" title="BSN Assistent" 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 Backdrop ──────────────────────────────────────── -->
|
||
<div class="chat-backdrop" id="chatBackdrop"></div>
|
||
|
||
<!-- ── 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 Assistent. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div>
|
||
<button id="greetingSpeakBtn" title="Vorlesen" style="background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;" onmouseenter="this.style.background='rgba(32,34,138,0.18)';this.style.borderColor='rgba(32,34,138,0.4)'" onmouseleave="this.style.background='rgba(32,34,138,0.08)';this.style.borderColor='rgba(32,34,138,0.2)'">🔊</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>E-Mail für Benachrichtigungen</label>
|
||
<input type="email" id="subEmail" placeholder="deine@email.de" maxlength="200">
|
||
<label>Handynummer für WhatsApp-Benachrichtigungen</label>
|
||
<input type="tel" id="subPhone" placeholder="+49 170 1234567" maxlength="50">
|
||
<p style="font-size:0.7rem;color:#999;margin:-0.3rem 0 0.5rem 0;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#20228a" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:4px;"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> E-Mail oder Handynummer erforderlich, damit wir dich bei Antworten & Likes benachrichtigen können.</p>
|
||
<label>Nachricht (optional, reicht auch ohne Text)</label>
|
||
<textarea id="subText" placeholder="Was möchtest du mitteilen? ..." maxlength="20000"></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');
|
||
const chatBubble = document.getElementById('chatBubble');
|
||
let sOpen = false;
|
||
|
||
const sBackdrop = document.getElementById('chatBackdrop');
|
||
|
||
sBubble.addEventListener('click', () => {
|
||
sOpen = !sOpen;
|
||
sOverlay.classList.toggle('open', sOpen);
|
||
if (sBackdrop) sBackdrop.classList.toggle('open', sOpen);
|
||
// Bubbles verstecken wenn Overlay offen
|
||
if (sBubble) sBubble.classList.toggle('hidden', sOpen);
|
||
if (chatBubble) chatBubble.classList.toggle('hidden', sOpen);
|
||
const chatO = document.getElementById('chatOverlay');
|
||
if (chatO) chatO.classList.remove('open');
|
||
});
|
||
sClose.addEventListener('click', () => {
|
||
sOpen = false;
|
||
sOverlay.classList.remove('open');
|
||
if (sBackdrop) sBackdrop.classList.remove('open');
|
||
if (sBubble) sBubble.classList.remove('hidden');
|
||
if (chatBubble) chatBubble.classList.remove('hidden');
|
||
});
|
||
|
||
sSend.addEventListener('click', async () => {
|
||
const name = document.getElementById('subName').value.trim();
|
||
const email = document.getElementById('subEmail').value.trim();
|
||
const phone = document.getElementById('subPhone').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;
|
||
}
|
||
|
||
if (!email && !phone) {
|
||
sStatus.textContent = '⚠️ Bitte E-Mail oder Handynummer angeben, damit wir dich benachrichtigen können.';
|
||
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 (email) formData.append('email', email);
|
||
if (phone) formData.append('phone', phone);
|
||
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('subEmail').value = '';
|
||
document.getElementById('subPhone').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>
|
||
// Like button handler
|
||
(function() {
|
||
var likedPosts = JSON.parse(localStorage.getItem('likedPosts') || '[]');
|
||
|
||
// Restore liked state on page load
|
||
document.querySelectorAll('.stat-like-btn').forEach(function(btn) {
|
||
var id = parseInt(btn.getAttribute('data-id'));
|
||
if (likedPosts.indexOf(id) !== -1) {
|
||
btn.classList.add('liked');
|
||
}
|
||
// Touch-Event für Mobile
|
||
btn.addEventListener('touchend', function(e) { e.preventDefault(); btn.click(); });
|
||
});
|
||
|
||
document.querySelectorAll('.stat-like-btn').forEach(function(btn) {
|
||
btn.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
var id = this.getAttribute('data-id');
|
||
if (this.classList.contains('liked')) return;
|
||
this.disabled = true;
|
||
var self = this;
|
||
fetch('/api/like/' + id, {method:'POST'})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (!data.already) {
|
||
self.classList.add('liked');
|
||
var countSpan = self.querySelector('.like-count');
|
||
if (countSpan) countSpan.textContent = data.likes;
|
||
else self.setAttribute('data-likes', data.likes);
|
||
likedPosts.push(parseInt(id));
|
||
localStorage.setItem('likedPosts', JSON.stringify(likedPosts));
|
||
}
|
||
self.disabled = false;
|
||
})
|
||
.catch(function() { self.disabled = false; });
|
||
});
|
||
});
|
||
})();
|
||
</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;
|
||
|
||
const backdrop = document.getElementById('chatBackdrop');
|
||
const sBubble = document.getElementById('submitBubble');
|
||
|
||
function toggle() {
|
||
open = !open;
|
||
overlay.classList.toggle('open', open);
|
||
if (backdrop) backdrop.classList.toggle('open', open);
|
||
// Bubbles verstecken wenn Overlay offen, damit man nicht versehentlich darunter klickt
|
||
if (bubble) bubble.classList.toggle('hidden', open);
|
||
if (sBubble) sBubble.classList.toggle('hidden', open);
|
||
if (open) setTimeout(() => input.focus(), 100);
|
||
}
|
||
|
||
bubble.addEventListener('click', toggle);
|
||
closeBtn.addEventListener('click', () => {
|
||
open = false;
|
||
overlay.classList.remove('open');
|
||
if (backdrop) backdrop.classList.remove('open');
|
||
if (bubble) bubble.classList.remove('hidden');
|
||
if (sBubble) sBubble.classList.remove('hidden');
|
||
});
|
||
|
||
// Click backdrop to close either overlay
|
||
if (backdrop) {
|
||
backdrop.addEventListener('click', () => {
|
||
open = false;
|
||
overlay.classList.remove('open');
|
||
backdrop.classList.remove('open');
|
||
if (bubble) bubble.classList.remove('hidden');
|
||
if (sBubble) sBubble.classList.remove('hidden');
|
||
// Also close submit overlay if open
|
||
const sO = document.getElementById('submitOverlay');
|
||
if (sO) { sO.classList.remove('open'); }
|
||
});
|
||
}
|
||
|
||
// Wire up greeting speak button
|
||
const greetBtn = document.getElementById('greetingSpeakBtn');
|
||
if (greetBtn) {
|
||
greetBtn.addEventListener('click', () => {
|
||
speak('Hey! Ich bin der BSN Assistent. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!');
|
||
});
|
||
}
|
||
|
||
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.innerHTML = text;
|
||
wrapper.appendChild(div);
|
||
|
||
if (role === 'assistant') {
|
||
const btn = document.createElement('button');
|
||
btn.innerHTML = '🔊';
|
||
btn.title = 'Vorlesen';
|
||
btn.style.cssText = 'background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;';
|
||
btn.onmouseenter = () => { btn.style.background = 'rgba(32,34,138,0.18)'; btn.style.borderColor = 'rgba(32,34,138,0.4)'; };
|
||
btn.onmouseleave = () => { btn.style.background = 'rgba(32,34,138,0.08)'; btn.style.borderColor = 'rgba(32,34,138,0.2)'; };
|
||
btn.onclick = () => speak(text);
|
||
wrapper.appendChild(btn);
|
||
}
|
||
|
||
body.appendChild(wrapper);
|
||
body.scrollTop = body.scrollHeight;
|
||
return div;
|
||
}
|
||
|
||
async function speak(text) {
|
||
try {
|
||
const r = await fetch('/api/speak', {
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({text:text})
|
||
});
|
||
if (!r.ok) throw new Error('TTS failed');
|
||
const blob = await r.blob();
|
||
const audio = new Audio(URL.createObjectURL(blob));
|
||
audio.play();
|
||
} catch(e) {
|
||
// Fallback: browser TTS
|
||
if (window.speechSynthesis) {
|
||
window.speechSynthesis.cancel();
|
||
const u = new SpeechSynthesisUtterance(text);
|
||
u.lang = 'de-DE';
|
||
u.rate = 0.95;
|
||
const voices = speechSynthesis.getVoices();
|
||
const thorsten = voices.find(v => v.name === 'Thorsten');
|
||
if (thorsten) u.voice = thorsten;
|
||
else { 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 BSN 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>
|
||
|
||
<div class="offcanvas-backdrop" id="offcanvasBackdrop"></div>
|
||
<nav class="offcanvas-nav" id="offcanvasNav">
|
||
<div class="offcanvas-nav-header">
|
||
<span class="nav-logo">BSN Community</span>
|
||
<button class="nav-close" id="navClose" aria-label="Menü schließen">✕</button>
|
||
</div>
|
||
<ul class="offcanvas-nav-links">
|
||
<li class="nav-section">Navigation</li>
|
||
<li><a href="/">Startseite</a></li>
|
||
<li class="nav-section">Rechtliches</li>
|
||
<li><a href="/impressum">Impressum</a></li>
|
||
<li><a href="/datenschutz">Datenschutzerklärung</a></li>
|
||
<li><a href="/agb">AGB</a></li>
|
||
</ul>
|
||
<div class="offcanvas-nav-footer">
|
||
© 2026 Brettspiel-News.de
|
||
</div>
|
||
</nav>
|
||
|
||
<script>
|
||
(function() {
|
||
var toggle = document.getElementById('navToggle');
|
||
var close = document.getElementById('navClose');
|
||
var nav = document.getElementById('offcanvasNav');
|
||
var backdrop = document.getElementById('offcanvasBackdrop');
|
||
var main = document.querySelector('main');
|
||
var header = document.querySelector('.site-header');
|
||
var footer = document.querySelector('.site-footer');
|
||
|
||
function open() {
|
||
nav.classList.add('open');
|
||
backdrop.classList.add('open');
|
||
if (main) main.style.filter = 'blur(4px)';
|
||
if (header) header.style.filter = 'blur(4px)';
|
||
if (footer) footer.style.filter = 'blur(4px)';
|
||
}
|
||
function closeNav() {
|
||
nav.classList.remove('open');
|
||
backdrop.classList.remove('open');
|
||
if (main) main.style.filter = '';
|
||
if (header) header.style.filter = '';
|
||
if (footer) footer.style.filter = '';
|
||
}
|
||
|
||
toggle.addEventListener('click', open);
|
||
close.addEventListener('click', closeNav);
|
||
backdrop.addEventListener('click', closeNav);
|
||
})();
|
||
</script>
|
||
|
||
<script>
|
||
// ── Login-Dropdown Toggle ──────────────────────────────────
|
||
(function() {
|
||
var btn = document.getElementById('loginToggle');
|
||
var menu = document.getElementById('loginDropdown');
|
||
if (btn && menu) {
|
||
btn.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
menu.classList.toggle('open');
|
||
btn.classList.toggle('active');
|
||
});
|
||
document.addEventListener('click', function(e) {
|
||
if (!btn.contains(e.target) && !menu.contains(e.target)) {
|
||
menu.classList.remove('open');
|
||
btn.classList.remove('active');
|
||
}
|
||
});
|
||
}
|
||
})();
|
||
|
||
// ── Welcome Overlay ────────────────────────────────────────
|
||
(function() {
|
||
var backdrop = document.getElementById('welcomeBackdrop');
|
||
var closeBtn = document.getElementById('welcomeClose');
|
||
var checkbox = document.getElementById('welcomeDismiss');
|
||
if (!backdrop) return;
|
||
// Nur anzeigen, wenn nicht per localStorage ausgeblendet
|
||
if (localStorage.getItem('welcomeDismissed') !== 'true') {
|
||
backdrop.classList.add('open');
|
||
}
|
||
function dismiss() {
|
||
if (checkbox && checkbox.checked) {
|
||
localStorage.setItem('welcomeDismissed', 'true');
|
||
}
|
||
backdrop.classList.remove('open');
|
||
}
|
||
if (closeBtn) closeBtn.addEventListener('click', dismiss);
|
||
backdrop.addEventListener('click', function(e) {
|
||
if (e.target === backdrop) dismiss();
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
</body>
|
||
</html>""").replace("{LOGO_B64}", LOGO_B64)
|
||
|
||
|
||
|
||
|
||
def generate_image_thumbnail(image_path: str, width: int = 300) -> str | None:
|
||
"""Generate a thumbnail for an image. Returns path to .thumb.jpg or None."""
|
||
thumb_path = image_path + ".thumb.jpg"
|
||
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 100:
|
||
return thumb_path
|
||
try:
|
||
from PIL import Image
|
||
img = Image.open(image_path)
|
||
img.thumbnail((width, width * 3), Image.LANCZOS)
|
||
if img.mode in ('RGBA', 'P'):
|
||
img = img.convert('RGB')
|
||
img.save(thumb_path, 'JPEG', quality=75)
|
||
return thumb_path
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
# ── Legal pages ─────────────────────────────────────────────────────
|
||
|
||
def _overlay_page(title, content_html, max_width="640px"):
|
||
"""Overlay-Seite: Milchglas-Backdrop + zentrierte weiße Card. Gleicher Look wie Submit-Overlay."""
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||
<title>{title} — BSN Community</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:#F9FAFB; color:#1F2937; min-height:100vh; display:flex; align-items:flex-start; justify-content:center; padding:2rem 1rem; -webkit-font-smoothing:antialiased; }}
|
||
/* Milchglas-Backdrop */
|
||
body::before {{ content:''; position:fixed; inset:0; background:rgba(0,0,0,0.08); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); z-index:0; }}
|
||
.overlay-card {{ position:relative; z-index:1; background:#fff; border-radius:18px; box-shadow:0 16px 64px rgba(0,0,0,0.12); width:100%; max-width:{max_width}; padding:2rem; animation:fadeUp 0.3s ease; }}
|
||
@keyframes fadeUp {{ from{{opacity:0;transform:translateY(16px);}} to{{opacity:1;transform:translateY(0);}} }}
|
||
.overlay-card h1 {{ font-size:1.5rem; font-weight:800; color:#1F2937; margin-bottom:0.3rem; }}
|
||
.overlay-card .stand {{ font-size:0.78rem; color:#9CA3AF; margin-bottom:1.5rem; }}
|
||
.overlay-card h2 {{ font-size:1.1rem; font-weight:700; color:#374151; margin:1.5rem 0 0.4rem; }}
|
||
.overlay-card h3 {{ font-size:0.95rem; font-weight:600; color:#374151; margin:1rem 0 0.3rem; }}
|
||
.overlay-card p {{ line-height:1.65; margin-bottom:0.7rem; font-size:0.9rem; color:#4B5563; }}
|
||
.overlay-card ul, .overlay-card ol {{ margin:0.5rem 0 0.8rem 1.5rem; font-size:0.9rem; line-height:1.65; color:#4B5563; }}
|
||
.overlay-card blockquote {{ border-left:3px solid #E5E7EB; padding:0.5rem 1rem; margin:1rem 0; background:#F9FAFB; border-radius:0 8px 8px 0; font-size:0.85rem; color:#6B7280; }}
|
||
.overlay-card hr {{ border:none; border-top:1px solid #E5E7EB; margin:1.5rem 0; }}
|
||
.overlay-card a {{ color:#1F2937; text-decoration:underline; text-underline-offset:2px; }}
|
||
.overlay-card input, .overlay-card textarea, .overlay-card select {{ width:100%; padding:0.65rem 0.8rem; margin:0.3rem 0; border:1.5px solid #E5E7EB; border-radius:10px; font-size:0.9rem; font-family:inherit; outline:none; transition:border-color 0.15s; background:#fff; }}
|
||
.overlay-card input:focus, .overlay-card textarea:focus {{ border-color:#6B7280; }}
|
||
.overlay-card .btn {{ display:inline-block; width:100%; padding:0.7rem; background:#1F2937; color:#F9FAFB; border:none; border-radius:10px; font-size:0.9rem; font-weight:600; cursor:pointer; font-family:inherit; transition:background 0.15s; text-align:center; text-decoration:none; margin-top:0.5rem; }}
|
||
.overlay-card .btn:hover {{ background:#374151; }}
|
||
.overlay-card .btn-secondary {{ background:#F3F4F6; color:#374151; }}
|
||
.overlay-card .btn-secondary:hover {{ background:#E5E7EB; }}
|
||
.overlay-card .error {{ color:#EF4444; font-size:0.85rem; margin:0.5rem 0; }}
|
||
.overlay-card .hint {{ color:#9CA3AF; font-size:0.75rem; margin:0.2rem 0 0.5rem; }}
|
||
.overlay-card .link {{ text-align:center; margin-top:1rem; font-size:0.85rem; color:#6B7280; }}
|
||
.overlay-card .link a {{ color:#1F2937; font-weight:500; }}
|
||
.back-link {{ display:inline-flex; align-items:center; gap:0.3rem; margin-top:1.5rem; color:#9CA3AF; text-decoration:none; font-weight:500; font-size:0.85rem; transition:color 0.15s; }}
|
||
.back-link:hover {{ color:#6B7280; }}
|
||
.overlay-nav {{ display:flex; gap:1rem; flex-wrap:wrap; justify-content:center; padding-top:1.2rem; margin-top:1.2rem; border-top:1px solid #F3F4F6; }}
|
||
.overlay-nav a {{ color:#9CA3AF; text-decoration:none; font-size:0.78rem; font-weight:500; transition:color 0.15s; }}
|
||
.overlay-nav a:hover {{ color:#6B7280; }}
|
||
@media(max-width:600px) {{ body {{ padding:0.5rem; align-items:flex-start; }} .overlay-card {{ padding:1.2rem; border-radius:14px; }} .overlay-nav {{ gap:0.6rem; }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="overlay-card">
|
||
{content_html}
|
||
<div class="overlay-nav">
|
||
<a href="/impressum">Impressum</a>
|
||
<a href="/datenschutz">Datenschutz</a>
|
||
<a href="/agb">AGB</a>
|
||
</div>
|
||
<a href="/" class="back-link">← Zurück zur Startseite</a>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def _legal_page(title, content_html):
|
||
"""Legacy-Wrapper — nutzt jetzt _overlay_page."""
|
||
return _overlay_page(title, content_html)
|
||
|
||
|
||
@app.route("/impressum")
|
||
def legal_impressum():
|
||
return _legal_page("Impressum", """<h1>Impressum</h1>
|
||
<p class="stand">Angaben gemäß § 5 DDG (Digitale-Dienste-Gesetz)</p>
|
||
|
||
<h2>Verantwortlich für den Inhalt</h2>
|
||
<p>Daniel Krause<br>Brettspiel-News.de<br>Teichweg 13a<br>50374 Erftstadt<br>Deutschland</p>
|
||
|
||
<h2>Kontakt</h2>
|
||
<p>Telefon: +49 152 27909535<br>E-Mail: <a href="mailto:kontakt@brettspiel-news.de">kontakt@brettspiel-news.de</a></p>
|
||
|
||
<h2>Umsatzsteuer-ID</h2>
|
||
<p>USt-IdNr. gemäß § 27 a UStG: <strong>DE238244230</strong></p>
|
||
|
||
<h2>Redaktionell verantwortlich</h2>
|
||
<p>Daniel Krause<br>Teichweg 13a<br>50374 Erftstadt</p>
|
||
|
||
<hr>
|
||
|
||
<h2>Hinweis zur Streitschlichtung</h2>
|
||
<p>Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: <a href="https://ec.europa.eu/consumers/odr/">https://ec.europa.eu/consumers/odr/</a></p>
|
||
<p>Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>
|
||
|
||
<h2>Haftung für Inhalte</h2>
|
||
<p>Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.</p>
|
||
<p>Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.</p>
|
||
|
||
<h2>Haftung für Links</h2>
|
||
<p>Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich.</p>
|
||
|
||
<h2>Urheberrecht</h2>
|
||
<p>Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.</p>
|
||
<p>Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.</p>
|
||
|
||
<hr>
|
||
<p><strong>BSN-Chatsystem</strong> (chat.datenhimmel.work) ist ein Angebot von Brettspiel-News.de, betrieben durch Daniel Krause.</p>""")
|
||
|
||
|
||
@app.route("/datenschutz")
|
||
def legal_datenschutz():
|
||
return _legal_page("Datenschutzerklärung", """<h1>Datenschutzerklärung</h1>
|
||
<p class="stand">Stand: 17. Juni 2026 — Geltungsbereich: BSN-Chatsystem (chat.datenhimmel.work)</p>
|
||
|
||
<h2>1. Verantwortlicher</h2>
|
||
<p>Daniel Krause<br>Teichweg 13a<br>50374 Erftstadt<br>Deutschland<br>E-Mail: <a href="mailto:kontakt@brettspiel-news.de">kontakt@brettspiel-news.de</a><br>Telefon: +49 152 27909535</p>
|
||
|
||
<h2>2. Allgemeines zur Datenverarbeitung</h2>
|
||
<h3>2.1 Umfang</h3>
|
||
<p>Wir verarbeiten personenbezogene Daten nur, soweit dies zur Bereitstellung des BSN-Chatsystems erforderlich ist.</p>
|
||
<h3>2.2 Rechtsgrundlagen (Art. 6 DSGVO)</h3>
|
||
<ul><li><strong>lit. a:</strong> Einwilligung</li><li><strong>lit. b:</strong> Vertragserfüllung</li><li><strong>lit. c:</strong> Rechtliche Verpflichtung</li><li><strong>lit. f:</strong> Berechtigtes Interesse (Betrieb, redaktionelle Berichterstattung)</li></ul>
|
||
<h3>2.3 Speicherdauer</h3>
|
||
<p>Daten werden gelöscht, sobald der Zweck entfällt, es sei denn gesetzliche Aufbewahrungspflichten bestehen.</p>
|
||
|
||
<h2>3. Datenerhebung über WhatsApp</h2>
|
||
<p>Verarbeitet werden: WhatsApp-Telefonnummer, Profilname, Nachrichteninhalte, Medien (Bilder, Videos, Audio), Transkriptionen, KI-Zusammenfassungen.</p>
|
||
<p><strong>Zweck:</strong> Entgegennahme von Community-Beiträgen, redaktionelle Sichtung, Veröffentlichung.</p>
|
||
<p><strong>Empfänger:</strong> Meta Platforms, Inc. (USA) — zertifiziert unter EU-US Data Privacy Framework. DeepSeek / datenhimmel.work — zur KI-Zusammenfassung.</p>
|
||
|
||
<h2>4. Datenerhebung über das Web-Formular</h2>
|
||
<p>Verarbeitet werden: Name (optional), Nachricht, hochgeladene Medien, Metadaten, KI-Zusammenfassung, Referenz-ID.</p>
|
||
<p>Zweck und Rechtsgrundlagen wie unter Ziffer 3.</p>
|
||
|
||
<h2>5. Veröffentlichung von Beiträgen</h2>
|
||
<p>Bei Veröffentlichung werden sichtbar: Ihr Name (oder "Anonyme Quelle"), Beitragsinhalt, Medien, KI-Zusammenfassung, Halle/Kategorie. <strong>Ohne Namensangabe erfolgt die Veröffentlichung als "Anonyme Quelle".</strong></p>
|
||
|
||
<h2>6. BSN Assistent (KI-Chat)</h2>
|
||
<p>Kein Zugriff auf nicht-veröffentlichte Daten. Keine Speicherung von Chat-Verläufen. Keine Verarbeitung personenbezogener Daten der Fragesteller.</p>
|
||
|
||
<h2>7. Cookies und Tracking</h2>
|
||
<p>Keine Cookies, kein Tracking. Nur localStorage für Theme-Präferenz (Dark/Light).</p>
|
||
|
||
<h2>8. Ihre Rechte</h2>
|
||
<ul><li><strong>Auskunft</strong> (Art. 15 DSGVO)</li><li><strong>Berichtigung</strong> (Art. 16 DSGVO)</li><li><strong>Löschung</strong> (Art. 17 DSGVO): WhatsApp: "Meine Daten löschen" senden. Web: Referenz-ID an <a href="mailto:kontakt@brettspiel-news.de">kontakt@brettspiel-news.de</a></li><li><strong>Einschränkung</strong> (Art. 18 DSGVO)</li><li><strong>Datenübertragbarkeit</strong> (Art. 20 DSGVO)</li><li><strong>Widerspruch</strong> (Art. 21 DSGVO)</li><li><strong>Widerruf</strong> (Art. 7 Abs. 3 DSGVO)</li><li><strong>Beschwerde</strong> (Art. 77 DSGVO): LDI NRW, Kavalleriestraße 2–4, 40213 Düsseldorf</li></ul>
|
||
|
||
<h2>9. Sicherheit (Art. 32 DSGVO)</h2>
|
||
<p>HTTPS-Verschlüsselung, Zugriffskontrolle, Datenminimierung, Löschkonzept.</p>
|
||
|
||
<h2>10. Drittlandtransfer</h2>
|
||
<p>Meta (USA): EU-US DPF-zertifiziert. DeepSeek/datenhimmel.work: Serverstandort zu prüfen; ggf. Standardvertragsklauseln nach Art. 46 DSGVO.</p>
|
||
|
||
<h2>11. Automatisierte Entscheidungsfindung</h2>
|
||
<p>Kein Profiling nach Art. 22 DSGVO.</p>
|
||
|
||
<hr>
|
||
<blockquote>Dieses Dokument wurde mit Sorgfalt erstellt, ersetzt jedoch keine individuelle Rechtsberatung durch einen Fachanwalt für Datenschutzrecht.</blockquote>""")
|
||
|
||
|
||
@app.route("/agb")
|
||
def legal_agb():
|
||
return _legal_page("AGB", """<h1>Allgemeine Nutzungsbedingungen (AGB)</h1>
|
||
<p class="stand">Geltungsbereich: BSN-Chatsystem (chat.datenhimmel.work) — Stand: 17. Juni 2026</p>
|
||
|
||
<h2>1. Geltungsbereich</h2>
|
||
<p>Diese AGB regeln die Nutzung des BSN-Chatsystems, betrieben von:<br>Daniel Krause, Teichweg 13a, 50374 Erftstadt<br>E-Mail: <a href="mailto:kontakt@brettspiel-news.de">kontakt@brettspiel-news.de</a></p>
|
||
<p>Mit der Nutzung erkennen Sie diese Bedingungen an.</p>
|
||
|
||
<h2>2. Nutzungsvoraussetzungen</h2>
|
||
<p>Die Nutzung ist <strong>kostenlos</strong>. Kein Anspruch auf Veröffentlichung. Mindestalter: 16 Jahre.</p>
|
||
|
||
<h2>3. Einreichung von Inhalten</h2>
|
||
<p>Sie können Texte (max. 5.000 Zeichen), Fotos, Videos und Sprachnachrichten einreichen. Sie versichern: Urheberschaft, keine Rechtsverletzungen, Einwilligung abgebildeter Personen, keine rechtswidrigen Inhalte.</p>
|
||
|
||
<h2>4. Rechteeinräumung</h2>
|
||
<p>Sie räumen ein einfaches, zeitlich und räumlich unbeschränktes, unentgeltliches Nutzungsrecht ein (Speicherung, Bearbeitung, öffentliche Wiedergabe, Vervielfältigung). Sie bleiben Urheber. Das Recht erlischt bei Löschung.</p>
|
||
|
||
<h2>5. Veröffentlichung</h2>
|
||
<p>Keine automatische Veröffentlichung. Redaktionelle Prüfung vorbehalten. Kein Anspruch auf Veröffentlichung. Bei Veröffentlichung: Vorname/Spitzname oder "Anonyme Quelle". Beiträge sind öffentlich einsehbar.</p>
|
||
|
||
<h2>6. Pflichten des Nutzers</h2>
|
||
<p>Keine rechtswidrigen, Rechte-verletzenden Inhalte, kein Spam, keine Malware, keine Desinformation. Sie stellen den Betreiber von Ansprüchen Dritter frei.</p>
|
||
|
||
<h2>7. Moderation und Löschung</h2>
|
||
<p>Der Betreiber kann Beiträge jederzeit ändern, zurücknehmen oder löschen.</p>
|
||
<p><strong>So löschen Sie Ihre Daten:</strong><br>WhatsApp: "Meine Daten löschen" senden.<br>Web: Referenz-ID an <a href="mailto:kontakt@brettspiel-news.de">kontakt@brettspiel-news.de</a></p>
|
||
|
||
<h2>8. Haftung</h2>
|
||
<p>Unbeschränkt bei Vorsatz/grober Fahrlässigkeit. Bei leichter Fahrlässigkeit nur bei Kardinalpflichtverletzung, begrenzt auf vorhersehbaren Schaden. Keine Haftung für Nutzerinhalte. §§ 8–10 DDG: Keine Überwachungspflicht, aber Entfernung bei Kenntnis.</p>
|
||
|
||
<h2>9. KI-BSN Assistent</h2>
|
||
<p>Beantwortet Fragen auf Basis veröffentlichter Beiträge (DeepSeek V4 Flash). Antworten unverbindlich, können Fehler enthalten. Keine Speicherung von Chat-Verläufen.</p>
|
||
|
||
<h2>10. Änderungen der AGB</h2>
|
||
<p>Änderungen mit Wirkung für die Zukunft vorbehalten. Aktuelle Version unter <a href="/agb">chat.datenhimmel.work/agb</a>.</p>
|
||
|
||
<h2>11. Schlussbestimmungen</h2>
|
||
<p>Es gilt deutsches Recht unter Ausschluss des CISG. Gerichtsstand: Erftstadt (für Kaufleute). Salvatorische Klausel.</p>
|
||
|
||
<hr>
|
||
<blockquote>Diese AGB regeln ausschließlich das BSN-Chatsystem. Für brettspiel-news.de gelten die dort hinterlegten Bedingungen.<br>Die Datenschutzerklärung ist unter <a href="/datenschutz">chat.datenhimmel.work/datenschutz</a> abrufbar.</blockquote>""")
|
||
|
||
|
||
@app.route('/media/<path:filename>')
|
||
def serve_media(filename):
|
||
"""Serve media files with aggressive caching headers."""
|
||
safe = os.path.normpath(filename)
|
||
if '..' in safe or safe.startswith('/'):
|
||
return '', 404
|
||
filepath = MEDIA_DIR / safe
|
||
if not filepath.is_file():
|
||
# Try with .thumb.jpg suffix
|
||
filepath_thumb = MEDIA_DIR / (safe + '.thumb.jpg')
|
||
if filepath_thumb.is_file():
|
||
filepath = filepath_thumb
|
||
else:
|
||
return '', 404
|
||
from flask import send_file
|
||
response = send_file(str(filepath))
|
||
response.headers['Cache-Control'] = 'public, max-age=86400, immutable'
|
||
response.headers['ETag'] = f'"{filepath.stat().st_mtime}-{filepath.stat().st_size}"'
|
||
return response
|
||
|
||
|
||
@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?'})
|
||
|
||
# ── FAQ-Trigger: "Keine Antwort" / "Warum keine Nachricht" ──────
|
||
faq_keywords = [
|
||
"keine antwort", "bekomme keine", "keine nachricht",
|
||
"antwort kommt nicht", "warum keine", "keine rückmeldung",
|
||
"why no reply", "not receiving", "save contact", "nummer speichern"
|
||
]
|
||
if any(kw in user_message.lower() for kw in faq_keywords):
|
||
return jsonify({'reply': (
|
||
"💡 *Tipp:* Damit unsere Antworten bei dir ankommen, "
|
||
"speichere bitte unsere Nummer in deinem Adressbuch. "
|
||
"WhatsApp filtert Nachrichten von unbekannten Business-Accounts "
|
||
"auf manchen Handys leider automatisch heraus.\n\n"
|
||
"Sobald du uns als Kontakt gespeichert hast, klappt's! ✅"
|
||
)})
|
||
|
||
# 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 40"
|
||
).fetchall()
|
||
|
||
# Build compact context string
|
||
context_parts = []
|
||
for i, r in enumerate(rows, 1):
|
||
text = (r['summary'] or r['content'] or '').strip()[:300]
|
||
halle = r['halle'] or ''
|
||
spiel = r['spiel_titel'] or ''
|
||
cat = r['category'] or ''
|
||
name = (r['sender_name'] or 'Anonym').strip()
|
||
context_parts.append(
|
||
f"[Beitrag #{r['id']}] {text}"
|
||
+ (f" | 📍{halle}" if halle else '')
|
||
+ (f" | 🎲{spiel}" if spiel else '')
|
||
+ (f" | #{cat}" if cat else '')
|
||
+ (f" | von {name}" if name else '')
|
||
)
|
||
|
||
# Also search aussteller table using SQL LIKE filters (OOM-safe)
|
||
# Extract alphanumeric+umlaut tokens (strip punctuation like '?', '!', ',')
|
||
search_terms = re.findall(r'[a-z0-9äöüß]+', user_message.lower())
|
||
# Filter stopwords to reduce noise
|
||
stopwords = {'die', 'der', 'das', 'und', 'ist', 'ein', 'eine', 'den', 'dem', 'des',
|
||
'mit', 'auf', 'für', 'von', 'zu', 'im', 'in', 'an', 'es', 'nicht',
|
||
'ich', 'wir', 'sie', 'er', 'was', 'wo', 'wie', 'welche', 'welcher',
|
||
'welches', 'gibt', 'noch', 'mehr', 'alle', 'oder', 'auch'}
|
||
meaningful_terms = [t for t in search_terms if t not in stopwords and len(t) > 1]
|
||
|
||
# Build SQL LIKE filter for aussteller with relevance scoring
|
||
aussteller_rows = []
|
||
if meaningful_terms:
|
||
# Build score expression: longer terms = higher weight
|
||
score_parts = []
|
||
like_clauses = []
|
||
params = []
|
||
for t in meaningful_terms:
|
||
term_len = len(t)
|
||
score_parts.append(
|
||
f"(CASE WHEN LOWER(name) LIKE ? OR LOWER(halle) LIKE ? OR LOWER(stand) LIKE ? "
|
||
f"OR LOWER(land) LIKE ? OR LOWER(info) LIKE ? OR LOWER(themen) LIKE ? THEN {term_len} ELSE 0 END)"
|
||
)
|
||
like_clauses.append(
|
||
"(LOWER(name) LIKE ? OR LOWER(halle) LIKE ? OR LOWER(stand) LIKE ? "
|
||
"OR LOWER(land) LIKE ? OR LOWER(info) LIKE ? OR LOWER(themen) LIKE ?)"
|
||
)
|
||
# Push params: first 6 for scoring, then 6 for WHERE
|
||
params.extend([f'%{t}%'] * 6)
|
||
# Re-add WHERE params after score params
|
||
params.extend(params[:]) # duplicate for WHERE clause
|
||
score_expr = ' + '.join(score_parts)
|
||
sql = (
|
||
f"SELECT name, halle, stand, land, info, web, themen, ({score_expr}) AS relevance "
|
||
"FROM aussteller "
|
||
f"WHERE {' OR '.join(like_clauses)} "
|
||
"ORDER BY relevance DESC LIMIT 30"
|
||
)
|
||
try:
|
||
aussteller_rows = db.execute(sql, params).fetchall()
|
||
except Exception:
|
||
aussteller_rows = []
|
||
|
||
if aussteller_rows:
|
||
# Score and filter exhibitors by keyword match (already SQL-filtered)
|
||
scored = []
|
||
for a in aussteller_rows:
|
||
full_text = f"{a['name'] or ''} {a['halle'] or ''} {a['stand'] or ''} {a['land'] or ''} {a['info'] or ''} {a['themen'] or ''}".lower()
|
||
score = sum(1 for t in meaningful_terms if t in full_text)
|
||
scored.append((score, a))
|
||
scored.sort(key=lambda x: -x[0])
|
||
# Merge exhibitors with similar names (case-insensitive substring match)
|
||
merged = {}
|
||
merge_order = []
|
||
for score, a in scored:
|
||
name_lower = (a['name'] or '').strip().lower()
|
||
found = False
|
||
for existing_name in merge_order:
|
||
if name_lower in existing_name or existing_name in name_lower:
|
||
existing_stands = merged[existing_name]['stand']
|
||
new_stands = (a['stand'] or '').strip()
|
||
all_stands = set(s.strip() for s in (existing_stands + '|' + new_stands).split('|') if s.strip())
|
||
merged[existing_name]['stand'] = '|'.join(sorted(all_stands))
|
||
found = True
|
||
break
|
||
if not found:
|
||
merge_order.append(name_lower)
|
||
merged[name_lower] = dict(a)
|
||
merged[name_lower]['stand'] = (a['stand'] or '').strip()
|
||
merged[name_lower]['_score'] = score
|
||
|
||
new_scored = [(merged[n]['_score'], merged[n]) for n in merge_order]
|
||
new_scored.sort(key=lambda x: -x[0])
|
||
for _, a in new_scored[:20]:
|
||
stands_raw = (a['stand'] or '').strip()
|
||
stands_list = [s.strip() for s in stands_raw.split('|') if s.strip()]
|
||
halle_stand = ' | '.join(stands_list) if len(stands_list) > 1 else (stands_list[0] if stands_list else '')
|
||
land = a['land'] or ''
|
||
info = (a['info'] or '').strip()[:100]
|
||
web = a['web'] or ''
|
||
context_parts.append(
|
||
f"[Aussteller] {a['name']}"
|
||
+ (f" | {halle_stand}" if halle_stand else '')
|
||
+ (f" | {land}" if land else '')
|
||
+ (f" | {info}" if info else '')
|
||
+ (f" | Web: {web}" if web else '')
|
||
)
|
||
|
||
# Search neuheiten table using SQL LIKE filters with relevance scoring (OOM-safe)
|
||
neuheiten_rows = []
|
||
if meaningful_terms:
|
||
score_parts_n = []
|
||
like_clauses_n = []
|
||
params_n = []
|
||
for t in meaningful_terms:
|
||
term_len = len(t)
|
||
score_parts_n.append(
|
||
f"(CASE WHEN LOWER(titel) LIKE ? OR LOWER(untertitel) LIKE ? OR LOWER(halle) LIKE ? "
|
||
f"OR LOWER(stand) LIKE ? OR LOWER(info_text) LIKE ? OR LOWER(themen) LIKE ? THEN {term_len} ELSE 0 END)"
|
||
)
|
||
like_clauses_n.append(
|
||
"(LOWER(titel) LIKE ? OR LOWER(untertitel) LIKE ? OR LOWER(halle) LIKE ? "
|
||
"OR LOWER(stand) LIKE ? OR LOWER(info_text) LIKE ? OR LOWER(themen) LIKE ?)"
|
||
)
|
||
params_n.extend([f'%{t}%'] * 6)
|
||
params_n.extend(params_n[:]) # duplicate for WHERE clause
|
||
score_expr_n = ' + '.join(score_parts_n)
|
||
sql_n = (
|
||
f"SELECT titel, untertitel, halle, stand, info_text, themen, web, flags, ({score_expr_n}) AS relevance "
|
||
"FROM neuheiten "
|
||
f"WHERE {' OR '.join(like_clauses_n)} "
|
||
"ORDER BY relevance DESC LIMIT 30"
|
||
)
|
||
try:
|
||
neuheiten_rows = db.execute(sql_n, params_n).fetchall()
|
||
except Exception:
|
||
neuheiten_rows = []
|
||
|
||
if neuheiten_rows:
|
||
scored_n = []
|
||
for n in neuheiten_rows:
|
||
full_text = f"{n['titel'] or ''} {n['untertitel'] or ''} {n['halle'] or ''} {n['stand'] or ''} {n['info_text'] or ''} {n['themen'] or ''}".lower()
|
||
score = sum(1 for t in meaningful_terms if t in full_text)
|
||
if score > 0:
|
||
scored_n.append((score, n))
|
||
scored_n.sort(key=lambda x: -x[0])
|
||
for _, n in scored_n[:20]:
|
||
halle_stand = f"{n['halle'] or ''} {n['stand'] or ''}".strip()
|
||
verlag = n['untertitel'] or ''
|
||
themen = (n['themen'] or '').strip()
|
||
ausverkauft = 'AUSVERKAUFT' if (n['flags'] or 0) & 1 else ''
|
||
context_parts.append(
|
||
f"[Neuheit] {n['titel']}"
|
||
+ (f" | Verlag: {verlag}" if verlag else '')
|
||
+ (f" | {halle_stand}" if halle_stand else '')
|
||
+ (f" | 🔴 {ausverkauft}" if ausverkauft else '')
|
||
+ (f" | {themen}" if themen else '')
|
||
)
|
||
|
||
# Also search published submissions for ausverkauft stories with matching spiel_titel
|
||
if meaningful_terms:
|
||
ausverkauft_subs = []
|
||
try:
|
||
for t in meaningful_terms:
|
||
rows = db.execute(
|
||
"SELECT id, spiel_titel, content, halle, published_at FROM submissions "
|
||
"WHERE status='published' AND deleted_at IS NULL "
|
||
"AND category='ausverkauft' AND spiel_titel IS NOT NULL "
|
||
"AND LOWER(spiel_titel) LIKE ? LIMIT 5",
|
||
(f"%{t}%",),
|
||
).fetchall()
|
||
ausverkauft_subs.extend(rows)
|
||
except Exception:
|
||
pass
|
||
# Deduplicate by spiel_titel
|
||
seen_titles = set()
|
||
for r in ausverkauft_subs[:10]:
|
||
title = (r['spiel_titel'] or '').strip()
|
||
if title and title.lower() not in seen_titles:
|
||
seen_titles.add(title.lower())
|
||
halle = r['halle'] or ''
|
||
snippet = (r['content'] or '').strip()[:120]
|
||
context_parts.append(
|
||
f"[Beitrag #{r['id']}] 🔴 AUSVERKAUFT: {title}"
|
||
+ (f" | {halle}" if halle else '')
|
||
+ (f" | {snippet}" if snippet else '')
|
||
)
|
||
context = '\n'.join(context_parts)
|
||
|
||
# System prompt
|
||
system = (
|
||
"Du bist der BSN Assistent von boardgame.network / 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"
|
||
"- [Aussteller]-Einträge enthalten Verlage/Shops mit Halle, Stand und Land.\n"
|
||
"- [Neuheit]-Einträge enthalten Brettspiel-Neuheiten mit Verlag, Halle, Stand und Kategorien.\n"
|
||
"- [Neuheit] mit 'AUSVERKAUFT' = am Stand vergriffen — erwähne das aktiv.\n"
|
||
"- [Beitrag #ID]-Einträge = Community-Beiträge und Ausverkauft-Meldungen.\n"
|
||
"- Verlinke Community-Beiträge IMMER so: [Kurzbeschreibung](/beitrag/ID)\n"
|
||
" Beispiel: 'Lies [diesen Erfahrungsbericht](/beitrag/53)!'\n"
|
||
" Nimm die ID aus dem [Beitrag #ID]-Tag im Kontext.\n\n"
|
||
f"KONTEXT (veröffentlichte Community-Beiträge + SPIEL Essen Aussteller):\n{context[:10000]}"
|
||
)
|
||
|
||
# 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': 'BSN 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 BSN Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'})
|
||
reply = r.json()["choices"][0]["message"]["content"].strip()
|
||
# Markdown-Links [text](url) → klickbare HTML-Links
|
||
reply = re.sub(
|
||
r'\[([^\]]+)\]\((https?://[^)]+)\)',
|
||
r'<a href="\2" target="_blank" rel="noopener" style="color:#20228a;text-decoration:underline;">\1</a>',
|
||
reply
|
||
)
|
||
# Relative Links /beitrag/ID
|
||
reply = re.sub(
|
||
r'\[([^\]]+)\]\((/beitrag/\d+)\)',
|
||
r'<a href="\2" style="color:#20228a;text-decoration:underline;">\1</a>',
|
||
reply
|
||
)
|
||
# Zeilenumbrüche in <br> für innerHTML-Darstellung
|
||
reply = reply.replace('\n', '<br>')
|
||
except Exception:
|
||
return jsonify({'reply': 'Der BSN Assistent antwortet gerade nicht. Bitte versuch es später nochmal.'})
|
||
|
||
return jsonify({'reply': reply})
|
||
|
||
|
||
@app.route('/api/speak', methods=['POST'])
|
||
def api_speak():
|
||
"""Generate TTS audio via Piper (Thorsten voice) and return as WAV."""
|
||
import subprocess
|
||
import tempfile
|
||
data = request.get_json(force=True, silent=True) or {}
|
||
text = (data.get('text') or '').strip()
|
||
if not text or len(text) > 2000:
|
||
return jsonify({'error': 'Text required, max 2000 chars'}), 400
|
||
|
||
model = '/home/hermes/.local/share/piper-voices/de_DE-thorsten-medium.onnx'
|
||
if not os.path.exists(model):
|
||
model = '/home/hermes/.hermes/cache/piper-voices/de_DE-thorsten-medium.onnx'
|
||
if not os.path.exists(model):
|
||
return jsonify({'error': 'Thorsten voice not found'}), 500
|
||
|
||
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
||
tmp_path = tmp.name
|
||
|
||
try:
|
||
env = os.environ.copy()
|
||
env['LD_LIBRARY_PATH'] = '/home/hermes/.local/lib:' + env.get('LD_LIBRARY_PATH', '')
|
||
env['ESPEAK_DATA_PATH'] = '/home/hermes/.local/share/espeak-ng-data'
|
||
result = subprocess.run(
|
||
['/home/hermes/.local/bin/piper', '--model', model, '--output_file', tmp_path],
|
||
input=text.encode('utf-8'),
|
||
capture_output=True,
|
||
timeout=30,
|
||
env=env,
|
||
)
|
||
if result.returncode != 0:
|
||
return jsonify({'error': 'TTS generation failed'}), 500
|
||
|
||
from flask import send_file
|
||
response = send_file(tmp_path, mimetype='audio/wav', as_attachment=False)
|
||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||
return response
|
||
except Exception as e:
|
||
return jsonify({'error': f'TTS failed: {str(e)}'}), 500
|
||
finally:
|
||
# Clean up temp file after response is sent
|
||
import threading
|
||
threading.Timer(5.0, lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None).start()
|
||
|
||
|
||
@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]
|
||
email = (request.form.get('email') or '').strip()[:200]
|
||
phone = (request.form.get('phone') or '').strip()[:50]
|
||
|
||
# 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
|
||
|
||
# Require email OR phone for notifications
|
||
if not email and not phone:
|
||
return jsonify({'error': 'Bitte E-Mail-Adresse oder Handynummer angeben, damit wir dich bei Antworten benachrichtigen können.'}), 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
|
||
|
||
# Hash phone for privacy (keep cleartext for notifications)
|
||
phone_hash_val = None
|
||
if phone:
|
||
ph, _ = _hash_phone(phone)
|
||
phone_hash_val = ph
|
||
|
||
# Validate halle
|
||
halle_valid = None
|
||
if halle:
|
||
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
|
||
|
||
# #out detection for web form
|
||
category = None
|
||
if text and "#out" in text.lower():
|
||
category = "ausverkauft"
|
||
if not spiel_titel:
|
||
idx = text.lower().find("#out")
|
||
after = text[idx + 4:].strip()
|
||
if after:
|
||
spiel_titel = re.split(r'[.!?\n]| - | – | — | ist | war | in ', after, maxsplit=1)[0].strip()[:200]
|
||
|
||
db.execute(
|
||
"""INSERT INTO submissions
|
||
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, email, phone, phone_hash, user_id)
|
||
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?, ?, ?)""",
|
||
('web', sender_id, sender_name, msg_type, text or None, media_path, halle_valid, category, spiel_titel or None, email or None, phone or None,
|
||
phone_hash_val,
|
||
(_get_user_from_session(db) or {}).get('id')),
|
||
)
|
||
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
|
||
|
||
# Track view
|
||
db.execute("UPDATE submissions SET views = views + 1 WHERE id = ?", (sub_id,))
|
||
db.commit()
|
||
|
||
# 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:
|
||
basename = os.path.basename(path)
|
||
src = f"/media/{basename}"
|
||
if mime.startswith("image/"):
|
||
media_html += f'<img src="{src}" alt="Bild" loading="lazy" style="width:100%;height:auto;border-radius:12px;margin:0.5rem 0;">'
|
||
elif mime.startswith("video/"):
|
||
thumb = generate_video_thumbnail(path)
|
||
poster = f' poster="/media/{os.path.basename(thumb)}"' if thumb else ""
|
||
media_html += (
|
||
f'<video controls playsinline{poster} 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
|
||
|
||
_detail_svg_icons = {
|
||
"text": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:6px;"><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:6px;"><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:6px;"><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:6px;"><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:6px;"><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 = _detail_svg_icons.get(row["type"], _detail_svg_icons["document"])
|
||
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()
|
||
|
||
# Comments
|
||
comment_rows = db.execute(
|
||
"SELECT id, author_name, content, created_at, parent_id, likes FROM comments WHERE submission_id = ? AND status='published' ORDER BY created_at ASC",
|
||
(sub_id,),
|
||
).fetchall()
|
||
|
||
# Build nested structure: top-level + children
|
||
top_level = [c for c in comment_rows if not c["parent_id"]]
|
||
children = {}
|
||
for c in comment_rows:
|
||
if c["parent_id"]:
|
||
children.setdefault(c["parent_id"], []).append(c)
|
||
|
||
def render_comments(comments, indent=0):
|
||
html = ""
|
||
for c in comments:
|
||
c_author = (c["author_name"] or "Anonym").strip()
|
||
c_date = c["created_at"] or ""
|
||
try:
|
||
cd = datetime.strptime(c_date, "%Y-%m-%d %H:%M:%S")
|
||
c_date = cd.strftime("%d.%m.%y %H:%M")
|
||
except Exception:
|
||
pass
|
||
margin = "margin-left:1.5rem;" if indent > 0 else ""
|
||
html += f"""<div class="comment-item" style="{margin}">
|
||
<div class="comment-header"><strong class="comment-author">{c_author}</strong> <span class="comment-date">{c_date}</span></div>
|
||
<p class="comment-text">{c["content"]}</p>
|
||
<div class="comment-actions">
|
||
<button class="comment-like-btn" data-cid="{c['id']}" data-clikes="{c['likes'] or 0}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:3px;"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> <span class="clike-count">{c['likes'] or 0}</span></button>
|
||
<button class="reply-btn" onclick="replyTo({c['id']}, '{c_author}')">↩ Antworten</button>
|
||
</div>
|
||
</div>"""
|
||
# Recursively render children
|
||
if c["id"] in children:
|
||
html += render_comments(children[c["id"]], indent + 1)
|
||
return html
|
||
|
||
comments_html = render_comments(top_level)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||
<title>Beitrag — BSN Community</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:#F9FAFB; color:#1F2937; min-height:100vh; -webkit-font-smoothing:antialiased; }}
|
||
.site-header {{ background:#1F2937; padding:1rem 2rem; display:flex; align-items:center; gap:0.5rem; position:sticky; top:0; z-index:100; }}
|
||
.nav-toggle {{ background:none; border:none; color:#D1D5DB; font-size:1.3rem; cursor:pointer; padding:0.3rem 0.4rem; line-height:1; margin-right:0.5rem; transition:opacity 0.2s; }}
|
||
.nav-toggle:hover {{ opacity:0.7; }}
|
||
.offcanvas-backdrop {{ position:fixed; inset:0; background:rgba(0,0,0,0.3); z-index:200; opacity:0; pointer-events:none; transition:opacity 0.3s ease; }}
|
||
.offcanvas-backdrop.open {{ opacity:1; pointer-events:auto; }}
|
||
.offcanvas-nav {{ position:fixed; top:0; left:0; width:280px; height:100%; height:100dvh; background:#fff; z-index:201; transform:translateX(-100%); transition:transform 0.3s cubic-bezier(0.4,0,0.2,1); box-shadow:4px 0 24px rgba(0,0,0,0.1); display:flex; flex-direction:column; }}
|
||
.offcanvas-nav.open {{ transform:translateX(0); }}
|
||
.offcanvas-nav-header {{ background:#1F2937; color:#F9FAFB; padding:1.2rem 1.5rem; display:flex; align-items:center; justify-content:space-between; }}
|
||
.offcanvas-nav-header .nav-logo {{ font-weight:800; font-size:1rem; }}
|
||
.offcanvas-nav-header .nav-close {{ background:none; border:none; color:#D1D5DB; font-size:1.4rem; cursor:pointer; line-height:1; }}
|
||
.offcanvas-nav-links {{ list-style:none; padding:1rem 0; flex:1; }}
|
||
.offcanvas-nav-links a {{ display:block; padding:0.75rem 1.5rem; color:#1F2937; text-decoration:none; font-size:0.95rem; font-weight:500; transition:background 0.15s; }}
|
||
.offcanvas-nav-links a:hover {{ background:#F3F4F6; }}
|
||
.offcanvas-nav-links .nav-section {{ padding:0.5rem 1.5rem 0.2rem; font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#9CA3AF; }}
|
||
.offcanvas-nav-footer {{ padding:1rem 1.5rem; border-top:1px solid #E5E7EB; font-size:0.75rem; color:#9CA3AF; }}
|
||
.site-logo {{ display:flex; align-items:center; gap:0.6rem; text-decoration:none; color:#F9FAFB; }}
|
||
.site-logo .logo-icon {{ width:40px; height:40px; background:#374151; 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:#F9FAFB; }}
|
||
.site-logo-sub {{ font-size:0.7rem; font-weight:500; color:#9CA3AF; letter-spacing:0.03em; margin-left:0.5rem; white-space:nowrap; }}
|
||
.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); position:relative; }}
|
||
.detail-ausverkauft-banner {{ position:absolute; top:0; left:0; right:0; z-index:2; background:linear-gradient(180deg,#dc3545 0%,#b02a37 100%); color:#fff; padding:0.5rem 1rem; font-size:0.8rem; font-weight:800; letter-spacing:0.12em; text-align:center; border-radius:16px 16px 0 0; display:flex; align-items:center; justify-content:center; gap:0.5rem; flex-wrap:wrap; }}
|
||
.detail-ausverkauft-banner .badge-title {{ font-weight:500; letter-spacing:0.02em; font-size:0.72rem; opacity:0.9; }}
|
||
.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-card.has-banner .detail-halle {{ margin-top:2.8rem; }}
|
||
.comments-section {{ max-width:720px; margin:2rem auto 0; padding:0 1.5rem; }}
|
||
.comments-heading {{ font-size:1rem; font-weight:700; color:#1a1a2e; margin-bottom:1rem; }}
|
||
.comment-item {{ background:#f8f6f0; border-radius:10px; padding:0.8rem 1rem; margin-bottom:0.6rem; }}
|
||
.comment-header {{ display:flex; align-items:baseline; gap:0.5rem; margin-bottom:0.3rem; }}
|
||
.comment-author {{ font-size:0.85rem; color:#20228a; }}
|
||
.comment-date {{ font-size:0.7rem; color:#aaa; }}
|
||
.comment-text {{ font-size:0.9rem; color:#333; line-height:1.5; white-space:pre-wrap; word-break:break-word; }}
|
||
.comment-form {{ margin-top:1.5rem; display:flex; flex-direction:column; gap:0.6rem; }}
|
||
.comment-input {{ padding:0.6rem 0.9rem; border:1.5px solid #ddd; border-radius:10px; font-size:0.85rem; font-family:inherit; }}
|
||
.comment-input:focus {{ outline:none; border-color:#20228a; }}
|
||
.comment-textarea {{ padding:0.6rem 0.9rem; border:1.5px solid #ddd; border-radius:10px; font-size:0.85rem; font-family:inherit; min-height:80px; resize:vertical; }}
|
||
.comment-textarea:focus {{ outline:none; border-color:#20228a; }}
|
||
.comment-submit-btn {{ align-self:flex-end; background:#20228a; color:#fff; border:none; border-radius:10px; padding:0.55rem 1.5rem; font-size:0.85rem; font-weight:600; cursor:pointer; font-family:inherit; transition:background 0.15s; }}
|
||
.comment-submit-btn:hover {{ background:#161e6e; }}
|
||
.comment-submit-btn:active {{ transform:scale(0.97); }}
|
||
.comment-submit-btn:disabled {{ opacity:0.6; pointer-events:none; }}
|
||
.reply-btn {{ background:none; border:1px solid #ccc; border-radius:6px; padding:0.2rem 0.6rem; font-size:0.72rem; color:#666; cursor:pointer; margin-top:0.3rem; font-family:inherit; transition:all 0.15s; }}
|
||
.reply-btn:hover {{ border-color:#20228a; color:#20228a; background:rgba(32,34,138,0.04); }}
|
||
.comment-actions {{ display:flex; align-items:center; gap:0.4rem; margin-top:0.4rem; }}
|
||
.comment-like-btn {{ background:none; border:1.5px solid #e0dcd3; border-radius:999px; padding:0.15rem 0.5rem; font-size:0.68rem; cursor:pointer; color:#888; font-family:inherit; transition:all 0.15s; display:inline-flex; align-items:center; -webkit-tap-highlight-color:transparent; }}
|
||
.comment-like-btn:hover {{ border-color:#20228a; color:#20228a; background:rgba(32,34,138,0.05); }}
|
||
.comment-like-btn.liked {{ background:#20228a; color:#fff; border-color:#20228a; font-weight:600; }}
|
||
.comment-like-btn.liked svg, .comment-like-btn.liked svg path {{ stroke:#fff !important; }}
|
||
.comment-like-btn:active {{ transform:scale(0.94); }}
|
||
.reply-info {{ font-size:0.75rem; color:#20228a; margin-bottom:0.3rem; font-weight:600; display:none; }}
|
||
.comment-email, .comment-phone {{ padding:0.6rem 0.9rem; border:1.5px solid #ddd; border-radius:10px; font-size:0.85rem; font-family:inherit; }}
|
||
.comment-email:focus, .comment-phone:focus {{ outline:none; border-color:#20228a; }}
|
||
.comment-field-hint {{ font-size:0.7rem; color:#999; margin-top:-0.3rem; margin-bottom:0.3rem; }}
|
||
.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; }}
|
||
}}
|
||
|
||
.detail-like-btn:hover {{ border-color:#20228a; color:#20228a; background:rgba(32,34,138,0.05); }}
|
||
.detail-like-btn.liked {{ background:#20228a; color:#fff; border-color:#20228a; font-weight:600; }}
|
||
.detail-like-btn.liked svg, .detail-like-btn.liked svg path {{ stroke:#fff !important; }}
|
||
.detail-like-btn:active {{ transform:scale(0.94); }}</style>
|
||
</head>
|
||
<body>
|
||
<header class="site-header">
|
||
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
||
<a href="/" class="site-logo">
|
||
<img src="/static/logo.png" alt="Brettspiel News" style="height:30px;width:auto;display:block;" width="160" height="30">
|
||
<span class="site-logo-sub">boardgame.network</span>
|
||
</a>
|
||
</header>
|
||
<main class="detail-container">
|
||
<a href="/" class="back-link">← Zurück zur Übersicht</a>
|
||
<article class="detail-card{(" has-banner" if row["category"] == "ausverkauft" else "")}">
|
||
{"".join([f'<div class="detail-halle"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" style="vertical-align:middle;margin-right:2px;"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> {row["halle"]}</div>' if row["halle"] else '', f'<div class="detail-ausverkauft-banner"><span class="badge-icon">🚫</span> AUSVERKAUFT{("<span class=\"badge-title\"> · " + row["spiel_titel"] + "</span>") if row["spiel_titel"] else ""}</div>' if row["category"] == "ausverkauft" 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 ''])}
|
||
<div style="display:flex;align-items:center;gap:1rem;margin-top:1.5rem;padding-top:1rem;border-top:1px solid #f0ede6;">
|
||
<span style="font-size:0.8rem;color:#999;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#999" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:2px;"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> {row['views'] or 0} Aufrufe</span>
|
||
<button id="detail-like-btn" data-id="{sub_id}" class="detail-like-btn"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#20228a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:2px;"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> {row['likes'] or 0}</button>
|
||
</div>
|
||
</article>
|
||
<section class="comments-section">
|
||
<h3 class="comments-heading">💬 Kommentare ({len(comment_rows)})</h3>
|
||
{comments_html}
|
||
<form id="commentForm" class="comment-form" onsubmit="return submitComment(event, {sub_id})">
|
||
<div id="replyInfo" class="reply-info">↩ Antwort an <span id="replyTarget"></span> <a href="#" onclick="cancelReply()" style="color:#c62828;font-size:0.7rem;">× abbrechen</a></div>
|
||
<input type="hidden" name="parent_id" id="commentParentId" value="">
|
||
<input type="text" name="author" placeholder="Dein Name (optional)" maxlength="80" class="comment-input">
|
||
<textarea name="content" placeholder="Schreib einen Kommentar..." maxlength="2000" required class="comment-textarea"></textarea>
|
||
<div class="comment-field-hint"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#20228a" stroke-width="2" stroke-linecap="round" style="vertical-align:middle;margin-right:4px;"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> Damit wir dich bei Antworten benachrichtigen können (E-Mail oder Nummer erforderlich):</div>
|
||
<input type="email" name="email" placeholder="E-Mail-Adresse" maxlength="200" class="comment-email">
|
||
<input type="tel" name="phone" placeholder="Handynummer (für WhatsApp-Benachrichtigung)" maxlength="50" class="comment-phone">
|
||
<button type="submit" class="comment-submit-btn">Kommentar senden</button>
|
||
</form>
|
||
<div id="commentStatus" style="display:none;margin-top:0.5rem;padding:0.5rem;border-radius:8px;font-size:0.85rem;"></div>
|
||
</section>
|
||
</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>
|
||
<script>
|
||
(function(){{
|
||
var btn = document.getElementById('detail-like-btn');
|
||
if (!btn) return;
|
||
|
||
var likedPosts = JSON.parse(localStorage.getItem('likedPosts') || '[]');
|
||
var id = parseInt(btn.getAttribute('data-id'));
|
||
if (likedPosts.indexOf(id) !== -1) {{
|
||
btn.classList.add('liked');
|
||
btn.style.background = '#20228a';
|
||
btn.style.color = '#fff';
|
||
btn.style.borderColor = '#20228a';
|
||
}}
|
||
|
||
btn.addEventListener('click', function() {{
|
||
if (this.classList.contains('liked')) return;
|
||
this.disabled = true;
|
||
fetch('/api/like/' + id, {{method:'POST'}})
|
||
.then(function(r) {{ return r.json(); }})
|
||
.then(function(data) {{
|
||
if (!data.already) {{
|
||
btn.classList.add('liked');
|
||
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#20228a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:2px;"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> ' + data.likes;
|
||
btn.style.background = '#20228a';
|
||
btn.style.color = '#fff';
|
||
btn.style.borderColor = '#20228a';
|
||
likedPosts.push(id);
|
||
localStorage.setItem('likedPosts', JSON.stringify(likedPosts));
|
||
}}
|
||
btn.disabled = false;
|
||
}})
|
||
.catch(function() {{ btn.disabled = false; }});
|
||
}});
|
||
}})();
|
||
function replyTo(commentId, authorName) {{
|
||
document.getElementById('commentParentId').value = commentId;
|
||
document.getElementById('replyTarget').textContent = authorName;
|
||
document.getElementById('replyInfo').style.display = 'block';
|
||
document.getElementById('commentForm').content.focus();
|
||
window.scrollTo({{ top: document.getElementById('commentForm').offsetTop - 100, behavior: 'smooth' }});
|
||
}}
|
||
function cancelReply() {{
|
||
document.getElementById('commentParentId').value = '';
|
||
document.getElementById('replyTarget').textContent = '';
|
||
document.getElementById('replyInfo').style.display = 'none';
|
||
return false;
|
||
}}
|
||
function submitComment(event, subId) {{
|
||
event.preventDefault();
|
||
var form = document.getElementById('commentForm');
|
||
var status = document.getElementById('commentStatus');
|
||
var btn = form.querySelector('.comment-submit-btn');
|
||
var author = form.author.value.trim();
|
||
var content = form.content.value.trim();
|
||
var email = form.email.value.trim();
|
||
var phone = form.phone.value.trim();
|
||
var parentId = form.parent_id.value;
|
||
if (!content) return false;
|
||
if (!email && !phone) {{
|
||
status.style.display = 'block';
|
||
status.style.background = '#fff3e0';
|
||
status.style.color = '#e65100';
|
||
status.textContent = '⚠️ Bitte E-Mail oder Handynummer angeben, damit wir dich benachrichtigen können.';
|
||
return false;
|
||
}}
|
||
btn.disabled = true;
|
||
btn.textContent = 'Wird gesendet...';
|
||
status.style.display = 'none';
|
||
var body = {{author: author, content: content, email: email, phone: phone}};
|
||
if (parentId) body.parent_id = parseInt(parentId);
|
||
fetch('/api/comment/' + subId, {{
|
||
method: 'POST',
|
||
headers: {{'Content-Type': 'application/json'}},
|
||
body: JSON.stringify(body)
|
||
}})
|
||
.then(function(r) {{ return r.json(); }})
|
||
.then(function(data) {{
|
||
if (data.ok) {{
|
||
status.style.display = 'block';
|
||
status.style.background = '#e8f5e9';
|
||
status.style.color = '#2e7d32';
|
||
status.textContent = '✅ Kommentar gesendet!';
|
||
form.reset();
|
||
cancelReply();
|
||
// Reload after short delay to show the new comment
|
||
setTimeout(function() {{ location.reload(); }}, 1200);
|
||
}} else {{
|
||
status.style.display = 'block';
|
||
status.style.background = '#fce4ec';
|
||
status.style.color = '#c62828';
|
||
status.textContent = '❌ ' + (data.error || 'Fehler');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Kommentar senden';
|
||
}}
|
||
}})
|
||
.catch(function() {{
|
||
status.style.display = 'block';
|
||
status.style.background = '#fce4ec';
|
||
status.style.color = '#c62828';
|
||
status.textContent = '❌ Netzwerkfehler';
|
||
btn.disabled = false;
|
||
btn.textContent = 'Kommentar senden';
|
||
}});
|
||
return false;
|
||
}}
|
||
|
||
// ── Comment Like Buttons ────────────────────────────────────
|
||
(function() {{
|
||
var likedComments = JSON.parse(localStorage.getItem('likedComments') || '[]');
|
||
document.querySelectorAll('.comment-like-btn').forEach(function(btn) {{
|
||
var cid = parseInt(btn.getAttribute('data-cid'));
|
||
if (likedComments.indexOf(cid) !== -1) {{
|
||
btn.classList.add('liked');
|
||
}}
|
||
btn.addEventListener('click', function() {{
|
||
var id = parseInt(this.getAttribute('data-cid'));
|
||
if (likedComments.indexOf(id) !== -1) return;
|
||
fetch('/api/comment-like/' + id, {{method:'POST'}})
|
||
.then(function(r) {{ return r.json(); }})
|
||
.then(function(data) {{
|
||
if (!data.already) {{
|
||
this.classList.add('liked');
|
||
var count = this.querySelector('.clike-count');
|
||
if (count) count.textContent = data.likes;
|
||
this.setAttribute('data-clikes', data.likes);
|
||
likedComments.push(id);
|
||
localStorage.setItem('likedComments', JSON.stringify(likedComments));
|
||
}}
|
||
}}.bind(this))
|
||
.catch(function() {{}});
|
||
}});
|
||
}});
|
||
}})();
|
||
</script>
|
||
|
||
<div class="offcanvas-backdrop" id="offcanvasBackdrop"></div>
|
||
<nav class="offcanvas-nav" id="offcanvasNav">
|
||
<div class="offcanvas-nav-header">
|
||
<span class="nav-logo">BSN Community</span>
|
||
<button class="nav-close" id="navClose" aria-label="Menü schließen">✕</button>
|
||
</div>
|
||
<ul class="offcanvas-nav-links">
|
||
<li class="nav-section">Navigation</li>
|
||
<li><a href="/">Startseite</a></li>
|
||
<li class="nav-section">Community</li>
|
||
<li><a href="/register">📋 Registrieren</a></li>
|
||
<li><a href="/login">🔐 Login</a></li>
|
||
<li class="nav-section">Rechtliches</li>
|
||
<li><a href="/impressum">Impressum</a></li>
|
||
<li><a href="/datenschutz">Datenschutzerklärung</a></li>
|
||
<li><a href="/agb">AGB</a></li>
|
||
</ul>
|
||
<div class="offcanvas-nav-footer">
|
||
© 2026 Brettspiel-News.de
|
||
</div>
|
||
</nav>
|
||
|
||
<script>
|
||
(function() {{
|
||
var toggle = document.getElementById('navToggle');
|
||
var close = document.getElementById('navClose');
|
||
var nav = document.getElementById('offcanvasNav');
|
||
var backdrop = document.getElementById('offcanvasBackdrop');
|
||
var main = document.querySelector('main');
|
||
var header = document.querySelector('.site-header');
|
||
|
||
function open() {{
|
||
nav.classList.add('open');
|
||
backdrop.classList.add('open');
|
||
if (main) main.style.filter = 'blur(4px)';
|
||
if (header) header.style.filter = 'blur(4px)';
|
||
}}
|
||
function closeNav() {{
|
||
nav.classList.remove('open');
|
||
backdrop.classList.remove('open');
|
||
if (main) main.style.filter = '';
|
||
if (header) header.style.filter = '';
|
||
}}
|
||
|
||
toggle.addEventListener('click', open);
|
||
close.addEventListener('click', closeNav);
|
||
backdrop.addEventListener('click', closeNav);
|
||
}})();
|
||
</script>
|
||
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
@app.route("/api/like/<int:sub_id>", methods=["POST"])
|
||
def api_like(sub_id: int):
|
||
db = get_db()
|
||
row = db.execute(
|
||
"SELECT id, likes, channel, sender_id, sender_name, email, phone, spiel_titel FROM submissions WHERE id = ? AND status='published' AND deleted_at IS NULL",
|
||
(sub_id,),
|
||
).fetchone()
|
||
if not row:
|
||
return jsonify({"error": "Nicht gefunden"}), 404
|
||
|
||
liked = request.cookies.get(f"liked_{sub_id}", "")
|
||
if liked == "1":
|
||
return jsonify({"likes": row["likes"] or 0, "already": True})
|
||
|
||
db.execute("UPDATE submissions SET likes = likes + 1 WHERE id = ?", (sub_id,))
|
||
db.commit()
|
||
new_likes = db.execute("SELECT likes FROM submissions WHERE id = ?", (sub_id,)).fetchone()[0]
|
||
|
||
# Send notification to submission author
|
||
base = request.host_url.rstrip("/")
|
||
link = f"{base}/beitrag/{sub_id}"
|
||
post_label = row["spiel_titel"] or row["sender_name"] or f"Beitrag #{sub_id}"
|
||
|
||
# WhatsApp notification
|
||
if row["channel"] == "whatsapp" and row["sender_id"]:
|
||
try:
|
||
msg = f"❤️ *Dein Beitrag wurde geliked!*\n\n{post_label}\n\n👍 {new_likes} Likes\n👉 {link}"
|
||
send_whatsapp_message(row["sender_id"], msg)
|
||
except Exception:
|
||
pass
|
||
if row["phone"]:
|
||
try:
|
||
msg = f"❤️ *Dein Beitrag wurde geliked!*\n\n{post_label}\n\n👍 {new_likes} Likes\n👉 {link}"
|
||
send_whatsapp_message(row["phone"], msg)
|
||
except Exception:
|
||
pass
|
||
|
||
# Email notification
|
||
if row["email"]:
|
||
try:
|
||
subject = f"❤️ Dein BSN-Beitrag wurde geliked ({new_likes} Likes)"
|
||
body = (
|
||
f"Hallo,\n\n"
|
||
f"Dein Beitrag \"{post_label}\" hat jetzt {new_likes} Likes.\n\n"
|
||
f"👉 {link}\n\n"
|
||
f"Viele Grüße,\n"
|
||
f"boardgame.network / brettspiel-news.de\n"
|
||
)
|
||
_send_email(row["email"], subject, body)
|
||
except Exception:
|
||
pass
|
||
|
||
resp = jsonify({"likes": new_likes or 0, "already": False})
|
||
resp.set_cookie(f"liked_{sub_id}", "1", max_age=365*24*60*60, httponly=True, samesite="Lax")
|
||
return resp
|
||
|
||
|
||
@app.route("/api/comment-like/<int:comment_id>", methods=["POST"])
|
||
def api_comment_like(comment_id: int):
|
||
"""Like a comment (cookie-based dedup)."""
|
||
db = get_db()
|
||
row = db.execute(
|
||
"SELECT id, likes FROM comments WHERE id = ? AND status='published'",
|
||
(comment_id,),
|
||
).fetchone()
|
||
if not row:
|
||
return jsonify({"error": "Nicht gefunden"}), 404
|
||
|
||
liked = request.cookies.get(f"cliked_{comment_id}", "")
|
||
if liked == "1":
|
||
return jsonify({"likes": row["likes"] or 0, "already": True})
|
||
|
||
db.execute("UPDATE comments SET likes = likes + 1 WHERE id = ?", (comment_id,))
|
||
db.commit()
|
||
new_likes = db.execute("SELECT likes FROM comments WHERE id = ?", (comment_id,)).fetchone()[0]
|
||
|
||
resp = jsonify({"likes": new_likes or 0, "already": False})
|
||
resp.set_cookie(f"cliked_{comment_id}", "1", max_age=365*24*60*60, httponly=True, samesite="Lax")
|
||
return resp
|
||
|
||
|
||
@app.route("/api/comment/<int:sub_id>", methods=["POST"])
|
||
def api_comment(sub_id: int):
|
||
db = get_db()
|
||
row = db.execute(
|
||
"SELECT id, channel, sender_id, sender_name, email, phone FROM submissions WHERE id = ? AND status='published' AND deleted_at IS NULL",
|
||
(sub_id,),
|
||
).fetchone()
|
||
if not row:
|
||
return jsonify({"error": "Beitrag nicht gefunden"}), 404
|
||
|
||
data = request.get_json(force=True, silent=True) or {}
|
||
author = (data.get("author") or "").strip()[:80]
|
||
content = (data.get("content") or "").strip()[:2000]
|
||
email = (data.get("email") or "").strip()[:200]
|
||
phone = (data.get("phone") or "").strip()[:50]
|
||
parent_id = data.get("parent_id")
|
||
if parent_id is not None:
|
||
try:
|
||
parent_id = int(parent_id)
|
||
except (ValueError, TypeError):
|
||
parent_id = None
|
||
|
||
if not content:
|
||
return jsonify({"error": "Kommentar darf nicht leer sein"}), 400
|
||
|
||
# Require email OR phone for notification
|
||
if not email and not phone:
|
||
return jsonify({"error": "Bitte E-Mail-Adresse oder Handynummer angeben, damit wir dich bei Antworten benachrichtigen können."}), 400
|
||
|
||
verification_token = str(uuid.uuid4()) if VERIFICATION_REQUIRED else None
|
||
|
||
db.execute(
|
||
"INSERT INTO comments (submission_id, author_name, content, parent_id, email, phone, status, verification_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(sub_id, author or None, content, parent_id, email or None, phone or None, "pending" if VERIFICATION_REQUIRED else "published", verification_token),
|
||
)
|
||
db.commit()
|
||
comment_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||
|
||
# === VERIFICATION REQUIRED (production mode) ===
|
||
if VERIFICATION_REQUIRED and verification_token:
|
||
base = request.host_url.rstrip("/")
|
||
verify_url = f"{base}/verify-comment/{verification_token}"
|
||
|
||
# Send verification via WhatsApp
|
||
if phone:
|
||
try:
|
||
msg = (
|
||
f"📨 *Bitte bestätige deinen Kommentar!*\n\n"
|
||
f"Jemand (du?) hat auf boardgame.network kommentiert:\n"
|
||
f"_{content[:150]}{'...' if len(content) > 150 else ''}_\n\n"
|
||
f"Klicke hier zum Bestätigen:\n"
|
||
f"👉 {verify_url}\n\n"
|
||
f"Falls du das nicht warst, ignoriere diese Nachricht einfach."
|
||
)
|
||
send_whatsapp_message(phone, msg)
|
||
except Exception:
|
||
pass
|
||
|
||
# Send verification via email
|
||
if email:
|
||
try:
|
||
subject = "📨 Bitte bestätige deinen Kommentar auf boardgame.network"
|
||
body = (
|
||
f"Hallo,\n\n"
|
||
f"jemand (du?) hat auf boardgame.network kommentiert:\n\n"
|
||
f"\"{content[:200]}{'...' if len(content) > 200 else ''}\"\n\n"
|
||
f"Klicke zum Bestätigen:\n"
|
||
f"👉 {verify_url}\n\n"
|
||
f"Falls du das nicht warst, ignoriere diese E-Mail einfach.\n\n"
|
||
f"Viele Grüße,\n"
|
||
f"boardgame.network\n"
|
||
)
|
||
_send_email(email, subject, body)
|
||
except Exception:
|
||
pass
|
||
|
||
return jsonify({"ok": True, "comment_id": comment_id, "pending": True,
|
||
"message": "Bitte bestätige deinen Kommentar über den Link in deiner E-Mail oder WhatsApp-Nachricht."}), 201
|
||
|
||
# === NO VERIFICATION (test system) — immediate moderation + notification ===
|
||
# LLM Moderation
|
||
mod_status, mod_reason = moderate_comment(content)
|
||
db.execute(
|
||
"UPDATE comments SET status=?, moderation_reason=? WHERE id=?",
|
||
(mod_status, mod_reason, comment_id),
|
||
)
|
||
db.commit()
|
||
|
||
if mod_status == "blocked":
|
||
print(f"[moderate] Comment #{comment_id} BLOCKED: {mod_reason}", file=sys.stderr)
|
||
return jsonify({"ok": True, "comment_id": comment_id}), 201
|
||
|
||
# Build notification link
|
||
base = request.host_url.rstrip("/")
|
||
link = f"{base}/beitrag/{sub_id}"
|
||
|
||
# 1. Notify parent comment author (if replying to a comment)
|
||
if parent_id:
|
||
parent = db.execute(
|
||
"SELECT author_name, email, phone FROM comments WHERE id = ? AND submission_id = ? AND status='published'",
|
||
(parent_id, sub_id),
|
||
).fetchone()
|
||
if parent:
|
||
# WhatsApp to parent comment author
|
||
if parent["phone"]:
|
||
try:
|
||
msg = (
|
||
f"💬 *Antwort auf deinen Kommentar!*\n\n"
|
||
f"{'*' + (author or 'Jemand') + '* hat geantwortet' if author else 'Jemand hat geantwortet'}:\n"
|
||
f"_{content[:200]}{'...' if len(content) > 200 else ''}_\n\n"
|
||
f"👉 {link}"
|
||
)
|
||
send_whatsapp_message(parent["phone"], msg)
|
||
except Exception:
|
||
pass
|
||
# Email to parent comment author
|
||
if parent["email"]:
|
||
try:
|
||
subject = "💬 Antwort auf deinen BSN-Kommentar"
|
||
body = (
|
||
f"Hallo,\n\n"
|
||
f"{author or 'Jemand'} hat auf deinen Kommentar geantwortet:\n\n"
|
||
f"\"{content[:300]}{'...' if len(content) > 300 else ''}\"\n\n"
|
||
f"👉 {link}\n\n"
|
||
f"Viele Grüße,\n"
|
||
f"boardgame.network / brettspiel-news.de\n"
|
||
)
|
||
_send_email(parent["email"], subject, body)
|
||
except Exception:
|
||
pass
|
||
|
||
# 2. Notify submission author (unless they're replying to themselves)
|
||
notify_submission_author = True
|
||
if parent_id:
|
||
parent = db.execute(
|
||
"SELECT author_name, email, phone FROM comments WHERE id = ? AND submission_id = ? AND status='published'",
|
||
(parent_id, sub_id),
|
||
).fetchone()
|
||
if parent and (parent["email"] == row["email"] or parent["phone"] == row["phone"]):
|
||
notify_submission_author = False
|
||
|
||
if notify_submission_author:
|
||
if row["channel"] == "whatsapp" and row["sender_id"]:
|
||
try:
|
||
msg = (
|
||
f"💬 *Neuer Kommentar zu deinem Beitrag!*\n\n"
|
||
f"{'*' + (author or 'Jemand') + '* schreibt' if author else 'Jemand hat kommentiert'}:\n"
|
||
f"_{content[:200]}{'...' if len(content) > 200 else ''}_\n\n"
|
||
f"👉 {link}"
|
||
)
|
||
send_whatsapp_message(row["sender_id"], msg)
|
||
except Exception:
|
||
pass
|
||
if row["email"]:
|
||
try:
|
||
subject = f"💬 Neuer Kommentar zu deinem BSN-Beitrag #{sub_id}"
|
||
body = (
|
||
f"Hallo,\n\n"
|
||
f"{author or 'Jemand'} hat deinen Beitrag kommentiert:\n\n"
|
||
f"\"{content[:300]}{'...' if len(content) > 300 else ''}\"\n\n"
|
||
f"👉 {link}\n\n"
|
||
f"Viele Grüße,\n"
|
||
f"boardgame.network / brettspiel-news.de\n"
|
||
)
|
||
_send_email(row["email"], subject, body)
|
||
except Exception:
|
||
pass
|
||
if row["phone"]:
|
||
try:
|
||
msg = (
|
||
f"💬 *Neuer Kommentar zu deinem Beitrag!*\n\n"
|
||
f"{'*' + (author or 'Jemand') + '* schreibt' if author else 'Jemand hat kommentiert'}:\n"
|
||
f"_{content[:200]}{'...' if len(content) > 200 else ''}_\n\n"
|
||
f"👉 {link}"
|
||
)
|
||
send_whatsapp_message(row["phone"], msg)
|
||
except Exception:
|
||
pass
|
||
|
||
return jsonify({"ok": True, "comment_id": comment_id}), 201
|
||
|
||
|
||
@app.route("/verify-comment/<token>")
|
||
def verify_comment(token: str):
|
||
"""Verify a pending comment via token link."""
|
||
db = get_db()
|
||
row = db.execute(
|
||
"SELECT c.*, s.spiel_titel FROM comments c LEFT JOIN submissions s ON c.submission_id = s.id WHERE c.verification_token = ? AND c.status = 'pending'",
|
||
(token,),
|
||
).fetchone()
|
||
if not row:
|
||
return "<h1>Ungültiger Link</h1><p>Dieser Bestätigungslink ist nicht gültig oder wurde bereits verwendet.</p><p><a href='/'>← Zur Startseite</a></p>", 404
|
||
|
||
# Run LLM moderation
|
||
mod_status, mod_reason = moderate_comment(row["content"])
|
||
db.execute(
|
||
"UPDATE comments SET status=?, moderation_reason=?, verification_token=NULL WHERE id=?",
|
||
(mod_status if mod_status == "blocked" else "published", mod_reason, row["id"]),
|
||
)
|
||
db.commit()
|
||
|
||
if mod_status == "blocked":
|
||
return f"<html lang=de><body style='font-family:sans-serif;text-align:center;padding:3rem;'><h1 style='color:#c62828;'>🚫 Kommentar abgelehnt</h1><p>{mod_reason or 'Verstoß gegen Community-Richtlinien'}</p><p><a href='/'>← Zur Startseite</a></p></body></html>", 403
|
||
|
||
# Redirect to the submission detail page
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head><meta charset="UTF-8"><meta http-equiv="refresh" content="3;url=/beitrag/{row['submission_id']}">
|
||
<title>Kommentar bestätigt</title>
|
||
<style>
|
||
body {{font-family:'Inter',sans-serif;text-align:center;padding:3rem;background:#f5f3ee;color:#1a1a2e;}}
|
||
h1 {{color:#20228a;}}
|
||
a {{color:#20228a;}}
|
||
</style></head>
|
||
<body>
|
||
<h1>✅ Kommentar bestätigt!</h1>
|
||
<p>Dein Kommentar ist jetzt auf boardgame.network sichtbar.</p>
|
||
<p>Du wirst in 3 Sekunden weitergeleitet…<br><a href="/beitrag/{row['submission_id']}">→ Direkt zum Beitrag</a></p>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
|
||
# -*- coding: utf-8 -*-
|
||
"""Telegram Bot webhook handler — integrated into app.py"""
|
||
|
||
def _telegram_download_file(file_id):
|
||
"""Download a file from Telegram servers. Returns local path or None."""
|
||
import requests as _r
|
||
if not TELEGRAM_TOKEN:
|
||
return None
|
||
try:
|
||
gr = _r.get("{}/getFile?file_id={}".format(TELEGRAM_API, file_id), timeout=10)
|
||
gr.raise_for_status()
|
||
gdata = gr.json()
|
||
if not gdata.get("ok"):
|
||
print("[telegram] getFile failed: {}".format(gdata), file=sys.stderr)
|
||
return None
|
||
file_path = gdata["result"]["file_path"]
|
||
ext = os.path.splitext(file_path)[1].lower() or ".bin"
|
||
safe_name = "tg_{}{}".format(file_id, ext)
|
||
dest = str(MEDIA_DIR / safe_name)
|
||
dl_url = "https://api.telegram.org/file/bot{}/{}".format(TELEGRAM_TOKEN, file_path)
|
||
fr = _r.get(dl_url, timeout=60)
|
||
fr.raise_for_status()
|
||
with open(dest, "wb") as f:
|
||
f.write(fr.content)
|
||
if ext == ".mov":
|
||
mp4_path = dest + ".mp4"
|
||
import subprocess
|
||
result = subprocess.run(
|
||
["/home/hermes/.local/bin/ffmpeg", "-y", "-i", dest,
|
||
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
|
||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", mp4_path],
|
||
capture_output=True, text=True, timeout=120
|
||
)
|
||
if result.returncode == 0 and os.path.exists(mp4_path):
|
||
os.remove(dest)
|
||
return mp4_path
|
||
return dest
|
||
except Exception as e:
|
||
print("[telegram download error] {}".format(e), file=sys.stderr)
|
||
return None
|
||
|
||
|
||
def _telegram_autoreply(chat_id, sender_name, is_new_user):
|
||
"""Send welcome or thank-you message via Telegram Send API."""
|
||
if not TELEGRAM_TOKEN:
|
||
return
|
||
import requests as _r
|
||
try:
|
||
if is_new_user:
|
||
text = (
|
||
"🎲 Hey {}! Cool, dass du da bist — willkommen in der ".format(sender_name) +
|
||
"brettspiel-news.de Community!\n\n"
|
||
"Schick uns einfach, was dich bewegt: 💬 Text, 🎤 Sprachnachrichten, "
|
||
"🖼️ Fotos oder 🎬 Videos — alles rund um Brettspiele. Wir schauen uns alles an!\n\n"
|
||
"✍️ Nenn uns deinen Vornamen, wenn du magst — das ist deine Einwilligung, "
|
||
"dass wir dich als Quelle nennen, falls wir deinen Beitrag verwenden. "
|
||
"Ohne Namen schreiben wir „anonyme Quelle\".\n\n"
|
||
"🔒 „Meine Daten löschen\" genügt — und alles ist sofort und vollständig weg."
|
||
)
|
||
else:
|
||
text = "📨 Danke {}! Ist angekommen — wir kümmern uns drum.".format(sender_name)
|
||
|
||
_r.post(
|
||
"{}/sendMessage".format(TELEGRAM_API),
|
||
json={"chat_id": chat_id, "text": text},
|
||
timeout=10,
|
||
)
|
||
except Exception as e:
|
||
print("[telegram reply error] {}".format(e), file=sys.stderr)
|
||
|
||
|
||
@app.route("/telegram/webhook", methods=["GET", "POST"])
|
||
def telegram_webhook():
|
||
"""Telegram Bot Webhook. GET=health check, POST=incoming messages."""
|
||
if request.method == "GET":
|
||
if TELEGRAM_TOKEN:
|
||
import requests as _r
|
||
try:
|
||
r = _r.get("{}/getMe".format(TELEGRAM_API), timeout=5)
|
||
data = r.json()
|
||
if data.get("ok"):
|
||
return jsonify({"status": "ok", "bot": data["result"]["username"]})
|
||
except Exception:
|
||
pass
|
||
return jsonify({"status": "ok", "token_configured": True})
|
||
return jsonify({"status": "off", "reason": "TELEGRAM_TOKEN not set"}), 500
|
||
|
||
# POST: Incoming message
|
||
# POST: Incoming message → Queue (asynchrone Verarbeitung)
|
||
body = request.get_json(force=True, silent=True)
|
||
if not body or not TELEGRAM_TOKEN:
|
||
return "OK", 200
|
||
|
||
from webhook_queue import telegram_intake
|
||
db = get_db()
|
||
return telegram_intake(request, db)
|
||
|
||
|
||
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")
|
||
print(f" Telegram: {"configured" if TELEGRAM_TOKEN else "no token"}")
|
||
app.run(host="127.0.0.1", port=PORT, debug=False)
|