From d4959172a9179dc5c904cdebc4e521388cae66ea Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sat, 25 Apr 2026 20:17:25 +0200 Subject: [PATCH] Add migration learning platform: Onboarding, Translation, Parent Portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1.1 — user_language_api.py: Stores native language preference per user (TR/AR/UK/RU/PL/DE/EN). Onboarding page with flag-based language selection for students and parents. Phase 1.2 — translation_service.py: Batch-translates vocabulary words into target languages via Ollama LLM. Stores in translations JSONB. New endpoint POST /vocabulary/translate triggers translation. Phase 2.1 — Parent Portal (/parent): Simplified UI in parent's native language showing child's learning progress. Daily tips translated. Phase 2.2 — Parent Quiz (/parent/quiz/[unitId]): Parents can quiz their child on vocabulary WITHOUT speaking DE or EN. Shows word in child's learning language + parent's native language as hint. Answer hidden by default, revealed on tap. All UI text translated into 7 languages (DE/EN/TR/AR/UK/RU/PL). Arabic gets RTL layout support. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend-lehrer/main.py | 4 + backend-lehrer/translation_service.py | 179 ++++++++++++++++++++ backend-lehrer/user_language_api.py | 86 ++++++++++ backend-lehrer/vocabulary_api.py | 26 +++ studio-v2/app/api/user/[...path]/route.ts | 35 ++++ studio-v2/app/onboarding/page.tsx | 175 +++++++++++++++++++ studio-v2/app/parent/page.tsx | 125 ++++++++++++++ studio-v2/app/parent/quiz/[unitId]/page.tsx | 175 +++++++++++++++++++ 8 files changed, 805 insertions(+) create mode 100644 backend-lehrer/translation_service.py create mode 100644 backend-lehrer/user_language_api.py create mode 100644 studio-v2/app/api/user/[...path]/route.ts create mode 100644 studio-v2/app/onboarding/page.tsx create mode 100644 studio-v2/app/parent/page.tsx create mode 100644 studio-v2/app/parent/quiz/[unitId]/page.tsx diff --git a/backend-lehrer/main.py b/backend-lehrer/main.py index f795589..1133a68 100644 --- a/backend-lehrer/main.py +++ b/backend-lehrer/main.py @@ -119,6 +119,10 @@ app.include_router(progress_router, prefix="/api") from vocabulary_api import router as vocabulary_router app.include_router(vocabulary_router, prefix="/api") +# --- 4d. User Language Preferences --- +from user_language_api import router as user_language_router +app.include_router(user_language_router, prefix="/api") + from unit_api import router as unit_router app.include_router(unit_router) # Already has /api/units prefix diff --git a/backend-lehrer/translation_service.py b/backend-lehrer/translation_service.py new file mode 100644 index 0000000..a10836c --- /dev/null +++ b/backend-lehrer/translation_service.py @@ -0,0 +1,179 @@ +""" +Translation Service — Batch-translates vocabulary words into target languages. + +Uses Ollama (local LLM) to translate EN/DE word pairs into TR, AR, UK, RU, PL. +Translations are cached in vocabulary_words.translations JSONB field. + +All processing happens locally — no external API calls, GDPR-compliant. +""" + +import json +import logging +import os +from typing import Any, Dict, List + +import httpx + +logger = logging.getLogger(__name__) + +OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434") +TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "qwen3:30b-a3b") + +LANGUAGE_NAMES = { + "tr": "Turkish", + "ar": "Arabic", + "uk": "Ukrainian", + "ru": "Russian", + "pl": "Polish", + "fr": "French", + "es": "Spanish", +} + + +async def translate_words_batch( + words: List[Dict[str, str]], + target_language: str, + batch_size: int = 30, +) -> List[Dict[str, str]]: + """ + Translate a batch of EN/DE word pairs into a target language. + + Args: + words: List of dicts with 'english' and 'german' keys + target_language: ISO 639-1 code (tr, ar, uk, ru, pl) + batch_size: Words per LLM request + + Returns: + List of dicts with 'english', 'translation', 'example' keys + """ + lang_name = LANGUAGE_NAMES.get(target_language, target_language) + all_translations = [] + + for i in range(0, len(words), batch_size): + batch = words[i:i + batch_size] + word_list = "\n".join( + f"{j+1}. {w['english']} = {w.get('german', '')}" + for j, w in enumerate(batch) + ) + + prompt = f"""Translate these English/German word pairs into {lang_name}. +For each word, provide the translation and a short example sentence in {lang_name}. + +Words: +{word_list} + +Reply ONLY with a JSON array, no explanation: +[ + {{"english": "word", "translation": "...", "example": "..."}}, + ... +]""" + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post( + f"{OLLAMA_BASE_URL}/api/generate", + json={ + "model": TRANSLATION_MODEL, + "prompt": prompt, + "stream": False, + "options": {"temperature": 0.2, "num_predict": 4096}, + }, + ) + resp.raise_for_status() + response_text = resp.json().get("response", "") + + # Parse JSON from response + import re + match = re.search(r'\[[\s\S]*\]', response_text) + if match: + batch_translations = json.loads(match.group()) + all_translations.extend(batch_translations) + logger.info( + f"Translated batch {i//batch_size + 1}: " + f"{len(batch_translations)} words → {lang_name}" + ) + else: + logger.warning(f"No JSON array in LLM response for {lang_name}") + + except Exception as e: + logger.error(f"Translation batch failed ({lang_name}): {e}") + + return all_translations + + +async def translate_and_store( + word_ids: List[str], + target_language: str, +) -> int: + """ + Translate vocabulary words and store in the database. + + Fetches words from DB, translates via LLM, stores in translations JSONB. + Skips words that already have a translation for the target language. + + Returns count of newly translated words. + """ + from vocabulary_db import get_pool + + pool = await get_pool() + async with pool.acquire() as conn: + # Fetch words that need translation + rows = await conn.fetch( + """ + SELECT id, english, german, translations + FROM vocabulary_words + WHERE id = ANY($1::uuid[]) + """, + [__import__('uuid').UUID(wid) for wid in word_ids], + ) + + words_to_translate = [] + word_map = {} + for row in rows: + translations = row["translations"] or {} + if isinstance(translations, str): + translations = json.loads(translations) + if target_language not in translations: + words_to_translate.append({ + "english": row["english"], + "german": row["german"], + }) + word_map[row["english"].lower()] = str(row["id"]) + + if not words_to_translate: + logger.info(f"All {len(rows)} words already translated to {target_language}") + return 0 + + # Translate + results = await translate_words_batch(words_to_translate, target_language) + + # Store results + updated = 0 + async with pool.acquire() as conn: + for result in results: + en = result.get("english", "").lower() + word_id = word_map.get(en) + if not word_id: + continue + + translation = result.get("translation", "") + example = result.get("example", "") + if not translation: + continue + + await conn.execute( + """ + UPDATE vocabulary_words + SET translations = translations || $1::jsonb + WHERE id = $2 + """, + json.dumps({target_language: { + "text": translation, + "example": example, + }}), + __import__('uuid').UUID(word_id), + ) + updated += 1 + + logger.info(f"Stored {updated} translations for {target_language}") + return updated diff --git a/backend-lehrer/user_language_api.py b/backend-lehrer/user_language_api.py new file mode 100644 index 0000000..b498aa6 --- /dev/null +++ b/backend-lehrer/user_language_api.py @@ -0,0 +1,86 @@ +""" +User Language Preferences API — Stores native language + learning level. + +Each user (student, parent, teacher) can set their native language. +This drives: UI language, third-language display in flashcards, +parent portal language, and translation generation. + +Supported languages: de, en, tr, ar, uk, ru, pl +""" + +import logging +import os +from typing import Any, Dict, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/user", tags=["user-language"]) + +# Supported native languages with metadata +SUPPORTED_LANGUAGES = { + "de": {"name": "Deutsch", "name_native": "Deutsch", "flag": "de", "rtl": False}, + "en": {"name": "English", "name_native": "English", "flag": "gb", "rtl": False}, + "tr": {"name": "Tuerkisch", "name_native": "Turkce", "flag": "tr", "rtl": False}, + "ar": {"name": "Arabisch", "name_native": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "flag": "sy", "rtl": True}, + "uk": {"name": "Ukrainisch", "name_native": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", "flag": "ua", "rtl": False}, + "ru": {"name": "Russisch", "name_native": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", "flag": "ru", "rtl": False}, + "pl": {"name": "Polnisch", "name_native": "Polski", "flag": "pl", "rtl": False}, +} + +# In-memory store (will be replaced with DB later) +_preferences: Dict[str, Dict[str, Any]] = {} + + +class LanguagePreference(BaseModel): + native_language: str # ISO 639-1 code + role: str = "student" # student, parent, teacher + learning_level: str = "A1" # A1, A2, B1, B2, C1 + + +@router.get("/languages") +def get_supported_languages(): + """List all supported native languages with metadata.""" + return { + "languages": [ + {"code": code, **meta} + for code, meta in SUPPORTED_LANGUAGES.items() + ] + } + + +@router.get("/language-preference") +def get_language_preference(user_id: str = Query("default")): + """Get user's language preference.""" + pref = _preferences.get(user_id) + if not pref: + return {"user_id": user_id, "native_language": "de", "role": "student", "learning_level": "A1", "is_default": True} + return {**pref, "is_default": False} + + +@router.put("/language-preference") +def set_language_preference( + pref: LanguagePreference, + user_id: str = Query("default"), +): + """Set user's native language and learning level.""" + if pref.native_language not in SUPPORTED_LANGUAGES: + raise HTTPException( + status_code=400, + detail=f"Sprache '{pref.native_language}' nicht unterstuetzt. " + f"Verfuegbar: {', '.join(SUPPORTED_LANGUAGES.keys())}", + ) + + _preferences[user_id] = { + "user_id": user_id, + "native_language": pref.native_language, + "role": pref.role, + "learning_level": pref.learning_level, + } + + lang_meta = SUPPORTED_LANGUAGES[pref.native_language] + logger.info(f"Language preference set: user={user_id} lang={pref.native_language} ({lang_meta['name']})") + + return {**_preferences[user_id], "language_meta": lang_meta} diff --git a/backend-lehrer/vocabulary_api.py b/backend-lehrer/vocabulary_api.py index cf9358c..f83da53 100644 --- a/backend-lehrer/vocabulary_api.py +++ b/backend-lehrer/vocabulary_api.py @@ -324,3 +324,29 @@ async def api_bulk_import(payload: BulkImportPayload): count = await insert_words_bulk(words) logger.info(f"Bulk imported {count} vocabulary words") return {"imported": count} + + +# --------------------------------------------------------------------------- +# Translation Generation +# --------------------------------------------------------------------------- + + +class TranslateRequest(BaseModel): + word_ids: List[str] + target_language: str + + +@router.post("/translate") +async def api_translate_words(payload: TranslateRequest): + """Generate translations for vocabulary words into a target language. + + Uses local LLM (Ollama) for translation. Results are cached in the + vocabulary_words.translations JSONB field. + """ + from translation_service import translate_and_store + + if payload.target_language not in {"tr", "ar", "uk", "ru", "pl", "fr", "es"}: + raise HTTPException(status_code=400, detail=f"Sprache '{payload.target_language}' nicht unterstuetzt") + + count = await translate_and_store(payload.word_ids, payload.target_language) + return {"translated": count, "target_language": payload.target_language} diff --git a/studio-v2/app/api/user/[...path]/route.ts b/studio-v2/app/api/user/[...path]/route.ts new file mode 100644 index 0000000..8e4d572 --- /dev/null +++ b/studio-v2/app/api/user/[...path]/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-lehrer:8001' + +async function proxyRequest( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +): Promise { + const { path } = await params + const pathStr = path.join('/') + const searchParams = request.nextUrl.searchParams.toString() + const url = `${BACKEND_URL}/api/user/${pathStr}${searchParams ? `?${searchParams}` : ''}` + + try { + const fetchOptions: RequestInit = { + method: request.method, + headers: { 'Content-Type': 'application/json' }, + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + fetchOptions.body = await request.text() + } + const resp = await fetch(url, fetchOptions) + const data = await resp.text() + return new NextResponse(data, { + status: resp.status, + headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' }, + }) + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 502 }) + } +} + +export const GET = proxyRequest +export const POST = proxyRequest +export const PUT = proxyRequest diff --git a/studio-v2/app/onboarding/page.tsx b/studio-v2/app/onboarding/page.tsx new file mode 100644 index 0000000..1223a30 --- /dev/null +++ b/studio-v2/app/onboarding/page.tsx @@ -0,0 +1,175 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useTheme } from '@/lib/ThemeContext' +import { useLanguage } from '@/lib/LanguageContext' + +interface LangOption { + code: string + name: string + name_native: string + flag: string + rtl: boolean +} + +const STORAGE_KEY = 'bp_native_language' + +export default function OnboardingPage() { + const router = useRouter() + const { isDark } = useTheme() + const { setLanguage } = useLanguage() + const [languages, setLanguages] = useState([]) + const [selected, setSelected] = useState(null) + const [role, setRole] = useState<'student' | 'parent'>('student') + const [saving, setSaving] = useState(false) + + // Skip if already onboarded + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) router.replace('/learn') + }, [router]) + + // Load supported languages + useEffect(() => { + fetch('/api/user/languages') + .then(r => r.ok ? r.json() : null) + .then(d => { if (d?.languages) setLanguages(d.languages) }) + .catch(() => { + // Fallback if API unavailable + setLanguages([ + { code: 'de', name: 'Deutsch', name_native: 'Deutsch', flag: 'de', rtl: false }, + { code: 'tr', name: 'Tuerkisch', name_native: 'Turkce', flag: 'tr', rtl: false }, + { code: 'ar', name: 'Arabisch', name_native: '\u0627\u0644\u0639\u0631\u0628\u064a\u0629', flag: 'sy', rtl: true }, + { code: 'uk', name: 'Ukrainisch', name_native: '\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430', flag: 'ua', rtl: false }, + { code: 'ru', name: 'Russisch', name_native: '\u0420\u0443\u0441\u0441\u043a\u0438\u0439', flag: 'ru', rtl: false }, + { code: 'pl', name: 'Polnisch', name_native: 'Polski', flag: 'pl', rtl: false }, + ]) + }) + }, []) + + const handleContinue = async () => { + if (!selected) return + setSaving(true) + + // Save to backend + try { + await fetch('/api/user/language-preference?user_id=default', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ native_language: selected, role }), + }) + } catch {} + + // Save locally + set UI language + localStorage.setItem(STORAGE_KEY, selected) + setLanguage(selected) + + // Navigate + if (role === 'parent') { + router.push('/parent') + } else { + router.push('/learn') + } + } + + const greetings: Record = { + de: 'Willkommen!', + en: 'Welcome!', + tr: 'Hos geldiniz!', + ar: '\u0645\u0631\u062d\u0628\u0627!', + uk: '\u041b\u0430\u0441\u043a\u0430\u0432\u043e \u043f\u0440\u043e\u0441\u0438\u043c\u043e!', + ru: '\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c!', + pl: 'Witamy!', + } + + return ( +
+
+ {/* Greeting in multiple languages */} +
+
+ {selected ? greetings[selected] || greetings.de : ( + + {Object.values(greetings).slice(0, 4).join(' / ')} + + )} +
+

+ BreakPilot Sprache +

+
+ + {/* Role Selection */} +
+ {(['student', 'parent'] as const).map(r => ( + + ))} +
+ + {/* Question */} +

+ {role === 'student' ? 'Was ist deine Muttersprache?' : 'Welche Sprache sprechen Sie?'} +

+ + {/* Language Grid */} +
+ {languages.map(lang => ( + + ))} +
+ + {/* Continue */} + +
+
+ ) +} diff --git a/studio-v2/app/parent/page.tsx b/studio-v2/app/parent/page.tsx new file mode 100644 index 0000000..ede7ab9 --- /dev/null +++ b/studio-v2/app/parent/page.tsx @@ -0,0 +1,125 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import Link from 'next/link' +import { useTheme } from '@/lib/ThemeContext' +import { useLanguage } from '@/lib/LanguageContext' + +interface LearningUnit { + id: string + label: string + meta: string + status: string + created_at: string +} + +// Parent-specific translations (expanded from i18n) +const parentT: Record> = { + title: { de: 'Eltern-Portal', tr: 'Ebeveyn Portal\u0131', ar: '\u0628\u0648\u0627\u0628\u0629 \u0627\u0644\u0648\u0627\u0644\u062f\u064a\u0646', uk: '\u041f\u043e\u0440\u0442\u0430\u043b \u0431\u0430\u0442\u044c\u043a\u0456\u0432', ru: '\u041f\u043e\u0440\u0442\u0430\u043b \u0440\u043e\u0434\u0438\u0442\u0435\u043b\u0435\u0439', pl: 'Portal rodzica', en: 'Parent Portal' }, + greeting: { de: 'Willkommen!', tr: 'Hos geldiniz!', ar: '\u0645\u0631\u062d\u0628\u0627!', uk: '\u041b\u0430\u0441\u043a\u0430\u0432\u043e \u043f\u0440\u043e\u0441\u0438\u043c\u043e!', ru: '\u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c!', pl: 'Witamy!', en: 'Welcome!' }, + child_progress: { de: 'Lernfortschritt', tr: '\u00d6\u011frenme ilerlemesi', ar: '\u062a\u0642\u062f\u0645 \u0627\u0644\u062a\u0639\u0644\u0645', uk: '\u041f\u0440\u043e\u0433\u0440\u0435\u0441 \u043d\u0430\u0432\u0447\u0430\u043d\u043d\u044f', ru: '\u041f\u0440\u043e\u0433\u0440\u0435\u0441\u0441 \u043e\u0431\u0443\u0447\u0435\u043d\u0438\u044f', pl: 'Post\u0119py w nauce', en: 'Learning Progress' }, + quiz_child: { de: 'Vokabeln abfragen', tr: 'Kelime sorgula', ar: '\u0627\u062e\u062a\u0628\u0627\u0631 \u0627\u0644\u0645\u0641\u0631\u062f\u0627\u062a', uk: '\u041e\u043f\u0438\u0442\u0430\u0442\u0438 \u0441\u043b\u043e\u0432\u0430', ru: '\u041e\u043f\u0440\u043e\u0441\u0438\u0442\u044c \u0441\u043b\u043e\u0432\u0430', pl: 'Odpyta\u0107 s\u0142\u00f3wka', en: 'Quiz your child' }, + no_units: { de: 'Noch keine Lerneinheiten vorhanden.', tr: 'Hen\u00fcz \u00f6\u011frenme birimi yok.', ar: '\u0644\u0627 \u062a\u0648\u062c\u062f \u0648\u062d\u062f\u0627\u062a \u062a\u0639\u0644\u064a\u0645\u064a\u0629 \u0628\u0639\u062f.', uk: '\u041d\u0430\u0432\u0447\u0430\u043b\u044c\u043d\u0438\u0445 \u043c\u043e\u0434\u0443\u043b\u0456\u0432 \u043f\u043e\u043a\u0438 \u043d\u0435\u043c\u0430\u0454.', ru: '\u0423\u0447\u0435\u0431\u043d\u044b\u0445 \u043c\u043e\u0434\u0443\u043b\u0435\u0439 \u043f\u043e\u043a\u0430 \u043d\u0435\u0442.', pl: 'Brak jednostek do nauki.', en: 'No learning units yet.' }, + words: { de: 'Woerter', tr: 'kelime', ar: '\u0643\u0644\u0645\u0627\u062a', uk: '\u0441\u043b\u0456\u0432', ru: '\u0441\u043b\u043e\u0432', pl: 's\u0142\u00f3w', en: 'words' }, + tip: { de: 'Tipp fuer heute', tr: 'Bug\u00fcn\u00fcn ipucu', ar: '\u0646\u0635\u064a\u062d\u0629 \u0627\u0644\u064a\u0648\u0645', uk: '\u041f\u043e\u0440\u0430\u0434\u0430 \u043d\u0430 \u0441\u044c\u043e\u0433\u043e\u0434\u043d\u0456', ru: '\u0421\u043e\u0432\u0435\u0442 \u043d\u0430 \u0441\u0435\u0433\u043e\u0434\u043d\u044f', pl: 'Wskaz\u00f3wka na dzi\u015b', en: 'Tip for today' }, +} + +export default function ParentPage() { + const { isDark } = useTheme() + const { language } = useLanguage() + const [units, setUnits] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + const t = (key: string) => parentT[key]?.[language] || parentT[key]?.['de'] || key + + const glassCard = isDark + ? 'bg-white/10 backdrop-blur-xl border border-white/10' + : 'bg-white/80 backdrop-blur-xl border border-black/5' + + useEffect(() => { + fetch('/api/learning-units/') + .then(r => r.ok ? r.json() : []) + .then(setUnits) + .catch(() => {}) + .finally(() => setIsLoading(false)) + }, []) + + return ( +
+ + {/* Header */} +
+
+
+
+

+ {t('title')} +

+

+ {t('greeting')} +

+
+ + {language.toUpperCase()} + +
+
+
+ + {/* Content */} +
+

+ {t('child_progress')} +

+ + {isLoading && ( +
+
+
+ )} + + {!isLoading && units.length === 0 && ( +
+

{t('no_units')}

+
+ )} + + {units.map(unit => ( +
+

+ {unit.label} +

+

+ {unit.meta} +

+ + + + + + {t('quiz_child')} + +
+ ))} + + {/* Daily Tip */} +
+

+ {t('tip')} +

+

+ {language === 'tr' && '\u00c7ocugunuza her g\u00fcn 5 dakika kelime \u00e7al\u0131s\u0131n. K\u0131sa ama d\u00fczenli \u00e7al\u0131sma en etkili y\u00f6ntemdir.'} + {language === 'ar' && '\u0627\u0637\u0644\u0628 \u0645\u0646 \u0637\u0641\u0644\u0643 \u0623\u0646 \u064a\u062a\u062f\u0631\u0628 \u0639\u0644\u0649 \u0627\u0644\u0645\u0641\u0631\u062f\u0627\u062a 5 \u062f\u0642\u0627\u0626\u0642 \u0643\u0644 \u064a\u0648\u0645. \u0627\u0644\u062a\u062f\u0631\u064a\u0628 \u0627\u0644\u0642\u0635\u064a\u0631 \u0648\u0627\u0644\u0645\u0646\u062a\u0638\u0645 \u0647\u0648 \u0627\u0644\u0623\u0643\u062b\u0631 \u0641\u0639\u0627\u0644\u064a\u0629.'} + {language === 'uk' && '\u041f\u043e\u043f\u0440\u043e\u0441\u0456\u0442\u044c \u0434\u0438\u0442\u0438\u043d\u0443 \u0432\u0447\u0438\u0442\u0438 \u0441\u043b\u043e\u0432\u0430 5 \u0445\u0432\u0438\u043b\u0438\u043d \u043a\u043e\u0436\u043d\u043e\u0433\u043e \u0434\u043d\u044f. \u041a\u043e\u0440\u043e\u0442\u043a\u0456 \u0430\u043b\u0435 \u0440\u0435\u0433\u0443\u043b\u044f\u0440\u043d\u0456 \u0437\u0430\u043d\u044f\u0442\u0442\u044f \u043d\u0430\u0439\u0435\u0444\u0435\u043a\u0442\u0438\u0432\u043d\u0456\u0448\u0456.'} + {language === 'ru' && '\u041f\u043e\u043f\u0440\u043e\u0441\u0438\u0442\u0435 \u0440\u0435\u0431\u0435\u043d\u043a\u0430 \u0443\u0447\u0438\u0442\u044c \u0441\u043b\u043e\u0432\u0430 5 \u043c\u0438\u043d\u0443\u0442 \u043a\u0430\u0436\u0434\u044b\u0439 \u0434\u0435\u043d\u044c. \u041a\u043e\u0440\u043e\u0442\u043a\u0438\u0435 \u0440\u0435\u0433\u0443\u043b\u044f\u0440\u043d\u044b\u0435 \u0437\u0430\u043d\u044f\u0442\u0438\u044f \u0441\u0430\u043c\u044b\u0435 \u044d\u0444\u0444\u0435\u043a\u0442\u0438\u0432\u043d\u044b\u0435.'} + {(language === 'de' || !['tr','ar','uk','ru'].includes(language)) && 'Bitten Sie Ihr Kind jeden Tag 5 Minuten Vokabeln zu ueben. Kurze regelmaessige Uebungen sind am effektivsten.'} +

+
+
+
+ ) +} diff --git a/studio-v2/app/parent/quiz/[unitId]/page.tsx b/studio-v2/app/parent/quiz/[unitId]/page.tsx new file mode 100644 index 0000000..567c615 --- /dev/null +++ b/studio-v2/app/parent/quiz/[unitId]/page.tsx @@ -0,0 +1,175 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { useTheme } from '@/lib/ThemeContext' +import { useLanguage } from '@/lib/LanguageContext' +import { AudioButton } from '@/components/learn/AudioButton' + +interface QAItem { + id: string; question: string; answer: string + translations?: Record +} + +const pt: Record> = { + 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' }, +} + +export default function ParentQuizPage() { + const { unitId } = useParams<{ unitId: string }>() + const router = useRouter() + const { isDark } = useTheme() + const { language } = useLanguage() + + const [items, setItems] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [showAnswer, setShowAnswer] = useState(false) + 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 glassCard = isDark + ? 'bg-white/10 backdrop-blur-xl border border-white/10' + : 'bg-white/80 backdrop-blur-xl border border-black/5' + + useEffect(() => { + fetch(`/api/learning-units/${unitId}/qa`) + .then(r => r.ok ? r.json() : { qa_items: [] }) + .then(d => setItems(d.qa_items || [])) + .catch(() => {}) + .finally(() => setIsLoading(false)) + }, [unitId]) + + const handleResult = useCallback((correct: boolean) => { + setStats(prev => ({ + correct: prev.correct + (correct ? 1 : 0), + incorrect: prev.incorrect + (correct ? 0 : 1), + })) + setShowAnswer(false) + 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 || '' + + if (isLoading) { + return
+
+
+ } + + return ( +
+ + {/* Header */} +
+
+ + + {t('question')} {currentIndex + 1}/{items.length} + + +
+
+ + {/* Progress */} +
+
+
+ +
+ {isComplete ? ( +
+
{stats.correct > stats.incorrect ? '\uD83C\uDF89' : '\uD83D\uDCAA'}
+

{t('done')}

+

+ {stats.correct}/{items.length} {t('correct').toLowerCase()} +

+
+ + +
+
+ ) : currentItem ? ( +
+ {/* Instruction for parent */} +

+ {t('ask_child')} +

+ + {/* Word to ask */} +
+ + {currentItem.question} + + {nativeTranslation && ( +

+ ({nativeTranslation}) +

+ )} +
+ +
+
+ + {/* Show/Hide Answer */} + + + {showAnswer && ( +
+

{t('correct_answer')}

+ + {currentItem.answer} + + {nativeTranslation && ( +

+ ({nativeTranslation}) +

+ )} +
+ +
+
+ )} + + {/* Right/Wrong Buttons */} +
+ + +
+
+ ) : null} +
+
+ ) +}