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("/")
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user