Compare commits
7 Commits
d02c9906a9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 42746581b1 | |||
| db707f650a | |||
| 3a26e03510 | |||
| b787aa12af | |||
| ed63994e1c | |||
| e07c1437d3 | |||
| 7c63321663 |
+263
-91
@@ -1903,11 +1903,21 @@ textarea{{min-height:100px;resize:vertical}}
|
|||||||
<h1>Review Builder</h1>
|
<h1>Review Builder</h1>
|
||||||
<p style="color:var(--muted)">Word-Datei + Bilder hochladen. Inhalte werden automatisch zugeordnet.</p>
|
<p style="color:var(--muted)">Word-Datei + Bilder hochladen. Inhalte werden automatisch zugeordnet.</p>
|
||||||
<form id="reviewForm" enctype="multipart/form-data">
|
<form id="reviewForm" enctype="multipart/form-data">
|
||||||
<div class="card"><h2>1. Word-Datei hochladen</h2>
|
<div class="card"><h2>1. Dateien hochladen</h2>
|
||||||
<div class="drop-zone" id="wordDrop">
|
<div class="row-2">
|
||||||
<div class="drop-zone-label"><strong>Word-Datei hier ablegen</strong> oder klicken (.docx)</div>
|
<div>
|
||||||
|
<div class="drop-zone" id="excelDrop" style="padding:20px">
|
||||||
|
<div class="drop-zone-label"><strong>Excel mit Fakten</strong><br>(.xlsx)</div>
|
||||||
|
<input type="file" id="excelInput" accept=".xlsx" style="display:none">
|
||||||
|
</div><ul class="file-list" id="excelList"></ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="drop-zone" id="wordDrop" style="padding:20px">
|
||||||
|
<div class="drop-zone-label"><strong>Word mit Artikeltext</strong><br>(.docx)</div>
|
||||||
<input type="file" id="wordInput" accept=".docx" style="display:none">
|
<input type="file" id="wordInput" accept=".docx" style="display:none">
|
||||||
</div><ul class="file-list" id="wordList"></ul></div>
|
</div><ul class="file-list" id="wordList"></ul>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
<div class="card"><h2>2. Bilder hochladen</h2>
|
<div class="card"><h2>2. Bilder hochladen</h2>
|
||||||
<div class="drop-zone" id="imageDrop">
|
<div class="drop-zone" id="imageDrop">
|
||||||
<div class="drop-zone-label"><strong>Bilder hier ablegen</strong> oder klicken</div>
|
<div class="drop-zone-label"><strong>Bilder hier ablegen</strong> oder klicken</div>
|
||||||
@@ -1916,7 +1926,7 @@ textarea{{min-height:100px;resize:vertical}}
|
|||||||
<p style="font-size:.8em;color:var(--muted);margin-top:6px">Auto-Resize: 1920x1080 @ 72dpi</p></div>
|
<p style="font-size:.8em;color:var(--muted);margin-top:6px">Auto-Resize: 1920x1080 @ 72dpi</p></div>
|
||||||
<div class="card"><h2>3. Metadaten</h2>
|
<div class="card"><h2>3. Metadaten</h2>
|
||||||
<div class="row-2">
|
<div class="row-2">
|
||||||
<div><label>Spielname *</label><input type="text" name="spielname" id="spielname" required></div>
|
<div><label>Spielname (aus Excel)</label><input type="text" name="spielname" id="spielname" placeholder="Automatisch aus Excel"></div>
|
||||||
<div><label>Autor *</label><select name="autor" id="autor" required><option value="">Autor wahlen...</option>{author_opts}</select></div>
|
<div><label>Autor *</label><select name="autor" id="autor" required><option value="">Autor wahlen...</option>{author_opts}</select></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-2">
|
<div class="row-2">
|
||||||
@@ -1930,6 +1940,10 @@ textarea{{min-height:100px;resize:vertical}}
|
|||||||
<div class="row-2">
|
<div class="row-2">
|
||||||
<div><label>Fantasywelt URL</label><input type="text" name="fantasywelt_url" id="fantasywelt_url"></div>
|
<div><label>Fantasywelt URL</label><input type="text" name="fantasywelt_url" id="fantasywelt_url"></div>
|
||||||
<div><label>Spiele-Offensive URL</label><input type="text" name="so_url" id="so_url"></div>
|
<div><label>Spiele-Offensive URL</label><input type="text" name="so_url" id="so_url"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row-2">
|
||||||
|
<div><label>Amazon URL</label><input type="text" name="amazon_url" id="amazon_url"></div>
|
||||||
|
<div><label>AllGames4You URL</label><input type="text" name="allgames4you_url" id="allgames4you_url"></div>
|
||||||
</div></div>
|
</div></div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-outline" id="previewBtn">Vorschau</button>
|
<button type="button" class="btn btn-outline" id="previewBtn">Vorschau</button>
|
||||||
@@ -1939,10 +1953,17 @@ textarea{{min-height:100px;resize:vertical}}
|
|||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script>
|
<script>
|
||||||
|
const excelDrop=document.getElementById('excelDrop'),excelInput=document.getElementById('excelInput'),excelList=document.getElementById('excelList');
|
||||||
const wordDrop=document.getElementById('wordDrop'),wordInput=document.getElementById('wordInput'),wordList=document.getElementById('wordList');
|
const wordDrop=document.getElementById('wordDrop'),wordInput=document.getElementById('wordInput'),wordList=document.getElementById('wordList');
|
||||||
const imageDrop=document.getElementById('imageDrop'),imageInput=document.getElementById('imageInput'),imageList=document.getElementById('imageList');
|
const imageDrop=document.getElementById('imageDrop'),imageInput=document.getElementById('imageInput'),imageList=document.getElementById('imageList');
|
||||||
let wordFile=null,imageFiles=[];
|
let excelFile=null,wordFile=null,imageFiles=[];
|
||||||
function st(msg,type){{var t=document.getElementById('toast');t.textContent=msg;t.className='toast '+type+' show';clearTimeout(t._t);t._t=setTimeout(function(){{t.classList.remove('show')}},3000)}}
|
function st(msg,type){{var t=document.getElementById('toast');t.textContent=msg;t.className='toast '+type+' show';clearTimeout(t._t);t._t=setTimeout(function(){{t.classList.remove('show')}},3000)}}
|
||||||
|
excelDrop.addEventListener('click',function(){{excelInput.click()}});
|
||||||
|
excelDrop.addEventListener('dragover',function(e){{e.preventDefault();excelDrop.classList.add('dragover')}});
|
||||||
|
excelDrop.addEventListener('dragleave',function(){{excelDrop.classList.remove('dragover')}});
|
||||||
|
excelDrop.addEventListener('drop',function(e){{e.preventDefault();excelDrop.classList.remove('dragover');if(e.dataTransfer.files[0])aef(e.dataTransfer.files[0])}});
|
||||||
|
excelInput.addEventListener('change',function(){{if(excelInput.files[0])aef(excelInput.files[0])}});
|
||||||
|
function aef(f){{excelFile=f;excelList.innerHTML='<li>'+f.name+' ('+(f.size/1024).toFixed(1)+' KB) <span class="remove" onclick="excelFile=null;excelList.innerHTML=\\'\\';">X</span></li>'}}
|
||||||
wordDrop.addEventListener('click',function(){{wordInput.click()}});
|
wordDrop.addEventListener('click',function(){{wordInput.click()}});
|
||||||
wordDrop.addEventListener('dragover',function(e){{e.preventDefault();wordDrop.classList.add('dragover')}});
|
wordDrop.addEventListener('dragover',function(e){{e.preventDefault();wordDrop.classList.add('dragover')}});
|
||||||
wordDrop.addEventListener('dragleave',function(){{wordDrop.classList.remove('dragover')}});
|
wordDrop.addEventListener('dragleave',function(){{wordDrop.classList.remove('dragover')}});
|
||||||
@@ -1955,7 +1976,7 @@ imageDrop.addEventListener('dragleave',function(){{imageDrop.classList.remove('d
|
|||||||
imageDrop.addEventListener('drop',function(e){{e.preventDefault();imageDrop.classList.remove('dragover');aif(e.dataTransfer.files)}});
|
imageDrop.addEventListener('drop',function(e){{e.preventDefault();imageDrop.classList.remove('dragover');aif(e.dataTransfer.files)}});
|
||||||
imageInput.addEventListener('change',function(){{aif(imageInput.files)}});
|
imageInput.addEventListener('change',function(){{aif(imageInput.files)}});
|
||||||
function aif(files){{for(var i=0;i<files.length;i++){{(function(f){{imageFiles.push(f);var li=document.createElement('li');li.innerHTML=f.name+' ('+(f.size/1024).toFixed(1)+' KB) <span class="remove">X</span>';li.querySelector('.remove').onclick=function(){{imageFiles=imageFiles.filter(function(x){{return x!==f}});li.remove()}};imageList.appendChild(li)}})(files[i])}}}}
|
function aif(files){{for(var i=0;i<files.length;i++){{(function(f){{imageFiles.push(f);var li=document.createElement('li');li.innerHTML=f.name+' ('+(f.size/1024).toFixed(1)+' KB) <span class="remove">X</span>';li.querySelector('.remove').onclick=function(){{imageFiles=imageFiles.filter(function(x){{return x!==f}});li.remove()}};imageList.appendChild(li)}})(files[i])}}}}
|
||||||
async function submitForm(publish){{var s=document.getElementById('spielname').value.trim();if(!s){{st('Spielname fehlt','err');return}}if(!wordFile){{st('Word-Datei fehlt','err');return}}var fd=new FormData();fd.append('word',wordFile);imageFiles.forEach(function(f){{fd.append('images',f)}});fd.append('spielname',s);fd.append('autor',document.getElementById('autor').value);fd.append('rubrik',document.getElementById('rubrik').value);fd.append('komplexitaet',document.getElementById('komplexitaet').value);fd.append('disclosure',document.getElementById('disclosure').value);fd.append('verlag',document.getElementById('verlag').value.trim());fd.append('fantasywelt_url',document.getElementById('fantasywelt_url').value.trim());fd.append('so_url',document.getElementById('so_url').value.trim());fd.append('publish',publish?'1':'0');st(publish?'Veroffentliche...':'Baue Vorschau...','info');try{{var resp=await fetch('/review-builder/build?key={access_token}',{{method:'POST',body:fd}});var data=await resp.json();if(!data.ok){{st(data.error||'Fehler','err');return}}var box=document.getElementById('resultBox');box.classList.add('visible');document.getElementById('resultTitle').textContent=data.title;document.getElementById('resultLink').innerHTML=data.published?'<a href="'+data.article_url+'" target="_blank">Artikel online: '+data.article_url+'</a>':'<a href="/'+data.filename+'?key={access_token}" target="_blank">Vorschau: '+data.filename+'</a>';st(data.published?'Veroffentlicht!':'Vorschau bereit','ok')}}catch(e){{st('Fehler: '+e.message,'err')}}}}
|
async function submitForm(publish){{var s=document.getElementById('spielname').value.trim();var fd=new FormData();if(excelFile)fd.append('excel',excelFile);if(wordFile)fd.append('word',wordFile);imageFiles.forEach(function(f){{fd.append('images',f)}});fd.append('spielname',s);fd.append('autor',document.getElementById('autor').value);fd.append('rubrik',document.getElementById('rubrik').value);fd.append('komplexitaet',document.getElementById('komplexitaet').value);fd.append('disclosure',document.getElementById('disclosure').value);fd.append('verlag',document.getElementById('verlag').value.trim());fd.append('fantasywelt_url',document.getElementById('fantasywelt_url').value.trim());fd.append('so_url',document.getElementById('so_url').value.trim());fd.append('amazon_url',document.getElementById('amazon_url').value.trim());fd.append('allgames4you_url',document.getElementById('allgames4you_url').value.trim());fd.append('publish',publish?'1':'0');st(publish?'Veroffentliche...':'Baue Vorschau...','info');try{{var resp=await fetch('/review-builder/build?key={access_token}',{{method:'POST',body:fd}});var data=await resp.json();if(!data.ok){{st(data.error||'Fehler','err');return}}var box=document.getElementById('resultBox');box.classList.add('visible');document.getElementById('resultTitle').textContent=data.title;document.getElementById('resultLink').innerHTML=data.published?'<a href="'+data.article_url+'" target="_blank">Artikel online: '+data.article_url+'</a>':'<a href="/'+data.filename+'?key={access_token}" target="_blank">Vorschau: '+data.filename+'</a>';st(data.published?'Veroffentlicht!':'Vorschau bereit','ok')}}catch(e){{st('Fehler: '+e.message,'err')}}}}
|
||||||
document.getElementById('previewBtn').addEventListener('click',function(){{submitForm(false)}});
|
document.getElementById('previewBtn').addEventListener('click',function(){{submitForm(false)}});
|
||||||
document.getElementById('publishBtn').addEventListener('click',function(){{submitForm(true)}});
|
document.getElementById('publishBtn').addEventListener('click',function(){{submitForm(true)}});
|
||||||
</script></body></html>"""
|
</script></body></html>"""
|
||||||
@@ -1979,7 +2000,7 @@ document.getElementById('publishBtn').addEventListener('click',function(){{submi
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _handle_review_builder_build(self):
|
def _handle_review_builder_build(self):
|
||||||
"""POST: Word parsen, LLM-strukturieren, HTML bauen."""
|
"""POST: Excel parsen, HTML bauen."""
|
||||||
ctype = self.headers.get("Content-Type", "")
|
ctype = self.headers.get("Content-Type", "")
|
||||||
if "multipart/form-data" not in ctype:
|
if "multipart/form-data" not in ctype:
|
||||||
self._json_reply(400, {"ok": False, "error": "Nur multipart/form-data"})
|
self._json_reply(400, {"ok": False, "error": "Nur multipart/form-data"})
|
||||||
@@ -1990,68 +2011,105 @@ document.getElementById('publishBtn').addEventListener('click',function(){{submi
|
|||||||
if boundary.startswith('"') and boundary.endswith('"'):
|
if boundary.startswith('"') and boundary.endswith('"'):
|
||||||
boundary = boundary[1:-1]
|
boundary = boundary[1:-1]
|
||||||
parts = _parse_multipart(raw, boundary)
|
parts = _parse_multipart(raw, boundary)
|
||||||
|
excel_data = parts.get("excel")
|
||||||
word_data = parts.get("word")
|
word_data = parts.get("word")
|
||||||
image_datas = [v for k, v in parts.items() if k.startswith("images_")]
|
image_datas = [v for k, v in parts.items() if k.startswith("images_")]
|
||||||
meta = {k: v for k, v in parts.items() if k not in ("word",) and not k.startswith("images_")}
|
meta = {k: v for k, v in parts.items() if k not in ("excel", "word") and not k.startswith("images_")}
|
||||||
if not word_data:
|
|
||||||
self._json_reply(400, {"ok": False, "error": "Keine Word-Datei"})
|
# Excel parsen (Fakten, Wertung, Pro/Kontra, Tags)
|
||||||
return
|
xl = {}
|
||||||
try:
|
if excel_data:
|
||||||
from docx import Document
|
try:
|
||||||
doc = Document(io.BytesIO(word_data))
|
import openpyxl
|
||||||
word_text = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
wb = openpyxl.load_workbook(io.BytesIO(excel_data), data_only=True)
|
||||||
except Exception as e:
|
ws = wb["EINGABE"]
|
||||||
self._json_reply(400, {"ok": False, "error": f"Word-Fehler: {e}"})
|
for row in ws.iter_rows(min_row=1, values_only=True):
|
||||||
return
|
key = str(row[0]).strip() if row[0] else ""
|
||||||
spielname = meta.get("spielname", "Unbekannt").strip()
|
val = str(row[1]).strip() if row[1] else ""
|
||||||
|
if key and val:
|
||||||
|
xl[key] = val
|
||||||
|
except Exception as e:
|
||||||
|
self._json_reply(400, {"ok": False, "error": f"Excel-Fehler: {e}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Word parsen (Artikeltext)
|
||||||
|
word_text = ""
|
||||||
|
if word_data:
|
||||||
|
try:
|
||||||
|
from docx import Document
|
||||||
|
doc = Document(io.BytesIO(word_data))
|
||||||
|
word_text = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
||||||
|
except Exception as e:
|
||||||
|
self._json_reply(400, {"ok": False, "error": f"Word-Fehler: {e}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
spielname = xl.get("Spieltitel", meta.get("spielname", "Unbekannt")).strip()
|
||||||
|
zusatz = xl.get("Zusatztitel", "").strip()
|
||||||
|
tester = xl.get("Test von", "").strip()
|
||||||
|
|
||||||
|
# Autor-Mapping: "Jan Maguna" → autor_key (immer aus Excel, Form nur Fallback)
|
||||||
autor_key = meta.get("autor", "basti").strip()
|
autor_key = meta.get("autor", "basti").strip()
|
||||||
|
cfg = REVIEW_CONFIG
|
||||||
|
if tester:
|
||||||
|
for k, v in cfg.get("authors", {}).items():
|
||||||
|
if v["name"].lower() == tester.lower():
|
||||||
|
autor_key = k
|
||||||
|
break
|
||||||
|
author_info = cfg.get("authors", {}).get(autor_key, {"name": tester or autor_key, "banner": "sebastian_meine_meinung_uberschrift.png"})
|
||||||
|
|
||||||
rubrik_id = int(meta.get("rubrik", "8"))
|
rubrik_id = int(meta.get("rubrik", "8"))
|
||||||
komplex = meta.get("komplexitaet", "").strip()
|
do_publish = meta.get("publish", "0") == "1"
|
||||||
disclosure_id = meta.get("disclosure", "verlag_gestellt").strip()
|
|
||||||
verlag_name = meta.get("verlag", "").strip()
|
|
||||||
fantasywelt_url = meta.get("fantasywelt_url", "").strip()
|
fantasywelt_url = meta.get("fantasywelt_url", "").strip()
|
||||||
so_url = meta.get("so_url", "").strip()
|
so_url = meta.get("so_url", "").strip()
|
||||||
do_publish = meta.get("publish", "0") == "1"
|
amazon_url = meta.get("amazon_url", "").strip()
|
||||||
cfg = REVIEW_CONFIG
|
allgames4you_url = meta.get("allgames4you_url", "").strip()
|
||||||
author_info = cfg.get("authors", {}).get(autor_key, {"name": autor_key, "banner": "sebastian_meine_meinung_uberschrift.png"})
|
|
||||||
|
# Excel-Felder
|
||||||
|
komplex = xl.get("Komplexität des Spiels", "").strip()
|
||||||
|
autoren = xl.get("Autor:innen", "").strip()
|
||||||
|
illustratoren = xl.get("Illustrator:innen", "").strip()
|
||||||
|
verlag = xl.get("Verlag/Vertrieb", "").strip()
|
||||||
|
personen = xl.get("Personenzahl", "").strip()
|
||||||
|
spielzeit = xl.get("Spielzeit", "").strip()
|
||||||
|
jahr = xl.get("Erscheinungsjahr", "").strip()
|
||||||
|
preis = xl.get("UVP/Ø-Preis", "").strip()
|
||||||
|
app = xl.get("App-Unterstützung", "").strip()
|
||||||
|
alter = xl.get("empfohlenes Alter", "").strip()
|
||||||
|
wertung = xl.get("WERTUNG (1-100)", "").strip()
|
||||||
|
sprache = xl.get("Sprache", "Deutsch").strip()
|
||||||
|
|
||||||
|
# Mechanismen
|
||||||
|
mech = []
|
||||||
|
for i in range(1, 5):
|
||||||
|
m = xl.get(f"Mechanismus {i}", "").strip()
|
||||||
|
if m: mech.append(m)
|
||||||
|
|
||||||
|
# Pro/Kontra
|
||||||
|
pros = []
|
||||||
|
for i in range(1, 5):
|
||||||
|
p = xl.get(f"Pro {i}", "").strip()
|
||||||
|
if p: pros.append(p)
|
||||||
|
kontras = []
|
||||||
|
for i in range(1, 5):
|
||||||
|
k = xl.get(f"Kontra {i}", "").strip()
|
||||||
|
if k: kontras.append(k)
|
||||||
|
|
||||||
|
# Tags
|
||||||
|
tags = []
|
||||||
|
for i in range(1, 11):
|
||||||
|
t = xl.get(f"Tag {i}", "").strip()
|
||||||
|
if t: tags.append(t)
|
||||||
|
|
||||||
|
# Disclosure aus Verlag
|
||||||
|
verlag_name = meta.get("verlag", verlag).strip()
|
||||||
|
disclosure_id = meta.get("disclosure", "verlag_gestellt").strip()
|
||||||
disclosure_text = ""
|
disclosure_text = ""
|
||||||
for d in cfg.get("disclosures", []):
|
for d in cfg.get("disclosures", []):
|
||||||
if d["id"] == disclosure_id:
|
if d["id"] == disclosure_id:
|
||||||
disclosure_text = d["text"].replace("{VERLAG}", verlag_name or "der Verlag").replace("{SPIEL}", spielname)
|
disclosure_text = d["text"].replace("{VERLAG}", verlag_name or "der Verlag").replace("{SPIEL}", spielname)
|
||||||
break
|
break
|
||||||
# LLM-Strukturierung
|
|
||||||
teaser = word_text[:300]
|
# Bilder speichern
|
||||||
erklarung = word_text
|
|
||||||
fazit = ""
|
|
||||||
try:
|
|
||||||
prompt = (f'Analysiere den Brettspiel-Review-Text fur "{spielname}". '
|
|
||||||
f'Extrahiere:\n1. TEASER (1-3 einleitende Satze)\n'
|
|
||||||
f'2. SPIELERKLARUNG (ausfuhrliche Spielbeschreibung)\n'
|
|
||||||
f'3. FAZIT (personliche Meinung/Bewertung)\n\n'
|
|
||||||
f'Ausgabeformat:\n---TEASER---\n[Text]\n---ERKLAERUNG---\n[Text]\n---FAZIT---\n[Text]\n\n'
|
|
||||||
f'Text:\n{word_text[:8000]}')
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
api_body = json.dumps({
|
|
||||||
"model": "deepseek-v4-flash:cloud", "provider": "datenhimmel",
|
|
||||||
"messages": [{"role": "system", "content": "Du bist ein praziser Textanalysator."},
|
|
||||||
{"role": "user", "content": prompt}],
|
|
||||||
"temperature": 0.1
|
|
||||||
}).encode()
|
|
||||||
req = Request("https://ui.datenhimmel.work/api/v1/chat/completions",
|
|
||||||
data=api_body, headers={"Content-Type": "application/json",
|
|
||||||
"Authorization": "Bearer sk-ce3...b6db"})
|
|
||||||
resp = urlopen(req, timeout=30)
|
|
||||||
result = json.loads(resp.read().decode())
|
|
||||||
llm_text = result["choices"][0]["message"]["content"]
|
|
||||||
tm = re.search(r'---TEASER---\s*(.*?)\s*---ERKLAERUNG---', llm_text, re.DOTALL)
|
|
||||||
em = re.search(r'---ERKLAERUNG---\s*(.*?)\s*---FAZIT---', llm_text, re.DOTALL)
|
|
||||||
fm = re.search(r'---FAZIT---\s*(.*)', llm_text, re.DOTALL)
|
|
||||||
if tm: teaser = tm.group(1).strip()
|
|
||||||
if em: erklarung = em.group(1).strip()
|
|
||||||
if fm: fazit = fm.group(1).strip()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[review-builder] LLM fehlgeschlagen: {e}", flush=True)
|
|
||||||
# Bilder speichern & resizen
|
|
||||||
erster = spielname[0].lower() if spielname else "x"
|
erster = spielname[0].lower() if spielname else "x"
|
||||||
ordner = spielname.lower().replace(" ", "")
|
ordner = spielname.lower().replace(" ", "")
|
||||||
base_img = f"images/Bilderverzeichnis/01/{erster}/{ordner}"
|
base_img = f"images/Bilderverzeichnis/01/{erster}/{ordner}"
|
||||||
@@ -2062,53 +2120,107 @@ document.getElementById('publishBtn').addEventListener('click',function(){{submi
|
|||||||
_resize_image(img_data, os.path.join(wd, f"bild_{idx+1}.jpg"))
|
_resize_image(img_data, os.path.join(wd, f"bild_{idx+1}.jpg"))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[review-builder] Bild {idx} fehlgeschlagen: {e}", flush=True)
|
print(f"[review-builder] Bild {idx} fehlgeschlagen: {e}", flush=True)
|
||||||
|
|
||||||
# HTML bauen
|
# HTML bauen
|
||||||
datum = datetime.now().strftime("%d. %B %Y")
|
datum = datetime.now().strftime("%d. %B %Y")
|
||||||
autor_name = author_info["name"]
|
autor_name = author_info["name"]
|
||||||
autor_banner = author_info["banner"]
|
autor_banner = author_info["banner"]
|
||||||
if not teaser.startswith("<"): teaser = f"<p>{teaser}</p>"
|
|
||||||
if not erklarung.startswith("<"): erklarung = f"<p>{erklarung.replace(chr(10)+chr(10), '</p><p>')}</p>"
|
# FAKTEN ZUM SPIEL — 3-Spalten-Grid (CSS: .bsn-fakten)
|
||||||
if fazit and not fazit.startswith("<"): fazit = f"<p>{fazit.replace(chr(10)+chr(10), '</p><p>')}</p>"
|
fakten_items = []
|
||||||
|
if autoren: fakten_items.append(('Autor:innen', autoren))
|
||||||
|
if illustratoren: fakten_items.append(('Illustrator:innen', illustratoren))
|
||||||
|
if verlag: fakten_items.append(('Verlag/Vertrieb', verlag))
|
||||||
|
if personen: fakten_items.append(('Personenzahl', personen))
|
||||||
|
if spielzeit: fakten_items.append(('Spielzeit', spielzeit))
|
||||||
|
if jahr: fakten_items.append(('Erscheinungsjahr', jahr))
|
||||||
|
if preis: fakten_items.append(('ungefährer Preis', preis))
|
||||||
|
if app: fakten_items.append(('App', app))
|
||||||
|
if alter: fakten_items.append(('empfohlenes Alter', alter))
|
||||||
|
fakten_html = ""
|
||||||
|
if fakten_items:
|
||||||
|
items = "".join(f'<div class="bsn-fakten-item"><span class="bsn-fakten-val">{v}</span><span class="bsn-fakten-lbl">{k}</span></div>' for k, v in fakten_items)
|
||||||
|
fakten_html = f'<div class="bsn-fakten"><h3>FAKTEN ZUM SPIEL</h3><div class="bsn-fakten-grid">{items}</div></div>'
|
||||||
|
|
||||||
|
# BEWERTUNGSKARTE — dunkelblauer Header + 3 Spalten + Footer (CSS: .bsn-rating-card)
|
||||||
|
mech_items = "".join(f'<span class="bsn-rc-mech">{m}</span>' for m in mech) if mech else ""
|
||||||
|
pro_items = "".join(f'<span class="bsn-rc-pro">{p}</span>' for p in pros) if pros else ""
|
||||||
|
kontra_items = "".join(f'<span class="bsn-rc-kontra">{k}</span>' for k in kontras) if kontras else ""
|
||||||
|
rating_html = ""
|
||||||
|
if wertung:
|
||||||
|
rating_html = f"""<div class="bsn-rating-card">
|
||||||
|
<div class="bsn-rc-header"><span class="bsn-rc-title">{spielname}</span><span class="bsn-rc-meta">Komplexität: {komplex or '—'}</span><span class="bsn-rc-meta">Sprachen: {sprache}</span></div>
|
||||||
|
<div class="bsn-rc-body"><div class="bsn-rc-col"><h4>MECHANISMEN</h4>{mech_items or '<span class="bsn-rc-empty">—</span>'}</div>
|
||||||
|
<div class="bsn-rc-col"><h4>PRO</h4>{pro_items or '<span class="bsn-rc-empty">—</span>'}</div>
|
||||||
|
<div class="bsn-rc-col"><h4>KONTRA</h4>{kontra_items or '<span class="bsn-rc-empty">—</span>'}</div></div>
|
||||||
|
<div class="bsn-rc-footer"><span class="bsn-rc-author">Bewertung von: {autor_name}</span><span class="bsn-rc-score">MEINE WERTUNG {wertung} / 100</span></div>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
# Artikeltext aus Word splitten
|
||||||
|
erklarung_html = ""
|
||||||
|
fazit_html = ""
|
||||||
|
if word_text:
|
||||||
|
import re as re2
|
||||||
|
split_marker = re2.search(r'(?i)^Fazit\s*$', word_text, re2.MULTILINE)
|
||||||
|
if split_marker:
|
||||||
|
erkl = word_text[:split_marker.start()].strip()
|
||||||
|
faz = word_text[split_marker.end():].strip()
|
||||||
|
elif "Fazit" in word_text:
|
||||||
|
parts = word_text.split("Fazit", 1)
|
||||||
|
erkl = parts[0].strip()
|
||||||
|
faz = parts[1].strip()
|
||||||
|
else:
|
||||||
|
erkl = word_text
|
||||||
|
faz = ""
|
||||||
|
if erkl and "<" not in erkl[:100]:
|
||||||
|
erkl = f"<p>{erkl.replace(chr(10)+chr(10), '</p><p>')}</p>"
|
||||||
|
if faz and "<" not in faz[:100]:
|
||||||
|
faz = f"<p>{faz.replace(chr(10)+chr(10), '</p><p>')}</p>"
|
||||||
|
erklarung_html = erkl
|
||||||
|
fazit_html = faz
|
||||||
|
|
||||||
disc_html = ""
|
disc_html = ""
|
||||||
if disclosure_text:
|
if disclosure_text:
|
||||||
disc_html = f'<p style="text-align:center;"><em>{disclosure_text}<br>Dies hat keinen Einfluss auf unsere Bewertung!</em></p>'
|
disc_html = f'<p class="bsn-disclosure"><em>{disclosure_text}<br>Dies hat keinen Einfluss auf unsere Bewertung!</em></p>'
|
||||||
|
|
||||||
aff_html = ""
|
aff_html = ""
|
||||||
if fantasywelt_url:
|
if fantasywelt_url:
|
||||||
aff_html += f'<p><a href="{fantasywelt_url}" target="_blank" rel="noopener"><img style="margin:10px auto;display:block;" src="images/banners/fantasywelt/banner_fantasywelt-jetzt-kaufen.jpg" alt="Jetzt bei Fantasywelt kaufen"></a></p>\n'
|
aff_html += f'<p class="bsn-affiliate"><a href="{fantasywelt_url}" target="_blank" rel="noopener"><img src="images/banners/fantasywelt/banner_fantasywelt-jetzt-kaufen.jpg" alt="Bei Fantasywelt kaufen"></a></p>\n'
|
||||||
if so_url:
|
if so_url:
|
||||||
aff_html += f'<p><a href="{so_url}" target="_blank" rel="noopener"><img style="margin:10px auto;display:block;" src="images/banners/fantasywelt/SO_kaufen_button.jpg" alt="Bei Spiele-Offensive kaufen"></a></p>\n'
|
aff_html += f'<p class="bsn-affiliate"><a href="{so_url}" target="_blank" rel="noopener"><img src="images/banners/fantasywelt/SO_kaufen_button.jpg" alt="Bei Spiele-Offensive kaufen"></a></p>\n'
|
||||||
|
if amazon_url:
|
||||||
|
aff_html += f'<p class="bsn-affiliate"><a href="{amazon_url}" target="_blank" rel="noopener"><img src="images/banners/amazon/amazon-kaufen-button.jpg" alt="Bei Amazon kaufen"></a></p>\n'
|
||||||
|
if allgames4you_url:
|
||||||
|
aff_html += f'<p class="bsn-affiliate"><a href="{allgames4you_url}" target="_blank" rel="noopener"><img src="images/banners/allgames4you/allgames4you-kaufen-button.jpg" alt="Bei AllGames4You kaufen"></a></p>\n'
|
||||||
|
|
||||||
gal_path = f"01/{erster}/{ordner}"
|
gal_path = f"01/{erster}/{ordner}"
|
||||||
body = f"""{teaser}
|
tags_str = ", ".join(tags) if tags else f"{spielname}, Brettspiel, Review, Bewertung, {autor_name}"
|
||||||
|
title_full = f"{spielname} {zusatz}".strip()
|
||||||
|
display_autor = autoren or verlag or meta.get("verlag", "").strip() or "unbekannt"
|
||||||
|
|
||||||
|
body = f"""<p class="bsn-dachzeile">{spielname} — {komplex or 'Brettspiel'} von {display_autor}</p>
|
||||||
<hr id="system-readmore">
|
<hr id="system-readmore">
|
||||||
<p> </p>
|
{fakten_html}
|
||||||
<p><img style="margin:10px auto;display:block;" src="{base_img}/wertung/info.png" alt="Fakten zum Spiel"></p>
|
|
||||||
{disc_html}
|
{disc_html}
|
||||||
<h2>So spielt sich {spielname}</h2>
|
{erklarung_html}
|
||||||
{erklarung}
|
|
||||||
<hr class="system-pagebreak" title="Fazit + Wertung + Bilder vom Spiel">
|
|
||||||
<h2><img src="images/banners/redaktion/{autor_banner}" alt="Meine Meinung"></h2>
|
<h2><img src="images/banners/redaktion/{autor_banner}" alt="Meine Meinung"></h2>
|
||||||
{fazit}
|
{fazit_html}
|
||||||
<p> </p>
|
{rating_html}
|
||||||
<p><img style="margin:10px auto;display:block;" src="{base_img}/wertung/wertung.png" alt="Wertung zum Spiel"></p>
|
|
||||||
<p> </p>
|
|
||||||
<p style="text-align:center;">{{module Discord-Hinweis Tests}}</p>
|
|
||||||
<p> </p>
|
<p> </p>
|
||||||
{aff_html}
|
{aff_html}
|
||||||
<p> </p>
|
|
||||||
<h2>Bilder vom Spiel</h2>
|
<h2>Bilder vom Spiel</h2>
|
||||||
<p>{{gallery}}{gal_path}{{/gallery}}</p>
|
<p>{{gallery}}{gal_path}{{/gallery}}</p>"""
|
||||||
<p>{{module Aktuelle Texte fur Content}}</p>"""
|
|
||||||
datei = datetime.now().strftime("%Y-%m-%d")
|
datei = datetime.now().strftime("%Y-%m-%d")
|
||||||
safe = spielname.lower().replace(" ", "-").replace(":", "").replace("/", "-")
|
safe = spielname.lower().replace(" ", "-").replace(":", "").replace("/", "-")
|
||||||
filename = f"artikel_{safe}_{datei}.html"
|
filename = f"artikel_{safe}_{datei}.html"
|
||||||
filepath = os.path.join(WORKSPACE, filename)
|
filepath = os.path.join(WORKSPACE, filename)
|
||||||
desc = teaser.replace("<p>", "").replace("</p>", "")[:150]
|
|
||||||
full = f"""<!DOCTYPE html>
|
full = f"""<!DOCTYPE html>
|
||||||
<html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
<html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
<meta name="description" content="Brettspiel-Review: {spielname} \u2014 {desc}">
|
<meta name="description" content="Brettspiel-Review: {title_full}">
|
||||||
<meta name="keywords" content="{spielname},Brettspiel,Review,Bewertung,{autor_name}">
|
<meta name="keywords" content="{tags_str}">
|
||||||
<meta name="category-id" content="{rubrik_id}">
|
<meta name="category-id" content="{rubrik_id}">
|
||||||
<title>{spielname} \u2014 Review von {autor_name} | Brettspiel-News.de</title></head>
|
<title>{title_full} — Review von {autor_name} | Brettspiel-News.de</title></head>
|
||||||
<body><article><p class="meta">{datum} | von {autor_name}</p>{body}</article></body></html>"""
|
<body><article><p class="meta">{datum} | von {autor_name}</p>{body}</article></body></html>"""
|
||||||
with open(filepath, "w", encoding="utf-8") as f:
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
f.write(full)
|
f.write(full)
|
||||||
@@ -2716,21 +2828,81 @@ def _resize_image(data, output_path):
|
|||||||
img.save(output_path, "JPEG", quality=85, dpi=(72, 72))
|
img.save(output_path, "JPEG", quality=85, dpi=(72, 72))
|
||||||
|
|
||||||
|
|
||||||
def _publish_to_joomla(filepath, rubrik_id):
|
def _publish_to_joomla(filepath, rubrik_id, allow_live=False):
|
||||||
"""Artikel via Joomla-API veroffentlichen."""
|
"""Artikel via Joomla-API veroffentlichen.
|
||||||
|
|
||||||
|
Nutzt das gleiche flache Payload-Format wie _handle_publish()
|
||||||
|
(Joomla 5/6 API akzeptiert NICHT den JSON:API-Wrapper bei POST).
|
||||||
|
"""
|
||||||
import requests
|
import requests
|
||||||
with open(filepath, encoding="utf-8") as f:
|
with open(filepath, encoding="utf-8") as f:
|
||||||
html = f.read()
|
html = f.read()
|
||||||
|
|
||||||
|
# ── Metadaten extrahieren ──
|
||||||
title_match = re.search(r"<title>(.*?)</title>", html)
|
title_match = re.search(r"<title>(.*?)</title>", html)
|
||||||
title = title_match.group(1) if title_match else "Unbekannt"
|
title = title_match.group(1) if title_match else "Unbekannt"
|
||||||
|
|
||||||
|
alias = re.sub(r'[^a-z0-9]+', '-', title.lower().strip())[:100]
|
||||||
|
|
||||||
body_match = re.search(r"<article>(.*?)</article>", html, re.DOTALL)
|
body_match = re.search(r"<article>(.*?)</article>", html, re.DOTALL)
|
||||||
body = body_match.group(1).strip() if body_match else html
|
article_body = body_match.group(1).strip() if body_match else html
|
||||||
payload = {"data": {"type": "articles", "attributes": {
|
|
||||||
"title": title, "alias": title.lower().replace(" ", "-")[:100],
|
# ── Interne Marker entfernen (wie _handle_publish) ──
|
||||||
"articletext": body, "state": 1, "catid": rubrik_id, "language": "de-DE"
|
article_body = re.sub(r'<h1[^>]*>.*?</h1>\s*', '', article_body, flags=re.DOTALL)
|
||||||
}}}
|
article_body = re.sub(r'<p\s+class="meta"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# ── Read-More-Split für introtext/fulltext ──
|
||||||
|
introtext = ""
|
||||||
|
fulltext = article_body.strip()
|
||||||
|
sr_match = re.search(r'<hr\s+id="system-readmore"\s*/?>', article_body, re.IGNORECASE)
|
||||||
|
if sr_match:
|
||||||
|
split_idx = sr_match.start()
|
||||||
|
introtext = article_body[:split_idx].strip()
|
||||||
|
fulltext = article_body[split_idx:].strip()
|
||||||
|
fulltext = re.sub(r'^<hr\s+id="system-readmore"\s*/?>\s*', '', fulltext)
|
||||||
|
|
||||||
|
# ── Meta-Description & Keywords ──
|
||||||
|
meta_desc_match = re.search(r'<meta\s+name="description"\s+content="([^"]+)"', html, re.IGNORECASE)
|
||||||
|
meta_key_match = re.search(r'<meta\s+name="keywords"\s+content="([^"]+)"', html, re.IGNORECASE)
|
||||||
|
|
||||||
|
# ── Kategorie/State/Featured (gleiche Logik wie _handle_publish) ──
|
||||||
|
catid = rubrik_id or 61 # Default: Korrektur
|
||||||
|
state = 0 if catid == 61 else 1
|
||||||
|
access = 6 if catid == 61 else 1
|
||||||
|
featured = 0
|
||||||
|
featured_up = None
|
||||||
|
featured_down = None
|
||||||
|
if allow_live and catid != 61:
|
||||||
|
from datetime import datetime as dt, timedelta
|
||||||
|
now = dt.now()
|
||||||
|
featured = 1
|
||||||
|
featured_up = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
featured_down = (now + timedelta(days=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
# ── Flaches Payload (Joomla 5/6 API-Format) ──
|
||||||
|
article_payload = {
|
||||||
|
"title": title,
|
||||||
|
"alias": alias,
|
||||||
|
"introtext": introtext,
|
||||||
|
"fulltext": fulltext,
|
||||||
|
"catid": catid,
|
||||||
|
"language": "*",
|
||||||
|
"state": state,
|
||||||
|
"access": access,
|
||||||
|
"featured": featured,
|
||||||
|
"created_by": 560,
|
||||||
|
}
|
||||||
|
if meta_desc_match and meta_desc_match.group(1).strip():
|
||||||
|
article_payload["metadesc"] = meta_desc_match.group(1).strip()
|
||||||
|
if meta_key_match and meta_key_match.group(1).strip():
|
||||||
|
article_payload["metakey"] = meta_key_match.group(1).strip()
|
||||||
|
if featured_up:
|
||||||
|
article_payload["featured_up"] = featured_up
|
||||||
|
if featured_down:
|
||||||
|
article_payload["featured_down"] = featured_down
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = requests.post(JOOMLA_API_URL, json=payload, headers={
|
r = requests.post(JOOMLA_API_URL, json=article_payload, headers={
|
||||||
"Accept": "application/vnd.api+json", "Content-Type": "application/json",
|
"Accept": "application/vnd.api+json", "Content-Type": "application/json",
|
||||||
"X-Joomla-Token": JOOMLA_TOKEN
|
"X-Joomla-Token": JOOMLA_TOKEN
|
||||||
}, timeout=30)
|
}, timeout=30)
|
||||||
|
|||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
BSN FAKTEN ZUM SPIEL — 3-Spalten-Grid, mobile-first
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.bsn-fakten {
|
||||||
|
margin: 24px 0;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bsn-fakten h3 {
|
||||||
|
background: #fff;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
}
|
||||||
|
.bsn-fakten-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.bsn-fakten-item {
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-right: 1px solid #f0f0f0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.bsn-fakten-item:nth-child(3n) { border-right: none; }
|
||||||
|
.bsn-fakten-val {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.bsn-fakten-lbl {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.bsn-fakten-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.bsn-fakten-item:nth-child(3n) { border-right: 1px solid #f0f0f0; }
|
||||||
|
.bsn-fakten-item:nth-child(2n) { border-right: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.bsn-fakten-grid { grid-template-columns: 1fr; }
|
||||||
|
.bsn-fakten-item { border-right: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
BSN BEWERTUNGSKARTE — dunkelblauer Header, 3 Spalten, Footer
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.bsn-rating-card {
|
||||||
|
margin: 32px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
.bsn-rc-header {
|
||||||
|
background: #20228a;
|
||||||
|
color: #fff;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.bsn-rc-title {
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.bsn-rc-meta {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.bsn-rc-body {
|
||||||
|
background: #fff;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.bsn-rc-col {
|
||||||
|
padding: 16px 14px;
|
||||||
|
border-right: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.bsn-rc-col:last-child { border-right: none; }
|
||||||
|
.bsn-rc-col h4 {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #20228a;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.bsn-rc-mech,
|
||||||
|
.bsn-rc-pro,
|
||||||
|
.bsn-rc-kontra {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.88em;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px dotted #ddd;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.bsn-rc-empty {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #bbb;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.bsn-rc-footer {
|
||||||
|
background: #20228a;
|
||||||
|
color: #fff;
|
||||||
|
padding: 14px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.bsn-rc-author {
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.bsn-rc-score {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.bsn-rc-body { grid-template-columns: 1fr; }
|
||||||
|
.bsn-rc-col { border-right: none; border-bottom: 1px solid #eee; }
|
||||||
|
.bsn-rc-col:last-child { border-bottom: none; }
|
||||||
|
.bsn-rc-header { flex-direction: column; gap: 4px; }
|
||||||
|
.bsn-rc-footer { flex-direction: column; text-align: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
BSN Allgemeine Artikel-Elemente
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.bsn-dachzeile {
|
||||||
|
color: #20228a;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #20228a;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.bsn-disclosure {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.bsn-affiliate {
|
||||||
|
text-align: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.bsn-affiliate img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user