feat(i18n, vocab): enhance localization and vocabulary preparation in VocabLessonView
Some checks failed
Deploy to production / deploy (push) Has been cancelled

- Added new localization keys in German, English, and Spanish for exercise flow, progress, and learning path instructions, improving user guidance across languages.
- Updated VocabLessonView to incorporate these new keys, enhancing the clarity of vocabulary preparation steps and overall user experience during lessons.
- Refactored the layout to better present the learning path and vocabulary preparation stages, ensuring a more cohesive and informative interface.
This commit is contained in:
Torsten Schulz (local)
2026-04-01 10:21:13 +02:00
parent 8bbfd46ada
commit b8e3732ef8
4 changed files with 614 additions and 179 deletions

View File

@@ -431,6 +431,19 @@
"invalidCode": "Ungültiger Code",
"courseNotFound": "Kurs nicht gefunden",
"grammarExercises": "Grammatik-Prüfung",
"exerciseFlowIntro": "Arbeite die Aufgaben der Reihe nach durch. Jede korrekt gelöste Aufgabe bringt dich direkt näher zum Abschluss der Lektion.",
"exerciseProgressLabel": "Fortschritt",
"exerciseTargetLabel": "Benötigt",
"exerciseCardLabel": "Aufgabe {number}",
"exerciseStatusOpen": "Offen",
"exerciseStatusCorrect": "Erledigt",
"exerciseStatusRetry": "Nochmal prüfen",
"exerciseAnswerAllHint": "Beantworte zuerst alle {total} Aufgaben. Aktuell bearbeitet: {answered}. Zum Bestehen brauchst du mindestens {target}%.",
"exerciseNeedMoreCorrectHint": "Du hast aktuell {score}%. Für den Abschluss dieser Lektion brauchst du mindestens {target}%.",
"exercisePassedHint": "Ziel erreicht: {score}% von benötigten {target}%. Sobald alle Aufgaben bearbeitet sind, gilt die Prüfung als bestanden.",
"exerciseReinforcementHint": "Nach einem Fehler geht es kurz zurück in den Lernmodus. Übe noch {count} Trainerfragen, dann wird die Kapitel-Prüfung wieder freigeschaltet.",
"exercisePrepReinforcementHint": "Nach einem Fehler gehst du noch einmal durch die vorbereiteten Begriffe. Danach wird die Kapitel-Prüfung wieder freigeschaltet.",
"exerciseGrammarLead": "Wichtige Grammatik für diese Prüfung",
"noExercises": "Keine Prüfung verfügbar",
"enterAnswer": "Antwort eingeben",
"checkAnswer": "Antwort prüfen",
@@ -456,6 +469,9 @@
"vocabPrepStep2": "Gehe die gleichen Begriffe noch einmal durch (aktive Wiederholung, ohne zu üben).",
"vocabPrepConfirm2": "Zweite Durchsicht erledigt",
"vocabPrepReady": "Du kannst jetzt mit dem Vokabeltrainer starten.",
"learningPathLabel": "Hauptpfad",
"learningPathTitle": "Dein Lernweg für diese Lektion",
"learningPathIntro": "Arbeite diese Schritte nacheinander durch: vorbereiten, kurz überblicken, trainieren und dann zur Kapitel-Prüfung wechseln.",
"lessonDetailsToggle": "Mehr Lektionsdetails anzeigen",
"deepenSectionTitle": "Vertiefen und nachlesen",
"assistantSectionTitle": "Mit Sprachassistent vertiefen",

View File

@@ -431,6 +431,19 @@
"invalidCode": "Invalid code",
"courseNotFound": "Course not found",
"grammarExercises": "Chapter Test",
"exerciseFlowIntro": "Work through the tasks in order. Every correct answer moves you closer to completing the lesson.",
"exerciseProgressLabel": "Progress",
"exerciseTargetLabel": "Required",
"exerciseCardLabel": "Task {number}",
"exerciseStatusOpen": "Open",
"exerciseStatusCorrect": "Done",
"exerciseStatusRetry": "Try again",
"exerciseAnswerAllHint": "Answer all {total} tasks first. Completed so far: {answered}. You need at least {target}% to pass.",
"exerciseNeedMoreCorrectHint": "You currently have {score}%. You need at least {target}% to complete this lesson.",
"exercisePassedHint": "Target reached: {score}% out of the required {target}%. Once all tasks have been answered, the chapter test is passed.",
"exerciseReinforcementHint": "After a mistake, the flow returns briefly to learning mode. Practice {count} more trainer questions and the chapter test will unlock again.",
"exercisePrepReinforcementHint": "After a mistake, go through the prepared terms once more. Then the chapter test will unlock again.",
"exerciseGrammarLead": "Key grammar for this test",
"noExercises": "No test available",
"enterAnswer": "Enter answer",
"checkAnswer": "Check Answer",
@@ -456,6 +469,9 @@
"vocabPrepStep2": "Go through the same items again (active review, not testing yet).",
"vocabPrepConfirm2": "Second pass done",
"vocabPrepReady": "You can start the vocabulary trainer now.",
"learningPathLabel": "Main path",
"learningPathTitle": "Your learning flow for this lesson",
"learningPathIntro": "Work through these steps in order: prepare, review briefly, train, then move to the chapter test.",
"lessonDetailsToggle": "Show more lesson details",
"deepenSectionTitle": "Deepen and review",
"assistantSectionTitle": "Deepen with language assistant",

View File

@@ -429,6 +429,19 @@
"invalidCode": "Código inválido",
"courseNotFound": "Curso no encontrado",
"grammarExercises": "Prueba de gramática",
"exerciseFlowIntro": "Resuelve las tareas en orden. Cada respuesta correcta te acerca al cierre de la lección.",
"exerciseProgressLabel": "Progreso",
"exerciseTargetLabel": "Necesario",
"exerciseCardLabel": "Tarea {number}",
"exerciseStatusOpen": "Pendiente",
"exerciseStatusCorrect": "Hecha",
"exerciseStatusRetry": "Revisar otra vez",
"exerciseAnswerAllHint": "Responde primero las {total} tareas. Completadas hasta ahora: {answered}. Necesitas al menos {target}% para aprobar.",
"exerciseNeedMoreCorrectHint": "Ahora mismo tienes {score}%. Necesitas al menos {target}% para completar esta lección.",
"exercisePassedHint": "Objetivo alcanzado: {score}% de los {target}% necesarios. En cuanto todas las tareas estén respondidas, la prueba queda aprobada.",
"exerciseReinforcementHint": "Después de un error, el flujo vuelve brevemente al modo de aprendizaje. Practica {count} preguntas más en el entrenador y la prueba del capítulo se desbloqueará otra vez.",
"exercisePrepReinforcementHint": "Después de un error, vuelve a repasar los términos preparados una vez más. Luego la prueba del capítulo se desbloqueará otra vez.",
"exerciseGrammarLead": "Gramática clave para esta prueba",
"noExercises": "No hay prueba disponible",
"enterAnswer": "Introduce la respuesta",
"checkAnswer": "Comprobar respuesta",
@@ -454,6 +467,9 @@
"vocabPrepStep2": "Repasa los mismos elementos otra vez (repaso activo, aún sin practicar).",
"vocabPrepConfirm2": "Segunda lectura hecha",
"vocabPrepReady": "Ya puedes iniciar el entrenador de vocabulario.",
"learningPathLabel": "Ruta principal",
"learningPathTitle": "Tu recorrido de aprendizaje para esta lección",
"learningPathIntro": "Sigue estos pasos en orden: preparar, repasar brevemente, entrenar y luego pasar a la prueba del capítulo.",
"lessonDetailsToggle": "Mostrar más detalles de la lección",
"deepenSectionTitle": "Profundizar y repasar",
"assistantSectionTitle": "Profundizar con el asistente de idiomas",

View File

@@ -83,169 +83,176 @@
<p>Diese Lektion priorisiert Wiederholung und Vertiefung. Neuer Stoff wird bewusst reduziert, damit vorhandene Muster stabil werden.</p>
</div>
<!-- Zwei Durchgänge: dieselben Kernmuster schrittweise vor dem Trainer -->
<div
v-if="prepItems.length > 0 && !vocabTrainerActive"
class="vocab-prep-pass didactic-card"
>
<h4>{{ $t('socialnetwork.vocab.courses.vocabPrepTitle') }}</h4>
<template v-if="lessonPrepStage < 2 && currentPrepItem">
<p class="vocab-prep-pass__step">
{{ $t('socialnetwork.vocab.courses.vocabPrepProgress', { pass: lessonPrepStage + 1, current: lessonPrepIndex + 1, total: prepItems.length }) }}
</p>
<p>
{{ lessonPrepStage === 0
? $t('socialnetwork.vocab.courses.vocabPrepStep1')
: $t('socialnetwork.vocab.courses.vocabPrepStep2') }}
</p>
<div class="vocab-prep-card">
<div class="vocab-prep-card__target">{{ currentPrepItem.target }}</div>
<div v-if="currentPrepItem.gloss" class="vocab-prep-card__gloss">{{ currentPrepItem.gloss }}</div>
</div>
<button type="button" class="btn-prep-pass" @click="advancePrepPass">
{{ isLastPrepItemInPass
? (lessonPrepStage === 0
? $t('socialnetwork.vocab.courses.vocabPrepConfirm1')
: $t('socialnetwork.vocab.courses.vocabPrepConfirm2'))
: $t('socialnetwork.vocab.courses.vocabPrepNextItem') }}
</button>
</template>
<p v-else class="vocab-prep-pass__ready">{{ $t('socialnetwork.vocab.courses.vocabPrepReady') }}</p>
</div>
<section class="lesson-primary-flow surface-card">
<div class="lesson-primary-flow__header">
<span class="lesson-primary-flow__eyebrow">{{ $t('socialnetwork.vocab.courses.learningPathLabel') }}</span>
<h4 class="lesson-primary-flow__title">{{ $t('socialnetwork.vocab.courses.learningPathTitle') }}</h4>
<p class="lesson-primary-flow__intro">{{ $t('socialnetwork.vocab.courses.learningPathIntro') }}</p>
</div>
<!-- Gesamtübersicht: gleicher Lernsatz wie Vorbereitung und Trainer -->
<details
v-if="lesson && lessonVocab.length > 0 && !vocabTrainerActive"
class="vocab-list vocab-list--overview"
:open="lessonPrepStage >= 2"
>
<summary class="vocab-list__summary">
{{ $t('socialnetwork.vocab.courses.vocabOverviewToggle') }}
</summary>
<p class="vocab-info-text">{{ $t('socialnetwork.vocab.courses.vocabInfoText') }}</p>
<div class="vocab-items">
<div v-for="(vocab, index) in lessonVocab" :key="index" class="vocab-item">
<strong>{{ vocab.learning || '—' }}</strong>
<span class="separator"></span>
<span>{{ vocab.reference }}</span>
</div>
</div>
</details>
<!-- Vokabeltrainer -->
<div v-if="trainableLessonVocab.length > 0" class="vocab-trainer-section">
<h4>{{ $t('socialnetwork.vocab.courses.vocabTrainer') }}</h4>
<div v-if="hasPreviousVocab" class="review-priority-note">
<strong>Wiederholung läuft schrittweise mit</strong>
<p>Zuerst liegt der Fokus auf den neuen Begriffen dieser Lektion. Mit deinem Fortschritt fließen ältere Vokabeln dann zunehmend mit ein.</p>
</div>
<div v-if="hasExercises && !canAccessExercises" class="exercise-lock-note">
<strong>Kapitel-Prüfung noch gesperrt</strong>
<p>{{ exerciseUnlockHint }}</p>
</div>
<div v-if="!vocabTrainerActive" class="vocab-trainer-start">
<template v-if="canStartVocabTrainerPrep">
<p>{{ hasPreviousVocab ? 'Starte mit den neuen Vokabeln dieser Lektion. Mit fortschreitendem Üben mischt der Trainer automatisch passende Wiederholungen ein.' : $t('socialnetwork.vocab.courses.vocabTrainerDescription') }}</p>
<button @click="startVocabTrainer" class="btn-start-trainer">
{{ hasPreviousVocab ? 'Lektion starten' : $t('socialnetwork.vocab.courses.startVocabTrainer') }}
<!-- Zwei Durchgänge: dieselben Kernmuster schrittweise vor dem Trainer -->
<div
v-if="prepItems.length > 0 && !vocabTrainerActive"
class="vocab-prep-pass didactic-card"
>
<h4>{{ $t('socialnetwork.vocab.courses.vocabPrepTitle') }}</h4>
<template v-if="lessonPrepStage < 2 && currentPrepItem">
<p class="vocab-prep-pass__step">
{{ $t('socialnetwork.vocab.courses.vocabPrepProgress', { pass: lessonPrepStage + 1, current: lessonPrepIndex + 1, total: prepItems.length }) }}
</p>
<p>
{{ lessonPrepStage === 0
? $t('socialnetwork.vocab.courses.vocabPrepStep1')
: $t('socialnetwork.vocab.courses.vocabPrepStep2') }}
</p>
<div class="vocab-prep-card">
<div class="vocab-prep-card__target">{{ currentPrepItem.target }}</div>
<div v-if="currentPrepItem.gloss" class="vocab-prep-card__gloss">{{ currentPrepItem.gloss }}</div>
</div>
<button type="button" class="btn-prep-pass" @click="advancePrepPass">
{{ isLastPrepItemInPass
? (lessonPrepStage === 0
? $t('socialnetwork.vocab.courses.vocabPrepConfirm1')
: $t('socialnetwork.vocab.courses.vocabPrepConfirm2'))
: $t('socialnetwork.vocab.courses.vocabPrepNextItem') }}
</button>
</template>
<p v-else class="vocab-trainer-locked-hint">{{ $t('socialnetwork.vocab.courses.vocabTrainerLockedHint') }}</p>
<p v-else class="vocab-prep-pass__ready">{{ $t('socialnetwork.vocab.courses.vocabPrepReady') }}</p>
</div>
<div v-else class="vocab-trainer-active">
<div class="vocab-trainer-stats">
<div class="stats-row">
<span>{{ $t('socialnetwork.vocab.courses.correct') }}: {{ vocabTrainerCorrect }}</span>
<span>{{ $t('socialnetwork.vocab.courses.wrong') }}: {{ vocabTrainerWrong }}</span>
<span>{{ $t('socialnetwork.vocab.courses.totalAttempts') }}: {{ vocabTrainerTotalAttempts }}</span>
<span v-if="vocabTrainerTotalAttempts > 0" class="success-rate">
{{ $t('socialnetwork.vocab.courses.successRate') }}: {{ Math.round((vocabTrainerCorrect / vocabTrainerTotalAttempts) * 100) }}%
</span>
</div>
<div class="stats-row">
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerPhase === 'current' }">
{{ $t('socialnetwork.vocab.courses.currentLesson') || 'Aktuelle Lektion' }}
</span>
<span v-if="previousVocab && previousVocab.length > 0" class="mode-badge" :class="{ 'mode-active': vocabTrainerPhase === 'mixed' }">
{{ $t('socialnetwork.vocab.courses.mixedReview') || 'Gemischt' }}
</span>
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerMode === 'multiple_choice', 'mode-completed': vocabTrainerMode === 'typing' }">
{{ $t('socialnetwork.vocab.courses.modeMultipleChoice') }}
</span>
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerMode === 'typing' }">
{{ $t('socialnetwork.vocab.courses.modeTyping') }}
</span>
<button @click="stopVocabTrainer" class="btn-stop-trainer">{{ $t('socialnetwork.vocab.courses.stopTrainer') }}</button>
</div>
<div v-if="hasPreviousVocab" class="stats-row trainer-progress-row">
<span>Neue Inhalte: {{ vocabTrainerCurrentAttempts }}/{{ trainerNewFocusTarget }}</span>
<span>Wiederholung: {{ vocabTrainerReviewAttempts }}</span>
<span>Mischanteil: {{ Math.round(currentReviewShare * 100) }}%</span>
<!-- Gesamtübersicht: gleicher Lernsatz wie Vorbereitung und Trainer -->
<details
v-if="lesson && lessonVocab.length > 0 && !vocabTrainerActive"
class="vocab-list vocab-list--overview"
:open="lessonPrepStage >= 2"
>
<summary class="vocab-list__summary">
{{ $t('socialnetwork.vocab.courses.vocabOverviewToggle') }}
</summary>
<p class="vocab-info-text">{{ $t('socialnetwork.vocab.courses.vocabInfoText') }}</p>
<div class="vocab-items">
<div v-for="(vocab, index) in lessonVocab" :key="index" class="vocab-item">
<strong>{{ vocab.learning || '—' }}</strong>
<span class="separator"></span>
<span>{{ vocab.reference }}</span>
</div>
</div>
<div v-if="currentVocabQuestion" class="vocab-question">
<div class="vocab-prompt">
<div class="vocab-direction">{{ vocabTrainerDirection === 'L2R' ? $t('socialnetwork.vocab.courses.translateTo') : $t('socialnetwork.vocab.courses.translateFrom') }}</div>
<div class="vocab-word">{{ currentVocabQuestion.prompt }}</div>
</div>
<div v-if="vocabTrainerAnswered" class="vocab-feedback" :class="{ correct: vocabTrainerLastCorrect, wrong: !vocabTrainerLastCorrect }">
<div v-if="vocabTrainerLastCorrect">{{ $t('socialnetwork.vocab.courses.correct') }}!</div>
<div v-else>
{{ $t('socialnetwork.vocab.courses.wrong') }}. {{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ currentVocabQuestion.answer }}
</div>
</div>
<!-- Multiple Choice Modus -->
<div v-else-if="vocabTrainerMode === 'multiple_choice'" class="vocab-answer-area multiple-choice">
<div class="choice-buttons">
<button
v-for="(option, index) in vocabTrainerChoiceOptions"
:key="index"
@click="selectVocabChoice(option)"
class="choice-button"
:class="{ selected: vocabTrainerSelectedChoice === option }"
>
{{ option }}
</button>
</div>
<!-- Button entfernt: Prüfung erfolgt automatisch beim Klick auf Option -->
</div>
<!-- Texteingabe Modus -->
<div v-else class="vocab-answer-area typing">
<div v-if="vocabTrainerAutoSwitchedToTyping" class="mode-switch-notice">
<button @click="switchBackToMultipleChoice" class="btn-switch-mode">
{{ $t('socialnetwork.vocab.courses.switchBackToMultipleChoice') }}
</button>
</div>
<input
v-model="vocabTrainerAnswer"
@keydown.enter.prevent="checkVocabAnswer"
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
class="vocab-input"
ref="vocabInput"
/>
<button @click="checkVocabAnswer" :disabled="!vocabTrainerAnswer.trim()" class="btn-check">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</details>
<!-- Vokabeltrainer -->
<div v-if="trainableLessonVocab.length > 0" class="vocab-trainer-section">
<h4>{{ $t('socialnetwork.vocab.courses.vocabTrainer') }}</h4>
<div v-if="hasPreviousVocab" class="review-priority-note">
<strong>Wiederholung läuft schrittweise mit</strong>
<p>Zuerst liegt der Fokus auf den neuen Begriffen dieser Lektion. Mit deinem Fortschritt fließen ältere Vokabeln dann zunehmend mit ein.</p>
</div>
<div v-if="hasExercises && !canAccessExercises" class="exercise-lock-note">
<strong>Kapitel-Prüfung noch gesperrt</strong>
<p>{{ exerciseUnlockHint }}</p>
</div>
<div v-if="!vocabTrainerActive" class="vocab-trainer-start">
<template v-if="canStartVocabTrainerPrep">
<p>{{ hasPreviousVocab ? 'Starte mit den neuen Vokabeln dieser Lektion. Mit fortschreitendem Üben mischt der Trainer automatisch passende Wiederholungen ein.' : $t('socialnetwork.vocab.courses.vocabTrainerDescription') }}</p>
<button @click="startVocabTrainer" class="btn-start-trainer">
{{ hasPreviousVocab ? 'Lektion starten' : $t('socialnetwork.vocab.courses.startVocabTrainer') }}
</button>
</template>
<p v-else class="vocab-trainer-locked-hint">{{ $t('socialnetwork.vocab.courses.vocabTrainerLockedHint') }}</p>
</div>
<div v-else class="vocab-trainer-active">
<div class="vocab-trainer-stats">
<div class="stats-row">
<span>{{ $t('socialnetwork.vocab.courses.correct') }}: {{ vocabTrainerCorrect }}</span>
<span>{{ $t('socialnetwork.vocab.courses.wrong') }}: {{ vocabTrainerWrong }}</span>
<span>{{ $t('socialnetwork.vocab.courses.totalAttempts') }}: {{ vocabTrainerTotalAttempts }}</span>
<span v-if="vocabTrainerTotalAttempts > 0" class="success-rate">
{{ $t('socialnetwork.vocab.courses.successRate') }}: {{ Math.round((vocabTrainerCorrect / vocabTrainerTotalAttempts) * 100) }}%
</span>
</div>
<div class="stats-row">
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerPhase === 'current' }">
{{ $t('socialnetwork.vocab.courses.currentLesson') || 'Aktuelle Lektion' }}
</span>
<span v-if="previousVocab && previousVocab.length > 0" class="mode-badge" :class="{ 'mode-active': vocabTrainerPhase === 'mixed' }">
{{ $t('socialnetwork.vocab.courses.mixedReview') || 'Gemischt' }}
</span>
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerMode === 'multiple_choice', 'mode-completed': vocabTrainerMode === 'typing' }">
{{ $t('socialnetwork.vocab.courses.modeMultipleChoice') }}
</span>
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerMode === 'typing' }">
{{ $t('socialnetwork.vocab.courses.modeTyping') }}
</span>
<button @click="stopVocabTrainer" class="btn-stop-trainer">{{ $t('socialnetwork.vocab.courses.stopTrainer') }}</button>
</div>
<div v-if="hasPreviousVocab" class="stats-row trainer-progress-row">
<span>Neue Inhalte: {{ vocabTrainerCurrentAttempts }}/{{ trainerNewFocusTarget }}</span>
<span>Wiederholung: {{ vocabTrainerReviewAttempts }}</span>
<span>Mischanteil: {{ Math.round(currentReviewShare * 100) }}%</span>
</div>
</div>
<!-- "Weiter"-Button nur bei falscher Antwort (bei richtiger Antwort wird automatisch weiter gemacht) -->
<div v-if="vocabTrainerAnswered && !vocabTrainerLastCorrect" class="vocab-next">
<button @click="nextVocabQuestion">{{ $t('socialnetwork.vocab.courses.next') }}</button>
<div v-if="currentVocabQuestion" class="vocab-question">
<div class="vocab-prompt">
<div class="vocab-direction">{{ vocabTrainerDirection === 'L2R' ? $t('socialnetwork.vocab.courses.translateTo') : $t('socialnetwork.vocab.courses.translateFrom') }}</div>
<div class="vocab-word">{{ currentVocabQuestion.prompt }}</div>
</div>
<div v-if="vocabTrainerAnswered" class="vocab-feedback" :class="{ correct: vocabTrainerLastCorrect, wrong: !vocabTrainerLastCorrect }">
<div v-if="vocabTrainerLastCorrect">{{ $t('socialnetwork.vocab.courses.correct') }}!</div>
<div v-else>
{{ $t('socialnetwork.vocab.courses.wrong') }}. {{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ currentVocabQuestion.answer }}
</div>
</div>
<!-- Multiple Choice Modus -->
<div v-else-if="vocabTrainerMode === 'multiple_choice'" class="vocab-answer-area multiple-choice">
<div class="choice-buttons">
<button
v-for="(option, index) in vocabTrainerChoiceOptions"
:key="index"
@click="selectVocabChoice(option)"
class="choice-button"
:class="{ selected: vocabTrainerSelectedChoice === option }"
>
{{ option }}
</button>
</div>
</div>
<!-- Texteingabe Modus -->
<div v-else class="vocab-answer-area typing">
<div v-if="vocabTrainerAutoSwitchedToTyping" class="mode-switch-notice">
<button @click="switchBackToMultipleChoice" class="btn-switch-mode">
{{ $t('socialnetwork.vocab.courses.switchBackToMultipleChoice') }}
</button>
</div>
<input
v-model="vocabTrainerAnswer"
@keydown.enter.prevent="checkVocabAnswer"
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
class="vocab-input"
ref="vocabInput"
/>
<button @click="checkVocabAnswer" :disabled="!vocabTrainerAnswer.trim()" class="btn-check">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
</div>
<!-- "Weiter"-Button nur bei falscher Antwort (bei richtiger Antwort wird automatisch weiter gemacht) -->
<div v-if="vocabTrainerAnswered && !vocabTrainerLastCorrect" class="vocab-next">
<button @click="nextVocabQuestion">{{ $t('socialnetwork.vocab.courses.next') }}</button>
</div>
</div>
</div>
</div>
</div>
<!-- Hinweis wenn keine Vokabeln vorhanden -->
<div v-else-if="lesson && lessonVocab.length === 0" class="no-vocab-info">
<p>{{ $t('socialnetwork.vocab.courses.noVocabInfo') }}</p>
</div>
<!-- Hinweis wenn keine Vokabeln vorhanden -->
<div v-else-if="lesson && lessonVocab.length === 0" class="no-vocab-info">
<p>{{ $t('socialnetwork.vocab.courses.noVocabInfo') }}</p>
</div>
<!-- Button um zu Übungen zu wechseln -->
<div v-if="hasExercises && canAccessExercises" class="continue-to-exercises">
<button @click="openExercisesTab" class="btn-continue">
{{ $t('socialnetwork.vocab.courses.startExercises') }}
</button>
</div>
<!-- Button um zu Übungen zu wechseln -->
<div v-if="hasExercises && canAccessExercises" class="continue-to-exercises">
<button @click="openExercisesTab" class="btn-continue">
{{ $t('socialnetwork.vocab.courses.startExercises') }}
</button>
</div>
</section>
<details class="lesson-deepen-section surface-card">
<summary class="lesson-deepen-section__summary">
@@ -403,9 +410,76 @@
<!-- Übungen-Tab (Kapitel-Prüfung) -->
<div v-if="activeTab === 'exercises'" class="grammar-exercises">
<div v-if="lesson && effectiveExercises && effectiveExercises.length > 0">
<h3>{{ $t('socialnetwork.vocab.courses.grammarExercises') }}</h3>
<div v-for="exercise in effectiveExercises" :key="exercise.id" class="exercise-item">
<h4>{{ exercise.title }}</h4>
<div class="exercise-flow-header surface-card">
<div>
<span class="exercise-flow-header__eyebrow">{{ $t('socialnetwork.vocab.courses.exercises') }}</span>
<h3>{{ $t('socialnetwork.vocab.courses.grammarExercises') }}</h3>
<p class="exercise-flow-header__intro">{{ $t('socialnetwork.vocab.courses.exerciseFlowIntro') }}</p>
<p class="exercise-flow-header__hint">{{ exerciseStatusHint }}</p>
</div>
<div class="exercise-flow-header__stats">
<div class="exercise-flow-stat">
<span>{{ $t('socialnetwork.vocab.courses.exerciseProgressLabel') }}</span>
<strong>{{ exerciseCorrectCount }}/{{ effectiveExercises.length }}</strong>
</div>
<div class="exercise-flow-stat">
<span>{{ $t('socialnetwork.vocab.courses.successRate') }}</span>
<strong>{{ exerciseProgressPercent }}%</strong>
</div>
<div class="exercise-flow-stat">
<span>{{ $t('socialnetwork.vocab.courses.exerciseTargetLabel') }}</span>
<strong>{{ exerciseTargetScore }}%</strong>
</div>
</div>
</div>
<div v-if="visibleGrammarExplanations.length > 0" class="exercise-grammar-card surface-card">
<div class="exercise-grammar-card__header">
<span class="exercise-flow-header__eyebrow">{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</span>
<strong>{{ $t('socialnetwork.vocab.courses.exerciseGrammarLead') }}</strong>
</div>
<div class="exercise-grammar-card__list">
<article
v-for="(explanation, index) in visibleGrammarExplanations.slice(0, 2)"
:key="'exercise-grammar-' + index"
class="exercise-grammar-card__item"
>
<strong>{{ explanation.title || $t('socialnetwork.vocab.courses.grammarImpulse') }}</strong>
<p>{{ explanation.text }}</p>
<p v-if="explanation.example" class="grammar-example">{{ explanation.example }}</p>
</article>
</div>
</div>
<div class="exercise-flow-list">
<div
v-for="(exercise, index) in effectiveExercises"
:key="exercise.id"
class="exercise-item surface-card"
:class="{
'exercise-item--answered': exerciseResults[exercise.id],
'exercise-item--correct': exerciseResults[exercise.id]?.correct,
'exercise-item--wrong': exerciseResults[exercise.id] && !exerciseResults[exercise.id]?.correct
}"
>
<div class="exercise-item__header">
<div>
<span class="exercise-item__index">{{ $t('socialnetwork.vocab.courses.exerciseCardLabel', { number: index + 1 }) }}</span>
<h4>{{ exercise.title }}</h4>
</div>
<span
class="exercise-item__status"
:class="{
'exercise-item__status--open': !exerciseResults[exercise.id],
'exercise-item__status--correct': exerciseResults[exercise.id]?.correct,
'exercise-item__status--wrong': exerciseResults[exercise.id] && !exerciseResults[exercise.id]?.correct
}"
>
{{ !exerciseResults[exercise.id]
? $t('socialnetwork.vocab.courses.exerciseStatusOpen')
: (exerciseResults[exercise.id]?.correct
? $t('socialnetwork.vocab.courses.exerciseStatusCorrect')
: $t('socialnetwork.vocab.courses.exerciseStatusRetry')) }}
</span>
</div>
<p v-if="exercise.instruction" class="exercise-instruction">{{ exercise.instruction }}</p>
<!-- Multiple Choice Übung -->
@@ -696,6 +770,7 @@
<p class="unknown-exercise__type">Typ: {{ getExerciseType(exercise) }}</p>
</div>
</div>
</div>
</div>
<div v-else-if="lesson && (!effectiveExercises || effectiveExercises.length === 0)">
<p>{{ $t('socialnetwork.vocab.courses.noExercises') }}</p>
@@ -813,6 +888,8 @@ export default {
recognizedText: {}, // { [exerciseId]: string }
recordingStatus: {}, // { [exerciseId]: string }
isSpeechRecognitionSupported: false,
exerciseRetryPending: false,
exerciseRetryPendingSinceAttempts: 0,
assistantLoading: false,
assistantSubmitting: false,
assistantSettings: null,
@@ -878,6 +955,7 @@ export default {
},
canAccessExercises() {
if (!this.hasExercises) return false;
if (this.exerciseNeedsReinforcement) return false;
const isReview = this.lesson?.lessonType === 'review' || this.lesson?.lessonType === 'vocab_review';
if (isReview) return true;
if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) {
@@ -886,6 +964,11 @@ export default {
return this.exercisePreparationCompleted;
},
exerciseUnlockHint() {
if (this.exerciseNeedsReinforcement) {
return this.$t('socialnetwork.vocab.courses.exerciseReinforcementHint', {
count: this.exerciseRetryRemainingAttempts
});
}
if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) {
return this.$t('socialnetwork.vocab.courses.exerciseUnlockHintAfterPrep');
}
@@ -906,6 +989,62 @@ export default {
}
return [];
},
exerciseCorrectCount() {
return this.effectiveExercises.filter((exercise) => Boolean(this.exerciseResults[exercise.id]?.correct)).length;
},
exerciseAnsweredCount() {
return this.effectiveExercises.filter((exercise) => Boolean(this.exerciseResults[exercise.id])).length;
},
exerciseProgressPercent() {
if (!this.effectiveExercises.length) return 0;
return Math.round((this.exerciseCorrectCount / this.effectiveExercises.length) * 100);
},
exerciseTargetScore() {
return Number(this.lesson?.targetScorePercent) || 80;
},
exerciseRetryUnlockAttempts() {
return Math.min(8, Math.max(2, Math.ceil(this.trainerNewFocusTarget * 0.25)));
},
exerciseRetryRemainingAttempts() {
if (!this.exerciseRetryPending) return 0;
const sinceWrong = this.vocabTrainerTotalAttempts - this.exerciseRetryPendingSinceAttempts;
return Math.max(0, this.exerciseRetryUnlockAttempts - sinceWrong);
},
exerciseNeedsReinforcement() {
return this.exerciseRetryPending && this.exerciseRetryRemainingAttempts > 0;
},
visibleGrammarExplanations() {
const didacticFocus = Array.isArray(this.lessonDidactics?.grammarFocus) ? this.lessonDidactics.grammarFocus : [];
if (didacticFocus.length > 0) {
return didacticFocus;
}
return this.grammarExplanations;
},
exerciseStatusHint() {
if (!this.effectiveExercises.length) return '';
if (this.exerciseNeedsReinforcement) {
return this.$t('socialnetwork.vocab.courses.exerciseReinforcementHint', {
count: this.exerciseRetryRemainingAttempts
});
}
if (this.exerciseAnsweredCount < this.effectiveExercises.length) {
return this.$t('socialnetwork.vocab.courses.exerciseAnswerAllHint', {
answered: this.exerciseAnsweredCount,
total: this.effectiveExercises.length,
target: this.exerciseTargetScore
});
}
if (this.exerciseProgressPercent >= this.exerciseTargetScore) {
return this.$t('socialnetwork.vocab.courses.exercisePassedHint', {
score: this.exerciseProgressPercent,
target: this.exerciseTargetScore
});
}
return this.$t('socialnetwork.vocab.courses.exerciseNeedMoreCorrectHint', {
score: this.exerciseProgressPercent,
target: this.exerciseTargetScore
});
},
grammarExplanations() {
// Extrahiere Grammatik-Erklärungen aus den Übungen
try {
@@ -965,7 +1104,7 @@ export default {
const reference = String(entry?.reference || '').trim();
const learning = String(entry?.learning || '').trim();
if (!reference) return;
const key = reference.toLowerCase();
const key = this.normalizeLessonVocabTerm(reference);
if (!vocabByReference.has(key)) {
vocabByReference.set(key, { learning, reference });
return;
@@ -1082,6 +1221,14 @@ export default {
}
},
methods: {
normalizeLessonVocabTerm(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/^[.,!?;:]+|[.,!?;:]+$/g, '')
.trim();
},
normalizeCorePatternEntry(p) {
if (p && typeof p === 'object' && p.target) {
return {
@@ -1134,6 +1281,9 @@ export default {
});
},
updateExerciseUnlockState() {
if (this.exerciseRetryPending && this.exerciseRetryRemainingAttempts <= 0) {
this.exerciseRetryPending = false;
}
if (this.exercisePreparationCompleted) {
return;
}
@@ -1305,6 +1455,8 @@ export default {
this.assistantMessages = [];
this.assistantInput = '';
this.assistantError = '';
this.exerciseRetryPending = false;
this.exerciseRetryPendingSinceAttempts = 0;
this.exercisePreparationCompleted = false;
this.lessonPrepStage = 0;
this.lessonPrepIndex = 0;
@@ -1698,7 +1850,32 @@ export default {
const res = await apiClient.post(`/api/vocab/grammar-exercises/${exerciseId}/check`, { answer });
this.exerciseResults[exerciseId] = res.data;
if (!res.data?.correct) {
if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) {
this.lessonPrepStage = 0;
this.lessonPrepIndex = 0;
this.errorMessage = this.$t('socialnetwork.vocab.courses.exercisePrepReinforcementHint');
} else {
this.exerciseRetryPending = true;
this.exerciseRetryPendingSinceAttempts = this.vocabTrainerTotalAttempts;
this.errorMessage = this.$t('socialnetwork.vocab.courses.exerciseReinforcementHint', {
count: this.exerciseRetryUnlockAttempts
});
}
this.activeTab = 'learn';
this.showErrorDialog = true;
this.$nextTick(() => {
const scrollEl = document.querySelector('.app-content__scroll.contentscroll');
if (scrollEl) {
scrollEl.scrollTop = 0;
} else {
window.scrollTo(0, 0);
}
});
return;
}
// Prüfe ob alle Übungen bestanden sind (mit Verzögerung, um mehrfache Aufrufe zu vermeiden)
this.$nextTick(() => {
this.checkLessonCompletion();
@@ -1727,29 +1904,25 @@ export default {
return;
}
// Prüfe ob alle Übungen korrekt beantwortet wurden
const allCompleted = allExercises.every(exercise => {
const result = this.exerciseResults[exercise.id];
return result && result.correct;
});
const answeredExercises = allExercises.filter((exercise) => Boolean(this.exerciseResults[exercise.id])).length;
const correctExercises = allExercises.filter((exercise) => this.exerciseResults[exercise.id]?.correct).length;
const score = Math.round((correctExercises / allExercises.length) * 100);
const passed = answeredExercises === allExercises.length
&& score >= this.exerciseTargetScore
&& !this.exerciseNeedsReinforcement;
debugLog('[VocabLessonView] checkLessonCompletion - passed:', passed, 'Beantwortet:', answeredExercises, 'Übungen:', allExercises.length, 'Korrekt:', correctExercises, 'Score:', score);
debugLog('[VocabLessonView] checkLessonCompletion - allCompleted:', allCompleted, 'Übungen:', allExercises.length, 'Korrekt:', allExercises.filter(ex => this.exerciseResults[ex.id]?.correct).length);
if (allCompleted && !this.isCheckingLessonCompletion) {
if (passed && !this.isCheckingLessonCompletion) {
this.isCheckingLessonCompletion = true;
debugLog('[VocabLessonView] Alle Übungen abgeschlossen - starte Fortschritts-Update');
try {
// Berechne Gesamt-Score
const totalExercises = allExercises.length;
const correctExercises = allExercises.filter(ex => this.exerciseResults[ex.id]?.correct).length;
const score = Math.round((correctExercises / totalExercises) * 100);
debugLog('[VocabLessonView] Score berechnet:', score, '%');
// Aktualisiere Fortschritt
await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, {
completed: true,
completed: score >= this.exerciseTargetScore,
score: score,
timeSpentMinutes: 0 // TODO: Zeit tracken
});
@@ -2407,9 +2580,9 @@ export default {
.lesson-overview-card {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
margin-bottom: 20px;
gap: 16px;
padding: 18px 18px 16px;
margin-bottom: 16px;
background: linear-gradient(135deg, #fff8eb 0%, #f7efe2 100%);
border: 1px solid rgba(160, 120, 40, 0.18);
border-radius: 12px;
@@ -2544,7 +2717,9 @@ export default {
}
.vocab-prep-pass {
margin-bottom: 18px;
margin-bottom: 14px;
background: linear-gradient(180deg, #fffefd 0%, #fff7ec 100%);
border: 1px solid rgba(210, 131, 31, 0.18);
}
.vocab-prep-pass__step {
@@ -2584,6 +2759,40 @@ export default {
font-weight: 600;
}
.lesson-primary-flow {
margin-top: 18px;
padding: 18px;
border: 1px solid rgba(210, 131, 31, 0.2);
background: linear-gradient(180deg, rgba(255, 251, 245, 0.98), rgba(255, 246, 233, 0.94));
box-shadow: 0 14px 30px rgba(194, 141, 61, 0.08);
}
.lesson-primary-flow__header {
margin-bottom: 14px;
}
.lesson-primary-flow__eyebrow {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.16);
color: #7a4b00;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.lesson-primary-flow__title {
margin: 10px 0 6px;
color: #2d2114;
}
.lesson-primary-flow__intro {
margin: 0;
color: #6b5535;
}
.vocab-trainer-locked-hint {
margin: 0;
color: #8a5a00;
@@ -2593,6 +2802,8 @@ export default {
.lesson-assistant-section {
margin-top: 20px;
padding: 16px 18px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(176, 176, 176, 0.2);
}
.lesson-deepen-section .learn-grid,
@@ -2661,12 +2872,158 @@ export default {
margin-top: 30px;
}
.exercise-flow-header {
display: flex;
justify-content: space-between;
gap: 18px;
padding: 18px;
margin-bottom: 16px;
border: 1px solid rgba(80, 118, 178, 0.16);
background: linear-gradient(180deg, rgba(246, 250, 255, 0.98), rgba(236, 244, 255, 0.94));
}
.exercise-flow-header h3 {
margin: 8px 0 6px;
}
.exercise-flow-header__eyebrow {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
background: rgba(58, 117, 196, 0.12);
color: #27528f;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.exercise-flow-header__intro {
margin: 0;
color: #4e6280;
}
.exercise-flow-header__hint {
margin: 8px 0 0;
color: #39506f;
font-weight: 500;
}
.exercise-flow-header__stats {
display: grid;
grid-template-columns: repeat(3, minmax(110px, 1fr));
gap: 10px;
min-width: 360px;
}
.exercise-flow-stat {
padding: 12px 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(80, 118, 178, 0.12);
}
.exercise-flow-stat span {
display: block;
margin-bottom: 6px;
color: #60708b;
font-size: 0.82rem;
}
.exercise-flow-list {
display: grid;
gap: 14px;
}
.exercise-grammar-card {
margin-bottom: 16px;
padding: 16px 18px;
border: 1px solid rgba(113, 94, 54, 0.18);
background: linear-gradient(180deg, rgba(255, 250, 241, 0.98), rgba(255, 246, 229, 0.94));
}
.exercise-grammar-card__header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.exercise-grammar-card__list {
display: grid;
gap: 12px;
}
.exercise-grammar-card__item {
padding: 12px 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(113, 94, 54, 0.12);
}
.exercise-grammar-card__item p {
margin: 6px 0 0;
}
.exercise-item {
background: white;
padding: 15px;
padding: 18px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 15px;
border-radius: 12px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.04);
}
.exercise-item__header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 10px;
}
.exercise-item__header h4 {
margin: 4px 0 0;
}
.exercise-item__index {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
background: rgba(58, 117, 196, 0.08);
color: #27528f;
font-size: 0.78rem;
font-weight: 700;
}
.exercise-item__status {
flex: 0 0 auto;
padding: 5px 10px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
}
.exercise-item__status--open {
background: rgba(96, 112, 139, 0.12);
color: #55647d;
}
.exercise-item__status--correct {
background: rgba(40, 167, 69, 0.14);
color: #1f7a35;
}
.exercise-item__status--wrong {
background: rgba(220, 53, 69, 0.12);
color: #a12634;
}
.exercise-item--correct {
border-color: rgba(40, 167, 69, 0.28);
}
.exercise-item--wrong {
border-color: rgba(220, 53, 69, 0.18);
}
.exercise-instruction {
@@ -2942,10 +3299,11 @@ export default {
.vocab-trainer-section {
margin: 20px 0;
padding: 15px;
padding: 18px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
border: 1px solid rgba(210, 131, 31, 0.2);
border-radius: 12px;
box-shadow: 0 10px 20px rgba(210, 131, 31, 0.08);
}
.vocab-trainer-section h4 {
@@ -3472,7 +3830,36 @@ export default {
.lesson-meta-grid {
grid-template-columns: 1fr;
}
.learn-section {
margin-top: 14px;
padding: 14px;
}
.lesson-primary-flow,
.lesson-deepen-section,
.lesson-assistant-section {
padding: 14px;
}
.lesson-header {
gap: 10px;
margin-bottom: 14px;
}
.exercise-flow-header {
flex-direction: column;
}
.exercise-flow-header__stats {
grid-template-columns: 1fr;
min-width: 0;
}
.exercise-item__header {
flex-direction: column;
gap: 8px;
}
}
</style>