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:
@@ -119,6 +119,10 @@ app.include_router(progress_router, prefix="/api")
|
|||||||
from vocabulary.api import router as vocabulary_router
|
from vocabulary.api import router as vocabulary_router
|
||||||
app.include_router(vocabulary_router, prefix="/api")
|
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 ---
|
# --- 4d. User Language Preferences ---
|
||||||
from api.user_language import router as user_language_router
|
from api.user_language import router as user_language_router
|
||||||
app.include_router(user_language_router, prefix="/api")
|
app.include_router(user_language_router, prefix="/api")
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ from .db import (
|
|||||||
get_all_pos,
|
get_all_pos,
|
||||||
VocabularyWord,
|
VocabularyWord,
|
||||||
)
|
)
|
||||||
from units.learning import (
|
|
||||||
LearningUnitCreate,
|
|
||||||
create_learning_unit,
|
|
||||||
get_learning_unit,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
# Unit creation and translation lookup moved to vocabulary/unit_api.py
|
||||||
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", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
356
backend-lehrer/vocabulary/unit_api.py
Normal file
356
backend-lehrer/vocabulary/unit_api.py
Normal 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
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
import { Sidebar } from '@/components/Sidebar'
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
import { AudioButton } from '@/components/learn/AudioButton'
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
|
import UnitBuilder, { type UnitWord } from './components/UnitBuilder'
|
||||||
|
|
||||||
interface VocabWord {
|
interface VocabWord {
|
||||||
id: string
|
id: string
|
||||||
english: string
|
english: string
|
||||||
german: string
|
german: string
|
||||||
|
word?: string
|
||||||
|
lang?: string
|
||||||
ipa_en: string
|
ipa_en: string
|
||||||
ipa_de: string
|
ipa_de: string
|
||||||
part_of_speech: string
|
part_of_speech: string
|
||||||
@@ -20,11 +23,18 @@ interface VocabWord {
|
|||||||
image_url: string
|
image_url: string
|
||||||
difficulty: number
|
difficulty: number
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
translations?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Use Next.js API proxy to avoid mixed-content/CORS issues */
|
function vocabToUnit(w: VocabWord, searchLang: string): UnitWord {
|
||||||
function getApiBase() {
|
// Source = the word in the language we searched, Target = the translation
|
||||||
return '' // Same-origin: /api/vocabulary/... proxied by Next.js
|
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() {
|
export default function VocabularyPage() {
|
||||||
@@ -39,11 +49,9 @@ export default function VocabularyPage() {
|
|||||||
const [diffFilter, setDiffFilter] = useState(0)
|
const [diffFilter, setDiffFilter] = useState(0)
|
||||||
const [searchLang, setSearchLang] = useState('en')
|
const [searchLang, setSearchLang] = useState('en')
|
||||||
const [topics, setTopics] = useState<{ topic: string; words: string[]; display_words?: string[]; word_count: number }[]>([])
|
const [topics, setTopics] = useState<{ topic: string; words: string[]; display_words?: string[]; word_count: number }[]>([])
|
||||||
const [showTopics, setShowTopics] = useState(false)
|
|
||||||
|
|
||||||
// Unit builder
|
// Unit builder state (UnitWord format)
|
||||||
const [selectedWords, setSelectedWords] = useState<VocabWord[]>([])
|
const [unitWords, setUnitWords] = useState<UnitWord[]>([])
|
||||||
const [unitTitle, setUnitTitle] = useState('')
|
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
|
||||||
const glassCard = isDark
|
const glassCard = isDark
|
||||||
@@ -54,9 +62,8 @@ export default function VocabularyPage() {
|
|||||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||||
|
|
||||||
// Load filters on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${getApiBase()}/api/vocabulary/filters`)
|
fetch('/api/vocabulary/filters')
|
||||||
.then(r => r.ok ? r.json() : null)
|
.then(r => r.ok ? r.json() : null)
|
||||||
.then(d => { if (d) setFilters(d) })
|
.then(d => { if (d) setFilters(d) })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -66,30 +73,28 @@ export default function VocabularyPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!query.trim() && !posFilter && !diffFilter) {
|
if (!query.trim() && !posFilter && !diffFilter) {
|
||||||
setResults([])
|
setResults([])
|
||||||
|
setTopics([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
setIsSearching(true)
|
setIsSearching(true)
|
||||||
try {
|
try {
|
||||||
let url: string
|
let url: string
|
||||||
if (query.trim()) {
|
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 {
|
} else {
|
||||||
const params = new URLSearchParams({ limit: '30' })
|
const params = new URLSearchParams({ limit: '30' })
|
||||||
if (posFilter) params.set('pos', posFilter)
|
if (posFilter) params.set('pos', posFilter)
|
||||||
if (diffFilter) params.set('difficulty', String(diffFilter))
|
if (diffFilter) params.set('difficulty', String(diffFilter))
|
||||||
url = `${getApiBase()}/api/vocabulary/browse?${params}`
|
url = `/api/vocabulary/browse?${params}`
|
||||||
}
|
}
|
||||||
const resp = await fetch(url)
|
const resp = await fetch(url)
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
setResults(data.words || [])
|
setResults(data.words || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also search for matching topics
|
|
||||||
if (query.trim()) {
|
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) {
|
if (topicResp.ok) {
|
||||||
const topicData = await topicResp.json()
|
const topicData = await topicResp.json()
|
||||||
setTopics(topicData.topics || [])
|
setTopics(topicData.topics || [])
|
||||||
@@ -101,28 +106,38 @@ export default function VocabularyPage() {
|
|||||||
setIsSearching(false)
|
setIsSearching(false)
|
||||||
}
|
}
|
||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [query, posFilter, diffFilter, searchLang])
|
}, [query, posFilter, diffFilter, searchLang])
|
||||||
|
|
||||||
const toggleWord = useCallback((word: VocabWord) => {
|
const toggleWord = useCallback((word: VocabWord) => {
|
||||||
setSelectedWords(prev => {
|
setUnitWords(prev => {
|
||||||
const exists = prev.find(w => w.id === word.id)
|
const exists = prev.find(w => w.id === word.id)
|
||||||
if (exists) return prev.filter(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 () => {
|
const isSelected = (wordId: string) => unitWords.some(w => w.id === wordId)
|
||||||
if (!unitTitle.trim() || selectedWords.length === 0) return
|
|
||||||
|
const createUnit = useCallback(async (title: string, sourceLang: string, targetLang: string) => {
|
||||||
|
if (!title.trim() || unitWords.length === 0) return
|
||||||
setIsCreating(true)
|
setIsCreating(true)
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: unitTitle,
|
title,
|
||||||
word_ids: selectedWords.map(w => w.id),
|
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) {
|
if (resp.ok) {
|
||||||
@@ -134,9 +149,29 @@ export default function VocabularyPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false)
|
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 (
|
return (
|
||||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||||
@@ -157,7 +192,7 @@ export default function VocabularyPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Woerterbuch</h1>
|
<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'}`}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,18 +219,6 @@ export default function VocabularyPage() {
|
|||||||
<option value="ar">AR</option>
|
<option value="ar">AR</option>
|
||||||
<option value="uk">UK</option>
|
<option value="uk">UK</option>
|
||||||
<option value="pl">PL</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>
|
</select>
|
||||||
<input
|
<input
|
||||||
type="text"
|
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}`}
|
className={`flex-1 px-4 py-3 rounded-xl border outline-none text-lg ${glassInput}`}
|
||||||
autoFocus
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="flex justify-center py-8">
|
<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 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 key={topic.topic} className={`${glassCard} rounded-xl p-3`}>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<span className={`text-sm font-semibold ${isDark ? 'text-cyan-300' : 'text-cyan-700'}`}>
|
<span className={`text-sm font-semibold ${isDark ? 'text-cyan-300' : 'text-cyan-700'}`}>
|
||||||
💡 {topic.topic} ({topic.word_count})
|
{topic.topic} ({topic.word_count})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
@@ -248,44 +256,13 @@ export default function VocabularyPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button onClick={() => addTopicWords(topic, true)}
|
||||||
onClick={async () => {
|
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'}`}>
|
||||||
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'}`}
|
|
||||||
>
|
|
||||||
Anzeigen
|
Anzeigen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => addTopicWords(topic, false)}
|
||||||
onClick={async () => {
|
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'}`}>
|
||||||
setIsSearching(true)
|
+ Alle zur Unit
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,12 +270,17 @@ export default function VocabularyPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSearching && results.length === 0 && query.trim() && topics.length === 0 && (
|
{/* No results message */}
|
||||||
|
{noSearchResults && (
|
||||||
<div className={`${glassCard} rounded-2xl p-8 text-center`}>
|
<div className={`${glassCard} rounded-2xl p-8 text-center`}>
|
||||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Keine Ergebnisse fuer "{query}"</p>
|
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Keine Ergebnisse fuer "{query}"</p>
|
||||||
|
<p className={`text-xs mt-2 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
||||||
|
Du kannst das Wort rechts manuell hinzufuegen →
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Result list */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{results.map(word => (
|
{results.map(word => (
|
||||||
<div
|
<div
|
||||||
@@ -310,7 +292,6 @@ export default function VocabularyPage() {
|
|||||||
}`}
|
}`}
|
||||||
onClick={() => toggleWord(word)}
|
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'}`}>
|
<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 ? (
|
{word.image_url ? (
|
||||||
<img src={word.image_url} alt={word.english} className="w-full h-full object-cover rounded-xl" />
|
<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>
|
<span className="text-xl">📝</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Word info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<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>}
|
{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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`${isDark ? 'text-white/70' : 'text-slate-600'}`}>{word.german}</span>
|
<span className={`${isDark ? 'text-white/70' : 'text-slate-600'}`}>{word.german || word.english}</span>
|
||||||
<AudioButton text={word.german} lang="de" isDark={isDark} size="sm" />
|
{word.german && <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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{/* Select indicator */}
|
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 transition-colors ${
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||||
isSelected(word.id)
|
isSelected(word.id)
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
@@ -360,59 +330,17 @@ export default function VocabularyPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Unit Builder */}
|
{/* Right: Unit Builder */}
|
||||||
<div className="w-80 flex-shrink-0">
|
<UnitBuilder
|
||||||
<div className={`${glassCard} rounded-2xl p-5 sticky top-6`}>
|
isDark={isDark}
|
||||||
<h3 className={`text-lg font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
glassCard={glassCard}
|
||||||
Lernunit erstellen
|
glassInput={glassInput}
|
||||||
</h3>
|
selectedWords={unitWords}
|
||||||
|
onWordsChange={setUnitWords}
|
||||||
<input
|
onCreateUnit={createUnit}
|
||||||
type="text"
|
isCreating={isCreating}
|
||||||
value={unitTitle}
|
noSearchResults={noSearchResults}
|
||||||
onChange={e => setUnitTitle(e.target.value)}
|
searchQuery={query}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user