Compare commits
13 Commits
main
...
ui-ux-verb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49f3b2d2fb | ||
|
|
968333bf45 | ||
|
|
f90c0de46d | ||
|
|
d6ed8fa56e | ||
|
|
fee5f8b9a3 | ||
|
|
7645f6a791 | ||
|
|
117b34653c | ||
|
|
bba39525a0 | ||
|
|
9bd3b700c4 | ||
|
|
742c265df8 | ||
|
|
b9444289b5 | ||
|
|
14a4846948 | ||
|
|
e1ef7ba926 |
47
README.md
47
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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">🎲</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">🎲</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 %}
|
||||||
|
|||||||
@@ -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ückliegt, werden täglich automatisch aus den
|
mehr als 12 Monate zurückliegt, werden tä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">
|
||||||
↻ 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">
|
||||||
↻ Wiederherstellen
|
{% from "icons.html" import icon_sync %}{{ icon_sync("h-4 w-4") }} Wiederherstellen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">–</span>
|
<span class="text-slate-400">–</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">← 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 →</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% from "seitennavigation.html" import seitennavigation %}
|
||||||
|
{{ seitennavigation(seiten_url, seite, gesamt_seiten) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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">Benachrichtigungen — Einstellungen</h1>
|
<h1 class="text-2xl font-bold text-slate-900 mb-1">Benachrichtigungen — Einstellungen</h1>
|
||||||
<p class="text-slate-600 mb-4">
|
<p class="text-slate-600 mb-4">
|
||||||
Wählen Sie, über welche Kanäle Sie Benachrichtigungen erhalten.
|
Wählen Sie, über welche Kanäle Sie Benachrichtigungen erhalten.
|
||||||
Mehrere Kanäle sind gleichzeitig möglich.
|
Mehrere Kanäle sind gleichzeitig mö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 %}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block titel %}{{ titel }} — {{ app_name }}{% endblock %}
|
{% block titel %}{{ titel }} — {{ 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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">🏚 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 → Verlagsauswahl.</p>
|
<p class="text-slate-500">Gleiches Spiel bei verschiedenen Verlagen/Vertrieb → 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">🇧 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">📖 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änger (BGG-Erweiterungs-Relation + Fuzzy-Match) schon in der Redaktion.</p>
|
<p class="text-slate-500">Spiel oder Vorgä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">📋 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üfen</h2>
|
<h2 class="text-base font-semibold text-slate-800 mb-3">Titel prü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 %}⚠ {{ ergebnis.konflikte|length }} Treffer für „{{ ergebnis.titel }}“{% else %}✓ Keine Treffer fü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ür „{{ ergebnis.titel }}“{% else %}{% from "icons.html" import icon_check %}{{ icon_check("h-4 w-4") }} Keine Treffer fü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üfungen</h2>
|
<h2 class="text-base font-semibold text-slate-800 px-4 pt-4 pb-2">Letzte Prü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üft von</th>
|
<th class="px-4 py-2 font-medium text-xs uppercase tracking-wider">Geprü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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">„{{ ausgabe.name }}“ · Redaktionsschluss:
|
<p class="text-slate-600 mb-4">„{{ ausgabe.name }}“ · 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">
|
||||||
|
|||||||
@@ -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üsse der kommenden Ausgaben. Vier Wochen vor jedem
|
Redaktionsschlü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ö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ö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üfen</h2>
|
<h2 class="text-base font-semibold text-slate-800">Sofort prüfen</h2>
|
||||||
<p class="text-xs text-slate-500">Führt denselben Check aus wie der tägliche Hintergrund-Job ({{ fenster_tage }} Tage vor dem Redaktionsschluss). Doppelerinnerungen sind ausgeschlossen.</p>
|
<p class="text-xs text-slate-500">Führt denselben Check aus wie der tä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ür welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird höchstens einmal erinnert.</p>
|
<p class="text-xs text-slate-500 mb-3">Wer wurde für welche Ausgabe wann erinnert? Pro Ausgabe und Benutzer wird hö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äle</th>
|
<th class="px-4 py-3 font-medium text-xs uppercase tracking-wider">Kanä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">
|
||||||
|
„<span x-text="loeschTitel"></span>“ 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 %}
|
||||||
|
|||||||
@@ -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 — direkt in Excel
|
<strong>CSV</strong> (Semikolon-getrennt, UTF-8 mit BOM — 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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">🎲</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">🎲</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 %}
|
||||||
|
|||||||
@@ -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 }} ↗</a>
|
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }} ↗</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">→ 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 (≥768px): Tabellenform -->
|
<!-- Desktop/Tablet (≥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 }} {{ '▲' if richtung == 'auf' else '▼' }}</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">→ 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äge …</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|||||||
@@ -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">
|
||||||
↻ 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">
|
||||||
↻ 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">
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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">🎲</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">🎲</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 %}
|
||||||
|
|||||||
@@ -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 }}“ · Rezensent:
|
<p class="text-slate-600 mb-4">„{{ eintrag.titel }}“ · 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">
|
||||||
|
|||||||
@@ -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ür welche Ausgabe.
|
Planungsliste der Redaktion: wer reviewt welches Spiel für welche Ausgabe.
|
||||||
Jede Eintragung — aus den Neuheiten verschoben oder händisch nachgetragen —
|
Jede Eintragung — aus den Neuheiten verschoben oder händisch nachgetragen —
|
||||||
@@ -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ändisch nachtragen</h2>
|
<h2 class="text-base font-semibold text-slate-800 mb-3">Titel hä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 }} ↗</a>
|
class="ml-0.5 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }} ↗</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.">⚠ Prü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ü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ö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öschen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,18 +133,18 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Desktop/Tablet (≥768px): Tabellenform -->
|
<!-- Desktop/Tablet (≥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 }} ↗</a>
|
class="ml-1 text-[10px] uppercase tracking-wide text-sky-600/70 hover:text-sky-800 align-super">{{ eintrag.quelle }} ↗</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.">⚠ Prü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ü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ö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ö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äge …</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">
|
||||||
|
„<span x-text="loeschTitel"></span>“ 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 %}
|
||||||
|
|||||||
@@ -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üfung hat Treffer ergeben. Bitte prüfen, bevor
|
Die automatische Dedup-Prüfung hat Treffer ergeben. Bitte prü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">⚠ {{ konflikte|length }} Treffer fü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ü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ührt werden?</h2>
|
<h2 class="text-base font-semibold text-slate-800 mb-3">Welcher Verlag soll gefü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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ----------------
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|||||||
@@ -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">🎳 {{ 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 ≥≥ 44px -->
|
<!-- Mobil: Burger-Button (Alpine.js), Touch-Ziel ≥≥ 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 }} ▾
|
{{ 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 %}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} — {{ app_name }}{% endblock %}
|
{% block titel %}{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer bearbeiten{% endif %} — {{ 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 „{{ ziel.username }}“ bearbeiten{% endif %}
|
{% if aktion == 'neu' %}Neuer Benutzer{% else %}Benutzer „{{ ziel.username }}“ 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ändert werden.</p>
|
<p class="text-xs text-slate-400 mt-1">Der Benutzername kann nicht geä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ändert){% endif %}
|
{% if aktion == 'neu' %}Passwort{% else %}Neues Passwort (leer = unverä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">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block titel %}Benutzerverwaltung — {{ app_name }}{% endblock %}
|
{% block titel %}Benutzerverwaltung — {{ 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 '–' }}</td>
|
<td class="px-4 py-2 text-slate-500">{{ b.created_at.strftime('%d.%m.%Y') if b.created_at else '–' }}</td>
|
||||||
|
|||||||
@@ -1,17 +1,56 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block titel %}Übersicht — {{ app_name }}{% endblock %}
|
{% block titel %}Übersicht — {{ 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 — 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 }} →</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 %}
|
||||||
|
|||||||
101
src/redaktionskern/templates/icons.html
Normal file
101
src/redaktionskern/templates/icons.html
Normal 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 %}
|
||||||
@@ -5,38 +5,52 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Anmelden — {{ app_name }}</title>
|
<title>Anmelden — {{ 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">🎳 {{ app_name }}</h1>
|
<div class="bg-white shadow-md rounded-xl p-8 w-full">
|
||||||
<p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p>
|
<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>
|
||||||
{% if fehler %}
|
<p class="text-sm text-slate-500 mb-6">Bitte anmelden.</p>
|
||||||
<div class="mb-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2" role="alert">
|
{% if fehler %}
|
||||||
{{ fehler }}
|
<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 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/login" class="space-y-4" x-data="{ passwortSichtbar: false }">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
|
||||||
|
<input id="username" name="username" value="{{ username }}" required autocomplete="username"
|
||||||
|
{% 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>
|
||||||
|
<label for="password" class="block text-sm font-medium mb-1">Passwort</label>
|
||||||
|
<div class="relative">
|
||||||
|
<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>
|
||||||
|
<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 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="https://boardgamegeek.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="/static/powered-by-bgg.png" alt="Powered by BoardGameGeek"
|
||||||
|
width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="/login" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="username" class="block text-sm font-medium mb-1">Benutzername</label>
|
|
||||||
<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">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password" class="block text-sm font-medium mb-1">Passwort</label>
|
|
||||||
<input id="password" name="password" type="password" required autocomplete="current-password"
|
|
||||||
class="w-full border border-slate-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
|
||||||
</div>
|
|
||||||
<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">
|
|
||||||
Anmelden
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<a href="https://boardgamegeek.com" target="_blank" rel="noopener noreferrer">
|
|
||||||
<img src="/static/powered-by-bgg.png" alt="Powered by BoardGameGeek"
|
|
||||||
width="120" height="35" class="inline-block opacity-80 hover:opacity-100">
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
61
src/redaktionskern/templates/seitennavigation.html
Normal file
61
src/redaktionskern/templates/seitennavigation.html
Normal 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">…</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">…</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 %}
|
||||||
26
src/redaktionskern/templates/status.html
Normal file
26
src/redaktionskern/templates/status.html
Normal 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 %}
|
||||||
@@ -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 "🎲" 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):
|
||||||
|
|||||||
Reference in New Issue
Block a user