Compare commits
12 Commits
6600d5e346
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63269b18c4 | |||
| 180a77aa8b | |||
| b46dafef08 | |||
| 6a55a3e475 | |||
| 5a50df7923 | |||
| af0af785ab | |||
| ebf6e0e6fa | |||
| a3d06f0f90 | |||
| f145b815e3 | |||
| 9016977158 | |||
| eda106849e | |||
| 6ab03fbe85 |
@@ -1579,12 +1579,13 @@ def admin_dashboard():
|
||||
<script>setTimeout(function(){{location.reload()}},30000);
|
||||
function toggleTheme(){{document.body.classList.toggle('light');localStorage.setItem('theme',document.body.classList.contains('light')?'light':'dark');}}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
</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;">
|
||||
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;width:auto;">
|
||||
<span class="admin-logo-sub">boardgame.network</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
@@ -1678,9 +1679,10 @@ def admin_comments():
|
||||
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;}}
|
||||
.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;}}}}
|
||||
</style></head>
|
||||
</style><link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-header">
|
||||
<img src="/static/logo.png" alt="BSN">
|
||||
@@ -1730,9 +1732,10 @@ def test_page():
|
||||
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>
|
||||
</style><link rel="stylesheet" href="/static/theme.css">
|
||||
</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>
|
||||
<img src="/static/logo.png" alt="Brettspiel News" style="height:38px;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" \\
|
||||
@@ -1809,8 +1812,9 @@ def submission_detail(sub_id: int):
|
||||
}
|
||||
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>'
|
||||
basename = os.path.basename(path)
|
||||
if not (MEDIA_DIR / basename).exists():
|
||||
media_html += f'<p style="color:#888;">📁 Datei nicht gefunden: {basename}</p>'
|
||||
continue
|
||||
_, ext = os.path.splitext(path)
|
||||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
||||
@@ -1952,6 +1956,7 @@ def submission_detail(sub_id: int):
|
||||
img, video {{ border-radius:var(--radius-sm); }}
|
||||
audio {{ width:100%; margin:0.3rem 0; }}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="detail-header">
|
||||
@@ -2244,7 +2249,8 @@ def submission_done(sub_id: int):
|
||||
75%% { opacity:1; transform:scale(1); }
|
||||
100%% { opacity:0; transform:scale(0.95); }
|
||||
}
|
||||
</style></head>
|
||||
</style><link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast">✅ Beitrag als <strong>erledigt</strong> markiert</div>
|
||||
<script>setTimeout(function(){ window.location='/admin'; }, 1800);</script>
|
||||
@@ -2354,7 +2360,8 @@ def submission_delete(sub_id: int):
|
||||
75% {{ opacity:1; transform:scale(1); }}
|
||||
100% {{ opacity:0; transform:scale(0.95); }}
|
||||
}}
|
||||
</style></head>
|
||||
</style><link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast">🗑️ Beitrag #{sub_id} gelöscht</div>
|
||||
<script>setTimeout(function(){{ window.location='/admin'; }}, 1800);</script>
|
||||
@@ -2574,6 +2581,9 @@ def user_profile():
|
||||
|
||||
@app.route("/")
|
||||
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."""
|
||||
import base64
|
||||
db = get_db()
|
||||
@@ -2658,7 +2668,8 @@ def public_frontend():
|
||||
.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>
|
||||
</style><link rel="stylesheet" href="/static/theme.css">
|
||||
</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>"""
|
||||
|
||||
@@ -2725,9 +2736,9 @@ def public_frontend():
|
||||
# Skip media not marked for publishing
|
||||
if flags[i] != "1":
|
||||
continue
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
basename = os.path.basename(path)
|
||||
if not (MEDIA_DIR / basename).exists():
|
||||
continue
|
||||
_, ext = os.path.splitext(path)
|
||||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
||||
try:
|
||||
@@ -2935,7 +2946,7 @@ def public_frontend():
|
||||
.text-only .card-content { padding:0.8rem 1rem 0.7rem; }
|
||||
|
||||
/* Text-only card border */
|
||||
.text-only { border:1px solid #F3F4F6; }
|
||||
.text-only { /* no border */ }
|
||||
|
||||
/* Card link */
|
||||
.card-link { text-decoration:none; color:inherit; display:block; }
|
||||
@@ -2953,11 +2964,11 @@ def public_frontend():
|
||||
.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 { background:none; border:none; 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.liked { background:none; }
|
||||
.stat-like-btn.liked svg, .stat-like-btn.liked svg path { stroke:#EF4444 !important; fill:#EF4444 !important; }
|
||||
.stat-like-btn:active { transform:scale(0.96); }
|
||||
|
||||
/* Filter bar */
|
||||
@@ -3141,6 +3152,7 @@ def public_frontend():
|
||||
.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>
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -3165,6 +3177,7 @@ def public_frontend():
|
||||
|
||||
<header class="site-header">
|
||||
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
||||
""" + _lang_dropdown_html(lang) + """
|
||||
<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>
|
||||
@@ -3812,6 +3825,7 @@ def _overlay_page(title, content_html, max_width="640px"):
|
||||
.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>
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="overlay-card">
|
||||
@@ -4019,6 +4033,213 @@ 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
// UI text translation
|
||||
var lang = localStorage.getItem("bsn-lang") || "de";
|
||||
if (lang !== "de") {
|
||||
var uiText = + '_ui_text_json(lang)' + ;
|
||||
function translateUI() {
|
||||
// Translate text content of elements with specific text
|
||||
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
||||
var node;
|
||||
while (node = walker.nextNode()) {
|
||||
var txt = node.textContent.trim();
|
||||
if (uiText[txt]) {
|
||||
node.textContent = node.textContent.replace(txt, uiText[txt]);
|
||||
}
|
||||
}
|
||||
// Translate input placeholders
|
||||
document.querySelectorAll("[placeholder]").forEach(function(el) {
|
||||
var ph = el.getAttribute("placeholder");
|
||||
if (uiText[ph]) el.setAttribute("placeholder", uiText[ph]);
|
||||
});
|
||||
// Translate button text
|
||||
document.querySelectorAll("button").forEach(function(el) {
|
||||
if (el.textContent.trim() && uiText[el.textContent.trim()]) {
|
||||
el.textContent = uiText[el.textContent.trim()];
|
||||
}
|
||||
});
|
||||
}
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", translateUI);
|
||||
} else {
|
||||
translateUI();
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# UI-Text-Übersetzungen (via JS + Fallback)
|
||||
# ═══════════════════════════════════════════
|
||||
UI_TEXT = {
|
||||
"Willkommen in der BSN Community!": {"en": "Welcome to the BSN Community!", "fr": "Bienvenue dans la communauté BSN !", "es": "¡Bienvenido a la comunidad BSN!"},
|
||||
"Nicht mehr anzeigen": {"en": "Don't show again", "fr": "Ne plus afficher", "es": "No mostrar más"},
|
||||
"Alles klar!": {"en": "Got it!", "fr": "Compris !", "es": "¡Entendido!"},
|
||||
"Powered by": {"en": "Powered by", "fr": "Propulsé par", "es": "Desarrollado por"},
|
||||
"Zurück zur Übersicht": {"en": "Back to overview", "fr": "Retour à l'aperçu", "es": "Volver a la vista general"},
|
||||
"Aufrufe": {"en": "views", "fr": "vues", "es": "vistas"},
|
||||
"Kommentare": {"en": "Comments", "fr": "Commentaires", "es": "Comentarios"},
|
||||
"Dein Name (optional)": {"en": "Your name (optional)", "fr": "Votre nom (facultatif)", "es": "Tu nombre (opcional)"},
|
||||
"Schreib einen Kommentar...": {"en": "Write a comment...", "fr": "Écrire un commentaire...", "es": "Escribe un comentario..."},
|
||||
"Damit wir dich bei Antworten benachrichtigen können (E-Mail oder Nummer erforderlich):": {"en": "So we can notify you of replies (email or phone required):", "fr": "Pour vous notifier des réponses (email ou numéro requis) :", "es": "Para notificarte de las respuestas (correo o número requerido):"},
|
||||
"E-Mail-Adresse": {"en": "Email address", "fr": "Adresse e-mail", "es": "Dirección de correo"},
|
||||
"Handynummer (für WhatsApp-Benachrichtigung)": {"en": "Phone number (for WhatsApp notification)", "fr": "Numéro (notification WhatsApp)", "es": "Número (notificación WhatsApp)"},
|
||||
"Kommentar senden": {"en": "Send comment", "fr": "Envoyer", "es": "Enviar comentario"},
|
||||
"Beitrags-ID:": {"en": "Post ID:", "fr": "ID publication :", "es": "ID publicación:"},
|
||||
}
|
||||
|
||||
def __(text: str, lang: str) -> str:
|
||||
"""UI-String übersetzen. Fallback: Originaltext."""
|
||||
if lang == "de" or not text:
|
||||
return text
|
||||
entry = UI_TEXT.get(text)
|
||||
return entry.get(lang, text) if entry else text
|
||||
|
||||
def _ui_text_json(lang: str) -> str:
|
||||
"""Erzeugt JSON-Objekt mit allen Übersetzungen für target_lang."""
|
||||
result = {}
|
||||
for de_text, trans in UI_TEXT.items():
|
||||
result[de_text] = trans.get(lang, de_text)
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
@app.route('/api/chat', methods=['POST'])
|
||||
def api_chat():
|
||||
"""Chat agent with read-only access to published submissions."""
|
||||
@@ -4245,7 +4466,7 @@ def api_chat():
|
||||
"- 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"
|
||||
"- Antworte immer auf Deutsch, freundlich und hilfsbereit.\n - Verwende KEINE Emojis, Icons oder Symbole in deinen Antworten.\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"
|
||||
@@ -4258,16 +4479,8 @@ def api_chat():
|
||||
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
|
||||
# Read Mistral API key from environment
|
||||
api_key = os.environ.get("MISTRAL_API_KEY", "")
|
||||
if not api_key:
|
||||
return jsonify({'reply': 'BSN Assistent ist aktuell nicht verfügbar (API-Key fehlt).'})
|
||||
|
||||
@@ -4282,9 +4495,9 @@ def api_chat():
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
"https://ui.datenhimmel.work/api/chat/completions",
|
||||
"https://api.mistral.ai/v1/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},
|
||||
json={"model": "ministral-3b-latest", "messages": messages, "max_tokens": 400, "temperature": 0.4},
|
||||
timeout=15,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
@@ -4484,6 +4697,9 @@ def api_submit():
|
||||
|
||||
@app.route("/beitrag/<int:sub_id>")
|
||||
def public_detail(sub_id: int):
|
||||
lang = request.args.get("lang", "de")
|
||||
if lang not in ("de", "en", "fr", "es"):
|
||||
lang = "de"
|
||||
"""Public detail view — full media, text, and metadata."""
|
||||
import base64
|
||||
db = get_db()
|
||||
@@ -4515,7 +4731,8 @@ def public_detail(sub_id: int):
|
||||
for i, path in enumerate(paths):
|
||||
if flags[i] != "1":
|
||||
continue
|
||||
if not os.path.exists(path):
|
||||
basename = os.path.basename(path)
|
||||
if not (MEDIA_DIR / basename).exists():
|
||||
continue
|
||||
_, ext = os.path.splitext(path)
|
||||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
||||
@@ -4528,7 +4745,7 @@ def public_detail(sub_id: int):
|
||||
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'<video controls autoplay loop muted 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/"):
|
||||
@@ -4555,7 +4772,10 @@ def public_detail(sub_id: int):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
content = (row["summary"] or row["content"] or "").strip()
|
||||
# ── Mehrsprachigkeit ──
|
||||
translation = get_translated_content(db, sub_id, lang, row)
|
||||
content = translation["content"]
|
||||
display_text = translation["display_text"]
|
||||
|
||||
# Comments
|
||||
comment_rows = db.execute(
|
||||
@@ -4585,7 +4805,7 @@ def public_detail(sub_id: int):
|
||||
<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="comment-like-btn" data-cid="{c['id']}" data-clikes="{c['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" 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>"""
|
||||
@@ -4594,6 +4814,10 @@ def public_detail(sub_id: int):
|
||||
html += render_comments(children[c["id"]], indent + 1)
|
||||
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)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
@@ -4657,10 +4881,10 @@ def public_detail(sub_id: int):
|
||||
.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 {{ background:none; border:none; 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 {{ opacity:0.8; }}
|
||||
.comment-like-btn.liked {{ background:none; }}
|
||||
.comment-like-btn.liked svg, .comment-like-btn.liked svg path {{ stroke:#EF4444 !important; fill:#EF4444 !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; }}
|
||||
@@ -4676,21 +4900,23 @@ def public_detail(sub_id: int):
|
||||
.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:hover {{ opacity:0.8; }}
|
||||
.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:active {{ transform:scale(0.94); }}</style>
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<button id="navToggle" class="nav-toggle" aria-label="Menü öffnen">☰</button>
|
||||
{_lang_dropdown_html(lang)}
|
||||
<a href="/" class="site-logo">
|
||||
<img src="/static/logo.png" alt="Brettspiel News" style="height:30px;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>
|
||||
</a>
|
||||
</header>
|
||||
<main class="detail-container">
|
||||
<a href="/" class="back-link">← Zurück zur Übersicht</a>
|
||||
<a href="/" class="back-link">← {__("Zurück zur Übersicht", lang)}</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">
|
||||
@@ -4701,31 +4927,31 @@ def public_detail(sub_id: int):
|
||||
<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>
|
||||
<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", lang)}</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="#EF4444" stroke="#EF4444" stroke-width="0.5" 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>
|
||||
<h3 class="comments-heading">💬 {__("Kommentare", lang)} ({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>
|
||||
<input type="text" name="author" placeholder="{__("Dein Name (optional)", lang)}" maxlength="80" class="comment-input">
|
||||
<textarea name="content" placeholder="{__("Schreib einen Kommentar...", lang)}" 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):", lang)}</div>
|
||||
<input type="email" name="email" placeholder="{__("E-Mail-Adresse", lang)}" maxlength="200" class="comment-email">
|
||||
<input type="tel" name="phone" placeholder="{__("Handynummer (für WhatsApp-Benachrichtigung)", lang)}" maxlength="50" class="comment-phone">
|
||||
<button type="submit" class="comment-submit-btn">{__("Kommentar senden", lang)}</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>
|
||||
<p>{__("Powered by", lang)} <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.
|
||||
{__("Beitrags-ID:", lang)} <strong style="color:#20228a;">#{sub_id}</strong> \u2014 Bei Fragen oder Löschwunsch bitte diese ID angeben.
|
||||
</div>
|
||||
<script>
|
||||
(function(){{
|
||||
@@ -4736,9 +4962,6 @@ def public_detail(sub_id: int):
|
||||
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() {{
|
||||
@@ -4750,9 +4973,6 @@ def public_detail(sub_id: int):
|
||||
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));
|
||||
}}
|
||||
@@ -5210,7 +5430,8 @@ def verify_comment(token: str):
|
||||
body {{font-family:'Inter',sans-serif;text-align:center;padding:3rem;background:#f5f3ee;color:#1a1a2e;}}
|
||||
h1 {{color:#20228a;}}
|
||||
a {{color:#20228a;}}
|
||||
</style></head>
|
||||
</style><link rel="stylesheet" href="/static/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>✅ Kommentar bestätigt!</h1>
|
||||
<p>Dein Kommentar ist jetzt auf boardgame.network sichtbar.</p>
|
||||
|
||||
+5562
File diff suppressed because one or more lines are too long
@@ -0,0 +1,63 @@
|
||||
/* === BSN Design-Tokens — Zentrales Variablen-System === */
|
||||
/* Wird VOR allen <style>-Blöcken geladen. */
|
||||
/* Bei Design-Änderungen NUR diese Datei anpassen. */
|
||||
|
||||
:root {
|
||||
/* ===== BRAND ===== */
|
||||
--bsn-blue: #20228a;
|
||||
--bsn-blue-dark: #161e6e;
|
||||
--bsn-blue-light: #5e5ce6;
|
||||
|
||||
/* ===== SEMANTIC ===== */
|
||||
--heart-red: #EF4444;
|
||||
--heart-red-dark: #DC2626;
|
||||
--success: #22c55e;
|
||||
--success-dark: #28a745;
|
||||
--success-glow: #30d158;
|
||||
--warning: #eab308;
|
||||
--warning-dark: #ffc107;
|
||||
--danger: #dc3545;
|
||||
--danger-glow: #ff453a;
|
||||
|
||||
/* ===== TEXT ===== */
|
||||
--text-primary: #1F2937;
|
||||
--text-secondary: #6B7280;
|
||||
--text-muted: #9CA3AF;
|
||||
--text-light: #D1D5DB;
|
||||
--text-white: #F9FAFB;
|
||||
|
||||
/* ===== BACKGROUNDS ===== */
|
||||
--bg-page: #F9FAFB;
|
||||
--bg-card: #ffffff;
|
||||
--bg-offwhite: #f0ebe0;
|
||||
--bg-light-blue: #f0f4ff;
|
||||
|
||||
/* ===== BORDERS ===== */
|
||||
--border-light: #E5E7EB;
|
||||
--border-lighter: #F3F4F6;
|
||||
|
||||
/* ===== RADIUS ===== */
|
||||
--radius-xs: 6px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* ===== SHADOWS ===== */
|
||||
--shadow-card: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.03);
|
||||
--shadow-card-hover: 0 4px 16px rgba(0,0,0,0.06);
|
||||
--shadow-ausverkauft: 0 2px 8px rgba(220,38,38,0.3);
|
||||
|
||||
/* ===== SPACING ===== */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 0.75rem;
|
||||
--space-lg: 1rem;
|
||||
--space-xl: 1.5rem;
|
||||
|
||||
/* ===== FONTS ===== */
|
||||
--font-xs: 0.68rem;
|
||||
--font-sm: 0.75rem;
|
||||
--font-base: 0.85rem;
|
||||
--font-md: 0.95rem;
|
||||
}
|
||||
Reference in New Issue
Block a user