Compare commits
45 Commits
eecb5472dd
...
855cc4caf4
| Author | SHA1 | Date | |
|---|---|---|---|
| 855cc4caf4 | |||
| c09fc6c7bc | |||
| 387219682d | |||
| 6f43224fda | |||
| 9b96998654 | |||
| 91e8b92bdc | |||
| c2efb9934c | |||
| 0d2e79da66 | |||
| cb4ea8e49a | |||
| d14826b199 | |||
| 693989c1a6 | |||
| bd24fa6ba6 | |||
| ef821831a4 | |||
| 93f7ef88e3 | |||
| 6ea20fa1a3 | |||
| bf2f7daaeb | |||
| fc2fe98bd9 | |||
| 1a272371f4 | |||
| fdde5d43b3 | |||
| f6caa3091f | |||
| 91d6918e2c | |||
| 82f5b4fbba | |||
| afe7a983d1 | |||
| 6d54ee8178 | |||
| a1664ab12c | |||
| 9f21bd070a | |||
| 5012699aaf | |||
| d8771bb509 | |||
| 7f8743d1e3 | |||
| 9de26701dd | |||
| c252556528 | |||
| 68d1679294 | |||
| 9e63b09cb7 | |||
| bd3ca854ef | |||
| b495e63e6f | |||
| 198a0b2a0d | |||
| 6b3bff48f0 | |||
| 0f0bbc3dc0 | |||
| 3cdab5a967 | |||
| f2300219d7 | |||
| aaa52a8901 | |||
| 1fb6702bf4 | |||
| 6210ceb05e | |||
| 3619ddfdad | |||
| f2346b88cd |
@@ -23,6 +23,53 @@ TTS_SERVICE_URL = os.getenv("TTS_SERVICE_URL", "http://bp-compliance-tts:8095")
|
|||||||
# Local cache directory for generated audio
|
# Local cache directory for generated audio
|
||||||
AUDIO_CACHE_DIR = os.path.expanduser("~/Arbeitsblaetter/audio-cache")
|
AUDIO_CACHE_DIR = os.path.expanduser("~/Arbeitsblaetter/audio-cache")
|
||||||
|
|
||||||
|
# Abbreviations expanded before TTS (so the speaker says the full word)
|
||||||
|
_TTS_EXPANSIONS = {
|
||||||
|
"sth.": "something",
|
||||||
|
"sth": "something",
|
||||||
|
"sb.": "somebody",
|
||||||
|
"sb": "somebody",
|
||||||
|
"smth.": "something",
|
||||||
|
"smb.": "somebody",
|
||||||
|
"sbd.": "somebody",
|
||||||
|
"etc.": "etcetera",
|
||||||
|
"e.g.": "for example",
|
||||||
|
"i.e.": "that is",
|
||||||
|
"esp.": "especially",
|
||||||
|
"approx.": "approximately",
|
||||||
|
"vs.": "versus",
|
||||||
|
"nr.": "number",
|
||||||
|
"no.": "number",
|
||||||
|
"p.": "page",
|
||||||
|
"adj.": "adjective",
|
||||||
|
"adv.": "adverb",
|
||||||
|
"prep.": "preposition",
|
||||||
|
"pron.": "pronoun",
|
||||||
|
"pl.": "plural",
|
||||||
|
"sg.": "singular",
|
||||||
|
"syn.": "synonym",
|
||||||
|
"ant.": "antonym",
|
||||||
|
# DE
|
||||||
|
"usw.": "und so weiter",
|
||||||
|
"bzw.": "beziehungsweise",
|
||||||
|
"z.B.": "zum Beispiel",
|
||||||
|
"d.h.": "das heisst",
|
||||||
|
"vgl.": "vergleiche",
|
||||||
|
"ca.": "circa",
|
||||||
|
"evtl.": "eventuell",
|
||||||
|
"ggf.": "gegebenenfalls",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_abbreviations(text: str) -> str:
|
||||||
|
"""Expand abbreviations so TTS speaks the full word."""
|
||||||
|
import re
|
||||||
|
for abbr, full in _TTS_EXPANSIONS.items():
|
||||||
|
# Word-boundary aware replacement (case-insensitive)
|
||||||
|
pattern = re.escape(abbr)
|
||||||
|
text = re.sub(rf'\b{pattern}', full, text, flags=re.IGNORECASE)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _ensure_cache_dir():
|
def _ensure_cache_dir():
|
||||||
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
|
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
|
||||||
@@ -56,48 +103,17 @@ async def synthesize_word(
|
|||||||
if os.path.exists(cached):
|
if os.path.exists(cached):
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Call Piper TTS service
|
# Expand abbreviations before speaking
|
||||||
|
speak_text = _expand_abbreviations(text)
|
||||||
|
|
||||||
|
# Call Piper TTS service via /synthesize-direct (returns MP3, selects language correctly)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{TTS_SERVICE_URL}/synthesize",
|
f"{TTS_SERVICE_URL}/synthesize-direct",
|
||||||
json={
|
json={
|
||||||
"text": text,
|
"text": speak_text,
|
||||||
"language": language,
|
"language": language,
|
||||||
"voice": "thorsten-high" if language == "de" else "lessac-high",
|
|
||||||
"module_id": "vocabulary",
|
|
||||||
"content_id": word_id or _cache_key(text, language),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning(f"TTS service returned {resp.status_code} for '{text}'")
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = resp.json()
|
|
||||||
audio_url = data.get("audio_url") or data.get("presigned_url")
|
|
||||||
|
|
||||||
if audio_url:
|
|
||||||
# Download the audio file
|
|
||||||
audio_resp = await client.get(audio_url)
|
|
||||||
if audio_resp.status_code == 200:
|
|
||||||
with open(cached, "wb") as f:
|
|
||||||
f.write(audio_resp.content)
|
|
||||||
logger.info(f"TTS cached: '{text}' ({language}) → {cached}")
|
|
||||||
return cached
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"TTS service unavailable: {e}")
|
|
||||||
|
|
||||||
# Fallback: try direct MP3 endpoint
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
resp = await client.post(
|
|
||||||
f"{TTS_SERVICE_URL}/synthesize/mp3",
|
|
||||||
json={
|
|
||||||
"text": text,
|
|
||||||
"language": language,
|
|
||||||
"voice": "thorsten-high" if language == "de" else "lessac-high",
|
|
||||||
"module_id": "vocabulary",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("audio"):
|
if resp.status_code == 200 and resp.headers.get("content-type", "").startswith("audio"):
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Image Service — Fetches vocabulary images from Wikipedia + Emoji fallback.
|
||||||
|
|
||||||
|
On-demand: Images are fetched when a learning unit is created,
|
||||||
|
then cached in the vocabulary_words.image_url field.
|
||||||
|
|
||||||
|
Sources (in priority order):
|
||||||
|
1. Wikipedia REST API (free, no account needed, CC license)
|
||||||
|
2. Emoji fallback for abstract words
|
||||||
|
|
||||||
|
Later: Unsplash API (needs account), Stable Diffusion (local batch)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Emoji map for common abstract words that don't have good photos
|
||||||
|
EMOJI_FALLBACK: dict[str, str] = {
|
||||||
|
"strong": "💪", "weak": "😩", "hard-working": "📚", "skinny": "🦴",
|
||||||
|
"female": "👩", "male": "👨", "definite": "✅", "definitely": "✅",
|
||||||
|
"even": "⚖️", "violent": "⚡", "opinion": "💭", "message": "💬",
|
||||||
|
"beginning": "🏁", "mention": "🗣️", "summarize": "📋", "mark": "✏️",
|
||||||
|
"throw": "🤾", "take": "🤲", "sum": "➕", "on the one hand": "👐",
|
||||||
|
"apple": "🍎", "gym": "🏋️", "medal": "🏅", "sportswoman": "🏃♀️",
|
||||||
|
"role model": "⭐", "tourist office": "🏨", "the olympics": "🏅",
|
||||||
|
"box": "🥊", "football": "⚽", "footballer": "⚽",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_wikipedia_image(word: str) -> Optional[str]:
|
||||||
|
"""Fetch thumbnail image URL from Wikipedia for a word."""
|
||||||
|
# Clean word for Wikipedia lookup
|
||||||
|
query = word.split(",")[0].strip() # "throw, threw, thrown" → "throw"
|
||||||
|
query = query.replace("sth.", "").replace("sb.", "").strip()
|
||||||
|
if query.startswith("the "):
|
||||||
|
query = query[4:]
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"https://en.wikipedia.org/api/rest_v1/page/summary/{query}",
|
||||||
|
headers={"User-Agent": "BreakPilot/1.0 (https://breakpilot.com; education platform; contact@breakpilot.com)"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
thumb = data.get("thumbnail", {})
|
||||||
|
url = thumb.get("source")
|
||||||
|
if url:
|
||||||
|
logger.info(f"Wikipedia image for '{word}': {url}")
|
||||||
|
return url
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Wikipedia image lookup failed for '{word}': {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_emoji_for_word(word: str) -> str:
|
||||||
|
"""Get an emoji representation for a word."""
|
||||||
|
lower = word.lower()
|
||||||
|
for key, emoji in EMOJI_FALLBACK.items():
|
||||||
|
if key in lower:
|
||||||
|
return emoji
|
||||||
|
# Generic fallback by part of speech could be added here
|
||||||
|
return "📝"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_image_for_word(word: str) -> str:
|
||||||
|
"""Get the best available image for a vocabulary word.
|
||||||
|
|
||||||
|
Returns a URL (Wikipedia) or emoji string.
|
||||||
|
Result should be stored in vocabulary_words.image_url.
|
||||||
|
"""
|
||||||
|
# Try Wikipedia first
|
||||||
|
url = await fetch_wikipedia_image(word)
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
|
||||||
|
# Fallback to emoji
|
||||||
|
return get_emoji_for_word(word)
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_words_with_images(word_ids: list[str]) -> int:
|
||||||
|
"""Fetch and store images for vocabulary words that don't have one yet."""
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
updated = 0
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT id, english, image_url FROM vocabulary_words WHERE id = ANY($1::uuid[])",
|
||||||
|
[uuid.UUID(wid) for wid in word_ids],
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row["image_url"]:
|
||||||
|
continue # Already has an image
|
||||||
|
|
||||||
|
image = await get_image_for_word(row["english"])
|
||||||
|
if image:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE vocabulary_words SET image_url = $1 WHERE id = $2",
|
||||||
|
image, row["id"],
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
logger.info(f"Image for '{row['english']}': {image[:60]}...")
|
||||||
|
|
||||||
|
logger.info(f"Enriched {updated} words with images")
|
||||||
|
return updated
|
||||||
@@ -41,14 +41,22 @@ router = APIRouter(prefix="/vocabulary", tags=["vocabulary"])
|
|||||||
@router.get("/search")
|
@router.get("/search")
|
||||||
async def api_search_words(
|
async def api_search_words(
|
||||||
q: str = Query("", description="Search query"),
|
q: str = Query("", description="Search query"),
|
||||||
lang: str = Query("en", pattern="^(en|de)$"),
|
lang: str = Query("en"),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
|
source: str = Query("kaikki", description="Source: kaikki (6M words) or manual (27 words)"),
|
||||||
):
|
):
|
||||||
"""Full-text search for vocabulary words."""
|
"""Full-text search for vocabulary words.
|
||||||
|
|
||||||
|
source=kaikki searches the 6.27M Kaikki/Wiktionary dictionary.
|
||||||
|
source=manual searches the manually curated vocabulary_words table.
|
||||||
|
"""
|
||||||
if not q.strip():
|
if not q.strip():
|
||||||
return {"words": [], "query": q, "total": 0}
|
return {"words": [], "query": q, "total": 0}
|
||||||
|
|
||||||
|
if source == "kaikki":
|
||||||
|
return await _search_kaikki(q.strip(), lang, limit, offset)
|
||||||
|
|
||||||
words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset)
|
words = await search_words(q.strip(), lang=lang, limit=limit, offset=offset)
|
||||||
return {
|
return {
|
||||||
"words": [w.to_dict() for w in words],
|
"words": [w.to_dict() for w in words],
|
||||||
@@ -57,6 +65,52 @@ async def api_search_words(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_kaikki(q: str, lang: str, limit: int, offset: int):
|
||||||
|
"""Search the vocabulary_kaikki table (6.27M Wiktionary entries)."""
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT id, word, lang, pos, ipa, translations, example
|
||||||
|
FROM vocabulary_kaikki
|
||||||
|
WHERE lang = $1 AND lower(word) LIKE $2
|
||||||
|
ORDER BY length(word), lower(word)
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
""",
|
||||||
|
lang, f"{q.lower()}%", limit, offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
words = []
|
||||||
|
for r in rows:
|
||||||
|
tr = r["translations"]
|
||||||
|
if isinstance(tr, str):
|
||||||
|
import json as _json
|
||||||
|
tr = _json.loads(tr)
|
||||||
|
words.append({
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"english": r["word"] if r["lang"] == "en" else "",
|
||||||
|
"german": tr.get("de", {}).get("text", "") if r["lang"] == "en" else r["word"] if r["lang"] == "de" else "",
|
||||||
|
"word": r["word"],
|
||||||
|
"lang": r["lang"],
|
||||||
|
"ipa_en": r["ipa"] if r["lang"] == "en" else "",
|
||||||
|
"ipa_de": r["ipa"] if r["lang"] == "de" else "",
|
||||||
|
"part_of_speech": r["pos"],
|
||||||
|
"syllables_en": [],
|
||||||
|
"syllables_de": [],
|
||||||
|
"example_en": r["example"] if r["lang"] == "en" else "",
|
||||||
|
"example_de": r["example"] if r["lang"] == "de" else "",
|
||||||
|
"image_url": "",
|
||||||
|
"audio_url_en": "",
|
||||||
|
"audio_url_de": "",
|
||||||
|
"difficulty": 0,
|
||||||
|
"tags": [],
|
||||||
|
"translations": tr,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"words": words, "query": q, "total": len(words), "source": "kaikki"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse")
|
@router.get("/browse")
|
||||||
async def api_browse_words(
|
async def api_browse_words(
|
||||||
pos: str = Query("", description="Part of speech filter"),
|
pos: str = Query("", description="Part of speech filter"),
|
||||||
@@ -92,10 +146,13 @@ async def api_get_filters():
|
|||||||
tags = await get_all_tags()
|
tags = await get_all_tags()
|
||||||
pos_list = await get_all_pos()
|
pos_list = await get_all_pos()
|
||||||
total = await count_words()
|
total = await count_words()
|
||||||
|
# Kaikki stats (hardcoded to avoid slow COUNT on 6M rows)
|
||||||
return {
|
return {
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"parts_of_speech": pos_list,
|
"parts_of_speech": pos_list,
|
||||||
"total_words": total,
|
"total_words": total,
|
||||||
|
"kaikki_total": 6271749,
|
||||||
|
"kaikki_languages": 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +218,22 @@ async def api_get_syllable_audio(word_id: str, lang: str = "en"):
|
|||||||
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tts")
|
||||||
|
async def api_tts(text: str = Query("", min_length=1), lang: str = Query("de")):
|
||||||
|
"""Text-to-Speech endpoint. Returns MP3 audio for any text.
|
||||||
|
|
||||||
|
Uses Piper TTS (Thorsten DE / Lessac EN). Cached by text+lang.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import Response as FastAPIResponse
|
||||||
|
from services.audio import get_or_generate_audio
|
||||||
|
|
||||||
|
audio_bytes = await get_or_generate_audio(text, language=lang)
|
||||||
|
if not audio_bytes:
|
||||||
|
raise HTTPException(status_code=503, detail="TTS Service nicht verfuegbar")
|
||||||
|
|
||||||
|
return FastAPIResponse(content=audio_bytes, media_type="audio/mpeg")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Learning Unit Creation from Word Selection
|
# Learning Unit Creation from Word Selection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -254,6 +327,13 @@ async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload):
|
|||||||
},
|
},
|
||||||
}, f, ensure_ascii=False, indent=2)
|
}, 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")
|
logger.info(f"Created vocab unit {lu.id} with {len(words)} words")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -331,6 +411,83 @@ async def api_bulk_import(payload: BulkImportPayload):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enrich-images")
|
||||||
|
async def api_enrich_images(word_ids: List[str] = None):
|
||||||
|
"""Fetch and store images for vocabulary words (Wikipedia + emoji fallback)."""
|
||||||
|
from services.image_service import enrich_words_with_images
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
import uuid as _uuid
|
||||||
|
|
||||||
|
if not word_ids:
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("SELECT id FROM vocabulary_words WHERE image_url = '' OR image_url IS NULL")
|
||||||
|
word_ids = [str(r["id"]) for r in rows]
|
||||||
|
|
||||||
|
if not word_ids:
|
||||||
|
return {"enriched": 0, "message": "All words already have images"}
|
||||||
|
|
||||||
|
count = await enrich_words_with_images(word_ids)
|
||||||
|
return {"enriched": count, "total": len(word_ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/topics")
|
||||||
|
async def api_get_topics(
|
||||||
|
q: str = Query("", description="Search topic or word"),
|
||||||
|
lang: str = Query("en", description="Display language for word labels"),
|
||||||
|
):
|
||||||
|
"""Find topics matching a search word. Returns related word lists.
|
||||||
|
|
||||||
|
If q matches a topic name → returns that topic.
|
||||||
|
If q matches a word in any topic → returns all topics containing that word.
|
||||||
|
Words are returned with translations if lang != en.
|
||||||
|
"""
|
||||||
|
from vocabulary.db import get_pool
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if not q.strip():
|
||||||
|
rows = await conn.fetch("SELECT topic, words, word_count FROM vocabulary_topics ORDER BY topic LIMIT 50")
|
||||||
|
else:
|
||||||
|
q_lower = q.strip().lower()
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT topic, words, word_count FROM vocabulary_topics
|
||||||
|
WHERE lower(topic) LIKE $1 OR $2 = ANY(words)
|
||||||
|
ORDER BY word_count DESC
|
||||||
|
""", f"%{q_lower}%", q_lower)
|
||||||
|
|
||||||
|
# Translate word labels if not English
|
||||||
|
topics = []
|
||||||
|
for r in rows:
|
||||||
|
en_words = list(r["words"])
|
||||||
|
display_words = en_words
|
||||||
|
if lang != "en":
|
||||||
|
# Batch-lookup translations from Kaikki
|
||||||
|
translated = []
|
||||||
|
for w in en_words[:20]: # Limit to 20 for speed
|
||||||
|
tr_row = await conn.fetchrow(
|
||||||
|
"SELECT translations FROM vocabulary_kaikki WHERE lang = 'en' AND lower(word) = $1 LIMIT 1",
|
||||||
|
w.lower(),
|
||||||
|
)
|
||||||
|
if tr_row and tr_row["translations"]:
|
||||||
|
import json as _json
|
||||||
|
tr = tr_row["translations"]
|
||||||
|
if isinstance(tr, str):
|
||||||
|
tr = _json.loads(tr)
|
||||||
|
tr_text = tr.get(lang, {}).get("text", "")
|
||||||
|
translated.append(tr_text if tr_text else w)
|
||||||
|
else:
|
||||||
|
translated.append(w)
|
||||||
|
display_words = translated + en_words[20:]
|
||||||
|
topics.append({
|
||||||
|
"topic": r["topic"],
|
||||||
|
"words": en_words,
|
||||||
|
"display_words": display_words,
|
||||||
|
"word_count": r["word_count"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"topics": topics, "query": q, "lang": lang}
|
||||||
|
|
||||||
|
|
||||||
class TranslateRequest(BaseModel):
|
class TranslateRequest(BaseModel):
|
||||||
word_ids: List[str]
|
word_ids: List[str]
|
||||||
target_language: str
|
target_language: str
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ volumes:
|
|||||||
transcription_models:
|
transcription_models:
|
||||||
transcription_temp:
|
transcription_temp:
|
||||||
lehrer_backend_data:
|
lehrer_backend_data:
|
||||||
|
lehrer_arbeitsblaetter:
|
||||||
opensearch_data:
|
opensearch_data:
|
||||||
# Communication (Jitsi + Matrix)
|
# Communication (Jitsi + Matrix)
|
||||||
synapse_data:
|
synapse_data:
|
||||||
@@ -159,6 +160,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- lehrer_backend_data:/app/data
|
- lehrer_backend_data:/app/data
|
||||||
|
- lehrer_arbeitsblaetter:/root/Arbeitsblaetter
|
||||||
environment:
|
environment:
|
||||||
PORT: 8001
|
PORT: 8001
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dlehrer,core,public
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-breakpilot}:${POSTGRES_PASSWORD:-breakpilot123}@bp-core-postgres:5432/${POSTGRES_DB:-breakpilot_db}?options=-csearch_path%3Dlehrer,core,public
|
||||||
|
|||||||
@@ -20,10 +20,21 @@ async function proxyRequest(
|
|||||||
fetchOptions.body = await request.text()
|
fetchOptions.body = await request.text()
|
||||||
}
|
}
|
||||||
const resp = await fetch(url, fetchOptions)
|
const resp = await fetch(url, fetchOptions)
|
||||||
|
const contentType = resp.headers.get('Content-Type') || 'application/json'
|
||||||
|
|
||||||
|
// Binary responses (audio, images) must use arrayBuffer, not text
|
||||||
|
if (contentType.startsWith('audio') || contentType.startsWith('image')) {
|
||||||
|
const buffer = await resp.arrayBuffer()
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
status: resp.status,
|
||||||
|
headers: { 'Content-Type': contentType },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const data = await resp.text()
|
const data = await resp.text()
|
||||||
return new NextResponse(data, {
|
return new NextResponse(data, {
|
||||||
status: resp.status,
|
status: resp.status,
|
||||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
headers: { 'Content-Type': contentType },
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json({ error: String(e) }, { status: 502 })
|
return NextResponse.json({ error: String(e) }, { status: 502 })
|
||||||
|
|||||||
@@ -4,6 +4,15 @@
|
|||||||
|
|
||||||
/* BreakPilot Studio v2 - Base Styles */
|
/* BreakPilot Studio v2 - Base Styles */
|
||||||
|
|
||||||
|
/* Hide scrollbars globally but keep scroll functionality */
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,109 +1,126 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import React from 'react'
|
||||||
import { useLanguage } from '@/lib/LanguageContext'
|
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
|
|
||||||
export default function ImpressumPage() {
|
export default function ImpressumPage() {
|
||||||
const { t } = useLanguage()
|
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
|
const gc = isDark
|
||||||
|
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||||
|
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||||
|
const h2c = isDark ? 'text-white' : 'text-slate-900'
|
||||||
|
const tc = isDark ? 'text-white/70' : 'text-slate-600'
|
||||||
|
const sc = isDark ? 'text-white/90' : 'text-slate-800'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${
|
<div className={`min-h-screen flex ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'}`}>
|
||||||
isDark
|
<div className="relative z-10 p-4 flex-shrink-0"><Sidebar /></div>
|
||||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
<div className="flex-1 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
<div className="max-w-3xl mx-auto px-6 py-8 space-y-6">
|
||||||
}`}>
|
|
||||||
<div className="flex-1 p-8">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
{/* Back Link */}
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
|
|
||||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
{t('back_to_selection')}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Content Card */}
|
<h1 className={`text-2xl font-bold ${h2c}`}>Impressum</h1>
|
||||||
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
|
|
||||||
isDark
|
|
||||||
? 'bg-white/10 border-white/20'
|
|
||||||
: 'bg-white/70 border-black/10 shadow-lg'
|
|
||||||
}`}>
|
|
||||||
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
|
||||||
{t('imprint')}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
<section className={`${gc} rounded-2xl p-6`}>
|
||||||
<section>
|
<h2 className={`text-lg font-semibold mb-3 ${h2c}`}>Angaben gemaess § 5 TMG</h2>
|
||||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<div className={`space-y-1 ${tc}`}>
|
||||||
Angaben gemäß § 5 TMG
|
<p>[Firmenname GmbH]</p>
|
||||||
</h2>
|
<p>[Strasse und Hausnummer]</p>
|
||||||
<p>
|
<p>[PLZ Ort]</p>
|
||||||
BreakPilot GmbH<br />
|
<p className="mt-3"><strong>Vertreten durch:</strong> [Geschaeftsfuehrer]</p>
|
||||||
Musterstraße 123<br />
|
<p><strong>Registergericht:</strong> [Amtsgericht], HRB [Nummer]</p>
|
||||||
12345 Musterstadt<br />
|
<p><strong>USt-IdNr.:</strong> DE [Nummer]</p>
|
||||||
Deutschland
|
</div>
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section className={`${gc} rounded-2xl p-6`}>
|
||||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<h2 className={`text-lg font-semibold mb-3 ${h2c}`}>Kontakt</h2>
|
||||||
Kontakt
|
<div className={`space-y-1 ${tc}`}>
|
||||||
</h2>
|
<p>E-Mail: [E-Mail-Adresse]</p>
|
||||||
<p>
|
<p>Telefon: [Telefonnummer]</p>
|
||||||
Telefon: +49 (0) 123 456789<br />
|
</div>
|
||||||
E-Mail: info@breakpilot.de
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section className={`${gc} rounded-2xl p-6`}>
|
||||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<h2 className={`text-lg font-semibold mb-3 ${h2c}`}>Quellen, Lizenzen und Namensnennung</h2>
|
||||||
Vertretungsberechtigte Geschäftsführer
|
<div className={`space-y-4 ${tc}`}>
|
||||||
</h2>
|
|
||||||
<p>Max Mustermann</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<div>
|
||||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<h3 className={`font-medium mb-1 ${sc}`}>Woerterbuch- und Uebersetzungsdaten</h3>
|
||||||
Registereintrag
|
<p className="text-sm">
|
||||||
</h2>
|
Basierend auf Daten aus{' '}
|
||||||
<p>
|
<a href="https://en.wiktionary.org" className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">Wiktionary</a>,
|
||||||
Eintragung im Handelsregister<br />
|
extrahiert ueber{' '}
|
||||||
Registergericht: Amtsgericht Musterstadt<br />
|
<a href="https://kaikki.org" className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">Kaikki.org</a>.
|
||||||
Registernummer: HRB 12345
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
<p className="text-xs mt-1 opacity-70">
|
||||||
|
Referenz: Tatu Ylonen: "Wiktextract: Wiktionary as Machine-Readable Structured Data", LREC 2022, pp. 1317-1325.
|
||||||
<section>
|
|
||||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
|
||||||
Umsatzsteuer-ID
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:<br />
|
|
||||||
DE 123456789
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
<p className="text-xs mt-1 opacity-70">
|
||||||
|
Lizenz: CC BY-SA 3.0 und GFDL. Aenderungen: Strukturierte Extraktion, Filterung und Aufbereitung fuer Lernzwecke.
|
||||||
<div className={`mt-8 p-4 rounded-2xl ${
|
|
||||||
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
|
|
||||||
}`}>
|
|
||||||
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
|
|
||||||
Hinweis: Dies ist ein Platzhalter. Bitte ersetzen Sie diese Angaben durch Ihre tatsächlichen Unternehmensdaten.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<h3 className={`font-medium mb-1 ${sc}`}>IPA-Lautschrift</h3>
|
||||||
|
<p className="text-sm">
|
||||||
|
<a href="https://github.com/open-dict-data/ipa-dict" className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">ipa-dict</a> — Phonemische Transkriptionen fuer 31 Sprachen.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 opacity-70">Lizenz: MIT License.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer />
|
<div>
|
||||||
|
<h3 className={`font-medium mb-1 ${sc}`}>Vokabel-Bilder</h3>
|
||||||
|
<p className="text-sm">
|
||||||
|
<a href="https://commons.wikimedia.org" className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">Wikimedia Commons</a> — Freie Medien.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 opacity-70">Lizenz: CC BY-SA. Einzelbildnachweise auf Anfrage.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-medium mb-1 ${sc}`}>Sprachsynthese (Text-to-Speech)</h3>
|
||||||
|
<p className="text-sm">
|
||||||
|
<a href="https://github.com/rhasspy/piper" className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">Piper TTS</a> (Rhasspy Project) — Deutsch: Thorsten, Englisch: Lessac.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 opacity-70">Lizenz: MIT License.</p>
|
||||||
|
<p className="text-sm mt-1">Weitere Sprachen (TR, AR, UK, RU, PL, FR, ES): Microsoft Edge TTS.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-medium mb-1 ${sc}`}>Rechtschreibpruefung</h3>
|
||||||
|
<p className="text-sm">
|
||||||
|
<a href="https://github.com/barrust/pyspellchecker" className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">pyspellchecker</a> — Verfuegbar fuer: EN, DE, FR, ES, PT, IT, NL, RU, AR.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 opacity-70">Lizenz: MIT License.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${gc} rounded-2xl p-6`}>
|
||||||
|
<h2 className={`text-lg font-semibold mb-3 ${h2c}`}>Haftungsausschluss</h2>
|
||||||
|
<div className={`space-y-2 text-sm ${tc}`}>
|
||||||
|
<p><strong>Haftung fuer Inhalte:</strong> [Standardtext einfuegen]</p>
|
||||||
|
<p><strong>Haftung fuer Links:</strong> [Standardtext einfuegen]</p>
|
||||||
|
<p><strong>Urheberrecht:</strong> [Standardtext einfuegen]</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${gc} rounded-2xl p-6`}>
|
||||||
|
<h2 className={`text-lg font-semibold mb-3 ${h2c}`}>Streitschlichtung</h2>
|
||||||
|
<p className={`text-sm ${tc}`}>[Hinweis zur OS-Plattform und Verbraucherstreitbeilegung]</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={`${gc} rounded-2xl p-6`}>
|
||||||
|
<h2 className={`text-lg font-semibold mb-3 ${h2c}`}>Datenschutzbeauftragter</h2>
|
||||||
|
<div className={`space-y-1 ${tc}`}>
|
||||||
|
<p>[Name, Adresse, E-Mail]</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AlertsProvider } from '@/lib/AlertsContext'
|
|||||||
import { AlertsB2BProvider } from '@/lib/AlertsB2BContext'
|
import { AlertsB2BProvider } from '@/lib/AlertsB2BContext'
|
||||||
import { MessagesProvider } from '@/lib/MessagesContext'
|
import { MessagesProvider } from '@/lib/MessagesContext'
|
||||||
import { ActivityProvider } from '@/lib/ActivityContext'
|
import { ActivityProvider } from '@/lib/ActivityContext'
|
||||||
|
import { NativeLanguageProvider } from '@/lib/NativeLanguageContext'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'BreakPilot Studio v2',
|
title: 'BreakPilot Studio v2',
|
||||||
@@ -26,7 +27,9 @@ export default function RootLayout({
|
|||||||
<AlertsB2BProvider>
|
<AlertsB2BProvider>
|
||||||
<MessagesProvider>
|
<MessagesProvider>
|
||||||
<ActivityProvider>
|
<ActivityProvider>
|
||||||
|
<NativeLanguageProvider>
|
||||||
{children}
|
{children}
|
||||||
|
</NativeLanguageProvider>
|
||||||
</ActivityProvider>
|
</ActivityProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</AlertsB2BProvider>
|
</AlertsB2BProvider>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
import { FlashCard } from '@/components/learn/FlashCard'
|
import { FlashCard } from '@/components/learn/FlashCard'
|
||||||
import { AudioButton } from '@/components/learn/AudioButton'
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export default function FlashcardsPage() {
|
|||||||
const { unitId } = useParams<{ unitId: string }>()
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
|
|
||||||
const [items, setItems] = useState<QAItem[]>([])
|
const [items, setItems] = useState<QAItem[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
@@ -101,7 +103,7 @@ export default function FlashcardsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'}`}>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
@@ -181,6 +183,6 @@ export default function FlashcardsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
import { AudioButton } from '@/components/learn/AudioButton'
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export default function ListenPage() {
|
|||||||
const { unitId } = useParams<{ unitId: string }>()
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
|
|
||||||
const [items, setItems] = useState<QAItem[]>([])
|
const [items, setItems] = useState<QAItem[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
@@ -74,7 +76,7 @@ export default function ListenPage() {
|
|||||||
const currentItem = items[currentIndex]
|
const currentItem = items[currentIndex]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-green-50 to-emerald-100'}`}>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
@@ -132,6 +134,6 @@ export default function ListenPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,42 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||||
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
|
import { ExerciseLayout } from '@/components/learn/ExerciseLayout'
|
||||||
|
|
||||||
interface QAItem { id: string; question: string; answer: string }
|
interface QAItem {
|
||||||
|
id: string; question: string; answer: string
|
||||||
|
translations?: Record<string, any>
|
||||||
|
image_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
function getApiBase() { return '' }
|
function getApiBase() { return '' }
|
||||||
|
|
||||||
|
function SelectedImage({ items, selectedId, isDark }: { items: QAItem[]; selectedId: string | null; isDark: boolean }) {
|
||||||
|
if (!selectedId) return null
|
||||||
|
const item = items.find(i => i.id === selectedId)
|
||||||
|
if (!item?.image_url) return null
|
||||||
|
const isEmoji = item.image_url.length <= 4
|
||||||
|
return (
|
||||||
|
<div className={`mt-4 rounded-2xl overflow-hidden flex items-center justify-center ${
|
||||||
|
isDark ? 'bg-white/5' : 'bg-slate-50'
|
||||||
|
}`} style={{ minHeight: isEmoji ? 80 : 120 }}>
|
||||||
|
{isEmoji ? (
|
||||||
|
<span className="text-6xl">{item.image_url}</span>
|
||||||
|
) : (
|
||||||
|
<img src={item.image_url} alt={item.question} className="max-h-[160px] object-contain rounded-xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function MatchPage() {
|
export default function MatchPage() {
|
||||||
const { unitId } = useParams<{ unitId: string }>()
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
|
|
||||||
const [allItems, setAllItems] = useState<QAItem[]>([])
|
const [allItems, setAllItems] = useState<QAItem[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -20,7 +46,10 @@ export default function MatchPage() {
|
|||||||
const [selectedLeft, setSelectedLeft] = useState<string | null>(null)
|
const [selectedLeft, setSelectedLeft] = useState<string | null>(null)
|
||||||
const [matched, setMatched] = useState<Set<string>>(new Set())
|
const [matched, setMatched] = useState<Set<string>>(new Set())
|
||||||
const [wrongPair, setWrongPair] = useState<string | null>(null)
|
const [wrongPair, setWrongPair] = useState<string | null>(null)
|
||||||
|
const [firstTryCorrect, setFirstTryCorrect] = useState(0)
|
||||||
|
const [retryCorrect, setRetryCorrect] = useState(0)
|
||||||
const [errors, setErrors] = useState(0)
|
const [errors, setErrors] = useState(0)
|
||||||
|
const [failedIds, setFailedIds] = useState<Set<string>>(new Set())
|
||||||
const [isComplete, setIsComplete] = useState(false)
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
|
|
||||||
const glassCard = isDark
|
const glassCard = isDark
|
||||||
@@ -36,16 +65,8 @@ export default function MatchPage() {
|
|||||||
})()
|
})()
|
||||||
}, [unitId])
|
}, [unitId])
|
||||||
|
|
||||||
// Take 6 items per round
|
const roundItems = useMemo(() => allItems.slice(round * 6, round * 6 + 6), [allItems, round])
|
||||||
const roundItems = useMemo(() => {
|
const shuffledRight = useMemo(() => [...roundItems].sort(() => Math.random() - 0.5), [roundItems])
|
||||||
const start = round * 6
|
|
||||||
return allItems.slice(start, start + 6)
|
|
||||||
}, [allItems, round])
|
|
||||||
|
|
||||||
// Shuffled right column
|
|
||||||
const shuffledRight = useMemo(() => {
|
|
||||||
return [...roundItems].sort(() => Math.random() - 0.5)
|
|
||||||
}, [roundItems])
|
|
||||||
|
|
||||||
const handleLeftTap = useCallback((id: string) => {
|
const handleLeftTap = useCallback((id: string) => {
|
||||||
if (matched.has(id)) return
|
if (matched.has(id)) return
|
||||||
@@ -55,116 +76,145 @@ export default function MatchPage() {
|
|||||||
|
|
||||||
const handleRightTap = useCallback((id: string) => {
|
const handleRightTap = useCallback((id: string) => {
|
||||||
if (!selectedLeft || matched.has(id)) return
|
if (!selectedLeft || matched.has(id)) return
|
||||||
|
|
||||||
if (selectedLeft === id) {
|
if (selectedLeft === id) {
|
||||||
// Correct match
|
|
||||||
setMatched(prev => new Set([...prev, id]))
|
setMatched(prev => new Set([...prev, id]))
|
||||||
|
if (failedIds.has(id)) setRetryCorrect(c => c + 1)
|
||||||
|
else setFirstTryCorrect(c => c + 1)
|
||||||
setSelectedLeft(null)
|
setSelectedLeft(null)
|
||||||
|
|
||||||
// Check if round complete
|
|
||||||
if (matched.size + 1 >= roundItems.length) {
|
if (matched.size + 1 >= roundItems.length) {
|
||||||
const nextStart = (round + 1) * 6
|
const nextStart = (round + 1) * 6
|
||||||
if (nextStart >= allItems.length) {
|
if (nextStart >= allItems.length) setTimeout(() => setIsComplete(true), 500)
|
||||||
setTimeout(() => setIsComplete(true), 500)
|
else setTimeout(() => { setRound(r => r + 1); setMatched(new Set()); setSelectedLeft(null) }, 800)
|
||||||
} else {
|
|
||||||
setTimeout(() => {
|
|
||||||
setRound(r => r + 1)
|
|
||||||
setMatched(new Set())
|
|
||||||
setSelectedLeft(null)
|
|
||||||
}, 800)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Wrong match
|
|
||||||
setWrongPair(id)
|
setWrongPair(id)
|
||||||
setErrors(e => e + 1)
|
setErrors(e => e + 1)
|
||||||
setTimeout(() => {
|
setFailedIds(prev => new Set([...prev, selectedLeft]))
|
||||||
setWrongPair(null)
|
setTimeout(() => { setWrongPair(null); setSelectedLeft(null) }, 600)
|
||||||
setSelectedLeft(null)
|
}
|
||||||
}, 600)
|
}, [selectedLeft, matched, roundItems, round, allItems, failedIds])
|
||||||
|
|
||||||
|
const restart = () => {
|
||||||
|
setRound(0); setMatched(new Set()); setFirstTryCorrect(0); setRetryCorrect(0)
|
||||||
|
setErrors(0); setFailedIds(new Set()); setIsComplete(false); setSelectedLeft(null)
|
||||||
}
|
}
|
||||||
}, [selectedLeft, matched, roundItems, round, allItems])
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-indigo-50 to-violet-100'}`}>
|
return <div className="flex items-center justify-center py-20">
|
||||||
<div className="w-8 h-8 border-4 border-indigo-400 border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-4 border-indigo-400 border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPairs = allItems.length
|
const totalPairs = allItems.length
|
||||||
const matchedTotal = round * 6 + matched.size
|
const matchedTotal = round * 6 + matched.size
|
||||||
|
const isPerfect = isComplete && errors === 0
|
||||||
|
|
||||||
|
// Native helper panel: list of words in native language
|
||||||
|
const nativePanel = (
|
||||||
|
<div className={`${glassCard} rounded-2xl p-4`}>
|
||||||
|
<p className={`text-xs font-medium mb-3 ${isDark ? 'text-cyan-300/70' : 'text-cyan-600'}`}>
|
||||||
|
{nativeLang.toUpperCase()} · {t('english')} · {t('german')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{roundItems.map(item => {
|
||||||
|
const native = wordInNative(item.translations)
|
||||||
|
const isSelected = selectedLeft === item.id
|
||||||
|
const isMatched = matched.has(item.id)
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-indigo-50 to-violet-100'}`}>
|
<div key={`n-${item.id}`}
|
||||||
{/* Header */}
|
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg border text-sm transition-all ${
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
isMatched
|
||||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
? 'opacity-30 border-green-400/30 bg-green-500/5'
|
||||||
<button onClick={() => router.push('/learn')} className={`flex items-center gap-2 text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}>
|
: isSelected
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
? (isDark ? 'border-cyan-400/50 bg-cyan-500/10 text-cyan-200' : 'border-cyan-400 bg-cyan-50 text-cyan-800')
|
||||||
Zurueck
|
: (isDark ? 'border-white/10 text-white/50' : 'border-slate-200 text-slate-500')
|
||||||
</button>
|
}`}>
|
||||||
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Zuordnen</h1>
|
<span className="flex-1 truncate">{native || '—'}</span>
|
||||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{matchedTotal}/{totalPairs}</span>
|
{native && !isMatched && (
|
||||||
</div>
|
<AudioButton text={native} lang={nativeLang as 'en' | 'de'} isDark={isDark} size="sm" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
|
||||||
{isComplete ? (
|
|
||||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
|
||||||
<StarRating stars={accuracyToStars(totalPairs, totalPairs + errors)} size="lg" animated />
|
|
||||||
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Alle zugeordnet!</h2>
|
|
||||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{errors} Fehler</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button onClick={() => { setRound(0); setMatched(new Set()); setErrors(0); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-white font-medium">Nochmal</button>
|
|
||||||
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>Zurueck</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-full max-w-2xl grid grid-cols-2 gap-6">
|
|
||||||
{/* Left column: English */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>English</p>
|
|
||||||
{roundItems.map(item => (
|
|
||||||
<button
|
|
||||||
key={`l-${item.id}`}
|
|
||||||
onClick={() => handleLeftTap(item.id)}
|
|
||||||
disabled={matched.has(item.id)}
|
|
||||||
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
|
||||||
matched.has(item.id)
|
|
||||||
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
|
|
||||||
: selectedLeft === item.id
|
|
||||||
? (isDark ? 'border-blue-400 bg-blue-500/20 text-white' : 'border-blue-500 bg-blue-50 text-blue-900')
|
|
||||||
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.question}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column: German (shuffled) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Deutsch</p>
|
|
||||||
{shuffledRight.map(item => (
|
|
||||||
<button
|
|
||||||
key={`r-${item.id}`}
|
|
||||||
onClick={() => handleRightTap(item.id)}
|
|
||||||
disabled={matched.has(item.id) || !selectedLeft}
|
|
||||||
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
|
||||||
matched.has(item.id)
|
|
||||||
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
|
|
||||||
: wrongPair === item.id
|
|
||||||
? (isDark ? 'border-red-400 bg-red-500/20 text-red-200 animate-pulse' : 'border-red-500 bg-red-50 text-red-800 animate-pulse')
|
|
||||||
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.answer}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExerciseLayout
|
||||||
|
title={t('match')}
|
||||||
|
exerciseType="match"
|
||||||
|
onBack={() => router.push('/learn')}
|
||||||
|
progress={{ current: matchedTotal, total: totalPairs }}
|
||||||
|
nativeHelper={nativePanel}
|
||||||
|
score={
|
||||||
|
<div className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||||
|
<span className="text-green-400">✓{firstTryCorrect}</span>{' '}
|
||||||
|
<span className="text-yellow-400">↻{retryCorrect}</span>{' '}
|
||||||
|
<span className="text-red-400">✗{errors}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isComplete ? (
|
||||||
|
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md mx-auto`}>
|
||||||
|
<StarRating stars={isPerfect ? 3 : accuracyToStars(firstTryCorrect, totalPairs)} size="lg" animated />
|
||||||
|
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
|
{isPerfect ? t('well_done') : t('all_matched')}
|
||||||
|
</h2>
|
||||||
|
<div className={`flex justify-center gap-6 mb-4 text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||||
|
<div><span className="text-green-400 font-bold text-lg">{firstTryCorrect}</span><br/>{t('correct')}</div>
|
||||||
|
<div><span className="text-yellow-400 font-bold text-lg">{retryCorrect}</span><br/>2.</div>
|
||||||
|
<div><span className="text-red-400 font-bold text-lg">{errors}</span><br/>{t('errors')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={restart} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-white font-medium">{t('again')}</button>
|
||||||
|
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>{t('back')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Left: English */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
|
{t('english')} / English
|
||||||
|
</p>
|
||||||
|
{roundItems.map(item => (
|
||||||
|
<div key={`l-${item.id}`}
|
||||||
|
className={`flex items-center gap-2 p-3 min-h-[48px] rounded-xl border-2 text-sm font-medium transition-all ${
|
||||||
|
matched.has(item.id) ? 'opacity-30 border-green-400 bg-green-500/10'
|
||||||
|
: selectedLeft === item.id ? (isDark ? 'border-blue-400 bg-blue-500/20 text-white' : 'border-blue-500 bg-blue-50 text-blue-900')
|
||||||
|
: (isDark ? 'border-white/20 bg-white/5 text-white' : 'border-slate-200 bg-white text-slate-900')
|
||||||
|
}`}>
|
||||||
|
<button onClick={() => handleLeftTap(item.id)} disabled={matched.has(item.id)} className="flex-1 text-left">{item.question}</button>
|
||||||
|
{!matched.has(item.id) && <AudioButton text={item.question} lang="en" isDark={isDark} size="sm" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: German */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
|
{t('german')} / Deutsch
|
||||||
|
</p>
|
||||||
|
{shuffledRight.map(item => (
|
||||||
|
<div key={`r-${item.id}`}
|
||||||
|
className={`flex items-center gap-2 p-3 min-h-[48px] rounded-xl border-2 text-sm font-medium transition-all ${
|
||||||
|
matched.has(item.id) ? 'opacity-30 border-green-400 bg-green-500/10'
|
||||||
|
: wrongPair === item.id ? (isDark ? 'border-red-400 bg-red-500/20 text-red-200 animate-pulse' : 'border-red-500 bg-red-50 text-red-800 animate-pulse')
|
||||||
|
: (isDark ? 'border-white/20 bg-white/5 text-white' : 'border-slate-200 bg-white text-slate-900')
|
||||||
|
}`}>
|
||||||
|
<button onClick={() => handleRightTap(item.id)} disabled={matched.has(item.id) || !selectedLeft} className="flex-1 text-left">{item.answer}</button>
|
||||||
|
{!matched.has(item.id) && <AudioButton text={item.answer} lang="de" isDark={isDark} size="sm" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image preview for selected word */}
|
||||||
|
<SelectedImage items={roundItems} selectedId={selectedLeft} isDark={isDark} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ExerciseLayout>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
import { AudioButton } from '@/components/learn/AudioButton'
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
import { MicrophoneInput } from '@/components/learn/MicrophoneInput'
|
import { MicrophoneInput } from '@/components/learn/MicrophoneInput'
|
||||||
import { SyllableBow, simpleSyllableSplit } from '@/components/learn/SyllableBow'
|
import { SyllableBow, simpleSyllableSplit } from '@/components/learn/SyllableBow'
|
||||||
@@ -20,6 +21,7 @@ export default function PronouncePage() {
|
|||||||
const { unitId } = useParams<{ unitId: string }>()
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
|
|
||||||
const [items, setItems] = useState<QAItem[]>([])
|
const [items, setItems] = useState<QAItem[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
@@ -67,7 +69,7 @@ export default function PronouncePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-rose-50 to-red-100'}`}>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
@@ -128,6 +130,6 @@ export default function PronouncePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
import { QuizQuestion } from '@/components/learn/QuizQuestion'
|
import { QuizQuestion } from '@/components/learn/QuizQuestion'
|
||||||
|
|
||||||
interface MCQuestion {
|
interface MCQuestion {
|
||||||
@@ -21,6 +22,7 @@ export default function QuizPage() {
|
|||||||
const { unitId } = useParams<{ unitId: string }>()
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
|
|
||||||
const [questions, setQuestions] = useState<MCQuestion[]>([])
|
const [questions, setQuestions] = useState<MCQuestion[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
@@ -40,10 +42,37 @@ export default function QuizPage() {
|
|||||||
const loadMC = async () => {
|
const loadMC = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/mc`)
|
// Try MC endpoint first
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
let resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/mc`)
|
||||||
|
if (resp.ok) {
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
setQuestions(data.questions || [])
|
setQuestions(data.questions || [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Generate MC from QA items (vocab units don't have pre-generated MC)
|
||||||
|
resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/qa`)
|
||||||
|
if (!resp.ok) throw new Error('Keine Daten verfuegbar')
|
||||||
|
const qaData = await resp.json()
|
||||||
|
const qaItems = qaData.qa_items || []
|
||||||
|
if (qaItems.length < 4) throw new Error('Zu wenige Woerter fuer Quiz (min. 4)')
|
||||||
|
|
||||||
|
// Build MC questions from QA: question=EN word, options=4 DE answers (1 correct + 3 random)
|
||||||
|
const generated: MCQuestion[] = qaItems.map((item: any, idx: number) => {
|
||||||
|
const others = qaItems.filter((_: any, i: number) => i !== idx)
|
||||||
|
const distractors = [...others].sort(() => Math.random() - 0.5).slice(0, 3)
|
||||||
|
const options = [
|
||||||
|
{ id: item.id, text: item.answer },
|
||||||
|
...distractors.map((d: any) => ({ id: d.id, text: d.answer })),
|
||||||
|
].sort(() => Math.random() - 0.5)
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
question: item.question,
|
||||||
|
options,
|
||||||
|
correct_answer: item.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setQuestions(generated)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -73,7 +102,7 @@ export default function QuizPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'}`}>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
@@ -110,7 +139,7 @@ export default function QuizPage() {
|
|||||||
{stats.correct === questions.length ? '🏆' : stats.correct > stats.incorrect ? '🎉' : '💪'}
|
{stats.correct === questions.length ? '🏆' : stats.correct > stats.incorrect ? '🎉' : '💪'}
|
||||||
</div>
|
</div>
|
||||||
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
{stats.correct === questions.length ? 'Perfekt!' : 'Geschafft!'}
|
{stats.correct === questions.length ? 'Perfekt!' : t('done')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`text-lg mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
<p className={`text-lg mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||||
{stats.correct} von {questions.length} richtig
|
{stats.correct} von {questions.length} richtig
|
||||||
@@ -152,6 +181,6 @@ export default function QuizPage() {
|
|||||||
<p className={isDark ? 'text-white/60' : 'text-slate-500'}>Keine Quiz-Fragen verfuegbar.</p>
|
<p className={isDark ? 'text-white/60' : 'text-slate-500'}>Keine Quiz-Fragen verfuegbar.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
import { AudioButton } from '@/components/learn/AudioButton'
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
|
|
||||||
function getApiBase() {
|
function getApiBase() {
|
||||||
@@ -13,6 +14,7 @@ export default function StoryPage() {
|
|||||||
const { unitId } = useParams<{ unitId: string }>()
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
|
|
||||||
const [story, setStory] = useState<{ story_html: string; story_text: string; vocab_used: string[]; language: string } | null>(null)
|
const [story, setStory] = useState<{ story_html: string; story_text: string; vocab_used: string[]; language: string } | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -65,7 +67,7 @@ export default function StoryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-amber-50 to-orange-100'}`}>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
@@ -169,6 +171,6 @@ export default function StoryPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
import { TypeInput } from '@/components/learn/TypeInput'
|
import { TypeInput } from '@/components/learn/TypeInput'
|
||||||
import { AudioButton } from '@/components/learn/AudioButton'
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export default function TypePage() {
|
|||||||
const { unitId } = useParams<{ unitId: string }>()
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
|
|
||||||
const [items, setItems] = useState<QAItem[]>([])
|
const [items, setItems] = useState<QAItem[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
@@ -94,7 +96,7 @@ export default function TypePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'}`}>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
@@ -186,6 +188,6 @@ export default function TypePage() {
|
|||||||
<p className={isDark ? 'text-white/60' : 'text-slate-500'}>Keine Vokabeln verfuegbar.</p>
|
<p className={isDark ? 'text-white/60' : 'text-slate-500'}>Keine Vokabeln verfuegbar.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
|
import { LanguageSwitcher } from '@/components/learn/LanguageSwitcher'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared layout for ALL /learn/* pages.
|
||||||
|
* Provides: Sidebar + gradient background + language switcher.
|
||||||
|
*/
|
||||||
|
export default function LearnLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const { nativeLang, setNativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||||
|
isDark
|
||||||
|
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||||
|
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'
|
||||||
|
}`}>
|
||||||
|
<div className="relative z-10 p-4 flex-shrink-0">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||||
|
{/* Sticky language switcher at top-right */}
|
||||||
|
<div className="sticky top-0 z-20 flex justify-end px-4 py-2">
|
||||||
|
<LanguageSwitcher
|
||||||
|
currentLang={nativeLang}
|
||||||
|
onLangChange={setNativeLang}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<a href="/impressum" className={`text-[10px] ${isDark ? 'text-white/20 hover:text-white/40' : 'text-slate-300 hover:text-slate-500'}`}>
|
||||||
|
Impressum
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+138
-57
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
import { Sidebar } from '@/components/Sidebar'
|
|
||||||
import { UnitCard } from '@/components/learn/UnitCard'
|
import { UnitCard } from '@/components/learn/UnitCard'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
|
|
||||||
interface LearningUnit {
|
interface LearningUnit {
|
||||||
id: string
|
id: string
|
||||||
@@ -17,31 +17,106 @@ interface LearningUnit {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function getApiBase() {
|
// Parent guide translations
|
||||||
return '' // Same-origin proxy via /api/learning-units/...
|
const guide: Record<string, Record<string, string>> = {
|
||||||
|
welcome: {
|
||||||
|
de: 'Willkommen bei den Lernmodulen!',
|
||||||
|
en: 'Welcome to the learning modules!',
|
||||||
|
tr: 'Ogrenme modullerine hos geldiniz!',
|
||||||
|
ar: '\u0645\u0631\u062d\u0628\u0627 \u0628\u0643 \u0641\u064a \u0648\u062d\u062f\u0627\u062a \u0627\u0644\u062a\u0639\u0644\u0645!',
|
||||||
|
uk: '\u041b\u0430\u0441\u043a\u0430\u0432\u043e \u043f\u0440\u043e\u0441\u0438\u043c\u043e \u0434\u043e \u043d\u0430\u0432\u0447\u0430\u043b\u044c\u043d\u0438\u0445 \u043c\u043e\u0434\u0443\u043b\u0456\u0432!',
|
||||||
|
ru: '\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 \u0443\u0447\u0435\u0431\u043d\u044b\u0435 \u043c\u043e\u0434\u0443\u043b\u0438!',
|
||||||
|
pl: 'Witamy w modulach do nauki!',
|
||||||
|
},
|
||||||
|
what_is_this: {
|
||||||
|
de: 'Was ist das?',
|
||||||
|
en: 'What is this?',
|
||||||
|
tr: 'Bu nedir?',
|
||||||
|
ar: '\u0645\u0627 \u0647\u0630\u0627\u061f',
|
||||||
|
uk: '\u0429\u043e \u0446\u0435?',
|
||||||
|
ru: '\u0427\u0442\u043e \u044d\u0442\u043e?',
|
||||||
|
pl: 'Co to jest?',
|
||||||
|
},
|
||||||
|
explanation: {
|
||||||
|
de: 'Der Lehrer Ihres Kindes hat Vokabeln zusammengestellt, die Ihr Kind lernen muss. Hier koennen Sie Ihrem Kind beim Ueben helfen — auch wenn Sie selbst kein Deutsch oder Englisch sprechen. Jedes Wort wird in Ihrer Sprache angezeigt.',
|
||||||
|
en: 'Your child\'s teacher has prepared vocabulary that your child needs to learn. Here you can help your child practice — even if you don\'t speak German or English. Every word is shown in your language.',
|
||||||
|
tr: 'Cocugunuzun ogretmeni, cocugunuzun ogrenmesi gereken kelimeleri hazirladi. Burada cocugunuzun pratik yapmasina yardimci olabilirsiniz — Almanca veya Ingilizce bilmeseniz bile. Her kelime sizin dilinizde gosterilir.',
|
||||||
|
ar: '\u0623\u0639\u062f \u0645\u0639\u0644\u0645 \u0637\u0641\u0644\u0643 \u0645\u0641\u0631\u062f\u0627\u062a \u064a\u062d\u062a\u0627\u062c \u0637\u0641\u0644\u0643 \u0644\u062a\u0639\u0644\u0645\u0647\u0627. \u0647\u0646\u0627 \u064a\u0645\u0643\u0646\u0643 \u0645\u0633\u0627\u0639\u062f\u0629 \u0637\u0641\u0644\u0643 \u0639\u0644\u0649 \u0627\u0644\u062a\u062f\u0631\u064a\u0628 \u2014 \u062d\u062a\u0649 \u0644\u043e \u0644\u0645 \u062a\u062a\u062d\u062f\u062b \u0627\u0644\u0623\u0644\u0645\u0627\u0646\u064a\u0629 \u0623\u043e \u0627\u0644\u0625\u0646\u062c\u0644\u064a\u0632\u064a\u0629. \u0643\u0644 \u0643\u0644\u0645\u0629 \u062a\u0638\u0647\u0631 \u0628\u0644\u063a\u062a\u0643.',
|
||||||
|
uk: '\u0412\u0447\u0438\u0442\u0435\u043b\u044c \u0432\u0430\u0448\u043e\u0457 \u0434\u0438\u0442\u0438\u043d\u0438 \u043f\u0456\u0434\u0433\u043e\u0442\u0443\u0432\u0430\u0432 \u0441\u043b\u043e\u0432\u0430, \u044f\u043a\u0456 \u0434\u0438\u0442\u0438\u043d\u0430 \u043f\u043e\u0432\u0438\u043d\u043d\u0430 \u0432\u0438\u0432\u0447\u0438\u0442\u0438. \u0422\u0443\u0442 \u0432\u0438 \u043c\u043e\u0436\u0435\u0442\u0435 \u0434\u043e\u043f\u043e\u043c\u043e\u0433\u0442\u0438 \u0434\u0438\u0442\u0438\u043d\u0456 \u043f\u0440\u0430\u043a\u0442\u0438\u043a\u0443\u0432\u0430\u0442\u0438 \u2014 \u043d\u0430\u0432\u0456\u0442\u044c \u044f\u043a\u0449\u043e \u0432\u0438 \u043d\u0435 \u0440\u043e\u0437\u043c\u043e\u0432\u043b\u044f\u0454\u0442\u0435 \u043d\u0456\u043c\u0435\u0446\u044c\u043a\u043e\u044e \u0447\u0438 \u0430\u043d\u0433\u043b\u0456\u0439\u0441\u044c\u043a\u043e\u044e. \u041a\u043e\u0436\u043d\u0435 \u0441\u043b\u043e\u0432\u043e \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u043e \u0432\u0430\u0448\u043e\u044e \u043c\u043e\u0432\u043e\u044e.',
|
||||||
|
ru: '\u0423\u0447\u0438\u0442\u0435\u043b\u044c \u0432\u0430\u0448\u0435\u0433\u043e \u0440\u0435\u0431\u0435\u043d\u043a\u0430 \u043f\u043e\u0434\u0433\u043e\u0442\u043e\u0432\u0438\u043b \u0441\u043b\u043e\u0432\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0440\u0435\u0431\u0435\u043d\u043e\u043a \u0434\u043e\u043b\u0436\u0435\u043d \u0432\u044b\u0443\u0447\u0438\u0442\u044c. \u0417\u0434\u0435\u0441\u044c \u0432\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u043e\u043c\u043e\u0447\u044c \u0440\u0435\u0431\u0435\u043d\u043a\u0443 \u0442\u0440\u0435\u043d\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u2014 \u0434\u0430\u0436\u0435 \u0435\u0441\u043b\u0438 \u0432\u044b \u043d\u0435 \u0433\u043e\u0432\u043e\u0440\u0438\u0442\u0435 \u043f\u043e-\u043d\u0435\u043c\u0435\u0446\u043a\u0438 \u0438\u043b\u0438 \u043f\u043e-\u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u0438. \u041a\u0430\u0436\u0434\u043e\u0435 \u0441\u043b\u043e\u0432\u043e \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0430 \u0432\u0430\u0448\u0435\u043c \u044f\u0437\u044b\u043a\u0435.',
|
||||||
|
pl: 'Nauczyciel Twojego dziecka przygotowal slowka, ktorych dziecko musi sie nauczyc. Tutaj mozesz pomoc dziecku cwczyc — nawet jesli nie mowisz po niemiecku ani angielsku. Kazde slowo jest pokazane w Twoim jezyku.',
|
||||||
|
},
|
||||||
|
exercises_explained: {
|
||||||
|
de: 'Waehlen Sie eine Uebung:',
|
||||||
|
en: 'Choose an exercise:',
|
||||||
|
tr: 'Bir alistirma secin:',
|
||||||
|
ar: '\u0627\u062e\u062a\u0631 \u062a\u0645\u0631\u064a\u0646\u0627:',
|
||||||
|
uk: '\u0412\u0438\u0431\u0435\u0440\u0456\u0442\u044c \u0432\u043f\u0440\u0430\u0432\u0443:',
|
||||||
|
ru: '\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u043f\u0440\u0430\u0436\u043d\u0435\u043d\u0438\u0435:',
|
||||||
|
pl: 'Wybierz cwiczenie:',
|
||||||
|
},
|
||||||
|
exercise_cards: {
|
||||||
|
de: 'Karteikarten — Wort umdrehen und pruefen',
|
||||||
|
tr: 'Kartlar — Kelimeyi cevir ve kontrol et',
|
||||||
|
ar: '\u0628\u0637\u0627\u0642\u0627\u062a \u2014 \u0627\u0642\u0644\u0628 \u0627\u0644\u0643\u0644\u0645\u0629 \u0648\u062a\u062d\u0642\u0642',
|
||||||
|
uk: '\u041a\u0430\u0440\u0442\u043a\u0438 \u2014 \u041f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0456\u0442\u044c \u0441\u043b\u043e\u0432\u043e \u0456 \u043f\u0435\u0440\u0435\u0432\u0456\u0440\u0442\u0435',
|
||||||
|
ru: '\u041a\u0430\u0440\u0442\u043e\u0447\u043a\u0438 \u2014 \u041f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0438\u0442\u0435 \u0441\u043b\u043e\u0432\u043e \u0438 \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435',
|
||||||
|
pl: 'Fiszki — Odwroc slowo i sprawdz',
|
||||||
|
en: 'Flashcards — Flip the word and check',
|
||||||
|
},
|
||||||
|
exercise_quiz: {
|
||||||
|
de: 'Quiz — Richtige Uebersetzung aus 4 Optionen waehlen',
|
||||||
|
tr: 'Quiz — 4 secenekten dogru ceviriyi sec',
|
||||||
|
ar: '\u0627\u062e\u062a\u0628\u0627\u0631 \u2014 \u0627\u062e\u062a\u0631 \u0627\u0644\u062a\u0631\u062c\u0645\u0629 \u0627\u0644\u0635\u062d\u064a\u062d\u0629 \u0645\u0646 4 \u062e\u064a\u0627\u0631\u0627\u062a',
|
||||||
|
uk: '\u0422\u0435\u0441\u0442 \u2014 \u0412\u0438\u0431\u0435\u0440\u0456\u0442\u044c \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u0438\u0439 \u043f\u0435\u0440\u0435\u043a\u043b\u0430\u0434 \u0437 4 \u0432\u0430\u0440\u0456\u0430\u043d\u0442\u0456\u0432',
|
||||||
|
ru: '\u0422\u0435\u0441\u0442 \u2014 \u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043f\u0435\u0440\u0435\u0432\u043e\u0434 \u0438\u0437 4 \u0432\u0430\u0440\u0438\u0430\u043d\u0442\u043e\u0432',
|
||||||
|
pl: 'Quiz — Wybierz poprawne tlumaczenie z 4 opcji',
|
||||||
|
en: 'Quiz — Choose correct translation from 4 options',
|
||||||
|
},
|
||||||
|
exercise_listen: {
|
||||||
|
de: 'Hoeren — Wort hoeren und Uebersetzung waehlen',
|
||||||
|
tr: 'Dinleme — Kelimeyi dinle ve ceviriyi sec',
|
||||||
|
ar: '\u0627\u0633\u062a\u0645\u0627\u0639 \u2014 \u0627\u0633\u062a\u0645\u0639 \u0644\u0644\u0643\u0644\u0645\u0629 \u0648\u0627\u062e\u062a\u0631 \u0627\u0644\u062a\u0631\u062c\u0645\u0629',
|
||||||
|
uk: '\u0421\u043b\u0443\u0445\u0430\u043d\u043d\u044f \u2014 \u041f\u043e\u0441\u043b\u0443\u0445\u0430\u0439 \u0441\u043b\u043e\u0432\u043e \u0456 \u0432\u0438\u0431\u0435\u0440\u0438 \u043f\u0435\u0440\u0435\u043a\u043b\u0430\u0434',
|
||||||
|
ru: '\u0421\u043b\u0443\u0448\u0430\u043d\u0438\u0435 \u2014 \u041f\u043e\u0441\u043b\u0443\u0448\u0430\u0439\u0442\u0435 \u0441\u043b\u043e\u0432\u043e \u0438 \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0435\u0440\u0435\u0432\u043e\u0434',
|
||||||
|
pl: 'Sluchanie — Posluchaj slowa i wybierz tlumaczenie',
|
||||||
|
en: 'Listening — Hear the word and choose the translation',
|
||||||
|
},
|
||||||
|
exercise_speak: {
|
||||||
|
de: 'Sprechen — Wort hoeren und nachsprechen',
|
||||||
|
tr: 'Konusma — Kelimeyi dinle ve tekrar et',
|
||||||
|
ar: '\u062a\u062d\u062f\u062b \u2014 \u0627\u0633\u062a\u0645\u0639 \u0644\u0644\u0643\u0644\u0645\u0629 \u0648\u0643\u0631\u0631\u0647\u0627',
|
||||||
|
uk: '\u0413\u043e\u0432\u043e\u0440\u0456\u043d\u043d\u044f \u2014 \u041f\u043e\u0441\u043b\u0443\u0445\u0430\u0439 \u0441\u043b\u043e\u0432\u043e \u0456 \u043f\u043e\u0432\u0442\u043e\u0440\u0438',
|
||||||
|
ru: '\u0413\u043e\u0432\u043e\u0440\u0435\u043d\u0438\u0435 \u2014 \u041f\u043e\u0441\u043b\u0443\u0448\u0430\u0439\u0442\u0435 \u0441\u043b\u043e\u0432\u043e \u0438 \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435',
|
||||||
|
pl: 'Mowienie — Posluchaj slowa i powtorz',
|
||||||
|
en: 'Speaking — Hear the word and repeat it',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getApiBase() { return '' }
|
||||||
|
|
||||||
export default function LearnPage() {
|
export default function LearnPage() {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
|
const { t, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
const [units, setUnits] = useState<LearningUnit[]>([])
|
const [units, setUnits] = useState<LearningUnit[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
||||||
const glassCard = isDark
|
const glassCard = isDark
|
||||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||||
|
|
||||||
useEffect(() => {
|
const g = (key: string) => guide[key]?.[nativeLang] || guide[key]?.['de'] || key
|
||||||
loadUnits()
|
|
||||||
}, [])
|
useEffect(() => { loadUnits() }, [])
|
||||||
|
|
||||||
const loadUnits = async () => {
|
const loadUnits = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${getApiBase()}/api/learning-units/`)
|
const resp = await fetch(`${getApiBase()}/api/learning-units/`)
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
const data = await resp.json()
|
setUnits(await resp.json())
|
||||||
setUnits(data)
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -52,62 +127,58 @@ export default function LearnPage() {
|
|||||||
const handleDelete = async (unitId: string) => {
|
const handleDelete = async (unitId: string) => {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}`, { method: 'DELETE' })
|
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}`, { method: 'DELETE' })
|
||||||
if (resp.ok) {
|
if (resp.ok) setUnits(prev => prev.filter(u => u.id !== unitId))
|
||||||
setUnits((prev) => prev.filter((u) => u.id !== unitId))
|
} catch {}
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Delete failed:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
<>
|
||||||
isDark
|
|
||||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
|
||||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'
|
|
||||||
}`}>
|
|
||||||
{/* Background Blobs */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
|
||||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
|
||||||
isDark ? 'bg-blue-500 opacity-50' : 'bg-blue-300 opacity-30'
|
|
||||||
}`} />
|
|
||||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
|
||||||
isDark ? 'bg-cyan-500 opacity-50' : 'bg-cyan-300 opacity-30'
|
|
||||||
}`} style={{ animationDelay: '2s' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="relative z-10 p-4">
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
<div className="max-w-5xl mx-auto px-6 py-4">
|
<div className="max-w-5xl mx-auto px-6 py-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-blue-500/30' : 'bg-blue-200'}`}>
|
||||||
isDark ? 'bg-blue-500/30' : 'bg-blue-200'
|
|
||||||
}`}>
|
|
||||||
<svg className={`w-6 h-6 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={`w-6 h-6 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
Meine Lernmodule
|
{isThirdLanguage ? g('welcome') : 'Meine Lernmodule'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
Karteikarten, Quiz und Lueckentexte aus deinen Vokabeln
|
{isThirdLanguage
|
||||||
|
? g('exercises_explained')
|
||||||
|
: 'Karteikarten, Quiz und Lueckentexte aus deinen Vokabeln'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="max-w-5xl mx-auto w-full px-6 py-6">
|
<div className="max-w-5xl mx-auto w-full px-6 py-6">
|
||||||
|
{/* Parent Guide Panel — only for non-DE/EN users */}
|
||||||
|
{isThirdLanguage && (
|
||||||
|
<div className={`${glassCard} rounded-2xl p-6 mb-6`}>
|
||||||
|
<h3 className={`text-sm font-semibold mb-2 ${isDark ? 'text-cyan-300' : 'text-cyan-700'}`}>
|
||||||
|
{g('what_is_this')}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-sm leading-relaxed mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||||
|
{g('explanation')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={`border-t pt-3 space-y-1.5 ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||||
|
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
|
{g('exercises_explained')}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>🃏 {g('exercise_cards')}</p>
|
||||||
|
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>❓ {g('exercise_quiz')}</p>
|
||||||
|
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>🔊 {g('exercise_listen')}</p>
|
||||||
|
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>🎤 {g('exercise_speak')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className={`w-8 h-8 border-4 ${isDark ? 'border-blue-400' : 'border-blue-600'} border-t-transparent rounded-full animate-spin`} />
|
<div className={`w-8 h-8 border-4 ${isDark ? 'border-blue-400' : 'border-blue-600'} border-t-transparent rounded-full animate-spin`} />
|
||||||
@@ -116,46 +187,56 @@ export default function LearnPage() {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className={`${glassCard} rounded-2xl p-6 text-center`}>
|
<div className={`${glassCard} rounded-2xl p-6 text-center`}>
|
||||||
<p className={`${isDark ? 'text-red-300' : 'text-red-600'}`}>Fehler: {error}</p>
|
<p className={isDark ? 'text-red-300' : 'text-red-600'}>Fehler: {error}</p>
|
||||||
<button onClick={loadUnits} className="mt-3 px-4 py-2 rounded-xl bg-blue-500 text-white text-sm">
|
<button onClick={loadUnits} className="mt-3 px-4 py-2 rounded-xl bg-blue-500 text-white text-sm">
|
||||||
Erneut versuchen
|
{t('again')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && units.length === 0 && (
|
{!isLoading && !error && units.length === 0 && (
|
||||||
<div className={`${glassCard} rounded-2xl p-12 text-center`}>
|
<div className={`${glassCard} rounded-2xl p-12 text-center`}>
|
||||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center ${
|
<div className={`w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center ${isDark ? 'bg-blue-500/20' : 'bg-blue-100'}`}>
|
||||||
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
|
||||||
}`}>
|
|
||||||
<svg className={`w-8 h-8 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className={`w-8 h-8 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
Noch keine Lernmodule
|
{isThirdLanguage ? g('welcome') : 'Noch keine Lernmodule'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
<a href="/vocabulary" className="inline-block mt-3 px-6 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium hover:shadow-lg transition-all">
|
||||||
Scanne eine Schulbuchseite im Vokabel-Arbeitsblatt Generator und klicke "Lernmodule generieren".
|
{isThirdLanguage ? g('exercises_explained') : 'Zum Woerterbuch'}
|
||||||
</p>
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create new unit button */}
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="mb-4">
|
||||||
<a
|
<a
|
||||||
href="/vocab-worksheet"
|
href="/vocabulary"
|
||||||
className="inline-block px-6 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium hover:shadow-lg transition-all"
|
className={`inline-flex items-center gap-2 px-5 py-3 rounded-xl font-medium transition-all ${
|
||||||
|
isDark
|
||||||
|
? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg hover:shadow-blue-500/25'
|
||||||
|
: 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Zum Vokabel-Scanner
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Neue Lernunit erstellen
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && units.length > 0 && (
|
{!isLoading && units.length > 0 && (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{units.map((unit) => (
|
{units.map(unit => (
|
||||||
<UnitCard key={unit.id} unit={unit} isDark={isDark} glassCard={glassCard} onDelete={handleDelete} />
|
<UnitCard key={unit.id} unit={unit} isDark={isDark} glassCard={glassCard} onDelete={handleDelete} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
import { useLanguage } from '@/lib/LanguageContext'
|
import { useLanguage } from '@/lib/LanguageContext'
|
||||||
import type { Language } from '@/lib/i18n'
|
import type { Language } from '@/lib/i18n'
|
||||||
|
import { LearnLayout } from '@/components/learn/LearnLayout'
|
||||||
|
|
||||||
interface LangOption {
|
interface LangOption {
|
||||||
code: string
|
code: string
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
|
import { LanguageSwitcher } from '@/components/learn/LanguageSwitcher'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared layout for ALL /parent/* pages.
|
||||||
|
* Same design as learn layout — Sidebar + gradient + language switcher.
|
||||||
|
*/
|
||||||
|
export default function ParentLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const { nativeLang, setNativeLang } = useNativeLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||||
|
isDark
|
||||||
|
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||||
|
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'
|
||||||
|
}`}>
|
||||||
|
<div className="relative z-10 p-4 flex-shrink-0">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||||
|
{/* Sticky language switcher at top-right */}
|
||||||
|
<div className="sticky top-0 z-20 flex justify-end px-4 py-2">
|
||||||
|
<LanguageSwitcher
|
||||||
|
currentLang={nativeLang}
|
||||||
|
onLangChange={setNativeLang}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import React, { useState, useEffect } from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
import { useLanguage } from '@/lib/LanguageContext'
|
import { useLanguage } from '@/lib/LanguageContext'
|
||||||
|
import type { Language } from '@/lib/i18n'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
|
|
||||||
interface LearningUnit {
|
interface LearningUnit {
|
||||||
id: string
|
id: string
|
||||||
@@ -26,11 +28,20 @@ const parentT: Record<string, Record<string, string>> = {
|
|||||||
|
|
||||||
export default function ParentPage() {
|
export default function ParentPage() {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { language } = useLanguage()
|
const { language, setLanguage } = useLanguage()
|
||||||
|
const { nativeLang, setNativeLang } = useNativeLanguage()
|
||||||
const [units, setUnits] = useState<LearningUnit[]>([])
|
const [units, setUnits] = useState<LearningUnit[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
const t = (key: string) => parentT[key]?.[language] || parentT[key]?.['de'] || key
|
// Use nativeLang for translations (synced with localStorage)
|
||||||
|
const activeLang = nativeLang || language
|
||||||
|
const t = (key: string) => parentT[key]?.[activeLang] || parentT[key]?.['de'] || key
|
||||||
|
|
||||||
|
/** Switch both UI language and native language together */
|
||||||
|
const switchLang = (lang: string) => {
|
||||||
|
setLanguage(lang as Language)
|
||||||
|
setNativeLang(lang)
|
||||||
|
}
|
||||||
|
|
||||||
const glassCard = isDark
|
const glassCard = isDark
|
||||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||||
@@ -45,9 +56,7 @@ export default function ParentPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen ${isDark ? 'bg-gradient-to-br from-slate-900 via-blue-900 to-indigo-900' : 'bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50'}`}
|
<div dir={language === 'ar' ? 'rtl' : 'ltr'}>
|
||||||
dir={language === 'ar' ? 'rtl' : 'ltr'}>
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
<div className="max-w-lg mx-auto px-6 py-5">
|
<div className="max-w-lg mx-auto px-6 py-5">
|
||||||
@@ -60,9 +69,9 @@ export default function ParentPage() {
|
|||||||
{t('greeting')}
|
{t('greeting')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/onboarding" className={`text-sm px-3 py-1.5 rounded-lg ${isDark ? 'bg-white/10 text-white/60' : 'bg-slate-100 text-slate-500'}`}>
|
<span className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
{language.toUpperCase()}
|
{activeLang.toUpperCase()}
|
||||||
</Link>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,23 +5,33 @@ import { useParams, useRouter } from 'next/navigation'
|
|||||||
import { useTheme } from '@/lib/ThemeContext'
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
import { useLanguage } from '@/lib/LanguageContext'
|
import { useLanguage } from '@/lib/LanguageContext'
|
||||||
import { AudioButton } from '@/components/learn/AudioButton'
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
|
|
||||||
interface QAItem {
|
interface QAItem {
|
||||||
id: string; question: string; answer: string
|
id: string; question: string; answer: string
|
||||||
translations?: Record<string, { text?: string }>
|
translations?: Record<string, { text?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const pt: Record<string, Record<string, string>> = {
|
// Context explanations in all languages
|
||||||
ask_child: { de: 'Fragen Sie Ihr Kind:', tr: '\u00c7ocu\u011funuza sorun:', ar: '\u0627\u0633\u0623\u0644 \u0637\u0641\u0644\u0643:', uk: '\u0417\u0430\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u0434\u0438\u0442\u0438\u043d\u0443:', ru: '\u0421\u043f\u0440\u043e\u0441\u0438\u0442\u0435 \u0440\u0435\u0431\u0435\u043d\u043a\u0430:', en: 'Ask your child:' },
|
const parentGuide: Record<string, Record<string, string>> = {
|
||||||
correct_answer: { de: 'Richtige Antwort:', tr: 'Do\u011fru cevap:', ar: '\u0627\u0644\u0625\u062c\u0627\u0628\u0629 \u0627\u0644\u0635\u062d\u064a\u062d\u0629:', uk: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u0430 \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u044c:', ru: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043e\u0442\u0432\u0435\u0442:', en: 'Correct answer:' },
|
title: {
|
||||||
show_answer: { de: 'Antwort zeigen', tr: 'Cevab\u0131 g\u00f6ster', ar: '\u0625\u0638\u0647\u0627\u0631 \u0627\u0644\u0625\u062c\u0627\u0628\u0629', uk: '\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u0438 \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u044c', ru: '\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043e\u0442\u0432\u0435\u0442', en: 'Show answer' },
|
de: 'Vokabelabfrage fuer Ihr Kind',
|
||||||
hide_answer: { de: 'Antwort verbergen', tr: 'Cevab\u0131 gizle', ar: '\u0625\u062e\u0641\u0627\u0621 \u0627\u0644\u0625\u062c\u0627\u0628\u0629', uk: '\u0421\u0445\u043e\u0432\u0430\u0442\u0438 \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u044c', ru: '\u0421\u043a\u0440\u044b\u0442\u044c \u043e\u0442\u0432\u0435\u0442', en: 'Hide answer' },
|
en: 'Vocabulary quiz for your child',
|
||||||
wrong: { de: 'Falsch', tr: 'Yanl\u0131\u015f', ar: '\u062e\u0637\u0623', uk: '\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', ru: '\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', en: 'Wrong' },
|
tr: 'Cocugunuz icin kelime sorgusu',
|
||||||
correct: { de: 'Richtig', tr: 'Do\u011fru', ar: '\u0635\u062d\u064a\u062d', uk: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', ru: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', en: 'Correct' },
|
ar: '\u0627\u062e\u062a\u0628\u0627\u0631 \u0645\u0641\u0631\u062f\u0627\u062a \u0644\u0637\u0641\u0644\u0643',
|
||||||
done: { de: 'Fertig!', tr: 'Bitti!', ar: '\u0627\u0646\u062a\u0647\u0649!', uk: '\u0413\u043e\u0442\u043e\u0432\u043e!', ru: '\u0413\u043e\u0442\u043e\u0432\u043e!', en: 'Done!' },
|
uk: '\u041e\u043f\u0438\u0442\u0443\u0432\u0430\u043d\u043d\u044f \u0441\u043b\u0456\u0432 \u0434\u043b\u044f \u0432\u0430\u0448\u043e\u0457 \u0434\u0438\u0442\u0438\u043d\u0438',
|
||||||
again: { de: 'Nochmal', tr: 'Tekrar', ar: '\u0645\u0631\u0629 \u0623\u062e\u0631\u0649', uk: '\u0429\u0435 \u0440\u0430\u0437', ru: '\u0415\u0449\u0435 \u0440\u0430\u0437', en: 'Again' },
|
ru: '\u041e\u043f\u0440\u043e\u0441 \u0441\u043b\u043e\u0432 \u0434\u043b\u044f \u0432\u0430\u0448\u0435\u0433\u043e \u0440\u0435\u0431\u0435\u043d\u043a\u0430',
|
||||||
back: { de: 'Zurueck', tr: 'Geri', ar: '\u0631\u062c\u0648\u0639', uk: '\u041d\u0430\u0437\u0430\u0434', ru: '\u041d\u0430\u0437\u0430\u0434', en: 'Back' },
|
pl: 'Test slowek dla Twojego dziecka',
|
||||||
question: { de: 'Frage', tr: 'Soru', ar: '\u0633\u0624\u0627\u0644', uk: '\u041f\u0438\u0442\u0430\u043d\u043d\u044f', ru: '\u0412\u043e\u043f\u0440\u043e\u0441', en: 'Question' },
|
},
|
||||||
|
how_it_works: {
|
||||||
|
de: 'So funktioniert es: Druecken Sie den Lautsprecher — Ihr Kind hoert das englische Wort und sagt die deutsche Uebersetzung. Sie sehen die richtige Antwort und koennen pruefen ob Ihr Kind richtig liegt.',
|
||||||
|
en: 'How it works: Press the speaker — your child hears the English word and says the German translation. You see the correct answer and can check.',
|
||||||
|
tr: 'Nasil calisir: Hoparlore basin — cocugunuz Ingilizce kelimeyi duyar ve Almanca cevirisini soyler. Siz dogru cevabi gorursunuz ve kontrol edebilirsiniz.',
|
||||||
|
ar: '\u0643\u064a\u0641 \u064a\u0639\u0645\u0644: \u0627\u0636\u063a\u0637 \u0639\u0644\u0649 \u0627\u0644\u0645\u0643\u0628\u0631 \u2014 \u0637\u0641\u0644\u0643 \u064a\u0633\u0645\u0639 \u0627\u0644\u0643\u0644\u0645\u0629 \u0628\u0627\u0644\u0625\u0646\u062c\u0644\u064a\u0632\u064a\u0629 \u0648\u064a\u0642\u0648\u0644 \u0627\u0644\u062a\u0631\u062c\u0645\u0629 \u0628\u0627\u0644\u0623\u0644\u0645\u0627\u0646\u064a\u0629. \u062a\u0631\u0649 \u0627\u0644\u0625\u062c\u0627\u0628\u0629 \u0627\u0644\u0635\u062d\u064a\u062d\u0629 \u0648\u064a\u0645\u0643\u0646\u0643 \u0627\u0644\u062a\u062d\u0642\u0642.',
|
||||||
|
uk: '\u042f\u043a \u0446\u0435 \u043f\u0440\u0430\u0446\u044e\u0454: \u041d\u0430\u0442\u0438\u0441\u043d\u0456\u0442\u044c \u043d\u0430 \u0434\u0438\u043d\u0430\u043c\u0456\u043a \u2014 \u0434\u0438\u0442\u0438\u043d\u0430 \u043f\u043e\u0447\u0443\u0454 \u0430\u043d\u0433\u043b\u0456\u0439\u0441\u044c\u043a\u0435 \u0441\u043b\u043e\u0432\u043e \u0456 \u043a\u0430\u0436\u0435 \u043d\u0456\u043c\u0435\u0446\u044c\u043a\u0438\u0439 \u043f\u0435\u0440\u0435\u043a\u043b\u0430\u0434. \u0412\u0438 \u0431\u0430\u0447\u0438\u0442\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u0443 \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u044c.',
|
||||||
|
ru: '\u041a\u0430\u043a \u044d\u0442\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442: \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043d\u0430 \u0434\u0438\u043d\u0430\u043c\u0438\u043a \u2014 \u0440\u0435\u0431\u0435\u043d\u043e\u043a \u0443\u0441\u043b\u044b\u0448\u0438\u0442 \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u043e\u0435 \u0441\u043b\u043e\u0432\u043e \u0438 \u0441\u043a\u0430\u0436\u0435\u0442 \u043d\u0435\u043c\u0435\u0446\u043a\u0438\u0439 \u043f\u0435\u0440\u0435\u0432\u043e\u0434. \u0412\u044b \u0432\u0438\u0434\u0438\u0442\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043e\u0442\u0432\u0435\u0442.',
|
||||||
|
pl: 'Jak to dziala: Nacisnij glosnik — dziecko slyszy angielskie slowo i mowi niemiecki odpowiednik. Widzisz poprawna odpowiedz i mozesz sprawdzic.',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ParentQuizPage() {
|
export default function ParentQuizPage() {
|
||||||
@@ -29,6 +39,7 @@ export default function ParentQuizPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
|
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||||
|
|
||||||
const [items, setItems] = useState<QAItem[]>([])
|
const [items, setItems] = useState<QAItem[]>([])
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
@@ -36,13 +47,14 @@ export default function ParentQuizPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
|
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
|
||||||
const [isComplete, setIsComplete] = useState(false)
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
|
const [showGuide, setShowGuide] = useState(true)
|
||||||
const t = (key: string) => pt[key]?.[language] || pt[key]?.['de'] || key
|
|
||||||
|
|
||||||
const glassCard = isDark
|
const glassCard = isDark
|
||||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||||
|
|
||||||
|
const pg = (key: string) => parentGuide[key]?.[nativeLang] || parentGuide[key]?.['de'] || key
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/learning-units/${unitId}/qa`)
|
fetch(`/api/learning-units/${unitId}/qa`)
|
||||||
.then(r => r.ok ? r.json() : { qa_items: [] })
|
.then(r => r.ok ? r.json() : { qa_items: [] })
|
||||||
@@ -57,33 +69,33 @@ export default function ParentQuizPage() {
|
|||||||
incorrect: prev.incorrect + (correct ? 0 : 1),
|
incorrect: prev.incorrect + (correct ? 0 : 1),
|
||||||
}))
|
}))
|
||||||
setShowAnswer(false)
|
setShowAnswer(false)
|
||||||
if (currentIndex + 1 >= items.length) { setIsComplete(true) }
|
if (currentIndex + 1 >= items.length) setIsComplete(true)
|
||||||
else { setCurrentIndex(i => i + 1) }
|
else setCurrentIndex(i => i + 1)
|
||||||
}, [currentIndex, items.length])
|
}, [currentIndex, items.length])
|
||||||
|
|
||||||
const currentItem = items[currentIndex]
|
const currentItem = items[currentIndex]
|
||||||
const nativeTranslation = currentItem?.translations?.[language]?.text || ''
|
const nativeWord = wordInNative(currentItem?.translations)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-slate-900 via-blue-900 to-indigo-900' : 'bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50'}`}>
|
return <div className="flex items-center justify-center py-20">
|
||||||
<div className="w-8 h-8 border-4 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
<div className="w-8 h-8 border-4 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-slate-900 via-blue-900 to-indigo-900' : 'bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50'}`}
|
<div dir={nativeLang === 'ar' ? 'rtl' : 'ltr'}>
|
||||||
dir={language === 'ar' ? 'rtl' : 'ltr'}>
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={`${glassCard} border-0 border-b`}>
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
<div className="max-w-lg mx-auto px-6 py-4 flex items-center justify-between">
|
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
<button onClick={() => router.push('/parent')} className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
<button onClick={() => router.push('/parent')} className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||||
{t('back')}
|
{t('back')}
|
||||||
</button>
|
</button>
|
||||||
<span className={`font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<span className={`font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
{t('question')} {currentIndex + 1}/{items.length}
|
{t('question')} {currentIndex + 1}/{items.length}
|
||||||
</span>
|
</span>
|
||||||
<span />
|
<button onClick={() => setShowGuide(!showGuide)} className={`text-sm ${isDark ? 'text-blue-300' : 'text-blue-600'}`}>
|
||||||
|
{showGuide ? '?' : '?'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,9 +104,9 @@ export default function ParentQuizPage() {
|
|||||||
<div className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all" style={{ width: `${(currentIndex / Math.max(items.length, 1)) * 100}%` }} />
|
<div className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all" style={{ width: `${(currentIndex / Math.max(items.length, 1)) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
<div className="max-w-4xl mx-auto px-6 py-6">
|
||||||
{isComplete ? (
|
{isComplete ? (
|
||||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md mx-auto`}>
|
||||||
<div className="text-5xl mb-4">{stats.correct > stats.incorrect ? '\uD83C\uDF89' : '\uD83D\uDCAA'}</div>
|
<div className="text-5xl mb-4">{stats.correct > stats.incorrect ? '\uD83C\uDF89' : '\uD83D\uDCAA'}</div>
|
||||||
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('done')}</h2>
|
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('done')}</h2>
|
||||||
<p className={`text-lg mb-6 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
<p className={`text-lg mb-6 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||||
@@ -102,30 +114,51 @@ export default function ParentQuizPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false) }}
|
<button onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false) }}
|
||||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium">{t('again')}</button>
|
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium">
|
||||||
|
{t('again')}
|
||||||
|
</button>
|
||||||
<button onClick={() => router.push('/parent')}
|
<button onClick={() => router.push('/parent')}
|
||||||
className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>{t('back')}</button>
|
className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>
|
||||||
|
{t('back')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : currentItem ? (
|
) : currentItem ? (
|
||||||
<div className="w-full max-w-md space-y-6">
|
<div className="flex gap-6">
|
||||||
{/* Instruction for parent */}
|
{/* Left: Quiz Card */}
|
||||||
<p className={`text-center text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
<div className="flex-1 space-y-5">
|
||||||
|
{/* Instruction */}
|
||||||
|
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||||
{t('ask_child')}
|
{t('ask_child')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Word to ask */}
|
{/* Word Card */}
|
||||||
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
|
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
|
||||||
<span className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
{/* English word */}
|
||||||
|
<span className={`text-xs font-medium ${isDark ? 'text-white/30' : 'text-slate-400'}`}>ENGLISH</span>
|
||||||
|
<p className={`text-3xl font-bold mt-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
{currentItem.question}
|
{currentItem.question}
|
||||||
</span>
|
</p>
|
||||||
{nativeTranslation && (
|
|
||||||
<p className={`text-lg mt-3 ${isDark ? 'text-blue-300/70' : 'text-blue-600'}`}>
|
{/* Native translation */}
|
||||||
({nativeTranslation})
|
{nativeWord && (
|
||||||
|
<p className={`text-lg mt-3 ${isDark ? 'text-cyan-300/80' : 'text-cyan-700'}`}>
|
||||||
|
= {nativeWord}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Audio buttons */}
|
||||||
<div className="flex justify-center gap-3 mt-4">
|
<div className="flex justify-center gap-3 mt-4">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
<AudioButton text={currentItem.question} lang="en" isDark={isDark} size="md" />
|
<AudioButton text={currentItem.question} lang="en" isDark={isDark} size="md" />
|
||||||
|
<span className={`text-[10px] ${isDark ? 'text-white/30' : 'text-slate-400'}`}>EN</span>
|
||||||
|
</div>
|
||||||
|
{nativeWord && (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<AudioButton text={nativeWord} lang={nativeLang as 'en' | 'de'} isDark={isDark} size="md" />
|
||||||
|
<span className={`text-[10px] ${isDark ? 'text-white/30' : 'text-slate-400'}`}>{nativeLang.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,35 +172,72 @@ export default function ParentQuizPage() {
|
|||||||
{showAnswer ? t('hide_answer') : t('show_answer')}
|
{showAnswer ? t('hide_answer') : t('show_answer')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Answer */}
|
||||||
{showAnswer && (
|
{showAnswer && (
|
||||||
<div className={`${glassCard} rounded-2xl p-6 text-center`}>
|
<div className={`${glassCard} rounded-2xl p-6 text-center`}>
|
||||||
<p className={`text-xs mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('correct_answer')}</p>
|
<p className={`text-xs mb-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('correct_answer')}</p>
|
||||||
<span className={`text-2xl font-bold ${isDark ? 'text-green-300' : 'text-green-700'}`}>
|
<span className={`text-xs ${isDark ? 'text-white/30' : 'text-slate-400'}`}>DEUTSCH</span>
|
||||||
|
<p className={`text-2xl font-bold ${isDark ? 'text-green-300' : 'text-green-700'}`}>
|
||||||
{currentItem.answer}
|
{currentItem.answer}
|
||||||
</span>
|
</p>
|
||||||
{nativeTranslation && (
|
{nativeWord && (
|
||||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
<p className={`text-sm mt-1 ${isDark ? 'text-cyan-300/60' : 'text-cyan-600'}`}>
|
||||||
({nativeTranslation})
|
= {nativeWord}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-center mt-3">
|
<div className="flex justify-center gap-3 mt-3">
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
<AudioButton text={currentItem.answer} lang="de" isDark={isDark} size="md" />
|
<AudioButton text={currentItem.answer} lang="de" isDark={isDark} size="md" />
|
||||||
|
<span className={`text-[10px] ${isDark ? 'text-white/30' : 'text-slate-400'}`}>DE</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Right/Wrong Buttons */}
|
{/* Trilingual Buttons */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button onClick={() => handleResult(false)}
|
<button onClick={() => handleResult(false)}
|
||||||
className="flex-1 py-4 rounded-2xl font-semibold bg-gradient-to-r from-red-500 to-rose-500 text-white hover:shadow-lg transition-all">
|
className="flex-1 py-4 rounded-2xl font-semibold bg-gradient-to-r from-red-500 to-rose-500 text-white hover:shadow-lg transition-all">
|
||||||
{t('wrong')}
|
<span className="text-lg">{t('wrong')}</span>
|
||||||
|
{isThirdLanguage && <span className={`block text-xs mt-0.5 text-white/60`}>Falsch / Wrong</span>}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleResult(true)}
|
<button onClick={() => handleResult(true)}
|
||||||
className="flex-1 py-4 rounded-2xl font-semibold bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg transition-all">
|
className="flex-1 py-4 rounded-2xl font-semibold bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg transition-all">
|
||||||
{t('correct')}
|
<span className="text-lg">{t('correct')}</span>
|
||||||
|
{isThirdLanguage && <span className={`block text-xs mt-0.5 text-white/60`}>Richtig / Correct</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Explanation Card (in parent's native language) */}
|
||||||
|
{showGuide && (
|
||||||
|
<div className="w-72 flex-shrink-0">
|
||||||
|
<div className={`${glassCard} rounded-2xl p-5 sticky top-6`}>
|
||||||
|
<h3 className={`text-sm font-semibold mb-3 ${isDark ? 'text-cyan-300' : 'text-cyan-700'}`}>
|
||||||
|
{pg('title')}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-xs leading-relaxed ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||||
|
{pg('how_it_works')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={`mt-4 pt-3 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||||
|
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
|
{items.length} {t('cards').toLowerCase()} · {t('question')} {currentIndex + 1}
|
||||||
|
</p>
|
||||||
|
<div className={`mt-2 h-1.5 rounded-full ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>
|
||||||
|
<div className="h-full rounded-full bg-gradient-to-r from-cyan-500 to-blue-500 transition-all"
|
||||||
|
style={{ width: `${((currentIndex + 1) / items.length) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
{stats.correct + stats.incorrect > 0 && (
|
||||||
|
<p className={`text-xs mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
|
✅ {stats.correct} ❌ {stats.incorrect}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export default function VocabularyPage() {
|
|||||||
const [filters, setFilters] = useState<{ tags: string[]; parts_of_speech: string[]; total_words: number }>({ tags: [], parts_of_speech: [], total_words: 0 })
|
const [filters, setFilters] = useState<{ tags: string[]; parts_of_speech: string[]; total_words: number }>({ tags: [], parts_of_speech: [], total_words: 0 })
|
||||||
const [posFilter, setPosFilter] = useState('')
|
const [posFilter, setPosFilter] = useState('')
|
||||||
const [diffFilter, setDiffFilter] = useState(0)
|
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
|
// Unit builder
|
||||||
const [selectedWords, setSelectedWords] = useState<VocabWord[]>([])
|
const [selectedWords, setSelectedWords] = useState<VocabWord[]>([])
|
||||||
@@ -71,7 +74,7 @@ export default function VocabularyPage() {
|
|||||||
try {
|
try {
|
||||||
let url: string
|
let url: string
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
url = `${getApiBase()}/api/vocabulary/search?q=${encodeURIComponent(query)}&limit=30`
|
url = `${getApiBase()}/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)
|
||||||
@@ -83,6 +86,15 @@ export default function VocabularyPage() {
|
|||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
setResults(data.words || [])
|
setResults(data.words || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also search for matching topics
|
||||||
|
if (query.trim()) {
|
||||||
|
const topicResp = await fetch(`${getApiBase()}/api/vocabulary/topics?q=${encodeURIComponent(query)}&lang=${searchLang}`)
|
||||||
|
if (topicResp.ok) {
|
||||||
|
const topicData = await topicResp.json()
|
||||||
|
setTopics(topicData.topics || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Search failed:', err)
|
console.error('Search failed:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -91,7 +103,7 @@ export default function VocabularyPage() {
|
|||||||
}, 300)
|
}, 300)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [query, posFilter, diffFilter])
|
}, [query, posFilter, diffFilter, searchLang])
|
||||||
|
|
||||||
const toggleWord = useCallback((word: VocabWord) => {
|
const toggleWord = useCallback((word: VocabWord) => {
|
||||||
setSelectedWords(prev => {
|
setSelectedWords(prev => {
|
||||||
@@ -145,7 +157,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.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` : filters.total_words > 0 ? `${filters.total_words.toLocaleString()} Woerter` : 'Woerter suchen und Lernunits erstellen'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,11 +170,38 @@ export default function VocabularyPage() {
|
|||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className={`${glassCard} rounded-2xl p-4`}>
|
<div className={`${glassCard} rounded-2xl p-4`}>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
<select value={searchLang} onChange={e => setSearchLang(e.target.value)}
|
||||||
|
className={`px-3 py-2 rounded-xl border text-sm ${glassInput}`}>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="de">DE</option>
|
||||||
|
<option value="fr">FR</option>
|
||||||
|
<option value="es">ES</option>
|
||||||
|
<option value="it">IT</option>
|
||||||
|
<option value="pt">PT</option>
|
||||||
|
<option value="nl">NL</option>
|
||||||
|
<option value="tr">TR</option>
|
||||||
|
<option value="ru">RU</option>
|
||||||
|
<option value="ar">AR</option>
|
||||||
|
<option value="uk">UK</option>
|
||||||
|
<option value="pl">PL</option>
|
||||||
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
placeholder="Wort suchen (EN oder DE)..."
|
placeholder={searchLang === 'de' ? 'Deutsches Wort suchen...' : searchLang === 'en' ? 'English word search...' : 'Wort suchen...'}
|
||||||
className={`flex-1 px-4 py-3 rounded-xl border outline-none text-lg ${glassInput}`}
|
className={`flex-1 px-4 py-3 rounded-xl border outline-none text-lg ${glassInput}`}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -190,7 +229,71 @@ export default function VocabularyPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isSearching && results.length === 0 && query.trim() && (
|
{/* Topic suggestions */}
|
||||||
|
{topics.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topics.map(topic => (
|
||||||
|
<div key={topic.topic} className={`${glassCard} rounded-xl p-3`}>
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className={`text-sm font-semibold ${isDark ? 'text-cyan-300' : 'text-cyan-700'}`}>
|
||||||
|
💡 {topic.topic} ({topic.word_count})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-3">
|
||||||
|
{(topic.display_words || topic.words).slice(0, 15).map((w: string, i: number) => (
|
||||||
|
<span key={i} className={`text-xs px-2 py-0.5 rounded-full ${isDark ? 'bg-white/5 text-white/50' : 'bg-slate-100 text-slate-500'}`}>{w}</span>
|
||||||
|
))}
|
||||||
|
{topic.words.length > 15 && (
|
||||||
|
<span className={`text-xs px-2 py-0.5 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>+{topic.words.length - 15}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={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'}`}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearching && results.length === 0 && query.trim() && topics.length === 0 && (
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,16 +61,11 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
</svg>
|
</svg>
|
||||||
), showMessagesBadge: true },
|
), showMessagesBadge: true },
|
||||||
{ id: 'woerterbuch', labelKey: 'nav_woerterbuch', href: '/vocabulary', icon: (
|
{ id: 'lernmodule', labelKey: 'Lernmodule', href: '/learn', icon: (
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
</svg>
|
</svg>
|
||||||
)},
|
)},
|
||||||
{ id: 'lernmodule', labelKey: 'nav_lernmodule', href: '/learn', icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
|
||||||
</svg>
|
|
||||||
)},
|
|
||||||
{ id: 'eltern', labelKey: 'nav_eltern', href: '/parent', icon: (
|
{ id: 'eltern', labelKey: 'nav_eltern', href: '/parent', icon: (
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
@@ -153,7 +148,7 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
|
|||||||
onMouseEnter={() => setSidebarHovered(true)}
|
onMouseEnter={() => setSidebarHovered(true)}
|
||||||
onMouseLeave={() => setSidebarHovered(false)}
|
onMouseLeave={() => setSidebarHovered(false)}
|
||||||
>
|
>
|
||||||
<div className={`sticky top-4 h-[calc(100vh-32px)] backdrop-blur-2xl rounded-3xl border flex flex-col p-3 overflow-y-auto overflow-x-hidden ${
|
<div className={`sticky top-4 h-[calc(100vh-32px)] backdrop-blur-2xl rounded-3xl border flex flex-col p-3 overflow-y-auto overflow-x-hidden scrollbar-hide ${
|
||||||
isDark
|
isDark
|
||||||
? 'bg-white/10 border-white/20'
|
? 'bg-white/10 border-white/20'
|
||||||
: 'bg-white/70 border-black/10 shadow-xl'
|
: 'bg-white/70 border-black/10 shadow-xl'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface AudioButtonProps {
|
interface AudioButtonProps {
|
||||||
text: string
|
text: string
|
||||||
@@ -9,47 +9,65 @@ interface AudioButtonProps {
|
|||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AudioButton — plays TTS audio for a word or phrase.
|
||||||
|
*
|
||||||
|
* Priority: Piper TTS (Thorsten DE / Lessac EN) via backend API.
|
||||||
|
* Fallback: Browser Web Speech API if Piper is unavailable.
|
||||||
|
*/
|
||||||
export function AudioButton({ text, lang, isDark, size = 'md' }: AudioButtonProps) {
|
export function AudioButton({ text, lang, isDark, size = 'md' }: AudioButtonProps) {
|
||||||
const [isSpeaking, setIsSpeaking] = useState(false)
|
const [isSpeaking, setIsSpeaking] = useState(false)
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
const speak = useCallback(() => {
|
const speak = useCallback(async () => {
|
||||||
if (!('speechSynthesis' in window)) return
|
// Stop if already playing
|
||||||
if (isSpeaking) {
|
if (isSpeaking) {
|
||||||
window.speechSynthesis.cancel()
|
audioRef.current?.pause()
|
||||||
|
window.speechSynthesis?.cancel()
|
||||||
setIsSpeaking(false)
|
setIsSpeaking(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsSpeaking(true)
|
||||||
|
|
||||||
|
// Try Piper TTS via backend API first
|
||||||
|
try {
|
||||||
|
const url = `/api/vocabulary/tts?text=${encodeURIComponent(text)}&lang=${lang}`
|
||||||
|
const resp = await fetch(url)
|
||||||
|
if (resp.ok && resp.headers.get('content-type')?.startsWith('audio')) {
|
||||||
|
const blob = await resp.blob()
|
||||||
|
const audioUrl = URL.createObjectURL(blob)
|
||||||
|
const audio = new Audio(audioUrl)
|
||||||
|
audioRef.current = audio
|
||||||
|
audio.onended = () => { setIsSpeaking(false); URL.revokeObjectURL(audioUrl) }
|
||||||
|
audio.onerror = () => { setIsSpeaking(false); URL.revokeObjectURL(audioUrl) }
|
||||||
|
await audio.play()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Piper unavailable — fall through to Web Speech API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Browser Web Speech API
|
||||||
|
if ('speechSynthesis' in window) {
|
||||||
const utterance = new SpeechSynthesisUtterance(text)
|
const utterance = new SpeechSynthesisUtterance(text)
|
||||||
utterance.lang = lang === 'de' ? 'de-DE' : 'en-GB'
|
utterance.lang = lang === 'de' ? 'de-DE' : 'en-GB'
|
||||||
utterance.rate = 0.9
|
utterance.rate = 0.9
|
||||||
utterance.pitch = 1.0
|
|
||||||
|
|
||||||
// Try to find a good voice
|
|
||||||
const voices = window.speechSynthesis.getVoices()
|
const voices = window.speechSynthesis.getVoices()
|
||||||
const preferred = voices.find((v) =>
|
const preferred = voices.find((v) =>
|
||||||
v.lang.startsWith(lang === 'de' ? 'de' : 'en') && v.localService
|
v.lang.startsWith(lang === 'de' ? 'de' : 'en') && v.localService
|
||||||
) || voices.find((v) => v.lang.startsWith(lang === 'de' ? 'de' : 'en'))
|
) || voices.find((v) => v.lang.startsWith(lang === 'de' ? 'de' : 'en'))
|
||||||
if (preferred) utterance.voice = preferred
|
if (preferred) utterance.voice = preferred
|
||||||
|
|
||||||
utterance.onend = () => setIsSpeaking(false)
|
utterance.onend = () => setIsSpeaking(false)
|
||||||
utterance.onerror = () => setIsSpeaking(false)
|
utterance.onerror = () => setIsSpeaking(false)
|
||||||
|
|
||||||
setIsSpeaking(true)
|
|
||||||
window.speechSynthesis.speak(utterance)
|
window.speechSynthesis.speak(utterance)
|
||||||
|
} else {
|
||||||
|
setIsSpeaking(false)
|
||||||
|
}
|
||||||
}, [text, lang, isSpeaking])
|
}, [text, lang, isSpeaking])
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = { sm: 'w-7 h-7', md: 'w-9 h-9', lg: 'w-11 h-11' }
|
||||||
sm: 'w-7 h-7',
|
const iconSizes = { sm: 'w-3.5 h-3.5', md: 'w-4 h-4', lg: 'w-5 h-5' }
|
||||||
md: 'w-9 h-9',
|
|
||||||
lg: 'w-11 h-11',
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconSizes = {
|
|
||||||
sm: 'w-3.5 h-3.5',
|
|
||||||
md: 'w-4 h-4',
|
|
||||||
lg: 'w-5 h-5',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||||
|
import { exerciseExplanations } from '@/lib/exerciseExplanations'
|
||||||
|
|
||||||
|
interface ExerciseLayoutProps {
|
||||||
|
/** Main exercise content (2/3 left) */
|
||||||
|
children: React.ReactNode
|
||||||
|
/** Native language helper content for the right panel */
|
||||||
|
nativeHelper?: React.ReactNode
|
||||||
|
/** Explanation text for parents (shown above native words) */
|
||||||
|
exerciseExplanation?: string
|
||||||
|
/** Exercise type key for auto-explanation lookup (e.g. 'match', 'flashcards') */
|
||||||
|
exerciseType?: string
|
||||||
|
/** Title for the exercise in the header */
|
||||||
|
title: string
|
||||||
|
/** Progress: current / total */
|
||||||
|
progress?: { current: number; total: number }
|
||||||
|
/** Back button handler */
|
||||||
|
onBack?: () => void
|
||||||
|
/** Score display */
|
||||||
|
score?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explanations imported from exerciseExplanations.ts (26 languages each)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard exercise layout: 2/3 work area (left) + 1/3 native helper (right).
|
||||||
|
* The right panel only appears for non-DE/EN speakers.
|
||||||
|
*/
|
||||||
|
export function ExerciseLayout({
|
||||||
|
children,
|
||||||
|
nativeHelper,
|
||||||
|
exerciseExplanation,
|
||||||
|
exerciseType,
|
||||||
|
title,
|
||||||
|
progress,
|
||||||
|
onBack,
|
||||||
|
score,
|
||||||
|
}: ExerciseLayoutProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
const { nativeLang, isThirdLanguage, t } = useNativeLanguage()
|
||||||
|
|
||||||
|
const glassCard = isDark
|
||||||
|
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||||
|
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||||
|
|
||||||
|
const typeKey = exerciseType || title.toLowerCase()
|
||||||
|
const explanation = exerciseExplanation
|
||||||
|
|| exerciseExplanations[typeKey]?.[nativeLang]
|
||||||
|
|| exerciseExplanations[typeKey]?.['en']
|
||||||
|
|| exerciseExplanations[typeKey]?.['de']
|
||||||
|
|| ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`${glassCard} border-0 border-b`}>
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-3 flex items-center justify-between">
|
||||||
|
{onBack ? (
|
||||||
|
<button onClick={onBack} className={`flex items-center gap-2 text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||||
|
{t('back')}
|
||||||
|
</button>
|
||||||
|
) : <span />}
|
||||||
|
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{title}</h1>
|
||||||
|
{score || <span />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{progress && (
|
||||||
|
<div className="max-w-5xl mx-auto w-full px-6 pt-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex-1 h-2 rounded-full ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||||
|
<div className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all"
|
||||||
|
style={{ width: `${(progress.current / Math.max(progress.total, 1)) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs font-medium tabular-nums ${isDark ? 'text-white/50' : 'text-slate-400'}`}>
|
||||||
|
{progress.current}/{progress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="max-w-5xl mx-auto w-full px-6 py-6">
|
||||||
|
{/* Explanation banner — full width above exercise, only for non-DE/EN */}
|
||||||
|
{isThirdLanguage && explanation && (
|
||||||
|
<div className={`rounded-2xl p-4 mb-5 ${isDark ? 'bg-cyan-500/5 border border-cyan-400/20' : 'bg-cyan-50 border border-cyan-200'}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-lg">💡</span>
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-xs font-semibold mb-1 ${isDark ? 'text-cyan-300' : 'text-cyan-700'}`}>
|
||||||
|
{nativeLang.toUpperCase()} · {t('english')} · {t('german')}
|
||||||
|
</h3>
|
||||||
|
<p className={`text-xs leading-relaxed ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||||
|
{explanation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2/3 left + 1/3 right — both start at same height */}
|
||||||
|
<div className="flex gap-0 items-start">
|
||||||
|
{/* Left: Exercise area (2/3 or full) */}
|
||||||
|
<div className={isThirdLanguage ? 'w-2/3 pr-6' : 'w-full'}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider line */}
|
||||||
|
{isThirdLanguage && (
|
||||||
|
<div className={`w-px self-stretch ${isDark ? 'bg-white/10' : 'bg-slate-200'}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right: Native language words (1/3) */}
|
||||||
|
{isThirdLanguage && (
|
||||||
|
<div className="w-1/3 pl-6">
|
||||||
|
<div className="sticky top-20">
|
||||||
|
{nativeHelper}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface LanguageSwitcherProps {
|
||||||
|
currentLang: string
|
||||||
|
onLangChange: (lang: string) => void
|
||||||
|
isDark: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANGS = [
|
||||||
|
// Main languages
|
||||||
|
{ code: 'de', label: 'DE', name: 'Deutsch' },
|
||||||
|
{ code: 'en', label: 'EN', name: 'English' },
|
||||||
|
// Migration languages
|
||||||
|
{ code: 'tr', label: 'TR', name: 'Turkce' },
|
||||||
|
{ code: 'ar', label: 'AR', name: 'العربية' },
|
||||||
|
{ code: 'uk', label: 'UK', name: 'Українська' },
|
||||||
|
{ code: 'ru', label: 'RU', name: 'Русский' },
|
||||||
|
{ code: 'pl', label: 'PL', name: 'Polski' },
|
||||||
|
// European languages
|
||||||
|
{ code: 'fr', label: 'FR', name: 'Francais' },
|
||||||
|
{ code: 'es', label: 'ES', name: 'Espanol' },
|
||||||
|
{ code: 'it', label: 'IT', name: 'Italiano' },
|
||||||
|
{ code: 'pt', label: 'PT', name: 'Portugues' },
|
||||||
|
{ code: 'nl', label: 'NL', name: 'Nederlands' },
|
||||||
|
{ code: 'ro', label: 'RO', name: 'Romana' },
|
||||||
|
{ code: 'el', label: 'EL', name: 'Ελληνικά' },
|
||||||
|
{ code: 'bg', label: 'BG', name: 'Български' },
|
||||||
|
{ code: 'hr', label: 'HR', name: 'Hrvatski' },
|
||||||
|
{ code: 'cs', label: 'CS', name: 'Cestina' },
|
||||||
|
{ code: 'hu', label: 'HU', name: 'Magyar' },
|
||||||
|
{ code: 'sv', label: 'SV', name: 'Svenska' },
|
||||||
|
{ code: 'da', label: 'DA', name: 'Dansk' },
|
||||||
|
{ code: 'fi', label: 'FI', name: 'Suomi' },
|
||||||
|
{ code: 'sk', label: 'SK', name: 'Slovencina' },
|
||||||
|
{ code: 'sl', label: 'SL', name: 'Slovenscina' },
|
||||||
|
{ code: 'lt', label: 'LT', name: 'Lietuviu' },
|
||||||
|
{ code: 'lv', label: 'LV', name: 'Latviesu' },
|
||||||
|
{ code: 'et', label: 'ET', name: 'Eesti' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact language switcher for exercise pages.
|
||||||
|
* Shows as dropdown or pill buttons depending on compact prop.
|
||||||
|
*/
|
||||||
|
export function LanguageSwitcher({ currentLang, onLangChange, isDark, compact = true }: LanguageSwitcherProps) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={currentLang}
|
||||||
|
onChange={e => onLangChange(e.target.value)}
|
||||||
|
className={`text-xs px-2 py-1 rounded-lg border-0 cursor-pointer ${
|
||||||
|
isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{LANGS.map(l => <option key={l.code} value={l.code}>{l.label} — {l.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{LANGS.map(l => (
|
||||||
|
<button
|
||||||
|
key={l.code}
|
||||||
|
onClick={() => onLangChange(l.code)}
|
||||||
|
className={`px-2 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
currentLang === l.code
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: isDark ? 'bg-white/5 text-white/40 hover:bg-white/10' : 'bg-slate-100 text-slate-400 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
|
|
||||||
|
interface LearnLayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
gradient?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared layout for all learn sub-pages.
|
||||||
|
* Adds Sidebar + consistent gradient background.
|
||||||
|
*/
|
||||||
|
export function LearnLayout({ children, gradient }: LearnLayoutProps) {
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
|
const bg = gradient || (isDark
|
||||||
|
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||||
|
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen flex relative overflow-hidden ${bg}`}>
|
||||||
|
<div className="relative z-10 p-4 flex-shrink-0">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||||
|
import { exerciseT, type ExerciseKey } from './exerciseTranslations'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'bp_native_language'
|
||||||
|
|
||||||
|
interface NativeLanguageState {
|
||||||
|
nativeLang: string
|
||||||
|
setNativeLang: (lang: string) => void
|
||||||
|
isThirdLanguage: boolean
|
||||||
|
t: (key: ExerciseKey) => string
|
||||||
|
wordInNative: (translations?: Record<string, any>) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const NativeLanguageContext = createContext<NativeLanguageState>({
|
||||||
|
nativeLang: 'de',
|
||||||
|
setNativeLang: () => {},
|
||||||
|
isThirdLanguage: false,
|
||||||
|
t: (key) => key,
|
||||||
|
wordInNative: () => '',
|
||||||
|
})
|
||||||
|
|
||||||
|
export function NativeLanguageProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [nativeLang, setNativeLangState] = useState('de')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) setNativeLangState(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setNativeLang = useCallback((lang: string) => {
|
||||||
|
setNativeLangState(lang)
|
||||||
|
localStorage.setItem(STORAGE_KEY, lang)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isThirdLanguage = nativeLang !== 'de' && nativeLang !== 'en'
|
||||||
|
|
||||||
|
const t = useCallback((key: ExerciseKey): string => {
|
||||||
|
const entry = exerciseT[key]
|
||||||
|
if (!entry) return key
|
||||||
|
const e = entry as Record<string, string>
|
||||||
|
return e[nativeLang] || e['en'] || e['de'] || key
|
||||||
|
}, [nativeLang])
|
||||||
|
|
||||||
|
const wordInNative = useCallback((translations?: Record<string, any>): string => {
|
||||||
|
if (!translations || !isThirdLanguage) return ''
|
||||||
|
const entry = translations[nativeLang]
|
||||||
|
if (!entry) return ''
|
||||||
|
return typeof entry === 'string' ? entry : entry.text || ''
|
||||||
|
}, [nativeLang, isThirdLanguage])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NativeLanguageContext.Provider value={{ nativeLang, setNativeLang, isThirdLanguage, t, wordInNative }}>
|
||||||
|
{children}
|
||||||
|
</NativeLanguageContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNativeLanguage() {
|
||||||
|
return useContext(NativeLanguageContext)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Exercise explanations for parents in ALL 26 supported languages.
|
||||||
|
* Shown in the right-side helper panel when a non-DE/EN language is selected.
|
||||||
|
*
|
||||||
|
* Each text is a one-time translation that never changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const matchText: Record<string, string> = {
|
||||||
|
de: 'Ihr Kind soll die richtige Uebersetzung finden. Es klickt zuerst ein Wort in der linken Spalte und dann die passende Uebersetzung in der rechten Spalte. In diesem Bereich sehen Sie die Woerter in Ihrer Sprache.',
|
||||||
|
en: 'Your child should find the correct translation. They click a word on the left, then the matching translation on the right. Here you see the words in your language.',
|
||||||
|
tr: 'Cocugunuz dogru ceviriyi bulmali. Soldaki bir kelimeye, sonra sagdaki dogru ceviriye tiklar. Bu alanda kelimeleri kendi dilinizde gorursunuz.',
|
||||||
|
ar: '\u064a\u062c\u0628 \u0639\u0644\u0649 \u0637\u0641\u0644\u0643 \u0625\u064a\u062c\u0627\u062f \u0627\u0644\u062a\u0631\u062c\u0645\u0629 \u0627\u0644\u0635\u062d\u064a\u062d\u0629. \u064a\u0646\u0642\u0631 \u0639\u0644\u0649 \u0643\u0644\u0645\u0629 \u0639\u0644\u0649 \u0627\u0644\u064a\u0633\u0627\u0631 \u062b\u0645 \u0627\u0644\u062a\u0631\u062c\u0645\u0629 \u0627\u0644\u0645\u0646\u0627\u0633\u0628\u0629 \u0639\u0644\u0649 \u0627\u0644\u064a\u0645\u064a\u0646. \u0647\u0646\u0627 \u062a\u0631\u0649 \u0627\u0644\u0643\u0644\u0645\u0627\u062a \u0628\u0644\u063a\u062a\u0643.',
|
||||||
|
uk: '\u0412\u0430\u0448\u0430 \u0434\u0438\u0442\u0438\u043d\u0430 \u043f\u043e\u0432\u0438\u043d\u043d\u0430 \u0437\u043d\u0430\u0439\u0442\u0438 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u0438\u0439 \u043f\u0435\u0440\u0435\u043a\u043b\u0430\u0434. \u0412\u043e\u043d\u0430 \u043d\u0430\u0442\u0438\u0441\u043a\u0430\u0454 \u043d\u0430 \u0441\u043b\u043e\u0432\u043e \u043b\u0456\u0432\u043e\u0440\u0443\u0447, \u043f\u043e\u0442\u0456\u043c \u043d\u0430 \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u043d\u0438\u0439 \u043f\u0435\u0440\u0435\u043a\u043b\u0430\u0434 \u043f\u0440\u0430\u0432\u043e\u0440\u0443\u0447. \u0422\u0443\u0442 \u0432\u0438 \u0431\u0430\u0447\u0438\u0442\u0435 \u0441\u043b\u043e\u0432\u0430 \u0432\u0430\u0448\u043e\u044e \u043c\u043e\u0432\u043e\u044e.',
|
||||||
|
ru: '\u0412\u0430\u0448 \u0440\u0435\u0431\u0435\u043d\u043e\u043a \u0434\u043e\u043b\u0436\u0435\u043d \u043d\u0430\u0439\u0442\u0438 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043f\u0435\u0440\u0435\u0432\u043e\u0434. \u041e\u043d \u043d\u0430\u0436\u0438\u043c\u0430\u0435\u0442 \u043d\u0430 \u0441\u043b\u043e\u0432\u043e \u0441\u043b\u0435\u0432\u0430, \u0437\u0430\u0442\u0435\u043c \u043d\u0430 \u043f\u0435\u0440\u0435\u0432\u043e\u0434 \u0441\u043f\u0440\u0430\u0432\u0430. \u0417\u0434\u0435\u0441\u044c \u0432\u044b \u0432\u0438\u0434\u0438\u0442\u0435 \u0441\u043b\u043e\u0432\u0430 \u043d\u0430 \u0432\u0430\u0448\u0435\u043c \u044f\u0437\u044b\u043a\u0435.',
|
||||||
|
pl: 'Twoje dziecko powinno znalezc poprawne tlumaczenie. Klika slowo po lewej, potem odpowiednie tlumaczenie po prawej. Tutaj widzisz slowa w swoim jezyku.',
|
||||||
|
fr: 'Votre enfant doit trouver la bonne traduction. Il clique sur un mot a gauche, puis sur la traduction correspondante a droite. Ici vous voyez les mots dans votre langue.',
|
||||||
|
es: 'Su hijo debe encontrar la traduccion correcta. Hace clic en una palabra a la izquierda y luego en la traduccion correspondiente a la derecha. Aqui ve las palabras en su idioma.',
|
||||||
|
it: 'Suo figlio deve trovare la traduzione corretta. Clicca su una parola a sinistra, poi sulla traduzione corrispondente a destra. Qui vede le parole nella sua lingua.',
|
||||||
|
pt: 'O seu filho deve encontrar a traducao correta. Clica numa palavra a esquerda e depois na traducao correspondente a direita. Aqui ve as palavras na sua lingua.',
|
||||||
|
nl: 'Uw kind moet de juiste vertaling vinden. Het klikt op een woord links en dan op de juiste vertaling rechts. Hier ziet u de woorden in uw taal.',
|
||||||
|
ro: 'Copilul dumneavoastra trebuie sa gaseasca traducerea corecta. Face clic pe un cuvant in stanga, apoi pe traducerea potrivita in dreapta. Aici vedeti cuvintele in limba dumneavoastra.',
|
||||||
|
el: '\u03a4\u03bf \u03c0\u03b1\u03b9\u03b4\u03af \u03c3\u03b1\u03c2 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b2\u03c1\u03b5\u03b9 \u03c4\u03b7 \u03c3\u03c9\u03c3\u03c4\u03ae \u03bc\u03b5\u03c4\u03ac\u03c6\u03c1\u03b1\u03c3\u03b7. \u039a\u03ac\u03bd\u03b5\u03b9 \u03ba\u03bb\u03b9\u03ba \u03c3\u03b5 \u03bc\u03b9\u03b1 \u03bb\u03ad\u03be\u03b7 \u03b1\u03c1\u03b9\u03c3\u03c4\u03b5\u03c1\u03ac \u03ba\u03b1\u03b9 \u03bc\u03b5\u03c4\u03ac \u03c3\u03c4\u03b7 \u03c3\u03c9\u03c3\u03c4\u03ae \u03bc\u03b5\u03c4\u03ac\u03c6\u03c1\u03b1\u03c3\u03b7 \u03b4\u03b5\u03be\u03b9\u03ac. \u0395\u03b4\u03ce \u03b2\u03bb\u03ad\u03c0\u03b5\u03c4\u03b5 \u03c4\u03b9\u03c2 \u03bb\u03ad\u03be\u03b5\u03b9\u03c2 \u03c3\u03c4\u03b7 \u03b3\u03bb\u03ce\u03c3\u03c3\u03b1 \u03c3\u03b1\u03c2.',
|
||||||
|
bg: '\u0412\u0430\u0448\u0435\u0442\u043e \u0434\u0435\u0442\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043d\u0430\u043c\u0435\u0440\u0438 \u043f\u0440\u0430\u0432\u0438\u043b\u043d\u0438\u044f \u043f\u0440\u0435\u0432\u043e\u0434. \u0429\u0440\u0430\u043a\u0432\u0430 \u0432\u044a\u0440\u0445\u0443 \u0434\u0443\u043c\u0430 \u0432\u043b\u044f\u0432\u043e, \u0441\u043b\u0435\u0434 \u0442\u043e\u0432\u0430 \u0432\u044a\u0440\u0445\u0443 \u0441\u044a\u043e\u0442\u0432\u0435\u0442\u043d\u0438\u044f \u043f\u0440\u0435\u0432\u043e\u0434 \u0432\u0434\u044f\u0441\u043d\u043e. \u0422\u0443\u043a \u0432\u0438\u0436\u0434\u0430\u0442\u0435 \u0434\u0443\u043c\u0438\u0442\u0435 \u043d\u0430 \u0432\u0430\u0448\u0438\u044f \u0435\u0437\u0438\u043a.',
|
||||||
|
hr: 'Vase dijete treba pronaci tocan prijevod. Klikne na rijec lijevo, zatim na odgovarajuci prijevod desno. Ovdje vidite rijeci na vasem jeziku.',
|
||||||
|
cs: 'Vase dite ma najit spravny preklad. Klikne na slovo vlevo a pak na odpovidajici preklad vpravo. Zde vidite slova ve vasem jazyce.',
|
||||||
|
hu: 'Gyermekenek meg kell talalnia a helyes forditast. Kattintson egy szora bal oldalon, majd a megfelelo forditasra jobb oldalon. Itt latja a szavakat az On nyelven.',
|
||||||
|
sv: 'Ditt barn ska hitta ratt oversattning. Det klickar pa ett ord till vanster och sedan pa ratt oversattning till hoger. Har ser du orden pa ditt sprak.',
|
||||||
|
da: 'Dit barn skal finde den rigtige oversaettelse. Det klikker pa et ord til venstre og derefter pa den rigtige oversaettelse til hojre. Her ser du ordene pa dit sprog.',
|
||||||
|
fi: 'Lapsesi tulee loytaa oikea kaannos. Han napsauttaa sanaa vasemmalla ja sitten oikeaa kaannosta oikealla. Taalla naet sanat omalla kielellasi.',
|
||||||
|
sk: 'Vase dieta ma najst spravny preklad. Klikne na slovo vlavo a potom na odpovedajuci preklad vpravo. Tu vidite slova vo vasom jazyku.',
|
||||||
|
sl: 'Vas otrok mora najti pravilen prevod. Klikne na besedo levo, nato na ustrezen prevod desno. Tukaj vidite besede v vasem jeziku.',
|
||||||
|
lt: 'Jusu vaikas turi rasti teisinga vertima. Jis spusteli zodi kaireje, tada tinkama vertima desineje. Cia matote zodzius savo kalba.',
|
||||||
|
lv: 'Jasu bernam jabut pareizam tulkojumam. Vinsh uzklikskina uz varda pa kreisi, tad uz pareizo tulkojumu pa labi. Seit jus redzat vardus sava valoda.',
|
||||||
|
et: 'Teie laps peab leidma oige tolke. Ta klikib vasakul oleval sonal ja seejarel paremal oleval oigel tolkel. Siin naete sonu oma keeles.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const flashcardsText: Record<string, string> = {
|
||||||
|
de: 'Ihr Kind sieht ein englisches Wort und soll die deutsche Uebersetzung wissen. Klicken Sie auf die Karte um sie umzudrehen. Hier sehen Sie die Woerter in Ihrer Sprache.',
|
||||||
|
en: 'Your child sees an English word and should know the German translation. Click to flip the card. Here you see the words in your language.',
|
||||||
|
tr: 'Cocugunuz bir Ingilizce kelime gorur ve Almanca cevirisini bilmeli. Karti cevirmek icin tiklayin. Burada kelimeleri kendi dilinizde gorursunuz.',
|
||||||
|
ar: '\u064a\u0631\u0649 \u0637\u0641\u0644\u0643 \u0643\u0644\u0645\u0629 \u0625\u0646\u062c\u0644\u064a\u0632\u064a\u0629 \u0648\u064a\u062c\u0628 \u0623\u0646 \u064a\u0639\u0631\u0641 \u0627\u0644\u062a\u0631\u062c\u0645\u0629 \u0627\u0644\u0623\u0644\u0645\u0627\u0646\u064a\u0629. \u0627\u0646\u0642\u0631 \u0644\u0642\u0644\u0628 \u0627\u0644\u0628\u0637\u0627\u0642\u0629. \u0647\u0646\u0627 \u062a\u0631\u0649 \u0627\u0644\u0643\u0644\u0645\u0627\u062a \u0628\u0644\u063a\u062a\u0643.',
|
||||||
|
uk: '\u0414\u0438\u0442\u0438\u043d\u0430 \u0431\u0430\u0447\u0438\u0442\u044c \u0430\u043d\u0433\u043b\u0456\u0439\u0441\u044c\u043a\u0435 \u0441\u043b\u043e\u0432\u043e \u0456 \u043f\u043e\u0432\u0438\u043d\u043d\u0430 \u0437\u043d\u0430\u0442\u0438 \u043d\u0456\u043c\u0435\u0446\u044c\u043a\u0438\u0439 \u043f\u0435\u0440\u0435\u043a\u043b\u0430\u0434. \u041d\u0430\u0442\u0438\u0441\u043d\u0456\u0442\u044c \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u0442\u0430\u043d\u043d\u044f. \u0422\u0443\u0442 \u0441\u043b\u043e\u0432\u0430 \u0432\u0430\u0448\u043e\u044e \u043c\u043e\u0432\u043e\u044e.',
|
||||||
|
ru: '\u0420\u0435\u0431\u0435\u043d\u043e\u043a \u0432\u0438\u0434\u0438\u0442 \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u043e\u0435 \u0441\u043b\u043e\u0432\u043e \u0438 \u0434\u043e\u043b\u0436\u0435\u043d \u0437\u043d\u0430\u0442\u044c \u043d\u0435\u043c\u0435\u0446\u043a\u0438\u0439 \u043f\u0435\u0440\u0435\u0432\u043e\u0434. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0447\u0442\u043e\u0431\u044b \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u0442\u044c. \u0417\u0434\u0435\u0441\u044c \u0441\u043b\u043e\u0432\u0430 \u043d\u0430 \u0432\u0430\u0448\u0435\u043c \u044f\u0437\u044b\u043a\u0435.',
|
||||||
|
pl: 'Dziecko widzi angielskie slowo i powinno znac niemieckie tlumaczenie. Kliknij aby odwrocic kartke. Tutaj slowa w Twoim jezyku.',
|
||||||
|
fr: 'Votre enfant voit un mot anglais et doit connaitre la traduction allemande. Cliquez pour retourner la carte. Ici vous voyez les mots dans votre langue.',
|
||||||
|
es: 'Su hijo ve una palabra en ingles y debe saber la traduccion en aleman. Haga clic para voltear la tarjeta. Aqui ve las palabras en su idioma.',
|
||||||
|
it: 'Suo figlio vede una parola inglese e deve conoscere la traduzione tedesca. Clicchi per girare la carta. Qui vede le parole nella sua lingua.',
|
||||||
|
pt: 'O seu filho ve uma palavra em ingles e deve saber a traducao em alemao. Clique para virar o cartao. Aqui ve as palavras na sua lingua.',
|
||||||
|
nl: 'Uw kind ziet een Engels woord en moet de Duitse vertaling weten. Klik om de kaart om te draaien. Hier ziet u de woorden in uw taal.',
|
||||||
|
ro: 'Copilul dumneavoastra vede un cuvant in engleza si trebuie sa stie traducerea in germana. Faceti clic pentru a intoarce cardul. Aici vedeti cuvintele in limba dumneavoastra.',
|
||||||
|
el: '\u03a4\u03bf \u03c0\u03b1\u03b9\u03b4\u03af \u03c3\u03b1\u03c2 \u03b2\u03bb\u03ad\u03c0\u03b5\u03b9 \u03bc\u03b9\u03b1 \u03b1\u03b3\u03b3\u03bb\u03b9\u03ba\u03ae \u03bb\u03ad\u03be\u03b7 \u03ba\u03b1\u03b9 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b3\u03bd\u03c9\u03c1\u03af\u03b6\u03b5\u03b9 \u03c4\u03b7 \u03b3\u03b5\u03c1\u03bc\u03b1\u03bd\u03b9\u03ba\u03ae \u03bc\u03b5\u03c4\u03ac\u03c6\u03c1\u03b1\u03c3\u03b7. \u039a\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b3\u03c5\u03c1\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd \u03ba\u03ac\u03c1\u03c4\u03b1. \u0395\u03b4\u03ce \u03b2\u03bb\u03ad\u03c0\u03b5\u03c4\u03b5 \u03c4\u03b9\u03c2 \u03bb\u03ad\u03be\u03b5\u03b9\u03c2 \u03c3\u03c4\u03b7 \u03b3\u03bb\u03ce\u03c3\u03c3\u03b1 \u03c3\u03b1\u03c2.',
|
||||||
|
bg: '\u0412\u0430\u0448\u0435\u0442\u043e \u0434\u0435\u0442\u0435 \u0432\u0438\u0436\u0434\u0430 \u0430\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u0430 \u0434\u0443\u043c\u0430 \u0438 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0437\u043d\u0430\u0435 \u043d\u0435\u043c\u0441\u043a\u0438\u044f \u043f\u0440\u0435\u0432\u043e\u0434. \u0429\u0440\u0430\u043a\u043d\u0435\u0442\u0435 \u0437\u0430 \u0434\u0430 \u043e\u0431\u044a\u0440\u043d\u0435\u0442\u0435 \u043a\u0430\u0440\u0442\u0430\u0442\u0430. \u0422\u0443\u043a \u0432\u0438\u0436\u0434\u0430\u0442\u0435 \u0434\u0443\u043c\u0438\u0442\u0435 \u043d\u0430 \u0432\u0430\u0448\u0438\u044f \u0435\u0437\u0438\u043a.',
|
||||||
|
hr: 'Vase dijete vidi englesku rijec i treba znati njemacki prijevod. Kliknite da preokrenete karticu. Ovdje vidite rijeci na vasem jeziku.',
|
||||||
|
cs: 'Vase dite vidi anglicke slovo a ma znat nemecky preklad. Kliknete pro otoceni karty. Zde vidite slova ve vasem jazyce.',
|
||||||
|
hu: 'Gyermeke lat egy angol szot es tudnia kell a nemet forditast. Kattintson a kartya megforditasahoz. Itt latja a szavakat az On nyelven.',
|
||||||
|
sv: 'Ditt barn ser ett engelskt ord och ska kunna den tyska oversattningen. Klicka for att vanda kortet. Har ser du orden pa ditt sprak.',
|
||||||
|
da: 'Dit barn ser et engelsk ord og skal kende den tyske oversaettelse. Klik for at vende kortet. Her ser du ordene pa dit sprog.',
|
||||||
|
fi: 'Lapsesi nakee englanninkielisen sanan ja hanen tulee tietaa saksankielinen kaannos. Napsauta kaantaaksesi kortin. Taalla naet sanat omalla kielellasi.',
|
||||||
|
sk: 'Vase dieta vidi anglicke slovo a ma poznat nemecky preklad. Kliknite pre otocenie karty. Tu vidite slova vo vasom jazyku.',
|
||||||
|
sl: 'Vas otrok vidi anglesko besedo in mora poznati nemski prevod. Kliknite za obracanje kartice. Tukaj vidite besede v vasem jeziku.',
|
||||||
|
lt: 'Jusu vaikas mato angliska zodi ir turi zinoti vokiska vertima. Spustelekite, kad apverstu kortele. Cia matote zodzius savo kalba.',
|
||||||
|
lv: 'Jasu berns redz anglu valodas vardu un tam jabut zinamamam vacu tulkojumam. Noklikskiniet, lai apgrieztu kartiti. Seit jus redzat vardus sava valoda.',
|
||||||
|
et: 'Teie laps naeb ingliskeelset sona ja peaks teadma saksakeelset tolget. Klopsake kaardi umberpooramiseks. Siin naete sonu oma keeles.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exerciseExplanations: Record<string, Record<string, string>> = {
|
||||||
|
match: matchText,
|
||||||
|
flashcards: flashcardsText,
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Exercise UI translations for all 7 supported languages.
|
||||||
|
* Used by useNativeLanguage() hook across all learning pages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExerciseKey = keyof typeof exerciseT
|
||||||
|
|
||||||
|
export const exerciseT = {
|
||||||
|
// --- Buttons ---
|
||||||
|
correct: { de: 'Richtig', en: 'Correct', tr: 'Dogru', ar: '\u0635\u062d\u064a\u062d', uk: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', ru: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', pl: 'Dobrze' },
|
||||||
|
wrong: { de: 'Falsch', en: 'Wrong', tr: 'Yanlis', ar: '\u062e\u0637\u0623', uk: '\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', ru: '\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e', pl: 'Zle' },
|
||||||
|
check: { de: 'Pruefen', en: 'Check', tr: 'Kontrol et', ar: '\u062a\u062d\u0642\u0642', uk: '\u041f\u0435\u0440\u0435\u0432\u0456\u0440\u0438\u0442\u0438', ru: '\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c', pl: 'Sprawdz' },
|
||||||
|
next: { de: 'Weiter', en: 'Next', tr: 'Ileri', ar: '\u0627\u0644\u062a\u0627\u0644\u064a', uk: '\u0414\u0430\u043b\u0456', ru: '\u0414\u0430\u043b\u0435\u0435', pl: 'Dalej' },
|
||||||
|
back: { de: 'Zurueck', en: 'Back', tr: 'Geri', ar: '\u0631\u062c\u0648\u0639', uk: '\u041d\u0430\u0437\u0430\u0434', ru: '\u041d\u0430\u0437\u0430\u0434', pl: 'Wstecz' },
|
||||||
|
again: { de: 'Nochmal', en: 'Again', tr: 'Tekrar', ar: '\u0645\u0631\u0629 \u0623\u062e\u0631\u0649', uk: '\u0429\u0435 \u0440\u0430\u0437', ru: '\u0415\u0449\u0435 \u0440\u0430\u0437', pl: 'Jeszcze raz' },
|
||||||
|
done: { de: 'Geschafft!', en: 'Done!', tr: 'Bitti!', ar: '\u0627\u0646\u062a\u0647\u0649!', uk: '\u0413\u043e\u0442\u043e\u0432\u043e!', ru: '\u0413\u043e\u0442\u043e\u0432\u043e!', pl: 'Gotowe!' },
|
||||||
|
show_answer: { de: 'Antwort zeigen', en: 'Show answer', tr: 'Cevabi goster', ar: '\u0625\u0638\u0647\u0627\u0631 \u0627\u0644\u0625\u062c\u0627\u0628\u0629', uk: '\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u0438', ru: '\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c', pl: 'Pokaz odpowiedz' },
|
||||||
|
hide_answer: { de: 'Antwort verbergen', en: 'Hide answer', tr: 'Cevabi gizle', ar: '\u0625\u062e\u0641\u0627\u0621', uk: '\u0421\u0445\u043e\u0432\u0430\u0442\u0438', ru: '\u0421\u043a\u0440\u044b\u0442\u044c', pl: 'Ukryj odpowiedz' },
|
||||||
|
|
||||||
|
// --- Instructions ---
|
||||||
|
flip_card: { de: 'Klick zum Umdrehen', en: 'Click to flip', tr: 'Cevirmek icin tikla', ar: '\u0627\u0646\u0642\u0631 \u0644\u0644\u0642\u0644\u0628', uk: '\u041a\u043b\u0456\u043a \u0434\u043b\u044f \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u0442\u0430\u043d\u043d\u044f', ru: '\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0447\u0442\u043e\u0431\u044b \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u0442\u044c', pl: 'Kliknij aby odwrocic' },
|
||||||
|
ask_child: { de: 'Fragen Sie Ihr Kind:', en: 'Ask your child:', tr: 'Cocugunuza sorun:', ar: '\u0627\u0633\u0623\u0644 \u0637\u0641\u0644\u0643:', uk: '\u0417\u0430\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u0434\u0438\u0442\u0438\u043d\u0443:', ru: '\u0421\u043f\u0440\u043e\u0441\u0438\u0442\u0435 \u0440\u0435\u0431\u0435\u043d\u043a\u0430:', pl: 'Zapytaj dziecko:' },
|
||||||
|
correct_answer: { de: 'Richtige Antwort:', en: 'Correct answer:', tr: 'Dogru cevap:', ar: '\u0627\u0644\u0625\u062c\u0627\u0628\u0629 \u0627\u0644\u0635\u062d\u064a\u062d\u0629:', uk: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u0430 \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u044c:', ru: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043e\u0442\u0432\u0435\u0442:', pl: 'Poprawna odpowiedz:' },
|
||||||
|
translate: { de: 'Uebersetze:', en: 'Translate:', tr: 'Cevir:', ar: '\u062a\u0631\u062c\u0645:', uk: '\u041f\u0435\u0440\u0435\u043a\u043b\u0430\u0434\u0438:', ru: '\u041f\u0435\u0440\u0435\u0432\u0435\u0434\u0438\u0442\u0435:', pl: 'Przetlumacz:' },
|
||||||
|
type_answer: { de: 'Antwort eintippen...', en: 'Type your answer...', tr: 'Cevabin yaz...', ar: '\u0627\u0643\u062a\u0628 \u0625\u062c\u0627\u0628\u062a\u0643...', uk: '\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u0432\u0456\u0434\u043f\u043e\u0432\u0456\u0434\u044c...', ru: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043e\u0442\u0432\u0435\u0442...', pl: 'Wpisz odpowiedz...' },
|
||||||
|
listen_instruction: { de: 'Hoere das Wort und waehle die Uebersetzung', en: 'Listen and choose the translation', tr: 'Kelimeyi dinle ve ceviriyi sec', ar: '\u0627\u0633\u062a\u0645\u0639 \u0648\u0627\u062e\u062a\u0631 \u0627\u0644\u062a\u0631\u062c\u0645\u0629', uk: '\u041f\u043e\u0441\u043b\u0443\u0445\u0430\u0439 \u0456 \u0432\u0438\u0431\u0435\u0440\u0438 \u043f\u0435\u0440\u0435\u043a\u043b\u0430\u0434', ru: '\u041f\u043e\u0441\u043b\u0443\u0448\u0430\u0439\u0442\u0435 \u0438 \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0435\u0440\u0435\u0432\u043e\u0434', pl: 'Posluchaj i wybierz tlumaczenie' },
|
||||||
|
pronounce_instruction: { de: 'Hoere zu, dann sprich nach', en: 'Listen, then repeat', tr: 'Dinle, sonra tekrar et', ar: '\u0627\u0633\u062a\u0645\u0639 \u062b\u0645 \u0643\u0631\u0631', uk: '\u041f\u043e\u0441\u043b\u0443\u0445\u0430\u0439, \u043f\u043e\u0442\u0456\u043c \u043f\u043e\u0432\u0442\u043e\u0440\u0438', ru: '\u041f\u043e\u0441\u043b\u0443\u0448\u0430\u0439\u0442\u0435, \u0437\u0430\u0442\u0435\u043c \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435', pl: 'Posluchaj, potem powtorz' },
|
||||||
|
match_instruction: { de: 'Verbinde die Paare', en: 'Match the pairs', tr: 'Esleri eslestir', ar: '\u0637\u0627\u0628\u0642 \u0627\u0644\u0623\u0632\u0648\u0627\u062c', uk: '\u0417\u2019\u0454\u0434\u043d\u0430\u0439 \u043f\u0430\u0440\u0438', ru: '\u0421\u043e\u0435\u0434\u0438\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u044b', pl: 'Polacz pary' },
|
||||||
|
tap_speaker: { de: 'Tippe auf den Lautsprecher', en: 'Tap the speaker', tr: 'Hoparlore dokun', ar: '\u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0627\u0644\u0645\u0643\u0628\u0631', uk: '\u041d\u0430\u0442\u0438\u0441\u043d\u0456\u0442\u044c \u043d\u0430 \u0434\u0438\u043d\u0430\u043c\u0456\u043a', ru: '\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043d\u0430 \u0434\u0438\u043d\u0430\u043c\u0438\u043a', pl: 'Dotknij glosnika' },
|
||||||
|
|
||||||
|
// --- Results ---
|
||||||
|
almost_right: { de: 'Fast richtig!', en: 'Almost right!', tr: 'Neredeyse dogru!', ar: '\u062a\u0642\u0631\u064a\u0628\u0627 \u0635\u062d\u064a\u062d!', uk: '\u041c\u0430\u0439\u0436\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e!', ru: '\u041f\u043e\u0447\u0442\u0438 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e!', pl: 'Prawie dobrze!' },
|
||||||
|
well_done: { de: 'Super gemacht!', en: 'Well done!', tr: 'Harika!', ar: '\u0623\u062d\u0633\u0646\u062a!', uk: '\u0427\u0443\u0434\u043e\u0432\u043e!', ru: '\u041c\u043e\u043b\u043e\u0434\u0435\u0446!', pl: 'Swietnie!' },
|
||||||
|
correct_spoken: { de: 'Richtig ausgesprochen!', en: 'Correct pronunciation!', tr: 'Dogru telaffuz!', ar: '\u0646\u0637\u0642 \u0635\u062d\u064a\u062d!', uk: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u0430 \u0432\u0438\u043c\u043e\u0432\u0430!', ru: '\u041f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u043e\u0438\u0437\u043d\u043e\u0448\u0435\u043d\u0438\u0435!', pl: 'Poprawna wymowa!' },
|
||||||
|
all_matched: { de: 'Alle zugeordnet!', en: 'All matched!', tr: 'Hepsi eslesti!', ar: '\u062a\u0645 \u0627\u0644\u0645\u0637\u0627\u0628\u0642\u0629!', uk: '\u0412\u0441\u0435 \u0437\u2019\u0454\u0434\u043d\u0430\u043d\u043e!', ru: '\u0412\u0441\u0435 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u043e!', pl: 'Wszystko dopasowane!' },
|
||||||
|
errors: { de: 'Fehler', en: 'Errors', tr: 'Hata', ar: '\u0623\u062e\u0637\u0627\u0621', uk: '\u041f\u043e\u043c\u0438\u043b\u043a\u0438', ru: '\u041e\u0448\u0438\u0431\u043a\u0438', pl: 'Bledy' },
|
||||||
|
|
||||||
|
// --- Labels ---
|
||||||
|
english: { de: 'Englisch', en: 'English', tr: 'Ingilizce', ar: '\u0627\u0644\u0625\u0646\u062c\u0644\u064a\u0632\u064a\u0629', uk: '\u0410\u043d\u0433\u043b\u0456\u0439\u0441\u044c\u043a\u0430', ru: '\u0410\u043d\u0433\u043b\u0438\u0439\u0441\u043a\u0438\u0439', pl: 'Angielski' },
|
||||||
|
german: { de: 'Deutsch', en: 'German', tr: 'Almanca', ar: '\u0627\u0644\u0623\u0644\u0645\u0627\u0646\u064a\u0629', uk: '\u041d\u0456\u043c\u0435\u0446\u044c\u043a\u0430', ru: '\u041d\u0435\u043c\u0435\u0446\u043a\u0438\u0439', pl: 'Niemiecki' },
|
||||||
|
question: { de: 'Frage', en: 'Question', tr: 'Soru', ar: '\u0633\u0624\u0627\u0644', uk: '\u041f\u0438\u0442\u0430\u043d\u043d\u044f', ru: '\u0412\u043e\u043f\u0440\u043e\u0441', pl: 'Pytanie' },
|
||||||
|
cards: { de: 'Karten', en: 'Cards', tr: 'Kartlar', ar: '\u0628\u0637\u0627\u0642\u0627\u062a', uk: '\u041a\u0430\u0440\u0442\u043a\u0438', ru: '\u041a\u0430\u0440\u0442\u043e\u0447\u043a\u0438', pl: 'Karty' },
|
||||||
|
flashcards: { de: 'Karteikarten', en: 'Flashcards', tr: 'Kartlar', ar: '\u0628\u0637\u0627\u0642\u0627\u062a', uk: '\u041a\u0430\u0440\u0442\u043a\u0438', ru: '\u041a\u0430\u0440\u0442\u043e\u0447\u043a\u0438', pl: 'Fiszki' },
|
||||||
|
quiz: { de: 'Quiz', en: 'Quiz', tr: 'Quiz', ar: '\u0627\u062e\u062a\u0628\u0627\u0631', uk: '\u0422\u0435\u0441\u0442', ru: '\u0422\u0435\u0441\u0442', pl: 'Quiz' },
|
||||||
|
type_exercise: { de: 'Eintippen', en: 'Type', tr: 'Yaz', ar: '\u0627\u0643\u062a\u0628', uk: '\u0412\u0432\u0435\u0434\u0438', ru: '\u041d\u0430\u043f\u0438\u0448\u0438', pl: 'Wpisz' },
|
||||||
|
listen: { de: 'Hoeren', en: 'Listen', tr: 'Dinle', ar: '\u0627\u0633\u062a\u0645\u0639', uk: '\u0421\u043b\u0443\u0445\u0430\u0439', ru: '\u0421\u043b\u0443\u0448\u0430\u0442\u044c', pl: 'Sluchaj' },
|
||||||
|
match: { de: 'Zuordnen', en: 'Match', tr: 'Eslestir', ar: '\u0637\u0627\u0628\u0642', uk: '\u0417\u2019\u0454\u0434\u043d\u0430\u0439', ru: '\u0421\u043e\u0435\u0434\u0438\u043d\u0438', pl: 'Dopasuj' },
|
||||||
|
pronounce: { de: 'Sprechen', en: 'Speak', tr: 'Konus', ar: '\u062a\u062d\u062f\u062b', uk: '\u0413\u043e\u0432\u043e\u0440\u0438', ru: '\u0413\u043e\u0432\u043e\u0440\u0438', pl: 'Mow' },
|
||||||
|
story: { de: 'Geschichte', en: 'Story', tr: 'Hikaye', ar: '\u0642\u0635\u0629', uk: '\u0406\u0441\u0442\u043e\u0440\u0456\u044f', ru: '\u0418\u0441\u0442\u043e\u0440\u0438\u044f', pl: 'Historia' },
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
// Re-export from Context so all existing imports keep working
|
||||||
|
export { useNativeLanguage } from './NativeLanguageContext'
|
||||||
Reference in New Issue
Block a user