feat: CRM-Dashboard, Content-Verwaltung und Wort-Autocomplete

- Home-Seite nach Login mit Begrüßung und 3 Kacheln (Content erstellen, Content verwalten, User verwalten)
- AuthContext speichert User-Profil + Rolle; AdminRoute blockt Nicht-Admins
- Content verwalten (admin-only): Status-Dashboard pro Collection, Liste/Kachel-View, generisches Edit-Formular
- Nur aktive db_-Collections im Dashboard (alte pictures/objects/words/questions entfernt)
- Wort-Autocomplete in DrawIt: ab dem ersten Buchstaben Vorschläge aus db_words, Tastatur-Navigation, Duplikat-Filter
- Backend: /users/me Proxy, db-words/search Endpoint, generische Collection-Endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 23:37:48 +02:00
parent 05c62ac414
commit d02788bd0e
13 changed files with 1962 additions and 23 deletions

184
app.py
View File

@@ -71,6 +71,15 @@ def directus_auth_login():
return jsonify(data), status
@app.route("/api/directus/users/me", methods=["GET"])
def directus_users_me():
"""Proxy: aktueller User inkl. Rolle (für Begrüßung + Admin-Check)."""
token = request.headers.get("Authorization", "")
fields = "id,first_name,last_name,email,role.id,role.name,role.admin_access"
data, status = _directus("GET", f"/users/me?fields={fields}", token)
return jsonify(data), status
@app.route("/api/directus/pictures", methods=["GET"])
def directus_pictures():
"""Proxy: Directus-Bilder nach Status filtern."""
@@ -2111,6 +2120,181 @@ def directus_db_pair(pair_id):
return jsonify({"ok": s in (200, 204)})
@app.route("/api/directus/db-words/search", methods=["GET"])
def directus_db_words_search():
"""Schlanke Suche in db_words für Autocomplete.
Liefert id, titel_de, level — case-insensitive contains, sortiert alphabetisch."""
token = request.headers.get("Authorization", "")
q = (request.args.get("q") or "").strip()
try:
limit = max(1, min(int(request.args.get("limit", "10")), 50))
except ValueError:
limit = 10
if not q:
return jsonify({"data": []}), 200
qs = (
"fields=id,titel_de,level"
f"&filter[titel_de][_icontains]={urllib.parse.quote(q)}"
"&sort=titel_de"
f"&limit={limit}"
)
data, status = _directus("GET", f"/items/db_words?{qs}", token)
return jsonify({"data": data.get("data") or []}), status
# =====================================================
# CONTENT-MANAGEMENT DASHBOARD (admin)
# =====================================================
# Allowlist: nur diese Collections sind über die generischen
# Management-Endpoints erreichbar. Erweiterbar.
DASHBOARD_COLLECTIONS = [
{"name": "db_pictures", "label": "Bilder", "kind": "image", "group": "neu",
"fields": "id,media,status,date_created,design", "preview": "media", "title_field": None},
{"name": "db_objects", "label": "Objekte", "kind": "image", "group": "neu",
"fields": "id,picture,status,date_created,user_notes", "preview": None, "title_field": "user_notes"},
{"name": "db_words", "label": "Wörter", "kind": "text", "group": "neu",
"fields": "id,titel_de,level,status,date_created", "preview": None, "title_field": "titel_de"},
{"name": "db_question", "label": "Fragen", "kind": "text", "group": "neu",
"fields": "id,question_de,level,status,date_created", "preview": None, "title_field": "question_de"},
{"name": "db_statement", "label": "Statements", "kind": "text", "group": "neu",
"fields": "id,statement_de,level,status,date_created", "preview": None, "title_field": "statement_de"},
{"name": "db_pairs", "label": "Q&A-Paare", "kind": "text", "group": "neu",
"fields": "id,level,status,date_created", "preview": None, "title_field": None},
]
DASHBOARD_BY_NAME = {c["name"]: c for c in DASHBOARD_COLLECTIONS}
def _collection_config_or_404(name):
cfg = DASHBOARD_BY_NAME.get(name)
if not cfg:
return None, (jsonify({"error": "Unbekannte Collection"}), 404)
return cfg, None
@app.route("/api/directus/dashboard/summary", methods=["GET"])
def directus_dashboard_summary():
"""Liefert pro Collection Total + Status-Counts. Admin-Token vom User."""
token = request.headers.get("Authorization", "")
out = []
for cfg in DASHBOARD_COLLECTIONS:
entry = {
"name": cfg["name"],
"label": cfg["label"],
"kind": cfg["kind"],
"group": cfg["group"],
"total": 0,
"by_status": [],
"has_status": False,
"error": None,
}
# 1) Total-Count via aggregate
data, status = _directus("GET",
f"/items/{cfg['name']}?aggregate[count]=*", token)
if status == 200:
try:
entry["total"] = int(((data.get("data") or [{}])[0] or {}).get("count", 0) or 0)
except (TypeError, ValueError):
entry["total"] = 0
else:
entry["error"] = (data.get("errors") or [{}])[0].get("message") or f"HTTP {status}"
# 2) Status-Groups (kann fehlschlagen, wenn Feld fehlt → ignorieren)
data2, status2 = _directus("GET",
f"/items/{cfg['name']}?aggregate[count]=*&groupBy[]=status&limit=-1", token)
if status2 == 200:
rows = data2.get("data") or []
grouped = []
for r in rows:
if not isinstance(r, dict):
continue
grouped.append({
"status": r.get("status"),
"count": int((r.get("count") or {}).get("id", 0) if isinstance(r.get("count"), dict) else r.get("count") or 0),
})
grouped.sort(key=lambda x: -x["count"])
entry["by_status"] = grouped
entry["has_status"] = any(g.get("status") is not None for g in grouped)
out.append(entry)
return jsonify({"data": out}), 200
@app.route("/api/directus/collection/<name>", methods=["GET"])
def directus_collection_list(name):
"""Generische Liste mit optionalem Status-Filter + Pagination."""
cfg, err = _collection_config_or_404(name)
if err:
return err
token = request.headers.get("Authorization", "")
status_filter = request.args.get("status", "").strip()
limit = request.args.get("limit", "50")
offset = request.args.get("offset", "0")
sort = request.args.get("sort", "-date_created")
parts = [f"fields={cfg['fields']}", f"limit={limit}", f"offset={offset}", f"sort={sort}"]
if status_filter:
parts.append(f"filter[status][_eq]={urllib.parse.quote(status_filter)}")
query = "&".join(parts)
data, status = _directus("GET", f"/items/{name}?{query}", token)
# Total für aktuellen Filter
count_parts = ["aggregate[count]=*"]
if status_filter:
count_parts.append(f"filter[status][_eq]={urllib.parse.quote(status_filter)}")
count_data, _ = _directus("GET", f"/items/{name}?{'&'.join(count_parts)}", token)
total = 0
try:
total = int(((count_data.get("data") or [{}])[0] or {}).get("count", 0) or 0)
except (TypeError, ValueError):
pass
return jsonify({
"data": data.get("data") or [],
"meta": {"total": total, "limit": int(limit), "offset": int(offset)},
"collection": {
"name": cfg["name"],
"label": cfg["label"],
"kind": cfg["kind"],
"preview": cfg.get("preview"),
"title_field": cfg.get("title_field"),
},
}), status
@app.route("/api/directus/collection/<name>/<item_id>", methods=["GET", "PATCH"])
def directus_collection_item(name, item_id):
"""Eintrag laden oder aktualisieren."""
cfg, err = _collection_config_or_404(name)
if err:
return err
token = request.headers.get("Authorization", "")
if request.method == "GET":
data, status = _directus("GET", f"/items/{name}/{item_id}", token)
return jsonify({
"data": data.get("data"),
"collection": {
"name": cfg["name"],
"label": cfg["label"],
"kind": cfg["kind"],
"preview": cfg.get("preview"),
"title_field": cfg.get("title_field"),
},
}), status
# PATCH
body = request.get_json(force=True, silent=True) or {}
data, status = _directus("PATCH", f"/items/{name}/{item_id}", token, body)
return jsonify(data), status
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True)