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
|
||||
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():
|
||||
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
|
||||
@@ -56,48 +103,17 @@ async def synthesize_word(
|
||||
if os.path.exists(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:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TTS_SERVICE_URL}/synthesize",
|
||||
f"{TTS_SERVICE_URL}/synthesize-direct",
|
||||
json={
|
||||
"text": text,
|
||||
"text": speak_text,
|
||||
"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"):
|
||||
|
||||
@@ -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")
|
||||
async def api_search_words(
|
||||
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),
|
||||
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():
|
||||
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)
|
||||
return {
|
||||
"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")
|
||||
async def api_browse_words(
|
||||
pos: str = Query("", description="Part of speech filter"),
|
||||
@@ -92,10 +146,13 @@ async def api_get_filters():
|
||||
tags = await get_all_tags()
|
||||
pos_list = await get_all_pos()
|
||||
total = await count_words()
|
||||
# Kaikki stats (hardcoded to avoid slow COUNT on 6M rows)
|
||||
return {
|
||||
"tags": tags,
|
||||
"parts_of_speech": pos_list,
|
||||
"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")
|
||||
|
||||
|
||||
@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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -254,6 +327,13 @@ async def api_create_unit_from_words(payload: CreateUnitFromWordsPayload):
|
||||
},
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Auto-enrich words with images (Wikipedia + emoji fallback)
|
||||
try:
|
||||
from services.image_service import enrich_words_with_images
|
||||
await enrich_words_with_images(payload.word_ids)
|
||||
except Exception as e:
|
||||
logger.warning(f"Image enrichment failed (non-critical): {e}")
|
||||
|
||||
logger.info(f"Created vocab unit {lu.id} with {len(words)} words")
|
||||
|
||||
return {
|
||||
@@ -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):
|
||||
word_ids: List[str]
|
||||
target_language: str
|
||||
|
||||
@@ -20,6 +20,7 @@ volumes:
|
||||
transcription_models:
|
||||
transcription_temp:
|
||||
lehrer_backend_data:
|
||||
lehrer_arbeitsblaetter:
|
||||
opensearch_data:
|
||||
# Communication (Jitsi + Matrix)
|
||||
synapse_data:
|
||||
@@ -159,6 +160,7 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- lehrer_backend_data:/app/data
|
||||
- lehrer_arbeitsblaetter:/root/Arbeitsblaetter
|
||||
environment:
|
||||
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
|
||||
|
||||
@@ -20,10 +20,21 @@ async function proxyRequest(
|
||||
fetchOptions.body = await request.text()
|
||||
}
|
||||
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()
|
||||
return new NextResponse(data, {
|
||||
status: resp.status,
|
||||
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
|
||||
headers: { 'Content-Type': contentType },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e) }, { status: 502 })
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
/* 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;
|
||||
margin: 0;
|
||||
|
||||
@@ -1,109 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import React from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
|
||||
export default function ImpressumPage() {
|
||||
const { t } = useLanguage()
|
||||
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 (
|
||||
<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-indigo-100'
|
||||
}`}>
|
||||
<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>
|
||||
<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'}`}>
|
||||
<div className="relative z-10 p-4 flex-shrink-0"><Sidebar /></div>
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||
<div className="max-w-3xl mx-auto px-6 py-8 space-y-6">
|
||||
|
||||
{/* Content Card */}
|
||||
<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>
|
||||
<h1 className={`text-2xl font-bold ${h2c}`}>Impressum</h1>
|
||||
|
||||
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Angaben gemäß § 5 TMG
|
||||
</h2>
|
||||
<p>
|
||||
BreakPilot GmbH<br />
|
||||
Musterstraße 123<br />
|
||||
12345 Musterstadt<br />
|
||||
Deutschland
|
||||
<section className={`${gc} rounded-2xl p-6`}>
|
||||
<h2 className={`text-lg font-semibold mb-3 ${h2c}`}>Angaben gemaess § 5 TMG</h2>
|
||||
<div className={`space-y-1 ${tc}`}>
|
||||
<p>[Firmenname GmbH]</p>
|
||||
<p>[Strasse und Hausnummer]</p>
|
||||
<p>[PLZ Ort]</p>
|
||||
<p className="mt-3"><strong>Vertreten durch:</strong> [Geschaeftsfuehrer]</p>
|
||||
<p><strong>Registergericht:</strong> [Amtsgericht], HRB [Nummer]</p>
|
||||
<p><strong>USt-IdNr.:</strong> DE [Nummer]</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${gc} rounded-2xl p-6`}>
|
||||
<h2 className={`text-lg font-semibold mb-3 ${h2c}`}>Kontakt</h2>
|
||||
<div className={`space-y-1 ${tc}`}>
|
||||
<p>E-Mail: [E-Mail-Adresse]</p>
|
||||
<p>Telefon: [Telefonnummer]</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={`${gc} rounded-2xl p-6`}>
|
||||
<h2 className={`text-lg font-semibold mb-3 ${h2c}`}>Quellen, Lizenzen und Namensnennung</h2>
|
||||
<div className={`space-y-4 ${tc}`}>
|
||||
|
||||
<div>
|
||||
<h3 className={`font-medium mb-1 ${sc}`}>Woerterbuch- und Uebersetzungsdaten</h3>
|
||||
<p className="text-sm">
|
||||
Basierend auf Daten aus{' '}
|
||||
<a href="https://en.wiktionary.org" className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">Wiktionary</a>,
|
||||
extrahiert ueber{' '}
|
||||
<a href="https://kaikki.org" className="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">Kaikki.org</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Kontakt
|
||||
</h2>
|
||||
<p>
|
||||
Telefon: +49 (0) 123 456789<br />
|
||||
E-Mail: info@breakpilot.de
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
Referenz: Tatu Ylonen: "Wiktextract: Wiktionary as Machine-Readable Structured Data", LREC 2022, pp. 1317-1325.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Vertretungsberechtigte Geschäftsführer
|
||||
</h2>
|
||||
<p>Max Mustermann</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Registereintrag
|
||||
</h2>
|
||||
<p>
|
||||
Eintragung im Handelsregister<br />
|
||||
Registergericht: Amtsgericht Musterstadt<br />
|
||||
Registernummer: HRB 12345
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<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 className="text-xs mt-1 opacity-70">
|
||||
Lizenz: CC BY-SA 3.0 und GFDL. Aenderungen: Strukturierte Extraktion, Filterung und Aufbereitung fuer Lernzwecke.
|
||||
</p>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AlertsProvider } from '@/lib/AlertsContext'
|
||||
import { AlertsB2BProvider } from '@/lib/AlertsB2BContext'
|
||||
import { MessagesProvider } from '@/lib/MessagesContext'
|
||||
import { ActivityProvider } from '@/lib/ActivityContext'
|
||||
import { NativeLanguageProvider } from '@/lib/NativeLanguageContext'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'BreakPilot Studio v2',
|
||||
@@ -26,7 +27,9 @@ export default function RootLayout({
|
||||
<AlertsB2BProvider>
|
||||
<MessagesProvider>
|
||||
<ActivityProvider>
|
||||
{children}
|
||||
<NativeLanguageProvider>
|
||||
{children}
|
||||
</NativeLanguageProvider>
|
||||
</ActivityProvider>
|
||||
</MessagesProvider>
|
||||
</AlertsB2BProvider>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
import { FlashCard } from '@/components/learn/FlashCard'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
|
||||
@@ -23,6 +24,7 @@ export default function FlashcardsPage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||
|
||||
const [items, setItems] = useState<QAItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
@@ -101,7 +103,7 @@ export default function FlashcardsPage() {
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||
|
||||
@@ -14,6 +15,7 @@ export default function ListenPage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||
|
||||
const [items, setItems] = useState<QAItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
@@ -74,7 +76,7 @@ export default function ListenPage() {
|
||||
const currentItem = items[currentIndex]
|
||||
|
||||
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 */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,16 +3,42 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
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 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() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||
|
||||
const [allItems, setAllItems] = useState<QAItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -20,7 +46,10 @@ export default function MatchPage() {
|
||||
const [selectedLeft, setSelectedLeft] = useState<string | null>(null)
|
||||
const [matched, setMatched] = useState<Set<string>>(new Set())
|
||||
const [wrongPair, setWrongPair] = useState<string | null>(null)
|
||||
const [firstTryCorrect, setFirstTryCorrect] = useState(0)
|
||||
const [retryCorrect, setRetryCorrect] = useState(0)
|
||||
const [errors, setErrors] = useState(0)
|
||||
const [failedIds, setFailedIds] = useState<Set<string>>(new Set())
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
const glassCard = isDark
|
||||
@@ -36,16 +65,8 @@ export default function MatchPage() {
|
||||
})()
|
||||
}, [unitId])
|
||||
|
||||
// Take 6 items per round
|
||||
const roundItems = useMemo(() => {
|
||||
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 roundItems = useMemo(() => allItems.slice(round * 6, round * 6 + 6), [allItems, round])
|
||||
const shuffledRight = useMemo(() => [...roundItems].sort(() => Math.random() - 0.5), [roundItems])
|
||||
|
||||
const handleLeftTap = useCallback((id: string) => {
|
||||
if (matched.has(id)) return
|
||||
@@ -55,116 +76,145 @@ export default function MatchPage() {
|
||||
|
||||
const handleRightTap = useCallback((id: string) => {
|
||||
if (!selectedLeft || matched.has(id)) return
|
||||
|
||||
if (selectedLeft === id) {
|
||||
// Correct match
|
||||
setMatched(prev => new Set([...prev, id]))
|
||||
if (failedIds.has(id)) setRetryCorrect(c => c + 1)
|
||||
else setFirstTryCorrect(c => c + 1)
|
||||
setSelectedLeft(null)
|
||||
|
||||
// Check if round complete
|
||||
if (matched.size + 1 >= roundItems.length) {
|
||||
const nextStart = (round + 1) * 6
|
||||
if (nextStart >= allItems.length) {
|
||||
setTimeout(() => setIsComplete(true), 500)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setRound(r => r + 1)
|
||||
setMatched(new Set())
|
||||
setSelectedLeft(null)
|
||||
}, 800)
|
||||
}
|
||||
if (nextStart >= allItems.length) setTimeout(() => setIsComplete(true), 500)
|
||||
else setTimeout(() => { setRound(r => r + 1); setMatched(new Set()); setSelectedLeft(null) }, 800)
|
||||
}
|
||||
} else {
|
||||
// Wrong match
|
||||
setWrongPair(id)
|
||||
setErrors(e => e + 1)
|
||||
setTimeout(() => {
|
||||
setWrongPair(null)
|
||||
setSelectedLeft(null)
|
||||
}, 600)
|
||||
setFailedIds(prev => new Set([...prev, selectedLeft]))
|
||||
setTimeout(() => { setWrongPair(null); setSelectedLeft(null) }, 600)
|
||||
}
|
||||
}, [selectedLeft, matched, roundItems, round, allItems])
|
||||
}, [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)
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
const totalPairs = allItems.length
|
||||
const matchedTotal = round * 6 + matched.size
|
||||
const isPerfect = isComplete && errors === 0
|
||||
|
||||
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'}`}>
|
||||
{/* Header */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<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'}`}>
|
||||
<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>
|
||||
Zurueck
|
||||
</button>
|
||||
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Zuordnen</h1>
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{matchedTotal}/{totalPairs}</span>
|
||||
</div>
|
||||
</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>
|
||||
// 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 (
|
||||
<div key={`n-${item.id}`}
|
||||
className={`flex items-center gap-2 px-3 py-2.5 rounded-lg border text-sm transition-all ${
|
||||
isMatched
|
||||
? 'opacity-30 border-green-400/30 bg-green-500/5'
|
||||
: isSelected
|
||||
? (isDark ? 'border-cyan-400/50 bg-cyan-500/10 text-cyan-200' : 'border-cyan-400 bg-cyan-50 text-cyan-800')
|
||||
: (isDark ? 'border-white/10 text-white/50' : 'border-slate-200 text-slate-500')
|
||||
}`}>
|
||||
<span className="flex-1 truncate">{native || '—'}</span>
|
||||
{native && !isMatched && (
|
||||
<AudioButton text={native} lang={nativeLang as 'en' | 'de'} isDark={isDark} size="sm" />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
|
||||
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 { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
import { MicrophoneInput } from '@/components/learn/MicrophoneInput'
|
||||
import { SyllableBow, simpleSyllableSplit } from '@/components/learn/SyllableBow'
|
||||
@@ -20,6 +21,7 @@ export default function PronouncePage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||
|
||||
const [items, setItems] = useState<QAItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
@@ -67,7 +69,7 @@ export default function PronouncePage() {
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
import { QuizQuestion } from '@/components/learn/QuizQuestion'
|
||||
|
||||
interface MCQuestion {
|
||||
@@ -21,6 +22,7 @@ export default function QuizPage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||
|
||||
const [questions, setQuestions] = useState<MCQuestion[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
@@ -40,10 +42,37 @@ export default function QuizPage() {
|
||||
const loadMC = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/mc`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
setQuestions(data.questions || [])
|
||||
// Try MC endpoint first
|
||||
let resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/mc`)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
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) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -73,7 +102,7 @@ export default function QuizPage() {
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<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 ? '🎉' : '💪'}
|
||||
</div>
|
||||
<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>
|
||||
<p className={`text-lg mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
|
||||
function getApiBase() {
|
||||
@@ -13,6 +14,7 @@ export default function StoryPage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
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 [isLoading, setIsLoading] = useState(false)
|
||||
@@ -65,7 +67,7 @@ export default function StoryPage() {
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
import { TypeInput } from '@/components/learn/TypeInput'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
|
||||
@@ -21,6 +22,7 @@ export default function TypePage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||
|
||||
const [items, setItems] = useState<QAItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
@@ -94,7 +96,7 @@ export default function TypePage() {
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
+187
-106
@@ -2,8 +2,8 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { UnitCard } from '@/components/learn/UnitCard'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
|
||||
interface LearningUnit {
|
||||
id: string
|
||||
@@ -17,31 +17,106 @@ interface LearningUnit {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
return '' // Same-origin proxy via /api/learning-units/...
|
||||
// Parent guide translations
|
||||
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() {
|
||||
const { isDark } = useTheme()
|
||||
const { t, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||
const [units, setUnits] = useState<LearningUnit[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
const glassCard = isDark
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||
|
||||
useEffect(() => {
|
||||
loadUnits()
|
||||
}, [])
|
||||
const g = (key: string) => guide[key]?.[nativeLang] || guide[key]?.['de'] || key
|
||||
|
||||
useEffect(() => { loadUnits() }, [])
|
||||
|
||||
const loadUnits = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`${getApiBase()}/api/learning-units/`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
setUnits(data)
|
||||
setUnits(await resp.json())
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@@ -52,110 +127,116 @@ export default function LearnPage() {
|
||||
const handleDelete = async (unitId: string) => {
|
||||
try {
|
||||
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}`, { method: 'DELETE' })
|
||||
if (resp.ok) {
|
||||
setUnits((prev) => prev.filter((u) => u.id !== unitId))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
if (resp.ok) setUnits(prev => prev.filter(u => u.id !== unitId))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
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 */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-5xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Lernmodule
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Karteikarten, Quiz und Lueckentexte aus deinen Vokabeln
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-5xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${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">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{isThirdLanguage ? g('welcome') : 'Meine Lernmodule'}
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{isThirdLanguage
|
||||
? g('exercises_explained')
|
||||
: 'Karteikarten, Quiz und Lueckentexte aus deinen Vokabeln'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-5xl mx-auto w-full px-6 py-6">
|
||||
{isLoading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={`${glassCard} rounded-2xl p-6 text-center`}>
|
||||
<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">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && units.length === 0 && (
|
||||
<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 ${
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Noch keine Lernmodule
|
||||
</h3>
|
||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Scanne eine Schulbuchseite im Vokabel-Arbeitsblatt Generator und klicke "Lernmodule generieren".
|
||||
</p>
|
||||
<a
|
||||
href="/vocab-worksheet"
|
||||
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"
|
||||
>
|
||||
Zum Vokabel-Scanner
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && units.length > 0 && (
|
||||
<div className="grid gap-4">
|
||||
{units.map((unit) => (
|
||||
<UnitCard key={unit.id} unit={unit} isDark={isDark} glassCard={glassCard} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={`${glassCard} rounded-2xl p-6 text-center`}>
|
||||
<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">
|
||||
{t('again')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && units.length === 0 && (
|
||||
<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 ${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">
|
||||
<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>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{isThirdLanguage ? g('welcome') : 'Noch keine Lernmodule'}
|
||||
</h3>
|
||||
<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">
|
||||
{isThirdLanguage ? g('exercises_explained') : 'Zum Woerterbuch'}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new unit button */}
|
||||
{!isLoading && (
|
||||
<div className="mb-4">
|
||||
<a
|
||||
href="/vocabulary"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && units.length > 0 && (
|
||||
<div className="grid gap-4">
|
||||
{units.map(unit => (
|
||||
<UnitCard key={unit.id} unit={unit} isDark={isDark} glassCard={glassCard} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import type { Language } from '@/lib/i18n'
|
||||
import { LearnLayout } from '@/components/learn/LearnLayout'
|
||||
|
||||
interface LangOption {
|
||||
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 { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import type { Language } from '@/lib/i18n'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
|
||||
interface LearningUnit {
|
||||
id: string
|
||||
@@ -26,11 +28,20 @@ const parentT: Record<string, Record<string, string>> = {
|
||||
|
||||
export default function ParentPage() {
|
||||
const { isDark } = useTheme()
|
||||
const { language } = useLanguage()
|
||||
const { language, setLanguage } = useLanguage()
|
||||
const { nativeLang, setNativeLang } = useNativeLanguage()
|
||||
const [units, setUnits] = useState<LearningUnit[]>([])
|
||||
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
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
@@ -45,9 +56,7 @@ export default function ParentPage() {
|
||||
}, [])
|
||||
|
||||
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'}`}
|
||||
dir={language === 'ar' ? 'rtl' : 'ltr'}>
|
||||
|
||||
<div dir={language === 'ar' ? 'rtl' : 'ltr'}>
|
||||
{/* Header */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-lg mx-auto px-6 py-5">
|
||||
@@ -60,9 +69,9 @@ export default function ParentPage() {
|
||||
{t('greeting')}
|
||||
</p>
|
||||
</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'}`}>
|
||||
{language.toUpperCase()}
|
||||
</Link>
|
||||
<span className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{activeLang.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,23 +5,33 @@ import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
import { useNativeLanguage } from '@/lib/useNativeLanguage'
|
||||
|
||||
interface QAItem {
|
||||
id: string; question: string; answer: string
|
||||
translations?: Record<string, { text?: string }>
|
||||
}
|
||||
|
||||
const pt: Record<string, Record<string, string>> = {
|
||||
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:' },
|
||||
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:' },
|
||||
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' },
|
||||
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' },
|
||||
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' },
|
||||
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' },
|
||||
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!' },
|
||||
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' },
|
||||
back: { de: 'Zurueck', tr: 'Geri', ar: '\u0631\u062c\u0648\u0639', uk: '\u041d\u0430\u0437\u0430\u0434', ru: '\u041d\u0430\u0437\u0430\u0434', en: 'Back' },
|
||||
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' },
|
||||
// Context explanations in all languages
|
||||
const parentGuide: Record<string, Record<string, string>> = {
|
||||
title: {
|
||||
de: 'Vokabelabfrage fuer Ihr Kind',
|
||||
en: 'Vocabulary quiz for your child',
|
||||
tr: 'Cocugunuz icin kelime sorgusu',
|
||||
ar: '\u0627\u062e\u062a\u0628\u0627\u0631 \u0645\u0641\u0631\u062f\u0627\u062a \u0644\u0637\u0641\u0644\u0643',
|
||||
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',
|
||||
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',
|
||||
pl: 'Test slowek dla Twojego dziecka',
|
||||
},
|
||||
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() {
|
||||
@@ -29,6 +39,7 @@ export default function ParentQuizPage() {
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
const { language } = useLanguage()
|
||||
const { t, wordInNative, nativeLang, isThirdLanguage } = useNativeLanguage()
|
||||
|
||||
const [items, setItems] = useState<QAItem[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
@@ -36,13 +47,14 @@ export default function ParentQuizPage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
const t = (key: string) => pt[key]?.[language] || pt[key]?.['de'] || key
|
||||
const [showGuide, setShowGuide] = useState(true)
|
||||
|
||||
const glassCard = isDark
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||
|
||||
const pg = (key: string) => parentGuide[key]?.[nativeLang] || parentGuide[key]?.['de'] || key
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/learning-units/${unitId}/qa`)
|
||||
.then(r => r.ok ? r.json() : { qa_items: [] })
|
||||
@@ -57,33 +69,33 @@ export default function ParentQuizPage() {
|
||||
incorrect: prev.incorrect + (correct ? 0 : 1),
|
||||
}))
|
||||
setShowAnswer(false)
|
||||
if (currentIndex + 1 >= items.length) { setIsComplete(true) }
|
||||
else { setCurrentIndex(i => i + 1) }
|
||||
if (currentIndex + 1 >= items.length) setIsComplete(true)
|
||||
else setCurrentIndex(i => i + 1)
|
||||
}, [currentIndex, items.length])
|
||||
|
||||
const currentItem = items[currentIndex]
|
||||
const nativeTranslation = currentItem?.translations?.[language]?.text || ''
|
||||
const nativeWord = wordInNative(currentItem?.translations)
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
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'}`}
|
||||
dir={language === 'ar' ? 'rtl' : 'ltr'}>
|
||||
|
||||
<div dir={nativeLang === 'ar' ? 'rtl' : 'ltr'}>
|
||||
{/* Header */}
|
||||
<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'}`}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<span className={`font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{t('question')} {currentIndex + 1}/{items.length}
|
||||
</span>
|
||||
<span />
|
||||
<button onClick={() => setShowGuide(!showGuide)} className={`text-sm ${isDark ? 'text-blue-300' : 'text-blue-600'}`}>
|
||||
{showGuide ? '?' : '?'}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<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 ? (
|
||||
<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>
|
||||
<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'}`}>
|
||||
@@ -102,71 +114,129 @@ export default function ParentQuizPage() {
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<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')}
|
||||
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>
|
||||
) : currentItem ? (
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{/* Instruction for parent */}
|
||||
<p className={`text-center text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{t('ask_child')}
|
||||
</p>
|
||||
<div className="flex gap-6">
|
||||
{/* Left: Quiz Card */}
|
||||
<div className="flex-1 space-y-5">
|
||||
{/* Instruction */}
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{t('ask_child')}
|
||||
</p>
|
||||
|
||||
{/* Word to ask */}
|
||||
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
|
||||
<span className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{currentItem.question}
|
||||
</span>
|
||||
{nativeTranslation && (
|
||||
<p className={`text-lg mt-3 ${isDark ? 'text-blue-300/70' : 'text-blue-600'}`}>
|
||||
({nativeTranslation})
|
||||
{/* Word Card */}
|
||||
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
|
||||
{/* 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}
|
||||
</p>
|
||||
|
||||
{/* Native translation */}
|
||||
{nativeWord && (
|
||||
<p className={`text-lg mt-3 ${isDark ? 'text-cyan-300/80' : 'text-cyan-700'}`}>
|
||||
= {nativeWord}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Audio buttons */}
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
{/* Show/Hide Answer */}
|
||||
<button
|
||||
onClick={() => setShowAnswer(!showAnswer)}
|
||||
className={`w-full py-3 rounded-xl border text-sm font-medium transition-all ${
|
||||
isDark ? 'border-white/20 text-white/70 hover:bg-white/5' : 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{showAnswer ? t('hide_answer') : t('show_answer')}
|
||||
</button>
|
||||
|
||||
{/* Answer */}
|
||||
{showAnswer && (
|
||||
<div className={`${glassCard} rounded-2xl p-6 text-center`}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{t('correct_answer')}</p>
|
||||
<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}
|
||||
</p>
|
||||
{nativeWord && (
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-cyan-300/60' : 'text-cyan-600'}`}>
|
||||
= {nativeWord}
|
||||
</p>
|
||||
)}
|
||||
<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" />
|
||||
<span className={`text-[10px] ${isDark ? 'text-white/30' : 'text-slate-400'}`}>DE</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center gap-3 mt-4">
|
||||
<AudioButton text={currentItem.question} lang="en" isDark={isDark} size="md" />
|
||||
|
||||
{/* Trilingual Buttons */}
|
||||
<div className="flex gap-4">
|
||||
<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">
|
||||
<span className="text-lg">{t('wrong')}</span>
|
||||
{isThirdLanguage && <span className={`block text-xs mt-0.5 text-white/60`}>Falsch / Wrong</span>}
|
||||
</button>
|
||||
<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">
|
||||
<span className="text-lg">{t('correct')}</span>
|
||||
{isThirdLanguage && <span className={`block text-xs mt-0.5 text-white/60`}>Richtig / Correct</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show/Hide Answer */}
|
||||
<button
|
||||
onClick={() => setShowAnswer(!showAnswer)}
|
||||
className={`w-full py-3 rounded-xl border text-sm font-medium transition-all ${
|
||||
isDark ? 'border-white/20 text-white/70 hover:bg-white/5' : 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{showAnswer ? t('hide_answer') : t('show_answer')}
|
||||
</button>
|
||||
|
||||
{showAnswer && (
|
||||
<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>
|
||||
<span className={`text-2xl font-bold ${isDark ? 'text-green-300' : 'text-green-700'}`}>
|
||||
{currentItem.answer}
|
||||
</span>
|
||||
{nativeTranslation && (
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
({nativeTranslation})
|
||||
{/* 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="flex justify-center mt-3">
|
||||
<AudioButton text={currentItem.answer} lang="de" isDark={isDark} size="md" />
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Right/Wrong Buttons */}
|
||||
<div className="flex gap-4">
|
||||
<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">
|
||||
{t('wrong')}
|
||||
</button>
|
||||
<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">
|
||||
{t('correct')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</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 [posFilter, setPosFilter] = useState('')
|
||||
const [diffFilter, setDiffFilter] = useState(0)
|
||||
const [searchLang, setSearchLang] = useState('en')
|
||||
const [topics, setTopics] = useState<{ topic: string; words: string[]; display_words?: string[]; word_count: number }[]>([])
|
||||
const [showTopics, setShowTopics] = useState(false)
|
||||
|
||||
// Unit builder
|
||||
const [selectedWords, setSelectedWords] = useState<VocabWord[]>([])
|
||||
@@ -71,7 +74,7 @@ export default function VocabularyPage() {
|
||||
try {
|
||||
let url: string
|
||||
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 {
|
||||
const params = new URLSearchParams({ limit: '30' })
|
||||
if (posFilter) params.set('pos', posFilter)
|
||||
@@ -83,6 +86,15 @@ export default function VocabularyPage() {
|
||||
const data = await resp.json()
|
||||
setResults(data.words || [])
|
||||
}
|
||||
|
||||
// Also search for matching topics
|
||||
if (query.trim()) {
|
||||
const topicResp = await fetch(`${getApiBase()}/api/vocabulary/topics?q=${encodeURIComponent(query)}&lang=${searchLang}`)
|
||||
if (topicResp.ok) {
|
||||
const topicData = await topicResp.json()
|
||||
setTopics(topicData.topics || [])
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err)
|
||||
} finally {
|
||||
@@ -91,7 +103,7 @@ export default function VocabularyPage() {
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, posFilter, diffFilter])
|
||||
}, [query, posFilter, diffFilter, searchLang])
|
||||
|
||||
const toggleWord = useCallback((word: VocabWord) => {
|
||||
setSelectedWords(prev => {
|
||||
@@ -145,7 +157,7 @@ export default function VocabularyPage() {
|
||||
<div>
|
||||
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>Woerterbuch</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{filters.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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,11 +170,38 @@ export default function VocabularyPage() {
|
||||
{/* Search Bar */}
|
||||
<div className={`${glassCard} rounded-2xl p-4`}>
|
||||
<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
|
||||
type="text"
|
||||
value={query}
|
||||
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}`}
|
||||
autoFocus
|
||||
/>
|
||||
@@ -190,7 +229,71 @@ export default function VocabularyPage() {
|
||||
</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`}>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Keine Ergebnisse fuer "{query}"</p>
|
||||
</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" />
|
||||
</svg>
|
||||
), 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">
|
||||
<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>
|
||||
)},
|
||||
{ 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: (
|
||||
<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" />
|
||||
@@ -153,7 +148,7 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
|
||||
onMouseEnter={() => setSidebarHovered(true)}
|
||||
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
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-xl'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
|
||||
interface AudioButtonProps {
|
||||
text: string
|
||||
@@ -9,47 +9,65 @@ interface AudioButtonProps {
|
||||
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) {
|
||||
const [isSpeaking, setIsSpeaking] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const speak = useCallback(() => {
|
||||
if (!('speechSynthesis' in window)) return
|
||||
const speak = useCallback(async () => {
|
||||
// Stop if already playing
|
||||
if (isSpeaking) {
|
||||
window.speechSynthesis.cancel()
|
||||
audioRef.current?.pause()
|
||||
window.speechSynthesis?.cancel()
|
||||
setIsSpeaking(false)
|
||||
return
|
||||
}
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
utterance.lang = lang === 'de' ? 'de-DE' : 'en-GB'
|
||||
utterance.rate = 0.9
|
||||
utterance.pitch = 1.0
|
||||
|
||||
// Try to find a good voice
|
||||
const voices = window.speechSynthesis.getVoices()
|
||||
const preferred = voices.find((v) =>
|
||||
v.lang.startsWith(lang === 'de' ? 'de' : 'en') && v.localService
|
||||
) || voices.find((v) => v.lang.startsWith(lang === 'de' ? 'de' : 'en'))
|
||||
if (preferred) utterance.voice = preferred
|
||||
|
||||
utterance.onend = () => setIsSpeaking(false)
|
||||
utterance.onerror = () => setIsSpeaking(false)
|
||||
|
||||
setIsSpeaking(true)
|
||||
window.speechSynthesis.speak(utterance)
|
||||
|
||||
// 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)
|
||||
utterance.lang = lang === 'de' ? 'de-DE' : 'en-GB'
|
||||
utterance.rate = 0.9
|
||||
const voices = window.speechSynthesis.getVoices()
|
||||
const preferred = voices.find((v) =>
|
||||
v.lang.startsWith(lang === 'de' ? 'de' : 'en') && v.localService
|
||||
) || voices.find((v) => v.lang.startsWith(lang === 'de' ? 'de' : 'en'))
|
||||
if (preferred) utterance.voice = preferred
|
||||
utterance.onend = () => setIsSpeaking(false)
|
||||
utterance.onerror = () => setIsSpeaking(false)
|
||||
window.speechSynthesis.speak(utterance)
|
||||
} else {
|
||||
setIsSpeaking(false)
|
||||
}
|
||||
}, [text, lang, isSpeaking])
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-7 h-7',
|
||||
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',
|
||||
}
|
||||
const sizeClasses = { sm: 'w-7 h-7', 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 (
|
||||
<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