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
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:
307
studio-v2/app/vocabulary/components/UnitBuilder.tsx
Normal file
307
studio-v2/app/vocabulary/components/UnitBuilder.tsx
Normal 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'}`}>
|
||||
"{searchQuery}" 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user