hugin/static/css-framework/.opencode/skills/references/jinja2.md
2026-10-27 08:33:21 +01:00

417 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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