Compare commits

...

15 Commits

Author SHA1 Message Date
Hermes Agent 63269b18c4 feat: UI-Text-Übersetzung — Kommentar-Formular, Welcome-Overlay, Footer, Labels
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 11m3s
- __() Funktion + UI_TEXT Dict mit 16 Schlüsselbegriffen (DE→EN/FR/ES)
- Detailseite: serverseitig via f-string {__(...)}
- Indexseite: clientseitig via JS-Text-Walker + placeholder-Übersetzung
- _ui_text_json() injiziert Übersetzungen als JSON ins Dropdown-Script
- Übersetzte Elemente: Kommentar-Formular, Zurück-Link, Aufrufe, Kommentare,
  Welcome-Overlay (Willkommen/Nicht mehr anzeigen/Alles klar), Footer
2026-07-16 08:34:36 +02:00
Hermes Agent 180a77aa8b 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
2026-07-16 08:23:17 +02:00
Hermes Agent b46dafef08 fix: doppelten Hamburger-Button + fehlendes <header>-Tag repariert
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 12m43s
2026-07-16 08:12:56 +02:00
Hermes Agent 6a55a3e475 feat: Mehrsprachigkeit EN/FR/ES via Mistral + Sprachumschalter
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 11m29s
- Übersetzung per ministral-3b-latest (on-demand + DB-Cache)
- Sprachumschalter im Detailseiten-Header (DE/EN/FR/ES)
- ?lang= Query-Parameter auf /beitrag/<id>
- Neue translations-Tabelle mit submission_id+lang UNIQUE
2026-07-16 08:04:10 +02:00
Hermes Agent 5a50df7923 feat: Videos in Beitragsansicht autoplay (muted+loop+playsinline)
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 14m58s
2026-07-16 00:15:37 +02:00
Hermes Agent af0af785ab fix: Logo-Größe einheitlich 38px auf allen Seiten
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 10m55s
Vorher: Index=38px, Detail=30px, Admin=34px → Sprung je nach Ansicht
Jetzt: alle Views einheitlich 38px (height:38px;width:auto)
2026-07-16 00:14:46 +02:00
Hermes Agent ebf6e0e6fa fix: Herz-Icon ohne blauen Hintergrund — JS-Inline-Styles entfernt
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 12m16s
Die JavaScript-Initialisierung und Click-Handler setzten
btn.style.background = '#20228a' und ueberschrieben die CSS-Regel
background:none. Entfernt in beiden Code-Pfaden.
2026-07-16 00:13:24 +02:00
Hermes Agent a3d06f0f90 fix: BSN Assistent — keine Emojis/Icons in Antworten
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 10m19s
2026-07-15 23:05:19 +02:00
Hermes Agent f145b815e3 feat: Mistral API (ministral-3b-latest) + Theme-Variablen-System
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 5s
- Chat-Endpoint: datenhimmel → Mistral API (api.mistral.ai/v1)
- Model: ministral-3b-latest (max_tokens=400, temp=0.4)
- API-Key aus MISTRAL_API_KEY env-Variable (nicht mehr config.yaml)
- theme.css als zentrales Variablen-System (nur :root tokens + reset)
- theme.css wird ZUSÄTZLICH geladen — alle <style>-Blöcke bleiben intakt
- Kein Layout-Bruch: alle bestehenden Styles funktionieren weiter
2026-07-15 23:02:52 +02:00
Hermes Agent 9016977158 refactor: zentrales Theme-System — alle CSS in theme.css
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 14m39s
- 11 <style>-Blöcke (623 Zeilen) aus app.py entfernt
- 1485 Zeilen theme.css mit CSS-Custom-Properties
- Alle Design-Tokens als Variablen: Farben, Schrift, Abstände, Radien, Schatten
- Body-Klassen (.public, .admin, .welcome, .toast-page, .legacy-table, .test-page)
- Alle Herz-Buttons identisch: .stat-like-btn, .detail-like-btn, .comment-like-btn
- Bei Design-Änderungen nur noch EINE Datei anpassen

Noch 135 inline style='...' — kommen in separatem Refactoring
2026-07-15 22:55:59 +02:00
Hermes Agent eda106849e fix: einheitliche rote Herzen überall — kein Blau, keine Invertierung
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 13m20s
- .detail-like-btn: SVG fill=#EF4444, liked kein background
- .comment-like-btn: SVG fill=#EF4444, border entfernt, liked kein background
- Alle drei Button-Typen (.stat-like, .detail-like, .comment-like) jetzt identisch:
  Default: rote gefüllte Herzen ohne Border
  Liked: nur rote Herzen, kein Hintergrundwechsel
- media_path-Check: os.path.exists(path) → (MEDIA_DIR / basename).exists()
2026-07-15 22:47:20 +02:00
Hermes Agent 6ab03fbe85 fix: graue Ränder entfernt — .text-only + .stat-like-btn
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 13m47s
- .text-only: border:1px solid #F3F4F6 → entfernt (keine Card-Rahmen)
- .stat-like-btn: border:1px solid #E5E7EB → border:none (kein Button-Rand)
- Herz bleibt solid red #EF4444 im unliked state
2026-07-15 22:41:50 +02:00
Hermes Agent 6600d5e346 fix: Herz-Icon solid red (#EF4444) + CSS liked-state fill
Quality Gate / 🛡️ Lint + Type Check + Tests (push) Failing after 12m19s
- SVG fill="none" → fill="#EF4444" (solid red heart)
- CSS default: stroke + fill #EF4444
- CSS liked: stroke + fill #fff (white heart on red button)
- stroke-width 1.5 → 0.5 für cleaner Look
2026-07-15 22:38:20 +02:00
Hermes Agent 971e52564b fix: worker: source → channel für DB-Kompatibilität 2026-07-15 20:24:32 +02:00
Hermes Agent 82dbc6b076 fix: f-string backslash-escape fixes für Python 3.11 Kompatibilität 2026-07-15 20:12:21 +02:00
4 changed files with 5911 additions and 65 deletions
+284 -63
View File
@@ -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">
@@ -1962,7 +1967,7 @@ def submission_detail(sub_id: int):
<form method="post" action="/submission/{row['id']}/update-summary" style="margin-left:0.5rem;">
<button type="submit" class="btn btn-publish-big" name="action" value="publish" {'disabled' if row['status'] in ('published','gdpr_deleted') else ''} style="padding:0.4rem 1.2rem;font-size:0.8rem;white-space:nowrap;">📢 Veröffentlichen</button>
</form>
{"".join([f'<button type="button" id="unpublish-btn" class="btn btn-publish" style="padding:0.35rem 0.8rem;font-size:0.75rem;white-space:nowrap;background:rgba(255,193,7,0.18);color:#ffc107;border:1px solid rgba(255,193,7,0.3);">\u2b05\ufe0f Ver\u00f6ffentlichung zur\u00fccknehmen</button>' if row['status'] == 'published' else ''])}</header>
{''.join([f'<button type="button" id="unpublish-btn" class="btn btn-publish" style="padding:0.35rem 0.8rem;font-size:0.75rem;white-space:nowrap;background:rgba(255,193,7,0.18);color:#ffc107;border:1px solid rgba(255,193,7,0.3);">\u2b05\ufe0f Ver\u00f6ffentlichung zur\u00fccknehmen</button>' if row['status'] == 'published' else ''])}</header>
<main class="detail-main">
<div class="card">
@@ -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:
@@ -2821,7 +2832,7 @@ def public_frontend():
<div class="meta-spacer"></div>
<span class="card-meta-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="1.5" stroke-linecap="round"><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>{r['views'] or 0}</span>
<span class="card-meta-item"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" stroke-width="1.5" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>{r['comment_count'] or 0}</span>
<button class="stat-like-btn" data-id="{r['id']}" data-likes="{r['likes'] or 0}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#D1D5DB" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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="like-count">{r['likes'] or 0}</span></button>
<button class="stat-like-btn" data-id="{r['id']}" data-likes="{r['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"><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="like-count">{r['likes'] or 0}</span></button>
</div>"""
is_ausverkauft = r["category"] == "ausverkauft" and r["spiel_titel"]
@@ -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 svg, .stat-like-btn svg path { stroke:#D1D5DB !important; }
.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; }
.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>
+2 -2
View File
@@ -96,7 +96,7 @@ def process_whatsapp_submission(db: sqlite3.Connection, payload: dict) -> str:
db.execute(
"""INSERT INTO submissions
(sender_id, sender_name, source, type, content, payload_json, created_at)
(sender_id, sender_name, channel, type, content, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
sender_id,
@@ -153,7 +153,7 @@ def process_telegram_submission(db: sqlite3.Connection, payload: dict) -> str:
db.execute(
"""INSERT INTO submissions
(sender_id, sender_name, source, type, content, payload_json, created_at)
(sender_id, sender_name, channel, type, content, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(
sender_id,
+5562
View File
File diff suppressed because one or more lines are too long
+63
View File
@@ -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;
}