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:
184
app.py
184
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/<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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
157
frontend/src/components/WordAutocomplete.tsx
Normal file
157
frontend/src/components/WordAutocomplete.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
194
frontend/src/pages/ContentEdit.tsx
Normal file
194
frontend/src/pages/ContentEdit.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
247
frontend/src/pages/ContentList.tsx
Normal file
247
frontend/src/pages/ContentList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
frontend/src/pages/ContentManage.tsx
Normal file
106
frontend/src/pages/ContentManage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
123
frontend/src/pages/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user