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

@@ -119,6 +119,10 @@ app.include_router(progress_router, prefix="/api")
from vocabulary.api import router as vocabulary_router
app.include_router(vocabulary_router, prefix="/api")
# --- 4c2. Vocabulary Unit Creation + Translation ---
from vocabulary.unit_api import router as vocab_unit_router
app.include_router(vocab_unit_router, prefix="/api")
# --- 4d. User Language Preferences ---
from api.user_language import router as user_language_router
app.include_router(user_language_router, prefix="/api")

View File

@@ -22,11 +22,6 @@ from .db import (
get_all_pos,
VocabularyWord,
)
from units.learning import (
LearningUnitCreate,
create_learning_unit,
get_learning_unit,
)
logger = logging.getLogger(__name__)
@@ -239,130 +234,7 @@ async def api_tts(text: str = Query("", min_length=1), lang: str = Query("de")):
# ---------------------------------------------------------------------------
class CreateUnitFromWordsPayload(BaseModel):
title: str
word_ids: List[str]
grade: Optional[str] = None
language: Optional[str] = "de"
@router.post("/units")
async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload):
"""Create a learning unit from selected vocabulary word IDs.
Fetches full word details, creates a LearningUnit in the
learning_units system, and stores the vocabulary data.
"""
if not payload.word_ids:
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
# Fetch all selected words
words = []
for wid in payload.word_ids:
word = await get_word(wid)
if word:
words.append(word)
if not words:
raise HTTPException(status_code=404, detail="Keine der Woerter gefunden")
# Create learning unit
lu = create_learning_unit(LearningUnitCreate(
title=payload.title,
topic="Vocabulary",
grade_level=payload.grade or "5-8",
language=payload.language or "de",
status="raw",
))
# Save vocabulary data as analysis JSON for generators
import os
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
os.makedirs(analysis_dir, exist_ok=True)
vocab_data = [w.to_dict() for w in words]
analysis_path = os.path.join(analysis_dir, f"{lu.id}_vocab.json")
with open(analysis_path, "w", encoding="utf-8") as f:
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
# Also save as QA items for flashcards/type trainer
qa_items = []
for i, w in enumerate(words):
qa_items.append({
"id": f"qa_{i+1}",
"question": w.english,
"answer": w.german,
"question_type": "knowledge",
"key_terms": [w.english],
"difficulty": w.difficulty,
"source_hint": w.part_of_speech,
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
# Extra fields for enhanced flashcards
"ipa_en": w.ipa_en,
"ipa_de": w.ipa_de,
"syllables_en": w.syllables_en,
"syllables_de": w.syllables_de,
"example_en": w.example_en,
"example_de": w.example_de,
"image_url": w.image_url,
"audio_url_en": w.audio_url_en,
"audio_url_de": w.audio_url_de,
"part_of_speech": w.part_of_speech,
"translations": w.translations,
})
qa_path = os.path.join(analysis_dir, f"{lu.id}_qa.json")
with open(qa_path, "w", encoding="utf-8") as f:
json.dump({
"qa_items": qa_items,
"metadata": {
"subject": "English Vocabulary",
"grade_level": payload.grade or "5-8",
"source_title": payload.title,
"total_questions": len(qa_items),
},
}, f, ensure_ascii=False, indent=2)
# Auto-enrich words with images (Wikipedia + emoji fallback)
try:
from services.image_service import enrich_words_with_images
await enrich_words_with_images(payload.word_ids)
except Exception as e:
logger.warning(f"Image enrichment failed (non-critical): {e}")
logger.info(f"Created vocab unit {lu.id} with {len(words)} words")
return {
"unit_id": lu.id,
"title": payload.title,
"word_count": len(words),
"status": "created",
}
@router.get("/units/{unit_id}")
async def api_get_unit_words(unit_id: str):
"""Get all words for a learning unit."""
import os
vocab_path = os.path.join(
os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten"),
f"{unit_id}_vocab.json",
)
if not os.path.exists(vocab_path):
raise HTTPException(status_code=404, detail="Unit nicht gefunden")
with open(vocab_path, "r", encoding="utf-8") as f:
data = json.load(f)
return {
"unit_id": unit_id,
"title": data.get("title", ""),
"words": data.get("words", []),
}
# Unit creation and translation lookup moved to vocabulary/unit_api.py
# ---------------------------------------------------------------------------

View File

@@ -0,0 +1,356 @@
"""
Vocabulary Unit API — Create learning units, translate words, manage language pairs.
Endpoints for teachers to build vocabulary learning units with custom words,
auto-translation via Kaikki dictionary, and flexible language pair support.
"""
import json
import logging
import os
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from .db import get_word, VocabularyWord, get_pool
from units.learning import LearningUnitCreate, create_learning_unit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
# All supported language codes
SUPPORTED_LANGS = {
"en", "de", "fr", "es", "it", "pt", "nl", "tr", "ru", "ar",
"uk", "pl", "sv", "fi", "da", "ro", "el", "hu", "cs", "bg",
"lv", "lt", "sk", "et", "sl", "hr",
}
# ---------------------------------------------------------------------------
# Translation Lookup (auto-suggest)
# ---------------------------------------------------------------------------
@router.get("/lookup-translation")
async def api_lookup_translation(
word: str = Query("", min_length=1, description="Word to translate"),
source: str = Query("en", description="Source language code"),
target: str = Query("de", description="Target language code"),
limit: int = Query(5, ge=1, le=20),
):
"""Look up translations between any two languages via Kaikki dictionary.
Uses EN entries as a hub: all EN words have translations to 24 languages.
- EN → X: direct lookup (word in EN, translation from JSONB)
- X → EN: reverse lookup (search EN entries where translations.X matches)
- X → Y: bridge via EN (find EN word via X, then get Y translation)
"""
if source not in SUPPORTED_LANGS or target not in SUPPORTED_LANGS:
raise HTTPException(status_code=400, detail="Sprache nicht unterstuetzt")
if source == target:
return {"results": [], "word": word, "source": source, "target": target}
pool = await get_pool()
q = word.strip()
results = []
async with pool.acquire() as conn:
if source == "en":
# Direct: search EN word, return target translation
rows = await conn.fetch(
"""SELECT word, pos, ipa, translations
FROM vocabulary_kaikki
WHERE lang = 'en' AND lower(word) LIKE $1
ORDER BY length(word), lower(word)
LIMIT $2""",
f"{q.lower()}%", limit,
)
for r in rows:
tr = _parse_translations(r["translations"])
target_text = tr.get(target, {}).get("text", "")
if target_text:
results.append({
"source_text": r["word"],
"target_text": target_text,
"pos": r["pos"],
"ipa": r["ipa"] or "",
})
elif target == "en":
# Reverse: search EN entries where translations.source matches
rows = await conn.fetch(
"""SELECT word, pos, ipa, translations->'%s'->>'text' as src_text
FROM vocabulary_kaikki
WHERE lang = 'en'
AND translations->'%s'->>'text' ILIKE $1
ORDER BY length(word)
LIMIT $2""" % (source, source),
f"{q}%", limit,
)
for r in rows:
results.append({
"source_text": r["src_text"],
"target_text": r["word"],
"pos": r["pos"],
"ipa": r["ipa"] or "",
})
else:
# Bridge via EN: find EN word via source, then get target translation
rows = await conn.fetch(
"""SELECT word, pos, ipa, translations
FROM vocabulary_kaikki
WHERE lang = 'en'
AND translations->'%s'->>'text' ILIKE $1
ORDER BY length(word)
LIMIT $2""" % source,
f"{q}%", limit,
)
for r in rows:
tr = _parse_translations(r["translations"])
src_text = tr.get(source, {}).get("text", "")
target_text = tr.get(target, {}).get("text", "")
if src_text and target_text:
results.append({
"source_text": src_text,
"target_text": target_text,
"pos": r["pos"],
"ipa": "",
})
return {"results": results, "word": q, "source": source, "target": target}
def _parse_translations(tr) -> dict:
"""Parse translations field (may be JSONB dict or JSON string)."""
if isinstance(tr, str):
return json.loads(tr)
return tr or {}
# ---------------------------------------------------------------------------
# Unit Creation (with custom words + language pair)
# ---------------------------------------------------------------------------
class CustomWord(BaseModel):
source_text: str
target_text: str
class CreateUnitPayload(BaseModel):
title: str
word_ids: List[str] = []
custom_words: List[CustomWord] = []
source_lang: str = "en"
target_lang: str = "de"
grade: Optional[str] = None
@router.post("/units")
async def api_create_unit_from_words(payload: CreateUnitPayload):
"""Create a learning unit from dictionary words and/or custom word pairs.
Supports any language pair. Words can come from:
1. word_ids — looked up in Kaikki dictionary
2. custom_words — manually entered source/target pairs
"""
if not payload.word_ids and not payload.custom_words:
raise HTTPException(status_code=400, detail="Keine Woerter ausgewaehlt")
qa_items = []
vocab_data = []
idx = 0
# 1. Process dictionary words
for wid in payload.word_ids:
word = await get_word(wid)
if not word:
# Try Kaikki lookup
kaikki_word = await _get_kaikki_word(wid, payload.source_lang, payload.target_lang)
if kaikki_word:
qa_items.append(_make_qa_item(idx, kaikki_word, payload.source_lang, payload.target_lang))
vocab_data.append(kaikki_word)
idx += 1
continue
# Manual vocabulary_words entry
source_text, target_text = _get_word_pair(word, payload.source_lang, payload.target_lang)
qa_items.append({
"id": f"qa_{idx+1}",
"question": source_text,
"answer": target_text,
"question_type": "knowledge",
"key_terms": [source_text],
"difficulty": word.difficulty,
"source_hint": word.part_of_speech,
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
"ipa_en": word.ipa_en,
"ipa_de": word.ipa_de,
"syllables_en": word.syllables_en,
"syllables_de": word.syllables_de,
"example_en": word.example_en,
"example_de": word.example_de,
"image_url": word.image_url,
"audio_url_en": word.audio_url_en,
"audio_url_de": word.audio_url_de,
"part_of_speech": word.part_of_speech,
"translations": word.translations,
})
vocab_data.append(word.to_dict())
idx += 1
# 2. Process custom words (manually entered by teacher)
for cw in payload.custom_words:
qa_items.append({
"id": f"qa_{idx+1}",
"question": cw.source_text,
"answer": cw.target_text,
"question_type": "knowledge",
"key_terms": [cw.source_text],
"difficulty": 1,
"source_hint": "",
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
"part_of_speech": "",
"translations": {},
})
vocab_data.append({
"english": cw.source_text if payload.source_lang == "en" else cw.target_text if payload.target_lang == "en" else "",
"german": cw.source_text if payload.source_lang == "de" else cw.target_text if payload.target_lang == "de" else "",
"word": cw.source_text,
"translation": cw.target_text,
"source_lang": payload.source_lang,
"target_lang": payload.target_lang,
})
idx += 1
if not qa_items:
raise HTTPException(status_code=400, detail="Keine gültigen Woerter")
# Create learning unit
lang_label = f"{payload.source_lang.upper()}{payload.target_lang.upper()}"
lu = create_learning_unit(LearningUnitCreate(
title=payload.title,
topic="Vocabulary",
grade_level=payload.grade or "5-8",
language=payload.target_lang,
status="raw",
))
# Save files
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
os.makedirs(analysis_dir, exist_ok=True)
with open(os.path.join(analysis_dir, f"{lu.id}_vocab.json"), "w", encoding="utf-8") as f:
json.dump({"words": vocab_data, "title": payload.title}, f, ensure_ascii=False, indent=2)
with open(os.path.join(analysis_dir, f"{lu.id}_qa.json"), "w", encoding="utf-8") as f:
json.dump({
"qa_items": qa_items,
"metadata": {
"subject": f"Vocabulary {lang_label}",
"grade_level": payload.grade or "5-8",
"source_title": payload.title,
"total_questions": len(qa_items),
"source_lang": payload.source_lang,
"target_lang": payload.target_lang,
},
}, f, ensure_ascii=False, indent=2)
# Auto-enrich images for dictionary words
dict_ids = [wid for wid in payload.word_ids]
if dict_ids:
try:
from services.image_service import enrich_words_with_images
await enrich_words_with_images(dict_ids)
except Exception as e:
logger.warning(f"Image enrichment failed (non-critical): {e}")
logger.info(f"Created vocab unit {lu.id} ({lang_label}) with {len(qa_items)} words")
return {
"unit_id": lu.id,
"title": payload.title,
"word_count": len(qa_items),
"source_lang": payload.source_lang,
"target_lang": payload.target_lang,
"status": "created",
}
def _get_word_pair(word: VocabularyWord, source_lang: str, target_lang: str):
"""Extract source/target text from a VocabularyWord for the given language pair."""
lang_map = {"en": word.english, "de": word.german}
# Check translations for other languages
if source_lang not in lang_map:
tr = word.translations or {}
lang_map[source_lang] = tr.get(source_lang, {}).get("text", word.english)
if target_lang not in lang_map:
tr = word.translations or {}
lang_map[target_lang] = tr.get(target_lang, {}).get("text", word.german)
return lang_map.get(source_lang, word.english), lang_map.get(target_lang, word.german)
async def _get_kaikki_word(word_id: str, source_lang: str, target_lang: str) -> Optional[dict]:
"""Look up a word by ID in the Kaikki table and return a vocab dict."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, word, lang, pos, ipa, translations, example FROM vocabulary_kaikki WHERE id = $1",
_to_uuid(word_id),
)
if not row:
return None
tr = _parse_translations(row["translations"])
src = row["word"] if row["lang"] == source_lang else tr.get(source_lang, {}).get("text", "")
tgt = tr.get(target_lang, {}).get("text", "") if row["lang"] != target_lang else row["word"]
return {
"id": str(row["id"]),
"word": row["word"],
"lang": row["lang"],
"source_text": src or row["word"],
"target_text": tgt,
"pos": row["pos"],
"ipa": row["ipa"] or "",
"example": row["example"] or "",
"translations": tr,
}
def _make_qa_item(idx: int, kw: dict, source_lang: str, target_lang: str) -> dict:
"""Create a QA item from a Kaikki word dict."""
return {
"id": f"qa_{idx+1}",
"question": kw.get("source_text", kw.get("word", "")),
"answer": kw.get("target_text", ""),
"question_type": "knowledge",
"key_terms": [kw.get("source_text", kw.get("word", ""))],
"difficulty": 0,
"source_hint": kw.get("pos", ""),
"leitner_box": 0,
"correct_count": 0,
"incorrect_count": 0,
"last_seen": None,
"next_review": None,
"ipa_en": kw.get("ipa", "") if source_lang == "en" else "",
"ipa_de": kw.get("ipa", "") if source_lang == "de" else "",
"part_of_speech": kw.get("pos", ""),
"translations": kw.get("translations", {}),
}
def _to_uuid(s: str):
"""Convert string to UUID, return as-is if already valid."""
import uuid
try:
return uuid.UUID(s)
except (ValueError, AttributeError):
return s

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>