Add custom word entry + language pair support for learning units
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>
This commit is contained in:
Benjamin Admin
2026-04-29 15:24:13 +02:00
parent 855cc4caf4
commit 52a15b24fe
5 changed files with 762 additions and 295 deletions

View File

@@ -0,0 +1,307 @@
'use client'
import React, { useState, useCallback, useRef, useEffect } from 'react'
/** Supported language pairs */
const LANGUAGES = [
{ code: 'en', label: 'Englisch' },
{ code: 'de', label: 'Deutsch' },
{ code: 'fr', label: 'Franzoesisch' },
{ code: 'es', label: 'Spanisch' },
{ code: 'it', label: 'Italienisch' },
{ code: 'pt', label: 'Portugiesisch' },
{ code: 'nl', label: 'Niederlaendisch' },
{ code: 'tr', label: 'Tuerkisch' },
{ code: 'ru', label: 'Russisch' },
{ code: 'ar', label: 'Arabisch' },
{ code: 'uk', label: 'Ukrainisch' },
{ code: 'pl', label: 'Polnisch' },
{ code: 'sv', label: 'Schwedisch' },
{ code: 'da', label: 'Daenisch' },
{ code: 'fi', label: 'Finnisch' },
{ code: 'el', label: 'Griechisch' },
{ code: 'hu', label: 'Ungarisch' },
{ code: 'cs', label: 'Tschechisch' },
{ code: 'ro', label: 'Rumaenisch' },
]
export interface UnitWord {
id: string
source_text: string
target_text: string
pos?: string
is_custom?: boolean
}
interface Props {
isDark: boolean
glassCard: string
glassInput: string
selectedWords: UnitWord[]
onWordsChange: (words: UnitWord[]) => void
onCreateUnit: (title: string, sourceLang: string, targetLang: string) => void
isCreating: boolean
noSearchResults?: boolean
searchQuery?: string
}
export default function UnitBuilder({
isDark, glassCard, glassInput,
selectedWords, onWordsChange, onCreateUnit, isCreating,
noSearchResults, searchQuery,
}: Props) {
const [unitTitle, setUnitTitle] = useState('')
const [sourceLang, setSourceLang] = useState('de')
const [targetLang, setTargetLang] = useState('en')
const [showManualEntry, setShowManualEntry] = useState(false)
const [manualSource, setManualSource] = useState('')
const [manualTarget, setManualTarget] = useState('')
const [suggestions, setSuggestions] = useState<{ source_text: string; target_text: string; pos?: string }[]>([])
const [isLookingUp, setIsLookingUp] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
// Auto-suggest translation when typing in source field
useEffect(() => {
if (!manualSource.trim() || manualSource.length < 2) {
setSuggestions([])
return
}
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(async () => {
setIsLookingUp(true)
try {
const resp = await fetch(
`/api/vocabulary/lookup-translation?word=${encodeURIComponent(manualSource)}&source=${sourceLang}&target=${targetLang}&limit=5`
)
if (resp.ok) {
const data = await resp.json()
setSuggestions(data.results || [])
// Auto-fill target if exactly one match
if (data.results?.length === 1 && !manualTarget) {
setManualTarget(data.results[0].target_text)
}
}
} catch { /* ignore */ }
setIsLookingUp(false)
}, 400)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [manualSource, sourceLang, targetLang]) // eslint-disable-line react-hooks/exhaustive-deps
const addManualWord = useCallback(() => {
if (!manualSource.trim() || !manualTarget.trim()) return
const newWord: UnitWord = {
id: `custom_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
source_text: manualSource.trim(),
target_text: manualTarget.trim(),
is_custom: true,
}
onWordsChange([...selectedWords, newWord])
setManualSource('')
setManualTarget('')
setSuggestions([])
}, [manualSource, manualTarget, selectedWords, onWordsChange])
const removeWord = useCallback((id: string) => {
onWordsChange(selectedWords.filter(w => w.id !== id))
}, [selectedWords, onWordsChange])
const swapLanguages = useCallback(() => {
setSourceLang(targetLang)
setTargetLang(sourceLang)
}, [sourceLang, targetLang])
const srcLabel = LANGUAGES.find(l => l.code === sourceLang)?.label || sourceLang.toUpperCase()
const tgtLabel = LANGUAGES.find(l => l.code === targetLang)?.label || targetLang.toUpperCase()
return (
<div className="w-80 flex-shrink-0">
<div className={`${glassCard} rounded-2xl p-5 sticky top-6`}>
<h3 className={`text-lg font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Lernunit erstellen
</h3>
{/* Language pair selector */}
<div className="flex items-center gap-2 mb-3">
<select
value={sourceLang}
onChange={e => setSourceLang(e.target.value)}
className={`flex-1 px-2 py-1.5 rounded-lg border text-xs ${glassInput}`}
>
{LANGUAGES.map(l => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</select>
<button
onClick={swapLanguages}
className={`px-2 py-1.5 rounded-lg text-sm ${isDark ? 'bg-white/10 text-white/60 hover:bg-white/20' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
title="Sprachen tauschen"
>
</button>
<select
value={targetLang}
onChange={e => setTargetLang(e.target.value)}
className={`flex-1 px-2 py-1.5 rounded-lg border text-xs ${glassInput}`}
>
{LANGUAGES.map(l => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</select>
</div>
<input
type="text"
value={unitTitle}
onChange={e => setUnitTitle(e.target.value)}
placeholder="Titel (z.B. Unit 3 - Food)"
className={`w-full px-4 py-2.5 rounded-xl border outline-none text-sm mb-3 ${glassInput}`}
/>
{/* Manual word entry toggle */}
<button
onClick={() => setShowManualEntry(!showManualEntry)}
className={`w-full text-xs px-3 py-2 rounded-lg mb-3 flex items-center justify-center gap-1 ${
showManualEntry
? isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-blue-100 text-blue-700'
: isDark ? 'bg-white/10 text-white/60 hover:bg-white/15' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{showManualEntry ? '▾ Eigene Woerter eingeben' : '▸ Eigene Woerter eingeben'}
</button>
{/* Manual entry form */}
{showManualEntry && (
<div className={`rounded-xl p-3 mb-3 ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<div className="space-y-2">
<div>
<label className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{srcLabel}
</label>
<input
type="text"
value={manualSource}
onChange={e => setManualSource(e.target.value)}
placeholder={`z.B. ${sourceLang === 'de' ? 'schottisches Hochland' : 'Scottish Highlands'}`}
className={`w-full px-3 py-2 rounded-lg border outline-none text-sm ${glassInput}`}
onKeyDown={e => e.key === 'Enter' && manualTarget && addManualWord()}
/>
</div>
<div>
<label className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{tgtLabel} {isLookingUp && <span className="animate-pulse">...</span>}
</label>
<input
type="text"
value={manualTarget}
onChange={e => setManualTarget(e.target.value)}
placeholder={`z.B. ${targetLang === 'en' ? 'Scottish Highlands' : 'schottisches Hochland'}`}
className={`w-full px-3 py-2 rounded-lg border outline-none text-sm ${glassInput}`}
onKeyDown={e => e.key === 'Enter' && manualSource && addManualWord()}
/>
</div>
{/* Auto-suggest results */}
{suggestions.length > 1 && (
<div className={`rounded-lg p-2 space-y-1 ${isDark ? 'bg-white/5' : 'bg-white'}`}>
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Vorschlaege:</span>
{suggestions.map((s, i) => (
<button
key={i}
onClick={() => {
setManualSource(s.source_text)
setManualTarget(s.target_text)
}}
className={`w-full text-left text-xs px-2 py-1 rounded ${isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-700'}`}
>
{s.source_text} {s.target_text} {s.pos && <span className={isDark ? 'text-white/30' : 'text-slate-400'}>({s.pos})</span>}
</button>
))}
</div>
)}
<button
onClick={addManualWord}
disabled={!manualSource.trim() || !manualTarget.trim()}
className={`w-full py-2 rounded-lg text-sm font-medium ${
manualSource.trim() && manualTarget.trim()
? isDark ? 'bg-green-500/20 text-green-300 hover:bg-green-500/30' : 'bg-green-100 text-green-700 hover:bg-green-200'
: isDark ? 'bg-white/5 text-white/20' : 'bg-slate-100 text-slate-300'
}`}
>
+ Hinzufuegen
</button>
</div>
</div>
)}
{/* "No results" prompt to add manually */}
{noSearchResults && searchQuery && !showManualEntry && (
<div className={`rounded-xl p-3 mb-3 text-center ${isDark ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-amber-50 border border-amber-200'}`}>
<p className={`text-xs mb-2 ${isDark ? 'text-amber-300' : 'text-amber-700'}`}>
&quot;{searchQuery}&quot; nicht im Woerterbuch
</p>
<button
onClick={() => {
setShowManualEntry(true)
setManualSource(searchQuery)
}}
className={`text-xs px-3 py-1.5 rounded-lg font-medium ${isDark ? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30' : 'bg-amber-100 text-amber-800 hover:bg-amber-200'}`}
>
Manuell hinzufuegen
</button>
</div>
)}
{/* Word list */}
{selectedWords.length === 0 ? (
<p className={`text-sm text-center py-6 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Woerter aus dem Woerterbuch auswaehlen oder eigene eingeben
</p>
) : (
<div className="space-y-1.5 max-h-72 overflow-y-auto mb-3">
{selectedWords.map((w, i) => (
<div key={w.id} className={`flex items-center justify-between px-3 py-2 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={`text-xs w-5 text-center flex-shrink-0 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>{i+1}</span>
<div className="min-w-0 flex-1">
<span className={`text-sm font-medium truncate block ${isDark ? 'text-white' : 'text-slate-900'}`}>
{w.source_text}
</span>
<span className={`text-xs truncate block ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{w.target_text}
</span>
</div>
{w.is_custom && (
<span className={`text-xs px-1.5 py-0.5 rounded flex-shrink-0 ${isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-600'}`}>
eigen
</span>
)}
</div>
<button
onClick={() => removeWord(w.id)}
className={`text-xs ml-2 flex-shrink-0 ${isDark ? 'text-red-400 hover:text-red-300' : 'text-red-500 hover:text-red-700'}`}
>
</button>
</div>
))}
</div>
)}
<div className={`text-xs mb-3 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{selectedWords.length} Woerter · {sourceLang.toUpperCase()} {targetLang.toUpperCase()}
</div>
<button
onClick={() => onCreateUnit(unitTitle, sourceLang, targetLang)}
disabled={isCreating || selectedWords.length === 0 || !unitTitle.trim()}
className={`w-full py-3 rounded-xl font-medium transition-all ${
selectedWords.length > 0 && unitTitle.trim()
? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg'
: isDark ? 'bg-white/5 text-white/30' : 'bg-slate-100 text-slate-400'
}`}
>
{isCreating ? 'Wird erstellt...' : 'Lernunit starten'}
</button>
</div>
</div>
)
}

View File

@@ -5,11 +5,14 @@ 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
@@ -20,11 +23,18 @@ interface VocabWord {
image_url: string
difficulty: number
tags: string[]
translations?: Record<string, any>
}
/** Use Next.js API proxy to avoid mixed-content/CORS issues */
function getApiBase() {
return '' // Same-origin: /api/vocabulary/... proxied by Next.js
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() {
@@ -39,11 +49,9 @@ export default function VocabularyPage() {
const [diffFilter, setDiffFilter] = useState(0)
const [searchLang, setSearchLang] = useState('en')
const [topics, setTopics] = useState<{ topic: string; words: string[]; display_words?: string[]; word_count: number }[]>([])
const [showTopics, setShowTopics] = useState(false)
// Unit builder
const [selectedWords, setSelectedWords] = useState<VocabWord[]>([])
const [unitTitle, setUnitTitle] = useState('')
// Unit builder state (UnitWord format)
const [unitWords, setUnitWords] = useState<UnitWord[]>([])
const [isCreating, setIsCreating] = useState(false)
const glassCard = isDark
@@ -54,9 +62,8 @@ export default function VocabularyPage() {
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
// Load filters on mount
useEffect(() => {
fetch(`${getApiBase()}/api/vocabulary/filters`)
fetch('/api/vocabulary/filters')
.then(r => r.ok ? r.json() : null)
.then(d => { if (d) setFilters(d) })
.catch(() => {})
@@ -66,30 +73,28 @@ export default function VocabularyPage() {
useEffect(() => {
if (!query.trim() && !posFilter && !diffFilter) {
setResults([])
setTopics([])
return
}
const timer = setTimeout(async () => {
setIsSearching(true)
try {
let url: string
if (query.trim()) {
url = `${getApiBase()}/api/vocabulary/search?q=${encodeURIComponent(query)}&lang=${searchLang}&limit=30&source=kaikki`
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 = `${getApiBase()}/api/vocabulary/browse?${params}`
url = `/api/vocabulary/browse?${params}`
}
const resp = await fetch(url)
if (resp.ok) {
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)}&lang=${searchLang}`)
const topicResp = await fetch(`/api/vocabulary/topics?q=${encodeURIComponent(query)}&lang=${searchLang}`)
if (topicResp.ok) {
const topicData = await topicResp.json()
setTopics(topicData.topics || [])
@@ -101,28 +106,38 @@ export default function VocabularyPage() {
setIsSearching(false)
}
}, 300)
return () => clearTimeout(timer)
}, [query, posFilter, diffFilter, searchLang])
const toggleWord = useCallback((word: VocabWord) => {
setSelectedWords(prev => {
setUnitWords(prev => {
const exists = prev.find(w => w.id === word.id)
if (exists) return prev.filter(w => w.id !== word.id)
return [...prev, word]
return [...prev, vocabToUnit(word, searchLang)]
})
}, [])
}, [searchLang])
const createUnit = useCallback(async () => {
if (!unitTitle.trim() || selectedWords.length === 0) return
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 resp = await fetch(`${getApiBase()}/api/vocabulary/units`, {
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: unitTitle,
word_ids: selectedWords.map(w => w.id),
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) {
@@ -134,9 +149,29 @@ export default function VocabularyPage() {
} finally {
setIsCreating(false)
}
}, [unitTitle, selectedWords, router])
}, [unitWords, router])
const isSelected = (wordId: string) => selectedWords.some(w => w.id === wordId)
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 ${
@@ -157,7 +192,7 @@ export default function VocabularyPage() {
<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` : filters.total_words > 0 ? `${filters.total_words.toLocaleString()} Woerter` : 'Woerter suchen und Lernunits erstellen'}
{(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>
@@ -184,18 +219,6 @@ export default function VocabularyPage() {
<option value="ar">AR</option>
<option value="uk">UK</option>
<option value="pl">PL</option>
<option value="sv">SV</option>
<option value="fi">FI</option>
<option value="da">DA</option>
<option value="ro">RO</option>
<option value="el">EL</option>
<option value="hu">HU</option>
<option value="cs">CS</option>
<option value="bg">BG</option>
<option value="lv">LV</option>
<option value="lt">LT</option>
<option value="sk">SK</option>
<option value="et">ET</option>
</select>
<input
type="text"
@@ -205,24 +228,9 @@ export default function VocabularyPage() {
className={`flex-1 px-4 py-3 rounded-xl border outline-none text-lg ${glassInput}`}
autoFocus
/>
<select value={posFilter} onChange={e => setPosFilter(e.target.value)}
className={`px-3 py-2 rounded-xl border text-sm ${glassInput}`}>
<option value="">Alle Wortarten</option>
{filters.parts_of_speech.map(p => <option key={p} value={p}>{p}</option>)}
</select>
<select value={diffFilter} onChange={e => setDiffFilter(Number(e.target.value))}
className={`px-3 py-2 rounded-xl border text-sm ${glassInput}`}>
<option value={0}>Alle Level</option>
<option value={1}>A1</option>
<option value={2}>A2</option>
<option value={3}>B1</option>
<option value={4}>B2</option>
<option value={5}>C1</option>
</select>
</div>
</div>
{/* Results */}
{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`} />
@@ -236,7 +244,7 @@ export default function VocabularyPage() {
<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})
{topic.topic} ({topic.word_count})
</span>
</div>
<div className="flex flex-wrap gap-1 mb-3">
@@ -248,44 +256,13 @@ export default function VocabularyPage() {
)}
</div>
<div className="flex gap-2">
<button
onClick={async () => {
setIsSearching(true)
const topicWords: VocabWord[] = []
for (const w of topic.words) {
const r = await fetch(`${getApiBase()}/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])
}
}
setResults(topicWords)
setIsSearching(false)
}}
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'}`}
>
<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={async () => {
setIsSearching(true)
const topicWords: VocabWord[] = []
for (const w of topic.words) {
const r = await fetch(`${getApiBase()}/api/vocabulary/search?q=${encodeURIComponent(w)}&lang=en&limit=1&source=kaikki`)
if (r.ok) {
const d = await r.json()
if (d.words?.[0] && !selectedWords.find(s => s.id === d.words[0].id)) {
topicWords.push(d.words[0])
}
}
}
setSelectedWords(prev => [...prev, ...topicWords])
setResults(topicWords)
setIsSearching(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 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>
@@ -293,12 +270,17 @@ export default function VocabularyPage() {
</div>
)}
{!isSearching && results.length === 0 && query.trim() && topics.length === 0 && (
{/* 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 &quot;{query}&quot;</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
@@ -310,7 +292,6 @@ export default function VocabularyPage() {
}`}
onClick={() => toggleWord(word)}
>
{/* Image or emoji placeholder */}
<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" />
@@ -318,33 +299,22 @@ export default function VocabularyPage() {
<span className="text-xl">📝</span>
)}
</div>
{/* Word info */}
<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.english}</span>
<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.english} lang="en" isDark={isDark} size="sm" />
<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}</span>
<AudioButton text={word.german} lang="de" isDark={isDark} size="sm" />
</div>
<div className="flex items-center gap-2 mt-1">
{word.part_of_speech && (
<span className={`text-xs px-2 py-0.5 rounded-full ${isDark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-700'}`}>
{word.part_of_speech}
</span>
)}
{word.syllables_en.length > 0 && (
<span className={`text-xs ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
{word.syllables_en.join(' · ')}
</span>
)}
<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>
{/* Select indicator */}
<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'
@@ -360,59 +330,17 @@ export default function VocabularyPage() {
</div>
{/* Right: Unit Builder */}
<div className="w-80 flex-shrink-0">
<div className={`${glassCard} rounded-2xl p-5 sticky top-6`}>
<h3 className={`text-lg font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Lernunit erstellen
</h3>
<input
type="text"
value={unitTitle}
onChange={e => setUnitTitle(e.target.value)}
placeholder="Titel (z.B. Unit 3 - Food)"
className={`w-full px-4 py-2.5 rounded-xl border outline-none text-sm mb-4 ${glassInput}`}
/>
{selectedWords.length === 0 ? (
<p className={`text-sm text-center py-6 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Klicke auf Woerter um sie hinzuzufuegen
</p>
) : (
<div className="space-y-1.5 max-h-80 overflow-y-auto mb-4">
{selectedWords.map((w, i) => (
<div key={w.id} className={`flex items-center justify-between px-3 py-2 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
<div className="flex items-center gap-2 min-w-0">
<span className={`text-xs w-5 text-center ${isDark ? 'text-white/30' : 'text-slate-400'}`}>{i+1}</span>
<span className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{w.english}</span>
<span className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{w.german}</span>
</div>
<button onClick={(e) => { e.stopPropagation(); toggleWord(w) }}
className={`text-xs ${isDark ? 'text-red-400 hover:text-red-300' : 'text-red-500 hover:text-red-700'}`}>
</button>
</div>
))}
</div>
)}
<div className={`text-xs mb-3 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{selectedWords.length} Woerter ausgewaehlt
</div>
<button
onClick={createUnit}
disabled={isCreating || selectedWords.length === 0 || !unitTitle.trim()}
className={`w-full py-3 rounded-xl font-medium transition-all ${
selectedWords.length > 0 && unitTitle.trim()
? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg'
: isDark ? 'bg-white/5 text-white/30' : 'bg-slate-100 text-slate-400'
}`}
>
{isCreating ? 'Wird erstellt...' : 'Lernunit starten'}
</button>
</div>
</div>
<UnitBuilder
isDark={isDark}
glassCard={glassCard}
glassInput={glassInput}
selectedWords={unitWords}
onWordsChange={setUnitWords}
onCreateUnit={createUnit}
isCreating={isCreating}
noSearchResults={noSearchResults}
searchQuery={query}
/>
</div>
</div>
</div>