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:
@@ -431,6 +431,32 @@ async def api_enrich_images(word_ids: List[str] = None):
|
|||||||
return {"enriched": count, "total": len(word_ids)}
|
return {"enriched": count, "total": len(word_ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/topics")
|
||||||
|
async def api_get_topics(q: str = Query("", description="Search topic or word")):
|
||||||
|
"""Find topics matching a search word. Returns related word lists.
|
||||||
|
|
||||||
|
If q matches a topic name → returns that topic.
|
||||||
|
If q matches a word in any topic → returns all topics containing that word.
|
||||||
|
"""
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if not q.strip():
|
||||||
|
rows = await conn.fetch("SELECT topic, words, word_count FROM vocabulary_topics ORDER BY topic LIMIT 50")
|
||||||
|
else:
|
||||||
|
q_lower = q.strip().lower()
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT topic, words, word_count FROM vocabulary_topics
|
||||||
|
WHERE lower(topic) LIKE $1 OR $2 = ANY(words)
|
||||||
|
ORDER BY word_count DESC
|
||||||
|
""", f"%{q_lower}%", q_lower)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"topics": [{"topic": r["topic"], "words": list(r["words"]), "word_count": r["word_count"]} for r in rows],
|
||||||
|
"query": q,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TranslateRequest(BaseModel):
|
class TranslateRequest(BaseModel):
|
||||||
word_ids: List[str]
|
word_ids: List[str]
|
||||||
target_language: str
|
target_language: str
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export default function VocabularyPage() {
|
|||||||
const [posFilter, setPosFilter] = useState('')
|
const [posFilter, setPosFilter] = useState('')
|
||||||
const [diffFilter, setDiffFilter] = useState(0)
|
const [diffFilter, setDiffFilter] = useState(0)
|
||||||
const [searchLang, setSearchLang] = useState('en')
|
const [searchLang, setSearchLang] = useState('en')
|
||||||
|
const [topics, setTopics] = useState<{ topic: string; words: string[]; word_count: number }[]>([])
|
||||||
|
const [showTopics, setShowTopics] = useState(false)
|
||||||
|
|
||||||
// Unit builder
|
// Unit builder
|
||||||
const [selectedWords, setSelectedWords] = useState<VocabWord[]>([])
|
const [selectedWords, setSelectedWords] = useState<VocabWord[]>([])
|
||||||
@@ -84,6 +86,15 @@ export default function VocabularyPage() {
|
|||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
setResults(data.words || [])
|
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) {
|
} catch (err) {
|
||||||
console.error('Search failed:', err)
|
console.error('Search failed:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -218,7 +229,49 @@ export default function VocabularyPage() {
|
|||||||
</div>
|
</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`}>
|
<div className={`${glassCard} rounded-2xl p-8 text-center`}>
|
||||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Keine Ergebnisse fuer "{query}"</p>
|
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Keine Ergebnisse fuer "{query}"</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user