From 96d6af81633da11d3f5910ee0b8c9583d59157ed Mon Sep 17 00:00:00 2001 From: Flo Hartmann Date: Mon, 24 Aug 2026 16:54:56 +0000 Subject: [PATCH] =?UTF-8?q?Coverbilder=20(bild=5Furl)=20+=20responsives=20?= =?UTF-8?q?Layout=20f=C3=BCr=20Mobil/Tablet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , sonst ; 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. --- README.md | 59 ++- plugins/archiv/__init__.py | 32 +- plugins/archiv/models.py | 2 + plugins/archiv/service.py | 6 + plugins/archiv/templates/archiv/_cover.html | 24 + plugins/archiv/templates/archiv/index.html | 78 ++- .../benachrichtigung/einstellungen.html | 2 +- plugins/neuheiten/__init__.py | 22 + plugins/neuheiten/bgg.py | 26 + plugins/neuheiten/models.py | 3 + plugins/neuheiten/quellen/__init__.py | 2 + plugins/neuheiten/quellen/basis.py | 46 ++ plugins/neuheiten/quellen/brettspielbox.py | 3 +- plugins/neuheiten/quellen/cliquenabend.py | 3 +- plugins/neuheiten/quellen/spielbox.py | 3 +- plugins/neuheiten/quellen/spielessen.py | 43 ++ plugins/neuheiten/quellen_sync.py | 5 + plugins/neuheiten/sync.py | 5 + .../neuheiten/templates/neuheiten/_cover.html | 24 + .../neuheiten/templates/neuheiten/_liste.html | 61 ++- .../neuheiten/templates/neuheiten/index.html | 18 +- plugins/planung/__init__.py | 32 +- plugins/planung/models.py | 2 + plugins/planung/templates/planung/_cover.html | 24 + .../planung/templates/planung/bearbeiten.html | 11 +- plugins/planung/templates/planung/index.html | 117 ++++- .../planung/templates/planung/pruefung.html | 10 +- src/redaktionskern/templates/base.html | 46 +- .../templates/benutzer/formular.html | 6 +- .../templates/benutzer/liste.html | 2 +- src/redaktionskern/templates/login.html | 2 +- tests/test_covers.py | 481 ++++++++++++++++++ tests/test_planung.py | 3 +- 33 files changed, 1134 insertions(+), 69 deletions(-) create mode 100644 plugins/archiv/templates/archiv/_cover.html create mode 100644 plugins/neuheiten/templates/neuheiten/_cover.html create mode 100644 plugins/planung/templates/planung/_cover.html create mode 100644 tests/test_covers.py diff --git a/README.md b/README.md index f951561..0e9128f 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ Fake-BGG-Client, Prüfprotokoll, Prüfseite) und **planung** (beide Eintragswege mit Stub-gemockter dedup-/Benachrichtigungs-/Audit-API, Verlags-Auswahl- Dialog, Statuswechsel, Bearbeiten/Löschen, Rechte pro Rolle) plus ein Integrationstest mit den echten Plugins (In-App-Nachricht + Audit-Eintrag). +Dazu die **Coverbilder-Erweiterung**: Migrationen für `bild_url` in allen +vier Tabellen inkl. Daten-Erhalt beim ALTER TABLE, BGG-Bildextraktion +(`` bevorzugt, `` als Rückfallebene, gemockte Antworten), +Bildübernahme aus Web-Quellen, Update-Pflege des Bildes im Sync-Upsert sowie +Template-Darstellung (`loading="lazy"`/`decoding="async"` bzw. Platzhalter). Dazu das **archiv**-Plugin: 12-Monats-Regel mit eingefrorener Uhr (Grenzfälle „genau 12 Monate“, Schaltjahr/29. Februar, UTC-Konsistenz bei Zeitzonen-Unterschieden), Job-Lauf mit Audit-Einträgen, Wiederherstellen @@ -270,7 +275,8 @@ manuelle Synchronisation für die Redaktion, deutsche sortier-/filterbare Liste. ### Datentabelle -Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten` +Eigene Migrationen `0001_neuheiten_tabelle`, `0002_bgg_id_nullable`, +`0003_quellen_status` und `0004_bild_url`; Tabelle `neuheiten` (SQLite ↔ Postgres portabel): | Spalte | Inhalt | @@ -279,6 +285,7 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten` | `verlag` / `autor` | Verlag bzw. Autor(en), kommagetrennt | | `erscheinungsjahr` | Erscheinungsjahr — BGG liefert nur das Jahr, kein genaues Datum | | `bgg_id` | BoardGameGeek-ID, eindeutig → Merge-Kriterium des Syncs | +| `bild_url` | URL des Coverbildes (nullable; BGG-Image/-Thumbnail oder Bild der Web-Quelle) | | `status` | `neuheit` (spätere Plugins: Planung/Archiv) | | `quelle` | Datenquelle (`boardgamegeek`) | | `erstellt_am` / `aktualisiert_am` | Zeitstempel | @@ -288,6 +295,14 @@ Eigene Migration `0001_neuheiten_tabelle`, Tabelle `neuheiten` Pro Suchbegriff: `/search?type=boardgame&query=…` → IDs sammeln → `/thing?id=…&type=boardgame` in Batches à 20 IDs. +**Coverbilder:** Aus der Thing-Antwort wird die Bild-URL extrahiert +(bevorzugt das große ``, sonst das kleinere ``; +protokoll-relative URLs werden auf https normalisiert) und in +`bild_url` gespeichert. Beim Update bestehender Einträge wird das Bild +mitgepflegt; fehlt es in einer Antwort, bleibt ein bereits gespeichertes +Bild erhalten. Bilder werden direkt von BGG eingebettet (Hotlinking, +kein Download) — im UI immer mit `loading="lazy"` und `decoding="async"`. + - **Rate-Limit:** mindestens 1 Sekunde zwischen zwei Requests. - **Retry mit Backoff:** exponentielles Zurückhalten bei 5xx, 429 und Netzwerkfehlern; HTTP 202 (BGG-Warteschlange) wird gemäß `Retry-After` @@ -344,7 +359,9 @@ Neben BGG crawlt das Plugin zusätzlich redaktionell gepflegte Web-Quellen Adapter in `plugins/neuheiten/quellen/` — mit Rate-Limit (1 Request/s), Retry/Backoff, konditionalen Requests (ETag/304), Fehler-Isolation pro Quelle und Duplikat-Gate gegen die BGG-Liste. Der Crawler arbeitet standardmäßig rein -regelbasiert. +regelbasiert. Liefert eine Quelle im Listenelement ein Bild (`img` mit `src` +bzw. `data-src`), wird dessen URL in `bild_url` übernommen — sonst bleibt das +Feld leer. Optional kann eine **LLM-Hilfsstufe** (`plugins/neuheiten/quellen/ki_hilfe.py`) zugeschaltet werden — konsistent zur Dedup-KI über die @@ -452,12 +469,14 @@ laufen automatisch durch die dedup-Prüfung. ### Datentabelle -Eigene Migration `0001_planungsliste`, Tabelle `planungsliste`: +Eigene Migrationen `0001_planungsliste` und `0002_bild_url`, +Tabelle `planungsliste`: | Spalte | Inhalt | |--------|--------| | `titel` / `verlag` / `autor` | Spieldaten | | `bgg_id` | BoardGameGeek-ID (Match-Kriterium der dedup-Prüfung) | +| `bild_url` | URL des Coverbildes (nullable; wird beim Verschieben aus den Neuheiten übernommen) | | `ausgabe` | Magazin-Ausgabe, z. B. „3/2025“ (mehrere parallel möglich) | | `rezensent_id` | Zuordnung (FK auf die Benutzer-Tabelle des Kerns) | | `status` | `offen` → `in_bearbeitung` → `abgeschlossen` | @@ -501,9 +520,10 @@ unverändert zurück in die aktive Liste verschieben. ### Datentabellen -Eigene Migration `0001_archiv_tabellen` mit zwei Spiegel-Tabellen -(SQLite ↔ Postgres portabel, ohne Fremdschlüssel — archivierte Zeilen -überleben auch das Löschen eines Benutzers): +Eigene Migrationen `0001_archiv_tabellen` und `0002_bild_url` +(Spiegel-Tabellen, SQLite ↔ Postgres portabel, ohne Fremdschlüssel — +archivierte Zeilen überleben auch das Löschen eines Benutzers; +`bild_url` wird mit archiviert und beim Wiederherstellen zurückgeschrieben): | Tabelle | Spiegel von | Besondere Spalten | |---------|-------------|-------------------| @@ -639,6 +659,31 @@ Docker/Podman Compose ist vorgesehen (Henry-Lab, danach Kundenhardware). Für Postgres genügt `SPIELE_DATABASE_URL`; die Migrationen sind portabel geschrieben. +## Oberfläche: Coverbilder & responsives Layout + +**Coverbilder:** Die Listen (Neuheiten, Planung, Archiv) zeigen je Eintrag +eine kleine Cover-Vorschau (40×56 px, `loading="lazy"`, `decoding="async"`, +Alt-Text = Spieltitel); die Detail-/Prüf-Ansichten der Planung zeigen das +Cover großformatig (max. 200 px Breite). Fehlt ein Bild, erscheint ein +dezenter CSS-Platzhalter mit Spielicon-Zeichen. Eingebettet wird per +Hotlink direkt von BGG bzw. der jeweiligen Quelle — es werden keine Bilder +heruntergeladen oder lokal gespeichert. + +**Responsive Verhalten:** Das Basis-Layout ist mobil-first: + +- Navigation: Ab Tablet (`md:`) horizontale Leiste; auf Mobilgeräten ein + Burger-Menü (Alpine.js) mit vollhöhen Touch-Zielen. +- Listen: Auf schmalen Screens (<768 px) kollabieren Neuheiten, Planung und + Archiv zu Karten-Ansichten — Cover links, Titel + Metadaten rechts, + Status-Badge oben. Desktop behält die Tabellenform. +- Formulare (Planung, Benutzerverwaltung, Einstellungen): vollbreite + Eingabefelder mit Labels oberhalb auf Mobil. +- Sync-Übersicht und Audit-Log bleiben Tabellen und scrollen horizontal + (`overflow-x-auto`). +- Buttons/Links haben auf Mobil mindestens 44 px Höhe (Touch-Ziel). + +Getestet gegen 375 px (Smartphone), 768 px (Tablet) und Desktop. + ## Projekt-Fortschritt > Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert. @@ -654,7 +699,7 @@ geschrieben. | 7 | Plugin `archiv` (12-Monats-Autopilot, Wiederherstellen, täglicher Job) | ✅ fertig | | 8 | Plugin `erinnerung` (Redaktionsschluss pro Ausgabe, 4-Wochen-Erinnerung, Tages-Job) | ✅ fertig | | 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig | -| 10 | Integrationstests über alle Plugins | ✅ 217 Tests grün | +| 10 | Integrationstests über alle Plugins | ✅ 316 Tests grün | | 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ✅ live | Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten diff --git a/plugins/archiv/__init__.py b/plugins/archiv/__init__.py index 1b55768..24eb2de 100644 --- a/plugins/archiv/__init__.py +++ b/plugins/archiv/__init__.py @@ -71,6 +71,33 @@ def _tabelle_anlegen(conn) -> None: ArchivPlanung.__table__.create(conn, checkfirst=True) +def _spalte_existiert(conn, tabelle: str, spalte: str) -> bool: + """Dialekt-agnostische Spaltenprüfung (PRAGMA bzw. information_schema).""" + if conn.dialect.name == "sqlite": + info = conn.exec_driver_sql(f"PRAGMA table_info({tabelle})").fetchall() + return any(zeile[1] == spalte for zeile in info) + existiert = conn.exec_driver_sql( + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = :tabelle AND column_name = :spalte", + {"tabelle": tabelle, "spalte": spalte}, + ).scalar() + return existiert is not None + + +def _bild_url_spalten(conn) -> None: + """Migration 0002: Spalte bild_url in beiden Spiegel-Tabellen. + + `ALTER TABLE … ADD COLUMN` mit nullable VARCHAR ohne Default ist + SQLite- und Postgres-portabel und erhält bestehende Zeilen. + """ + for tabelle in ("archiv_neuheiten", "archiv_planung"): + if _spalte_existiert(conn, tabelle, "bild_url"): + continue + conn.exec_driver_sql( + f"ALTER TABLE {tabelle} ADD COLUMN bild_url VARCHAR(500)" + ) + + class ArchivPlugin(BasePlugin): name = "archiv" title = "Archiv" @@ -89,7 +116,10 @@ class ArchivPlugin(BasePlugin): # ---------- Plugin-Vertrag ---------- def migrations(self) -> list[Migration]: - return [Migration(version="0001_archiv_tabellen", up=_tabelle_anlegen)] + return [ + Migration(version="0001_archiv_tabellen", up=_tabelle_anlegen), + Migration(version="0002_bild_url", up=_bild_url_spalten), + ] def navigation(self) -> list[NavEntry]: return [NavEntry(label=self.title, url="/archiv")] diff --git a/plugins/archiv/models.py b/plugins/archiv/models.py index 3186b29..c13015f 100644 --- a/plugins/archiv/models.py +++ b/plugins/archiv/models.py @@ -63,6 +63,7 @@ class ArchivNeuheit(_ArchivMeta, Base): autor: Mapped[str | None] = mapped_column(String(300)) erscheinungsjahr: Mapped[int | None] = mapped_column(Integer) bgg_id: Mapped[int | None] = mapped_column(Integer) + bild_url: Mapped[str | None] = mapped_column(String(500)) status: Mapped[str] = mapped_column(String(50), default="neuheit") quelle: Mapped[str] = mapped_column(String(100), default="boardgamegeek") erstellt_am: Mapped[datetime | None] = mapped_column(DateTime) @@ -83,6 +84,7 @@ class ArchivPlanung(_ArchivMeta, Base): verlag: Mapped[str | None] = mapped_column(String(300)) autor: Mapped[str | None] = mapped_column(String(300)) bgg_id: Mapped[int | None] = mapped_column(Integer) + bild_url: Mapped[str | None] = mapped_column(String(500)) ausgabe: Mapped[str] = mapped_column(String(100), default="") #: Kein FK — Archiv-Zeilen überleben das Löschen des Benutzers. rezensent_id: Mapped[int | None] = mapped_column(Integer) diff --git a/plugins/archiv/service.py b/plugins/archiv/service.py index 2ecbb25..d47053d 100644 --- a/plugins/archiv/service.py +++ b/plugins/archiv/service.py @@ -201,6 +201,7 @@ class ArchivService: autor=zeile["autor"], erscheinungsjahr=zeile["erscheinungsjahr"], bgg_id=zeile["bgg_id"], + bild_url=zeile["bild_url"], status=zeile["status"], quelle=zeile["quelle"], erstellt_am=zeile["erstellt_am"], @@ -250,6 +251,7 @@ class ArchivService: verlag=zeile["verlag"], autor=zeile["autor"], bgg_id=zeile["bgg_id"], + bild_url=zeile["bild_url"], ausgabe=zeile["ausgabe"], rezensent_id=zeile["rezensent_id"], status=zeile["status"], @@ -305,6 +307,7 @@ class ArchivService: "verlag": a.verlag, "autor": a.autor, "bgg_id": a.bgg_id, + "bild_url": a.bild_url, "jahr": a.erscheinungsjahr, "ausgabe": "", "status": a.status, @@ -328,6 +331,7 @@ class ArchivService: "verlag": a.verlag, "autor": a.autor, "bgg_id": a.bgg_id, + "bild_url": a.bild_url, "jahr": None, "ausgabe": a.ausgabe, "status": a.status, @@ -409,6 +413,7 @@ class ArchivService: autor=archiv.autor, erscheinungsjahr=archiv.erscheinungsjahr, bgg_id=archiv.bgg_id, + bild_url=archiv.bild_url, status=archiv.status, quelle=archiv.quelle, erstellt_am=archiv.erstellt_am, @@ -464,6 +469,7 @@ class ArchivService: verlag=archiv.verlag, autor=archiv.autor, bgg_id=archiv.bgg_id, + bild_url=archiv.bild_url, ausgabe=archiv.ausgabe, rezensent_id=rezensent_id, status=archiv.status, diff --git a/plugins/archiv/templates/archiv/_cover.html b/plugins/archiv/templates/archiv/_cover.html new file mode 100644 index 0000000..e985fef --- /dev/null +++ b/plugins/archiv/templates/archiv/_cover.html @@ -0,0 +1,24 @@ +{# Coverbild-Vorschau des Archiv-Plugins. + Erwartete Variablen: cover_bild (URL oder None), cover_titel, + cover_gross (optional: true für großformatige Ansichten). + Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein + Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt + das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #} +{% set _gross = cover_gross | default(false) %} +{% if _gross %} + {% if cover_bild %} + Cover: {{ cover_titel }} + {% else %} + + {% endif %} +{% else %} + {% if cover_bild %} + Cover: {{ cover_titel }} + {% else %} + + {% endif %} +{% endif %} diff --git a/plugins/archiv/templates/archiv/index.html b/plugins/archiv/templates/archiv/index.html index 48215e6..416fafd 100644 --- a/plugins/archiv/templates/archiv/index.html +++ b/plugins/archiv/templates/archiv/index.html @@ -26,16 +26,16 @@ {% endif %}
-
-
+ +
+ class="w-full min-h-[44px] border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
-
+
{% if q or quelle_filter %} @@ -54,10 +54,65 @@

{{ anzahl }} Einträge im Archiv

-
+ +
    + {% for eintrag in eintraege %} +
  • +
    + {% set cover_bild = eintrag.bild_url %} + {% set cover_titel = eintrag.titel %} + {% include "archiv/_cover.html" %} +
    +
    +
    +

    + {{ eintrag.titel }} + {% if eintrag.bgg_id %} + BGG {{ eintrag.bgg_id }} + {% endif %} +

    + {% if eintrag.quelle == 'neuheiten' %} + Neuheiten + {% else %} + Planung + {% endif %} +
    +
    + {% if eintrag.verlag %}
    Verlag:
    {{ eintrag.verlag }}
    {% endif %} + {% if eintrag.jahr or eintrag.ausgabe %}
    Jahr/Ausgabe:
    {{ eintrag.jahr if eintrag.jahr else eintrag.ausgabe }}
    {% endif %} +
    Status:
    {{ status_anzeige.get(eintrag.status, eintrag.status) }}
    +
    +
    Archiviert am:
    +
    {{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}
    +
    +
    Grund:
    {{ eintrag.grund }}
    +
    + + + +
    +
  • + {% else %} +
  • + {% if q or quelle_filter %} + Keine Treffer für diese Suche. + {% else %} + Das Archiv ist leer — nichts zu sehen. + {% endif %} +
  • + {% endfor %} +
+ +