From 82f5b4fbba684d4656aac18b59e4a916402adc79 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 27 Apr 2026 22:30:07 +0200 Subject: [PATCH] Redesign: 2/3 + 1/3 layout for exercises with native helper panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExerciseLayout.tsx: Reusable layout component for all exercises. - Left 2/3: Standard exercise area (EN + DE) - Right 1/3: Native language helper (explanation + word list) - Only shows right panel for non-DE/EN speakers - Explanation card describes what the child should do - Column headers are trilingual (TR · English · Deutsch) Match page rebuilt using ExerciseLayout: - EN+DE cards in 2/3 left area with equal height + audio - Native words in 1/3 right panel with audio buttons - Highlights native word when EN word is selected - Progress bar with count, score counter ExerciseLayout can be reused for flashcards, quiz, type, etc. Co-Authored-By: Claude Opus 4.6 (1M context) --- studio-v2/app/learn/[unitId]/match/page.tsx | 318 ++++++------------ studio-v2/components/learn/ExerciseLayout.tsx | 136 ++++++++ 2 files changed, 244 insertions(+), 210 deletions(-) create mode 100644 studio-v2/components/learn/ExerciseLayout.tsx diff --git a/studio-v2/app/learn/[unitId]/match/page.tsx b/studio-v2/app/learn/[unitId]/match/page.tsx index b562b36..18e4224 100644 --- a/studio-v2/app/learn/[unitId]/match/page.tsx +++ b/studio-v2/app/learn/[unitId]/match/page.tsx @@ -6,6 +6,7 @@ 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 @@ -30,7 +31,6 @@ export default function MatchPage() { const [retryCorrect, setRetryCorrect] = useState(0) const [errors, setErrors] = useState(0) const [failedIds, setFailedIds] = useState>(new Set()) - const [flashNative, setFlashNative] = useState(null) const [isComplete, setIsComplete] = useState(false) const glassCard = isDark @@ -46,78 +46,38 @@ export default function MatchPage() { })() }, [unitId]) - const roundItems = useMemo(() => { - const start = round * 6 - return allItems.slice(start, start + 6) - }, [allItems, round]) + const roundItems = useMemo(() => allItems.slice(round * 6, round * 6 + 6), [allItems, round]) + const shuffledRight = useMemo(() => [...roundItems].sort(() => Math.random() - 0.5), [roundItems]) - const shuffledRight = useMemo(() => { - return [...roundItems].sort(() => Math.random() - 0.5) - }, [roundItems]) - - const handleLeftTap = useCallback((item: QAItem) => { - if (matched.has(item.id)) return - setSelectedLeft(item.id === selectedLeft ? null : item.id) + const handleLeftTap = useCallback((id: string) => { + if (matched.has(id)) return + setSelectedLeft(id === selectedLeft ? null : id) setWrongPair(null) - - // Flash native translation briefly - if (isThirdLanguage && item.id !== selectedLeft) { - const native = wordInNative(item.translations) - if (native) { - setFlashNative(native) - setTimeout(() => setFlashNative(null), 2000) - } - } - }, [selectedLeft, matched, isThirdLanguage, wordInNative]) + }, [selectedLeft, matched]) const handleRightTap = useCallback((id: string) => { if (!selectedLeft || matched.has(id)) return - if (selectedLeft === id) { - // Correct setMatched(prev => new Set([...prev, id])) - if (failedIds.has(id)) { - setRetryCorrect(c => c + 1) - } else { - setFirstTryCorrect(c => c + 1) - } + if (failedIds.has(id)) setRetryCorrect(c => c + 1) + else setFirstTryCorrect(c => c + 1) setSelectedLeft(null) - setFlashNative(null) - 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 setWrongPair(id) setErrors(e => e + 1) setFailedIds(prev => new Set([...prev, selectedLeft])) - setTimeout(() => { - setWrongPair(null) - setSelectedLeft(null) - setFlashNative(null) - }, 600) + setTimeout(() => { setWrongPair(null); setSelectedLeft(null) }, 600) } }, [selectedLeft, matched, roundItems, round, allItems, failedIds]) const restart = () => { - setRound(0) - setMatched(new Set()) - setFirstTryCorrect(0) - setRetryCorrect(0) - setErrors(0) - setFailedIds(new Set()) - setIsComplete(false) - setSelectedLeft(null) + setRound(0); setMatched(new Set()); setFirstTryCorrect(0); setRetryCorrect(0) + setErrors(0); setFailedIds(new Set()); setIsComplete(false); setSelectedLeft(null) } if (isLoading) { @@ -130,169 +90,107 @@ export default function MatchPage() { const matchedTotal = round * 6 + matched.size const isPerfect = isComplete && errors === 0 + // Native helper panel: list of words in native language + const nativePanel = ( +
+

+ {nativeLang.toUpperCase()} · {t('english')} · {t('german')} +

+
+ {roundItems.map(item => { + const native = wordInNative(item.translations) + const isSelected = selectedLeft === item.id + const isMatched = matched.has(item.id) + return ( +
+ {native || '—'} + {native && !isMatched && ( + + )} +
+ ) + })} +
+
+ ) + return ( - <> - {/* Header */} -
-
- -

{t('match')}

-
- ✓{firstTryCorrect}{' '} - ↻{retryCorrect}{' '} - ✗{errors} + router.push('/learn')} + progress={{ current: matchedTotal, total: totalPairs }} + exerciseExplanation={undefined} + nativeHelper={nativePanel} + score={ +
+ ✓{firstTryCorrect}{' '} + ↻{retryCorrect}{' '} + ✗{errors} +
+ } + > + {isComplete ? ( +
+ +

+ {isPerfect ? t('well_done') : t('all_matched')} +

+
+
{firstTryCorrect}
{t('correct')}
+
{retryCorrect}
2.
+
{errors}
{t('errors')}
+
+
+ +
-
- - {/* Progress bar with count */} -
-
-
-
+ ) : ( +
+ {/* Left: English */} +
+

+ {t('english')} / English +

+ {roundItems.map(item => ( +
+ + {!matched.has(item.id) && } +
+ ))}
- - {matchedTotal}/{totalPairs} - -
-
- {/* Native flash overlay */} - {flashNative && ( -
-
- {flashNative} + {/* Right: German */} +
+

+ {t('german')} / Deutsch +

+ {shuffledRight.map(item => ( +
+ + {!matched.has(item.id) && } +
+ ))}
)} - -
- {isComplete ? ( -
- -

- {isPerfect ? t('well_done') : t('all_matched')} -

-
-
{firstTryCorrect}
{t('correct')}
-
{retryCorrect}
2. Versuch
-
{errors}
{t('errors')}
-
- {!isPerfect && ( -

- {isThirdLanguage - ? (nativeLang === 'tr' ? 'Tam puan icin hatasiz tamamlayin' : 'Fuer volle Punkte fehlerfrei abschliessen') - : 'Fuer volle Punkte fehlerfrei abschliessen'} -

- )} -
- - -
-
- ) : ( -
- {/* Column headers */} -
-

{t('english')}

-

{t('german')}

- {isThirdLanguage && ( -

{nativeLang.toUpperCase()}

- )} -
- - {/* Grid */} -
- {/* Left: English (click to select, no sound) */} -
- {roundItems.map(item => ( -
- - {!matched.has(item.id) && ( - - )} -
- ))} -
- - {/* Middle: German (click to match + sound) */} -
- {shuffledRight.map(item => ( -
- - {!matched.has(item.id) && ( - - )} -
- ))} -
- - {/* Right: Native language (display only + sound) */} - {isThirdLanguage && ( -
- {roundItems.map(item => { - const native = wordInNative(item.translations) - return ( -
- {native || '—'} - {native && !matched.has(item.id) && ( - - )} -
- ) - })} -
- )} -
-
- )} -
- + ) } diff --git a/studio-v2/components/learn/ExerciseLayout.tsx b/studio-v2/components/learn/ExerciseLayout.tsx new file mode 100644 index 0000000..d6d1614 --- /dev/null +++ b/studio-v2/components/learn/ExerciseLayout.tsx @@ -0,0 +1,136 @@ +'use client' + +import React from 'react' +import { useTheme } from '@/lib/ThemeContext' +import { useNativeLanguage } from '@/lib/useNativeLanguage' + +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 + /** 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 +} + +const explanations: Record> = { + match: { + 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.', + }, + flashcards: { + 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.', + 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.', + 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.', + }, +} + +/** + * 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, + 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 explanation = exerciseExplanation + || explanations[title.toLowerCase()]?.[nativeLang] + || explanations[title.toLowerCase()]?.['de'] + || '' + + return ( + <> + {/* Header */} +
+
+ {onBack ? ( + + ) : } +

{title}

+ {score || } +
+
+ + {/* Progress bar */} + {progress && ( +
+
+
+
+
+ + {progress.current}/{progress.total} + +
+
+ )} + + {/* Main content: 2/3 left + 1/3 right */} +
+
+ {/* Left: Exercise area (2/3 or full) */} +
+ {children} +
+ + {/* Right: Native language helper (1/3) — only for migrants */} + {isThirdLanguage && ( +
+
+ {/* Explanation card */} + {explanation && ( +
+

+ {nativeLang.toUpperCase()} · {t('english')} · {t('german')} +

+

+ {explanation} +

+
+ )} + + {/* Native words */} + {nativeHelper} +
+
+ )} +
+
+ + ) +} + +export { explanations as exerciseExplanations }