feat: Mehrsprachigkeit v2 — Flag-Dropdown auf allen Seiten + localStorage
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:
Hermes Agent
2026-07-16 08:23:17 +02:00
parent b46dafef08
commit 180a77aa8b
2 changed files with 280 additions and 217 deletions
+149
View File
@@ -2581,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()
@@ -3174,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>
@@ -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'])
def api_chat():
"""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)
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>
@@ -4694,6 +4842,7 @@ def public_detail(sub_id: int):
<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:38px;width:auto;display:block;" width="160" height="30">
<span class="site-logo-sub">boardgame.network</span>