P2: Ungelesen-Badge in der Hauptnavigation
- Plugin-Vertrag: nav_badges(user_id) -> {URL: Anzahl}
- Kern-Middleware sammelt Badges pro Anfrage in request.state.nav_badges
- benachrichtigung-Plugin meldet die ungelesene Anzahl
- Rotes Zähler-Badge am Nav-Eintrag (Desktop + Mobil), nur wenn > 0
This commit is contained in:
@@ -80,6 +80,19 @@ class BenachrichtigungPlugin(BasePlugin):
|
||||
def navigation(self) -> list[NavEntry]:
|
||||
return [NavEntry(label=self.title, url="/benachrichtigung")]
|
||||
|
||||
def nav_badges(self, user_id: int) -> dict[str, int]:
|
||||
"""Nav-Badge: Anzahl ungelesener Benachrichtigungen."""
|
||||
with self.context.session_factory() as db:
|
||||
ungelesen = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Benachrichtigung)
|
||||
.where(
|
||||
Benachrichtigung.user_id == user_id,
|
||||
Benachrichtigung.gelesen.is_(False),
|
||||
)
|
||||
) or 0
|
||||
return {"/benachrichtigung": ungelesen} if ungelesen else {}
|
||||
|
||||
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
|
||||
"""Dashboard: Anzahl ungelesener In-App-Benachrichtigungen."""
|
||||
with self.context.session_factory() as db:
|
||||
|
||||
@@ -20,6 +20,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
|
||||
from sqlalchemy import func, select
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from redaktionskern.auth import routen as auth_routen
|
||||
@@ -134,6 +135,33 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
for plugin in plugins:
|
||||
app.include_router(plugin.router)
|
||||
|
||||
class NavBadgeMiddleware(BaseHTTPMiddleware):
|
||||
"""Sammelt optionale Zähler-Badges der Plugins für die Navigation.
|
||||
|
||||
Läuft INNERHALB der SessionMiddleware (wurde vor ihr registriert),
|
||||
greift also nur lesend auf die Session zu. Fehler eines Plugins
|
||||
dürfen die Anfrage nie brechen.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
badges: dict[str, int] = {}
|
||||
user_id = request.session.get("user_id")
|
||||
if user_id is not None:
|
||||
for plugin in registry.all():
|
||||
try:
|
||||
for url, anzahl in (plugin.nav_badges(user_id) or {}).items():
|
||||
if anzahl and anzahl > 0:
|
||||
badges[url] = badges.get(url, 0) + anzahl
|
||||
except Exception: # pragma: no cover - defensiv
|
||||
import logging
|
||||
|
||||
logging.getLogger("redaktionskern").exception(
|
||||
"nav_badges von Plugin %r fehlgeschlagen.", plugin.name
|
||||
)
|
||||
request.state.nav_badges = badges
|
||||
return await call_next(request)
|
||||
|
||||
app.add_middleware(NavBadgeMiddleware)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.session_secret,
|
||||
|
||||
@@ -106,6 +106,14 @@ class BasePlugin:
|
||||
"""Einträge für die Hauptnavigation (leer = keiner)."""
|
||||
return []
|
||||
|
||||
def nav_badges(self, user_id: int) -> dict[str, int]:
|
||||
"""Zähler-Badges für Navigations-Einträge: {URL: Anzahl}.
|
||||
|
||||
Der Kern ruft diesen Hook pro Anfrage für den angemeldeten
|
||||
Benutzer auf (leer = kein Badge). Werte <= 0 werden ignoriert.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def dashboard_karten(self, user) -> list[DashboardKarte]:
|
||||
"""Dashboard-Karten des Plugins für den angemeldeten Benutzer.
|
||||
|
||||
|
||||
@@ -35,8 +35,11 @@
|
||||
<span class="hidden md:flex items-center gap-5 flex-1">
|
||||
{% for eintrag in plugin_nav %}
|
||||
{% set aktiv = request.url.path == eintrag.url or request.url.path.startswith(eintrag.url ~ '/') %}
|
||||
{% set badge_n = request.state.nav_badges.get(eintrag.url, 0) %}
|
||||
<a href="{{ eintrag.url }}" {% if aktiv %}aria-current="page"{% endif %}
|
||||
class="text-sm pb-0.5 border-b-2 {% if aktiv %}text-emerald-700 font-semibold border-emerald-600{% else %}text-slate-600 border-transparent hover:text-emerald-700 hover:border-emerald-300{% endif %}">{{ eintrag.label }}</a>
|
||||
class="text-sm pb-0.5 border-b-2 inline-flex items-center {% if aktiv %}text-emerald-700 font-semibold border-emerald-600{% else %}text-slate-600 border-transparent hover:text-emerald-700 hover:border-emerald-300{% endif %}">{{ eintrag.label }}
|
||||
{%- if badge_n %}<span class="ml-1.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-red-600 text-white text-[11px] font-semibold" aria-label="{{ badge_n }} ungelesen">{{ badge_n }}</span>{% endif -%}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
<!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel ≥≥ 44px -->
|
||||
@@ -80,9 +83,11 @@
|
||||
class="md:hidden border-t border-slate-200 bg-white px-4 pb-3 pt-2">
|
||||
{% for eintrag in plugin_nav %}
|
||||
{% set aktiv = request.url.path == eintrag.url or request.url.path.startswith(eintrag.url ~ '/') %}
|
||||
{% set badge_n = request.state.nav_badges.get(eintrag.url, 0) %}
|
||||
<a href="{{ eintrag.url }}" @click="menue = false" {% if aktiv %}aria-current="page"{% endif %}
|
||||
class="block py-3 text-sm border-b border-slate-100 last:border-0 {% if aktiv %}font-semibold text-emerald-700{% else %}font-medium text-slate-700 hover:text-emerald-700{% endif %}">
|
||||
class="flex items-center py-3 text-sm border-b border-slate-100 last:border-0 {% if aktiv %}font-semibold text-emerald-700{% else %}font-medium text-slate-700 hover:text-emerald-700{% endif %}">
|
||||
{{ eintrag.label }}
|
||||
{%- if badge_n %}<span class="ml-2 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-red-600 text-white text-[11px] font-semibold" aria-label="{{ badge_n }} ungelesen">{{ badge_n }}</span>{% endif -%}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if not user %}
|
||||
|
||||
Reference in New Issue
Block a user