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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user