Compare commits

13 Commits

Author SHA1 Message Date
Flo Hartmann
49f3b2d2fb Docs: README um Dashboard-Karten, Nav-Badges und UI-Makros ergänzt 2026-08-26 12:34:16 +00:00
Flo Hartmann
968333bf45 P2: Typografie-Hierarchie — Seiten-/Karten-/Tabellen-Header
- h1: text-2xl font-bold text-slate-900
- Karten-h2: text-base font-semibold text-slate-800
- th: text-xs font-medium uppercase tracking-wider text-slate-500
2026-08-26 09:12:22 +00:00
Flo Hartmann
f90c0de46d 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
2026-08-26 09:08:24 +00:00
Flo Hartmann
d6ed8fa56e P2: Einheitliche Fokus-Ringe global
- .focus-ring Utility via Tailwind-Play-CDN (@apply) in base.html
- Globaler :focus-visible-Fallback (emerald Outline) für alle
  Bedienelemente ohne explizite Fokus-Klassen
- Benutzer-Formular und Audit-Log-Filter nutzen .focus-ring
2026-08-26 09:04:45 +00:00
Flo Hartmann
fee5f8b9a3 P1: Tabellen — Sticky-Header, größere mobile Cover, einklappbare Audit-Details
- Desktop-Tabellen (Neuheiten, Planung, Archiv, Audit-Log): Scrollbox
  max-h-70vh mit sticky thead
- Mobile Cover jetzt min. 48x68 px (bereits im Icon-Commit umgesetzt)
- Audit-Log: Alt/Neu/Details-JSON per nativem <details> einklappbar,
  monospace 11px
2026-08-26 09:03:21 +00:00
Flo Hartmann
7645f6a791 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
2026-08-26 08:59:49 +00:00
Flo Hartmann
117b34653c P1: Aktiver Navigationszustand (aria-current + visuell)
Desktop: emerald Unterstrich + font-semibold auf dem aktiven Eintrag;
mobil: fett + emerald. request.url.path steuert den Zustand, kein JS
nötig. Mobiles Menü schließt weiterhin nach jedem Link-Klick.
2026-08-26 08:51:44 +00:00
Flo Hartmann
bba39525a0 P1: Login-Seite gruppiert + Passwort-Anzeigen-Toggle
- Karte und BGG-Logo in einer Spalte gruppiert (vorher Flex-Zeile —
  Logo stand neben der Karte)
- Alpine-Toggle 'Passwort anzeigen/verbergen' (statisch type=password,
  funktioniert ohne JS sicher)
- Fokus-Ringe auf Feldern und Button
2026-08-26 08:50:44 +00:00
Flo Hartmann
9bd3b700c4 P1: Rollenbasiertes Dashboard mit Plugin-Karten
- Plugin-Vertrag erweitert: optionaler Hook dashboard_karten(user) +
  DashboardKarte-Dataclass (Kern bleibt schlank, Fachlogik im Plugin)
- Karten: Neuheiten+System-Status (neuheiten), Planungs-Stats bzw.
  eigene Einträge (planung), offene Dedup-Konflikte (dedup), nächste
  Redaktionsschlüsse (erinnerung), ungelesene Benachrichtigungen
  (benachrichtigung), letzte Audit-Einträge (audit-log, nur Admin),
  Benutzer-Anzahl (Kern, nur Admin)
- 'Geladene Plugins' als aufklappbarer Admin-Technikbereich
2026-08-26 08:48:30 +00:00
Flo Hartmann
742c265df8 P0: Formular-Accessibility — Fehlerboxen mit role=alert, Pflichtfeld-ARIA
- Alle fehler-Boxen: role=alert + tabindex=-1
- Login: aria-invalid/aria-describedby auf Feldern bei Fehler
- Pflichtfelder: aria-required=true ergänzt
2026-08-26 08:40:45 +00:00
Flo Hartmann
b9444289b5 P0: Status-Badge-Farben semantisch vereinheitlicht
Zentrales status_badge-Makro (status.html): neuheit/neu+abgeschlossen =
emerald, offen = slate, in_bearbeitung/planung = sky, warnung/konflikt/
pruefung = amber mit Warn-Icon, fehler = red, deaktiviert/archiviert =
slate. Angewendet in neuheiten, planung, archiv, dedup, Benutzerliste.
2026-08-26 08:38:56 +00:00
Flo Hartmann
14a4846948 P0: Lösch-Aktionen mit Bestätigungsdialog (Planung + Erinnerungen)
Alpine-Modal 'Eintrag löschen?' mit Abbrechen/Löschen; ohne JavaScript
sendet das Formular wie bisher direkt (Progressive Enhancement).
benutzer/liste.html hat keine Lösch-Aktion — nichts zu tun.
2026-08-26 08:35:18 +00:00
Flo Hartmann
e1ef7ba926 P0: x-cloak aktiv + Emojis durch Inline-SVG-Icons ersetzt
- base.html: [x-cloak]-CSS gegen Alpine-Flackern
- Neues zentrales Icon-Makro-Template (icons.html, Heroicons-Stil,
  currentColor, aria-hidden)
- Logo-Würfelbecher, Sync-Pfeile, Sortier-Chevrons, Warn-/Check-Icons,
  dedup-Karten (Gebäude/Tag/Buch/Dokument), Cover-Platzhalter als SVG
- Cover-Platzhalter: 'Kein Cover'-Text in Großansicht, Mindestgröße
  48x68 px in der Kartenansicht
2026-08-26 08:33:37 +00:00
35 changed files with 981 additions and 273 deletions

View File

@@ -161,7 +161,7 @@ Alternativ: installiertes Paket mit Entry-Point in der Gruppe
```python ```python
from fastapi import Depends, Request from fastapi import Depends, Request
from redaktionskern.auth.deps import require_user from redaktionskern.auth.deps import require_user
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
class MeinPlugin(BasePlugin): class MeinPlugin(BasePlugin):
name = "meinplugin" # eindeutig, = Ordnername name = "meinplugin" # eindeutig, = Ordnername
@@ -174,6 +174,12 @@ class MeinPlugin(BasePlugin):
def navigation(self): # Einträge in der Hauptnavigation (optional) def navigation(self): # Einträge in der Hauptnavigation (optional)
return [NavEntry(label=self.title, url="/meinplugin")] return [NavEntry(label=self.title, url="/meinplugin")]
def dashboard_karten(self, user): # Dashboard-Karten (optional, leer = keine)
return [DashboardKarte(titel="…", wert="…", link_url="/meinplugin")]
def nav_badges(self, user_id): # Zähler-Badges in der Navigation
return {"/meinplugin": 3} # (optional; Werte <= 0 werden ignoriert)
def on_load(self, context): # Lifecycle-Hook beim Start def on_load(self, context): # Lifecycle-Hook beim Start
super().on_load(context) # context.engine/.session_factory/.templates/ super().on_load(context) # context.engine/.session_factory/.templates/
# .settings/.registry (Plugin-Registry für # .settings/.registry (Plugin-Registry für
@@ -187,7 +193,10 @@ plugin = MeinPlugin()
Der Kern stellt jedem Plugin über `context.templates` eine gemeinsame Jinja- Der Kern stellt jedem Plugin über `context.templates` eine gemeinsame Jinja-
Umgebung bereit (Plugin-Templates erben `base.html`), dazu Engine und Umgebung bereit (Plugin-Templates erben `base.html`), dazu Engine und
Session-Fabrik. Migrationen laufen transaktional und werden pro Plugin in Session-Fabrik. Zusätzlich stehen allen Templates die Kern-Makros zur
Verfügung: `icons.html` (Inline-SVG-Icons), `status.html` (`status_badge` —
einheitliche Status-Farben) und `seitennavigation.html` (klassische
Seitennavigation). Migrationen laufen transaktional und werden pro Plugin in
`schema_migrations` protokolliert; die `up`-Funktionen bekommen eine `schema_migrations` protokolliert; die `up`-Funktionen bekommen eine
SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen. SQLAlchemy-Connection und können bei Dialekt-Unterschieden verzweigen.
@@ -664,10 +673,11 @@ geschrieben.
## Oberfläche: Coverbilder & responsives Layout ## Oberfläche: Coverbilder & responsives Layout
**Coverbilder:** Die Listen (Neuheiten, Planung, Archiv) zeigen je Eintrag **Coverbilder:** Die Listen (Neuheiten, Planung, Archiv) zeigen je Eintrag
eine kleine Cover-Vorschau (40×56 px, `loading="lazy"`, `decoding="async"`, eine kleine Cover-Vorschau (48×68 px, `loading="lazy"`, `decoding="async"`,
Alt-Text = Spieltitel); die Detail-/Prüf-Ansichten der Planung zeigen das Alt-Text = Spieltitel); die Detail-/Prüf-Ansichten der Planung zeigen das
Cover großformatig (max. 200 px Breite). Fehlt ein Bild, erscheint ein Cover großformatig (max. 200 px Breite). Fehlt ein Bild, erscheint ein
dezenter CSS-Platzhalter mit Spielicon-Zeichen. Eingebettet wird per dezenter Platzhalter mit SVG-Würfel (in der Großansicht zusätzlich mit dem
Hinweis „Kein Cover"). Eingebettet wird per
Hotlink direkt von BGG bzw. der jeweiligen Quelle — es werden keine Bilder Hotlink direkt von BGG bzw. der jeweiligen Quelle — es werden keine Bilder
heruntergeladen oder lokal gespeichert. heruntergeladen oder lokal gespeichert.
@@ -686,6 +696,34 @@ heruntergeladen oder lokal gespeichert.
Getestet gegen 375 px (Smartphone), 768 px (Tablet) und Desktop. Getestet gegen 375 px (Smartphone), 768 px (Tablet) und Desktop.
**Bedienung & Konsistenz (UI/UX-Runde):**
- **Dashboard:** rollenbasierte Karten statt Technik-Liste — Rezensenten
sehen ihre offenen/in-Bearbeitung-Einträge, Redaktionsschlüsse und
ungelesene Benachrichtigungen; Redaktion zusätzlich Neuheiten-Stand,
Planungs-Stats und offene Dedup-Konflikte; Admins zusätzlich
Benutzer-Anzahl, letzte Audit-Einträge und System-Status
(BGG-Token/Sync). Karten kommen per `dashboard_karten`-Hook aus den
Plugins; die Plugin-Liste steckt in einem aufklappbaren Admin-Bereich.
- **Icons:** Alle Emojis sind durch Inline-SVGs ersetzt
(`icons.html`-Makros, `currentColor`, `aria-hidden`).
- **Status-Badges:** einheitliches Farbsystem über `status_badge`
(neu/abgeschlossen = emerald, offen = slate, in Bearbeitung = sky,
Warnung/Konflikt/Prüfung = amber mit Warn-Icon, Fehler = red,
deaktiviert/archiviert = slate).
- **Löschen mit Bestätigung:** Alpine-Dialog „Eintrag löschen?" in Planung
und Erinnerungen (ohne JavaScript sendet das Formular direkt).
- **Pagination:** klassische Seitennavigation (Zurück/Weiter +
Seitenzahlen) für Neuheiten, Planung und Audit-Log — funktioniert ohne
JavaScript.
- **Tabellen:** Sticky-Header in den langen Desktop-Listen; Audit-Log
JSON-Details per `<details>` einklappbar.
- **Accessibility:** `[x-cloak]` gegen Alpine-Flackern, aktiver Nav-Eintrag
mit `aria-current`, Ungelesen-Badge am Nav-Eintrag „Benachrichtigungen",
einheitliche Fokus-Ringe (`.focus-ring` + `:focus-visible`-Fallback),
Fehlerboxen mit `role="alert"`, Pflichtfelder mit `aria-required`.
- **Login:** gruppierte Karte mit „Passwort anzeigen"-Toggle (Alpine).
## Projekt-Fortschritt ## Projekt-Fortschritt
> Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert. > Diese Tabelle ist der Live-Status. Sie wird bei jedem Push aktualisiert.
@@ -703,6 +741,7 @@ Getestet gegen 375 px (Smartphone), 768 px (Tablet) und Desktop.
| 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig | | 9 | Plugin `export` (CSV + PDF via WeasyPrint) | ✅ fertig |
| 10 | Integrationstests über alle Plugins | ✅ 316 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 | | 11 | Deployment auf swen.henry.insight-it.de (Compose + Traefik, Live-Check) | ✅ live |
| 12 | UI/UX-Verbesserungen (Dashboard-Karten, SVG-Icons, Status-Farben, Pagination, A11y) | ✅ 323 Tests grün |
Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten Legende: ✅ fertig · 🔄 in Arbeit · ⏳ offen · ⚠️ fertig mit offenen Punkten

View File

@@ -1,24 +1,31 @@
{# Coverbild-Vorschau des Archiv-Plugins. {# Coverbild-Vorschau.
Erwartete Variablen: cover_bild (URL oder None), cover_titel, Erwartete Variablen: cover_bild (URL oder None), cover_titel,
cover_gross (optional: true für großformatige Ansichten). cover_gross (optional: true für großformatige Ansichten).
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #} das Partial einen dezenten Platzhalter mit SVG-Würfel (in der großen
Ansicht zusätzlich mit dem Hinweis „Kein Cover"). #}
{% from "icons.html" import icon_wuerfel %}
{% set _gross = cover_gross | default(false) %} {% set _gross = cover_gross | default(false) %}
{% if _gross %} {% if _gross %}
{% if cover_bild %} {% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" <img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm"> class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
{% else %} {% else %}
<div aria-hidden="true" title="Kein Cover vorhanden" <div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-[210px] w-[150px] max-w-[200px] items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-6xl text-slate-300 select-none">&#127922;</div> class="flex h-[210px] w-[150px] max-w-[200px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-slate-300 select-none">
{{ icon_wuerfel("h-12 w-12") }}
<span class="text-xs font-medium text-slate-400">Kein Cover</span>
</div>
{% endif %} {% endif %}
{% else %} {% else %}
{% if cover_bild %} {% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="40" height="56" <img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="48" height="68"
class="block h-14 w-10 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover"> class="block h-[68px] w-12 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
{% else %} {% else %}
<div aria-hidden="true" title="Kein Cover vorhanden" <div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-14 w-10 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-lg text-slate-400 select-none">&#127922;</div> class="flex h-[68px] w-12 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-slate-400 select-none">
{{ icon_wuerfel("h-5 w-5") }}
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 max-w-3xl mb-4"> <p class="text-slate-600 max-w-3xl mb-4">
Sicherungsdatei der Redaktion: Titel, deren Erscheinungs- bzw. Eintragsdatum Sicherungsdatei der Redaktion: Titel, deren Erscheinungs- bzw. Eintragsdatum
mehr als 12 Monate zur&uuml;ckliegt, werden t&auml;glich automatisch aus den mehr als 12 Monate zur&uuml;ckliegt, werden t&auml;glich automatisch aus den
@@ -22,7 +22,7 @@
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div> <div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %} {% endif %}
{% if fehler %} {% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
@@ -81,7 +81,7 @@
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500"> <dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %} {% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
{% if eintrag.jahr or eintrag.ausgabe %}<div><dt class="inline font-medium text-slate-400">Jahr/Ausgabe: </dt><dd class="inline">{{ eintrag.jahr if eintrag.jahr else eintrag.ausgabe }}</dd></div>{% endif %} {% if eintrag.jahr or eintrag.ausgabe %}<div><dt class="inline font-medium text-slate-400">Jahr/Ausgabe: </dt><dd class="inline">{{ eintrag.jahr if eintrag.jahr else eintrag.ausgabe }}</dd></div>{% endif %}
<div><dt class="inline font-medium text-slate-400">Status: </dt><dd class="inline">{{ status_anzeige.get(eintrag.status, eintrag.status) }}</dd></div> <div><dt class="inline font-medium text-slate-400">Status: </dt><dd class="inline">{% from "status.html" import status_badge %}{{ status_badge(eintrag.status, status_anzeige.get(eintrag.status, eintrag.status)) }}</dd></div>
<div> <div>
<dt class="inline font-medium text-slate-400">Archiviert am: </dt> <dt class="inline font-medium text-slate-400">Archiviert am: </dt>
<dd class="inline">{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}</dd> <dd class="inline">{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}</dd>
@@ -92,7 +92,7 @@
<button type="submit" <button type="submit"
class="w-full min-h-[44px] inline-flex items-center justify-center gap-1 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap" class="w-full min-h-[44px] inline-flex items-center justify-center gap-1 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap"
title="Eintrag unverändert zurück in die aktive Liste verschieben"> title="Eintrag unverändert zurück in die aktive Liste verschieben">
&#8635; Wiederherstellen {% from "icons.html" import icon_sync %}{{ icon_sync("h-4 w-4") }} Wiederherstellen
</button> </button>
</form> </form>
</div> </div>
@@ -108,20 +108,20 @@
{% endfor %} {% endfor %}
</ul> </ul>
<div class="hidden md:block overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm"> <div class="hidden md:block overflow-auto max-h-[70vh] bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="text-left text-slate-500 sticky top-0 z-10 [&>tr>th]:bg-slate-50 [&>tr>th]:shadow-[inset_0_-1px_0_0_#e2e8f0]">
<tr> <tr>
<th class="px-4 py-3 font-medium">Cover</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Cover</th>
<th class="px-4 py-3 font-medium">Spieltitel</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Spieltitel</th>
<th class="px-4 py-3 font-medium">Herkunft</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Herkunft</th>
<th class="px-4 py-3 font-medium">Verlag</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Verlag</th>
<th class="px-4 py-3 font-medium">Jahr / Ausgabe</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Jahr / Ausgabe</th>
<th class="px-4 py-3 font-medium">Status</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Status</th>
<th class="px-4 py-3 font-medium">Rezensent</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Rezensent</th>
<th class="px-4 py-3 font-medium">Archiviert am</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Archiviert am</th>
<th class="px-4 py-3 font-medium">Grund</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Grund</th>
<th class="px-4 py-3 font-medium">Aktionen</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -148,7 +148,7 @@
</td> </td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td> <td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.jahr if eintrag.jahr else (eintrag.ausgabe or '—') }}</td> <td class="px-4 py-3 whitespace-nowrap">{{ eintrag.jahr if eintrag.jahr else (eintrag.ausgabe or '—') }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ status_anzeige.get(eintrag.status, eintrag.status) }}</td> <td class="px-4 py-3 whitespace-nowrap">{% from "status.html" import status_badge %}{{ status_badge(eintrag.status, status_anzeige.get(eintrag.status, eintrag.status)) }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') if eintrag.rezensent_id else '—' }}</td> <td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') if eintrag.rezensent_id else '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500"> <td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }} {{ eintrag.archiviert_am.strftime('%d.%m.%Y %H:%M') if eintrag.archiviert_am else '—' }}
@@ -157,9 +157,9 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
<form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen"> <form method="post" action="/archiv/{{ eintrag.quelle }}/{{ eintrag.id }}/wiederherstellen">
<button type="submit" <button type="submit"
class="text-xs bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-2.5 py-2 rounded-lg whitespace-nowrap" class="inline-flex items-center gap-1 text-xs bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-2.5 py-2 rounded-lg whitespace-nowrap"
title="Eintrag unverändert zurück in die aktive Liste verschieben"> title="Eintrag unverändert zurück in die aktive Liste verschieben">
&#8635; Wiederherstellen {% from "icons.html" import icon_sync %}{{ icon_sync("h-4 w-4") }} Wiederherstellen
</button> </button>
</form> </form>
</td> </td>

View File

@@ -28,7 +28,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles, require_user from redaktionskern.auth.deps import get_db, require_roles, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from .models import AuditEintrag from .models import AuditEintrag
@@ -88,6 +88,29 @@ class AuditLogPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url=f"/{self.name}")] return [NavEntry(label=self.title, url=f"/{self.name}")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: die letzten fünf Audit-Einträge (nur Admins)."""
if user.role != Role.ADMIN.value:
return []
with self.context.session_factory() as db:
letzte = db.scalars(
select(AuditEintrag).order_by(AuditEintrag.id.desc()).limit(5)
).all()
zeilen = tuple(
f"{e.erstellt_am.strftime('%d.%m. %H:%M') if e.erstellt_am else ''}"
f" · {e.actor_name or 'System'}: {e.action}"
for e in letzte
)
return [
DashboardKarte(
titel="Letzte Audit-Log-Einträge",
zeilen=zeilen,
beschreibung="" if zeilen else "Noch keine Ereignisse protokolliert.",
link_url="/audit-log",
link_label="Zum Audit-Log",
)
]
def on_load(self, context) -> None: def on_load(self, context) -> None:
super().on_load(context) super().on_load(context)
# Jinja-Filter für die JSON-Anzeige in der Admin-Ansicht. # Jinja-Filter für die JSON-Anzeige in der Admin-Ansicht.
@@ -262,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

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %} {% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-1">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-1">{{ titel }}</h1>
<p class="text-slate-600 mb-4 text-sm"> <p class="text-slate-600 mb-4 text-sm">
Protokoll aller relevanten Ereignisse — nur für Administratoren sichtbar. Protokoll aller relevanten Ereignisse — nur für Administratoren sichtbar.
</p> </p>
@@ -19,7 +19,7 @@
<label class="block text-sm"> <label class="block text-sm">
<span class="text-slate-500 block mb-1">Benutzer</span> <span class="text-slate-500 block mb-1">Benutzer</span>
<select name="benutzer" <select name="benutzer"
class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white"> class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white focus-ring">
<option value="">Alle</option> <option value="">Alle</option>
{% for name in benutzer_auswahl %} {% for name in benutzer_auswahl %}
<option value="{{ name }}" {% if name == filter_benutzer %}selected{% endif %}>{{ name }}</option> <option value="{{ name }}" {% if name == filter_benutzer %}selected{% endif %}>{{ name }}</option>
@@ -29,7 +29,7 @@
<label class="block text-sm"> <label class="block text-sm">
<span class="text-slate-500 block mb-1">Aktionstyp</span> <span class="text-slate-500 block mb-1">Aktionstyp</span>
<select name="aktion" <select name="aktion"
class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white"> class="w-full border border-slate-300 rounded-lg px-2 py-2 bg-white focus-ring">
<option value="">Alle</option> <option value="">Alle</option>
{% for a in aktionen_auswahl %} {% for a in aktionen_auswahl %}
<option value="{{ a }}" {% if a == filter_aktion %}selected{% endif %}> <option value="{{ a }}" {% if a == filter_aktion %}selected{% endif %}>
@@ -41,12 +41,12 @@
<label class="block text-sm"> <label class="block text-sm">
<span class="text-slate-500 block mb-1">Von</span> <span class="text-slate-500 block mb-1">Von</span>
<input type="date" name="von" value="{{ filter_von }}" <input type="date" name="von" value="{{ filter_von }}"
class="w-full border border-slate-300 rounded-lg px-2 py-2"> class="w-full border border-slate-300 rounded-lg px-2 py-2 focus-ring">
</label> </label>
<label class="block text-sm"> <label class="block text-sm">
<span class="text-slate-500 block mb-1">Bis</span> <span class="text-slate-500 block mb-1">Bis</span>
<input type="date" name="bis" value="{{ filter_bis }}" <input type="date" name="bis" value="{{ filter_bis }}"
class="w-full border border-slate-300 rounded-lg px-2 py-2"> class="w-full border border-slate-300 rounded-lg px-2 py-2 focus-ring">
</label> </label>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" <button type="submit"
@@ -61,16 +61,16 @@
</div> </div>
</form> </form>
<div class="bg-white rounded-xl border border-slate-200 overflow-x-auto"> <div class="bg-white rounded-xl border border-slate-200 overflow-auto max-h-[70vh]">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="text-left text-slate-500 sticky top-0 z-10 [&>tr>th]:bg-slate-50 [&>tr>th]:shadow-[inset_0_-1px_0_0_#e2e8f0]">
<tr> <tr>
<th class="px-4 py-2 font-medium whitespace-nowrap">Zeitpunkt</th> <th class="px-4 py-2 font-medium whitespace-nowrap text-xs uppercase tracking-wider">Zeitpunkt</th>
<th class="px-4 py-2 font-medium">Benutzer</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Benutzer</th>
<th class="px-4 py-2 font-medium">Aktion</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Aktion</th>
<th class="px-4 py-2 font-medium">Objekt</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Objekt</th>
<th class="px-4 py-2 font-medium">Änderung / Details</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Änderung / Details</th>
<th class="px-4 py-2 font-medium">IP</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">IP</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -86,12 +86,18 @@
</td> </td>
<td class="px-4 py-2 max-w-md"> <td class="px-4 py-2 max-w-md">
{% if e.alt or e.neu %} {% if e.alt or e.neu %}
{% if e.alt %}<div class="mb-1"><span class="text-slate-400 text-xs">Alt:</span> <details>
<pre class="text-xs bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.alt | json_schoen }}</pre></div>{% endif %} <summary class="cursor-pointer text-xs text-emerald-700 hover:text-emerald-900 select-none">Änderungen anzeigen</summary>
{% if e.neu %}<div><span class="text-slate-400 text-xs">Neu:</span> {% if e.alt %}<div class="mt-1 mb-1"><span class="text-slate-400 text-xs">Alt:</span>
<pre class="text-xs bg-emerald-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.neu | json_schoen }}</pre></div>{% endif %} <pre class="font-mono text-[11px] bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.alt | json_schoen }}</pre></div>{% endif %}
{% if e.neu %}<div class="mt-1"><span class="text-slate-400 text-xs">Neu:</span>
<pre class="font-mono text-[11px] bg-emerald-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.neu | json_schoen }}</pre></div>{% endif %}
</details>
{% elif e.details %} {% elif e.details %}
<pre class="text-xs bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.details | json_schoen }}</pre> <details>
<summary class="cursor-pointer text-xs text-emerald-700 hover:text-emerald-900 select-none">Details anzeigen</summary>
<pre class="mt-1 font-mono text-[11px] bg-slate-50 rounded p-1 overflow-x-auto whitespace-pre-wrap">{{ e.details | json_schoen }}</pre>
</details>
{% else %} {% else %}
<span class="text-slate-400">&ndash;</span> <span class="text-slate-400">&ndash;</span>
{% endif %} {% endif %}
@@ -117,16 +123,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

@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user from redaktionskern.auth.deps import get_db, require_user
from redaktionskern.auth.models import User from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from .adapter import BenachrichtigungsAdapter, adapter_aus_konfiguration from .adapter import BenachrichtigungsAdapter, adapter_aus_konfiguration
from .konfiguration import aus_umgebung from .konfiguration import aus_umgebung
@@ -80,6 +80,41 @@ class BenachrichtigungPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/benachrichtigung")] 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:
ungelesen = db.scalar(
select(func.count())
.select_from(Benachrichtigung)
.where(
Benachrichtigung.user_id == user.id,
Benachrichtigung.gelesen.is_(False),
)
) or 0
return [
DashboardKarte(
titel="Ungelesene Benachrichtigungen",
wert=str(ungelesen),
beschreibung="Nachrichten in deinem Posteingang",
link_url="/benachrichtigung",
link_label="Zum Posteingang",
ton="warnung" if ungelesen else "ok",
)
]
# ---------------- Öffentliche Plugin-API ---------------- # ---------------- Öffentliche Plugin-API ----------------
async def send_notification( async def send_notification(

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %} {% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-1">Benachrichtigungen &mdash; Einstellungen</h1> <h1 class="text-2xl font-bold text-slate-900 mb-1">Benachrichtigungen &mdash; Einstellungen</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
W&auml;hlen Sie, &uuml;ber welche Kan&auml;le Sie Benachrichtigungen erhalten. W&auml;hlen Sie, &uuml;ber welche Kan&auml;le Sie Benachrichtigungen erhalten.
Mehrere Kan&auml;le sind gleichzeitig m&ouml;glich. Mehrere Kan&auml;le sind gleichzeitig m&ouml;glich.
@@ -15,7 +15,7 @@
</div> </div>
{% endif %} {% endif %}
{% if fehler %} {% if fehler %}
<div class="mt-4 mb-2 rounded-lg bg-red-50 border border-red-200 text-red-800 px-4 py-2 text-sm"> <div class="mt-4 mb-2 rounded-lg bg-red-50 border border-red-200 text-red-800 px-4 py-2 text-sm" role="alert" tabindex="-1">
{{ fehler }} {{ fehler }}
</div> </div>
{% endif %} {% endif %}

View File

@@ -2,7 +2,7 @@
{% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %} {% block titel %}{{ titel }} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<div class="flex flex-wrap items-center gap-3 mb-1"> <div class="flex flex-wrap items-center gap-3 mb-1">
<h1 class="text-2xl font-bold">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900">{{ titel }}</h1>
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium <span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium
{% if ungelesen %}bg-red-100 text-red-700{% else %}bg-slate-200 text-slate-500{% endif %}"> {% if ungelesen %}bg-red-100 text-red-700{% else %}bg-slate-200 text-slate-500{% endif %}">
{{ ungelesen }} ungelesen {{ ungelesen }} ungelesen

View File

@@ -33,12 +33,12 @@ import logging
import os import os
from fastapi import Depends, Form, Request from fastapi import Depends, Form, Request
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user from redaktionskern.auth.deps import get_db, require_user
from redaktionskern.auth.models import User from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from .bgg import BggPruefClient from .bgg import BggPruefClient
from .ki_pruefung import KiPruefClient, lade_konfiguration from .ki_pruefung import KiPruefClient, lade_konfiguration
@@ -87,6 +87,36 @@ class DedupPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/dedup")] return [NavEntry(label=self.title, url="/dedup")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: offene Dedup-Konflikte (nur Redaktion)."""
from redaktionskern.auth.models import Role
from redaktionskern.db import Base
if user.role not in (Role.ADMIN.value, Role.REDAKTEUR.value):
return []
tabelle = Base.metadata.tables.get("planungsliste")
if tabelle is None:
return []
with self.context.session_factory() as db:
offene = db.scalar(
select(func.count())
.select_from(tabelle)
.where(
tabelle.c.pruefung.is_not(None),
tabelle.c.status != "abgeschlossen",
)
) or 0
return [
DashboardKarte(
titel="Offene Dedup-Konflikte",
wert=str(offene),
beschreibung="Planungseinträge mit Prüfhinweis",
link_url="/planung",
link_label="Zur Planung",
ton="warnung" if offene else "ok",
)
]
def on_load(self, context) -> None: def on_load(self, context) -> None:
super().on_load(context) super().on_load(context)
self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0" self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0"

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Die Prüfung läuft automatisch bei jeder Eintragung in die Planungsliste. Die Prüfung läuft automatisch bei jeder Eintragung in die Planungsliste.
Hier kann sie auch von Hand für einen Titel ausgeführt werden. Hier kann sie auch von Hand für einen Titel ausgeführt werden.
@@ -9,29 +9,29 @@
<div class="grid md:grid-cols-4 gap-3 mb-6 text-sm"> <div class="grid md:grid-cols-4 gap-3 mb-6 text-sm">
<div class="bg-white border border-slate-200 rounded-xl p-3"> <div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#127962; Verlags-Konflikt</p> <p class="font-medium text-slate-700 mb-1 flex items-center gap-1.5">{% from "icons.html" import icon_gebaeude %}{{ icon_gebaeude("h-4 w-4") }} Verlags-Konflikt</p>
<p class="text-slate-500">Gleiches Spiel bei verschiedenen Verlagen/Vertrieb &rarr; Verlagsauswahl.</p> <p class="text-slate-500">Gleiches Spiel bei verschiedenen Verlagen/Vertrieb &rarr; Verlagsauswahl.</p>
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl p-3"> <div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#127463; Titel-Varianten</p> <p class="font-medium text-slate-700 mb-1 flex items-center gap-1.5">{% from "icons.html" import icon_tag %}{{ icon_tag("h-4 w-4") }} Titel-Varianten</p>
<p class="text-slate-500">Deutsche Version wird bevorzugt (Heuristik + BGG Alternate-Names).</p> <p class="text-slate-500">Deutsche Version wird bevorzugt (Heuristik + BGG Alternate-Names).</p>
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl p-3"> <div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#128214; Bereits besprochen</p> <p class="font-medium text-slate-700 mb-1 flex items-center gap-1.5">{% from "icons.html" import icon_buch %}{{ icon_buch("h-4 w-4") }} Bereits besprochen</p>
<p class="text-slate-500">Spiel oder Vorg&auml;nger (BGG-Erweiterungs-Relation + Fuzzy-Match) schon in der Redaktion.</p> <p class="text-slate-500">Spiel oder Vorg&auml;nger (BGG-Erweiterungs-Relation + Fuzzy-Match) schon in der Redaktion.</p>
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl p-3"> <div class="bg-white border border-slate-200 rounded-xl p-3">
<p class="font-medium text-slate-700 mb-1">&#128203; Schon in Planung</p> <p class="font-medium text-slate-700 mb-1 flex items-center gap-1.5">{% from "icons.html" import icon_dokument %}{{ icon_dokument("h-4 w-4") }} Schon in Planung</p>
<p class="text-slate-500">Titel steht bereits in der Planungsliste eines anderen Rezensenten.</p> <p class="text-slate-500">Titel steht bereits in der Planungsliste eines anderen Rezensenten.</p>
</div> </div>
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Titel pr&uuml;fen</h2> <h2 class="text-base font-semibold text-slate-800 mb-3">Titel pr&uuml;fen</h2>
<form method="post" action="/dedup/pruefen" class="flex flex-wrap items-end gap-3"> <form method="post" action="/dedup/pruefen" class="flex flex-wrap items-end gap-3">
<div> <div>
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label> <label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required value="{{ ergebnis.titel if ergebnis else '' }}" <input type="text" id="titel" name="titel" required aria-required="true" value="{{ ergebnis.titel if ergebnis else '' }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-72 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-72 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div>
@@ -51,7 +51,7 @@
</form> </form>
{% if fehler %} {% if fehler %}
<div class="mt-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div> <div class="mt-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
{% if meldung %} {% if meldung %}
<div class="mt-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div> <div class="mt-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
@@ -60,8 +60,8 @@
{% if ergebnis %} {% if ergebnis %}
<div class="mt-4 rounded-lg border p-4 text-sm <div class="mt-4 rounded-lg border p-4 text-sm
{% if ergebnis.hat_konflikte %}bg-amber-50 border-amber-300{% else %}bg-emerald-50 border-emerald-200{% endif %}"> {% if ergebnis.hat_konflikte %}bg-amber-50 border-amber-300{% else %}bg-emerald-50 border-emerald-200{% endif %}">
<p class="font-medium mb-2 {% if ergebnis.hat_konflikte %}text-amber-800{% else %}text-emerald-800{% endif %}"> <p class="font-medium mb-2 flex items-center gap-1.5 {% if ergebnis.hat_konflikte %}text-amber-800{% else %}text-emerald-800{% endif %}">
{% if ergebnis.hat_konflikte %}&#9888; {{ ergebnis.konflikte|length }} Treffer f&uuml;r „{{ ergebnis.titel }}“{% else %}&#10003; Keine Treffer f&uuml;r „{{ ergebnis.titel }}“{% endif %} {% if ergebnis.hat_konflikte %}{% from "icons.html" import icon_warnung %}{{ icon_warnung("h-4 w-4") }} {{ ergebnis.konflikte|length }} Treffer f&uuml;r „{{ ergebnis.titel }}“{% else %}{% from "icons.html" import icon_check %}{{ icon_check("h-4 w-4") }} Keine Treffer f&uuml;r „{{ ergebnis.titel }}“{% endif %}
</p> </p>
<ul class="list-disc list-inside space-y-1 text-slate-700"> <ul class="list-disc list-inside space-y-1 text-slate-700">
{% for konflikt in ergebnis.konflikte %} {% for konflikt in ergebnis.konflikte %}
@@ -83,16 +83,16 @@
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-x-auto"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-x-auto">
<h2 class="font-semibold px-4 pt-4 pb-2">Letzte Pr&uuml;fungen</h2> <h2 class="text-base font-semibold text-slate-800 px-4 pt-4 pb-2">Letzte Pr&uuml;fungen</h2>
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-2 font-medium">Zeitpunkt</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Zeitpunkt</th>
<th class="px-4 py-2 font-medium">Titel</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Titel</th>
<th class="px-4 py-2 font-medium">Verlag</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Verlag</th>
<th class="px-4 py-2 font-medium">BGG-ID</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">BGG-ID</th>
<th class="px-4 py-2 font-medium">Gepr&uuml;ft von</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Gepr&uuml;ft von</th>
<th class="px-4 py-2 font-medium">Befund</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Befund</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -109,7 +109,7 @@
<span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs mr-1">{{ konflikt_anzeige.get(konflikt.art, konflikt.art) }}</span> <span class="inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs mr-1">{{ konflikt_anzeige.get(konflikt.art, konflikt.art) }}</span>
{% endfor %} {% endfor %}
{% else %} {% else %}
<span class="text-emerald-700">Keine Treffer</span> <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{% from "icons.html" import icon_check %}{{ icon_check("h-3.5 w-3.5") }} Keine Treffer</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@@ -36,7 +36,13 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_roles, require_user from redaktionskern.auth.deps import get_db, require_roles, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext from redaktionskern.contracts import (
BasePlugin,
DashboardKarte,
Migration,
NavEntry,
PluginContext,
)
# Bewusst `from .dienst import …` (wie `from .models import …` überall sonst): # Bewusst `from .dienst import …` (wie `from .models import …` überall sonst):
# `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader # `from . import dienst` funktioniert unter dem dateibasierten Plugin-Loader
@@ -116,6 +122,39 @@ class ErinnerungPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/erinnerung")] return [NavEntry(label=self.title, url="/erinnerung")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: die nächsten Redaktionsschlüsse (für alle Rollen)."""
from .dienst import heute
with self.context.session_factory() as db:
ausgaben = db.scalars(
select(Ausgabe)
.where(Ausgabe.redaktionsschluss >= heute())
.order_by(Ausgabe.redaktionsschluss)
.limit(3)
).all()
if not ausgaben:
return [
DashboardKarte(
titel="Nächste Redaktionsschlüsse",
beschreibung="Aktuell ist kein Redaktionsschluss geplant.",
link_url="/erinnerung",
)
]
zeilen = tuple(
f"{a.redaktionsschluss.strftime('%d.%m.%Y')}{a.name}"
for a in ausgaben
)
return [
DashboardKarte(
titel="Nächste Redaktionsschlüsse",
wert=str(len(ausgaben)),
zeilen=zeilen,
link_url="/erinnerung",
link_label="Alle Ausgaben",
)
]
def on_load(self, context: PluginContext) -> None: def on_load(self, context: PluginContext) -> None:
super().on_load(context) super().on_load(context)
import os import os

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">Ausgabe bearbeiten</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">Ausgabe bearbeiten</h1>
<p class="text-slate-600 mb-4">&bdquo;{{ ausgabe.name }}&ldquo; &middot; Redaktionsschluss: <p class="text-slate-600 mb-4">&bdquo;{{ ausgabe.name }}&ldquo; &middot; Redaktionsschluss:
{{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }} {{ ausgabe.redaktionsschluss.strftime('%d.%m.%Y') }}
</p> </p>
@@ -10,12 +10,12 @@
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4"> class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4">
<div> <div>
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label> <label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
<input type="text" id="name" name="name" required value="{{ ausgabe.name }}" <input type="text" id="name" name="name" required aria-required="true" value="{{ ausgabe.name }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div>
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label> <label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required value="{{ ausgabe.redaktionsschluss.isoformat() }}" <input type="date" id="redaktionsschluss" name="redaktionsschluss" required aria-required="true" value="{{ ausgabe.redaktionsschluss.isoformat() }}"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View File

@@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <div x-data="{ loeschFormular: null, loeschTitel: '' }">
<h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Redaktionsschl&uuml;sse der kommenden Ausgaben. Vier Wochen vor jedem Redaktionsschl&uuml;sse der kommenden Ausgaben. Vier Wochen vor jedem
Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene Redaktionsschluss erhalten alle Rezensenten eine Erinnerung, die noch offene
@@ -12,21 +13,21 @@
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div> <div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %} {% endif %}
{% if fehler %} {% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
{% if ist_admin %} {% if ist_admin %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Neue Ausgabe anlegen</h2> <h2 class="text-base font-semibold text-slate-800 mb-3">Neue Ausgabe anlegen</h2>
<form method="post" action="/erinnerung/ausgaben" class="flex flex-wrap items-end gap-3"> <form method="post" action="/erinnerung/ausgaben" class="flex flex-wrap items-end gap-3">
<div> <div>
<label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label> <label for="name" class="block text-xs font-medium text-slate-500 mb-1">Name/Titel der Ausgabe *</label>
<input type="text" id="name" name="name" required placeholder="z. B. 3/2025" <input type="text" id="name" name="name" required aria-required="true" placeholder="z. B. 3/2025"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div> <div>
<label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label> <label for="redaktionsschluss" class="block text-xs font-medium text-slate-500 mb-1">Redaktionsschluss *</label>
<input type="date" id="redaktionsschluss" name="redaktionsschluss" required <input type="date" id="redaktionsschluss" name="redaktionsschluss" required aria-required="true"
class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="border border-slate-300 rounded-lg px-3 py-2 text-sm w-48 focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<button type="submit" <button type="submit"
@@ -42,11 +43,11 @@
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-3 font-medium">Ausgabe</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Ausgabe</th>
<th class="px-4 py-3 font-medium">Redaktionsschluss</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Redaktionsschluss</th>
<th class="px-4 py-3 font-medium">Restzeit</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Restzeit</th>
<th class="px-4 py-3 font-medium">Erinnerungen</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Erinnerungen</th>
{% if ist_admin %}<th class="px-4 py-3 font-medium">Aktionen</th>{% endif %} {% if ist_admin %}<th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktionen</th>{% endif %}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -75,7 +76,9 @@
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<a href="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/bearbeiten" <a href="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/bearbeiten"
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2 py-1 rounded-lg whitespace-nowrap">Bearbeiten</a> class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2 py-1 rounded-lg whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/loeschen"> <form method="post" action="/erinnerung/ausgaben/{{ zeile.ausgabe.id }}/loeschen"
data-titel="{{ zeile.ausgabe.name }}"
@submit.prevent="loeschTitel = $event.target.dataset.titel; loeschFormular = $event.target">
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">L&ouml;schen</button> <button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2 py-1 rounded-lg whitespace-nowrap">L&ouml;schen</button>
</form> </form>
</div> </div>
@@ -97,7 +100,7 @@
{% if ist_admin %} {% if ist_admin %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6 flex flex-wrap items-center gap-3"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6 flex flex-wrap items-center gap-3">
<div class="mr-auto"> <div class="mr-auto">
<h2 class="font-semibold">Sofort pr&uuml;fen</h2> <h2 class="text-base font-semibold text-slate-800">Sofort pr&uuml;fen</h2>
<p class="text-xs text-slate-500">F&uuml;hrt denselben Check aus wie der t&auml;gliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p> <p class="text-xs text-slate-500">F&uuml;hrt denselben Check aus wie der t&auml;gliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p>
</div> </div>
<form method="post" action="/erinnerung/pruefen"> <form method="post" action="/erinnerung/pruefen">
@@ -109,16 +112,16 @@
</div> </div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Erinnerungsstatus</h2> <h2 class="text-base font-semibold text-slate-800 mb-3">Erinnerungsstatus</h2>
<p class="text-xs text-slate-500 mb-3">Wer wurde f&uuml;r welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird h&ouml;chstens einmal erinnert.</p> <p class="text-xs text-slate-500 mb-3">Wer wurde f&uuml;r welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird h&ouml;chstens einmal erinnert.</p>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-3 font-medium">Ausgabe</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Ausgabe</th>
<th class="px-4 py-3 font-medium">Rezensent</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Rezensent</th>
<th class="px-4 py-3 font-medium">Kan&auml;le</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Kan&auml;le</th>
<th class="px-4 py-3 font-medium">Erinnert am</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Erinnert am</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -141,4 +144,25 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- 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
class="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog" aria-modal="true" aria-labelledby="loesch-dialog-titel">
<div class="absolute inset-0 bg-slate-900/50" @click="loeschFormular = null"></div>
<div class="relative bg-white rounded-xl shadow-xl max-w-sm w-full p-5">
<h2 id="loesch-dialog-titel" class="text-base font-semibold text-slate-800 mb-1">Eintrag löschen?</h2>
<p class="text-sm text-slate-600 mb-4">
&bdquo;<span x-text="loeschTitel"></span>&ldquo; wird endgültig gelöscht.
Das lässt sich nicht rückgängig machen.
</p>
<div class="flex justify-end gap-2">
<button type="button" @click="loeschFormular = null"
class="px-4 py-2 rounded-lg border border-slate-300 text-sm hover:bg-slate-50">Abbrechen</button>
<button type="button" @click="if (loeschFormular) { loeschFormular.submit(); }"
class="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium">Löschen</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4 max-w-3xl"> <p class="text-slate-600 mb-4 max-w-3xl">
Redaktionslisten als Datei herunterladen: Redaktionslisten als Datei herunterladen:
<strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM &mdash; direkt in Excel <strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM &mdash; direkt in Excel
@@ -11,7 +11,7 @@
</p> </p>
{% if fehler %} {% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
<div class="grid gap-4 md:grid-cols-3 mb-6"> <div class="grid gap-4 md:grid-cols-3 mb-6">

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
@@ -35,7 +36,13 @@ from sqlalchemy import func, or_, select
from redaktionskern.auth.deps import require_roles, require_user from redaktionskern.auth.deps import require_roles, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry, PluginContext from redaktionskern.contracts import (
BasePlugin,
DashboardKarte,
Migration,
NavEntry,
PluginContext,
)
from .bgg import BggClient, BggFehler from .bgg import BggClient, BggFehler
from .models import Neuheit, QuellenStatus from .models import Neuheit, QuellenStatus
@@ -107,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)
@@ -142,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 = [
@@ -154,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,
@@ -176,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()
@@ -394,6 +416,43 @@ class NeuheitenPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/neuheiten")] return [NavEntry(label=self.title, url="/neuheiten")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: Neuheiten-Übersicht (Redaktion) + System-Status (Admin)."""
if user.role not in (Role.ADMIN.value, Role.REDAKTEUR.value):
return []
with self.context.session_factory() as db:
neu = db.scalar(
select(func.count())
.select_from(Neuheit)
.where(Neuheit.status == "neuheit")
) or 0
karten = [
DashboardKarte(
titel="Neuheiten",
wert=str(neu),
beschreibung="Offene Einträge in der Neuheitenliste",
link_url="/neuheiten",
link_label="Zu den Neuheiten",
ton="ok" if neu else "neutral",
)
]
if user.role == Role.ADMIN.value:
token = self._aktives_bgg_token()
sync_aktiv = self._scheduler is not None and self._scheduler.running
karten.append(
DashboardKarte(
titel="System-Status",
zeilen=(
f"BGG-API-Token: {'konfiguriert' if token else 'fehlt'}",
f"Hintergrund-Sync: {'aktiv' if sync_aktiv else 'aus'}",
),
ton="ok" if token else "fehler",
link_url="/neuheiten",
link_label="Neuheiten synchronisieren",
)
)
return karten
def on_load(self, context: PluginContext) -> None: def on_load(self, context: PluginContext) -> None:
super().on_load(context) super().on_load(context)
import os import os

View File

@@ -1,24 +1,31 @@
{# Coverbild-Vorschau des Neuheiten-Plugins. {# Coverbild-Vorschau.
Erwartete Variablen: cover_bild (URL oder None), cover_titel, Erwartete Variablen: cover_bild (URL oder None), cover_titel,
cover_gross (optional: true für großformatige Ansichten). cover_gross (optional: true für großformatige Ansichten).
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #} das Partial einen dezenten Platzhalter mit SVG-Würfel (in der großen
Ansicht zusätzlich mit dem Hinweis „Kein Cover"). #}
{% from "icons.html" import icon_wuerfel %}
{% set _gross = cover_gross | default(false) %} {% set _gross = cover_gross | default(false) %}
{% if _gross %} {% if _gross %}
{% if cover_bild %} {% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" <img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm"> class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
{% else %} {% else %}
<div aria-hidden="true" title="Kein Cover vorhanden" <div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-[210px] w-[150px] max-w-[200px] items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-6xl text-slate-300 select-none">&#127922;</div> class="flex h-[210px] w-[150px] max-w-[200px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-slate-300 select-none">
{{ icon_wuerfel("h-12 w-12") }}
<span class="text-xs font-medium text-slate-400">Kein Cover</span>
</div>
{% endif %} {% endif %}
{% else %} {% else %}
{% if cover_bild %} {% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="40" height="56" <img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="48" height="68"
class="block h-14 w-10 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover"> class="block h-[68px] w-12 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
{% else %} {% else %}
<div aria-hidden="true" title="Kein Cover vorhanden" <div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-14 w-10 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-lg text-slate-400 select-none">&#127922;</div> class="flex h-[68px] w-12 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-slate-400 select-none">
{{ icon_wuerfel("h-5 w-5") }}
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@@ -23,7 +23,7 @@
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a> class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %} {% endif %}
</h3> </h3>
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ eintrag.status }}</span> {% from "status.html" import status_badge %}{{ status_badge(eintrag.status, none, "ml-auto shrink-0") }}
</div> </div>
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500"> <dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %} {% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
@@ -37,7 +37,7 @@
{% if eintrag.status == 'neuheit' %} {% if eintrag.status == 'neuheit' %}
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}" class="mt-2"> <form method="post" action="/planung/uebernehmen/{{ eintrag.id }}" class="mt-2">
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)" <button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
class="w-full min-h-[44px] inline-flex items-center justify-center gap-1 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">&#8594; Zur Planung</button> class="w-full min-h-[44px] inline-flex items-center justify-center gap-1.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white px-3 py-2 rounded-lg text-sm whitespace-nowrap">{% from "icons.html" import icon_pfeil_rechts %}{{ icon_pfeil_rechts("h-4 w-4") }} Zur Planung</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -51,23 +51,27 @@
</ul> </ul>
<!-- Desktop/Tablet (&ge;768px): Tabellenform --> <!-- Desktop/Tablet (&ge;768px): Tabellenform -->
<div class="hidden md:block overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm"> <div class="hidden md:block overflow-auto max-h-[70vh] bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="text-left text-slate-500 sticky top-0 z-10 [&>tr>th]:bg-slate-50 [&>tr>th]:shadow-[inset_0_-1px_0_0_#e2e8f0]">
<tr> <tr>
<th class="px-4 py-3 font-medium">Cover</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Cover</th>
{% set basis = '/neuheiten?q=' ~ (q | urlencode) ~ '&status=' ~ (status_filter | urlencode) %} {% set basis = '/neuheiten?q=' ~ (q | urlencode) ~ '&status=' ~ (status_filter | urlencode) %}
{% for key, label in [('titel', 'Spieltitel'), ('verlag', 'Verlag'), ('autor', 'Autor'), ('jahr', 'Erscheinungsjahr'), ('status', 'Status'), ('aktualisiert', 'Aktualisiert')] %} {% for key, label in [('titel', 'Spieltitel'), ('verlag', 'Verlag'), ('autor', 'Autor'), ('jahr', 'Erscheinungsjahr'), ('status', 'Status'), ('aktualisiert', 'Aktualisiert')] %}
<th class="px-4 py-3 font-medium whitespace-nowrap"> <th class="px-4 py-3 font-medium whitespace-nowrap text-xs uppercase tracking-wider">
{% if sort == key %} {% if sort == key %}
<a href="{{ basis }}&sort={{ key }}&richtung={{ 'auf' if richtung == 'ab' else 'ab' }}" <a href="{{ basis }}&sort={{ key }}&richtung={{ 'auf' if richtung == 'ab' else 'ab' }}"
class="hover:text-emerald-700">{{ label }} {{ '&#9650;' if richtung == 'auf' else '&#9660;' }}</a> class="inline-flex items-center gap-1 hover:text-emerald-700">{{ label }}
{% from "icons.html" import icon_chevron_oben, icon_chevron_unten %}
{% if richtung == 'auf' %}{{ icon_chevron_oben("h-3 w-3") }}{% else %}{{ icon_chevron_unten("h-3 w-3") }}{% endif %}
<span class="sr-only">(sortiert {{ 'aufsteigend' if richtung == 'auf' else 'absteigend' }})</span>
</a>
{% else %} {% else %}
<a href="{{ basis }}&sort={{ key }}&richtung=auf" class="hover:text-emerald-700 text-slate-400">{{ label }}</a> <a href="{{ basis }}&sort={{ key }}&richtung=auf" class="hover:text-emerald-700 text-slate-400">{{ label }}</a>
{% endif %} {% endif %}
</th> </th>
{% endfor %} {% endfor %}
<th class="px-4 py-3 font-medium">Aktion</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -93,7 +97,7 @@
<td class="px-4 py-3">{{ eintrag.autor or '—' }}</td> <td class="px-4 py-3">{{ eintrag.autor or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.erscheinungsjahr if eintrag.erscheinungsjahr else '—' }}</td> <td class="px-4 py-3 whitespace-nowrap">{{ eintrag.erscheinungsjahr if eintrag.erscheinungsjahr else '—' }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ eintrag.status }}</span> {% from "status.html" import status_badge %}{{ status_badge(eintrag.status) }}
</td> </td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500"> <td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }} {{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}
@@ -102,7 +106,7 @@
{% if eintrag.status == 'neuheit' %} {% if eintrag.status == 'neuheit' %}
<form method="post" action="/planung/uebernehmen/{{ eintrag.id }}"> <form method="post" action="/planung/uebernehmen/{{ eintrag.id }}">
<button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)" <button type="submit" title="In die Planungsliste verschieben (läuft durch die dedup-Prüfung)"
class="text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">&#8594; Zur Planung</button> class="inline-flex items-center gap-1 text-xs bg-emerald-600 hover:bg-emerald-700 text-white px-2.5 py-2 rounded-lg whitespace-nowrap">{% from "icons.html" import icon_pfeil_rechts %}{{ icon_pfeil_rechts("h-3.5 w-3.5") }} Zur Planung</button>
</form> </form>
{% endif %} {% endif %}
</td> </td>
@@ -118,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

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Neuheitenliste aus der <a href="https://boardgamegeek.com" class="text-emerald-700 underline">BoardGameGeek</a>-Datenbank. Neuheitenliste aus der <a href="https://boardgamegeek.com" class="text-emerald-700 underline">BoardGameGeek</a>-Datenbank.
Erweiterungen und Prototypen werden beim Sync automatisch gefiltert. Erweiterungen und Prototypen werden beim Sync automatisch gefiltert.
@@ -15,7 +15,7 @@
</div> </div>
{% endif %} {% endif %}
{% if fehler %} {% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
<div class="flex flex-wrap items-end gap-3 mb-4"> <div class="flex flex-wrap items-end gap-3 mb-4">
@@ -29,8 +29,8 @@
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"> 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">
</div> </div>
<button type="submit" <button type="submit"
class="min-h-[44px] inline-flex items-center justify-center bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap"> class="min-h-[44px] inline-flex items-center justify-center gap-1.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium px-4 py-2 rounded-lg text-sm whitespace-nowrap">
&#8635; Jetzt synchronisieren {% from "icons.html" import icon_sync %}{{ icon_sync("h-4 w-4") }} Jetzt synchronisieren
</button> </button>
</form> </form>
{% endif %} {% endif %}
@@ -47,8 +47,8 @@
{% if ist_redaktion %} {% if ist_redaktion %}
<form method="post" action="/neuheiten/quellen-sync"> <form method="post" action="/neuheiten/quellen-sync">
<button type="submit" <button type="submit"
class="min-h-[44px] md:min-h-0 bg-sky-600 hover:bg-sky-700 active:bg-sky-800 text-white font-medium px-3 py-1.5 rounded-lg text-xs whitespace-nowrap"> class="min-h-[44px] md:min-h-0 inline-flex items-center gap-1.5 bg-sky-600 hover:bg-sky-700 active:bg-sky-800 text-white font-medium px-3 py-1.5 rounded-lg text-xs whitespace-nowrap">
&#8635; Web-Quellen synchronisieren {% from "icons.html" import icon_sync %}{{ icon_sync("h-3.5 w-3.5") }} Web-Quellen synchronisieren
</button> </button>
</form> </form>
{% endif %} {% endif %}
@@ -57,13 +57,13 @@
<table class="w-full text-xs"> <table class="w-full text-xs">
<thead class="text-left text-slate-400"> <thead class="text-left text-slate-400">
<tr> <tr>
<th class="px-2 py-1.5 font-medium">Quelle</th> <th class="px-2 py-1.5 font-medium text-xs uppercase tracking-wider">Quelle</th>
<th class="px-2 py-1.5 font-medium">Status</th> <th class="px-2 py-1.5 font-medium text-xs uppercase tracking-wider">Status</th>
<th class="px-2 py-1.5 font-medium">Letzter Lauf</th> <th class="px-2 py-1.5 font-medium text-xs uppercase tracking-wider">Letzter Lauf</th>
<th class="px-2 py-1.5 font-medium text-right">Neu</th> <th class="px-2 py-1.5 font-medium text-right text-xs uppercase tracking-wider">Neu</th>
<th class="px-2 py-1.5 font-medium text-right">Aktualisiert</th> <th class="px-2 py-1.5 font-medium text-right text-xs uppercase tracking-wider">Aktualisiert</th>
<th class="px-2 py-1.5 font-medium text-right">Gefiltert</th> <th class="px-2 py-1.5 font-medium text-right text-xs uppercase tracking-wider">Gefiltert</th>
<th class="px-2 py-1.5 font-medium">Hinweis</th> <th class="px-2 py-1.5 font-medium text-xs uppercase tracking-wider">Hinweis</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">

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
@@ -33,7 +34,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import AccessDenied, get_db, require_user from redaktionskern.auth.deps import AccessDenied, get_db, require_user
from redaktionskern.auth.models import Role, User from redaktionskern.auth.models import Role, User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, DashboardKarte, Migration, NavEntry
from redaktionskern.db import Base from redaktionskern.db import Base
from .models import ( from .models import (
@@ -134,6 +135,55 @@ class PlanungPlugin(BasePlugin):
def navigation(self) -> list[NavEntry]: def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/planung")] return [NavEntry(label=self.title, url="/planung")]
def dashboard_karten(self, user: User) -> list[DashboardKarte]:
"""Dashboard: Planungs-Stats — Rezensenten ihre eigenen, die
Redaktion die Gesamtzahlen je Status."""
with self.context.session_factory() as db:
zaehler = dict(
db.execute(
select(Planungseintrag.status, func.count())
.group_by(Planungseintrag.status)
).all()
)
if user.role in REDAKTION:
return [
DashboardKarte(
titel="Planung",
wert=str(zaehler.get("offen", 0)),
beschreibung="offene Einträge",
zeilen=tuple(
f"{STATUS_ANZEIGE[s]}: {zaehler.get(s, 0)}"
for s in GUELTIGE_STATUS
),
link_url="/planung",
link_label="Zur Planung",
ton="neutral",
)
]
eigene = dict(
db.execute(
select(Planungseintrag.status, func.count())
.where(Planungseintrag.rezensent_id == user.id)
.group_by(Planungseintrag.status)
).all()
)
return [
DashboardKarte(
titel="Meine offenen Planungseinträge",
wert=str(eigene.get("offen", 0)),
link_url="/planung?status=offen",
link_label="Anzeigen",
ton="neutral",
),
DashboardKarte(
titel="Meine Einträge in Bearbeitung",
wert=str(eigene.get("in_bearbeitung", 0)),
link_url="/planung?status=in_bearbeitung",
link_label="Anzeigen",
ton="neutral",
),
]
# ---------- Hilfsfunktionen ---------- # ---------- Hilfsfunktionen ----------
@staticmethod @staticmethod
@@ -250,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()
@@ -277,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

@@ -1,24 +1,31 @@
{# Coverbild-Vorschau des Planung-Plugins. {# Coverbild-Vorschau.
Erwartete Variablen: cover_bild (URL oder None), cover_titel, Erwartete Variablen: cover_bild (URL oder None), cover_titel,
cover_gross (optional: true für großformatige Ansichten). cover_gross (optional: true für großformatige Ansichten).
Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein Bilder werden direkt von der Quelle eingebettet (Hotlinking, kein
Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt Download) — mit loading="lazy" und decoding="async". Ohne Bild zeigt
das Partial einen dezenten Platzhalter mit Spielicon-Zeichen. #} das Partial einen dezenten Platzhalter mit SVG-Würfel (in der großen
Ansicht zusätzlich mit dem Hinweis „Kein Cover"). #}
{% from "icons.html" import icon_wuerfel %}
{% set _gross = cover_gross | default(false) %} {% set _gross = cover_gross | default(false) %}
{% if _gross %} {% if _gross %}
{% if cover_bild %} {% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" <img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async"
class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm"> class="block h-auto w-auto max-w-[200px] max-h-[280px] rounded-lg border border-slate-200 bg-white shadow-sm">
{% else %} {% else %}
<div aria-hidden="true" title="Kein Cover vorhanden" <div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-[210px] w-[150px] max-w-[200px] items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-6xl text-slate-300 select-none">&#127922;</div> class="flex h-[210px] w-[150px] max-w-[200px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 text-slate-300 select-none">
{{ icon_wuerfel("h-12 w-12") }}
<span class="text-xs font-medium text-slate-400">Kein Cover</span>
</div>
{% endif %} {% endif %}
{% else %} {% else %}
{% if cover_bild %} {% if cover_bild %}
<img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="40" height="56" <img src="{{ cover_bild }}" alt="Cover: {{ cover_titel }}" loading="lazy" decoding="async" width="48" height="68"
class="block h-14 w-10 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover"> class="block h-[68px] w-12 shrink-0 rounded border border-slate-200 bg-slate-50 object-cover">
{% else %} {% else %}
<div aria-hidden="true" title="Kein Cover vorhanden" <div role="img" aria-label="Kein Cover vorhanden" title="Kein Cover vorhanden"
class="flex h-14 w-10 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-lg text-slate-400 select-none">&#127922;</div> class="flex h-[68px] w-12 shrink-0 items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 text-slate-400 select-none">
{{ icon_wuerfel("h-5 w-5") }}
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">Eintrag bearbeiten</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">Eintrag bearbeiten</h1>
<p class="text-slate-600 mb-4">„{{ eintrag.titel }}“ &middot; Rezensent: <p class="text-slate-600 mb-4">„{{ eintrag.titel }}“ &middot; Rezensent:
{% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %} {% if user.id == eintrag.rezensent_id %}du{% else %}{{ user.display_name }}{% endif %}
</p> </p>
@@ -18,7 +18,7 @@
class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4 w-full"> class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 max-w-2xl space-y-4 w-full">
<div> <div>
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label> <label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required value="{{ eintrag.titel }}" <input type="text" id="titel" name="titel" required aria-required="true" value="{{ eintrag.titel }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"> class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
</div> </div>
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">

View File

@@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel }}</h1> <div x-data="{ loeschFormular: null, loeschTitel: '' }">
<h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Planungsliste der Redaktion: wer reviewt welches Spiel f&uuml;r welche Ausgabe. Planungsliste der Redaktion: wer reviewt welches Spiel f&uuml;r welche Ausgabe.
Jede Eintragung &mdash; aus den Neuheiten verschoben oder h&auml;ndisch nachgetragen &mdash; Jede Eintragung &mdash; aus den Neuheiten verschoben oder h&auml;ndisch nachgetragen &mdash;
@@ -12,16 +13,16 @@
<div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div> <div class="mb-4 px-4 py-3 rounded-lg border bg-emerald-50 border-emerald-200 text-emerald-800 text-sm">{{ meldung }}</div>
{% endif %} {% endif %}
{% if fehler %} {% if fehler %}
<div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm">{{ fehler }}</div> <div class="mb-4 px-4 py-3 rounded-lg border bg-red-50 border-red-200 text-red-700 text-sm" role="alert" tabindex="-1">{{ fehler }}</div>
{% endif %} {% endif %}
<div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6"> <div class="bg-white border border-slate-200 rounded-xl shadow-sm p-4 mb-6">
<h2 class="font-semibold mb-3">Titel h&auml;ndisch nachtragen</h2> <h2 class="text-base font-semibold text-slate-800 mb-3">Titel h&auml;ndisch nachtragen</h2>
<!-- Mobil: Felder untereinander vollbreit; ab sm: flexibel nebeneinander --> <!-- Mobil: Felder untereinander vollbreit; ab sm: flexibel nebeneinander -->
<form method="post" action="/planung/neu" class="grid grid-cols-1 gap-3 sm:flex sm:flex-wrap sm:items-end"> <form method="post" action="/planung/neu" class="grid grid-cols-1 gap-3 sm:flex sm:flex-wrap sm:items-end">
<div class="sm:w-64 lg:w-72"> <div class="sm:w-64 lg:w-72">
<label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label> <label for="titel" class="block text-xs font-medium text-slate-500 mb-1">Spieltitel *</label>
<input type="text" id="titel" name="titel" required <input type="text" id="titel" name="titel" required aria-required="true"
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"> 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">
</div> </div>
<div class="sm:w-48 lg:w-52"> <div class="sm:w-48 lg:w-52">
@@ -82,17 +83,11 @@
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a> class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %} {% endif %}
{% if eintrag.pruefung %} {% if eintrag.pruefung %}
<span class="ml-0.5 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs align-middle" <span class="ml-0.5 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs align-middle"
title="Die dedup-Prüfung hatte beim Eintragen Treffer.">&#9888; Pr&uuml;fhinweis</span> title="Die dedup-Prüfung hatte beim Eintragen Treffer.">{% from "icons.html" import icon_warnung %}{{ icon_warnung("h-3.5 w-3.5") }} Pr&uuml;fhinweis</span>
{% endif %} {% endif %}
</h3> </h3>
{% if eintrag.status == 'offen' %} {% from "status.html" import status_badge %}{{ status_badge(eintrag.status, status_anzeige[eintrag.status], "ml-auto shrink-0") }}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-700 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% elif eintrag.status == 'in_bearbeitung' %}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% else %}
<span class="ml-auto shrink-0 inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% endif %}
</div> </div>
<dl class="mt-1.5 space-y-0.5 text-xs text-slate-500"> <dl class="mt-1.5 space-y-0.5 text-xs text-slate-500">
{% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %} {% if eintrag.verlag %}<div><dt class="inline font-medium text-slate-400">Verlag: </dt><dd class="inline">{{ eintrag.verlag }}</dd></div>{% endif %}
@@ -119,7 +114,9 @@
{% endif %} {% endif %}
<a href="/planung/{{ eintrag.id }}/bearbeiten" <a href="/planung/{{ eintrag.id }}/bearbeiten"
class="min-h-[44px] inline-flex items-center justify-center border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-3 py-2 rounded-lg text-sm whitespace-nowrap">Bearbeiten</a> class="min-h-[44px] inline-flex items-center justify-center border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-3 py-2 rounded-lg text-sm whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/planung/{{ eintrag.id }}/loeschen"> <form method="post" action="/planung/{{ eintrag.id }}/loeschen"
data-titel="{{ eintrag.titel }}"
@submit.prevent="loeschTitel = $event.target.dataset.titel; loeschFormular = $event.target">
<button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center border border-red-200 text-red-600 hover:bg-red-50 px-3 py-2 rounded-lg text-sm whitespace-nowrap">L&ouml;schen</button> <button type="submit" class="w-full min-h-[44px] inline-flex items-center justify-center border border-red-200 text-red-600 hover:bg-red-50 px-3 py-2 rounded-lg text-sm whitespace-nowrap">L&ouml;schen</button>
</form> </form>
</div> </div>
@@ -136,18 +133,18 @@
</ul> </ul>
<!-- Desktop/Tablet (&ge;768px): Tabellenform --> <!-- Desktop/Tablet (&ge;768px): Tabellenform -->
<div class="hidden md:block overflow-x-auto bg-white rounded-xl border border-slate-200 shadow-sm"> <div class="hidden md:block overflow-auto max-h-[70vh] bg-white rounded-xl border border-slate-200 shadow-sm">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="text-left text-slate-500 sticky top-0 z-10 [&>tr>th]:bg-slate-50 [&>tr>th]:shadow-[inset_0_-1px_0_0_#e2e8f0]">
<tr> <tr>
<th class="px-4 py-3 font-medium">Cover</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Cover</th>
<th class="px-4 py-3 font-medium">Spieltitel</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Spieltitel</th>
<th class="px-4 py-3 font-medium">Verlag</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Verlag</th>
<th class="px-4 py-3 font-medium">Ausgabe</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Ausgabe</th>
<th class="px-4 py-3 font-medium">Rezensent</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Rezensent</th>
<th class="px-4 py-3 font-medium">Status</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Status</th>
<th class="px-4 py-3 font-medium">Aktualisiert</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktualisiert</th>
<th class="px-4 py-3 font-medium">Aktionen</th> <th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -169,21 +166,15 @@
class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a> class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }}&nbsp;</a>
{% endif %} {% endif %}
{% if eintrag.pruefung %} {% if eintrag.pruefung %}
<span class="ml-1 inline-block px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs" <span class="ml-1 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 text-xs"
title="Die dedup-Prüfung hatte beim Eintragen Treffer.">&#9888; Pr&uuml;fhinweis</span> title="Die dedup-Prüfung hatte beim Eintragen Treffer.">{% from "icons.html" import icon_warnung %}{{ icon_warnung("h-3.5 w-3.5") }} Pr&uuml;fhinweis</span>
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td> <td class="px-4 py-3">{{ eintrag.verlag or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ eintrag.ausgabe or '—' }}</td> <td class="px-4 py-3 whitespace-nowrap">{{ eintrag.ausgabe or '—' }}</td>
<td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') }}</td> <td class="px-4 py-3 whitespace-nowrap">{{ namen.get(eintrag.rezensent_id, '—') }}</td>
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
{% if eintrag.status == 'offen' %} {% from "status.html" import status_badge %}{{ status_badge(eintrag.status, status_anzeige[eintrag.status]) }}
<span class="inline-block px-2 py-0.5 rounded-full bg-slate-200 text-slate-700 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% elif eintrag.status == 'in_bearbeitung' %}
<span class="inline-block px-2 py-0.5 rounded-full bg-sky-100 text-sky-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% else %}
<span class="inline-block px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 text-xs">{{ status_anzeige[eintrag.status] }}</span>
{% endif %}
</td> </td>
<td class="px-4 py-3 whitespace-nowrap text-slate-500"> <td class="px-4 py-3 whitespace-nowrap text-slate-500">
{{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }} {{ eintrag.aktualisiert_am.strftime('%d.%m.%Y %H:%M') if eintrag.aktualisiert_am else '—' }}
@@ -205,7 +196,9 @@
{% endif %} {% endif %}
<a href="/planung/{{ eintrag.id }}/bearbeiten" <a href="/planung/{{ eintrag.id }}/bearbeiten"
class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2.5 py-2 rounded-lg whitespace-nowrap">Bearbeiten</a> class="text-xs border border-slate-300 hover:border-emerald-600 hover:text-emerald-700 px-2.5 py-2 rounded-lg whitespace-nowrap">Bearbeiten</a>
<form method="post" action="/planung/{{ eintrag.id }}/loeschen"> <form method="post" action="/planung/{{ eintrag.id }}/loeschen"
data-titel="{{ eintrag.titel }}"
@submit.prevent="loeschTitel = $event.target.dataset.titel; loeschFormular = $event.target">
<button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2.5 py-2 rounded-lg whitespace-nowrap">L&ouml;schen</button> <button type="submit" class="text-xs border border-red-200 text-red-600 hover:bg-red-50 px-2.5 py-2 rounded-lg whitespace-nowrap">L&ouml;schen</button>
</form> </form>
</div> </div>
@@ -224,11 +217,27 @@
</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 }}" <!-- Lösch-Bestätigungsdialog (Progressive Enhancement: ohne JavaScript
hx-trigger="revealed delay:150ms" hx-swap="outerHTML"> sendet das Formular sofort, mit Alpine öffnet sich dieser Dialog). -->
<div class="py-6 text-center text-sm text-slate-400">Lade weitere Eintr&auml;ge&nbsp;&hellip;</div> <div x-show="loeschFormular !== null" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog" aria-modal="true" aria-labelledby="loesch-dialog-titel">
<div class="absolute inset-0 bg-slate-900/50" @click="loeschFormular = null"></div>
<div class="relative bg-white rounded-xl shadow-xl max-w-sm w-full p-5">
<h2 id="loesch-dialog-titel" class="text-base font-semibold text-slate-800 mb-1">Eintrag löschen?</h2>
<p class="text-sm text-slate-600 mb-4">
&bdquo;<span x-text="loeschTitel"></span>&ldquo; wird endgültig gelöscht.
Das lässt sich nicht rückgängig machen.
</p>
<div class="flex justify-end gap-2">
<button type="button" @click="loeschFormular = null"
class="px-4 py-2 rounded-lg border border-slate-300 text-sm hover:bg-slate-50">Abbrechen</button>
<button type="button" @click="if (loeschFormular) { loeschFormular.submit(); }"
class="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium">Löschen</button>
</div>
</div>
</div>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %} {% block titel %}{{ titel_headline }} — Spiele-Redaktion{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-2">{{ titel_headline }}</h1> <h1 class="text-2xl font-bold text-slate-900 mb-2">{{ titel_headline }}</h1>
<p class="text-slate-600 mb-4"> <p class="text-slate-600 mb-4">
Die automatische Dedup-Pr&uuml;fung hat Treffer ergeben. Bitte pr&uuml;fen, bevor Die automatische Dedup-Pr&uuml;fung hat Treffer ergeben. Bitte pr&uuml;fen, bevor
der Eintrag angelegt wird. der Eintrag angelegt wird.
@@ -15,7 +15,7 @@
{% include "planung/_cover.html" %} {% include "planung/_cover.html" %}
</div> </div>
<div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-0 sm:mb-6 text-sm w-full"> <div class="bg-amber-50 border border-amber-300 rounded-xl p-4 mb-0 sm:mb-6 text-sm w-full">
<p class="font-medium text-amber-800 mb-2">&#9888; {{ konflikte|length }} Treffer f&uuml;r „{{ spiel_titel }}“</p> <p class="font-medium text-amber-800 mb-2 flex items-center gap-1.5">{% from "icons.html" import icon_warnung %}{{ icon_warnung("h-4 w-4") }} {{ konflikte|length }} Treffer f&uuml;r „{{ spiel_titel }}“</p>
<ul class="list-disc list-inside space-y-1 text-slate-700"> <ul class="list-disc list-inside space-y-1 text-slate-700">
{% for konflikt in konflikte %} {% for konflikt in konflikte %}
<li> <li>
@@ -41,7 +41,7 @@
<input type="hidden" name="{{ name }}" value="{{ wert }}"> <input type="hidden" name="{{ name }}" value="{{ wert }}">
{% endfor %} {% endfor %}
<h2 class="font-semibold mb-3">Welcher Verlag soll gef&uuml;hrt werden?</h2> <h2 class="text-base font-semibold text-slate-800 mb-3">Welcher Verlag soll gef&uuml;hrt werden?</h2>
<div class="space-y-2 mb-4 text-sm"> <div class="space-y-2 mb-4 text-sm">
{% for option in verlags_optionen %} {% for option in verlags_optionen %}
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">

View File

@@ -20,6 +20,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
from sqlalchemy import func, select from sqlalchemy import func, select
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from redaktionskern.auth import routen as auth_routen from redaktionskern.auth import routen as auth_routen
@@ -134,6 +135,33 @@ def create_app(settings: Settings | None = None) -> FastAPI:
for plugin in plugins: for plugin in plugins:
app.include_router(plugin.router) 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( app.add_middleware(
SessionMiddleware, SessionMiddleware,
secret_key=settings.session_secret, secret_key=settings.session_secret,

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_current_user, get_db, require_roles, require_user from redaktionskern.auth.deps import get_current_user, get_db, require_roles, require_user
from redaktionskern.auth.models import ROLEN, Role, User from redaktionskern.auth.models import ROLEN, Role, User
from redaktionskern.auth.security import hash_password, verify_password from redaktionskern.auth.security import hash_password, verify_password
from redaktionskern.contracts import DashboardKarte
router = APIRouter() router = APIRouter()
admin_router = APIRouter( admin_router = APIRouter(
@@ -24,9 +25,44 @@ def _render(request: Request, name: str, status_code: int = 200, **kontext):
# ---------------- Dashboard ---------------- # ---------------- Dashboard ----------------
def _plugin_dashboard_karten(request: Request, user: User) -> list[DashboardKarte]:
"""Sammelt die Dashboard-Karten aller geladenen Plugins ein.
Fachlogik bleibt in den Plugins (Plugin-Vertrag) — der Kern ruft nur
den optionalen Hook auf und duldet Fehler einzelner Plugins.
"""
karten: list[DashboardKarte] = []
for plugin in request.app.state.registry.all():
try:
karten.extend(plugin.dashboard_karten(user))
except Exception: # pragma: no cover - defensiv
import logging
logging.getLogger("redaktionskern").exception(
"Dashboard-Karten von Plugin %r fehlgeschlagen.", plugin.name
)
return karten
@router.get("/") @router.get("/")
def dashboard(request: Request, user: User = Depends(require_user)): def dashboard(
return _render(request, "dashboard.html", user=user) request: Request,
db: Session = Depends(get_db),
user: User = Depends(require_user),
):
karten = _plugin_dashboard_karten(request, user)
if user.role == Role.ADMIN.value:
benutzer_anzahl = db.scalar(select(func.count()).select_from(User)) or 0
karten.append(
DashboardKarte(
titel="Benutzer",
wert=str(benutzer_anzahl),
beschreibung="Angelegte Benutzerkonten",
link_url="/admin/benutzer",
link_label="Benutzerverwaltung",
)
)
return _render(request, "dashboard.html", user=user, karten=karten)
# ---------------- Login / Logout ---------------- # ---------------- Login / Logout ----------------

View File

@@ -33,6 +33,24 @@ class NavEntry:
url: str url: str
@dataclass(frozen=True)
class DashboardKarte:
"""Eine Karte auf dem Dashboard (von Plugins beigesteuert).
`ton` steuert die Farbgebung: neutral | ok | warnung | fehler.
`zeilen` sind optionale Kurzzeilen unter dem Hauptwert (z. B. die
nächsten Redaktionsschlüsse).
"""
titel: str
wert: str = ""
beschreibung: str = ""
zeilen: tuple[str, ...] = ()
link_url: str | None = None
link_label: str = "Öffnen"
ton: str = "neutral"
@dataclass(frozen=True) @dataclass(frozen=True)
class Migration: class Migration:
"""Eine Schema-Migration des Plugins.""" """Eine Schema-Migration des Plugins."""
@@ -88,6 +106,23 @@ class BasePlugin:
"""Einträge für die Hauptnavigation (leer = keiner).""" """Einträge für die Hauptnavigation (leer = keiner)."""
return [] 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.
Das Plugin entscheidet selbst anhand von `user.role`, welche
Karten es zeigt (leer = keine). Fehler werden vom Kern geduldet
und übersprungen, damit eine defekte Karte nie das Dashboard bricht.
"""
return []
def templates_dir(self) -> Path | None: def templates_dir(self) -> Path | None:
"""Template-Ordner des Plugins (<Paket>/templates), falls vorhanden.""" """Template-Ordner des Plugins (<Paket>/templates), falls vorhanden."""
modul = sys.modules.get(type(self).__module__) modul = sys.modules.get(type(self).__module__)

View File

@@ -7,15 +7,39 @@
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
<style>[x-cloak] { display: none !important; }</style>
<style type="text/tailwindcss">
/* Einheitlicher Fokus-Ring für Formularelemente und Button-Links */
.focus-ring { @apply focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500; }
</style>
<style>
/* Fallback ohne Klassen-Anpassung: sichtbarer Fokus auf allen
Bedienelementen (Tailwind-Ring überlagert diesen, wo vorhanden). */
input:focus-visible, select:focus-visible, textarea:focus-visible,
button:focus-visible, summary:focus-visible, a:focus-visible {
outline: 2px solid #10b981;
outline-offset: 1px;
}
input:focus-visible, select:focus-visible, textarea:focus-visible {
border-color: #10b981;
}
</style>
</head> </head>
<body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col"> <body class="bg-slate-100 text-slate-800 min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200" x-data="{ menue: false }"> <header class="bg-white border-b border-slate-200" x-data="{ menue: false }">
<nav class="max-w-6xl mx-auto px-4 py-2 md:py-3 flex items-center gap-5" aria-label="Hauptnavigation"> <nav class="max-w-6xl mx-auto px-4 py-2 md:py-3 flex items-center gap-5" aria-label="Hauptnavigation">
<a href="/" class="font-bold text-emerald-700 whitespace-nowrap">&#127923; {{ app_name }}</a> <a href="/" class="font-bold text-emerald-700 whitespace-nowrap inline-flex items-center gap-1.5">
{% from "icons.html" import icon_wuerfelbecher %}{{ icon_wuerfelbecher("h-5 w-5") }} {{ app_name }}
</a>
<!-- Desktop: horizontale Navigationsleiste --> <!-- Desktop: horizontale Navigationsleiste -->
<span class="hidden md:flex items-center gap-5 flex-1"> <span class="hidden md:flex items-center gap-5 flex-1">
{% for eintrag in plugin_nav %} {% for eintrag in plugin_nav %}
<a href="{{ eintrag.url }}" class="text-sm text-slate-600 hover:text-emerald-700">{{ eintrag.label }}</a> {% 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 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 %} {% endfor %}
</span> </span>
<!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel &ge;&ge; 44px --> <!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel &ge;&ge; 44px -->
@@ -35,7 +59,8 @@
{% if user %} {% if user %}
<button type="button" @click="offen = !offen" <button type="button" @click="offen = !offen"
class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100 inline-flex items-center min-h-[44px] md:min-h-0"> class="text-sm font-medium px-2 py-1 rounded hover:bg-slate-100 inline-flex items-center min-h-[44px] md:min-h-0">
{{ user.display_name }} &#9662; {{ user.display_name }}
{% from "icons.html" import icon_chevron_unten %}{{ icon_chevron_unten("h-3.5 w-3.5") }}
</button> </button>
<div x-show="offen" x-cloak @click.outside="offen = false" <div x-show="offen" x-cloak @click.outside="offen = false"
class="absolute right-0 mt-2 w-56 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-sm z-10"> class="absolute right-0 mt-2 w-56 bg-white border border-slate-200 rounded-lg shadow-lg p-3 text-sm z-10">
@@ -57,9 +82,12 @@
<div id="hauptmenue-mobil" x-show="menue" x-cloak @click.outside="menue = false" <div id="hauptmenue-mobil" x-show="menue" x-cloak @click.outside="menue = false"
class="md:hidden border-t border-slate-200 bg-white px-4 pb-3 pt-2"> class="md:hidden border-t border-slate-200 bg-white px-4 pb-3 pt-2">
{% for eintrag in plugin_nav %} {% for eintrag in plugin_nav %}
<a href="{{ eintrag.url }}" @click="menue = false" {% set aktiv = request.url.path == eintrag.url or request.url.path.startswith(eintrag.url ~ '/') %}
class="block py-3 text-sm font-medium text-slate-700 hover:text-emerald-700 border-b border-slate-100 last:border-0"> {% 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="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 }} {{ 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> </a>
{% endfor %} {% endfor %}
{% if not user %} {% if not user %}

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} &mdash; {{ app_name }}{% endblock %} {% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-4"> <h1 class="text-2xl font-bold text-slate-900 mb-4">
{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer &bdquo;{{ ziel.username }}&ldquo; bearbeiten{% endif %} {% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer &bdquo;{{ ziel.username }}&ldquo; bearbeiten{% endif %}
</h1> </h1>
{% if fehler %} {% if fehler %}
<div class="mb-4 max-w-lg rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert"> <div class="mb-4 max-w-lg rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert" tabindex="-1" id="formular-fehler">
{{ fehler }} {{ fehler }}
</div> </div>
{% endif %} {% endif %}
@@ -17,9 +17,9 @@
<div> <div>
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label> <label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
<input id="username" name="username" value="{{ form.username }}" required minlength="3" <input id="username" name="username" value="{{ form.username }}" required aria-required="true" minlength="3"
{% if aktion != 'neu' %}disabled{% endif %} {% if aktion != 'neu' %}disabled{% endif %}
class="w-full border border-slate-300 rounded-lg px-3 py-2 {% if aktion != 'neu' %}bg-slate-100{% endif %}"> class="w-full border border-slate-300 rounded-lg px-3 py-2 focus-ring {% if aktion != 'neu' %}bg-slate-100{% endif %}">
{% if aktion != 'neu' %} {% if aktion != 'neu' %}
<p class="text-xs text-slate-400 mt-1">Der Benutzername kann nicht ge&auml;ndert werden.</p> <p class="text-xs text-slate-400 mt-1">Der Benutzername kann nicht ge&auml;ndert werden.</p>
{% endif %} {% endif %}
@@ -28,7 +28,7 @@
<div> <div>
<label for="display_name" class="block text-sm font-medium mb-1">Anzeigename</label> <label for="display_name" class="block text-sm font-medium mb-1">Anzeigename</label>
<input id="display_name" name="display_name" value="{{ form.display_name }}" <input id="display_name" name="display_name" value="{{ form.display_name }}"
class="w-full border border-slate-300 rounded-lg px-3 py-2"> class="w-full border border-slate-300 rounded-lg px-3 py-2 focus-ring">
</div> </div>
<div> <div>
@@ -41,7 +41,7 @@
<input type="hidden" name="role" value="{{ form.role }}"> <input type="hidden" name="role" value="{{ form.role }}">
{% else %} {% else %}
<select id="role" name="role" <select id="role" name="role"
class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white"> class="w-full border border-slate-300 rounded-lg px-3 py-2 bg-white focus-ring">
{% for rollen_wert, rollen_text in rollen_anzeige.items() %} {% for rollen_wert, rollen_text in rollen_anzeige.items() %}
<option value="{{ rollen_wert }}" {% if form.role == rollen_wert %}selected{% endif %}>{{ rollen_text }}</option> <option value="{{ rollen_wert }}" {% if form.role == rollen_wert %}selected{% endif %}>{{ rollen_text }}</option>
{% endfor %} {% endfor %}
@@ -62,8 +62,8 @@
<label for="password" class="block text-sm font-medium mb-1"> <label for="password" class="block text-sm font-medium mb-1">
{% if aktion == 'neu' %}Passwort{% else %}Neues Passwort (leer = unver&auml;ndert){% endif %} {% if aktion == 'neu' %}Passwort{% else %}Neues Passwort (leer = unver&auml;ndert){% endif %}
</label> </label>
<input id="password" name="password" type="password" {% if aktion == 'neu' %}required{% endif %} minlength="8" <input id="password" name="password" type="password" {% if aktion == 'neu' %}required aria-required="true"{% endif %} minlength="8"
class="w-full border border-slate-300 rounded-lg px-3 py-2"> class="w-full border border-slate-300 rounded-lg px-3 py-2 focus-ring">
</div> </div>
<div class="flex flex-col sm:flex-row gap-3 pt-2"> <div class="flex flex-col sm:flex-row gap-3 pt-2">

View File

@@ -2,7 +2,7 @@
{% block titel %}Benutzerverwaltung &mdash; {{ app_name }}{% endblock %} {% block titel %}Benutzerverwaltung &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">Benutzerverwaltung</h1> <h1 class="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
<a href="/admin/benutzer/neu" <a href="/admin/benutzer/neu"
class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white rounded-lg px-3 py-2 text-sm font-medium min-h-[44px] inline-flex items-center"> class="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white rounded-lg px-3 py-2 text-sm font-medium min-h-[44px] inline-flex items-center">
+ Neuer Benutzer + Neuer Benutzer
@@ -13,12 +13,12 @@
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="bg-slate-50 text-left text-slate-500"> <thead class="bg-slate-50 text-left text-slate-500">
<tr> <tr>
<th class="px-4 py-2 font-medium">Benutzername</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Benutzername</th>
<th class="px-4 py-2 font-medium">Anzeigename</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Anzeigename</th>
<th class="px-4 py-2 font-medium">Rolle</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Rolle</th>
<th class="px-4 py-2 font-medium">Status</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Status</th>
<th class="px-4 py-2 font-medium">Erstellt am</th> <th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Erstellt am</th>
<th class="px-4 py-2"></th> <th class="px-4 py-2 text-xs uppercase tracking-wider"></th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@@ -28,10 +28,11 @@
<td class="px-4 py-2">{{ b.display_name }}</td> <td class="px-4 py-2">{{ b.display_name }}</td>
<td class="px-4 py-2">{{ rollen_anzeige[b.role] or b.role }}</td> <td class="px-4 py-2">{{ rollen_anzeige[b.role] or b.role }}</td>
<td class="px-4 py-2"> <td class="px-4 py-2">
{% from "status.html" import status_badge %}
{% if b.active %} {% if b.active %}
<span class="text-emerald-700">aktiv</span> {{ status_badge("aktiv", "aktiv") }}
{% else %} {% else %}
<span class="text-red-600">deaktiviert</span> {{ status_badge("deaktiviert", "deaktiviert") }}
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-2 text-slate-500">{{ b.created_at.strftime('%d.%m.%Y') if b.created_at else '&ndash;' }}</td> <td class="px-4 py-2 text-slate-500">{{ b.created_at.strftime('%d.%m.%Y') if b.created_at else '&ndash;' }}</td>

View File

@@ -1,17 +1,56 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titel %}&Uuml;bersicht &mdash; {{ app_name }}{% endblock %} {% block titel %}&Uuml;bersicht &mdash; {{ app_name }}{% endblock %}
{% block inhalt %} {% block inhalt %}
<h1 class="text-2xl font-bold mb-1">Willkommen, {{ user.display_name }}!</h1> <h1 class="text-2xl font-bold text-slate-900 mb-1">Willkommen, {{ user.display_name }}!</h1>
<p class="text-slate-600 mb-6"> <p class="text-slate-600 mb-6">
Rolle: <span class="font-medium">{{ rollen_anzeige[user.role] or user.role }}</span> Rolle: <span class="font-medium">{{ rollen_anzeige[user.role] or user.role }}</span>
</p> </p>
<section class="mb-8"> {% if karten %}
<h2 class="text-lg font-semibold mb-1">Geladene Plugins</h2> <section aria-label="Übersicht" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mb-8">
<p class="text-sm text-slate-500 mb-3"> {% for karte in karten %}
Der Kern ist schlank &mdash; alle Funktionen kommen aus Plugins. {% set ton_rand = {
</p> 'neutral': 'border-slate-200',
<div class="bg-white rounded-xl border border-slate-200 divide-y divide-slate-100"> 'ok': 'border-emerald-200',
'warnung': 'border-amber-300',
'fehler': 'border-red-300',
}[karte.ton] | default('border-slate-200') %}
{% set ton_wert = {
'neutral': 'text-slate-900',
'ok': 'text-emerald-700',
'warnung': 'text-amber-700',
'fehler': 'text-red-700',
}[karte.ton] | default('text-slate-900') %}
<div class="bg-white rounded-xl border {{ ton_rand }} shadow-sm p-4 flex flex-col">
<h2 class="text-base font-semibold text-slate-800 mb-1">{{ karte.titel }}</h2>
{% if karte.wert %}
<p class="text-3xl font-bold {{ ton_wert }} leading-tight mb-1">{{ karte.wert }}</p>
{% endif %}
{% if karte.beschreibung %}
<p class="text-sm text-slate-500 mb-1">{{ karte.beschreibung }}</p>
{% endif %}
{% if karte.zeilen %}
<ul class="text-sm text-slate-600 space-y-0.5 mt-1">
{% for zeile in karte.zeilen %}
<li class="truncate" title="{{ zeile }}">{{ zeile }}</li>
{% endfor %}
</ul>
{% endif %}
{% if karte.link_url %}
<a href="{{ karte.link_url }}"
class="mt-auto pt-3 text-sm font-medium text-emerald-700 hover:text-emerald-900">{{ karte.link_label }} &rarr;</a>
{% endif %}
</div>
{% endfor %}
</section>
{% endif %}
{% if user.role == 'admin' %}
<details class="mb-8">
<summary class="text-sm text-slate-500 cursor-pointer hover:text-emerald-700">
Technik: geladene Plugins ({{ geladene_plugins|length }})
</summary>
<div class="mt-3 bg-white rounded-xl border border-slate-200 divide-y divide-slate-100">
{% for plugin in geladene_plugins %} {% for plugin in geladene_plugins %}
<div class="px-4 py-3 flex flex-wrap items-baseline gap-x-3 gap-y-1"> <div class="px-4 py-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="font-medium">{{ plugin.title or plugin.name }}</span> <span class="font-medium">{{ plugin.title or plugin.name }}</span>
@@ -20,5 +59,6 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</section> </details>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,101 @@
{# Zentrale Inline-SVG-Icons (Heroicons-Stil, Outline).
Alle Icons sind dekorativ: aria-hidden + focusable=false, Farbe über
currentColor. Aufruf: {% from "icons.html" import icon_refresh %}
{{ icon_refresh("h-4 w-4") }} #}
{% macro _svg(klasse, pfad) -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ pfad }}"/>
</svg>
{%- endmacro %}
{% macro icon_wuerfelbecher(klasse="h-5 w-5") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6.5 3.5h11l-1.6 13.2a2.4 2.4 0 0 1-2.4 2.2h-3a2.4 2.4 0 0 1-2.4-2.2L6.5 3.5z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 7.5h12"/>
<circle cx="10.2" cy="12" r="0.4" fill="currentColor"/>
<circle cx="13.8" cy="12" r="0.4" fill="currentColor"/>
<circle cx="12" cy="14.8" r="0.4" fill="currentColor"/>
</svg>
{%- endmacro %}
{% macro icon_wuerfel(klasse="h-6 w-6") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<rect x="3.5" y="3.5" width="17" height="17" rx="3.5"/>
<circle cx="8.4" cy="8.4" r="0.5" fill="currentColor"/>
<circle cx="15.6" cy="8.4" r="0.5" fill="currentColor"/>
<circle cx="12" cy="12" r="0.5" fill="currentColor"/>
<circle cx="8.4" cy="15.6" r="0.5" fill="currentColor"/>
<circle cx="15.6" cy="15.6" r="0.5" fill="currentColor"/>
</svg>
{%- endmacro %}
{% macro icon_sync(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99") }}
{%- endmacro %}
{% macro icon_pfeil_rechts(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3") }}
{%- endmacro %}
{% macro icon_warnung(klasse="h-4 w-4") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
{%- endmacro %}
{% macro icon_check(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M4.5 12.75l6 6 9-13.5") }}
{%- endmacro %}
{% macro icon_gebaeude(klasse="h-5 w-5") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"/>
</svg>
{%- endmacro %}
{% macro icon_tag(klasse="h-5 w-5") -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.8" stroke="currentColor" class="{{ klasse }}"
aria-hidden="true" focusable="false">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z"/>
</svg>
{%- endmacro %}
{% macro icon_buch(klasse="h-5 w-5") -%}
{{ _svg(klasse, "M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25") }}
{%- endmacro %}
{% macro icon_dokument(klasse="h-5 w-5") -%}
{{ _svg(klasse, "M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z") }}
{%- endmacro %}
{% macro icon_chevron_unten(klasse="h-3.5 w-3.5") -%}
{{ _svg(klasse, "M19.5 8.25l-7.5 7.5-7.5-7.5") }}
{%- endmacro %}
{% macro icon_chevron_oben(klasse="h-3.5 w-3.5") -%}
{{ _svg(klasse, "M4.5 15.75l7.5-7.5 7.5 7.5") }}
{%- endmacro %}
{% macro icon_chevron_links(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M15.75 19.5L8.25 12l7.5-7.5") }}
{%- endmacro %}
{% macro icon_chevron_rechts(klasse="h-4 w-4") -%}
{{ _svg(klasse, "M8.25 4.5l7.5 7.5-7.5 7.5") }}
{%- endmacro %}

View File

@@ -5,29 +5,42 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anmelden &mdash; {{ app_name }}</title> <title>Anmelden &mdash; {{ app_name }}</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"></script>
<style>[x-cloak] { display: none !important; }</style>
</head> </head>
<body class="bg-slate-100 text-slate-800 min-h-screen flex items-center justify-center px-4"> <body class="bg-slate-100 text-slate-800 min-h-screen flex items-center justify-center px-4 py-8">
<div class="bg-white shadow-md rounded-xl p-8 w-full max-w-sm"> <div class="w-full max-w-sm flex flex-col items-center">
<h1 class="text-xl font-bold mb-1">&#127923; {{ app_name }}</h1> <div class="bg-white shadow-md rounded-xl p-8 w-full">
<h1 class="text-xl font-bold mb-1 flex items-center gap-2">{% from "icons.html" import icon_wuerfelbecher %}{{ icon_wuerfelbecher("h-6 w-6 text-emerald-700") }} {{ app_name }}</h1>
<p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p> <p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p>
{% if fehler %} {% if fehler %}
<div class="mb-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert"> <div id="login-fehler" class="mb-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert" tabindex="-1">
{{ fehler }} {{ fehler }}
</div> </div>
{% endif %} {% endif %}
<form method="post" action="/login" class="space-y-4"> <form method="post" action="/login" class="space-y-4" x-data="{ passwortSichtbar: false }">
<div> <div>
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label> <label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
<input id="username" name="username" value="{{ username }}" required autocomplete="username" <input id="username" name="username" value="{{ username }}" required autocomplete="username"
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500"> {% if fehler %}aria-invalid="true" aria-describedby="login-fehler"{% endif %}
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium mb-1">Passwort</label> <label for="password" class="block text-sm font-medium mb-1">Passwort</label>
<input id="password" name="password" type="password" required autocomplete="current-password" <div class="relative">
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500"> <input id="password" name="password" type="password" :type="passwortSichtbar ? 'text' : 'password'" required autocomplete="current-password"
{% if fehler %}aria-invalid="true" aria-describedby="login-fehler"{% endif %}
class="w-full border border-slate-300 rounded-lg px-3 py-2 pr-16 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500">
<button type="button" @click="passwortSichtbar = !passwortSichtbar"
:aria-pressed="passwortSichtbar.toString()"
class="absolute inset-y-0 right-0 my-1 mr-1 px-3 rounded-lg text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-700">
<span x-show="!passwortSichtbar">Anzeigen</span>
<span x-show="passwortSichtbar" x-cloak>Verbergen</span>
</button>
</div>
</div> </div>
<button type="submit" <button type="submit"
class="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium rounded-lg px-3 py-2"> class="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2">
Anmelden Anmelden
</button> </button>
</form> </form>
@@ -38,5 +51,6 @@
width="120" height="35" class="inline-block opacity-80 hover:opacity-100"> width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
</a> </a>
</div> </div>
</div>
</body> </body>
</html> </html>

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 %}

View File

@@ -0,0 +1,26 @@
{# Einheitliche Status-Badges (semantisches Farbsystem).
Aufruf: {% from "status.html" import status_badge %}
{{ status_badge(eintrag.status) }} oder mit abweichendem Text:
{{ status_badge(eintrag.status, status_anzeige[eintrag.status]) }} #}
{% from "icons.html" import icon_warnung %}
{% macro status_badge(status, text=none, extra="") -%}
{% set farben = {
'neuheit': 'bg-emerald-100 text-emerald-800',
'neu': 'bg-emerald-100 text-emerald-800',
'offen': 'bg-slate-200 text-slate-800',
'in_bearbeitung': 'bg-sky-100 text-sky-800',
'planung': 'bg-sky-100 text-sky-800',
'abgeschlossen': 'bg-emerald-100 text-emerald-800',
'aktiv': 'bg-emerald-100 text-emerald-800',
'warnung': 'bg-amber-100 text-amber-800',
'konflikt': 'bg-amber-100 text-amber-800',
'pruefung': 'bg-amber-100 text-amber-800',
'fehler': 'bg-red-100 text-red-800',
'deaktiviert': 'bg-slate-200 text-slate-600',
'archiviert': 'bg-slate-200 text-slate-600',
} -%}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs whitespace-nowrap {{ farben.get(status, 'bg-slate-100 text-slate-700') }} {{ extra }}">
{%- if status in ('warnung', 'konflikt', 'pruefung') %}{{ icon_warnung("h-3.5 w-3.5") }} {% endif -%}
{{ text if text is not none else status }}
</span>
{%- endmacro %}

View File

@@ -437,7 +437,9 @@ def test_neuheitenliste_rendert_platzhalter_ohne_bild(app, client):
assert antwort.status_code == 200 assert antwort.status_code == 200
liste = antwort.text.split('<div id="neuheiten-liste"')[1].split("</main>")[0] liste = antwort.text.split('<div id="neuheiten-liste"')[1].split("</main>")[0]
assert "<img" not in liste assert "<img" not in liste
assert "&#127922;" in antwort.text # Spielicon-Zeichen im Platzhalter-Div # SVG-Platzhalter statt Emoji: Würfel-Icon + Hinweistext
assert 'aria-label="Kein Cover vorhanden"' in liste
assert "<svg" in liste
def test_planungsliste_zeigt_cover(app, client): def test_planungsliste_zeigt_cover(app, client):