Coverbilder (bild_url) + responsives Layout für Mobil/Tablet

Teil 1 — Coverbilder:
- Migration 0004_bild_url (neuheiten), 0002_bild_url (planung, archiv
  mit beiden Spiegel-Tabellen); nullable VARCHAR(500), SQLite↔Postgres
  portabel, bestehende Zeilen bleiben erhalten
- BGG-Client extrahiert Cover aus der Thing-Antwort (bevorzugt <image>,
  sonst <thumbnail>; protokoll-relative URLs → https)
- Sync-Upsert pflegt bild_url beim Update; fehlendes Bild löscht keines
- Web-Quellen übernehmen img-URLs aus den Listenelementen (src/data-src,
  relative Pfade gegen die Seiten-URL aufgelöst); SPIEL-Essen nutzt das
  BILDER-Feld des JSON-Endpunkts
- Verschiebung Neuheiten→Planung und Archivierung/Wiederherstellen
  schreiben bild_url mit
- Anzeige: kleine Vorschaubilder (40x56) in den Listen, groß (max 200px)
  in Planung-Bearbeiten/Prüf-Dialog; Platzhalter-Div ohne Bild;
  Hotlinking mit loading=lazy + decoding=async

Teil 2 — Responsives Layout:
- Basislayout: Burger-Menü (Alpine.js) auf Mobil, Desktop-Leiste ab md:
- Listen kollabieren <768px zu Karten-Ansichten (Cover links, Titel +
  Metadaten rechts, Status-Badge oben); Desktop behält Tabellenform
- Touch-Ziele >= 44px auf Mobil, Formulare vollbreit mit Labels oben
- Sync-Übersicht/Audit-Log bleiben horizontal scrollbare Tabellen

Tests: 15 neue Fälle (tests/test_covers.py) — Migration inkl.
Daten-Erhalt, BGG-Parsing gemockt, Quellen-Übernahme, Template-Render;
316 Tests grün. README aktualisiert.
This commit is contained in:
Flo Hartmann
2026-08-24 16:54:56 +00:00
parent 20b84a8e1a
commit 96d6af8163
33 changed files with 1134 additions and 69 deletions

View File

@@ -24,6 +24,7 @@ from .basis import (
jahr_aus_datumsangabe,
titel_aehnlichkeit,
titel_normalisieren,
bild_aus_element,
)
from .brettspielbox import BrettspielboxQuelle
from .cliquenabend import CliquenabendQuelle
@@ -50,6 +51,7 @@ __all__ = [
"jahr_aus_datumsangabe",
"titel_aehnlichkeit",
"titel_normalisieren",
"bild_aus_element",
"SpielboxQuelle",
"BrettspielboxQuelle",
"SpielEssenQuelle",

View File

@@ -60,6 +60,52 @@ class QuellenTreffer:
verlag: str | None = None
autor: str | None = None
erscheinungsdatum_oder_quartal: str | None = None
#: Bild-URL der Quelle (img im Listenelement), sofern vorhanden; wird
#: direkt eingebettet (Hotlinking), nicht heruntergeladen.
bild_url: str | None = None
def _bild_url_normalisieren(url: str, basis_url: str | None) -> str | None:
"""Macht aus einer rohen Bildangabe eine absolute http(s)-URL.
Protokoll-relative URLs (`//host/bild.jpg`) erhalten https voran,
pfadrelative Angaben werden gegen die Seiten-URL aufgelöst; alles
andere (data:-URIs etc.) wird verworfen.
"""
url = url.strip()
if url.startswith("//"):
url = f"https:{url}"
elif not url.startswith(("http://", "https://")):
if basis_url is None:
return None
from urllib.parse import urljoin
url = urljoin(basis_url, url)
return url if url.startswith(("http://", "https://")) else None
def bild_aus_element(element, basis_url: str | None = None) -> str | None:
"""Liest die erste sinnvolle Bild-URL aus einem HTML-Element.
Akzeptiert `src` und gängige Lazy-Loading-Attribute (`data-src`,
`data-srcset`-Erster); protokoll- und pfadrelative URLs werden gegen
`basis_url` (die Seiten-URL) aufgelöst. Kein Bild → None.
"""
if element is None:
return None
bild = element.find("img") if hasattr(element, "find") else None
if bild is None:
return None
for attribut in ("src", "data-src"):
url = bild.get(attribut)
if url and url.strip():
return _bild_url_normalisieren(url, basis_url)
srcset = bild.get("data-srcset") or bild.get("srcset")
if srcset:
erster = srcset.split(",")[0].strip().split(" ")[0]
if erster:
return _bild_url_normalisieren(erster, basis_url)
return None
@dataclass

View File

@@ -15,7 +15,7 @@ import re
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
TITEL_VERLAG_MUSTER = re.compile(r"^\s*(.+?)\s*\(([^()]*)\)\s*$")
@@ -50,6 +50,7 @@ class BrettspielboxQuelle(QuellenAdapter):
autor=None,
erscheinungsdatum_oder_quartal=None,
quellen_url=quellen_url,
bild_url=bild_aus_element(eintrag, url),
)
)
return treffer, []

View File

@@ -14,7 +14,7 @@ import re
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
DATUM_MUSTER = re.compile(r"\d{2}\.\d{2}\.(\d{2})\b")
BASIS_URL = "https://www.cliquenabend.de"
@@ -72,6 +72,7 @@ class CliquenabendQuelle(QuellenAdapter):
autor=autor,
erscheinungsdatum_oder_quartal=datum,
quellen_url=quellen_url,
bild_url=bild_aus_element(spiel_zelle, quellen_url),
)

View File

@@ -17,7 +17,7 @@ import re
from bs4 import BeautifulSoup
from .basis import QuellenAdapter, QuellenTreffer
from .basis import QuellenAdapter, QuellenTreffer, bild_aus_element
DATUM_MUSTER = re.compile(r"(\d{2}\.\d{2}\.\d{4})")
@@ -57,6 +57,7 @@ class SpielboxQuelle(QuellenAdapter):
autor=None,
erscheinungsdatum_oder_quartal=datum,
quellen_url=quellen_url,
bild_url=bild_aus_element(element, url),
)
)

View File

@@ -121,9 +121,52 @@ class SpielEssenQuelle(QuellenAdapter):
autor=felder.get("Autor"),
erscheinungsdatum_oder_quartal=felder.get("Erscheinungsdatum"),
quellen_url=OEFFENTLICHE_SEITE,
bild_url=_bild_aus_produkt(produkt),
)
def _bild_aus_produkt(produkt: dict) -> str | None:
"""Erste nutzbare Bild-URL aus dem BILDER-Feld (tolerant).
Das Feld ist je nach Messejahr eine Liste von URLs/Pfaden, ein Objekt
oder ein String; nur absolute bzw. protokoll-relative http(s)-URLs
werden übernommen, alles andere bleibt leer.
"""
def _aus_wert(wert) -> str | None:
if isinstance(wert, str):
url = wert.strip()
if url.startswith("//"):
url = f"https:{url}"
if url.startswith(("http://", "https://")):
return url
return None
if isinstance(wert, dict):
for schluessel in ("URL", "BILD", "SRC", "PATH", "PFAD"):
fund = _aus_wert(wert.get(schluessel))
if fund:
return fund
for unterwert in wert.values():
fund = _aus_wert(unterwert)
if fund:
return fund
return None
if isinstance(wert, list):
for element in wert:
fund = _aus_wert(element)
if fund:
return fund
return None
bilder = produkt.get("BILDER")
if isinstance(bilder, list):
for einzel in bilder:
fund = _aus_wert(einzel)
if fund:
return fund
return None
return _aus_wert(bilder)
def _parse_info(info_html: str) -> dict[str, str]:
"""Liest die INFO-Html-Tabelle (Zeilen „Feld: Wert“) tolerant aus."""
felder: dict[str, str] = {}