feat: Mehrsprachigkeit v2 — Flag-Dropdown auf allen Seiten + localStorage
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 12m22s
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 12m22s
- Sprachwahl per Flag-only Dropdown (🇩🇪🇬🇧🇫🇷🇪🇸) in Header auf Index + Detail - localStorage-Persistenz: Sprache bleibt über Seitenwechsel erhalten - Kommentare werden mitübersetzt und in translations-Tabelle gecached - _lang_dropdown_html() als zentrale Hilfsfunktion - Mobile-First: Dropdown statt 4 Buttons — 44px Touch-Target
This commit is contained in:
@@ -2581,6 +2581,9 @@ def user_profile():
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def public_frontend():
|
def public_frontend():
|
||||||
|
lang = request.args.get("lang", "de")
|
||||||
|
if lang not in ("de", "en", "fr", "es"):
|
||||||
|
lang = "de"
|
||||||
"""Public frontend showing published submissions — SPIEL Essen inspired."""
|
"""Public frontend showing published submissions — SPIEL Essen inspired."""
|
||||||
import base64
|
import base64
|
||||||
db = get_db()
|
db = get_db()
|
||||||
@@ -3174,6 +3177,7 @@ def public_frontend():
|
|||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
||||||
|
""" + _lang_dropdown_html(lang) + """
|
||||||
<a href="/" class="site-logo">
|
<a href="/" class="site-logo">
|
||||||
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;">
|
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;">
|
||||||
<span class="site-logo-sub">boardgame.network</span>
|
<span class="site-logo-sub">boardgame.network</span>
|
||||||
@@ -4029,6 +4033,146 @@ def api_query():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
# Mehrsprachigkeit: Übersetzungs-Funktionen
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
LANG_NAMES = {"en": "Englisch", "fr": "Französisch", "es": "Spanisch"}
|
||||||
|
|
||||||
|
def translate_text(text: str, target_lang: str, content_type: str = "Beitrag") -> str:
|
||||||
|
if not text or not text.strip():
|
||||||
|
return text
|
||||||
|
api_key = os.environ.get("MISTRAL_API_KEY", "")
|
||||||
|
if not api_key:
|
||||||
|
return text
|
||||||
|
lang_name = LANG_NAMES.get(target_lang, target_lang)
|
||||||
|
prompt = (
|
||||||
|
f"Übersetze folgenden {content_type} ins {lang_name}. "
|
||||||
|
f"Nur die Übersetzung zurückgeben, keine Erklärungen. "
|
||||||
|
f"Behalte Zeilenumbrüche und Absätze bei:\n\n{text}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r = requests.post(
|
||||||
|
"https://api.mistral.ai/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
|
json={"model": "ministral-3b-latest", "messages": [
|
||||||
|
{"role": "user", "content": prompt}
|
||||||
|
], "max_tokens": 2000, "temperature": 0.2},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
return r.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return text
|
||||||
|
|
||||||
|
def get_translated_content(db, sub_id: int, target_lang: str, row) -> dict:
|
||||||
|
if target_lang == "de":
|
||||||
|
return {"content": (row["summary"] or row["content"] or "").strip(),
|
||||||
|
"display_text": row["display_text"] or ""}
|
||||||
|
cached = db.execute(
|
||||||
|
"SELECT content, display_text FROM translations WHERE submission_id=? AND lang=? AND comment_id IS NULL",
|
||||||
|
(sub_id, target_lang)
|
||||||
|
).fetchone()
|
||||||
|
if cached:
|
||||||
|
return {"content": cached["content"] or "", "display_text": cached["display_text"] or ""}
|
||||||
|
original_content = (row["summary"] or row["content"] or "").strip()
|
||||||
|
original_display = row["display_text"] or ""
|
||||||
|
translated_content = translate_text(original_content, target_lang, "Beitrag")
|
||||||
|
translated_display = translate_text(original_display, target_lang, "Anzeigetext") if original_display else ""
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR REPLACE INTO translations (submission_id, lang, content, display_text, comment_id) VALUES (?,?,?,?,NULL)",
|
||||||
|
(sub_id, target_lang, translated_content, translated_display)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"content": translated_content, "display_text": translated_display}
|
||||||
|
|
||||||
|
def translate_comments(db, comments: list, sub_id: int, target_lang: str) -> list:
|
||||||
|
if target_lang == "de" or not comments:
|
||||||
|
return comments
|
||||||
|
translated = []
|
||||||
|
for c in comments:
|
||||||
|
cid = c["id"]
|
||||||
|
cached = db.execute(
|
||||||
|
"SELECT content FROM translations WHERE submission_id=? AND lang=? AND comment_id=?",
|
||||||
|
(sub_id, target_lang, cid)
|
||||||
|
).fetchone()
|
||||||
|
if cached and (cached["content"] or "").strip():
|
||||||
|
c_dict = dict(c)
|
||||||
|
c_dict["content"] = cached["content"]
|
||||||
|
translated.append(c_dict)
|
||||||
|
else:
|
||||||
|
original = c["content"]
|
||||||
|
if original and original.strip():
|
||||||
|
tr = translate_text(original, target_lang, "Kommentar")
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR REPLACE INTO translations (submission_id, lang, comment_id, content) VALUES (?,?,?,?)",
|
||||||
|
(sub_id, target_lang, cid, tr)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
c_dict = dict(c)
|
||||||
|
c_dict["content"] = tr
|
||||||
|
else:
|
||||||
|
c_dict = dict(c)
|
||||||
|
translated.append(c_dict)
|
||||||
|
return translated
|
||||||
|
|
||||||
|
def _lang_dropdown_html(lang: str) -> str:
|
||||||
|
"""Flag-only dropdown for language selection — injected into page headers."""
|
||||||
|
flags = {"de": "🇩🇪", "en": "🇬🇧", "fr": "🇫🇷", "es": "🇪🇸"}
|
||||||
|
current_flag = flags.get(lang, "🇩🇪")
|
||||||
|
options = "".join(
|
||||||
|
'<a href="?lang=' + code + '" class="ld-opt' + (' ld-active' if code == lang else '') + '" data-lang="' + code + '"> ' + flags[code] + '</a>'
|
||||||
|
for code in ["de", "en", "fr", "es"]
|
||||||
|
)
|
||||||
|
return """<style>
|
||||||
|
.ld-wrap{position:relative;display:inline-flex;margin-right:0.25rem;flex-shrink:0}
|
||||||
|
.ld-btn{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:8px;color:#D1D5DB;font-size:1.1rem;cursor:pointer;padding:0.18rem 0.35rem;line-height:1}
|
||||||
|
.ld-drop{display:none;position:absolute;top:100%;left:0;background:#1F2937;border:1px solid rgba(255,255,255,0.12);border-radius:8px;overflow:hidden;z-index:300;min-width:44px}
|
||||||
|
.ld-drop.open{display:block}
|
||||||
|
.ld-opt{display:block;padding:0.35rem 0.5rem;text-decoration:none;font-size:1.1rem;text-align:center;transition:background 0.15s}
|
||||||
|
.ld-opt:hover{background:rgba(255,255,255,0.1)}
|
||||||
|
.ld-active{background:rgba(32,34,138,0.35)}
|
||||||
|
</style>
|
||||||
|
<div class="ld-wrap">
|
||||||
|
<button class="ld-btn" id="ldBtn" aria-label="Sprache wählen" title="Sprache">""" + current_flag + """</button>
|
||||||
|
<div class="ld-drop" id="ldDrop">""" + options + """</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var lang = localStorage.getItem("bsn-lang");
|
||||||
|
if (lang && lang !== "de") {
|
||||||
|
var u = new URL(window.location);
|
||||||
|
if (u.searchParams.get("lang") !== lang) {
|
||||||
|
u.searchParams.set("lang", lang);
|
||||||
|
window.location.replace(u.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var btn = document.getElementById("ldBtn");
|
||||||
|
var drop = document.getElementById("ldDrop");
|
||||||
|
if (btn && drop) {
|
||||||
|
btn.addEventListener("click", function(e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
drop.classList.toggle("open");
|
||||||
|
});
|
||||||
|
document.addEventListener("click", function() { drop.classList.remove("open"); });
|
||||||
|
drop.querySelectorAll(".ld-opt").forEach(function(a) {
|
||||||
|
a.addEventListener("click", function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
localStorage.setItem("bsn-lang", this.dataset.lang);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>"""
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/chat', methods=['POST'])
|
@app.route('/api/chat', methods=['POST'])
|
||||||
def api_chat():
|
def api_chat():
|
||||||
"""Chat agent with read-only access to published submissions."""
|
"""Chat agent with read-only access to published submissions."""
|
||||||
@@ -4603,6 +4747,10 @@ def public_detail(sub_id: int):
|
|||||||
html += render_comments(children[c["id"]], indent + 1)
|
html += render_comments(children[c["id"]], indent + 1)
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
if lang != "de":
|
||||||
|
top_level = translate_comments(db, top_level, sub_id, lang)
|
||||||
|
for pid in list(children.keys()):
|
||||||
|
children[pid] = translate_comments(db, children[pid], sub_id, lang)
|
||||||
comments_html = render_comments(top_level)
|
comments_html = render_comments(top_level)
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
@@ -4694,6 +4842,7 @@ def public_detail(sub_id: int):
|
|||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
||||||
|
{_lang_dropdown_html(lang)}
|
||||||
<a href="/" class="site-logo">
|
<a href="/" class="site-logo">
|
||||||
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;" width="160" height="30">
|
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;" width="160" height="30">
|
||||||
<span class="site-logo-sub">boardgame.network</span>
|
<span class="site-logo-sub">boardgame.network</span>
|
||||||
|
|||||||
+129
-215
@@ -24,7 +24,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests # type: ignore
|
import requests # type: ignore
|
||||||
from flask import make_response, Flask, request, jsonify, redirect, g # type: ignore
|
from flask import Flask, request, jsonify, g # type: ignore
|
||||||
|
|
||||||
# ── Config ──────────────────────────────────────────────────────────
|
# ── Config ──────────────────────────────────────────────────────────
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
@@ -54,76 +54,6 @@ ADMIN_PHONE = os.environ.get("ADMIN_PHONE", "4915227909535")
|
|||||||
VERIFICATION_REQUIRED = os.environ.get("VERIFICATION_REQUIRED", "").lower() in ("1", "true", "yes")
|
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)
|
# Test system: verification is OFF by default (set VERIFICATION_REQUIRED=1 in .env to enable)
|
||||||
|
|
||||||
# Admin Auth — Argon2-basiert, Rate-Limited, Session-Cookies (12h)
|
|
||||||
import time as _time_admin
|
|
||||||
from functools import wraps as _wraps_admin
|
|
||||||
from argon2 import PasswordHasher as _ArgonPH
|
|
||||||
from argon2.exceptions import VerifyMismatchError as _ArgonVerifyError
|
|
||||||
ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME", "admin")
|
|
||||||
_admin_auth_hash = os.environ.get("ADMIN_AUTH_HASH", "")
|
|
||||||
_admin_sess_secret = os.environ.get("ADMIN_SESSION_SECRET", secrets.token_urlsafe(32))
|
|
||||||
_admin_ph = _ArgonPH()
|
|
||||||
_admin_rate_store: dict[str, list[float]] = {} # IP -> [timestamps]
|
|
||||||
|
|
||||||
def _admin_rate_ok(ip: str) -> bool:
|
|
||||||
"""Max 5 login attempts per 15 minutes per IP."""
|
|
||||||
now = _time_admin.time()
|
|
||||||
window = now - 900
|
|
||||||
attempts = [t for t in _admin_rate_store.get(ip, []) if t > window]
|
|
||||||
_admin_rate_store[ip] = attempts
|
|
||||||
return len(attempts) < 5
|
|
||||||
|
|
||||||
def _admin_rate_add(ip: str) -> None:
|
|
||||||
_admin_rate_store.setdefault(ip, []).append(_time_admin.time())
|
|
||||||
|
|
||||||
def _admin_verify(pw: str) -> bool:
|
|
||||||
"""Verify password against Argon2 hash."""
|
|
||||||
if not _admin_auth_hash:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return _admin_ph.verify(_admin_auth_hash, pw)
|
|
||||||
except _ArgonVerifyError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _admin_session_create() -> str:
|
|
||||||
"""Create a signed admin session token (valid 12h)."""
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
ts = int(_time_admin.time())
|
|
||||||
payload = f"{token}:{ts}"
|
|
||||||
sig = __import__("hashlib").sha256(f"{_admin_sess_secret}:{payload}".encode()).hexdigest()[:16]
|
|
||||||
return f"{payload}:{sig}"
|
|
||||||
|
|
||||||
def _admin_session_verify(token: str) -> bool:
|
|
||||||
"""Verify admin session token. Max age: 12 hours."""
|
|
||||||
try:
|
|
||||||
parts = token.split(":")
|
|
||||||
if len(parts) != 3:
|
|
||||||
return False
|
|
||||||
tok, ts_str, sig = parts
|
|
||||||
ts = int(ts_str)
|
|
||||||
if _time_admin.time() - ts > 43200:
|
|
||||||
return False
|
|
||||||
payload = f"{tok}:{ts_str}"
|
|
||||||
expected = __import__("hashlib").sha256(f"{_admin_sess_secret}:{payload}".encode()).hexdigest()[:16]
|
|
||||||
return __import__("secrets").compare_digest(sig, expected)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _admin_session_from_request() -> str | None:
|
|
||||||
token = request.cookies.get("bsn_admin_session")
|
|
||||||
if token and _admin_session_verify(token):
|
|
||||||
return token
|
|
||||||
return None
|
|
||||||
|
|
||||||
def admin_required(f):
|
|
||||||
"""Decorator: redirect to login if no valid admin session."""
|
|
||||||
@_wraps_admin(f)
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
if not _admin_session_from_request():
|
|
||||||
return redirect(f"/admin/login?next={request.path}")
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return decorated
|
|
||||||
|
|
||||||
MEDIA_DIR = BASE_DIR / "media"
|
MEDIA_DIR = BASE_DIR / "media"
|
||||||
MEDIA_DIR.mkdir(exist_ok=True)
|
MEDIA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
@@ -714,7 +644,7 @@ def generate_thorsten_tts(text: str) -> str | None:
|
|||||||
|
|
||||||
PIPER = "/home/hermes/.local/bin/piper"
|
PIPER = "/home/hermes/.local/bin/piper"
|
||||||
MODEL = "/home/hermes/.hermes/cache/piper-voices/de_DE-thorsten-medium.onnx"
|
MODEL = "/home/hermes/.hermes/cache/piper-voices/de_DE-thorsten-medium.onnx"
|
||||||
FFMPEG = "/usr/bin/ffmpeg"
|
FFMPEG = "/home/hermes/.local/bin/ffmpeg"
|
||||||
LD_PATH = "/home/hermes/.local/lib"
|
LD_PATH = "/home/hermes/.local/lib"
|
||||||
ESPEAK_PATH = "/home/hermes/.local/share/espeak-ng-data"
|
ESPEAK_PATH = "/home/hermes/.local/share/espeak-ng-data"
|
||||||
|
|
||||||
@@ -982,7 +912,7 @@ def transcribe_video(video_path: str) -> str | None:
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
WHISPER_PYTHON = "/home/hermes/.hermes/venvs/speech/bin/python"
|
WHISPER_PYTHON = "/home/hermes/.hermes/venvs/speech/bin/python"
|
||||||
FFMPEG = "/usr/bin/ffmpeg"
|
FFMPEG = "/home/hermes/.local/bin/ffmpeg"
|
||||||
LD_PATH = "/home/hermes/.local/lib"
|
LD_PATH = "/home/hermes/.local/lib"
|
||||||
|
|
||||||
wav_path = None
|
wav_path = None
|
||||||
@@ -1012,13 +942,7 @@ def transcribe_video(video_path: str) -> str | None:
|
|||||||
def generate_video_thumbnail(video_path: str) -> str | None:
|
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."""
|
"""Extract a thumbnail frame from a video at 1 second. Returns path to .jpg or None."""
|
||||||
import subprocess
|
import subprocess
|
||||||
FFMPEG = "/usr/bin/ffmpeg"
|
FFMPEG = "/home/hermes/.local/bin/ffmpeg"
|
||||||
# Robust: if video_path doesn't exist (old Hermes path after DB migration), try MEDIA_DIR
|
|
||||||
if not os.path.exists(video_path):
|
|
||||||
basename = os.path.basename(video_path)
|
|
||||||
video_path = os.path.join(MEDIA_DIR, basename)
|
|
||||||
if not os.path.exists(video_path):
|
|
||||||
return None
|
|
||||||
thumb_path = video_path + ".thumb.jpg"
|
thumb_path = video_path + ".thumb.jpg"
|
||||||
# Don't regenerate if exists and is not empty
|
# Don't regenerate if exists and is not empty
|
||||||
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 100:
|
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 100:
|
||||||
@@ -1418,80 +1342,15 @@ def whatsapp_webhook():
|
|||||||
return "OK", 200
|
return "OK", 200
|
||||||
|
|
||||||
|
|
||||||
@app.route("/admin/login", methods=["GET", "POST"])
|
|
||||||
def admin_login():
|
|
||||||
"""Admin login page with Argon2 verification and rate limiting."""
|
|
||||||
error = ""
|
|
||||||
if request.method == "POST":
|
|
||||||
ip = request.remote_addr or "0.0.0.0"
|
|
||||||
if not _admin_rate_ok(ip):
|
|
||||||
error = "Zu viele Login-Versuche. Bitte warte 15 Minuten."
|
|
||||||
else:
|
|
||||||
username = (request.form.get("username") or "").strip()
|
|
||||||
pw = request.form.get("password") or ""
|
|
||||||
if username == ADMIN_USERNAME and _admin_verify(pw):
|
|
||||||
token = _admin_session_create()
|
|
||||||
next_url = request.args.get("next", "/admin")
|
|
||||||
resp = make_response(redirect(next_url))
|
|
||||||
resp.set_cookie("bsn_admin_session", token, max_age=43200,
|
|
||||||
httponly=True, secure=False, samesite="Lax")
|
|
||||||
return resp
|
|
||||||
else:
|
|
||||||
_admin_rate_add(ip)
|
|
||||||
error = "Ungültiger Benutzername oder Passwort."
|
|
||||||
return f"""<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Admin Login — boardgame.network</title>
|
|
||||||
<style>
|
|
||||||
* {{ box-sizing:border-box; margin:0; padding:0; }}
|
|
||||||
body {{ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:#F9FAFB; display:flex; align-items:center; justify-content:center; min-height:100vh; }}
|
|
||||||
.login-box {{ background:#fff; padding:2.5rem; border-radius:16px; box-shadow:0 4px 24px rgba(0,0,0,0.08); width:100%; max-width:400px; }}
|
|
||||||
.login-box h1 {{ font-size:1.4rem; color:#20228a; margin-bottom:0.3rem; }}
|
|
||||||
.login-box p {{ color:#6B7280; font-size:0.85rem; margin-bottom:1.5rem; }}
|
|
||||||
.login-box label {{ display:block; font-size:0.8rem; font-weight:600; color:#374151; margin-bottom:0.3rem; margin-top:1rem; }}
|
|
||||||
.login-box input {{ width:100%; padding:0.7rem 0.9rem; border:1.5px solid #D1D5DB; border-radius:10px; font-size:0.95rem; font-family:inherit; transition:border-color 0.15s; }}
|
|
||||||
.login-box input:focus {{ outline:none; border-color:#20228a; box-shadow:0 0 0 3px rgba(32,34,138,0.1); }}
|
|
||||||
.login-box button {{ width:100%; padding:0.8rem; background:#20228a; color:#fff; border:none; border-radius:10px; font-size:0.95rem; font-weight:600; cursor:pointer; margin-top:1.5rem; font-family:inherit; }}
|
|
||||||
.login-box button:hover {{ background:#161e6e; }}
|
|
||||||
.login-box .error {{ background:#FEE2E2; color:#991B1B; padding:0.6rem 0.9rem; border-radius:8px; font-size:0.8rem; margin-top:1rem; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="login-box">
|
|
||||||
<h1>🔐 Admin Login</h1>
|
|
||||||
<p>boardgame.network — geschützter Bereich</p>
|
|
||||||
<form method="post">
|
|
||||||
<label for="username">Benutzername</label>
|
|
||||||
<input id="username" name="username" type="text" autocomplete="username" required autofocus>
|
|
||||||
<label for="pw">Passwort</label>
|
|
||||||
<input id="pw" name="password" type="password" autocomplete="current-password" required>
|
|
||||||
<button type="submit">Anmelden</button>
|
|
||||||
{"<div class=error>" + error + "</div>" if error else ""}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
@app.route("/admin/logout")
|
|
||||||
def admin_logout():
|
|
||||||
"""Destroy admin session."""
|
|
||||||
resp = make_response(redirect("/admin/login"))
|
|
||||||
resp.set_cookie("bsn_admin_session", "", max_age=0, httponly=True)
|
|
||||||
return resp
|
|
||||||
|
|
||||||
@app.route("/admin")
|
@app.route("/admin")
|
||||||
@admin_required
|
|
||||||
def admin_dashboard():
|
def admin_dashboard():
|
||||||
"""Internal admin dashboard — filterable table of all submissions."""
|
"""Internal admin dashboard — filterable table of all submissions."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
|
||||||
channel_filter = request.args.get("channel", "")
|
channel_filter = request.args.get("channel", "")
|
||||||
status_filter = request.args.get("status", "unpublished")
|
status_filter = request.args.get("status", "")
|
||||||
category_filter = request.args.get("category", "")
|
category_filter = request.args.get("category", "")
|
||||||
search = request.args.get("q", "").lstrip("#")
|
search = request.args.get("q", "")
|
||||||
halle_filter = request.args.get("halle", "")
|
halle_filter = request.args.get("halle", "")
|
||||||
triage_filter = request.args.get("triage", "")
|
triage_filter = request.args.get("triage", "")
|
||||||
|
|
||||||
@@ -1501,10 +1360,7 @@ def admin_dashboard():
|
|||||||
if channel_filter:
|
if channel_filter:
|
||||||
query += " AND channel = ?"
|
query += " AND channel = ?"
|
||||||
params.append(channel_filter)
|
params.append(channel_filter)
|
||||||
if status_filter and status_filter not in ('all', ''):
|
if status_filter:
|
||||||
if status_filter == 'unpublished':
|
|
||||||
query += " AND (status != 'published' OR status IS NULL)"
|
|
||||||
else:
|
|
||||||
query += " AND status = ?"
|
query += " AND status = ?"
|
||||||
params.append(status_filter)
|
params.append(status_filter)
|
||||||
if category_filter:
|
if category_filter:
|
||||||
@@ -1517,8 +1373,8 @@ def admin_dashboard():
|
|||||||
query += " AND triage_tier = ?"
|
query += " AND triage_tier = ?"
|
||||||
params.append(int(triage_filter))
|
params.append(int(triage_filter))
|
||||||
if search:
|
if search:
|
||||||
query += " AND (content LIKE ? OR sender_id LIKE ? OR CAST(id AS TEXT) LIKE ?)"
|
query += " AND (content LIKE ? OR sender_id LIKE ?)"
|
||||||
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
params.extend([f"%{search}%", f"%{search}%"])
|
||||||
|
|
||||||
query += " ORDER BY created_at DESC LIMIT 200"
|
query += " ORDER BY created_at DESC LIMIT 200"
|
||||||
rows = db.execute(query, params).fetchall()
|
rows = db.execute(query, params).fetchall()
|
||||||
@@ -1568,7 +1424,7 @@ def admin_dashboard():
|
|||||||
<option value="">Alle</option>
|
<option value="">Alle</option>
|
||||||
{"".join(f'<option value="{h}" {"selected" if h==halle_filter else ""}>{h}</option>' for h in hallen)}
|
{"".join(f'<option value="{h}" {"selected" if h==halle_filter else ""}>{h}</option>' for h in hallen)}
|
||||||
</select></label>
|
</select></label>
|
||||||
<label>Suche: <input type="text" name="q" value="{search}" placeholder="Suche oder #ID..." onchange="this.value=this.value.replace(/^#/,'')" style="padding:0.3rem;"></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>
|
<button type="submit" style="padding:0.3rem 0.8rem;">Filtern</button>
|
||||||
<a href="/admin" style="padding:0.3rem 0.8rem;color:#4ecdc4;">Reset</a>
|
<a href="/admin" style="padding:0.3rem 0.8rem;color:#4ecdc4;">Reset</a>
|
||||||
</form>
|
</form>
|
||||||
@@ -1605,7 +1461,8 @@ def admin_dashboard():
|
|||||||
<td>{media_icon} {r['type']}</td>
|
<td>{media_icon} {r['type']}</td>
|
||||||
<td style="color:#4ecdc4;font-weight:600;white-space:nowrap;">{r['halle'] or '-'}</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="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;"><span class="badge badge-{r['status']}">{st_label}</span> {triage_badge}{content_snippet}</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>{r['category'] or '-'}</td>
|
||||||
<td style="font-size:0.75rem;white-space:nowrap;">{r['created_at']}</td>
|
<td style="font-size:0.75rem;white-space:nowrap;">{r['created_at']}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1627,7 +1484,7 @@ def admin_dashboard():
|
|||||||
else:
|
else:
|
||||||
triage_mobile = '<span class="triage-badge" title="Wartet auf KI-Prüfung" style="opacity:0.5">⏳</span>'
|
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']}">
|
mobile_cards += f"""<a href="/submission/{r['id']}" class="mobile-card row-{r['status']}">
|
||||||
<div class="mc-top"><span class="mc-badge badge badge-{r['status']}">{st_label}</span><span class="mc-id">#{r['id']}</span>{triage_mobile}</div>
|
<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-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-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-snippet">{content_snippet}</div>
|
||||||
@@ -1648,15 +1505,15 @@ def admin_dashboard():
|
|||||||
/* Glass header */
|
/* 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; }}
|
.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); }}
|
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:nowrap; gap:0.5rem; overflow-x:auto; -webkit-overflow-scrolling:touch; }}
|
.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-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-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 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:nowrap; align-items:center; overflow-x:auto; -webkit-overflow-scrolling:touch; }}
|
.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; }}
|
.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 {{ 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:hover {{ background:var(--surface); color:var(--text); border-color:var(--border); }}
|
||||||
.admin-btn.active {{ font-weight:700; }}
|
.admin-btn.active {{ background:var(--accent); color:#fff; border-color:var(--accent); }}
|
||||||
.admin-btn.secondary {{ background:transparent; color:var(--text3); }}
|
.admin-btn.secondary {{ background:transparent; color:var(--text3); }}
|
||||||
.admin-btn.secondary:hover {{ color:var(--text); background:var(--surface2); }}
|
.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 {{ color:var(--accent); text-decoration:none; font-weight:500; }}
|
||||||
@@ -1719,7 +1576,7 @@ def admin_dashboard():
|
|||||||
.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; }}
|
.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); }}
|
body.light .theme-toggle {{ background:rgba(255,255,255,0.85); }}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>setTimeout(function(){{location.reload()}},30000);
|
||||||
function toggleTheme(){{document.body.classList.toggle('light');localStorage.setItem('theme',document.body.classList.contains('light')?'light':'dark');}}
|
function toggleTheme(){{document.body.classList.toggle('light');localStorage.setItem('theme',document.body.classList.contains('light')?'light':'dark');}}
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="/static/theme.css">
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
@@ -1733,7 +1590,7 @@ function toggleTheme(){{document.body.classList.toggle('light');localStorage.set
|
|||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span class="count-badge">{len(rows)} Submissions</span>
|
<span class="count-badge">{len(rows)} Submissions</span>
|
||||||
<a href="/admin?status=all" class="admin-btn{' active' if status_filter == 'all' else ''}">📋 Alle</a>
|
<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=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=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?status=flagged" class="admin-btn{' active' if status_filter=='flagged' else ''}">🚩 Markiert</a>
|
||||||
@@ -1761,11 +1618,10 @@ function toggleTheme(){{document.body.classList.toggle('light');localStorage.set
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/admin/comments")
|
@app.route("/admin/comments")
|
||||||
@admin_required
|
|
||||||
def admin_comments():
|
def admin_comments():
|
||||||
"""Admin comment moderation — view all comments, filter by status."""
|
"""Admin comment moderation — view all comments, filter by status."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
status_filter = request.args.get("status", "unpublished")
|
status_filter = request.args.get("status", "")
|
||||||
query = """SELECT c.*, s.spiel_titel, s.sender_name AS sub_author
|
query = """SELECT c.*, s.spiel_titel, s.sender_name AS sub_author
|
||||||
FROM comments c
|
FROM comments c
|
||||||
LEFT JOIN submissions s ON c.submission_id = s.id
|
LEFT JOIN submissions s ON c.submission_id = s.id
|
||||||
@@ -1822,22 +1678,16 @@ def admin_comments():
|
|||||||
td {{padding:0.5rem;border-bottom:1px solid #eee;vertical-align:top;}}
|
td {{padding:0.5rem;border-bottom:1px solid #eee;vertical-align:top;}}
|
||||||
tr:hover {{background:#f8f6f0;}}
|
tr:hover {{background:#f8f6f0;}}
|
||||||
.back-link {{display:inline-block;color:#20228a;text-decoration:none;font-weight:600;margin-bottom:1rem;}}
|
.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:38px;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;}}}}
|
@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><link rel="stylesheet" href="/static/theme.css">
|
</style><link rel="stylesheet" href="/static/theme.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="admin-header">
|
<div class="admin-header">
|
||||||
<div class="admin-header-inner">
|
<img src="/static/logo.png" alt="BSN">
|
||||||
<div class="admin-logo-wrap">
|
<div><h1>💬 Kommentar-Moderation</h1><a href="/admin" class="back-link">← Zurück zum Admin</a></div>
|
||||||
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;">
|
</div>
|
||||||
<span class="admin-logo-sub">boardgame.network</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta">
|
|
||||||
<span class="count-badge">💬 Kommentare</span>
|
|
||||||
<a href="/admin" class="admin-btn">← Admin</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{tabs_html}
|
{tabs_html}
|
||||||
<script>
|
<script>
|
||||||
function toggleComment(id, action) {{
|
function toggleComment(id, action) {{
|
||||||
@@ -1856,7 +1706,6 @@ function toggleComment(id, action) {{
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/admin/comment/<int:comment_id>/<action>", methods=["POST"])
|
@app.route("/admin/comment/<int:comment_id>/<action>", methods=["POST"])
|
||||||
@admin_required
|
|
||||||
def admin_comment_action(comment_id: int, action: str):
|
def admin_comment_action(comment_id: int, action: str):
|
||||||
"""Block or unblock a comment."""
|
"""Block or unblock a comment."""
|
||||||
if action not in ("block", "unblock"):
|
if action not in ("block", "unblock"):
|
||||||
@@ -2655,8 +2504,8 @@ def user_register():
|
|||||||
phone_display = phone_last4_val if not is_email else None
|
phone_display = phone_last4_val if not is_email else None
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO users (phone, name, password_hash, verified, email, phone_hash, phone_last4) "
|
"INSERT INTO users (phone, name, password_hash, verified, email, phone_hash, phone_last4) "
|
||||||
"VALUES (?,?,?,?,?,?,?)",
|
"VALUES (?,?,?,1,?,?,?)",
|
||||||
(phone_display, name, pw_hash, 1, email_val, phone_hash_val, phone_last4_val),
|
(phone_display, name, pw_hash, email_val, phone_hash_val, phone_last4_val),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
user_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
user_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
@@ -2732,6 +2581,9 @@ def user_profile():
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def public_frontend():
|
def public_frontend():
|
||||||
|
lang = request.args.get("lang", "de")
|
||||||
|
if lang not in ("de", "en", "fr", "es"):
|
||||||
|
lang = "de"
|
||||||
"""Public frontend showing published submissions — SPIEL Essen inspired."""
|
"""Public frontend showing published submissions — SPIEL Essen inspired."""
|
||||||
import base64
|
import base64
|
||||||
db = get_db()
|
db = get_db()
|
||||||
@@ -3323,9 +3175,9 @@ def public_frontend():
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
||||||
|
""" + _lang_dropdown_html(lang) + """
|
||||||
<a href="/" class="site-logo">
|
<a href="/" class="site-logo">
|
||||||
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;">
|
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;">
|
||||||
<span class="site-logo-sub">boardgame.network</span>
|
<span class="site-logo-sub">boardgame.network</span>
|
||||||
@@ -4182,18 +4034,17 @@ def api_query():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── Übersetzung via Mistral ──
|
# ═══════════════════════════════════════════
|
||||||
|
# Mehrsprachigkeit: Übersetzungs-Funktionen
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
LANG_NAMES = {"en": "Englisch", "fr": "Französisch", "es": "Spanisch"}
|
LANG_NAMES = {"en": "Englisch", "fr": "Französisch", "es": "Spanisch"}
|
||||||
LANG_FLAGS = {"en": "🇬🇧", "fr": "🇫🇷", "es": "🇪🇸"}
|
|
||||||
|
|
||||||
def translate_text(text: str, target_lang: str, content_type: str = "Beitrag") -> str:
|
def translate_text(text: str, target_lang: str, content_type: str = "Beitrag") -> str:
|
||||||
"""Übersetzt text via Mistral API in target_lang (en/fr/es)."""
|
|
||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
return text
|
return text
|
||||||
api_key = os.environ.get("MISTRAL_API_KEY", "")
|
api_key = os.environ.get("MISTRAL_API_KEY", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return text # fallback: Originaltext
|
return text
|
||||||
|
|
||||||
lang_name = LANG_NAMES.get(target_lang, target_lang)
|
lang_name = LANG_NAMES.get(target_lang, target_lang)
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Übersetze folgenden {content_type} ins {lang_name}. "
|
f"Übersetze folgenden {content_type} ins {lang_name}. "
|
||||||
@@ -4210,43 +4061,118 @@ def translate_text(text: str, target_lang: str, content_type: str = "Beitrag") -
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
translated = r.json()["choices"][0]["message"]["content"].strip()
|
return r.json()["choices"][0]["message"]["content"].strip()
|
||||||
return translated
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return text # fallback bei Fehler
|
return text
|
||||||
|
|
||||||
def get_translated_content(db, sub_id: int, target_lang: str, row) -> dict:
|
def get_translated_content(db, sub_id: int, target_lang: str, row) -> dict:
|
||||||
"""Holt Übersetzung aus Cache oder übersetzt via Mistral."""
|
|
||||||
if target_lang == "de":
|
if target_lang == "de":
|
||||||
return {"content": (row["summary"] or row["content"] or "").strip(),
|
return {"content": (row["summary"] or row["content"] or "").strip(),
|
||||||
"display_text": row["display_text"] or ""}
|
"display_text": row["display_text"] or ""}
|
||||||
|
|
||||||
cached = db.execute(
|
cached = db.execute(
|
||||||
"SELECT content, display_text FROM translations WHERE submission_id=? AND lang=?",
|
"SELECT content, display_text FROM translations WHERE submission_id=? AND lang=? AND comment_id IS NULL",
|
||||||
(sub_id, target_lang)
|
(sub_id, target_lang)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if cached:
|
if cached:
|
||||||
return {"content": cached["content"] or "", "display_text": cached["display_text"] or ""}
|
return {"content": cached["content"] or "", "display_text": cached["display_text"] or ""}
|
||||||
|
|
||||||
original_content = (row["summary"] or row["content"] or "").strip()
|
original_content = (row["summary"] or row["content"] or "").strip()
|
||||||
original_display = row["display_text"] or ""
|
original_display = row["display_text"] or ""
|
||||||
|
|
||||||
translated_content = translate_text(original_content, target_lang, "Beitrag")
|
translated_content = translate_text(original_content, target_lang, "Beitrag")
|
||||||
translated_display = translate_text(original_display, target_lang, "Anzeigetext") if original_display else ""
|
translated_display = translate_text(original_display, target_lang, "Anzeigetext") if original_display else ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT OR REPLACE INTO translations (submission_id, lang, content, display_text) VALUES (?,?,?,?)",
|
"INSERT OR REPLACE INTO translations (submission_id, lang, content, display_text, comment_id) VALUES (?,?,?,?,NULL)",
|
||||||
(sub_id, target_lang, translated_content, translated_display)
|
(sub_id, target_lang, translated_content, translated_display)
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {"content": translated_content, "display_text": translated_display}
|
return {"content": translated_content, "display_text": translated_display}
|
||||||
|
|
||||||
|
def translate_comments(db, comments: list, sub_id: int, target_lang: str) -> list:
|
||||||
|
if target_lang == "de" or not comments:
|
||||||
|
return comments
|
||||||
|
translated = []
|
||||||
|
for c in comments:
|
||||||
|
cid = c["id"]
|
||||||
|
cached = db.execute(
|
||||||
|
"SELECT content FROM translations WHERE submission_id=? AND lang=? AND comment_id=?",
|
||||||
|
(sub_id, target_lang, cid)
|
||||||
|
).fetchone()
|
||||||
|
if cached and (cached["content"] or "").strip():
|
||||||
|
c_dict = dict(c)
|
||||||
|
c_dict["content"] = cached["content"]
|
||||||
|
translated.append(c_dict)
|
||||||
|
else:
|
||||||
|
original = c["content"]
|
||||||
|
if original and original.strip():
|
||||||
|
tr = translate_text(original, target_lang, "Kommentar")
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR REPLACE INTO translations (submission_id, lang, comment_id, content) VALUES (?,?,?,?)",
|
||||||
|
(sub_id, target_lang, cid, tr)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
c_dict = dict(c)
|
||||||
|
c_dict["content"] = tr
|
||||||
|
else:
|
||||||
|
c_dict = dict(c)
|
||||||
|
translated.append(c_dict)
|
||||||
|
return translated
|
||||||
|
|
||||||
|
def _lang_dropdown_html(lang: str) -> str:
|
||||||
|
"""Flag-only dropdown for language selection — injected into page headers."""
|
||||||
|
flags = {"de": "🇩🇪", "en": "🇬🇧", "fr": "🇫🇷", "es": "🇪🇸"}
|
||||||
|
current_flag = flags.get(lang, "🇩🇪")
|
||||||
|
options = "".join(
|
||||||
|
'<a href="?lang=' + code + '" class="ld-opt' + (' ld-active' if code == lang else '') + '" data-lang="' + code + '"> ' + flags[code] + '</a>'
|
||||||
|
for code in ["de", "en", "fr", "es"]
|
||||||
|
)
|
||||||
|
return """<style>
|
||||||
|
.ld-wrap{position:relative;display:inline-flex;margin-right:0.25rem;flex-shrink:0}
|
||||||
|
.ld-btn{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:8px;color:#D1D5DB;font-size:1.1rem;cursor:pointer;padding:0.18rem 0.35rem;line-height:1}
|
||||||
|
.ld-drop{display:none;position:absolute;top:100%;left:0;background:#1F2937;border:1px solid rgba(255,255,255,0.12);border-radius:8px;overflow:hidden;z-index:300;min-width:44px}
|
||||||
|
.ld-drop.open{display:block}
|
||||||
|
.ld-opt{display:block;padding:0.35rem 0.5rem;text-decoration:none;font-size:1.1rem;text-align:center;transition:background 0.15s}
|
||||||
|
.ld-opt:hover{background:rgba(255,255,255,0.1)}
|
||||||
|
.ld-active{background:rgba(32,34,138,0.35)}
|
||||||
|
</style>
|
||||||
|
<div class="ld-wrap">
|
||||||
|
<button class="ld-btn" id="ldBtn" aria-label="Sprache wählen" title="Sprache">""" + current_flag + """</button>
|
||||||
|
<div class="ld-drop" id="ldDrop">""" + options + """</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var lang = localStorage.getItem("bsn-lang");
|
||||||
|
if (lang && lang !== "de") {
|
||||||
|
var u = new URL(window.location);
|
||||||
|
if (u.searchParams.get("lang") !== lang) {
|
||||||
|
u.searchParams.set("lang", lang);
|
||||||
|
window.location.replace(u.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var btn = document.getElementById("ldBtn");
|
||||||
|
var drop = document.getElementById("ldDrop");
|
||||||
|
if (btn && drop) {
|
||||||
|
btn.addEventListener("click", function(e) {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
drop.classList.toggle("open");
|
||||||
|
});
|
||||||
|
document.addEventListener("click", function() { drop.classList.remove("open"); });
|
||||||
|
drop.querySelectorAll(".ld-opt").forEach(function(a) {
|
||||||
|
a.addEventListener("click", function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
localStorage.setItem("bsn-lang", this.dataset.lang);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>"""
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/chat', methods=['POST'])
|
@app.route('/api/chat', methods=['POST'])
|
||||||
def api_chat():
|
def api_chat():
|
||||||
"""Chat agent with read-only access to published submissions."""
|
"""Chat agent with read-only access to published submissions."""
|
||||||
@@ -4779,7 +4705,7 @@ def public_detail(sub_id: int):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ── Mehrsprachigkeit: Übersetzung laden/cachen ──
|
# ── Mehrsprachigkeit ──
|
||||||
translation = get_translated_content(db, sub_id, lang, row)
|
translation = get_translated_content(db, sub_id, lang, row)
|
||||||
content = translation["content"]
|
content = translation["content"]
|
||||||
display_text = translation["display_text"]
|
display_text = translation["display_text"]
|
||||||
@@ -4821,6 +4747,10 @@ def public_detail(sub_id: int):
|
|||||||
html += render_comments(children[c["id"]], indent + 1)
|
html += render_comments(children[c["id"]], indent + 1)
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
if lang != "de":
|
||||||
|
top_level = translate_comments(db, top_level, sub_id, lang)
|
||||||
|
for pid in list(children.keys()):
|
||||||
|
children[pid] = translate_comments(db, children[pid], sub_id, lang)
|
||||||
comments_html = render_comments(top_level)
|
comments_html = render_comments(top_level)
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
@@ -4903,32 +4833,16 @@ def public_detail(sub_id: int):
|
|||||||
.site-header {{ padding:0.8rem 1rem; }}
|
.site-header {{ padding:0.8rem 1rem; }}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
.detail-like-btn {{ background:none; border:none; border-radius:999px; padding:0.3rem 0.8rem; font-size:0.85rem; cursor:pointer; color:#9CA3AF; font-family:inherit; transition:all 0.15s; display:inline-flex; align-items:center; gap:0.2rem; -webkit-tap-highlight-color:transparent; }}
|
|
||||||
.detail-like-btn svg, .detail-like-btn svg path {{ stroke:#EF4444 !important; fill:#EF4444 !important; }}
|
|
||||||
.detail-like-btn:hover {{ opacity:0.8; }}
|
.detail-like-btn:hover {{ opacity:0.8; }}
|
||||||
.detail-like-btn.liked {{ background:none; }}
|
.detail-like-btn.liked {{ background:none; }}
|
||||||
.detail-like-btn.liked svg, .detail-like-btn.liked svg path {{ stroke:#EF4444 !important; fill:#EF4444 !important; }}
|
.detail-like-btn.liked svg, .detail-like-btn.liked svg path {{ stroke:#EF4444 !important; fill:#EF4444 !important; }}
|
||||||
.detail-like-btn:active {{ transform:scale(0.94); }}
|
.detail-like-btn:active {{ transform:scale(0.94); }}</style>
|
||||||
.lang-switcher {{ display:flex; gap:0.3rem; margin-right:auto; }}
|
|
||||||
.lang-btn {{ text-decoration:none; font-size:1rem; padding:0.2rem 0.35rem; border-radius:6px; transition:all 0.15s; opacity:0.5; line-height:1; }}
|
|
||||||
.lang-btn:hover {{ opacity:0.8; background:rgba(255,255,255,0.1); }}
|
|
||||||
.lang-btn.active {{ opacity:1; background:rgba(32,34,138,0.3); }}
|
|
||||||
@media(max-width:600px) {{
|
|
||||||
.lang-switcher {{ gap:0.15rem; }}
|
|
||||||
.lang-btn {{ font-size:0.85rem; padding:0.15rem 0.25rem; }}
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
<link rel="stylesheet" href="/static/theme.css">
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="lang-switcher">
|
|
||||||
<a href="?lang=de" class="lang-btn {'active' if lang == 'de' else ''}">🇩🇪</a>
|
|
||||||
<a href="?lang=en" class="lang-btn {'active' if lang == 'en' else ''}">🇬🇧</a>
|
|
||||||
<a href="?lang=fr" class="lang-btn {'active' if lang == 'fr' else ''}">🇫🇷</a>
|
|
||||||
<a href="?lang=es" class="lang-btn {'active' if lang == 'es' else ''}">🇪🇸</a>
|
|
||||||
</div>
|
|
||||||
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
||||||
|
{_lang_dropdown_html(lang)}
|
||||||
<a href="/" class="site-logo">
|
<a href="/" class="site-logo">
|
||||||
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;" width="160" height="30">
|
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;display:block;" width="160" height="30">
|
||||||
<span class="site-logo-sub">boardgame.network</span>
|
<span class="site-logo-sub">boardgame.network</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user