417 lines
12 KiB
Markdown
417 lines
12 KiB
Markdown
# Jinja2 / FastAPI — Referenz
|
||
|
||
Referenz für den Einsatz von CSS-Framework in FastAPI-Apps mit Jinja2-Templates und HTMX.
|
||
Schnellreferenz für Komponenten und Tokens: [SKILL.md](../SKILL.md)
|
||
|
||
---
|
||
|
||
## Setup
|
||
|
||
```python
|
||
# main.py
|
||
from fastapi import FastAPI
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
from starlette.middleware.sessions import SessionMiddleware
|
||
|
||
app = FastAPI()
|
||
app.add_middleware(SessionMiddleware, secret_key="...")
|
||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||
templates = Jinja2Templates(directory="templates")
|
||
```
|
||
|
||
Statische Dateien werden direkt über den Pfad `/static/...` referenziert — kein `url_for` im Template nötig.
|
||
|
||
---
|
||
|
||
## Basis-Template
|
||
|
||
`templates/base.html`:
|
||
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="de" data-theme="light">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{% block title %}App{% endblock %}</title>
|
||
|
||
<!-- Theme vor dem Rendern setzen — verhindert FOUC, ohne Inline-Skript (CSP) -->
|
||
<script src="/static/js/theme.js"></script>
|
||
|
||
<!-- HTMX ohne eigene Inline-Styles und ohne eval (CSP) -->
|
||
<meta name="htmx-config"
|
||
content='{"includeIndicatorStyles": false, "allowEval": false, "allowScriptTags": false}'>
|
||
|
||
<link rel="stylesheet" href="/static/css/main.css">
|
||
{% block head %}{% endblock %}
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Mobile Topbar -->
|
||
<div class="mobile-topbar">
|
||
<button data-action="sidebar-open" class="btn btn-ghost btn-icon">☰</button>
|
||
<span>{% block mobile_title %}App{% endblock %}</span>
|
||
</div>
|
||
|
||
<div id="sidebar-backdrop" class="sidebar-backdrop" data-action="sidebar-close"></div>
|
||
|
||
<div class="with-sidebar">
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="sidebar-header">
|
||
<a href="/" class="sidebar-brand">App</a>
|
||
</div>
|
||
<nav class="sidebar-nav">
|
||
{% block sidebar %}
|
||
<div class="sidebar-section">
|
||
<p class="sidebar-section-title">Navigation</p>
|
||
<a href="/"
|
||
class="sidebar-link {% if request.url.path == '/' %}active{% endif %}">
|
||
Übersicht
|
||
</a>
|
||
<a href="/einstellungen"
|
||
class="sidebar-link {% if request.url.path.startswith('/einstellungen') %}active{% endif %}">
|
||
Einstellungen
|
||
</a>
|
||
</div>
|
||
{% endblock %}
|
||
</nav>
|
||
</aside>
|
||
|
||
<main class="stack stack-lg min-w-0">
|
||
<!-- Flash Messages (via SessionMiddleware) -->
|
||
{% for msg in request.session.pop('_messages', []) %}
|
||
<div class="alert alert-{{ msg.category }}">
|
||
<div class="alert-content"><p>{{ msg.text }}</p></div>
|
||
<button class="alert-dismiss" data-action="alert-dismiss">✕</button>
|
||
</div>
|
||
{% endfor %}
|
||
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Toast Container -->
|
||
<div class="toast-container" id="toast-container"></div>
|
||
|
||
<!-- HTMX -->
|
||
<script src="/static/js/htmx.min.js" defer></script>
|
||
|
||
<!-- Framework JS: data-action und globale Funktionen -->
|
||
<script src="/static/js/framework.js" defer></script>
|
||
|
||
{% block scripts %}{% endblock %}
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
---
|
||
|
||
## Template rendern
|
||
|
||
```python
|
||
from fastapi import Request
|
||
from fastapi.responses import HTMLResponse
|
||
|
||
@router.get("/", response_class=HTMLResponse)
|
||
async def index(request: Request):
|
||
return templates.TemplateResponse(request, "index.html", {
|
||
"items": await get_items()
|
||
})
|
||
```
|
||
|
||
`request` wird immer explizit übergeben — Jinja2Templates macht ihn im Template als `request` verfügbar.
|
||
|
||
---
|
||
|
||
## Flash Messages
|
||
|
||
FastAPI hat kein eingebautes Flash-System. Einfache Session-Lösung mit `SessionMiddleware`:
|
||
|
||
```python
|
||
# Helfer-Funktion
|
||
def flash(request: Request, text: str, category: str = "info"):
|
||
messages = request.session.setdefault('_messages', [])
|
||
messages.append({'text': text, 'category': category})
|
||
|
||
# In einer Route
|
||
@router.post("/profil")
|
||
async def profil_speichern(request: Request):
|
||
flash(request, 'Profil aktualisiert', 'success')
|
||
flash(request, 'Ungültige Eingabe', 'danger')
|
||
flash(request, 'Bitte bestätigen', 'warning')
|
||
return RedirectResponse("/profil", status_code=303)
|
||
```
|
||
|
||
Im Template werden die Messages über `request.session.pop('_messages', [])` gelesen (siehe Basis-Template oben).
|
||
|
||
---
|
||
|
||
## HTMX-Muster
|
||
|
||
### Button Loading State
|
||
|
||
`hx-disabled-elt="this"` sperrt den Button; `.htmx-indicator` blendet den Spinner ein solange die Anfrage läuft.
|
||
|
||
```html
|
||
<button class="btn btn-primary"
|
||
hx-post="/api/speichern"
|
||
hx-target="#ergebnis"
|
||
hx-swap="outerHTML"
|
||
hx-disabled-elt="this">
|
||
<span class="htmx-indicator spinner spinner-sm"></span>
|
||
Speichern
|
||
</button>
|
||
<div id="ergebnis"></div>
|
||
```
|
||
|
||
### Formular mit Inline-Validation (422)
|
||
|
||
FastAPI antwortet bei Fehlern mit HTTP 422 und gibt das Formular-Partial zurück:
|
||
|
||
```python
|
||
from fastapi import Form, status
|
||
from fastapi.responses import HTMLResponse
|
||
|
||
@router.post("/api/formular", response_class=HTMLResponse)
|
||
async def formular(
|
||
request: Request,
|
||
email: str = Form(...)
|
||
):
|
||
errors = {}
|
||
if "@" not in email:
|
||
errors["email"] = "Ungültige E-Mail-Adresse"
|
||
|
||
if errors:
|
||
return templates.TemplateResponse(
|
||
request, "_form.html",
|
||
{"form": {"email": email}, "errors": errors},
|
||
status_code=422
|
||
)
|
||
# Erfolg
|
||
return templates.TemplateResponse(request, "_form_success.html", {})
|
||
```
|
||
|
||
```html
|
||
<!-- templates/_form.html — wird per hx-swap="outerHTML" ersetzt -->
|
||
<form hx-post="/api/formular"
|
||
hx-swap="outerHTML"
|
||
id="mein-formular">
|
||
<div class="form-group">
|
||
<label for="email">E-Mail</label>
|
||
<input type="email" id="email" name="email"
|
||
value="{{ form.email or '' }}"
|
||
class="{{ 'error' if errors and errors.email }}">
|
||
{% if errors and errors.email %}
|
||
<span class="form-error">{{ errors.email }}</span>
|
||
{% endif %}
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">
|
||
<span class="htmx-indicator spinner spinner-sm"></span>
|
||
Absenden
|
||
</button>
|
||
</form>
|
||
```
|
||
|
||
### Skeleton als Ladeindikator
|
||
|
||
```html
|
||
<div hx-get="/api/tabelle" hx-trigger="load" hx-swap="outerHTML">
|
||
<div class="stack">
|
||
<div class="skeleton skeleton-title"></div>
|
||
<div class="skeleton skeleton-line"></div>
|
||
<div class="skeleton skeleton-line is-short"></div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### Modal via HTMX befüllen
|
||
|
||
```html
|
||
<!-- Auslöser (z.B. in einer Tabellenzeile) -->
|
||
<button class="btn btn-ghost btn-sm"
|
||
hx-get="/api/detail/{{ item.id }}"
|
||
hx-target="#modal-body"
|
||
hx-swap="innerHTML"
|
||
data-action="modal-open" data-target="detail-modal">
|
||
Details
|
||
</button>
|
||
|
||
<!-- Modal-Gerüst — einmalig im Template -->
|
||
<div class="modal-backdrop" id="detail-modal"
|
||
data-action="modal-backdrop">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h3>Detail</h3>
|
||
<button class="btn btn-ghost btn-icon btn-sm"
|
||
data-action="modal-close" data-target="detail-modal">✕</button>
|
||
</div>
|
||
<div class="modal-body" id="modal-body">
|
||
<div class="skeleton skeleton-block"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### Toast über Server-Response (HX-Trigger)
|
||
|
||
```python
|
||
import json
|
||
from fastapi.responses import HTMLResponse
|
||
|
||
@router.post("/api/speichern", response_class=HTMLResponse)
|
||
async def speichern(request: Request):
|
||
content = templates.TemplateResponse(request, "_partial.html", {})
|
||
content.headers['HX-Trigger'] = json.dumps({
|
||
'showToast': {'message': 'Gespeichert', 'type': 'success'}
|
||
})
|
||
return content
|
||
```
|
||
|
||
Listener im `<body>` des Basis-Templates:
|
||
|
||
```html
|
||
<body hx-on::after-request="
|
||
var h = event.detail.xhr.getResponseHeader('HX-Trigger');
|
||
if (h) {
|
||
try {
|
||
var t = JSON.parse(h);
|
||
if (t.showToast) showToast(t.showToast.message, t.showToast.type);
|
||
} catch(e) {}
|
||
}
|
||
">
|
||
```
|
||
|
||
### Live-Suche
|
||
|
||
```html
|
||
<input type="search"
|
||
name="q"
|
||
placeholder="Suchen…"
|
||
hx-get="/api/suche"
|
||
hx-trigger="input changed delay:300ms"
|
||
hx-target="#ergebnisse"
|
||
hx-swap="innerHTML">
|
||
<div id="ergebnisse"></div>
|
||
```
|
||
|
||
```python
|
||
@router.get("/api/suche", response_class=HTMLResponse)
|
||
async def suche(request: Request, q: str = ""):
|
||
results = await search_items(q)
|
||
return templates.TemplateResponse(request, "_ergebnisse.html", {"results": results})
|
||
```
|
||
|
||
### Unendliches Nachladen (Intersection Observer)
|
||
|
||
```html
|
||
<div id="liste">
|
||
{% for item in items %}
|
||
<!-- Zeile -->
|
||
{% endfor %}
|
||
|
||
{% if has_more %}
|
||
<div hx-get="/api/liste?page={{ next_page }}"
|
||
hx-trigger="intersect once"
|
||
hx-target="#liste"
|
||
hx-swap="beforeend">
|
||
<div class="cluster justify-center p-4">
|
||
<div class="spinner"></div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
## Formular-Validation mit Pydantic
|
||
|
||
```python
|
||
from pydantic import BaseModel, EmailStr, ValidationError
|
||
|
||
class ProfilForm(BaseModel):
|
||
name: str
|
||
email: EmailStr
|
||
|
||
@router.post("/api/profil", response_class=HTMLResponse)
|
||
async def profil_speichern(
|
||
request: Request,
|
||
name: str = Form(...),
|
||
email: str = Form(...)
|
||
):
|
||
errors = {}
|
||
try:
|
||
ProfilForm(name=name, email=email)
|
||
except ValidationError as e:
|
||
for err in e.errors():
|
||
errors[err['loc'][0]] = err['msg']
|
||
|
||
if errors:
|
||
return templates.TemplateResponse(
|
||
request, "_profil_form.html",
|
||
{"form": {"name": name, "email": email}, "errors": errors},
|
||
status_code=422
|
||
)
|
||
|
||
await update_profil(name=name, email=email)
|
||
flash(request, 'Profil aktualisiert', 'success')
|
||
return RedirectResponse("/profil", status_code=303)
|
||
```
|
||
|
||
---
|
||
|
||
## Pagination
|
||
|
||
```python
|
||
# Route
|
||
@router.get("/liste", response_class=HTMLResponse)
|
||
async def liste(request: Request, page: int = 1, per_page: int = 20):
|
||
total = await count_items()
|
||
items = await get_items(offset=(page - 1) * per_page, limit=per_page)
|
||
return templates.TemplateResponse(request, "liste.html", {
|
||
"items": items,
|
||
"page": page,
|
||
"per_page": per_page,
|
||
"total": total,
|
||
"pages": (total + per_page - 1) // per_page,
|
||
})
|
||
```
|
||
|
||
```html
|
||
{% if pages > 1 %}
|
||
<nav class="pagination">
|
||
{% if page > 1 %}
|
||
<a class="page-item" href="?page={{ page - 1 }}">‹</a>
|
||
{% endif %}
|
||
|
||
{% for p in range(1, pages + 1) %}
|
||
<a class="page-item {{ 'active' if p == page }}"
|
||
href="?page={{ p }}">{{ p }}</a>
|
||
{% endfor %}
|
||
|
||
{% if page < pages %}
|
||
<a class="page-item" href="?page={{ page + 1 }}">›</a>
|
||
{% endif %}
|
||
</nav>
|
||
{% endif %}
|
||
```
|
||
|
||
---
|
||
|
||
## Dos & Don'ts
|
||
|
||
**Do:**
|
||
- Farben und Abstände ausschließlich über Tokens: `var(--accent)`, `var(--space-4)` usw.
|
||
- Semantisches HTML classless einsetzen: `<h2>`, `<p>`, `<table>` brauchen keine Klassen
|
||
- `request` immer explizit an `TemplateResponse` übergeben — Starlette macht ihn im Template verfügbar
|
||
- HTMX-Attribute direkt auf Framework-Elemente — kein Wrapper-`<div>` nötig
|
||
- Globale JS-Funktionen nutzen (`openModal`, `showToast`, `sortTable` usw.)
|
||
|
||
**Don't:**
|
||
- Keine `style`-Attribute, keine `on…`-Attribute, kein Inline-`<script>`: Die App läuft mit strenger CSP. Klassen statt Styles (`style="color:#333"` → `class="text-muted"`), `data-action` statt `onclick`, eigene Skripte als Datei
|
||
- Variable Breiten (Fortschritt) über `data-progress="75"`, nicht über `style`
|
||
- Kein `hx-on` (braucht `eval`)
|
||
- Kein paralleles Einbinden von Tailwind oder anderem Utility-Framework
|
||
- Keine neuen CSS-Custom-Properties die nicht in `tokens.css` des Frameworks stehen
|
||
- `main.css` nicht verändern — nur per `<link>` einbinden
|