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

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