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)

View File

@@ -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}</> : <Navigate to="/login" replace />
}
function AdminRoute({ children }: { children: ReactNode }) {
const { token, isAdmin } = useAuth()
if (!token) return <Navigate to="/login" replace />
if (!isAdmin) return <Navigate to="/" replace />
return <>{children}</>
}
export default function App() {
return (
<ThemeProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/draw" replace />} />
<Route path="/" element={<PrivateRoute><Home /></PrivateRoute>} />
<Route path="/draw" element={<PrivateRoute><DrawIt /></PrivateRoute>} />
<Route path="/generate" element={<PrivateRoute><GenerateIt /></PrivateRoute>} />
<Route path="/expand" element={<PrivateRoute><ExpandIt /></PrivateRoute>} />
<Route path="/content" element={<AdminRoute><ContentManage /></AdminRoute>} />
<Route path="/content/:name" element={<AdminRoute><ContentList /></AdminRoute>} />
<Route path="/content/:name/:id" element={<AdminRoute><ContentEdit /></AdminRoute>} />
</Routes>
</ThemeProvider>
)

View File

@@ -13,6 +13,27 @@ export async function directusLogin(email: string, password: string): Promise<st
return data.data.access_token
}
export interface DirectusMe {
id: string
first_name: string | null
last_name: string | null
email: string | null
role: {
id: string
name: string
admin_access: boolean
} | null
}
export async function getDirectusMe(token: string): Promise<DirectusMe> {
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<void>
})
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<DbWordSearchResult[]> {
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<DashboardEntry[]> {
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<string, unknown>[]
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<CollectionListResponse> {
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<string, unknown> | null
collection: CollectionMeta
}
export async function getCollectionItem(
name: string,
id: string,
token: string,
): Promise<CollectionItemResponse> {
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<string, unknown>,
token: string,
): Promise<void> {
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')
}
}

View File

@@ -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 (
<header className="topbar">
<div className="topbar-brand">
<button
type="button"
className="topbar-brand"
onClick={() => navigate('/')}
title="Zur Startseite"
>
<span className="topbar-logo-icon">
<CrosshairIcon />
</span>
<span className="topbar-brand-name">Content Mentor</span>
</div>
</button>
<nav className="topbar-nav">
<button

View File

@@ -0,0 +1,157 @@
import { useEffect, useRef, useState } from 'react'
import { searchDbWords, type DbWordSearchResult } from '../api'
interface WordAutocompleteProps {
value: string
onChange: (v: string) => void
onSubmit: () => void
onPick?: (word: DbWordSearchResult) => void
token: string | null
placeholder?: string
className?: string
inputStyle?: React.CSSProperties
minChars?: number
debounceMs?: number
excludeTitles?: string[]
}
export default function WordAutocomplete({
value,
onChange,
onSubmit,
onPick,
token,
placeholder,
className,
inputStyle,
minChars = 1,
debounceMs = 180,
excludeTitles = [],
}: WordAutocompleteProps) {
const [results, setResults] = useState<DbWordSearchResult[]>([])
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [highlight, setHighlight] = useState(0)
const wrapRef = useRef<HTMLDivElement>(null)
const reqIdRef = useRef(0)
const excludeSet = new Set(excludeTitles.map(s => s.trim().toLowerCase()))
// Debounced search
useEffect(() => {
if (!token) return
const trimmed = value.trim()
if (trimmed.length < minChars) {
setResults([])
setOpen(false)
return
}
const myId = ++reqIdRef.current
setLoading(true)
const t = setTimeout(() => {
searchDbWords(trimmed, token, 10)
.then(r => {
if (myId !== reqIdRef.current) return
const filtered = r.filter(x => !excludeSet.has(x.titel_de.trim().toLowerCase()))
setResults(filtered)
setOpen(filtered.length > 0)
setHighlight(0)
})
.finally(() => {
if (myId === reqIdRef.current) setLoading(false)
})
}, debounceMs)
return () => clearTimeout(t)
}, [value, token, minChars, debounceMs])
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!wrapRef.current) return
if (!wrapRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [])
const pick = (r: DbWordSearchResult) => {
onChange(r.titel_de)
onPick?.(r)
setOpen(false)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (open && results.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlight(h => (h + 1) % results.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlight(h => (h - 1 + results.length) % results.length)
return
}
if (e.key === 'Escape') {
e.preventDefault()
setOpen(false)
return
}
if (e.key === 'Tab') {
e.preventDefault()
pick(results[highlight])
return
}
if (e.key === 'Enter') {
// If user has typed something matching a suggestion exactly, prefer submit; else pick.
const exact = results.find(r => r.titel_de.trim().toLowerCase() === value.trim().toLowerCase())
if (exact) {
setOpen(false)
onSubmit()
} else {
e.preventDefault()
pick(results[highlight])
}
return
}
}
if (e.key === 'Enter') {
onSubmit()
}
}
return (
<div ref={wrapRef} className={`wac-wrap${className ? ' ' + className : ''}`}>
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => { if (results.length > 0) setOpen(true) }}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
style={inputStyle}
/>
{open && (
<div className="wac-panel" role="listbox">
{loading && results.length === 0 && (
<div className="wac-empty">Suche</div>
)}
{results.map((r, i) => (
<button
key={r.id}
type="button"
role="option"
className={`wac-item${i === highlight ? ' wac-item-active' : ''}`}
onMouseDown={e => { e.preventDefault(); pick(r) }}
onMouseEnter={() => setHighlight(i)}
>
<span className="wac-item-title">{r.titel_de}</span>
{r.level != null && <span className="wac-item-meta">L{r.level}</span>}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -1,8 +1,11 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import type { DirectusMe } from '../api'
interface AuthContextType {
token: string | null
login: (token: string) => void
user: DirectusMe | null
isAdmin: boolean
login: (token: string, user: DirectusMe) => void
logout: () => void
}
@@ -10,9 +13,22 @@ const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(null)
const [user, setUser] = useState<DirectusMe | null>(null)
const login = (newToken: string, newUser: DirectusMe) => {
setToken(newToken)
setUser(newUser)
}
const logout = () => {
setToken(null)
setUser(null)
}
const isAdmin = !!user?.role?.admin_access
return (
<AuthContext.Provider value={{ token, login: setToken, logout: () => setToken(null) }}>
<AuthContext.Provider value={{ token, user, isAdmin, login, logout }}>
{children}
</AuthContext.Provider>
)

View File

@@ -132,6 +132,18 @@ body {
gap: 8px;
margin-right: 8px;
user-select: none;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font-family: inherit;
border-radius: var(--r-md);
transition: opacity 0.15s ease;
}
.topbar-brand:hover {
opacity: 0.8;
}
.topbar-logo-icon {
@@ -1317,3 +1329,746 @@ select:focus {
transform: none;
box-shadow: none;
}
/* =====================================================
HOME / DASHBOARD
===================================================== */
.home-page {
flex: 1;
overflow-y: auto;
padding: 56px 32px 64px;
display: flex;
flex-direction: column;
align-items: center;
}
.home-greeting {
text-align: center;
margin-bottom: 40px;
max-width: 720px;
}
.home-greeting h1 {
font-size: 30px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-1);
margin-bottom: 8px;
}
.home-greeting p {
font-size: 15px;
color: var(--text-3);
}
.home-tiles {
width: 100%;
max-width: 1080px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.home-tile {
position: relative;
display: flex;
align-items: flex-start;
gap: 16px;
padding: 22px 22px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-xl);
box-shadow: var(--shadow-sm);
text-align: left;
cursor: pointer;
font-family: inherit;
color: var(--text-1);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.home-tile:hover:not(.is-disabled) {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--primary);
}
.home-tile:focus-visible {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(92,108,246,.18), var(--shadow-sm);
}
.home-tile.is-disabled {
cursor: not-allowed;
opacity: 0.72;
}
.home-tile-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: var(--r-lg);
background: var(--primary-muted);
color: var(--primary-muted-fg);
display: flex;
align-items: center;
justify-content: center;
}
.home-tile-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.home-tile-title {
font-size: 16px;
font-weight: 650;
color: var(--text-1);
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.home-tile-subtitle {
font-size: 13px;
color: var(--text-3);
line-height: 1.45;
}
.home-tile-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 2px 8px;
border-radius: var(--r-full);
background: var(--primary-muted);
color: var(--primary-muted-fg);
}
.home-tile-badge-soft {
background: var(--surface-3);
color: var(--text-2);
}
.home-tile-locked {
position: absolute;
top: 14px;
right: 14px;
color: var(--text-3);
}
/* =====================================================
CONTENT MANAGEMENT (Dashboard, Liste, Edit)
===================================================== */
.cm-page {
flex: 1;
overflow-y: auto;
padding: 32px 32px 64px;
max-width: 1280px;
width: 100%;
margin: 0 auto;
}
.cm-header,
.cm-listhead {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.cm-listhead {
flex-wrap: wrap;
}
.cm-listhead-titles {
flex: 1;
min-width: 0;
}
.cm-listhead-actions {
display: flex;
align-items: center;
gap: 10px;
}
.cm-title {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-1);
}
.cm-subtitle {
font-size: 13.5px;
color: var(--text-3);
margin-top: 4px;
}
.cm-section { margin-bottom: 32px; }
.cm-section-title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
margin-bottom: 12px;
}
.cm-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.cm-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 14px 16px 16px;
box-shadow: var(--shadow-xs);
display: flex;
flex-direction: column;
gap: 12px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.cm-card:hover { border-color: var(--primary); box-shadow: var(--shadow-sm); }
.cm-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
color: var(--text-1);
text-align: left;
}
.cm-card-titlewrap {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.cm-card-label {
font-size: 15px;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-card-total {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--primary);
flex-shrink: 0;
}
.cm-kind {
display: inline-block;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: var(--r-full);
width: fit-content;
}
.cm-kind-image {
background: rgba(92, 108, 246, 0.12);
color: var(--primary-muted-fg);
}
.cm-kind-text {
background: var(--surface-3);
color: var(--text-2);
}
.cm-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cm-pills-muted { opacity: 0.7; }
.cm-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-full);
padding: 3px 10px 3px 8px;
font-size: 12px;
color: var(--text-2);
cursor: pointer;
font-family: inherit;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.cm-pill:hover {
background: var(--surface-3);
border-color: var(--primary);
color: var(--text-1);
}
.cm-pill-static { cursor: default; }
.cm-pill-static:hover { background: var(--surface-2); border-color: var(--border); color: var(--text-2); }
.cm-pill-label {
text-transform: lowercase;
font-weight: 500;
}
.cm-pill-count {
background: var(--surface);
color: var(--text-1);
border: 1px solid var(--border);
padding: 0 6px;
border-radius: var(--r-full);
font-size: 11px;
font-weight: 600;
margin-left: 2px;
}
.cm-pill-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-3);
}
.cm-pill-dot-draft { background: #94a3b8; }
.cm-pill-dot-new { background: #5C6CF6; }
.cm-pill-dot-published { background: #059669; }
.cm-pill-dot-publish { background: #059669; }
.cm-pill-dot-ready { background: #059669; }
.cm-pill-dot-rejected { background: #DC2626; }
.cm-pill-dot-archived { background: #6b7280; }
.cm-pill-dot-pending { background: #D97706; }
.cm-pill-dot-review { background: #D97706; }
.cm-pill-dot-unknown { background: #d1d5db; }
.cm-empty {
padding: 40px;
text-align: center;
color: var(--text-3);
font-size: 14px;
}
.cm-error {
padding: 12px 14px;
background: var(--danger-bg);
border: 1px solid var(--danger);
color: var(--danger);
border-radius: var(--r-md);
margin-bottom: 12px;
font-size: 13px;
}
.cm-info {
padding: 10px 14px;
background: var(--success-bg);
border: 1px solid var(--success);
color: var(--success);
border-radius: var(--r-md);
margin-bottom: 12px;
font-size: 13px;
}
.cm-card-error {
font-size: 12px;
color: var(--danger);
}
.cm-input {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 7px 11px;
font-size: 13px;
color: var(--text-1);
font-family: inherit;
outline: none;
min-width: 220px;
transition: border-color 0.12s, box-shadow 0.12s;
}
.cm-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(92,108,246,.14);
}
.cm-view-toggle {
display: inline-flex;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 2px;
gap: 2px;
}
.cm-view-btn {
background: transparent;
border: none;
padding: 5px 10px;
border-radius: 6px;
color: var(--text-2);
cursor: pointer;
display: inline-flex;
align-items: center;
font-family: inherit;
transition: background 0.12s ease, color 0.12s ease;
}
.cm-view-btn.active {
background: var(--surface);
color: var(--text-1);
box-shadow: var(--shadow-xs);
}
.cm-view-btn:hover:not(.active) {
background: var(--surface-3);
color: var(--text-1);
}
.cm-itemgrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
}
.cm-tile-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
padding: 0;
cursor: pointer;
font-family: inherit;
text-align: left;
display: flex;
flex-direction: column;
transition: transform 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease;
}
.cm-tile-item:hover {
transform: translateY(-2px);
border-color: var(--primary);
box-shadow: var(--shadow-sm);
}
.cm-tile-thumb {
aspect-ratio: 1 / 1;
background: var(--surface-3);
display: flex;
align-items: center;
justify-content: center;
}
.cm-tile-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cm-tile-placeholder {
font-size: 32px;
color: var(--text-3);
}
.cm-tile-meta {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.cm-tile-title {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-tile-status {
font-size: 11.5px;
color: var(--text-3);
}
.cm-tablewrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
overflow-x: auto;
}
.cm-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.cm-table th,
.cm-table td {
padding: 9px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.cm-table thead th {
background: var(--surface-2);
font-weight: 650;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
}
.cm-table tbody tr {
cursor: pointer;
transition: background 0.1s ease;
}
.cm-table tbody tr:hover {
background: var(--surface-2);
}
.cm-table tbody tr:last-child td {
border-bottom: none;
}
.cm-td-id {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
color: var(--text-3);
font-size: 12px;
}
.cm-td-arrow {
text-align: right;
color: var(--text-3);
font-size: 18px;
}
.cm-pager {
margin-top: 18px;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.cm-pager-info {
color: var(--text-3);
font-size: 13px;
}
.cm-edit {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 20px;
}
.cm-edit:has(.cm-edit-preview) {
grid-template-columns: 320px minmax(0, 1fr);
}
.cm-edit-preview img {
width: 100%;
border-radius: var(--r-md);
border: 1px solid var(--border);
}
.cm-edit-fields {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.cm-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.cm-field label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
letter-spacing: 0.02em;
}
.cm-field-label {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
text-transform: none;
letter-spacing: 0;
}
.cm-field-readonly {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-3);
background: var(--surface-3);
padding: 1px 6px;
border-radius: var(--r-full);
}
.cm-field input,
.cm-field textarea {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 8px 11px;
font-size: 13px;
color: var(--text-1);
font-family: inherit;
outline: none;
transition: border-color 0.12s, box-shadow 0.12s;
width: 100%;
}
.cm-field textarea {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12.5px;
resize: vertical;
}
.cm-field input:focus,
.cm-field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(92,108,246,.14);
}
.cm-field input[readonly],
.cm-field textarea[readonly] {
opacity: 0.7;
cursor: not-allowed;
}
.cm-save {
width: auto !important;
padding: 9px 18px !important;
margin-top: 0 !important;
}
/* =====================================================
WORD AUTOCOMPLETE
===================================================== */
.wac-wrap {
position: relative;
flex: 1;
display: flex;
min-width: 0;
}
.wac-wrap input {
width: 100%;
}
.wac-panel {
position: absolute;
z-index: 60;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
padding: 4px;
max-height: 240px;
overflow-y: auto;
}
.wac-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 6px 10px;
background: transparent;
border: none;
border-radius: var(--r-sm);
cursor: pointer;
font-family: inherit;
font-size: 12.5px;
color: var(--text-1);
text-align: left;
}
.wac-item:hover,
.wac-item-active {
background: var(--primary-muted);
color: var(--primary-muted-fg);
}
.wac-item-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wac-item-meta {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-full);
padding: 1px 7px;
flex-shrink: 0;
}
.wac-item-active .wac-item-meta {
background: var(--surface);
color: var(--primary-muted-fg);
border-color: var(--primary-muted-fg);
}
.wac-empty {
padding: 10px 12px;
font-size: 12px;
color: var(--text-3);
text-align: center;
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import Topbar from '../components/Topbar'
import {
getCollectionItem,
updateCollectionItem,
directusAssetUrl,
type CollectionMeta,
} from '../api'
const READONLY_KEYS = new Set([
'id',
'date_created',
'date_updated',
'user_created',
'user_updated',
'sort',
])
const BackIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
)
export default function ContentEdit() {
const { name = '', id = '' } = useParams()
const navigate = useNavigate()
const { token } = useAuth()
const [item, setItem] = useState<Record<string, unknown> | null>(null)
const [meta, setMeta] = useState<CollectionMeta | null>(null)
const [edits, setEdits] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
useEffect(() => {
if (!token || !name || !id) return
setLoading(true)
setError(null)
getCollectionItem(name, id, token)
.then(r => {
setItem(r.data)
setMeta(r.collection)
setEdits({})
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [name, id, token])
const fields = useMemo(() => {
if (!item) return [] as string[]
return Object.keys(item).sort((a, b) => {
if (a === 'id') return -1
if (b === 'id') return 1
if (a === 'status') return -1
if (b === 'status') return 1
return a.localeCompare(b)
})
}, [item])
const previewUrl = useMemo(() => {
if (!item || !meta?.preview || !token) return null
const v = item[meta.preview]
if (typeof v === 'string' && v) return directusAssetUrl(v, token)
return null
}, [item, meta, token])
const setEdit = (key: string, value: string) => {
setEdits(prev => ({ ...prev, [key]: value }))
}
const onSave = async () => {
if (!token || !item) return
setSaving(true)
setError(null)
setInfo(null)
try {
const payload: Record<string, unknown> = {}
for (const [k, v] of Object.entries(edits)) {
const orig = item[k]
if (typeof orig === 'number') {
const n = Number(v)
payload[k] = Number.isFinite(n) ? n : v
} else if (typeof orig === 'boolean') {
payload[k] = v === 'true'
} else if (orig != null && typeof orig === 'object') {
try {
payload[k] = JSON.parse(v)
} catch {
throw new Error(`Feld "${k}" enthält kein gültiges JSON.`)
}
} else {
payload[k] = v
}
}
if (Object.keys(payload).length === 0) {
setInfo('Nichts zu speichern.')
return
}
await updateCollectionItem(name, id, payload, token)
setInfo('Gespeichert.')
// Reload
const r = await getCollectionItem(name, id, token)
setItem(r.data)
setEdits({})
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const valueFor = (k: string) => {
if (k in edits) return edits[k]
const v = item?.[k]
if (v == null) return ''
if (typeof v === 'object') return JSON.stringify(v, null, 2)
return String(v)
}
return (
<div className="app-shell">
<Topbar page="home" />
<div className="cm-page">
<div className="cm-listhead">
<button className="btn-ghost btn-sm" onClick={() => navigate(`/content/${name}`)}>
<BackIcon /> <span>Zurück zur Liste</span>
</button>
<div className="cm-listhead-titles">
<h1 className="cm-title">{meta?.label || name}</h1>
<p className="cm-subtitle">ID: <code>{id}</code></p>
</div>
<div className="cm-listhead-actions">
<button
className="auth-submit cm-save"
disabled={saving || Object.keys(edits).length === 0}
onClick={onSave}
style={{ width: 'auto' }}
>
{saving ? 'Wird gespeichert…' : 'Speichern'}
</button>
</div>
</div>
{loading && <div className="cm-empty">Wird geladen</div>}
{error && <div className="cm-error">{error}</div>}
{info && <div className="cm-info">{info}</div>}
{!loading && item && meta && (
<div className="cm-edit">
{previewUrl && (
<div className="cm-edit-preview">
<img src={previewUrl} alt={String(item.id ?? '')} />
</div>
)}
<div className="cm-edit-fields">
{fields.map(k => {
const orig = item[k]
const readonly = READONLY_KEYS.has(k)
const isJson = orig != null && typeof orig === 'object'
const v = valueFor(k)
return (
<div className="cm-field" key={k}>
<label>
<span className="cm-field-label">{k}</span>
{readonly && <span className="cm-field-readonly">read-only</span>}
</label>
{isJson || (typeof v === 'string' && v.length > 80)
? <textarea
rows={isJson ? 8 : 3}
value={v}
readOnly={readonly}
onChange={e => !readonly && setEdit(k, e.target.value)}
/>
: <input
type="text"
value={v}
readOnly={readonly}
onChange={e => !readonly && setEdit(k, e.target.value)}
/>}
</div>
)
})}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import Topbar from '../components/Topbar'
import {
getCollectionItems,
directusAssetUrl,
type CollectionListResponse,
type CollectionMeta,
} from '../api'
const PAGE_SIZE = 50
const GridIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
</svg>
)
const ListIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
)
const BackIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
)
function pickPreviewUrl(item: Record<string, unknown>, preview: string | null | undefined, token: string | null): string | null {
if (!preview || !token) return null
const v = item[preview]
if (typeof v === 'string' && v.length > 0) return directusAssetUrl(v, token)
return null
}
function pickTitle(item: Record<string, unknown>, meta: CollectionMeta): string {
if (meta.title_field) {
const v = item[meta.title_field]
if (typeof v === 'string' && v.trim()) return v.trim()
}
const id = item.id
return id != null ? `#${String(id).slice(0, 8)}` : '—'
}
export default function ContentList() {
const { name = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const { token } = useAuth()
const status = searchParams.get('status') || ''
const offsetParam = parseInt(searchParams.get('offset') || '0', 10)
const viewParam = (searchParams.get('view') as 'grid' | 'list') || null
const [resp, setResp] = useState<CollectionListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [view, setView] = useState<'grid' | 'list'>(viewParam ?? 'list')
useEffect(() => {
if (!token || !name) return
setLoading(true)
setError(null)
getCollectionItems(name, { status: status || undefined, limit: PAGE_SIZE, offset: offsetParam }, token)
.then(r => {
setResp(r)
if (!viewParam) setView(r.collection.kind === 'image' ? 'grid' : 'list')
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [name, status, offsetParam, token, viewParam])
const total = resp?.meta.total ?? 0
const items = resp?.data ?? []
const meta = resp?.collection
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const currentPage = Math.floor(offsetParam / PAGE_SIZE) + 1
const headerCols = useMemo(() => {
if (!items.length) return [] as string[]
const first = items[0]
return Object.keys(first).filter(k => k !== 'id').slice(0, 5)
}, [items])
const setOffset = (newOffset: number) => {
const sp = new URLSearchParams(searchParams)
sp.set('offset', String(newOffset))
setSearchParams(sp, { replace: false })
}
const setStatus = (s: string) => {
const sp = new URLSearchParams(searchParams)
if (s) sp.set('status', s)
else sp.delete('status')
sp.delete('offset')
setSearchParams(sp, { replace: false })
}
const toggleView = (v: 'grid' | 'list') => {
setView(v)
const sp = new URLSearchParams(searchParams)
sp.set('view', v)
setSearchParams(sp, { replace: true })
}
return (
<div className="app-shell">
<Topbar page="home" />
<div className="cm-page">
<div className="cm-listhead">
<button className="btn-ghost btn-sm" onClick={() => navigate('/content')}>
<BackIcon /> <span>Zurück</span>
</button>
<div className="cm-listhead-titles">
<h1 className="cm-title">{meta?.label || name}</h1>
<p className="cm-subtitle">
{total.toLocaleString('de-DE')} Einträge{status ? ` · Status: ${status}` : ''}
</p>
</div>
<div className="cm-listhead-actions">
<input
className="cm-input"
placeholder="Status filtern (z.B. draft)"
value={status}
onChange={e => setStatus(e.target.value)}
/>
<div className="cm-view-toggle">
<button
className={`cm-view-btn${view === 'list' ? ' active' : ''}`}
onClick={() => toggleView('list')}
title="Liste"
>
<ListIcon />
</button>
<button
className={`cm-view-btn${view === 'grid' ? ' active' : ''}`}
onClick={() => toggleView('grid')}
title="Kacheln"
>
<GridIcon />
</button>
</div>
</div>
</div>
{loading && <div className="cm-empty">Wird geladen</div>}
{error && <div className="cm-error">{error}</div>}
{!loading && !error && meta && items.length === 0 && (
<div className="cm-empty">Keine Einträge.</div>
)}
{!loading && !error && meta && items.length > 0 && view === 'grid' && (
<div className="cm-itemgrid">
{items.map(item => {
const id = String(item.id ?? '')
const url = pickPreviewUrl(item, meta.preview, token)
const title = pickTitle(item, meta)
const status = (item.status as string | undefined) || ''
return (
<button
key={id}
type="button"
className="cm-tile-item"
onClick={() => navigate(`/content/${name}/${id}`)}
>
<div className="cm-tile-thumb">
{url
? <img src={url} alt={title} loading="lazy" />
: <div className="cm-tile-placeholder">{meta.kind === 'image' ? '🖼' : '📄'}</div>}
</div>
<div className="cm-tile-meta">
<div className="cm-tile-title" title={title}>{title}</div>
{status && <div className="cm-tile-status">{status}</div>}
</div>
</button>
)
})}
</div>
)}
{!loading && !error && meta && items.length > 0 && view === 'list' && (
<div className="cm-tablewrap">
<table className="cm-table">
<thead>
<tr>
<th>ID</th>
{headerCols.map(c => <th key={c}>{c}</th>)}
<th />
</tr>
</thead>
<tbody>
{items.map(item => {
const id = String(item.id ?? '')
return (
<tr key={id} onClick={() => navigate(`/content/${name}/${id}`)}>
<td className="cm-td-id" title={id}>#{id.slice(0, 8)}</td>
{headerCols.map(c => {
const v = item[c]
const display = v == null ? '—' : (typeof v === 'object' ? JSON.stringify(v) : String(v))
return <td key={c} title={display}>{display.length > 80 ? display.slice(0, 77) + '…' : display}</td>
})}
<td className="cm-td-arrow"></td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{!loading && !error && total > PAGE_SIZE && (
<div className="cm-pager">
<button
className="btn-ghost btn-sm"
disabled={offsetParam <= 0}
onClick={() => setOffset(Math.max(0, offsetParam - PAGE_SIZE))}
>
Zurück
</button>
<span className="cm-pager-info">Seite {currentPage} / {totalPages}</span>
<button
className="btn-ghost btn-sm"
disabled={offsetParam + PAGE_SIZE >= total}
onClick={() => setOffset(offsetParam + PAGE_SIZE)}
>
Weiter
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import Topbar from '../components/Topbar'
import { getDashboardSummary, type DashboardEntry } from '../api'
const groupLabel = (g: 'alt' | 'neu') =>
g === 'alt' ? 'Alt (vor DB-Refactor)' : 'Neu (DB-basiert)'
function StatusPill({ status, count, onClick }: { status: string | null; count: number; onClick?: () => void }) {
const label = status ?? '—'
return (
<button type="button" className="cm-pill" onClick={onClick} title={`Status: ${label}`}>
<span className={`cm-pill-dot cm-pill-dot-${(status || 'unknown').toLowerCase().replace(/[^a-z]/g, '')}`} />
<span className="cm-pill-label">{label}</span>
<span className="cm-pill-count">{count}</span>
</button>
)
}
export default function ContentManage() {
const navigate = useNavigate()
const { token } = useAuth()
const [entries, setEntries] = useState<DashboardEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!token) return
setLoading(true)
setError(null)
getDashboardSummary(token)
.then(setEntries)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false))
}, [token])
const groups: ('neu' | 'alt')[] = ['neu', 'alt']
return (
<div className="app-shell">
<Topbar page="home" />
<div className="cm-page">
<div className="cm-header">
<div>
<h1 className="cm-title">Content verwalten</h1>
<p className="cm-subtitle">Übersicht aller Collections nach Status. Klick auf eine Karte oder einen Status zum Drill-Down.</p>
</div>
</div>
{loading && <div className="cm-empty">Wird geladen</div>}
{error && <div className="cm-error">{error}</div>}
{!loading && !error && groups.map(group => {
const items = entries.filter(e => e.group === group)
if (items.length === 0) return null
return (
<section key={group} className="cm-section">
<h2 className="cm-section-title">{groupLabel(group)}</h2>
<div className="cm-grid">
{items.map(entry => (
<article key={entry.name} className="cm-card">
<button
type="button"
className="cm-card-head"
onClick={() => navigate(`/content/${entry.name}`)}
>
<div className="cm-card-titlewrap">
<span className={`cm-kind cm-kind-${entry.kind}`}>{entry.kind === 'image' ? 'Bilder' : 'Text'}</span>
<span className="cm-card-label">{entry.label}</span>
</div>
<div className="cm-card-total">{entry.total.toLocaleString('de-DE')}</div>
</button>
{entry.error && <div className="cm-card-error">{entry.error}</div>}
{entry.has_status && entry.by_status.length > 0 && (
<div className="cm-pills">
{entry.by_status.map(g => (
<StatusPill
key={String(g.status)}
status={g.status}
count={g.count}
onClick={() => navigate(`/content/${entry.name}?status=${encodeURIComponent(g.status || '')}`)}
/>
))}
</div>
)}
{!entry.has_status && !entry.error && (
<div className="cm-pills cm-pills-muted">
<span className="cm-pill cm-pill-static">
<span className="cm-pill-label">Kein Status-Feld</span>
</span>
</div>
)}
</article>
))}
</div>
</section>
)
})}
</div>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import BlurhashCanvas from '../components/BlurhashCanvas'
import Topbar from '../components/Topbar'
import WordAutocomplete from '../components/WordAutocomplete'
import {
getDbPictures,
updateDbPicture,
@@ -362,13 +363,14 @@ export default function DrawIt() {
))}
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
<WordAutocomplete
value={pictureWordInput}
onChange={e => setPictureWordInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddPictureWord() }}
onChange={setPictureWordInput}
onSubmit={handleAddPictureWord}
token={token}
placeholder="Hauptwort hinzufügen…"
style={{ flex: 1, padding: '4px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12 }}
excludeTitles={pictureWords.map(w => w.titel_de)}
inputStyle={{ flex: 1, padding: '4px 8px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface-2)', color: 'var(--text-1)', fontFamily: 'var(--font)', fontSize: 12, width: '100%' }}
/>
<button
onClick={handleAddPictureWord}
@@ -654,17 +656,18 @@ export default function DrawIt() {
{/* Add word input */}
<div style={{ display:'flex', gap:4 }}>
<input
type="text"
<WordAutocomplete
value={selectedObjectId ? (wordInputs[selectedObjectId] || '') : newWordInput}
onChange={e => selectedObjectId
? setWordInputs(prev => ({ ...prev, [selectedObjectId]: e.target.value }))
: setNewWordInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()
}}
onChange={v => selectedObjectId
? setWordInputs(prev => ({ ...prev, [selectedObjectId]: v }))
: setNewWordInput(v)}
onSubmit={() => selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()}
token={token}
placeholder="Wort hinzufügen…"
style={{ flex:1, padding:'4px 8px', borderRadius:'var(--r-sm)', border:'1px solid var(--border)', background:'var(--surface-2)', color:'var(--text-1)', fontFamily:'var(--font)', fontSize:12 }}
excludeTitles={selectedObjectId
? (objectWords[selectedObjectId] || []).map(w => w.titel_de)
: pendingWords}
inputStyle={{ flex:1, padding:'4px 8px', borderRadius:'var(--r-sm)', border:'1px solid var(--border)', background:'var(--surface-2)', color:'var(--text-1)', fontFamily:'var(--font)', fontSize:12, width: '100%' }}
/>
<button
onClick={() => selectedObjectId ? handleAddObjectWord(selectedObjectId) : handleAddPendingWord()}

123
frontend/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import Topbar from '../components/Topbar'
const PenIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
)
const FolderIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
<line x1="8" y1="13" x2="16" y2="13" />
</svg>
)
const UsersIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
const LockIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)
interface TileProps {
icon: React.ReactNode
title: string
subtitle: string
onClick?: () => void
disabled?: boolean
comingSoon?: boolean
adminOnly?: boolean
locked?: boolean
}
function Tile({ icon, title, subtitle, onClick, disabled, comingSoon, adminOnly, locked }: TileProps) {
return (
<button
type="button"
className={`home-tile${disabled ? ' is-disabled' : ''}`}
onClick={disabled ? undefined : onClick}
disabled={disabled}
>
<div className="home-tile-icon">{icon}</div>
<div className="home-tile-body">
<div className="home-tile-title">
{title}
{adminOnly && (
<span className="home-tile-badge" title="Nur für Admins">
<LockIcon /> Admin
</span>
)}
{comingSoon && <span className="home-tile-badge home-tile-badge-soft">bald</span>}
</div>
<div className="home-tile-subtitle">{subtitle}</div>
</div>
{locked && <div className="home-tile-locked"><LockIcon /></div>}
</button>
)
}
export default function Home() {
const navigate = useNavigate()
const { user, isAdmin } = useAuth()
const firstName = user?.first_name?.trim() || user?.email?.split('@')[0] || 'da'
return (
<div className="app-shell">
<Topbar page="draw" />
<div className="home-page">
<div className="home-greeting">
<h1>Super, {firstName} ist wieder da,</h1>
<p>Was möchtest du heute machen?</p>
</div>
<div className="home-tiles">
<Tile
icon={<PenIcon />}
title="Content erstellen"
subtitle="Bilder annotieren, Objekte zuschneiden, Fragen generieren."
onClick={() => navigate('/draw')}
/>
<Tile
icon={<FolderIcon />}
title="Content verwalten"
subtitle={isAdmin
? 'Übersicht aller Collections nach Status — bearbeiten als Liste oder Kacheln.'
: 'Status-Dashboard für alle Collections. Nur für Admins verfügbar.'}
adminOnly
disabled={!isAdmin}
locked={!isAdmin}
onClick={() => navigate('/content')}
/>
<Tile
icon={<UsersIcon />}
title="User verwalten"
subtitle={isAdmin
? 'Admins, API-Tokens und Endnutzer — inkl. aktive Sessions.'
: 'User-Übersicht und aktive Sessions. Nur für Admins verfügbar.'}
adminOnly
comingSoon
disabled
locked={!isAdmin}
onClick={() => { /* Schritt 3 */ }}
/>
</div>
</div>
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useTheme } from '../context/ThemeContext'
import { directusLogin } from '../api'
import { directusLogin, getDirectusMe } from '../api'
const CrosshairIcon = () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -42,8 +42,9 @@ export default function Login() {
setLoading(true)
try {
const token = await directusLogin(email, password)
login(token)
navigate('/draw')
const me = await getDirectusMe(token)
login(token, me)
navigate('/')
} catch {
setError('Ungültige Zugangsdaten. Bitte erneut versuchen.')
} finally {