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);
|
<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">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="admin-header">
|
<header class="admin-header">
|
||||||
<div class="admin-header-inner">
|
<div class="admin-header-inner">
|
||||||
<div class="admin-logo-wrap">
|
<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>
|
<span class="admin-logo-sub">boardgame.network</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
@@ -1678,9 +1679,10 @@ def admin_comments():
|
|||||||
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 {{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;}}}}
|
@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>
|
<body>
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
<img src="/static/logo.png" alt="BSN">
|
<img src="/static/logo.png" alt="BSN">
|
||||||
@@ -1730,9 +1732,10 @@ def test_page():
|
|||||||
h1 { color:#4ecdc4; }
|
h1 { color:#4ecdc4; }
|
||||||
pre { background:#16213e; padding:1rem; border-radius:8px; overflow-x:auto; font-size:0.8rem; line-height:1.5; }
|
pre { background:#16213e; padding:1rem; border-radius:8px; overflow-x:auto; font-size:0.8rem; line-height:1.5; }
|
||||||
a { color:#4ecdc4; }
|
a { color:#4ecdc4; }
|
||||||
</style></head>
|
</style><link rel="stylesheet" href="/static/theme.css">
|
||||||
|
</head>
|
||||||
<body>
|
<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>
|
<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 \\
|
<pre>curl -X POST http://127.0.0.1:5002/whatsapp/webhook \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
@@ -1809,8 +1812,9 @@ def submission_detail(sub_id: int):
|
|||||||
}
|
}
|
||||||
for i, path in enumerate(paths):
|
for i, path in enumerate(paths):
|
||||||
checked = "checked" if flags[i] == "1" else ""
|
checked = "checked" if flags[i] == "1" else ""
|
||||||
if not os.path.exists(path):
|
basename = os.path.basename(path)
|
||||||
media_html += f'<p style="color:#888;">📁 Datei nicht gefunden: {os.path.basename(path)}</p>'
|
if not (MEDIA_DIR / basename).exists():
|
||||||
|
media_html += f'<p style="color:#888;">📁 Datei nicht gefunden: {basename}</p>'
|
||||||
continue
|
continue
|
||||||
_, ext = os.path.splitext(path)
|
_, ext = os.path.splitext(path)
|
||||||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
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); }}
|
img, video {{ border-radius:var(--radius-sm); }}
|
||||||
audio {{ width:100%; margin:0.3rem 0; }}
|
audio {{ width:100%; margin:0.3rem 0; }}
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="detail-header">
|
<header class="detail-header">
|
||||||
@@ -2244,7 +2249,8 @@ def submission_done(sub_id: int):
|
|||||||
75%% { opacity:1; transform:scale(1); }
|
75%% { opacity:1; transform:scale(1); }
|
||||||
100%% { opacity:0; transform:scale(0.95); }
|
100%% { opacity:0; transform:scale(0.95); }
|
||||||
}
|
}
|
||||||
</style></head>
|
</style><link rel="stylesheet" href="/static/theme.css">
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="toast">✅ Beitrag als <strong>erledigt</strong> markiert</div>
|
<div class="toast">✅ Beitrag als <strong>erledigt</strong> markiert</div>
|
||||||
<script>setTimeout(function(){ window.location='/admin'; }, 1800);</script>
|
<script>setTimeout(function(){ window.location='/admin'; }, 1800);</script>
|
||||||
@@ -2354,7 +2360,8 @@ def submission_delete(sub_id: int):
|
|||||||
75% {{ opacity:1; transform:scale(1); }}
|
75% {{ opacity:1; transform:scale(1); }}
|
||||||
100% {{ opacity:0; transform:scale(0.95); }}
|
100% {{ opacity:0; transform:scale(0.95); }}
|
||||||
}}
|
}}
|
||||||
</style></head>
|
</style><link rel="stylesheet" href="/static/theme.css">
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="toast">🗑️ Beitrag #{sub_id} gelöscht</div>
|
<div class="toast">🗑️ Beitrag #{sub_id} gelöscht</div>
|
||||||
<script>setTimeout(function(){{ window.location='/admin'; }}, 1800);</script>
|
<script>setTimeout(function(){{ window.location='/admin'; }}, 1800);</script>
|
||||||
@@ -2574,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()
|
||||||
@@ -2658,7 +2668,8 @@ def public_frontend():
|
|||||||
.empty h1 { color:#20228a; font-size:2rem; font-weight:800; margin-bottom:0.8rem; }
|
.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 p { color:#666; font-size:1.1rem; max-width:400px; line-height:1.6; }
|
||||||
.empty .logo { font-size:3rem; margin-bottom:1.5rem; }
|
.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>
|
<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>"""
|
</html>"""
|
||||||
|
|
||||||
@@ -2725,9 +2736,9 @@ def public_frontend():
|
|||||||
# Skip media not marked for publishing
|
# Skip media not marked for publishing
|
||||||
if flags[i] != "1":
|
if flags[i] != "1":
|
||||||
continue
|
continue
|
||||||
if not os.path.exists(path):
|
|
||||||
continue
|
|
||||||
basename = os.path.basename(path)
|
basename = os.path.basename(path)
|
||||||
|
if not (MEDIA_DIR / basename).exists():
|
||||||
|
continue
|
||||||
_, ext = os.path.splitext(path)
|
_, ext = os.path.splitext(path)
|
||||||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
||||||
try:
|
try:
|
||||||
@@ -2935,7 +2946,7 @@ def public_frontend():
|
|||||||
.text-only .card-content { padding:0.8rem 1rem 0.7rem; }
|
.text-only .card-content { padding:0.8rem 1rem 0.7rem; }
|
||||||
|
|
||||||
/* Text-only card border */
|
/* Text-only card border */
|
||||||
.text-only { border:1px solid #F3F4F6; }
|
.text-only { /* no border */ }
|
||||||
|
|
||||||
/* Card link */
|
/* Card link */
|
||||||
.card-link { text-decoration:none; color:inherit; display:block; }
|
.card-link { text-decoration:none; color:inherit; display:block; }
|
||||||
@@ -2953,11 +2964,11 @@ def public_frontend():
|
|||||||
.meta-spacer { flex:1; }
|
.meta-spacer { flex:1; }
|
||||||
|
|
||||||
/* Like button — subtle outline */
|
/* 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 svg, .stat-like-btn svg path { stroke:#EF4444 !important; fill:#EF4444 !important; }
|
||||||
.stat-like-btn:hover { border-color:#9CA3AF; color:#6B7280; }
|
.stat-like-btn:hover { border-color:#9CA3AF; color:#6B7280; }
|
||||||
.stat-like-btn.liked { background:#EF4444; color:#fff; border-color:#EF4444; }
|
.stat-like-btn.liked { background:none; }
|
||||||
.stat-like-btn.liked svg, .stat-like-btn.liked svg path { stroke:#fff !important; fill:#fff !important; }
|
.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); }
|
.stat-like-btn:active { transform:scale(0.96); }
|
||||||
|
|
||||||
/* Filter bar */
|
/* 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 { 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; }
|
.welcome-footer button:hover { background:#374151; }
|
||||||
</style>
|
</style>
|
||||||
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -3165,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>
|
||||||
@@ -3812,6 +3825,7 @@ def _overlay_page(title, content_html, max_width="640px"):
|
|||||||
.overlay-nav a:hover {{ color:#6B7280; }}
|
.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; }} }}
|
@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>
|
</style>
|
||||||
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="overlay-card">
|
<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'])
|
@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."""
|
||||||
@@ -4245,7 +4466,7 @@ def api_chat():
|
|||||||
"- Beantworte Fragen NUR mit Informationen aus dem Kontext unten.\n"
|
"- Beantworte Fragen NUR mit Informationen aus dem Kontext unten.\n"
|
||||||
"- Wenn die Antwort nicht im Kontext steht, sage ehrlich: 'Dazu habe ich leider keine Informationen.'\n"
|
"- 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"
|
"- 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"
|
"- Halte Antworten kurz und präzise (2-4 Sätze).\n"
|
||||||
"- Bei Standortfragen nenne immer die Hallen-Nummer.\n"
|
"- Bei Standortfragen nenne immer die Hallen-Nummer.\n"
|
||||||
"- [Aussteller]-Einträge enthalten Verlage/Shops mit Halle, Stand und Land.\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]}"
|
f"KONTEXT (veröffentlichte Community-Beiträge + SPIEL Essen Aussteller):\n{context[:10000]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Read API key
|
# Read Mistral API key from environment
|
||||||
config_path = os.path.expanduser("~/.hermes/config.yaml")
|
api_key = os.environ.get("MISTRAL_API_KEY", "")
|
||||||
api_key = ""
|
|
||||||
try:
|
|
||||||
with open(config_path) as f:
|
|
||||||
import yaml as _yaml
|
|
||||||
cfg = _yaml.safe_load(f)
|
|
||||||
api_key = cfg.get("providers", {}).get("datenhimmel", {}).get("api_key", "")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return jsonify({'reply': 'BSN Assistent ist aktuell nicht verfügbar (API-Key fehlt).'})
|
return jsonify({'reply': 'BSN Assistent ist aktuell nicht verfügbar (API-Key fehlt).'})
|
||||||
|
|
||||||
@@ -4282,9 +4495,9 @@ def api_chat():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
r = requests.post(
|
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"},
|
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,
|
timeout=15,
|
||||||
)
|
)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
@@ -4484,6 +4697,9 @@ def api_submit():
|
|||||||
|
|
||||||
@app.route("/beitrag/<int:sub_id>")
|
@app.route("/beitrag/<int:sub_id>")
|
||||||
def public_detail(sub_id: int):
|
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."""
|
"""Public detail view — full media, text, and metadata."""
|
||||||
import base64
|
import base64
|
||||||
db = get_db()
|
db = get_db()
|
||||||
@@ -4515,7 +4731,8 @@ def public_detail(sub_id: int):
|
|||||||
for i, path in enumerate(paths):
|
for i, path in enumerate(paths):
|
||||||
if flags[i] != "1":
|
if flags[i] != "1":
|
||||||
continue
|
continue
|
||||||
if not os.path.exists(path):
|
basename = os.path.basename(path)
|
||||||
|
if not (MEDIA_DIR / basename).exists():
|
||||||
continue
|
continue
|
||||||
_, ext = os.path.splitext(path)
|
_, ext = os.path.splitext(path)
|
||||||
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
mime = mime_map.get(ext.lower(), "application/octet-stream")
|
||||||
@@ -4528,7 +4745,7 @@ def public_detail(sub_id: int):
|
|||||||
thumb = generate_video_thumbnail(path)
|
thumb = generate_video_thumbnail(path)
|
||||||
poster = f' poster="/media/{os.path.basename(thumb)}"' if thumb else ""
|
poster = f' poster="/media/{os.path.basename(thumb)}"' if thumb else ""
|
||||||
media_html += (
|
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>'
|
f'<source src="{src}" type="{mime}"></video>'
|
||||||
)
|
)
|
||||||
elif mime.startswith("audio/"):
|
elif mime.startswith("audio/"):
|
||||||
@@ -4555,7 +4772,10 @@ def public_detail(sub_id: int):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Comments
|
||||||
comment_rows = db.execute(
|
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>
|
<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>
|
<p class="comment-text">{c["content"]}</p>
|
||||||
<div class="comment-actions">
|
<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>
|
<button class="reply-btn" onclick="replyTo({c['id']}, '{c_author}')">↩ Antworten</button>
|
||||||
</div>
|
</div>
|
||||||
</div>"""
|
</div>"""
|
||||||
@@ -4594,6 +4814,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>
|
||||||
@@ -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 {{ 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); }}
|
.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-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 {{ 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 {{ border-color:#20228a; color:#20228a; background:rgba(32,34,138,0.05); }}
|
.comment-like-btn:hover {{ opacity:0.8; }}
|
||||||
.comment-like-btn.liked {{ background:#20228a; color:#fff; border-color:#20228a; font-weight:600; }}
|
.comment-like-btn.liked {{ background:none; }}
|
||||||
.comment-like-btn.liked svg, .comment-like-btn.liked svg path {{ stroke:#fff !important; }}
|
.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); }}
|
.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; }}
|
.reply-info {{ font-size:0.75rem; color:#20228a; margin-bottom:0.3rem; font-weight:600; display:none; }}
|
||||||
.comment-email, .comment-phone {{ padding:0.6rem 0.9rem; border:1.5px solid #ddd; border-radius:10px; font-size:0.85rem; font-family:inherit; }}
|
.comment-email, .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; }}
|
.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:hover {{ opacity:0.8; }}
|
||||||
.detail-like-btn.liked {{ background:#20228a; color:#fff; border-color:#20228a; font-weight:600; }}
|
.detail-like-btn.liked {{ background:none; }}
|
||||||
.detail-like-btn.liked svg, .detail-like-btn.liked svg path {{ stroke:#fff !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); }}</style>
|
.detail-like-btn:active {{ transform:scale(0.94); }}</style>
|
||||||
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
</head>
|
</head>
|
||||||
<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: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>
|
<span class="site-logo-sub">boardgame.network</span>
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
<main class="detail-container">
|
<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 "")}">
|
<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 ''])}
|
{"".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">
|
<div class="detail-meta">
|
||||||
@@ -4701,31 +4927,31 @@ def public_detail(sub_id: int):
|
|||||||
<div class="detail-media">{media_html}</div>
|
<div class="detail-media">{media_html}</div>
|
||||||
{"".join([f'<div class="detail-content">{content}</div>' if content else ''])}
|
{"".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;">
|
<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>
|
<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="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>
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<section class="comments-section">
|
<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}
|
{comments_html}
|
||||||
<form id="commentForm" class="comment-form" onsubmit="return submitComment(event, {sub_id})">
|
<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>
|
<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="hidden" name="parent_id" id="commentParentId" value="">
|
||||||
<input type="text" name="author" placeholder="Dein Name (optional)" maxlength="80" class="comment-input">
|
<input type="text" name="author" placeholder="{__("Dein Name (optional)", lang)}" maxlength="80" class="comment-input">
|
||||||
<textarea name="content" placeholder="Schreib einen Kommentar..." maxlength="2000" required class="comment-textarea"></textarea>
|
<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):</div>
|
<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" maxlength="200" class="comment-email">
|
<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)" maxlength="50" class="comment-phone">
|
<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</button>
|
<button type="submit" class="comment-submit-btn">{__("Kommentar senden", lang)}</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="commentStatus" style="display:none;margin-top:0.5rem;padding:0.5rem;border-radius:8px;font-size:0.85rem;"></div>
|
<div id="commentStatus" style="display:none;margin-top:0.5rem;padding:0.5rem;border-radius:8px;font-size:0.85rem;"></div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<footer class="site-footer">
|
<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>
|
</footer>
|
||||||
<div style="text-align:center;padding:1rem 1.5rem 2.5rem;font-size:0.7rem;color:#aaa;max-width:720px;margin:0 auto;">
|
<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>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function(){{
|
(function(){{
|
||||||
@@ -4736,9 +4962,6 @@ def public_detail(sub_id: int):
|
|||||||
var id = parseInt(btn.getAttribute('data-id'));
|
var id = parseInt(btn.getAttribute('data-id'));
|
||||||
if (likedPosts.indexOf(id) !== -1) {{
|
if (likedPosts.indexOf(id) !== -1) {{
|
||||||
btn.classList.add('liked');
|
btn.classList.add('liked');
|
||||||
btn.style.background = '#20228a';
|
|
||||||
btn.style.color = '#fff';
|
|
||||||
btn.style.borderColor = '#20228a';
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
btn.addEventListener('click', function() {{
|
btn.addEventListener('click', function() {{
|
||||||
@@ -4750,9 +4973,6 @@ def public_detail(sub_id: int):
|
|||||||
if (!data.already) {{
|
if (!data.already) {{
|
||||||
btn.classList.add('liked');
|
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.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);
|
likedPosts.push(id);
|
||||||
localStorage.setItem('likedPosts', JSON.stringify(likedPosts));
|
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;}}
|
body {{font-family:'Inter',sans-serif;text-align:center;padding:3rem;background:#f5f3ee;color:#1a1a2e;}}
|
||||||
h1 {{color:#20228a;}}
|
h1 {{color:#20228a;}}
|
||||||
a {{color:#20228a;}}
|
a {{color:#20228a;}}
|
||||||
</style></head>
|
</style><link rel="stylesheet" href="/static/theme.css">
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>✅ Kommentar bestätigt!</h1>
|
<h1>✅ Kommentar bestätigt!</h1>
|
||||||
<p>Dein Kommentar ist jetzt auf boardgame.network sichtbar.</p>
|
<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