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

12 KiB
Raw Blame History

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


Setup

# 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:

<!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

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:

# 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.

<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:

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", {})
<!-- 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

<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

<!-- 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)

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:

<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

<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>
@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)

<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

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

# 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,
    })
{% 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