- 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>
158 lines
4.4 KiB
TypeScript
158 lines
4.4 KiB
TypeScript
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>
|
|
)
|
|
}
|