diff --git a/app.py b/app.py index 1e815d7..c4e9b3e 100644 --- a/app.py +++ b/app.py @@ -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/", 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//", 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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d38e25..eb1002b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,11 @@ import { Routes, Route, Navigate } from 'react-router-dom' import DrawIt from './pages/DrawIt' import ExpandIt from './pages/ExpandIt' import GenerateIt from './pages/GenerateIt' +import Home from './pages/Home' import Login from './pages/Login' +import ContentManage from './pages/ContentManage' +import ContentList from './pages/ContentList' +import ContentEdit from './pages/ContentEdit' import { useAuth } from './context/AuthContext' import { ThemeProvider } from './context/ThemeContext' import type { ReactNode } from 'react' @@ -12,15 +16,25 @@ function PrivateRoute({ children }: { children: ReactNode }) { return token ? <>{children} : } +function AdminRoute({ children }: { children: ReactNode }) { + const { token, isAdmin } = useAuth() + if (!token) return + if (!isAdmin) return + return <>{children} +} + export default function App() { return ( } /> - } /> + } /> } /> } /> } /> + } /> + } /> + } /> ) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5c957cb..2cddf74 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -13,6 +13,27 @@ export async function directusLogin(email: string, password: string): Promise { + const res = await fetch('/api/directus/users/me', { + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json() + if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Laden des Profils') + return data.data as DirectusMe +} + export interface DirectusPicture { id: string media: string @@ -566,3 +587,116 @@ export async function deleteDbPair(pairId: string, token: string): Promise }) if (!res.ok) throw new Error('Fehler beim Löschen des Pairs') } + +export interface DbWordSearchResult { + id: string + titel_de: string + level: number | null +} + +export async function searchDbWords(q: string, token: string, limit = 10): Promise { + const query = q.trim() + if (!query) return [] + const res = await fetch( + `/api/directus/db-words/search?q=${encodeURIComponent(query)}&limit=${limit}`, + { headers: { Authorization: `Bearer ${token}` } }, + ) + const data = await res.json() + if (!res.ok) return [] + return (data.data || []) as DbWordSearchResult[] +} + +// ── Content-Management Dashboard ───────────────────────────────────────────── + +export type CollectionKind = 'image' | 'text' + +export interface CollectionMeta { + name: string + label: string + kind: CollectionKind + preview?: string | null + title_field?: string | null +} + +export interface CollectionStatusGroup { + status: string | null + count: number +} + +export interface DashboardEntry { + name: string + label: string + kind: CollectionKind + group: 'alt' | 'neu' + total: number + by_status: CollectionStatusGroup[] + has_status: boolean + error: string | null +} + +export async function getDashboardSummary(token: string): Promise { + const res = await fetch('/api/directus/dashboard/summary', { + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json() + if (!res.ok) throw new Error(data.errors?.[0]?.message || 'Fehler beim Laden des Dashboards') + return (data.data || []) as DashboardEntry[] +} + +export interface CollectionListResponse { + data: Record[] + meta: { total: number; limit: number; offset: number } + collection: CollectionMeta +} + +export async function getCollectionItems( + name: string, + opts: { status?: string; limit?: number; offset?: number }, + token: string, +): Promise { + const qs = new URLSearchParams() + if (opts.status) qs.set('status', opts.status) + if (opts.limit !== undefined) qs.set('limit', String(opts.limit)) + if (opts.offset !== undefined) qs.set('offset', String(opts.offset)) + const res = await fetch(`/api/directus/collection/${encodeURIComponent(name)}?${qs.toString()}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json() + if (!res.ok) throw new Error(data.errors?.[0]?.message || data.error || 'Fehler beim Laden') + return data as CollectionListResponse +} + +export interface CollectionItemResponse { + data: Record | null + collection: CollectionMeta +} + +export async function getCollectionItem( + name: string, + id: string, + token: string, +): Promise { + const res = await fetch(`/api/directus/collection/${encodeURIComponent(name)}/${encodeURIComponent(id)}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json() + if (!res.ok) throw new Error(data.errors?.[0]?.message || data.error || 'Fehler beim Laden') + return data as CollectionItemResponse +} + +export async function updateCollectionItem( + name: string, + id: string, + payload: Record, + token: string, +): Promise { + const res = await fetch(`/api/directus/collection/${encodeURIComponent(name)}/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.errors?.[0]?.message || data.error || 'Fehler beim Aktualisieren') + } +} diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 96e8d05..2835112 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -35,7 +35,7 @@ const CrosshairIcon = () => ( ) interface TopbarProps { - page: 'draw' | 'generate' | 'expand' + page: 'home' | 'draw' | 'generate' | 'expand' center?: ReactNode } @@ -46,12 +46,17 @@ export default function Topbar({ page, center }: TopbarProps) { return (
-
+
+