Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 31s
CI / test-go-edu-search (push) Successful in 31s
CI / test-python-klausur (push) Failing after 2m29s
CI / test-python-agent-core (push) Successful in 24s
CI / test-nodejs-website (push) Successful in 22s
- New UnitBuilder component with language pair selector (DE⇄EN, ES, FR, etc.) - Manual word entry form with auto-suggest from Kaikki dictionary (6M words) - "No results" prompt to add multi-word terms (e.g. "schottisches Hochland") - New backend endpoint GET /vocabulary/lookup-translation (any→any via EN hub) - Updated POST /vocabulary/units: accepts custom_words + source_lang/target_lang - Split unit endpoints into vocabulary/unit_api.py (500 LOC budget) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
349 lines
15 KiB
TypeScript
349 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTheme } from '@/lib/ThemeContext'
|
|
import { Sidebar } from '@/components/Sidebar'
|
|
import { AudioButton } from '@/components/learn/AudioButton'
|
|
import UnitBuilder, { type UnitWord } from './components/UnitBuilder'
|
|
|
|
interface VocabWord {
|
|
id: string
|
|
english: string
|
|
german: string
|
|
word?: string
|
|
lang?: string
|
|
ipa_en: string
|
|
ipa_de: string
|
|
part_of_speech: string
|
|
syllables_en: string[]
|
|
syllables_de: string[]
|
|
example_en: string
|
|
example_de: string
|
|
image_url: string
|
|
difficulty: number
|
|
tags: string[]
|
|
translations?: Record<string, any>
|
|
}
|
|
|
|
function vocabToUnit(w: VocabWord, searchLang: string): UnitWord {
|
|
// Source = the word in the language we searched, Target = the translation
|
|
const src = w.word || w.english || ''
|
|
const tgt = searchLang === 'en'
|
|
? (w.german || '')
|
|
: searchLang === 'de'
|
|
? (w.english || '')
|
|
: (w.english || w.german || '')
|
|
return { id: w.id, source_text: src, target_text: tgt, pos: w.part_of_speech }
|
|
}
|
|
|
|
export default function VocabularyPage() {
|
|
const { isDark } = useTheme()
|
|
const router = useRouter()
|
|
|
|
const [query, setQuery] = useState('')
|
|
const [results, setResults] = useState<VocabWord[]>([])
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const [filters, setFilters] = useState<{ tags: string[]; parts_of_speech: string[]; total_words: number }>({ tags: [], parts_of_speech: [], total_words: 0 })
|
|
const [posFilter, setPosFilter] = useState('')
|
|
const [diffFilter, setDiffFilter] = useState(0)
|
|
const [searchLang, setSearchLang] = useState('en')
|
|
const [topics, setTopics] = useState<{ topic: string; words: string[]; display_words?: string[]; word_count: number }[]>([])
|
|
|
|
// Unit builder state (UnitWord format)
|
|
const [unitWords, setUnitWords] = useState<UnitWord[]>([])
|
|
const [isCreating, setIsCreating] = useState(false)
|
|
|
|
const glassCard = isDark
|
|
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
|
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
|
|
|
const glassInput = isDark
|
|
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
|
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
|
|
|
useEffect(() => {
|
|
fetch('/api/vocabulary/filters')
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(d => { if (d) setFilters(d) })
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
// Search with debounce
|
|
useEffect(() => {
|
|
if (!query.trim() && !posFilter && !diffFilter) {
|
|
setResults([])
|
|
setTopics([])
|
|
return
|
|
}
|
|
const timer = setTimeout(async () => {
|
|
setIsSearching(true)
|
|
try {
|
|
let url: string
|
|
if (query.trim()) {
|
|
url = `/api/vocabulary/search?q=${encodeURIComponent(query)}&lang=${searchLang}&limit=30&source=kaikki`
|
|
} else {
|
|
const params = new URLSearchParams({ limit: '30' })
|
|
if (posFilter) params.set('pos', posFilter)
|
|
if (diffFilter) params.set('difficulty', String(diffFilter))
|
|
url = `/api/vocabulary/browse?${params}`
|
|
}
|
|
const resp = await fetch(url)
|
|
if (resp.ok) {
|
|
const data = await resp.json()
|
|
setResults(data.words || [])
|
|
}
|
|
if (query.trim()) {
|
|
const topicResp = await fetch(`/api/vocabulary/topics?q=${encodeURIComponent(query)}&lang=${searchLang}`)
|
|
if (topicResp.ok) {
|
|
const topicData = await topicResp.json()
|
|
setTopics(topicData.topics || [])
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Search failed:', err)
|
|
} finally {
|
|
setIsSearching(false)
|
|
}
|
|
}, 300)
|
|
return () => clearTimeout(timer)
|
|
}, [query, posFilter, diffFilter, searchLang])
|
|
|
|
const toggleWord = useCallback((word: VocabWord) => {
|
|
setUnitWords(prev => {
|
|
const exists = prev.find(w => w.id === word.id)
|
|
if (exists) return prev.filter(w => w.id !== word.id)
|
|
return [...prev, vocabToUnit(word, searchLang)]
|
|
})
|
|
}, [searchLang])
|
|
|
|
const isSelected = (wordId: string) => unitWords.some(w => w.id === wordId)
|
|
|
|
const createUnit = useCallback(async (title: string, sourceLang: string, targetLang: string) => {
|
|
if (!title.trim() || unitWords.length === 0) return
|
|
setIsCreating(true)
|
|
try {
|
|
const dictWords = unitWords.filter(w => !w.is_custom)
|
|
const customWords = unitWords.filter(w => w.is_custom)
|
|
|
|
const resp = await fetch('/api/vocabulary/units', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title,
|
|
word_ids: dictWords.map(w => w.id),
|
|
custom_words: customWords.map(w => ({
|
|
source_text: w.source_text,
|
|
target_text: w.target_text,
|
|
})),
|
|
source_lang: sourceLang,
|
|
target_lang: targetLang,
|
|
}),
|
|
})
|
|
if (resp.ok) {
|
|
const data = await resp.json()
|
|
router.push(`/learn/${data.unit_id}/flashcards`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Create unit failed:', err)
|
|
} finally {
|
|
setIsCreating(false)
|
|
}
|
|
}, [unitWords, router])
|
|
|
|
const addTopicWords = useCallback(async (topic: { words: string[] }, showOnly: boolean) => {
|
|
setIsSearching(true)
|
|
const topicWords: VocabWord[] = []
|
|
for (const w of topic.words) {
|
|
const r = await fetch(`/api/vocabulary/search?q=${encodeURIComponent(w)}&lang=en&limit=1&source=kaikki`)
|
|
if (r.ok) {
|
|
const d = await r.json()
|
|
if (d.words?.[0]) topicWords.push(d.words[0])
|
|
}
|
|
}
|
|
if (!showOnly) {
|
|
const newUnitWords = topicWords
|
|
.filter(tw => !unitWords.find(uw => uw.id === tw.id))
|
|
.map(tw => vocabToUnit(tw, 'en'))
|
|
setUnitWords(prev => [...prev, ...newUnitWords])
|
|
}
|
|
setResults(topicWords)
|
|
setIsSearching(false)
|
|
}, [unitWords])
|
|
|
|
const noSearchResults = !isSearching && results.length === 0 && !!query.trim() && topics.length === 0
|
|
|
|
return (
|
|
<div className={`min-h-screen flex relative overflow-hidden ${
|
|
isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'
|
|
}`}>
|
|
<div className="relative z-10 p-4"><Sidebar /></div>
|
|
|
|
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto">
|
|
{/* Header */}
|
|
<div className={`${glassCard} border-0 border-b`}>
|
|
<div className="max-w-5xl mx-auto px-6 py-4">
|
|
<div className="flex items-center gap-4">
|
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-blue-500/30' : 'bg-blue-200'}`}>
|
|
<svg className={`w-6 h-6 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Woerterbuch</h1>
|
|
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
|
{(filters as any).kaikki_total > 0 ? `${((filters as any).kaikki_total as number).toLocaleString()} Woerter in ${(filters as any).kaikki_languages} Sprachen` : 'Woerter suchen und Lernunits erstellen'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-5xl mx-auto w-full px-6 py-6 flex gap-6">
|
|
{/* Left: Search + Results */}
|
|
<div className="flex-1 space-y-4">
|
|
{/* Search Bar */}
|
|
<div className={`${glassCard} rounded-2xl p-4`}>
|
|
<div className="flex gap-3">
|
|
<select value={searchLang} onChange={e => setSearchLang(e.target.value)}
|
|
className={`px-3 py-2 rounded-xl border text-sm ${glassInput}`}>
|
|
<option value="en">EN</option>
|
|
<option value="de">DE</option>
|
|
<option value="fr">FR</option>
|
|
<option value="es">ES</option>
|
|
<option value="it">IT</option>
|
|
<option value="pt">PT</option>
|
|
<option value="nl">NL</option>
|
|
<option value="tr">TR</option>
|
|
<option value="ru">RU</option>
|
|
<option value="ar">AR</option>
|
|
<option value="uk">UK</option>
|
|
<option value="pl">PL</option>
|
|
</select>
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
placeholder={searchLang === 'de' ? 'Deutsches Wort suchen...' : searchLang === 'en' ? 'English word search...' : 'Wort suchen...'}
|
|
className={`flex-1 px-4 py-3 rounded-xl border outline-none text-lg ${glassInput}`}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{isSearching && (
|
|
<div className="flex justify-center py-8">
|
|
<div className={`w-6 h-6 border-2 ${isDark ? 'border-blue-400' : 'border-blue-600'} border-t-transparent rounded-full animate-spin`} />
|
|
</div>
|
|
)}
|
|
|
|
{/* 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="mb-2">
|
|
<span className={`text-sm font-semibold ${isDark ? 'text-cyan-300' : 'text-cyan-700'}`}>
|
|
{topic.topic} ({topic.word_count})
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1 mb-3">
|
|
{(topic.display_words || topic.words).slice(0, 15).map((w: string, i: number) => (
|
|
<span key={i} 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 className="flex gap-2">
|
|
<button onClick={() => addTopicWords(topic, true)}
|
|
className={`flex-1 text-xs px-3 py-2 rounded-lg ${isDark ? 'bg-white/10 text-white/60 hover:bg-white/20' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}>
|
|
Anzeigen
|
|
</button>
|
|
<button onClick={() => addTopicWords(topic, false)}
|
|
className={`flex-1 text-xs px-3 py-2 rounded-lg font-semibold ${isDark ? 'bg-cyan-500/30 text-cyan-200 hover:bg-cyan-500/40 border border-cyan-400/30' : 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 border border-cyan-300'}`}>
|
|
+ Alle zur Unit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* No results message */}
|
|
{noSearchResults && (
|
|
<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={`text-xs mt-2 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
|
Du kannst das Wort rechts manuell hinzufuegen →
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Result list */}
|
|
<div className="space-y-2">
|
|
{results.map(word => (
|
|
<div
|
|
key={word.id}
|
|
className={`${glassCard} rounded-xl p-4 flex items-center gap-4 transition-all cursor-pointer ${
|
|
isSelected(word.id)
|
|
? (isDark ? 'ring-2 ring-blue-400/50 bg-blue-500/10' : 'ring-2 ring-blue-500/50 bg-blue-50')
|
|
: 'hover:shadow-md'
|
|
}`}
|
|
onClick={() => toggleWord(word)}
|
|
>
|
|
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl flex-shrink-0 ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
|
{word.image_url ? (
|
|
<img src={word.image_url} alt={word.english} className="w-full h-full object-cover rounded-xl" />
|
|
) : (
|
|
<span className="text-xl">📝</span>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`font-bold text-lg ${isDark ? 'text-white' : 'text-slate-900'}`}>{word.word || word.english}</span>
|
|
{word.ipa_en && <span className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{word.ipa_en}</span>}
|
|
<AudioButton text={word.word || word.english} lang={word.lang || searchLang} isDark={isDark} size="sm" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`${isDark ? 'text-white/70' : 'text-slate-600'}`}>{word.german || word.english}</span>
|
|
{word.german && <AudioButton text={word.german} lang="de" isDark={isDark} size="sm" />}
|
|
</div>
|
|
{word.part_of_speech && (
|
|
<span className={`text-xs px-2 py-0.5 rounded-full mt-1 inline-block ${isDark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-700'}`}>
|
|
{word.part_of_speech}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 transition-colors ${
|
|
isSelected(word.id)
|
|
? 'bg-blue-500 text-white'
|
|
: isDark ? 'bg-white/10 text-white/30' : 'bg-slate-100 text-slate-300'
|
|
}`}>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d={isSelected(word.id) ? "M5 13l4 4L19 7" : "M12 4v16m8-8H4"} />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Unit Builder */}
|
|
<UnitBuilder
|
|
isDark={isDark}
|
|
glassCard={glassCard}
|
|
glassInput={glassInput}
|
|
selectedWords={unitWords}
|
|
onWordsChange={setUnitWords}
|
|
onCreateUnit={createUnit}
|
|
isCreating={isCreating}
|
|
noSearchResults={noSearchResults}
|
|
searchQuery={query}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|