Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ThemenSuche - Autocomplete search for Abitur themes
|
||||
* Features debounced API calls, suggestion display, and keyboard navigation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, X, Loader2 } from 'lucide-react'
|
||||
import type { ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
|
||||
import { POPULAR_THEMES } from '@/lib/education/abitur-archiv-types'
|
||||
|
||||
interface ThemenSucheProps {
|
||||
onSearch: (query: string) => void
|
||||
onClear: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ThemenSuche({
|
||||
onSearch,
|
||||
onClear,
|
||||
placeholder = 'Thema suchen (z.B. Gedichtanalyse, Eroerterung, Drama...)'
|
||||
}: ThemenSucheProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Debounced API call for suggestions
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (query.length >= 2) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/suggest?q=${encodeURIComponent(query)}`)
|
||||
const data = await res.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
setShowDropdown(true)
|
||||
} catch (error) {
|
||||
console.error('Suggest error:', error)
|
||||
// Fallback to popular themes
|
||||
setSuggestions(POPULAR_THEMES.filter(t =>
|
||||
t.label.toLowerCase().includes(query.toLowerCase())
|
||||
))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} else if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
} else {
|
||||
setSuggestions([])
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!showDropdown || suggestions.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.max(prev - 1, -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0) {
|
||||
handleSelectSuggestion(suggestions[selectedIndex])
|
||||
} else if (query.trim()) {
|
||||
handleSearch()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}, [showDropdown, suggestions, selectedIndex, query])
|
||||
|
||||
const handleSelectSuggestion = (suggestion: ThemaSuggestion) => {
|
||||
setQuery(suggestion.label)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onSearch(suggestion.label)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
onSearch(query.trim())
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('')
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onClear()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
}
|
||||
setShowDropdown(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex items-center">
|
||||
<div className="absolute left-4 text-slate-400">
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setSelectedIndex(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-12 pr-24 py-3 text-lg border border-slate-300 rounded-xl
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
bg-white shadow-sm"
|
||||
/>
|
||||
<div className="absolute right-2 flex items-center gap-2">
|
||||
{query && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
title="Suche loeschen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim()}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{showDropdown && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-slate-200
|
||||
shadow-lg z-50 max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div className="p-2">
|
||||
{query.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Beliebte Themen
|
||||
</div>
|
||||
)}
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion.aufgabentyp}-${suggestion.label}`}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
className={`w-full px-3 py-2.5 text-left rounded-lg flex items-center justify-between
|
||||
transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-4 h-4 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{suggestion.label}</div>
|
||||
{suggestion.kategorie && (
|
||||
<div className="text-xs text-slate-500">{suggestion.kategorie}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-slate-400">
|
||||
{suggestion.count} Dokumente
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Theme Tags */}
|
||||
{!showDropdown && query.length === 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="text-sm text-slate-500">Vorschlaege:</span>
|
||||
{POPULAR_THEMES.slice(0, 5).map((theme) => (
|
||||
<button
|
||||
key={theme.aufgabentyp}
|
||||
onClick={() => handleSelectSuggestion(theme)}
|
||||
className="px-3 py-1 text-sm bg-slate-100 text-slate-700 rounded-full
|
||||
hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
{theme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user