Add topic suggestions: search "banana" → suggests "Fruit/Obst" topic

31 curated topics with 683 words (Fruit, Animals, Body, Eye, Sports,
School, Family, Weather, etc.). When user types a word that belongs
to a topic, the topic appears as a suggestion with "Alle laden" button.

Clicking "Alle laden" fetches all words from that topic via Kaikki
and displays them for easy selection into a learning unit.

New endpoint: GET /api/vocabulary/topics?q=banana
New table: vocabulary_topics (topic, words[], word_count)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-29 10:56:36 +02:00
parent c2efb9934c
commit 91e8b92bdc
2 changed files with 80 additions and 1 deletions

View File

@@ -38,6 +38,8 @@ export default function VocabularyPage() {
const [posFilter, setPosFilter] = useState('')
const [diffFilter, setDiffFilter] = useState(0)
const [searchLang, setSearchLang] = useState('en')
const [topics, setTopics] = useState<{ topic: string; words: string[]; word_count: number }[]>([])
const [showTopics, setShowTopics] = useState(false)
// Unit builder
const [selectedWords, setSelectedWords] = useState<VocabWord[]>([])
@@ -84,6 +86,15 @@ export default function VocabularyPage() {
const data = await resp.json()
setResults(data.words || [])
}
// Also search for matching topics
if (query.trim()) {
const topicResp = await fetch(`${getApiBase()}/api/vocabulary/topics?q=${encodeURIComponent(query)}`)
if (topicResp.ok) {
const topicData = await topicResp.json()
setTopics(topicData.topics || [])
}
}
} catch (err) {
console.error('Search failed:', err)
} finally {
@@ -218,7 +229,49 @@ export default function VocabularyPage() {
</div>
)}
{!isSearching && results.length === 0 && query.trim() && (
{/* Topic suggestions */}
{topics.length > 0 && (
<div className="space-y-2">
{topics.map(topic => (
<div key={topic.topic} className={`${glassCard} rounded-xl p-3`}>
<div className="flex items-center justify-between mb-2">
<span className={`text-sm font-semibold ${isDark ? 'text-cyan-300' : 'text-cyan-700'}`}>
💡 {topic.topic} ({topic.word_count})
</span>
<button
onClick={async () => {
// Load all words from this topic via Kaikki
setIsSearching(true)
const topicWords: VocabWord[] = []
for (const w of topic.words) {
const r = await fetch(`${getApiBase()}/api/vocabulary/search?q=${encodeURIComponent(w)}&lang=${searchLang}&limit=1&source=kaikki`)
if (r.ok) {
const d = await r.json()
if (d.words?.[0]) topicWords.push(d.words[0])
}
}
setResults(topicWords)
setIsSearching(false)
}}
className={`text-xs px-3 py-1 rounded-lg ${isDark ? 'bg-cyan-500/20 text-cyan-300 hover:bg-cyan-500/30' : 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200'}`}
>
Alle laden
</button>
</div>
<div className="flex flex-wrap gap-1">
{topic.words.slice(0, 15).map(w => (
<span key={w} className={`text-xs px-2 py-0.5 rounded-full ${isDark ? 'bg-white/5 text-white/50' : 'bg-slate-100 text-slate-500'}`}>{w}</span>
))}
{topic.words.length > 15 && (
<span className={`text-xs px-2 py-0.5 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>+{topic.words.length - 15}</span>
)}
</div>
</div>
))}
</div>
)}
{!isSearching && results.length === 0 && query.trim() && topics.length === 0 && (
<div className={`${glassCard} rounded-2xl p-8 text-center`}>
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Keine Ergebnisse fuer &quot;{query}&quot;</p>
</div>