P1: Einheitliche klassische Pagination (Neuheiten, Planung, Audit-Log)

- Infinite Scroll ersetzt durch Zurück/Weiter + Seitenzahlen-Fenster
  (funktioniert ohne JavaScript)
- Gemeinsames Makro seitennavigation.html (aria-current auf aktiver
  Seite, deaktivierte Buttons als span)
- Neuheiten: Pagination bleibt beim HTMX-Filter-Swap innerhalb von
  #neuheiten-liste konsistent; Filter/Sortierung/Jahr bleiben in den
  Seiten-URLs erhalten
- Audit-Log: gleiche Optik statt reiner Text-Links
This commit is contained in:
Flo Hartmann
2026-08-26 08:59:49 +00:00
parent 117b34653c
commit 7645f6a791
7 changed files with 104 additions and 34 deletions

View File

@@ -285,6 +285,7 @@ class AuditLogPlugin(BasePlugin):
"ende": offset + len(eintraege),
"url_zurueck": _seiten_url(seite - 1),
"url_weiter": _seiten_url(seite + 1),
"seiten_url": _seiten_url,
"url_ohne_seite": f"/audit-log?{basis}" if basis else "/audit-log",
"benutzer_auswahl": benutzer_auswahl,
"aktionen_auswahl": aktionen_auswahl,

View File

@@ -117,16 +117,7 @@
0 Einträge
{% endif %}
</p>
{% if gesamt_seiten > 1 %}
<div class="flex items-center gap-2">
{% if seite > 1 %}
<a href="{{ url_zurueck }}" class="border border-slate-300 hover:bg-slate-50 rounded-lg px-3 py-1">&larr; Zurück</a>
{% endif %}
<span>Seite {{ seite }} von {{ gesamt_seiten }}</span>
{% if seite < gesamt_seiten %}
<a href="{{ url_weiter }}" class="border border-slate-300 hover:bg-slate-50 rounded-lg px-3 py-1">Weiter &rarr;</a>
{% endif %}
</div>
{% endif %}
</div>
{% from "seitennavigation.html" import seitennavigation %}
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
{% endblock %}

View File

@@ -27,7 +27,8 @@ import logging
import os
import threading
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from math import ceil
from urllib.parse import quote, urlencode
from fastapi import Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse
@@ -113,6 +114,7 @@ class NeuheitenPlugin(BasePlugin):
abwaerts = richtung == "ab" if sort in SORTIERBAR else False
spalte_sortiert = spalte.desc() if abwaerts else spalte.asc()
pro_seite = 50
seite_aktuell = max(seite_nr, 1)
with self.context.session_factory() as db:
abfrage = select(Neuheit)
@@ -148,9 +150,11 @@ class NeuheitenPlugin(BasePlugin):
) if j is not None
]
gesamt = db.scalar(select(func.count()).select_from(abfrage.subquery())) or 0
gesamt_seiten = max(1, ceil(gesamt / pro_seite))
seite_aktuell = min(seite_aktuell, gesamt_seiten)
eintraege = db.scalars(
abfrage.order_by(spalte_sortiert, Neuheit.id.asc())
.offset((max(seite_nr, 1) - 1) * pro_seite)
.offset((seite_aktuell - 1) * pro_seite)
.limit(pro_seite)
).all()
status_optionen = [
@@ -160,7 +164,18 @@ class NeuheitenPlugin(BasePlugin):
]
quellen_zeilen = self._quellen_uebersicht(db)
hat_weitere = max(seite_nr, 1) * pro_seite < gesamt
def _seiten_url(nr: int) -> str:
parameter = {
"q": q,
"status": status,
"sort": sort if sort in SORTIERBAR else STANDARD_SORTIERUNG[0],
"richtung": "ab" if abwaerts else "auf",
"seite_nr": str(nr),
}
if jahr is not None:
parameter["jahr"] = str(jahr)
return f"/neuheiten?{urlencode(parameter)}"
kontext = {
"user": user,
"titel": self.title,
@@ -182,8 +197,9 @@ class NeuheitenPlugin(BasePlugin):
"suchbegriffe": ", ".join(self._suchbegriffe) or "",
"sync_aktiv": self._scheduler is not None and self._scheduler.running,
"anzahl": gesamt,
"hat_weitere": hat_weitere,
"naechste_seite": max(seite_nr, 1) + 1,
"seite": seite_aktuell,
"gesamt_seiten": gesamt_seiten,
"seiten_url": _seiten_url,
"quellen_zeilen": quellen_zeilen,
"quellen_sync_aktiv": (
os.environ.get("SPIELE_NEUHEITEN_QUELLEN_SYNC_AKTIV", "1").strip()

View File

@@ -122,11 +122,6 @@
</tbody>
</table>
</div>
{% from "seitennavigation.html" import seitennavigation %}
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
</div>
{% if hat_weitere %}
<div id="lade-mehr"
hx-get="/neuheiten?seite_nr={{ naechste_seite }}&q={{ q }}&status={{ status_filter }}&sort={{ sort }}&richtung={{ richtung }}"
hx-trigger="revealed delay:150ms" hx-swap="outerHTML">
<div class="py-6 text-center text-sm text-slate-400">Lade weitere Eintr&auml;ge&nbsp;&hellip;</div>
</div>
{% endif %}

View File

@@ -24,7 +24,8 @@ der Eintrage-Vorgang bricht.
from __future__ import annotations
import logging
from urllib.parse import quote
from math import ceil
from urllib.parse import quote, urlencode
from fastapi import Depends, Form, Request
from fastapi.responses import RedirectResponse
@@ -299,13 +300,22 @@ class PlanungPlugin(BasePlugin):
gesamt = db.scalar(
select(func.count()).select_from(abfrage.subquery())
) or 0
gesamt_seiten = max(1, ceil(gesamt / pro_seite))
seite_aktuell = min(max(seite_nr, 1), gesamt_seiten)
eintraege = db.scalars(
abfrage.order_by(
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
)
.offset((max(seite_nr, 1) - 1) * pro_seite)
.offset((seite_aktuell - 1) * pro_seite)
.limit(pro_seite)
).all()
def _seiten_url(nr: int) -> str:
parameter = {"seite_nr": str(nr)}
if status in GUELTIGE_STATUS:
parameter["status"] = status
return f"/planung?{urlencode(parameter)}"
namen = {
benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all()
@@ -326,8 +336,9 @@ class PlanungPlugin(BasePlugin):
"meldung": meldung[:300],
"fehler": fehler[:300],
"anzahl": gesamt,
"hat_weitere": max(seite_nr, 1) * pro_seite < gesamt,
"naechste_seite": max(seite_nr, 1) + 1,
"seite": seite_aktuell,
"gesamt_seiten": gesamt_seiten,
"seiten_url": _seiten_url,
},
)

View File

@@ -217,13 +217,8 @@
</tbody>
</table>
</div>
{% if hat_weitere %}
<div id="planung-lade-mehr"
hx-get="/planung?seite_nr={{ naechste_seite }}&status={{ status_filter }}"
hx-trigger="revealed delay:150ms" hx-swap="outerHTML">
<div class="py-6 text-center text-sm text-slate-400">Lade weitere Eintr&auml;ge&nbsp;&hellip;</div>
</div>
{% endif %}
{% from "seitennavigation.html" import seitennavigation %}
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
<!-- Lösch-Bestätigungsdialog (Progressive Enhancement: ohne JavaScript
sendet das Formular sofort, mit Alpine öffnet sich dieser Dialog). -->
<div x-show="loeschFormular !== null" x-cloak