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), "ende": offset + len(eintraege),
"url_zurueck": _seiten_url(seite - 1), "url_zurueck": _seiten_url(seite - 1),
"url_weiter": _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", "url_ohne_seite": f"/audit-log?{basis}" if basis else "/audit-log",
"benutzer_auswahl": benutzer_auswahl, "benutzer_auswahl": benutzer_auswahl,
"aktionen_auswahl": aktionen_auswahl, "aktionen_auswahl": aktionen_auswahl,

View File

@@ -117,16 +117,7 @@
0 Einträge 0 Einträge
{% endif %} {% endif %}
</p> </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> </div>
{% from "seitennavigation.html" import seitennavigation %}
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
{% endblock %} {% endblock %}

View File

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

View File

@@ -122,11 +122,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% from "seitennavigation.html" import seitennavigation %}
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
</div> </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 from __future__ import annotations
import logging 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 import Depends, Form, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -299,13 +300,22 @@ class PlanungPlugin(BasePlugin):
gesamt = db.scalar( gesamt = db.scalar(
select(func.count()).select_from(abfrage.subquery()) select(func.count()).select_from(abfrage.subquery())
) or 0 ) or 0
gesamt_seiten = max(1, ceil(gesamt / pro_seite))
seite_aktuell = min(max(seite_nr, 1), gesamt_seiten)
eintraege = db.scalars( eintraege = db.scalars(
abfrage.order_by( abfrage.order_by(
Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc() Planungseintrag.aktualisiert_am.desc(), Planungseintrag.id.desc()
) )
.offset((max(seite_nr, 1) - 1) * pro_seite) .offset((seite_aktuell - 1) * pro_seite)
.limit(pro_seite) .limit(pro_seite)
).all() ).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 = { namen = {
benutzer.id: (benutzer.display_name or benutzer.username) benutzer.id: (benutzer.display_name or benutzer.username)
for benutzer in db.scalars(select(User)).all() for benutzer in db.scalars(select(User)).all()
@@ -326,8 +336,9 @@ class PlanungPlugin(BasePlugin):
"meldung": meldung[:300], "meldung": meldung[:300],
"fehler": fehler[:300], "fehler": fehler[:300],
"anzahl": gesamt, "anzahl": gesamt,
"hat_weitere": max(seite_nr, 1) * pro_seite < gesamt, "seite": seite_aktuell,
"naechste_seite": max(seite_nr, 1) + 1, "gesamt_seiten": gesamt_seiten,
"seiten_url": _seiten_url,
}, },
) )

View File

@@ -217,13 +217,8 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{% if hat_weitere %} {% from "seitennavigation.html" import seitennavigation %}
<div id="planung-lade-mehr" {{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
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 %}
<!-- Lösch-Bestätigungsdialog (Progressive Enhancement: ohne JavaScript <!-- Lösch-Bestätigungsdialog (Progressive Enhancement: ohne JavaScript
sendet das Formular sofort, mit Alpine öffnet sich dieser Dialog). --> sendet das Formular sofort, mit Alpine öffnet sich dieser Dialog). -->
<div x-show="loeschFormular !== null" x-cloak <div x-show="loeschFormular !== null" x-cloak

View File

@@ -0,0 +1,61 @@
{# Klassische Seitennavigation: Zurück/Weiter + Seitenzahlen.
Parameter: seiten_url (Funktion nr → URL), seite, gesamt_seiten.
Funktioniert ohne JavaScript (einfache Links). #}
{% from "icons.html" import icon_chevron_links, icon_chevron_rechts %}
{% macro seitennavigation(seiten_url, seite, gesamt_seiten) %}
{% if gesamt_seiten > 1 %}
<nav class="flex flex-wrap items-center justify-between gap-3 mt-4" aria-label="Seitennavigation">
<div>
{% if seite > 1 %}
<a href="{{ seiten_url(seite - 1) }}" rel="prev"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg border border-slate-300 text-sm font-medium text-slate-700 hover:bg-slate-50">
{{ icon_chevron_links("h-4 w-4") }} Zurück
</a>
{% else %}
<span aria-disabled="true"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg border border-slate-200 text-sm font-medium text-slate-300 cursor-not-allowed">
{{ icon_chevron_links("h-4 w-4") }} Zurück
</span>
{% endif %}
</div>
<div class="flex flex-wrap items-center gap-1">
<span class="hidden sm:inline text-sm text-slate-500 mr-2">Seite {{ seite }} von {{ gesamt_seiten }}</span>
<span class="sm:hidden sr-only">Seite {{ seite }} von {{ gesamt_seiten }}</span>
{% set von = [seite - 2, 1] | max %}
{% set bis = [seite + 2, gesamt_seiten] | min %}
{% if von > 1 %}
<a href="{{ seiten_url(1) }}"
class="px-3 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 hover:bg-slate-50">1</a>
{% if von > 2 %}<span class="px-1 text-slate-400" aria-hidden="true">&hellip;</span>{% endif %}
{% endif %}
{% for nr in range(von, bis + 1) %}
{% if nr == seite %}
<span aria-current="page"
class="px-3 py-2 rounded-lg bg-emerald-600 text-white text-sm font-semibold">{{ nr }}</span>
{% else %}
<a href="{{ seiten_url(nr) }}"
class="px-3 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 hover:bg-slate-50">{{ nr }}</a>
{% endif %}
{% endfor %}
{% if bis < gesamt_seiten %}
{% if bis < gesamt_seiten - 1 %}<span class="px-1 text-slate-400" aria-hidden="true">&hellip;</span>{% endif %}
<a href="{{ seiten_url(gesamt_seiten) }}"
class="px-3 py-2 rounded-lg border border-slate-300 text-sm text-slate-700 hover:bg-slate-50">{{ gesamt_seiten }}</a>
{% endif %}
</div>
<div>
{% if seite < gesamt_seiten %}
<a href="{{ seiten_url(seite + 1) }}" rel="next"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg border border-slate-300 text-sm font-medium text-slate-700 hover:bg-slate-50">
Weiter {{ icon_chevron_rechts("h-4 w-4") }}
</a>
{% else %}
<span aria-disabled="true"
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg border border-slate-200 text-sm font-medium text-slate-300 cursor-not-allowed">
Weiter {{ icon_chevron_rechts("h-4 w-4") }}
</span>
{% endif %}
</div>
</nav>
{% endif %}
{% endmacro %}