Files
yourpart3/frontend/src/views/social/VocabLessonView.vue
Torsten Schulz (local) e28ed7bdb5
All checks were successful
Deploy to production / deploy (push) Successful in 2m17s
feat(VocabService, VocabPracticeDialog, VocabLessonView): enhance vocabulary training logic and UI feedback
- Introduced methods for improved text analysis and validation in VocabService, including `_wordCount` and `_looksLikeFragmentMismatch`, to better assess learning and reference pairs.
- Updated VocabPracticeDialog to display submitted answers and correct solutions, enhancing user feedback during practice sessions.
- Enhanced VocabLessonView to ensure only trainable vocabulary pairs are processed, improving the quality of vocabulary training.
- Added localization entries for new UI elements in both English and German, ensuring clarity in user interactions.
2026-04-20 08:48:39 +02:00

5752 lines
208 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="vocab-lesson-view">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="lesson">
<div class="lesson-header">
<button @click="back" class="btn-back">{{ $t('general.back') }}</button>
<h2>{{ lesson.title }}</h2>
<button
type="button"
class="btn-reset-lesson"
:disabled="resettingLessonProgress"
@click="confirmResetLessonProgress"
>
{{ resettingLessonProgress ? $t('general.loading') : $t('socialnetwork.vocab.courses.resetLessonProgress') }}
</button>
</div>
<!-- Tabs für Lernen und Übungen -->
<div class="lesson-tabs">
<button
:class="{ active: activeTab === 'learn' }"
@click="activeTab = 'learn'"
class="tab-button"
>
{{ $t('socialnetwork.vocab.courses.learn') }}
</button>
<button
:class="{ active: activeTab === 'exercises' }"
:disabled="!canAccessExercises"
@click="openExercisesTab"
class="tab-button"
>
{{ $t('socialnetwork.vocab.courses.exercises') }}
<span v-if="hasExercises" class="exercise-count">({{ effectiveExercises?.length || 0 }})</span>
</button>
</div>
<!-- Lernen-Tab -->
<div v-if="activeTab === 'learn'" class="learn-section">
<div class="lesson-overview-card">
<div>
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
<p class="lesson-overview-text">
{{ $t('socialnetwork.vocab.courses.lessonOverviewText') }}
</p>
</div>
<div class="lesson-meta-grid">
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonTypeLabel') }}</span>
<strong>{{ getLessonTypeLabel(lesson.lessonType) }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.didacticMode">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonMetaFocus') }}</span>
<strong>{{ getDidacticModeLabel(lessonPedagogy.didacticMode) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.recommendedDuration') }}</span>
<strong>{{ formatTargetMinutes(lesson.targetMinutes) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.exerciseLoad') }}</span>
<strong>{{ effectiveExercises?.length || 0 }} {{ $t('socialnetwork.vocab.courses.exercisesShort') }}</strong>
</div>
</div>
<div
v-if="lessonReviewBadgeLabel"
class="lesson-review-status"
:class="lessonReviewStatusClass"
>
<div class="lesson-review-status__top">
<span class="lesson-review-status__badge">{{ lessonReviewBadgeLabel }}</span>
<strong>{{ lessonReviewHeadline }}</strong>
</div>
<p>{{ lessonReviewHint }}</p>
</div>
<details
v-if="lessonPedagogy.phaseLabel || lessonPedagogy.newUnitTarget || lessonPedagogy.reviewWeight != null"
class="lesson-overview-more"
>
<summary class="lesson-overview-more__summary">
{{ $t('socialnetwork.vocab.courses.lessonDetailsToggle') }}
</summary>
<div class="lesson-overview-more__grid">
<div class="lesson-meta-item" v-if="lessonPedagogy.phaseLabel">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonMetaPhase') }}</span>
<strong>{{ getPhaseLabel(lessonPedagogy.phaseLabel) }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.newUnitTarget">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonMetaNewUnits') }}</span>
<strong>{{ lessonPedagogy.newUnitTarget }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.reviewWeight != null">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonMetaReview') }}</span>
<strong>{{ lessonPedagogy.reviewWeight }}%</strong>
</div>
</div>
</details>
</div>
<div v-if="lessonPedagogy.isIntensiveReview" class="lesson-intensity-banner">
<strong>{{ $t('socialnetwork.vocab.courses.intensiveReviewTitle') }}</strong>
<p>{{ $t('socialnetwork.vocab.courses.intensiveReviewIntro') }}</p>
</div>
<div
v-if="showDailyEnoughBanner"
class="lesson-enough-banner"
>
<strong>{{ $t('socialnetwork.vocab.courses.dailyEnoughTitle') }}</strong>
<p>{{ $t('socialnetwork.vocab.courses.dailyEnoughBody') }}</p>
<div class="lesson-enough-banner__actions">
<button type="button" class="btn-secondary" @click="dismissDailyEnoughBanner">
{{ $t('socialnetwork.vocab.courses.dailyEnoughStop') }}
</button>
<button type="button" class="btn-primary" @click="continueDailyEnoughBanner">
{{ $t('socialnetwork.vocab.courses.dailyEnoughContinue') }}
</button>
</div>
</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>
<!-- 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__row">
<span class="vocab-prep-card__label">{{ prepTargetLabel }}</span>
<div class="vocab-prep-card__target">{{ currentPrepItem.target }}</div>
</div>
<div class="vocab-prep-card__row">
<span class="vocab-prep-card__label">{{ prepGlossLabel }}</span>
<div class="vocab-prep-card__gloss">{{ currentPrepItem.gloss || '—' }}</div>
</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>
<!-- 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 paginatedLessonVocab" :key="`v-${vocabOverviewPagerMeta.page}-${index}`" class="vocab-item">
<strong>{{ vocab.learning || '—' }}</strong>
<span class="separator"></span>
<span>{{ vocab.reference }}</span>
</div>
</div>
<div v-if="vocabOverviewTotalPages > 1" class="vocab-items-pager">
<button
type="button"
class="vocab-items-pager__btn"
:disabled="vocabOverviewPagerMeta.page <= 1"
@click="vocabOverviewGoPrev"
>
{{ $t('socialnetwork.vocab.courses.vocabOverviewPrev') }}
</button>
<span class="vocab-items-pager__meta">{{ $t('socialnetwork.vocab.courses.vocabOverviewPager', vocabOverviewPagerMeta) }}</span>
<button
type="button"
class="vocab-items-pager__btn"
:disabled="vocabOverviewPagerMeta.page >= vocabOverviewPagerMeta.pages"
@click="vocabOverviewGoNext"
>
{{ $t('socialnetwork.vocab.courses.vocabOverviewNext') }}
</button>
</div>
</details>
<div v-if="visibleGrammarExplanations.length > 0" class="lesson-grammar-impulse didactic-card">
<div class="lesson-grammar-impulse__header">
<span class="lesson-primary-flow__eyebrow">{{ $t('socialnetwork.vocab.courses.grammarImpulse') }}</span>
<h4>{{ $t('socialnetwork.vocab.courses.learningGrammarTitle') }}</h4>
</div>
<p class="lesson-grammar-impulse__intro">{{ $t('socialnetwork.vocab.courses.learningGrammarIntro') }}</p>
<div class="lesson-grammar-impulse__list">
<article
v-for="(explanation, index) in visibleGrammarExplanations.slice(0, 2)"
:key="'primary-grammar-' + index"
class="lesson-grammar-impulse__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>
<!-- 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>{{ $t('socialnetwork.vocab.courses.reviewPriorityTitle') }}</strong>
<p>{{ $t('socialnetwork.vocab.courses.reviewPriorityIntro') }}</p>
</div>
<div v-if="hasExercises && !canAccessExercises" class="exercise-lock-note">
<strong>{{ $t('socialnetwork.vocab.courses.exerciseLockTitle') }}</strong>
<p>{{ exerciseUnlockHint }}</p>
</div>
<div v-if="!vocabTrainerActive" class="vocab-trainer-start">
<template v-if="canStartVocabTrainerPrep">
<p>{{ hasPreviousVocab ? $t('socialnetwork.vocab.courses.trainerStartWithReview') : $t('socialnetwork.vocab.courses.vocabTrainerDescription') }}</p>
<button @click="startVocabTrainer" class="btn-start-trainer">
{{ hasPreviousVocab ? $t('socialnetwork.vocab.courses.startLesson') : $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') }}
</span>
<span v-if="previousVocab && previousVocab.length > 0" class="mode-badge" :class="{ 'mode-active': vocabTrainerPhase === 'mixed' }">
{{ $t('socialnetwork.vocab.courses.mixedReview') }}
</span>
<span
class="mode-badge mode-clickable"
:class="{ 'mode-active': vocabTrainerMode === 'multiple_choice', 'mode-completed': vocabTrainerMode === 'typing' }"
@click="switchBackToMultipleChoice"
>
{{ $t('socialnetwork.vocab.courses.modeMultipleChoice') }}
</span>
<span
class="mode-badge mode-clickable"
:class="{ 'mode-active': vocabTrainerMode === 'typing' }"
@click="switchToTyping"
>
{{ $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>{{ $t('socialnetwork.vocab.courses.trainerProgressNewContent', { current: vocabTrainerCurrentAttempts, target: trainerNewFocusTarget }) }}</span>
<span>{{ $t('socialnetwork.vocab.courses.trainerProgressReview', { count: vocabTrainerReviewAttempts }) }}</span>
<span>{{ $t('socialnetwork.vocab.courses.trainerProgressMixShare', { percent: Math.round(currentReviewShare * 100) }) }}</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-if="vocabTrainerMode === 'multiple_choice' && !vocabTrainerAnswered" 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-if="vocabTrainerMode === 'typing' && (!vocabTrainerAnswered || !vocabTrainerLastCorrect)" class="vocab-answer-area typing">
<div 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="vocabTrainerMode === 'multiple_choice' && vocabTrainerAnswered && !vocabTrainerLastCorrect" class="vocab-next">
<button @click="continueAfterVocabAnswer">{{ $t('socialnetwork.vocab.courses.next') }}</button>
</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>
<!-- 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">
{{ $t('socialnetwork.vocab.courses.deepenSectionTitle') }}
</summary>
<div class="learn-grid">
<div
v-if="(lesson && lesson.description) || lessonDidactics.learningGoals.length > 0"
class="didactic-card lesson-intro-combined"
>
<div v-if="lesson && lesson.description" class="lesson-intro-block">
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
<p>{{ lesson.description }}</p>
</div>
<div v-if="lessonDidactics.learningGoals.length > 0" class="lesson-intro-block">
<h4>{{ $t('socialnetwork.vocab.courses.learningGoals') }}</h4>
<ul class="didactic-list">
<li v-for="(goal, index) in lessonDidactics.learningGoals" :key="'goal-' + index">{{ goal }}</li>
</ul>
</div>
</div>
<div v-if="normalizedCorePatterns.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.corePatterns') }}</h4>
<p v-if="corePatternsHaveGloss" class="core-patterns-hint">
{{ $t('socialnetwork.vocab.courses.corePatternsHint') }}
</p>
<div class="pattern-list">
<div v-for="(pattern, index) in normalizedCorePatterns" :key="'pattern-' + index" class="pattern-item">
<div class="pattern-target">{{ pattern.target }}</div>
<div v-if="pattern.gloss" class="pattern-gloss">{{ pattern.gloss }}</div>
</div>
</div>
</div>
<div v-if="lessonDidactics.grammarFocus.length > 0" class="grammar-explanations didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4>
<div v-for="(explanation, index) in lessonDidactics.grammarFocus" :key="'grammar-' + index" class="grammar-explanation-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>
</div>
</div>
<div v-if="lessonDidactics.speakingPrompts.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.speakingTasks') }}</h4>
<div v-for="(prompt, index) in lessonDidactics.speakingPrompts" :key="'speaking-' + index" class="speaking-prompt-item">
<strong>{{ prompt.title || $t('socialnetwork.vocab.courses.speakingPrompt') }}</strong>
<p>{{ prompt.prompt }}</p>
<p v-if="prompt.cue" class="speaking-cue">{{ prompt.cue }}</p>
</div>
</div>
<div v-if="lessonDidactics.practicalTasks.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.practicalTasks') }}</h4>
<div v-for="(task, index) in lessonDidactics.practicalTasks" :key="'task-' + index" class="practical-task-item">
<strong>{{ task.title }}</strong>
<p>{{ task.text }}</p>
</div>
</div>
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
</div>
</div>
</details>
<details class="lesson-assistant-section surface-card">
<summary class="lesson-deepen-section__summary">
{{ $t('socialnetwork.vocab.courses.assistantSectionTitle') }}
</summary>
<div ref="assistantCard" class="didactic-card language-assistant-card" :class="{ 'language-assistant-card--focused': isAssistantFocused }">
<div class="language-assistant-card__header">
<div>
<h4>{{ $t('socialnetwork.vocab.courses.languageAssistantTitle') }}</h4>
<p class="language-assistant-card__intro">{{ $t('socialnetwork.vocab.courses.languageAssistantIntro') }}</p>
</div>
<button @click="openLanguageAssistantSettings" class="button-secondary language-assistant-card__settings">
{{ $t('socialnetwork.vocab.courses.languageAssistantSettings') }}
</button>
</div>
<div v-if="assistantLoading" class="language-assistant-card__state">
{{ $t('general.loading') }}
</div>
<div v-else-if="!assistantAvailable" class="language-assistant-card__state">
<p>{{ $t('socialnetwork.vocab.courses.languageAssistantSetupHint') }}</p>
</div>
<div v-else class="language-assistant-panel">
<div class="language-assistant-panel__modes">
<button
v-for="mode in assistantModes"
:key="mode.value"
type="button"
class="assistant-mode-button"
:class="{ active: assistantMode === mode.value }"
@click="assistantMode = mode.value"
>
{{ mode.label }}
</button>
</div>
<div class="language-assistant-panel__presets">
<button type="button" class="assistant-preset-button" @click="sendPresetPrompt('explain')">
{{ $t('socialnetwork.vocab.courses.languageAssistantPromptExplain') }}
</button>
<button type="button" class="assistant-preset-button" @click="sendPresetPrompt('practice')">
{{ $t('socialnetwork.vocab.courses.languageAssistantPromptPractice') }}
</button>
<button type="button" class="assistant-preset-button" @click="sendPresetPrompt('correct')">
{{ $t('socialnetwork.vocab.courses.languageAssistantPromptCorrect') }}
</button>
</div>
<div v-if="assistantMessages.length > 0" class="language-assistant-chat">
<article
v-for="(message, index) in assistantMessages"
:key="`${message.role}-${index}`"
class="assistant-message"
:class="`assistant-message--${message.role}`"
>
<strong>{{ message.role === 'assistant' ? $t('socialnetwork.vocab.courses.languageAssistantSpeakerAi') : $t('socialnetwork.vocab.courses.languageAssistantSpeakerYou') }}</strong>
<p>{{ message.content }}</p>
</article>
</div>
<label class="language-assistant-panel__input">
<span>{{ $t('socialnetwork.vocab.courses.languageAssistantInputLabel') }}</span>
<textarea
v-model="assistantInput"
:placeholder="$t('socialnetwork.vocab.courses.languageAssistantInputPlaceholder')"
rows="4"
/>
</label>
<p v-if="assistantError" class="form-error">{{ assistantError }}</p>
<div class="language-assistant-panel__actions">
<button
type="button"
@click="sendAssistantMessage()"
:disabled="assistantSubmitting || !assistantInput.trim()"
>
{{ assistantSubmitting ? $t('socialnetwork.vocab.courses.languageAssistantSending') : $t('socialnetwork.vocab.courses.languageAssistantSend') }}
</button>
</div>
</div>
</div>
</details>
</div>
<!-- Übungen-Tab (Kapitel-Prüfung) -->
<div v-if="activeTab === 'exercises'" class="grammar-exercises">
<div v-if="lesson && effectiveExercises && effectiveExercises.length > 0">
<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
v-if="sequentialPanelActive && scrambledChapterExamExercises.length > 0"
class="exercise-sequential-nav surface-card"
>
<p class="exercise-sequential-nav__progress">
{{ $t('socialnetwork.vocab.courses.exerciseSequentialProgress', {
current: exerciseSequentialIndex + 1,
total: scrambledChapterExamExercises.length
}) }}
</p>
<div class="exercise-sequential-nav__buttons">
<button
type="button"
class="btn-seq"
:disabled="exerciseSequentialIndex <= 0"
@click="stepExercisePanel(-1)"
>
{{ $t('socialnetwork.vocab.courses.exerciseSequentialBack') }}
</button>
<button
type="button"
class="btn-seq btn-seq--primary"
:disabled="!canStepExercisePanelForward"
@click="stepExercisePanel(1)"
>
{{ $t('socialnetwork.vocab.courses.exerciseSequentialNext') }}
</button>
</div>
</div>
<div class="exercise-flow-list">
<div
v-for="(exercise, index) in exercisesPanelExercises"
: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: exercisePanelDisplayNumber(index) }) }}</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 -->
<div v-if="getExerciseType(exercise) === 'multiple_choice'" class="multiple-choice-exercise">
<p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p>
<div class="options">
<label v-for="(option, index) in getOptions(exercise)" :key="index" class="option-label">
<input
type="radio"
:name="'exercise-' + exercise.id"
:value="index"
v-model="exerciseAnswers[exercise.id]"
/>
<span>{{ option }}</span>
</label>
</div>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id] && exerciseAnswers[exercise.id] !== 0">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.alternatives') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<!-- Gap Fill Übung -->
<div v-else-if="getExerciseType(exercise) === 'gap_fill'" class="gap-fill-exercise">
<p class="exercise-text" v-html="formatGapFill(exercise)"></p>
<div class="gap-inputs">
<input
v-for="(gap, index) in getGapCount(exercise)"
:key="index"
v-model="exerciseAnswers[exercise.id][index]"
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
class="gap-input"
:ref="getGapInputRef(exercise.id, index)"
@keydown.enter.prevent="onGapInputEnter(exercise, index)"
/>
</div>
<button @click="checkAnswer(exercise.id)" :disabled="!hasAllGapsFilled(exercise)">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.alternatives') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<!-- Transformation Übung -->
<div v-else-if="getExerciseType(exercise) === 'transformation'" class="transformation-exercise">
<p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
class="transformation-input"
:ref="'exercise-input-' + exercise.id"
@keydown.enter.prevent="onSingleInputEnter(exercise)"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.alternatives') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'sentence_building'" class="sentence-building-exercise">
<p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.tokens?.length" class="token-list">
<span v-for="(token, index) in getQuestionData(exercise).tokens" :key="index" class="token-chip">{{ token }}</span>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.buildSentencePlaceholder')"
class="transformation-input"
:ref="'exercise-input-' + exercise.id"
@keydown.enter.prevent="onSingleInputEnter(exercise)"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelSentence') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'dialog_completion'" class="dialog-completion-exercise">
<p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.dialog" class="dialog-snippet">
<p v-for="(line, index) in getQuestionData(exercise).dialog" :key="index">{{ line }}</p>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.completeDialogPlaceholder')"
class="transformation-input"
:ref="'exercise-input-' + exercise.id"
@keydown.enter.prevent="onSingleInputEnter(exercise)"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelDialogLine') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'situational_response'" class="situational-response-exercise">
<p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p>
<textarea
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.situationalResponsePlaceholder')"
class="response-textarea"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelResponse') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.keywords') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'pattern_drill'" class="pattern-drill-exercise">
<p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p>
<p v-if="getQuestionData(exercise)?.pattern" class="pattern-drill-hint">
{{ $t('socialnetwork.vocab.courses.patternPrompt') }}: {{ getQuestionData(exercise).pattern }}
</p>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.patternDrillPlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelPattern') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<!-- Reading Aloud Übung -->
<div v-else-if="getExerciseType(exercise) === 'reading_aloud'" class="reading-aloud-exercise">
<p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p>
<p class="exercise-instruction">
{{ isSpeechRecognitionSupported ? $t('socialnetwork.vocab.courses.readingAloudInstruction') : $t('socialnetwork.vocab.courses.speakingFallbackInstruction') }}
</p>
<div v-if="isSpeechRecognitionSupported" class="reading-aloud-controls">
<button
v-if="!isRecording(exercise.id)"
@click="startReadingAloud(exercise.id)"
class="btn-record"
:disabled="!isSpeechRecognitionSupported"
>
{{ $t('socialnetwork.vocab.courses.startRecording') }}
</button>
<button
v-else
@click="stopReadingAloud(exercise.id)"
class="btn-stop-record"
>
{{ $t('socialnetwork.vocab.courses.stopRecording') }}
</button>
</div>
<div v-else class="speech-fallback">
<textarea
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.speakingFallbackPlaceholder')"
class="response-textarea"
/>
</div>
<div v-if="recordingStatus[exercise.id]" class="recording-status" :class="{ 'recording': isRecording(exercise.id) }">
<span v-if="isRecording(exercise.id)">{{ $t('socialnetwork.vocab.courses.recording') }}...</span>
<span v-else>{{ recordingStatus[exercise.id] }}</span>
</div>
<div v-if="recognizedText[exercise.id]" class="recognized-text">
<strong>{{ $t('socialnetwork.vocab.courses.recognizedText') }}:</strong>
<p>{{ recognizedText[exercise.id] }}</p>
</div>
<button
v-if="(recognizedText[exercise.id] && !isRecording(exercise.id)) || (!isSpeechRecognitionSupported && exerciseAnswers[exercise.id])"
@click="checkAnswer(exercise.id)"
class="btn-check"
>
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="!exerciseResults[exercise.id].correct && exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
<div v-if="!isSpeechRecognitionSupported" class="speech-not-supported">
<p>{{ $t('socialnetwork.vocab.courses.speechRecognitionNotSupported') }}</p>
</div>
</div>
<!-- Speaking From Memory Übung -->
<div v-else-if="getExerciseType(exercise) === 'speaking_from_memory'" class="speaking-from-memory-exercise">
<p v-if="getVisibleQuestionText(exercise)" class="exercise-question">{{ getVisibleQuestionText(exercise) }}</p>
<p class="exercise-instruction">
{{ isSpeechRecognitionSupported ? $t('socialnetwork.vocab.courses.speakingFromMemoryInstruction') : $t('socialnetwork.vocab.courses.speakingFallbackInstruction') }}
</p>
<div v-if="getQuestionData(exercise)?.keywords" class="keywords-hint">
<strong>{{ $t('socialnetwork.vocab.courses.keywords') }}:</strong>
<span v-for="(keyword, idx) in getQuestionData(exercise).keywords" :key="idx" class="keyword-tag">{{ keyword }}</span>
</div>
<div v-if="isSpeechRecognitionSupported" class="speaking-controls">
<button
v-if="!isRecording(exercise.id)"
@click="startSpeakingFromMemory(exercise.id)"
class="btn-record"
:disabled="!isSpeechRecognitionSupported"
>
{{ $t('socialnetwork.vocab.courses.startSpeaking') }}
</button>
<button
v-else
@click="stopSpeakingFromMemory(exercise.id)"
class="btn-stop-record"
>
{{ $t('socialnetwork.vocab.courses.stopRecording') }}
</button>
</div>
<div v-else class="speech-fallback">
<textarea
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.speakingFallbackPlaceholder')"
class="response-textarea"
/>
</div>
<div v-if="recordingStatus[exercise.id]" class="recording-status" :class="{ 'recording': isRecording(exercise.id) }">
<span v-if="isRecording(exercise.id)">{{ $t('socialnetwork.vocab.courses.recording') }}...</span>
<span v-else>{{ recordingStatus[exercise.id] }}</span>
</div>
<div v-if="recognizedText[exercise.id]" class="recognized-text">
<strong>{{ $t('socialnetwork.vocab.courses.recognizedText') }}:</strong>
<p>{{ recognizedText[exercise.id] }}</p>
</div>
<button
v-if="(recognizedText[exercise.id] && !isRecording(exercise.id)) || (!isSpeechRecognitionSupported && exerciseAnswers[exercise.id])"
@click="checkAnswer(exercise.id)"
class="btn-check"
>
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
<div v-if="!isSpeechRecognitionSupported" class="speech-not-supported">
<p>{{ $t('socialnetwork.vocab.courses.speechRecognitionNotSupported') }}</p>
</div>
</div>
<!-- Fallback für unbekannte Typen -->
<div v-else class="unknown-exercise">
<p>{{ $t('socialnetwork.vocab.courses.unknownExerciseTypeNotice') }}</p>
<p class="unknown-exercise__type">{{ $t('socialnetwork.vocab.courses.unknownExerciseTypeLabel', { type: getExerciseType(exercise) }) }}</p>
</div>
</div>
</div>
</div>
<div v-else-if="lesson && (!effectiveExercises || effectiveExercises.length === 0)">
<p>{{ $t('socialnetwork.vocab.courses.noExercises') }}</p>
</div>
</div>
</div>
</div>
<!-- Dialog für Navigation zur nächsten Lektion -->
<div v-if="showNextLessonDialog" class="dialog-overlay" @click.self="cancelNavigateToNextLesson">
<div class="dialog" style="width: 400px; height: auto;">
<div class="dialog-header">
<span class="dialog-title">{{ $t('socialnetwork.vocab.courses.lessonCompleted') }}</span>
<span class="dialog-close" @click="cancelNavigateToNextLesson"></span>
</div>
<div class="dialog-body">
<p>{{ $t('socialnetwork.vocab.courses.goToNextLesson') }}</p>
</div>
<div class="dialog-footer">
<button @click="confirmNavigateToNextLesson" class="dialog-button">{{ $t('general.yes') }}</button>
<button @click="cancelNavigateToNextLesson" class="dialog-button">{{ $t('general.no') }}</button>
</div>
</div>
</div>
<!-- Dialog für Kurs-Abschluss -->
<div v-if="showCompletionDialog" class="dialog-overlay" @click.self="closeCompletionDialog">
<div class="dialog" style="width: 400px; height: auto;">
<div class="dialog-header">
<span class="dialog-title">{{ $t('socialnetwork.vocab.courses.allLessonsCompleted') }}</span>
<span class="dialog-close" @click="closeCompletionDialog"></span>
</div>
<div class="dialog-body">
<p>{{ $t('socialnetwork.vocab.courses.allLessonsCompleted') }}</p>
</div>
<div class="dialog-footer">
<button @click="closeCompletionDialog" class="dialog-button">{{ $t('general.ok') }}</button>
</div>
</div>
</div>
<!-- Dialog für Fehler -->
<div v-if="showErrorDialog" class="dialog-overlay" @click.self="closeErrorDialog">
<div class="dialog" style="width: 400px; height: auto;">
<div class="dialog-header">
<span class="dialog-title">{{ $t('error-title') }}</span>
<span class="dialog-close" @click="closeErrorDialog"></span>
</div>
<div class="dialog-body">
<p>{{ errorMessage }}</p>
</div>
<div class="dialog-footer">
<button @click="closeErrorDialog" class="dialog-button">{{ $t('general.ok') }}</button>
</div>
</div>
</div>
<!-- Nach falscher Kapitel-Antwort: zuerst Lösung, dann optional zum Üben -->
<div v-if="showExerciseReinforcementDialog" class="dialog-overlay">
<div class="dialog" style="width: 440px; height: auto;">
<div class="dialog-header">
<span class="dialog-title">{{ $t('socialnetwork.vocab.courses.exerciseWrongTitle') }}</span>
</div>
<div class="dialog-body">
<p v-if="exerciseReinforcementCorrectAnswer" class="exercise-reinforcement-correct">
<strong>{{ $t('socialnetwork.vocab.courses.correctAnswer') }}:</strong>
{{ exerciseReinforcementCorrectAnswer }}
</p>
<p>{{ exerciseReinforcementMessage }}</p>
</div>
<div class="dialog-footer dialog-footer--stack">
<button type="button" class="dialog-button dialog-button--primary" @click="confirmExerciseReinforcement">
{{ $t('socialnetwork.vocab.courses.exerciseReinforcementGoPracticeAck') }}
</button>
<button type="button" class="dialog-button" @click="closeExerciseReinforcementDialog">
{{ $t('socialnetwork.vocab.courses.exerciseReinforcementStayAck') }}
</button>
</div>
</div>
</div>
<!-- Kapitel-Prüfung: Mindestziel am Ende nicht erreicht -->
<div v-if="showChapterExamFailedDialog" class="dialog-overlay" @click.self="closeChapterExamFailedDialog">
<div class="dialog" style="width: 440px; height: auto;">
<div class="dialog-header">
<span class="dialog-title">{{ $t('socialnetwork.vocab.courses.exerciseExamFailedTitle') }}</span>
<span class="dialog-close" @click="closeChapterExamFailedDialog"></span>
</div>
<div class="dialog-body">
<p>{{ $t('socialnetwork.vocab.courses.exerciseExamFailedBody', {
score: chapterExamFailedScore,
target: exerciseTargetScore,
count: exerciseRetryUnlockAttempts
}) }}</p>
<div v-if="chapterExamFailedDetails.length > 0" class="exam-failed-review">
<h4>{{ $t('socialnetwork.vocab.courses.exerciseExamFailedReviewTitle') }}</h4>
<ul>
<li v-for="(item, index) in chapterExamFailedDetails" :key="`failed-${index}-${item.id}`">
<p class="exam-failed-review__question">
<strong>{{ $t('socialnetwork.vocab.courses.exerciseExamFailedQuestionLabel') }} {{ index + 1 }}:</strong>
{{ item.question }}
</p>
<p class="exam-failed-review__answer">
<strong>{{ $t('socialnetwork.vocab.courses.exerciseExamFailedYourAnswerLabel') }}:</strong>
{{ item.userAnswer || $t('socialnetwork.vocab.courses.exerciseExamFailedNoAnswer') }}
</p>
<p class="exam-failed-review__answer">
<strong>{{ $t('socialnetwork.vocab.courses.correctAnswer') }}:</strong>
{{ item.correctAnswer || '—' }}
</p>
</li>
</ul>
</div>
</div>
<div class="dialog-footer">
<button type="button" class="dialog-button dialog-button--primary" @click="closeChapterExamFailedDialog">
{{ $t('socialnetwork.vocab.courses.exerciseExamFailedOk') }}
</button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
const debugLog = () => {};
const LESSON_STATE_VERSION = 1;
const VOCAB_REPEAT_INTERVALS = [1, 2, 4];
const VOCAB_MIN_CURRENT_EXPOSURES = 3;
/** Mindest-Erfolgsquote im Vokabeltrainer (gesamt), damit die Kapitel-Prüfung freigeschaltet wird. */
const EXERCISE_UNLOCK_MIN_SUCCESS_PERCENT = 70;
/** Max. Zeilen pro Seite in der einklappbaren Vokabel-Gesamtübersicht */
const VOCAB_OVERVIEW_PAGE_SIZE = 40;
export default {
name: 'VocabLessonView',
props: {
courseId: {
type: String,
required: true
},
lessonId: {
type: String,
required: true
}
},
data() {
return {
loading: false,
lesson: null,
exerciseAnswers: {},
exerciseResults: {},
activeTab: 'learn', // Standardmäßig "Lernen"-Tab
// Vokabeltrainer
vocabTrainerActive: false,
vocabTrainerPool: [],
vocabTrainerMode: 'multiple_choice', // 'multiple_choice' oder 'typing'
vocabTrainerAutoSwitchedToTyping: false, // Track ob automatisch zu Typing gewechselt wurde
vocabTrainerCorrect: 0,
vocabTrainerWrong: 0,
vocabTrainerTotalAttempts: 0,
vocabTrainerStats: {}, // { [vocabKey]: { attempts: 0, correct: 0, wrong: 0 } }
vocabTrainerRepeatQueue: [],
vocabTrainerChoiceOptions: [],
vocabTrainerPhase: 'current', // 'current' = aktuelle Lektion, 'mixed' = gemischt mit alten
vocabTrainerMixedPool: [], // Pool aus alten Lektionsvokabeln
vocabTrainerMixedAttempts: 0, // Zähler für Mixed-Phase
vocabTrainerCurrentAttempts: 0,
vocabTrainerReviewAttempts: 0,
exercisePreparationCompleted: false,
/** 0 = noch kein Durchgang, 1 = erste Durchsicht, 2 = zweite Durchsicht — dann Vokabeltrainer */
lessonPrepStage: 0,
lessonPrepIndex: 0,
currentVocabQuestion: null,
vocabTrainerAnswer: '',
vocabTrainerSelectedChoice: null,
vocabTrainerAnswered: false,
vocabTrainerLastCorrect: false,
vocabTrainerDirection: 'L2R', // L2R: learning->reference, R2L: reference->learning
isCheckingLessonCompletion: false, // Flag um Endlosschleife zu verhindern
isNavigatingToNext: false, // Flag um mehrfache Navigation zu verhindern
showNextLessonDialog: false,
// Speech Recognition für Reading Aloud und Speaking From Memory
speechRecognition: null,
activeRecognition: {}, // { [exerciseId]: SpeechRecognition instance }
recognizedText: {}, // { [exerciseId]: string }
recordingStatus: {}, // { [exerciseId]: string }
isSpeechRecognitionSupported: false,
exerciseRetryPending: false,
exerciseRetryPendingSinceAttempts: 0,
assistantLoading: false,
assistantSubmitting: false,
assistantSettings: null,
assistantMessages: [],
assistantInput: '',
assistantError: '',
assistantMode: 'practice',
isAssistantFocused: false,
nextLessonId: null,
showCompletionDialog: false,
showErrorDialog: false,
errorMessage: '',
showExerciseReinforcementDialog: false,
exerciseReinforcementPrepMode: false,
exerciseReinforcementCorrectAnswer: '',
exerciseReinforcementMessage: '',
showChapterExamFailedDialog: false,
chapterExamFailedScore: 0,
chapterExamFailedDetails: [],
/** Index in scrambledChapterExamExercises bei Ein-Frage-Ansicht */
exerciseSequentialIndex: 0,
dailyEnoughBannerDismissed: false,
/** Aus vorherigen Lektionen (MC-Optionen nach Fragentyp Ziel-/Muttersprache) */
distractorPool: { target: [], native: [] },
courseLanguageName: '',
courseNativeLanguageName: '',
/** Fortschritt aller Kurslektionen inkl. lessonState für Spezial-Trainer-Boost */
courseProgressList: [],
/** { [exerciseId]: { options: string[], useTextAnswer: boolean } } */
mcRandomizedOptions: {},
lessonStatePersistenceReady: false,
lessonStateSaveTimer: null,
lessonStateSaveInFlight: false,
pendingLessonStatePayload: null,
resettingLessonProgress: false,
/** Seitennummer (1-basiert) für die Vokabel-Gesamtübersicht */
vocabOverviewPage: 1
};
},
computed: {
...mapGetters(['user']),
hasExercises() {
const exercises = this.effectiveExercises;
return exercises && Array.isArray(exercises) && exercises.length > 0;
},
hasPreviousVocab() {
return Array.isArray(this.previousVocab) && this.previousVocab.length > 0;
},
lessonComplexityWeight() {
const lessonType = this.lesson?.lessonType;
if (['dialogue', 'phrases', 'survival', 'grammar'].includes(lessonType)) {
return 1.2;
}
if (lessonType === 'review' || lessonType === 'vocab_review') {
return 0.9;
}
return 1;
},
trainerNewFocusTarget() {
const vocabCount = this.trainableLessonVocab?.length || 0;
const exerciseCount = this.effectiveExercises?.length || 0;
const durationBonus = Math.max(0, Math.round((this.lesson?.targetMinutes || 0) / 5) - 1);
const coverageTarget = vocabCount * this.trainerMinimumCurrentExposures;
const baseTarget = Math.ceil((Math.max(vocabCount, 4) * 1.35) + (exerciseCount * 0.35) + durationBonus);
const weightedTarget = Math.ceil(baseTarget * this.lessonComplexityWeight);
return Math.max(6, Math.min(160, Math.max(weightedTarget, coverageTarget)));
},
trainerMinimumCurrentExposures() {
const mode = this.lessonPedagogy?.didacticMode || this.lesson?.lessonType || '';
if (mode === 'intensive_review' || mode === 'review' || mode === 'vocab_review') {
return 2;
}
if (['grammar', 'dialogue', 'phrases', 'survival'].includes(this.lesson?.lessonType)) {
return 4;
}
return VOCAB_MIN_CURRENT_EXPOSURES;
},
trainerReviewBlendStart() {
return Math.max(3, Math.ceil(this.trainerNewFocusTarget * 0.4));
},
trainerReviewRampWindow() {
return Math.max(4, this.trainerNewFocusTarget - this.trainerReviewBlendStart);
},
trainerExerciseUnlockAttempts() {
const unlockTarget = this.trainerNewFocusTarget + Math.ceil((this.effectiveExercises?.length || 0) * 0.25);
return Math.max(6, Math.min(140, unlockTarget));
},
exerciseUnlockMinSuccessPercent() {
return EXERCISE_UNLOCK_MIN_SUCCESS_PERCENT;
},
currentReviewShare() {
if (!this.hasPreviousVocab) {
return 0;
}
const lessonType = String(this.lesson?.lessonType || '').toLowerCase();
const didacticMode = String(this.lessonPedagogy?.didacticMode || '').toLowerCase();
const isReviewLesson = ['review', 'vocab_review'].includes(lessonType)
|| ['review', 'vocab_review', 'intensive_review'].includes(didacticMode);
if (isReviewLesson) {
// In Wiederholungslektionen soll altes Material frueher und staerker einfliesen.
// Start: ~35%, Ramp-up bis max ~75%.
const blendStart = Math.max(2, Math.ceil(this.trainerNewFocusTarget * 0.2));
const rampWindow = Math.max(6, Math.ceil(this.trainerNewFocusTarget * 0.5));
const progressPastBlendStart = Math.max(0, this.vocabTrainerCurrentAttempts - blendStart);
const normalizedRamp = Math.min(1, progressPastBlendStart / rampWindow);
return Math.min(0.75, 0.35 + (normalizedRamp * 0.4));
}
const progressPastBlendStart = Math.max(0, this.vocabTrainerCurrentAttempts - this.trainerReviewBlendStart);
const normalizedRamp = Math.min(1, progressPastBlendStart / this.trainerReviewRampWindow);
return Math.min(0.55, normalizedRamp * 0.55);
},
typingCurrentLessonShare() {
if (!this.hasPreviousVocab) {
return 1;
}
const lessonType = String(this.lesson?.lessonType || '').toLowerCase();
const didacticMode = String(this.lessonPedagogy?.didacticMode || '').toLowerCase();
const isReviewLesson = ['review', 'vocab_review'].includes(lessonType)
|| ['review', 'vocab_review', 'intensive_review'].includes(didacticMode);
if (isReviewLesson) {
return Math.max(0.25, 1 - this.currentReviewShare);
}
// Normale Lektion: Progressives Streuen im Typing-Modus
// Start 100% aktuelle Lektion -> dann 90% -> später 50%.
const startRampAt = Math.max(3, Math.ceil(this.trainerNewFocusTarget * 0.3));
const midRampAt = Math.max(startRampAt + 1, Math.ceil(this.trainerNewFocusTarget * 0.6));
const attempts = Math.max(0, Number(this.vocabTrainerCurrentAttempts) || 0);
if (attempts < startRampAt) {
return 1;
}
if (attempts < midRampAt) {
return 0.9;
}
return 0.5;
},
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) {
return this.lessonPrepStage >= 2;
}
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');
}
const core = this.$t('socialnetwork.vocab.courses.exerciseUnlockHintTrainerCore', {
newTarget: this.trainerNewFocusTarget,
attempts: this.trainerExerciseUnlockAttempts,
rate: this.exerciseUnlockMinSuccessPercent
});
if (this.hasPreviousVocab) {
return `${core} ${this.$t('socialnetwork.vocab.courses.exerciseUnlockHintTrainerMixSuffix')}`;
}
return core;
},
/** Für Wiederholungslektionen: Übungen aus vorherigen Lektionen (Kapitelprüfung). Sonst: eigene Grammatik-Übungen. */
effectiveExercises() {
if (!this.lesson) return [];
const isReview = this.lesson.lessonType === 'review' || this.lesson.lessonType === 'vocab_review';
if (isReview && this.lesson.reviewVocabExercises && Array.isArray(this.lesson.reviewVocabExercises) && this.lesson.reviewVocabExercises.length > 0) {
return this.lesson.reviewVocabExercises;
}
if (this.lesson.grammarExercises && Array.isArray(this.lesson.grammarExercises)) {
return this.lesson.grammarExercises;
}
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;
},
/** Kapitel-Prüfung: eine Frage pro Ansicht (Essen & Trinken: deterministisch gemischt). */
scrambledChapterExamExercises() {
const raw = this.effectiveExercises;
if (!raw.length) return [];
if ((this.lesson?.title || '').trim() === 'Essen & Trinken') {
return this._deterministicShuffle(raw.slice(), Number(this.lessonId) || 1);
}
return raw;
},
sequentialPanelActive() {
return (this.scrambledChapterExamExercises?.length || 0) > 1;
},
exercisesPanelExercises() {
const list = this.scrambledChapterExamExercises;
if (!list.length) return [];
if (!this.sequentialPanelActive) return list;
const idx = Math.max(0, Math.min(this.exerciseSequentialIndex, list.length - 1));
return [list[idx]];
},
canStepExercisePanelForward() {
const list = this.scrambledChapterExamExercises;
if (!list.length) return false;
const ex = list[this.exerciseSequentialIndex];
if (!ex) return false;
return Boolean(this.exerciseResults[ex.id]) && this.exerciseSequentialIndex < list.length - 1;
},
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 {
const exercises = this.effectiveExercises;
if (!exercises || !Array.isArray(exercises) || exercises.length === 0) {
return [];
}
const explanations = [];
const seen = new Set();
exercises.forEach(exercise => {
if (exercise.explanation && !seen.has(exercise.explanation)) {
seen.add(exercise.explanation);
explanations.push({
title: exercise.title || '',
text: exercise.explanation
});
}
});
return explanations;
} catch (e) {
console.error('Fehler beim Extrahieren der Grammatik-Erklärungen:', e);
return [];
}
},
/** Vokabeln aus vorherigen Lektionen (für gemischte Wiederholung im Vokabeltrainer) */
previousVocab() {
try {
if (!this.lesson || !this.lesson.previousLessonExercises) return [];
return this._extractVocabFromExercises(this.lesson.previousLessonExercises);
} catch (e) {
console.error('Fehler in previousVocab:', e);
return [];
}
},
importantVocab() {
// Extrahiere wichtige Begriffe aus den Übungen
try {
// Bei Wiederholungslektionen: Verwende Vokabeln aus vorherigen Lektionen (effectiveExercises = reviewVocabExercises)
// Normale Lektion: Verwende effectiveExercises (grammarExercises)
const exercises = this.effectiveExercises;
if (!exercises || !Array.isArray(exercises) || exercises.length === 0) {
debugLog('[importantVocab] Keine Übungen vorhanden');
return [];
}
return this._extractVocabFromExercises(exercises);
} catch (e) {
console.error('Fehler in importantVocab computed property:', e);
return [];
}
},
lessonVocab() {
const vocabByReference = new Map();
const targetTokenWeight = new Map();
const nativeTokenWeight = new Map();
const addTokens = (text, map, weight = 1) => {
const tokens = String(text || '')
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.map((t) => t.trim())
.filter((t) => t.length >= 2);
tokens.forEach((token) => {
map.set(token, (map.get(token) || 0) + weight);
});
};
const sideScore = (text, map) => {
const tokens = String(text || '')
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.map((t) => t.trim())
.filter((t) => t.length >= 2);
return tokens.reduce((sum, token) => sum + (map.get(token) || 0), 0);
};
(this.normalizedCorePatterns || []).forEach((p) => {
addTokens(p?.target, targetTokenWeight, 3);
addTokens(p?.gloss, nativeTokenWeight, 3);
});
(this.importantVocab || []).forEach((v) => {
addTokens(v?.reference, targetTokenWeight, 1);
addTokens(v?.learning, nativeTokenWeight, 1);
});
const orientPair = (learning, reference) => {
const l = String(learning || '').trim();
const r = String(reference || '').trim();
if (!l || !r) return { learning: l, reference: r };
const directScore = sideScore(r, targetTokenWeight) + sideScore(l, nativeTokenWeight);
const swappedScore = sideScore(l, targetTokenWeight) + sideScore(r, nativeTokenWeight);
if (swappedScore > directScore) {
return { learning: r, reference: l };
}
return { learning: l, reference: r };
};
const addEntry = (entry) => {
const oriented = orientPair(entry?.learning, entry?.reference);
const reference = String(oriented.reference || '').trim();
const learning = String(oriented.learning || '').trim();
if (!this.isTrainableLessonVocabPair(learning, reference)) return;
const key = this.normalizeLessonVocabTerm(reference);
if (!vocabByReference.has(key)) {
const variants = new Set();
if (learning) variants.add(learning);
vocabByReference.set(key, { learning, reference, learningVariants: variants });
return;
}
const existing = vocabByReference.get(key);
if (learning) {
if (!existing.learning) {
existing.learning = learning;
}
if (existing.learningVariants instanceof Set) {
existing.learningVariants.add(learning);
} else {
existing.learningVariants = new Set([existing.learning, learning].filter(Boolean));
}
}
};
this.normalizedCorePatterns.forEach((item) => {
addEntry({
learning: item.gloss || '',
reference: item.target || ''
});
});
this.importantVocab.forEach((item) => {
addEntry(item);
});
return Array.from(vocabByReference.values()).map((entry) => {
const variants = entry.learningVariants instanceof Set
? Array.from(entry.learningVariants)
: (Array.isArray(entry.learningVariants) ? entry.learningVariants : []);
const uniqueVariants = [...new Set([entry.learning, ...variants].filter(Boolean))];
return {
learning: entry.learning,
reference: entry.reference,
// Für den Trainer: alternative Übersetzungen als gleichwertig akzeptieren.
learningVariants: uniqueVariants
};
});
},
vocabOverviewTotalPages() {
const n = this.lessonVocab.length;
if (n === 0) {
return 1;
}
return Math.ceil(n / VOCAB_OVERVIEW_PAGE_SIZE);
},
paginatedLessonVocab() {
const list = this.lessonVocab;
const totalPages = this.vocabOverviewTotalPages;
const page = Math.min(Math.max(1, this.vocabOverviewPage), totalPages);
const start = (page - 1) * VOCAB_OVERVIEW_PAGE_SIZE;
return list.slice(start, start + VOCAB_OVERVIEW_PAGE_SIZE);
},
vocabOverviewPagerMeta() {
const n = this.lessonVocab.length;
const totalPages = this.vocabOverviewTotalPages;
const page = Math.min(Math.max(1, this.vocabOverviewPage), totalPages);
if (n === 0) {
return { from: 0, to: 0, page: 1, pages: 1, total: 0 };
}
const from = (page - 1) * VOCAB_OVERVIEW_PAGE_SIZE + 1;
const to = Math.min(page * VOCAB_OVERVIEW_PAGE_SIZE, n);
return { from, to, page, pages: totalPages, total: n };
},
trainableLessonVocab() {
return this.lessonVocab.filter((entry) => this.isTrainableLessonVocabPair(entry.learning, entry.reference));
},
lessonDidactics() {
return this.lesson?.didactics || {
learningGoals: [],
corePatterns: [],
grammarFocus: [],
speakingPrompts: [],
practicalTasks: []
};
},
normalizedCorePatterns() {
const raw = this.lessonDidactics.corePatterns || [];
return raw
.map((p) => this.normalizeCorePatternEntry(p))
.filter(Boolean);
},
prepTargetLabel() {
return String(
this.courseLanguageName
|| this.lesson?.course?.languageName
|| this.lesson?.languageName
|| this.$t('socialnetwork.vocab.courses.vocabPrepTargetLabel')
).toUpperCase();
},
prepGlossLabel() {
return String(
this.courseNativeLanguageName
|| this.lesson?.course?.nativeLanguageName
|| this.lesson?.nativeLanguageName
|| this.$t('socialnetwork.vocab.courses.vocabPrepGlossLabel')
).toUpperCase();
},
prepItems() {
// Vorbereitung nur mit echten Paaren (Zielsprache + Übersetzung),
// damit weder leere Gloss-Zeilen noch übergroße Listen entstehen.
const out = [];
const seen = new Set();
const targetTokenWeight = new Map();
const nativeTokenWeight = new Map();
const addTokens = (text, map, weight = 1) => {
const tokens = String(text || '')
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.map((t) => t.trim())
.filter((t) => t.length >= 2);
tokens.forEach((token) => {
map.set(token, (map.get(token) || 0) + weight);
});
};
// Core patterns gelten als qualitativ beste Quelle -> höheres Gewicht
(this.normalizedCorePatterns || []).forEach((p) => {
addTokens(p?.target, targetTokenWeight, 3);
addTokens(p?.gloss, nativeTokenWeight, 3);
});
// Übungs-Extraktion als Zusatzsignal
(this.importantVocab || []).forEach((v) => {
addTokens(v?.reference, targetTokenWeight, 1);
addTokens(v?.learning, nativeTokenWeight, 1);
});
const sideScore = (text, map) => {
const tokens = String(text || '')
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.map((t) => t.trim())
.filter((t) => t.length >= 2);
return tokens.reduce((sum, token) => sum + (map.get(token) || 0), 0);
};
const orientPair = (target, gloss) => {
const t = String(target || '').trim();
const g = String(gloss || '').trim();
if (!t || !g) return { target: t, gloss: g };
const directScore = sideScore(t, targetTokenWeight) + sideScore(g, nativeTokenWeight);
const swappedScore = sideScore(g, targetTokenWeight) + sideScore(t, nativeTokenWeight);
if (swappedScore > directScore) {
return { target: g, gloss: t };
}
return { target: t, gloss: g };
};
const pushUnique = (target, gloss) => {
const oriented = orientPair(target, gloss);
const t = String(oriented.target || '').trim();
const g = String(oriented.gloss || '').trim();
if (!this.isTrainableLessonVocabPair(g, t)) return;
const key = `${this.normalizeLessonVocabTerm(t)}|${this.normalizeLessonVocabTerm(g)}`;
if (seen.has(key)) return;
seen.add(key);
out.push({ target: t, gloss: g });
};
// 1) Didaktisch gepflegte Kernmuster zuerst (höchste Qualität)
this.normalizedCorePatterns.forEach((p) => {
pushUnique(p?.target, p?.gloss);
});
// 2) Ergänzung aus extrahierten Übungsvokabeln, aber nur saubere Paare
this.lessonVocab.forEach((item) => {
pushUnique(item?.reference, item?.learning);
});
// Begrenzen, damit die Vorbereitungsrunde kompakt bleibt
return out.slice(0, 30);
},
currentPrepItem() {
return this.prepItems[this.lessonPrepIndex] || null;
},
isLastPrepItemInPass() {
if (this.prepItems.length === 0) {
return true;
}
return this.lessonPrepIndex >= this.prepItems.length - 1;
},
corePatternsHaveGloss() {
return this.normalizedCorePatterns.some((p) => p.gloss);
},
canStartVocabTrainerPrep() {
if (!this.trainableLessonVocab || this.trainableLessonVocab.length === 0) {
return false;
}
if (this.prepItems.length > 0 && this.lessonPrepStage < 2) {
return false;
}
return true;
},
lessonPedagogy() {
return this.lesson?.pedagogy || {
didacticMode: null,
phaseLabel: null,
blockNumber: null,
difficultyWeight: null,
newUnitTarget: null,
reviewWeight: null,
isIntensiveReview: false
};
},
lessonProgress() {
return this.lesson?.progress || null;
},
lessonReviewBadgeLabel() {
const progress = this.lessonProgress;
if (!progress?.completed) {
return '';
}
return this.getReviewStageLabel(progress);
},
lessonReviewStatusClass() {
const progress = this.lessonProgress;
if (!progress?.completed) {
return '';
}
if (progress.reviewCompleted) {
return 'lesson-review-status--done';
}
if (progress.reviewDue) {
return 'lesson-review-status--due';
}
return 'lesson-review-status--scheduled';
},
lessonReviewHeadline() {
const progress = this.lessonProgress;
if (!progress?.completed) {
return '';
}
if (progress.reviewCompleted) {
return this.$t('socialnetwork.vocab.courses.lessonReviewHeadlineDone');
}
if (progress.reviewDue) {
return this.$t('socialnetwork.vocab.courses.lessonReviewHeadlineDue');
}
return this.$t('socialnetwork.vocab.courses.lessonReviewHeadlineScheduled');
},
lessonReviewHint() {
const progress = this.lessonProgress;
if (!progress?.completed) {
return '';
}
if (progress.reviewCompleted) {
return this.$t('socialnetwork.vocab.courses.lessonReviewHintDone');
}
return this.$t('socialnetwork.vocab.courses.lessonReviewHintNextDue', {
due: this.formatLessonReviewDue(progress.reviewNextDueAt)
});
},
showDailyEnoughBanner() {
if (this.dailyEnoughBannerDismissed) return false;
const progress = this.lessonProgress;
// Wenn eine Review-Welle fällig ist, ist "für heute genug" nicht korrekt.
if (progress?.completed && progress?.reviewDue) return false;
if (progress?.completed) return true;
// Falls der Fortschritt noch nicht gespeichert ist: Übungen sind vollständig + bestanden.
if (this.hasExercises && this.effectiveExercises.length > 0) {
const allAnswered = this.exerciseAnsweredCount >= this.effectiveExercises.length;
if (allAnswered && this.exerciseProgressPercent >= this.exerciseTargetScore) {
return true;
}
}
return false;
},
assistantAvailable() {
if (!this.assistantSettings) {
return false;
}
const enabled = this.assistantSettings.enabled !== false;
const hasBaseUrl = Boolean(this.assistantSettings.baseUrl);
return enabled && (this.assistantSettings.hasKey || hasBaseUrl);
},
assistantModes() {
return [
{ value: 'practice', label: this.$t('socialnetwork.vocab.courses.languageAssistantModePractice') },
{ value: 'explain', label: this.$t('socialnetwork.vocab.courses.languageAssistantModeExplain') },
{ value: 'correct', label: this.$t('socialnetwork.vocab.courses.languageAssistantModeCorrect') }
];
},
persistedLessonStateSnapshot() {
return {
activeTab: this.activeTab,
exerciseAnswers: this.exportPersistedExerciseAnswers(),
exerciseResults: this.exerciseResults,
exercisePreparationCompleted: this.exercisePreparationCompleted,
lessonPrepStage: this.lessonPrepStage,
lessonPrepIndex: this.lessonPrepIndex,
vocabTrainerActive: this.vocabTrainerActive,
vocabTrainerMode: this.vocabTrainerMode,
vocabTrainerAutoSwitchedToTyping: this.vocabTrainerAutoSwitchedToTyping,
vocabTrainerCorrect: this.vocabTrainerCorrect,
vocabTrainerWrong: this.vocabTrainerWrong,
vocabTrainerTotalAttempts: this.vocabTrainerTotalAttempts,
vocabTrainerStats: this.vocabTrainerStats,
vocabTrainerRepeatQueue: this.vocabTrainerRepeatQueue,
vocabTrainerCurrentAttempts: this.vocabTrainerCurrentAttempts,
vocabTrainerReviewAttempts: this.vocabTrainerReviewAttempts,
exerciseRetryPending: this.exerciseRetryPending,
exerciseRetryPendingSinceAttempts: this.exerciseRetryPendingSinceAttempts,
exerciseSequentialIndex: this.exerciseSequentialIndex
};
}
},
watch: {
persistedLessonStateSnapshot: {
handler() {
this.persistLessonState();
},
deep: true
},
async courseId(newVal, oldVal) {
if (newVal !== oldVal) {
await this.persistLessonState({ immediate: true, lessonIdOverride: this.lesson?.id });
// Reset Flags beim Kurswechsel
this.isCheckingLessonCompletion = false;
this.isNavigatingToNext = false;
this.loadLesson();
}
},
async lessonId(newVal, oldVal) {
if (newVal !== oldVal) {
await this.persistLessonState({ immediate: true, lessonIdOverride: oldVal });
// Reset Flags beim Lektionswechsel
this.isCheckingLessonCompletion = false;
this.isNavigatingToNext = false;
this.loadLesson();
}
},
lessonVocab: {
handler() {
const totalPages = this.vocabOverviewTotalPages;
if (this.vocabOverviewPage > totalPages) {
this.vocabOverviewPage = Math.max(1, totalPages);
}
},
deep: true
}
},
methods: {
dismissDailyEnoughBanner() {
this.dailyEnoughBannerDismissed = true;
},
continueDailyEnoughBanner() {
this.dailyEnoughBannerDismissed = true;
if (!this.vocabTrainerActive && this.trainableLessonVocab.length > 0) {
// Startet Trainer nur, wenn die Vorbereitung abgeschlossen ist.
if (this.canStartVocabTrainerPrep) {
this.startVocabTrainer();
} else {
this.vocabTrainerActive = true;
this.vocabTrainerPool = [...this.trainableLessonVocab];
this.$nextTick(() => this.nextVocabQuestion());
}
}
},
vocabOverviewGoPrev() {
if (this.vocabOverviewPagerMeta.page > 1) {
this.vocabOverviewPage -= 1;
}
},
vocabOverviewGoNext() {
if (this.vocabOverviewPagerMeta.page < this.vocabOverviewPagerMeta.pages) {
this.vocabOverviewPage += 1;
}
},
exportPersistedExerciseAnswers() {
const exportedAnswers = {};
this.effectiveExercises.forEach((exercise) => {
const currentAnswer = this.exerciseAnswers[exercise.id];
if (currentAnswer === undefined || currentAnswer === null || currentAnswer === '') {
return;
}
if (this.getExerciseType(exercise) === 'multiple_choice') {
if (typeof currentAnswer === 'string' && Number.isNaN(Number(currentAnswer))) {
exportedAnswers[exercise.id] = currentAnswer;
return;
}
const optionIndex = Number(currentAnswer);
const selectedOption = this.getOptions(exercise)[optionIndex];
if (selectedOption !== undefined) {
exportedAnswers[exercise.id] = String(selectedOption);
}
return;
}
if (Array.isArray(currentAnswer)) {
exportedAnswers[exercise.id] = currentAnswer.map((entry) => String(entry ?? ''));
return;
}
exportedAnswers[exercise.id] = String(currentAnswer);
});
return exportedAnswers;
},
getLessonStateStorageKey() {
if (typeof window === 'undefined' || !window.localStorage) {
return '';
}
const userId = this.user?.id || 'guest';
return `vocab-lesson-state:${LESSON_STATE_VERSION}:${userId}:${this.courseId}:${this.lessonId}`;
},
clearLocalLessonStateCache() {
const storageKey = this.getLessonStateStorageKey();
if (!storageKey) {
return;
}
try {
window.localStorage.removeItem(storageKey);
} catch (error) {
console.warn('[VocabLessonView] Lokaler Lektions-Cache konnte nicht gelöscht werden:', error);
}
},
confirmResetLessonProgress() {
if (!this.lessonId || this.resettingLessonProgress) {
return;
}
if (!window.confirm(this.$t('socialnetwork.vocab.courses.resetLessonProgressConfirm'))) {
return;
}
this.resetLessonProgressOnServer();
},
async resetLessonProgressOnServer() {
if (!this.lessonId || this.resettingLessonProgress) {
return;
}
this.resettingLessonProgress = true;
try {
await apiClient.delete(`/api/vocab/lessons/${this.lessonId}/progress`);
this.clearLocalLessonStateCache();
// Nach dem Reset soll der Nutzer wieder im Üben-/Lernen-Tab starten.
this.activeTab = 'learn';
if (this.$route?.query?.tab && this.$route.query.tab !== 'learn') {
this.$router.replace({
query: { ...this.$route.query, tab: 'learn' }
});
}
this.$root?.$refs?.messageDialog?.open?.('tr:socialnetwork.vocab.courses.resetLessonProgressSuccess');
await this.loadLesson();
} catch (e) {
console.error('[VocabLessonView] Lektion zurücksetzen fehlgeschlagen:', e);
const msg = e?.response?.data?.error || this.$t('socialnetwork.vocab.courses.resetLessonProgressError');
this.$root?.$refs?.messageDialog?.open?.(msg);
} finally {
this.resettingLessonProgress = false;
}
},
buildPersistedLessonState() {
return {
version: LESSON_STATE_VERSION,
updatedAt: new Date().toISOString(),
...this.persistedLessonStateSnapshot
};
},
readLocalLessonState() {
const storageKey = this.getLessonStateStorageKey();
if (!storageKey) {
return null;
}
try {
const raw = window.localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : null;
} catch (error) {
console.warn('[VocabLessonView] Konnte gespeicherten Lektionszustand nicht lesen:', error);
return null;
}
},
async flushLessonStateToServer({ lessonIdOverride = null, payloadOverride = null } = {}) {
if (this.lessonStateSaveInFlight) {
return;
}
const payload = payloadOverride || this.pendingLessonStatePayload;
const lessonId = lessonIdOverride || this.lessonId;
if (!this.lessonStatePersistenceReady || !payload || !lessonId) {
return;
}
if (!payloadOverride) {
this.pendingLessonStatePayload = null;
}
this.lessonStateSaveInFlight = true;
try {
const { data } = await apiClient.put(`/api/vocab/lessons/${lessonId}/progress`, {
lessonState: payload
});
if (!lessonIdOverride && this.lesson) {
this.lesson.progress = data;
}
} catch (error) {
console.warn('[VocabLessonView] Konnte Lektionszustand nicht serverseitig speichern:', error);
if (!payloadOverride) {
this.pendingLessonStatePayload = payload;
}
} finally {
this.lessonStateSaveInFlight = false;
if (this.pendingLessonStatePayload) {
this.lessonStateSaveTimer = window.setTimeout(() => {
this.flushLessonStateToServer();
}, 800);
}
}
},
async persistLessonState({ immediate = false, lessonIdOverride = null } = {}) {
if (!this.lessonStatePersistenceReady) {
return;
}
const payload = this.buildPersistedLessonState();
const shouldWriteLocalCache = !lessonIdOverride || String(lessonIdOverride) === String(this.lessonId);
const storageKey = shouldWriteLocalCache ? this.getLessonStateStorageKey() : '';
if (storageKey) {
try {
window.localStorage.setItem(storageKey, JSON.stringify(payload));
} catch (error) {
console.warn('[VocabLessonView] Konnte Lektionszustand nicht lokal speichern:', error);
}
}
this.pendingLessonStatePayload = payload;
if (this.lessonStateSaveTimer) {
window.clearTimeout(this.lessonStateSaveTimer);
this.lessonStateSaveTimer = null;
}
if (immediate) {
await this.flushLessonStateToServer({ lessonIdOverride, payloadOverride: payload });
return;
}
this.lessonStateSaveTimer = window.setTimeout(() => {
this.flushLessonStateToServer();
}, 450);
},
normalizePersistedExerciseAnswers(savedAnswers) {
const normalizedAnswers = { ...this.exerciseAnswers };
if (!savedAnswers || typeof savedAnswers !== 'object') {
return normalizedAnswers;
}
this.effectiveExercises.forEach((exercise) => {
const saved = savedAnswers[exercise.id];
if (saved === undefined) {
return;
}
if (this.getExerciseType(exercise) === 'multiple_choice') {
const options = this.getOptions(exercise);
if (typeof saved === 'number' && Number.isFinite(saved) && saved >= 0 && saved < options.length) {
normalizedAnswers[exercise.id] = Math.trunc(saved);
return;
}
const savedText = String(saved ?? '').trim();
const restoredIndex = options.findIndex((option) => String(option).trim() === savedText);
normalizedAnswers[exercise.id] = restoredIndex >= 0 ? restoredIndex : '';
return;
}
if (this.getExerciseType(exercise) === 'gap_fill') {
const gapCount = this.getGapCount(exercise);
const values = Array.isArray(saved) ? saved.slice(0, gapCount) : [];
while (values.length < gapCount) {
values.push('');
}
normalizedAnswers[exercise.id] = values;
return;
}
if (typeof saved === 'string' || typeof saved === 'number') {
normalizedAnswers[exercise.id] = saved;
}
});
return normalizedAnswers;
},
restoreLessonState() {
const serverState = this.lesson?.progress?.lessonState;
const localFallbackState = this.readLocalLessonState();
const parsedState = serverState
&& typeof serverState === 'object'
&& !Array.isArray(serverState)
&& serverState.version === LESSON_STATE_VERSION
? serverState
: localFallbackState;
if (!parsedState || parsedState.version !== LESSON_STATE_VERSION) {
// Ohne gespeicherten Zustand soll eine Lektion immer im Üben-/Lernen-Flow starten,
// sonst bleibt z.B. der Kapitel-Prüfungs-Tab "kleben" (u.a. nach Reset).
this.activeTab = 'learn';
this.lessonStatePersistenceReady = true;
return;
}
this.exerciseAnswers = this.normalizePersistedExerciseAnswers(parsedState.exerciseAnswers);
const restoredResults = {};
this.effectiveExercises.forEach((exercise) => {
restoredResults[exercise.id] = Object.prototype.hasOwnProperty.call(parsedState.exerciseResults || {}, exercise.id)
? parsedState.exerciseResults[exercise.id]
: null;
});
this.exerciseResults = restoredResults;
this.exercisePreparationCompleted = Boolean(parsedState.exercisePreparationCompleted);
this.lessonPrepStage = Math.min(2, Math.max(0, Number(parsedState.lessonPrepStage) || 0));
this.lessonPrepIndex = Math.max(0, Math.min(this.prepItems.length - 1, Number(parsedState.lessonPrepIndex) || 0));
this.activeTab = parsedState.activeTab === 'exercises' ? 'exercises' : 'learn';
this.vocabTrainerActive = Boolean(parsedState.vocabTrainerActive);
this.vocabTrainerMode = parsedState.vocabTrainerMode === 'typing' ? 'typing' : 'multiple_choice';
this.vocabTrainerAutoSwitchedToTyping = Boolean(parsedState.vocabTrainerAutoSwitchedToTyping);
this.vocabTrainerCorrect = Math.max(0, Number(parsedState.vocabTrainerCorrect) || 0);
this.vocabTrainerWrong = Math.max(0, Number(parsedState.vocabTrainerWrong) || 0);
this.vocabTrainerTotalAttempts = Math.max(0, Number(parsedState.vocabTrainerTotalAttempts) || 0);
this.vocabTrainerStats = parsedState.vocabTrainerStats && typeof parsedState.vocabTrainerStats === 'object'
? parsedState.vocabTrainerStats
: {};
this.vocabTrainerCurrentAttempts = Math.max(0, Number(parsedState.vocabTrainerCurrentAttempts) || 0);
this.vocabTrainerReviewAttempts = Math.max(0, Number(parsedState.vocabTrainerReviewAttempts) || 0);
this.exerciseRetryPending = Boolean(parsedState.exerciseRetryPending);
this.exerciseRetryPendingSinceAttempts = Math.max(0, Number(parsedState.exerciseRetryPendingSinceAttempts) || 0);
const maxIdx = Math.max(0, this.scrambledChapterExamExercises.length - 1);
this.exerciseSequentialIndex = Math.max(0, Math.min(maxIdx, Number(parsedState.exerciseSequentialIndex) || 0));
this.vocabTrainerMixedPool = this._buildMixedPool();
const knownRepeatKeys = new Set([...this.trainableLessonVocab, ...this.vocabTrainerMixedPool].map((entry) => this.getVocabKey(entry)));
this.vocabTrainerRepeatQueue = this.normalizeRepeatQueue(parsedState.vocabTrainerRepeatQueue)
.filter((entry) => knownRepeatKeys.has(entry.key));
this.vocabTrainerMixedAttempts = 0;
this.vocabTrainerPhase = this.hasPreviousVocab && this.currentReviewShare > 0 ? 'mixed' : 'current';
this.currentVocabQuestion = null;
this.vocabTrainerChoiceOptions = [];
this.vocabTrainerAnswer = '';
this.vocabTrainerSelectedChoice = null;
this.vocabTrainerAnswered = false;
this.vocabTrainerLastCorrect = false;
this.vocabTrainerDirection = 'L2R';
this.vocabTrainerPool = this.vocabTrainerMode === 'typing'
? [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool]
: [...this.trainableLessonVocab];
this.updateExerciseUnlockState();
if (this.$route.query.assistant || this.$route.query.tab === 'learn') {
this.activeTab = 'learn';
} else if (this.$route.query.tab === 'exercises' && this.canAccessExercises) {
this.activeTab = 'exercises';
} else if (this.activeTab === 'exercises' && !this.canAccessExercises) {
this.activeTab = 'learn';
}
this.syncExerciseSequentialIndexToFirstUnanswered();
this.lessonStatePersistenceReady = true;
if (this.vocabTrainerActive && this.vocabTrainerPool.length > 0) {
this.$nextTick(() => {
if (this.vocabTrainerActive && !this.currentVocabQuestion) {
this.nextVocabQuestion();
}
});
}
this.persistLessonState();
},
normalizeLessonVocabTerm(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/^[.,!?;:]+|[.,!?;:]+$/g, '')
.trim();
},
lessonVocabWordCount(value) {
return String(value || '')
.trim()
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
.filter(Boolean)
.length;
},
looksLikeLessonVocabFragmentMismatch(left, right) {
const leftWords = this.lessonVocabWordCount(left);
const rightWords = this.lessonVocabWordCount(right);
const leftText = String(left || '').trim();
const rightText = String(right || '').trim();
const leftLooksSentence = leftWords >= 3 || /[?!.].+\S/.test(leftText);
const rightLooksSentence = rightWords >= 3 || /[?!.].+\S/.test(rightText);
const leftLooksShortFragment = leftWords <= 1 && leftText.length <= 12;
const rightLooksShortFragment = rightWords <= 1 && rightText.length <= 12;
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
},
isTrainableLessonVocabPair(learning, reference) {
const l = String(learning || '').trim();
const r = String(reference || '').trim();
if (!l || !r || this.normalizeLessonVocabTerm(l) === this.normalizeLessonVocabTerm(r)) {
return false;
}
return !this.looksLikeLessonVocabFragmentMismatch(l, r);
},
normalizeCorePatternEntry(p) {
if (p && typeof p === 'object' && p.target) {
return {
target: String(p.target).trim(),
gloss: String(p.gloss || '').trim()
};
}
const s = String(p || '').trim();
if (!s) return null;
const i = s.indexOf('|');
if (i !== -1) {
return {
target: s.slice(0, i).trim(),
gloss: s.slice(i + 1).trim()
};
}
return { target: s, gloss: '' };
},
corePatternToDisplayString(p) {
const n = this.normalizeCorePatternEntry(p);
if (!n) return '';
return n.gloss ? `${n.target} (${n.gloss})` : n.target;
},
_deterministicShuffle(arr, seed) {
const out = arr.slice();
let s = Number(seed) || 1;
const rnd = () => {
s = (s * 1103515245 + 12345) & 0x7fffffff;
return s / 0x7fffffff;
};
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
},
exercisePanelDisplayNumber(panelIndex) {
if (this.sequentialPanelActive) {
return this.exerciseSequentialIndex + 1;
}
return panelIndex + 1;
},
stepExercisePanel(delta) {
const list = this.scrambledChapterExamExercises;
if (!list.length) return;
const next = this.exerciseSequentialIndex + delta;
if (next < 0 || next >= list.length) return;
this.exerciseSequentialIndex = next;
this.persistLessonState();
},
/**
* Kapitel-Prüfung (eine Frage pro Schritt): Wenn gespeicherte Antworten existieren, aber der
* Index noch auf einer schon beantworteten Frage steht, zur ersten noch offenen Frage springen.
*/
syncExerciseSequentialIndexToFirstUnanswered() {
const list = this.scrambledChapterExamExercises;
if (!list.length || !this.sequentialPanelActive) {
return;
}
const firstUnanswered = list.findIndex((exercise) => !this.exerciseResults[exercise.id]);
if (firstUnanswered === -1) {
return;
}
this.exerciseSequentialIndex = firstUnanswered;
},
confirmExerciseReinforcement() {
this.showExerciseReinforcementDialog = false;
if (this.exerciseReinforcementPrepMode) {
this.lessonPrepStage = 0;
this.lessonPrepIndex = 0;
} else {
this.exerciseRetryPending = true;
this.exerciseRetryPendingSinceAttempts = this.vocabTrainerTotalAttempts;
}
this.exerciseReinforcementPrepMode = false;
this.exerciseReinforcementCorrectAnswer = '';
this.exerciseReinforcementMessage = '';
this.activeTab = 'learn';
this.$nextTick(() => {
const scrollEl = document.querySelector('.app-content__scroll.contentscroll');
if (scrollEl) scrollEl.scrollTop = 0;
else window.scrollTo(0, 0);
});
this.persistLessonState();
},
closeExerciseReinforcementDialog() {
this.showExerciseReinforcementDialog = false;
this.exerciseReinforcementPrepMode = false;
this.exerciseReinforcementCorrectAnswer = '';
this.exerciseReinforcementMessage = '';
},
closeChapterExamFailedDialog() {
this.showChapterExamFailedDialog = false;
},
formatExerciseAnswerForReview(exercise, rawAnswer) {
if (rawAnswer === null || rawAnswer === undefined) return '';
const type = this.getExerciseType(exercise);
if (type === 'multiple_choice') {
const options = this.getOptions(exercise);
const idx = Number(rawAnswer);
if (Number.isInteger(idx) && idx >= 0 && idx < options.length) {
return String(options[idx] ?? '');
}
}
if (Array.isArray(rawAnswer)) {
return rawAnswer
.map((v) => String(v || '').trim())
.filter(Boolean)
.join(' | ');
}
return String(rawAnswer || '').trim();
},
buildChapterExamFailedDetails() {
const all = this.effectiveExercises;
if (!Array.isArray(all) || all.length === 0) return [];
const out = [];
all.forEach((exercise) => {
const result = this.exerciseResults[exercise.id];
if (!result || result.correct) return;
const question = String(this.getQuestionText(exercise) || exercise.title || '').trim();
const userAnswerRaw = this.exerciseAnswers[exercise.id];
out.push({
id: exercise.id,
question,
userAnswer: this.formatExerciseAnswerForReview(exercise, userAnswerRaw),
correctAnswer: String(result.correctAnswer || '').trim()
});
});
return out;
},
clearChapterExamAttemptState() {
const all = this.effectiveExercises;
if (!all.length) return;
const results = { ...this.exerciseResults };
const answers = { ...this.exerciseAnswers };
all.forEach((e) => {
delete results[e.id];
delete answers[e.id];
});
this.exerciseResults = results;
this.exerciseAnswers = answers;
this.exerciseSequentialIndex = 0;
this.buildMcRandomizedOptions();
},
handleChapterExamFailed(score) {
this.chapterExamFailedDetails = this.buildChapterExamFailedDetails();
this.exerciseRetryPending = true;
this.exerciseRetryPendingSinceAttempts = this.vocabTrainerTotalAttempts;
this.clearChapterExamAttemptState();
this.chapterExamFailedScore = score;
this.showChapterExamFailedDialog = true;
this.activeTab = 'learn';
this.$nextTick(() => {
const scrollEl = document.querySelector('.app-content__scroll.contentscroll');
if (scrollEl) scrollEl.scrollTop = 0;
else window.scrollTo(0, 0);
});
this.persistLessonState();
},
finalizeChapterExamIfComplete() {
const allExercises = this.effectiveExercises;
if (!this.lesson || !allExercises.length) return;
const allAnswered = allExercises.every((exercise) => Boolean(this.exerciseResults[exercise.id]));
if (!allAnswered) return;
const correctExercises = allExercises.filter((exercise) => this.exerciseResults[exercise.id]?.correct).length;
const score = Math.round((correctExercises / allExercises.length) * 100);
const target = this.exerciseTargetScore;
if (score >= target && !this.exerciseNeedsReinforcement) {
void this.checkLessonCompletion();
return;
}
this.handleChapterExamFailed(score);
},
afterChapterExamAnswerSubmitted(exerciseId) {
this.$nextTick(() => {
if (this.sequentialPanelActive) {
const list = this.scrambledChapterExamExercises;
const idx = list.findIndex((e) => e.id === exerciseId);
const result = this.exerciseResults[exerciseId];
// Nur bei richtiger Antwort automatisch weiter: Bei Fehler bleibt die Karte sichtbar,
// damit „Falsch“ und die Musterlösung angezeigt werden; Weiter geht es per Nav-Button.
if (
idx >= 0
&& idx < list.length - 1
&& result
&& result.correct
) {
this.exerciseSequentialIndex = idx + 1;
}
}
this.persistLessonState();
this.finalizeChapterExamIfComplete();
});
},
advancePrepPass() {
if (this.lessonPrepStage >= 2 || this.prepItems.length === 0) {
return;
}
if (!this.isLastPrepItemInPass) {
this.lessonPrepIndex += 1;
return;
}
this.lessonPrepStage += 1;
this.lessonPrepIndex = 0;
},
openExercisesTab() {
if (!this.canAccessExercises) {
this.activeTab = 'learn';
this.showErrorDialog = true;
this.errorMessage = this.exerciseUnlockHint;
return;
}
this.activeTab = 'exercises';
this.$nextTick(() => {
this.syncExerciseSequentialIndexToFirstUnanswered();
const scrollEl = document.querySelector('.app-content__scroll.contentscroll');
if (scrollEl) {
scrollEl.scrollTop = 0;
} else {
window.scrollTo(0, 0);
}
});
},
updateExerciseUnlockState() {
if (this.exerciseRetryPending && this.exerciseRetryRemainingAttempts <= 0) {
this.exerciseRetryPending = false;
}
if (this.exercisePreparationCompleted) {
return;
}
if (!this.hasExercises) {
this.exercisePreparationCompleted = true;
return;
}
const minimumAttempts = this.trainerExerciseUnlockAttempts;
const successRate = this.vocabTrainerTotalAttempts > 0
? (this.vocabTrainerCorrect / this.vocabTrainerTotalAttempts) * 100
: 0;
const currentLessonReady = this.vocabTrainerCurrentAttempts >= this.trainerNewFocusTarget;
if (
currentLessonReady
&& this.vocabTrainerTotalAttempts >= minimumAttempts
&& successRate >= EXERCISE_UNLOCK_MIN_SUCCESS_PERCENT
) {
this.exercisePreparationCompleted = true;
}
},
_extractVocabFromExercises(exercises) {
// Sicherstellen, dass exercises ein Array ist
if (!exercises) {
console.warn('[_extractVocabFromExercises] exercises ist null/undefined:', exercises);
return [];
}
// Konvertiere Vue Proxy oder Objekt zu Array
let exercisesArray = exercises;
// Prüfe ob es bereits ein Array ist (auch Vue Proxy-Arrays)
const isArrayLike = exercises.length !== undefined && typeof exercises.length === 'number';
if (!Array.isArray(exercises) && isArrayLike) {
debugLog('[_extractVocabFromExercises] exercises ist kein Array, aber array-like, konvertiere...');
// Versuche Array.from (funktioniert mit iterierbaren Objekten und Array-like Objekten)
try {
exercisesArray = Array.from(exercises);
} catch (e) {
// Fallback: Manuelle Konvertierung
try {
exercisesArray = [];
for (let i = 0; i < exercises.length; i++) {
exercisesArray.push(exercises[i]);
}
} catch (e2) {
console.error('[_extractVocabFromExercises] Kann exercises nicht zu Array konvertieren:', exercises, e2);
return [];
}
}
} else if (!Array.isArray(exercises)) {
console.error('[_extractVocabFromExercises] exercises ist weder Array noch array-like:', exercises);
return [];
}
const vocabMap = new Map();
exercisesArray.forEach((exercise, idx) => {
try {
debugLog(`[importantVocab] Verarbeite Übung ${idx + 1}:`, exercise.title);
// Extrahiere aus questionData
const qData = this.getQuestionData(exercise);
const aData = this.getAnswerData(exercise);
debugLog(`[importantVocab] qData:`, qData);
debugLog(`[importantVocab] aData:`, aData);
if (qData && aData) {
// Für Multiple Choice: Extrahiere Optionen und richtige Antwort
if (this.getExerciseType(exercise) === 'multiple_choice') {
const options = qData.options || [];
const correctIndex = aData.correctAnswer !== undefined ? aData.correctAnswer : (aData.correct || 0);
const correctAnswer = options[correctIndex] || '';
debugLog(`[importantVocab] Multiple Choice - options:`, options, `correctIndex:`, correctIndex, `correctAnswer:`, correctAnswer);
if (correctAnswer) {
// Versuche die Frage zu analysieren (z.B. "Wie sagt man 'X' auf Bisaya?" oder "Was bedeutet 'X'?")
const question = qData.question || qData.text || '';
debugLog(`[importantVocab] Frage:`, question);
// Pattern 1: Muttersprache → Zielsprache (MC: richtige Option = Zielsprache)
let match = question.match(/Wie sagt man ['"]([^'"]+)['"]/i);
if (!match) match = question.match(/Wie heißt ['"]([^'"]+)['"]/i);
if (!match) match = question.match(/How do you say ['"]([^'"]+)['"]/i);
if (match) {
const nativeWord = match[1]; // Das Wort in der Muttersprache
// Nur hinzufügen, wenn Muttersprache und Bisaya unterschiedlich sind (verhindert "ko" -> "ko")
if (nativeWord && correctAnswer && nativeWord.trim() !== correctAnswer.trim()) {
debugLog(`[importantVocab] Pattern 1 gefunden - Muttersprache:`, nativeWord, `Bisaya:`, correctAnswer);
// learning = Muttersprache (was man lernt), reference = Bisaya (Zielsprache)
vocabMap.set(`${nativeWord}-${correctAnswer}`, { learning: nativeWord, reference: correctAnswer });
} else {
debugLog(`[importantVocab] Pattern 1 übersprungen - Muttersprache und Bisaya sind gleich:`, nativeWord, correctAnswer);
}
} else {
// Pattern 2: Zielsprache im Satz → richtige Option = Muttersprache
match = question.match(/Was bedeutet ['"]([^'"]+)['"]/i);
if (!match) match = question.match(/Was heißt ['"]([^'"]+)['"]/i);
if (match) {
const bisayaWord = match[1];
// Nur hinzufügen, wenn Bisaya und Muttersprache unterschiedlich sind (verhindert "ko" -> "ko")
if (bisayaWord && correctAnswer && bisayaWord.trim() !== correctAnswer.trim()) {
debugLog(`[importantVocab] Pattern 2 gefunden - Bisaya:`, bisayaWord, `Muttersprache:`, correctAnswer);
// learning = Muttersprache (was man lernt), reference = Bisaya (Zielsprache)
vocabMap.set(`${correctAnswer}-${bisayaWord}`, { learning: correctAnswer, reference: bisayaWord });
} else {
debugLog(`[importantVocab] Pattern 2 übersprungen - Bisaya und Muttersprache sind gleich:`, bisayaWord, correctAnswer);
}
} else {
debugLog(`[importantVocab] Kein Pattern gefunden, Überspringe diese Übung`);
// Überspringe, wenn wir die Richtung nicht erkennen können
}
}
}
}
// Für Gap Fill: Extrahiere richtige Antworten
if (this.getExerciseType(exercise) === 'gap_fill') {
const answers = aData.answers || (aData.correct ? (Array.isArray(aData.correct) ? aData.correct : [aData.correct]) : []);
debugLog(`[importantVocab] Gap Fill - answers:`, answers);
if (answers.length > 0) {
// Versuche aus dem Text Kontext zu extrahieren
// Gap Fill hat normalerweise Format: "{gap} (Muttersprache) | {gap} (Muttersprache) | ..."
const text = qData.text || '';
// Extrahiere Wörter in Klammern als Muttersprache
const matches = text.matchAll(/\(([^)]+)\)/g);
const nativeWords = Array.from(matches, m => m[1]);
debugLog(`[importantVocab] Gap Fill - text:`, text, `nativeWords:`, nativeWords);
// Nur extrahieren, wenn Muttersprache-Hinweise (Klammern) vorhanden sind
if (nativeWords.length > 0) {
// Wenn möglich: rekonstruiere die vollständige Phrase um die Lücke herum.
// Beispiel: "{gap} ka mubalik? (Bitte wiederholen)" + "Palihug" => "Palihug ka mubalik?"
const stripHints = (s) => String(s || '')
.replace(/\([^)]*\)/g, '')
.replace(/\[[^\]]*\]/g, '')
.replace(/[^]*/g, '');
const stopChars = [',', '.', '|', ';', ':', '\n'];
const lastStopIndex = (s) => Math.max(...stopChars.map((c) => s.lastIndexOf(c)));
const firstStopIndex = (s) => {
const indices = stopChars
.map((c) => s.indexOf(c))
.filter((idx) => idx >= 0);
return indices.length ? Math.min(...indices) : -1;
};
const parts = String(text).split('{gap}');
answers.forEach((answer, index) => {
if (answer && answer.trim()) {
const nativeWord = nativeWords[index];
const nativeText = String(nativeWord || '').trim();
const answerText = String(answer || '').trim();
const nativeWordCount = nativeText.split(/\s+/).filter(Boolean).length;
const answerWordCount = answerText.split(/\s+/).filter(Boolean).length;
const nativeLooksSentence = /[?.!]/.test(nativeText) || nativeWordCount >= 4;
const answerLooksShortToken = answerWordCount <= 2;
const likelyFragmentMismatch = nativeLooksSentence && answerLooksShortToken;
// Prefer full phrase reconstruction when we have matching "{gap}" placeholders.
let reconstructed = '';
if (parts.length === answers.length + 1) {
const leftRaw = stripHints(parts[index] || '');
const rightRaw = stripHints(parts[index + 1] || '');
const leftCut = lastStopIndex(leftRaw);
const leftTail = leftCut >= 0 ? leftRaw.slice(leftCut + 1) : leftRaw;
const rightCut = firstStopIndex(rightRaw);
const rightHead = rightCut >= 0 ? rightRaw.slice(0, rightCut) : rightRaw;
reconstructed = `${leftTail}${answerText}${rightHead}`
.replace(/\s+/g, ' ')
.trim();
}
const reconstructedIsUseful =
reconstructed &&
reconstructed !== answerText &&
(reconstructed.includes(' ') || /[?!]/.test(reconstructed));
if (nativeText && (reconstructedIsUseful || answerText) && nativeText !== answerText && !likelyFragmentMismatch) {
const targetText = reconstructedIsUseful ? reconstructed : answerText;
// Sicherheitsfilter: wir wollen primär echte Phrasen, nicht einzelne Tokens als "Vokabelkarte".
const targetWordCount = targetText.split(/\s+/).filter(Boolean).length;
if (targetWordCount < 2 && !/[?!]/.test(targetText)) {
debugLog(`[importantVocab] Gap Fill übersprungen - Ziel wirkt zu kurz:`, nativeText, targetText);
return;
}
// Muttersprache (learning) -> Zielsprache (reference)
vocabMap.set(`${nativeText}-${targetText}`, { learning: nativeText, reference: targetText });
debugLog(`[importantVocab] Gap Fill extrahiert - Muttersprache:`, nativeText, `Bisaya:`, targetText);
} else {
debugLog(`[importantVocab] Gap Fill übersprungen - Satz/Fragment-Mismatch oder gleich:`, nativeText, answerText, reconstructed);
}
}
});
} else {
debugLog(`[importantVocab] Gap Fill übersprungen - keine Muttersprache-Hinweise (Klammern) gefunden`);
}
}
}
}
} catch (e) {
console.warn('Fehler beim Extrahieren von Vokabeln aus Übung:', e, exercise);
}
});
const result = Array.from(vocabMap.values());
debugLog(`[_extractVocabFromExercises] Ergebnis:`, result.length, 'Vokabeln');
return result;
},
async loadLesson() {
// Verhindere mehrfaches Laden
if (this.loading) {
debugLog('[VocabLessonView] loadLesson übersprungen - bereits am Laden');
return;
}
debugLog('[VocabLessonView] loadLesson gestartet für lessonId:', this.lessonId);
this.loading = true;
this.lessonStatePersistenceReady = false;
if (this.lessonStateSaveTimer) {
window.clearTimeout(this.lessonStateSaveTimer);
this.lessonStateSaveTimer = null;
}
this.pendingLessonStatePayload = null;
// Setze Antworten und Ergebnisse zurück
this.exerciseAnswers = {};
this.exerciseResults = {};
this.assistantMessages = [];
this.assistantInput = '';
this.assistantError = '';
this.exerciseRetryPending = false;
this.exerciseRetryPendingSinceAttempts = 0;
this.exerciseSequentialIndex = 0;
this.dailyEnoughBannerDismissed = false;
this.vocabOverviewPage = 1;
this.exercisePreparationCompleted = false;
this.lessonPrepStage = 0;
this.lessonPrepIndex = 0;
this.vocabTrainerActive = false;
this.vocabTrainerPool = [];
this.vocabTrainerMixedPool = [];
this.vocabTrainerRepeatQueue = [];
this.vocabTrainerPhase = 'current';
this.vocabTrainerCurrentAttempts = 0;
this.vocabTrainerReviewAttempts = 0;
this.courseProgressList = [];
this.courseLanguageName = '';
this.courseNativeLanguageName = '';
this.distractorPool = { target: [], native: [] };
this.mcRandomizedOptions = {};
// Reset Flags
this.isCheckingLessonCompletion = false;
this.isNavigatingToNext = false;
// Prüfe ob 'tab' Query-Parameter vorhanden ist (für Navigation zur nächsten Lektion)
const tabParam = this.$route.query.tab;
if (tabParam === 'learn') {
this.activeTab = 'learn';
}
if (tabParam === 'exercises') {
this.activeTab = 'learn';
}
if (this.$route.query.assistant) {
this.activeTab = 'learn';
}
try {
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}`);
this.lesson = res.data;
await this.loadCourseLanguageNames();
await this.loadCourseProgressForBoost();
debugLog('[VocabLessonView] Geladene Lektion:', this.lesson?.id, this.lesson?.title);
if (this.$route.query.assistant) {
this.$nextTick(() => {
this.focusAssistantCard();
});
}
try {
const poolRes = await apiClient.get(`/api/vocab/courses/${this.courseId}/distractor-pool`, {
params: { beforeLessonId: this.lessonId }
});
this.distractorPool = poolRes.data || { target: [], native: [] };
} catch (poolErr) {
console.warn('[VocabLessonView] Distraktor-Pool nicht geladen:', poolErr);
this.distractorPool = { target: [], native: [] };
}
await this.$nextTick();
let exercises = this.effectiveExercises;
if (!exercises || exercises.length === 0) {
debugLog('[VocabLessonView] Lade Übungen separat...');
await this.loadGrammarExercises();
exercises = this.effectiveExercises;
}
if (exercises && exercises.length > 0) {
debugLog('[VocabLessonView] Übungen für Kapitel-Prüfung:', exercises.length);
this.initializeExercises(exercises);
this.buildMcRandomizedOptions();
}
this.restoreLessonState();
debugLog('[VocabLessonView] loadLesson abgeschlossen');
} catch (e) {
console.error('[VocabLessonView] Fehler beim Laden der Lektion:', e);
this.lessonStatePersistenceReady = true;
} finally {
this.loading = false;
}
},
async loadCourseProgressForBoost() {
try {
const { data } = await apiClient.get(`/api/vocab/courses/${this.courseId}/progress`);
this.courseProgressList = Array.isArray(data) ? data : [];
} catch (e) {
this.courseProgressList = [];
}
},
async loadCourseLanguageNames() {
try {
const { data } = await apiClient.get(`/api/vocab/courses/${this.courseId}`);
this.courseLanguageName = String(data?.languageName || '').trim();
this.courseNativeLanguageName = String(data?.nativeLanguageName || '').trim();
} catch (e) {
this.courseLanguageName = '';
this.courseNativeLanguageName = '';
}
},
focusAssistantCard() {
const target = this.$refs.assistantCard;
if (!target || typeof target.scrollIntoView !== 'function') {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
this.isAssistantFocused = true;
window.setTimeout(() => {
this.isAssistantFocused = false;
}, 2200);
},
async loadAssistantSettings() {
this.assistantLoading = true;
try {
const { data } = await apiClient.get('/api/settings/llm');
this.assistantSettings = data;
} catch (e) {
this.assistantSettings = null;
} finally {
this.assistantLoading = false;
}
},
openLanguageAssistantSettings() {
this.$router.push('/settings/language-assistant');
},
buildAssistantPrompt(preset) {
const lessonTitle = this.lesson?.title || this.$t('socialnetwork.vocab.courses.thisLesson');
const firstPattern = this.lessonDidactics.corePatterns?.[0];
const firstPatternStr = firstPattern ? this.corePatternToDisplayString(firstPattern) : '';
const firstGrammar = this.lessonDidactics.grammarFocus?.[0]?.text;
if (preset === 'explain') {
return `${this.$t('socialnetwork.vocab.courses.languageAssistantPresetExplainStart')} "${lessonTitle}". ${firstPatternStr ? `${this.$t('socialnetwork.vocab.courses.languageAssistantPatternHint')} ${firstPatternStr}.` : ''} ${firstGrammar || ''}`.trim();
}
if (preset === 'correct') {
return this.$t('socialnetwork.vocab.courses.languageAssistantPresetCorrectStart', { lesson: lessonTitle });
}
return this.$t('socialnetwork.vocab.courses.languageAssistantPresetPracticeStart', { lesson: lessonTitle });
},
async sendPresetPrompt(preset) {
this.assistantMode = preset === 'explain' ? 'explain' : (preset === 'correct' ? 'correct' : 'practice');
await this.sendAssistantMessage(this.buildAssistantPrompt(preset));
},
async sendAssistantMessage(customMessage = null) {
const message = String(customMessage || this.assistantInput || '').trim();
if (!message || this.assistantSubmitting || !this.assistantAvailable) {
return;
}
this.assistantError = '';
this.assistantSubmitting = true;
this.assistantMessages.push({ role: 'user', content: message });
if (!customMessage) {
this.assistantInput = '';
}
try {
const history = this.assistantMessages.slice(0, -1).slice(-8);
const { data } = await apiClient.post(`/api/vocab/lessons/${this.lessonId}/assistant`, {
message,
mode: this.assistantMode,
history
});
this.assistantMessages.push({
role: 'assistant',
content: data.reply
});
} catch (e) {
this.assistantError = e.response?.data?.error || e.message || this.$t('socialnetwork.vocab.courses.languageAssistantError');
} finally {
this.assistantSubmitting = false;
}
},
initializeExercises(exercises) {
// Initialisiere Antwort-Arrays für Gap Fill Übungen
exercises.forEach(exercise => {
const exerciseType = this.getExerciseType(exercise);
if (exerciseType === 'gap_fill') {
const gapCount = this.getGapCount(exercise);
this.exerciseAnswers[exercise.id] = new Array(gapCount).fill('');
} else {
this.exerciseAnswers[exercise.id] = '';
}
this.exerciseResults[exercise.id] = null;
});
},
async loadGrammarExercises() {
try {
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}/grammar-exercises`);
const exercises = res.data || [];
this.lesson.grammarExercises = exercises;
} catch (e) {
console.error('Konnte Grammatik-Übungen nicht laden:', e);
this.lesson.grammarExercises = [];
}
},
getExerciseType(exercise) {
// Hole den Typ aus questionData oder exerciseType
if (exercise.questionData) {
const qData = typeof exercise.questionData === 'string'
? JSON.parse(exercise.questionData)
: exercise.questionData;
return qData.type;
}
// Fallback: Prüfe exerciseTypeId
const typeMap = {
1: 'gap_fill',
2: 'multiple_choice',
3: 'sentence_building',
4: 'transformation',
5: 'conjugation',
6: 'declension',
7: 'reading_aloud',
8: 'speaking_from_memory',
9: 'dialog_completion',
10: 'situational_response',
11: 'pattern_drill'
};
return typeMap[exercise.exerciseTypeId] || 'unknown';
},
getLessonTypeLabel(lessonType) {
const labels = {
vocab: this.$t('socialnetwork.vocab.courses.lessonTypeVocab'),
grammar: this.$t('socialnetwork.vocab.courses.lessonTypeGrammar'),
conversation: this.$t('socialnetwork.vocab.courses.lessonTypeConversation'),
culture: this.$t('socialnetwork.vocab.courses.lessonTypeCulture'),
review: this.$t('socialnetwork.vocab.courses.lessonTypeReview'),
vocab_review: this.$t('socialnetwork.vocab.courses.lessonTypeReview')
};
return labels[lessonType] || lessonType || this.$t('socialnetwork.vocab.courses.lessonTypeVocab');
},
getPhaseLabel(phaseLabel) {
switch (phaseLabel) {
case 'quickstart':
return this.$t('socialnetwork.vocab.courses.phaseQuickstart');
case 'daily_life':
return this.$t('socialnetwork.vocab.courses.phaseDailyLife');
case 'stabilization':
return this.$t('socialnetwork.vocab.courses.phaseStabilization');
default:
return this.$t('socialnetwork.vocab.courses.phaseDefault');
}
},
getDidacticModeLabel(didacticMode) {
switch (didacticMode) {
case 'core_input':
return this.$t('socialnetwork.vocab.courses.didacticModeCoreInput');
case 'guided_dialogue':
return this.$t('socialnetwork.vocab.courses.didacticModeGuidedDialogue');
case 'contrast_training':
return this.$t('socialnetwork.vocab.courses.didacticModeContrastTraining');
case 'pattern_drill':
return this.$t('socialnetwork.vocab.courses.didacticModePatternDrill');
case 'real_life_scenario':
return this.$t('socialnetwork.vocab.courses.didacticModeRealLifeScenario');
case 'intensive_review':
return this.$t('socialnetwork.vocab.courses.didacticModeIntensiveReview');
case 'checkpoint':
return this.$t('socialnetwork.vocab.courses.didacticModeCheckpoint');
default:
return this.$t('socialnetwork.vocab.courses.didacticModeFocusDefault');
}
},
getReviewStageLabel(progress) {
const stage = Number(progress?.reviewStage || 0);
if (stage === 0) return this.$t('socialnetwork.vocab.courses.reviewStageDay1');
if (stage === 1) return this.$t('socialnetwork.vocab.courses.reviewStageDay3');
if (stage === 2) return this.$t('socialnetwork.vocab.courses.reviewStageDay7');
if (stage >= 3) return this.$t('socialnetwork.vocab.courses.reviewStageCompleted');
return '';
},
formatTargetMinutes(targetMinutes) {
const minutes = Number(targetMinutes);
if (!minutes) {
return this.$t('socialnetwork.vocab.courses.durationFlexible');
}
return this.$t('socialnetwork.vocab.courses.durationMinutes', { minutes });
},
formatLessonReviewDue(reviewNextDueAt) {
if (!reviewNextDueAt) {
return this.$t('socialnetwork.vocab.courses.reviewTimeNow');
}
const dueTimestamp = new Date(reviewNextDueAt).getTime();
if (!Number.isFinite(dueTimestamp)) {
return this.$t('socialnetwork.vocab.courses.reviewTimeNow');
}
const diffMs = dueTimestamp - Date.now();
if (diffMs > 0) {
const untilDays = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
if (untilDays <= 1) {
return this.$t('socialnetwork.vocab.courses.reviewTimeTomorrow');
}
return this.$t('socialnetwork.vocab.courses.reviewTimeInDays', { count: untilDays });
}
const overdueDays = Math.floor((Date.now() - dueTimestamp) / (24 * 60 * 60 * 1000));
if (overdueDays <= 0) {
return this.$t('socialnetwork.vocab.courses.timeToday');
}
if (overdueDays === 1) {
return this.$t('socialnetwork.vocab.courses.timeSinceOneDay');
}
return this.$t('socialnetwork.vocab.courses.timeSinceDays', { count: overdueDays });
},
getQuestionData(exercise) {
if (!exercise.questionData) return null;
return typeof exercise.questionData === 'string'
? JSON.parse(exercise.questionData)
: exercise.questionData;
},
getAnswerData(exercise) {
if (!exercise.answerData) return null;
return typeof exercise.answerData === 'string'
? JSON.parse(exercise.answerData)
: exercise.answerData;
},
_shuffleArray(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
},
/**
* Welche Sprache haben die richtigen MC-Antworten? (Backend: vocabService._resolveMcAnswerSide)
* Nur über questionData.answerLanguage ('target'|'native') oder answerLanguageId (1|2).
* Ohne diese Felder: 'unknown' — dann kein Distraktor-Randomize (bis Inhalte annotiert sind).
*/
_resolveMcAnswerSide(questionData) {
const qd = questionData || {};
const raw = qd.answerLanguage;
if (typeof raw === 'string') {
const s = raw.trim().toLowerCase();
if (s === 'target' || s === 'learning' || s === 'l2') return 'target';
if (s === 'native' || s === 'l1') return 'native';
}
const id = qd.answerLanguageId;
if (id === 1 || id === '1') return 'target';
if (id === 2 || id === '2') return 'native';
return 'unknown';
},
/**
* Zufällige Distraktoren aus gelernten Vokabeln (vorherige Lektionen), gleiche Sprache wie die richtigen Optionen.
* Deaktivierbar pro Übung: questionData.randomizeDistractors === false
*/
randomizeMcOptionsIfPossible(exercise) {
const q = this.getQuestionData(exercise);
const a = this.getAnswerData(exercise);
if (!q || !a || q.type !== 'multiple_choice' || q.randomizeDistractors === false) return null;
const side = this._resolveMcAnswerSide(q);
if (side === 'unknown') return null;
const options = q.options || [];
let correctIndices = [];
if (a.correctAnswer !== undefined) {
correctIndices = Array.isArray(a.correctAnswer) ? a.correctAnswer.map(Number) : [Number(a.correctAnswer)];
} else if (a.correct !== undefined) {
correctIndices = Array.isArray(a.correct) ? a.correct.map(Number) : [Number(a.correct)];
}
const correctTexts = correctIndices.map((i) => options[i]).filter((t) => t !== undefined && t !== null);
if (!correctTexts.length) return null;
const totalSlots = options.length;
const need = totalSlots - correctTexts.length;
if (need <= 0) return null;
const norm = (s) => this.normalizeComparableText(s);
const correctSet = new Set(correctTexts.map(norm));
const poolRaw = side === 'target' ? this.distractorPool.target : this.distractorPool.native;
let poolFiltered = poolRaw.filter((w) => w && !correctSet.has(norm(w)));
poolFiltered = this._shuffleArray(poolFiltered);
let picked = poolFiltered.slice(0, need);
if (picked.length < need) {
const wrongFromDb = options.filter((_, i) => !correctIndices.includes(i));
const shuffledWrong = this._shuffleArray(wrongFromDb);
for (const w of shuffledWrong) {
if (picked.length >= need) break;
if (!picked.some((p) => norm(p) === norm(w))) picked.push(w);
}
}
if (picked.length < need) return null;
const all = this._shuffleArray([...correctTexts, ...picked]);
return { options: all, useTextAnswer: true };
},
/**
* Wenn keine Distraktor-Ersetzung läuft (z. B. answerLanguage fehlt), nur die gegebenen
* Optionen mischen. Richtige Antwort bleibt über Optionstext prüfbar (useTextAnswer).
*/
shuffleMcOptionsDisplayOrder(exercise) {
const q = this.getQuestionData(exercise);
if (!q || q.type !== 'multiple_choice' || q.randomizeDistractors === false) {
return null;
}
const options = q.options || [];
if (options.length < 2) {
return null;
}
return {
options: this._shuffleArray(options),
useTextAnswer: true
};
},
buildMcRandomizedOptions() {
this.mcRandomizedOptions = {};
const exercises = this.effectiveExercises;
if (!exercises) return;
exercises.forEach((ex) => {
if (this.getExerciseType(ex) !== 'multiple_choice') return;
const fromPool = this.randomizeMcOptionsIfPossible(ex);
if (fromPool) {
this.mcRandomizedOptions[ex.id] = fromPool;
return;
}
const orderOnly = this.shuffleMcOptionsDisplayOrder(ex);
if (orderOnly) {
this.mcRandomizedOptions[ex.id] = orderOnly;
}
});
},
getQuestionText(exercise) {
const qData = this.getQuestionData(exercise);
if (!qData) return exercise.title;
if (qData.question) return qData.question;
// Für Transformation-Übungen: Formatiere als "Übersetze 'X' ins Bisaya"
if (qData.type === 'transformation' && qData.text) {
const sourceLang = qData.sourceLanguage || 'Deutsch';
const targetLang = qData.targetLanguage || 'Bisaya';
return `Übersetze "${qData.text}" ins ${targetLang}`;
}
if (qData.text) return qData.text;
return exercise.title;
},
getVisibleQuestionText(exercise) {
const question = String(this.getQuestionText(exercise) || '').trim();
if (!question) return '';
const title = String(exercise?.title || '').trim();
const instruction = String(exercise?.instruction || '').trim();
const qNorm = this.normalizeComparableText(question);
if (title && qNorm === this.normalizeComparableText(title)) return '';
if (instruction && qNorm === this.normalizeComparableText(instruction)) return '';
return question;
},
getOptions(exercise) {
const custom = this.mcRandomizedOptions[exercise.id];
if (custom && Array.isArray(custom.options) && custom.options.length > 0) {
return custom.options;
}
const qData = this.getQuestionData(exercise);
return qData?.options || [];
},
formatGapFill(exercise) {
const qData = this.getQuestionData(exercise);
if (!qData || !qData.text) return '';
// Ersetze {gap} mit Platzhaltern
return qData.text.replace(/\{gap\}/g, '<span class="gap">_____</span>');
},
getGapCount(exercise) {
const qData = this.getQuestionData(exercise);
if (!qData) return 0;
// Zähle {gap} im Text
const matches = qData.text?.match(/\{gap\}/g);
return matches ? matches.length : (qData.gaps || 1);
},
getGapInputRef(exerciseId, index) {
return `gap-input-${exerciseId}-${index}`;
},
onGapInputEnter(exercise, index) {
const gapCount = this.getGapCount(exercise);
// Bei mehreren Feldern: Enter springt weiter, beim letzten wird geprüft
if (gapCount > 1 && index < gapCount - 1) {
const nextRefKey = this.getGapInputRef(exercise.id, index + 1);
const nextRef = this.$refs[nextRefKey];
if (nextRef && nextRef.focus) {
nextRef.focus();
}
return;
}
// Letztes Feld (oder nur ein Feld): Antwort prüfen auslösen
this.checkAnswer(exercise.id);
},
onSingleInputEnter(exercise) {
const answer = this.exerciseAnswers[exercise.id];
if (!answer || (typeof answer === 'string' && !answer.trim())) {
return;
}
this.checkAnswer(exercise.id);
},
hasAllGapsFilled(exercise) {
const answers = this.exerciseAnswers[exercise.id];
if (!answers || !Array.isArray(answers)) return false;
const gapCount = this.getGapCount(exercise);
return answers.length === gapCount && answers.every(a => a && a.trim());
},
async checkAnswer(exerciseId) {
try {
const exercise = this.effectiveExercises.find(e => e.id === exerciseId);
if (!exercise) return;
const exerciseType = this.getExerciseType(exercise);
let answer = this.exerciseAnswers[exerciseId];
// Formatiere Antwort je nach Typ
if (exerciseType === 'gap_fill') {
// Gap Fill: Array von Antworten
if (!Array.isArray(answer)) {
answer = [answer];
}
} else if (exerciseType === 'multiple_choice') {
const ro = this.mcRandomizedOptions[exercise.id];
if (ro && ro.useTextAnswer) {
const opts = this.getOptions(exercise);
const idx = Number(answer);
const pick = opts[idx];
answer = pick !== undefined && pick !== null ? String(pick) : '';
} else {
answer = Number(answer);
}
} else if (exerciseType === 'transformation' || exerciseType === 'sentence_building' || exerciseType === 'dialog_completion' || exerciseType === 'pattern_drill') {
// Transformation: String
answer = String(answer || '').trim();
} else if (exerciseType === 'situational_response') {
answer = String(answer || '').trim();
} else if (exerciseType === 'reading_aloud' || exerciseType === 'speaking_from_memory') {
// Reading Aloud / Speaking From Memory: Verwende erkannten Text
answer = this.recognizedText[exerciseId] || String(answer || '').trim();
}
const res = await apiClient.post(`/api/vocab/grammar-exercises/${exerciseId}/check`, { answer });
this.exerciseResults[exerciseId] = res.data;
if (!res.data?.correct) {
const correctText = res.data?.correctAnswer
? String(res.data.correctAnswer)
: '';
if (this.trainableLessonVocab.length === 0 && this.prepItems.length > 0) {
this.exerciseReinforcementPrepMode = true;
this.exerciseReinforcementCorrectAnswer = correctText;
this.exerciseReinforcementMessage = this.$t('socialnetwork.vocab.courses.exercisePrepReinforcementHint');
this.showExerciseReinforcementDialog = true;
return;
}
// Kapitel-Prüfung: Lösung inline anzeigen, ohne Sofort-Dialog; nach letzter Frage ggf. Übung + Neustart
this.afterChapterExamAnswerSubmitted(exerciseId);
return;
}
this.afterChapterExamAnswerSubmitted(exerciseId);
} catch (e) {
console.error('Fehler beim Prüfen der Antwort:', e);
this.showErrorDialog = true;
this.errorMessage = e.response?.data?.error || 'Fehler beim Prüfen der Antwort';
}
},
async checkLessonCompletion() {
// Verhindere mehrfache Ausführung
if (this.isCheckingLessonCompletion || this.isNavigatingToNext) {
debugLog('[VocabLessonView] checkLessonCompletion übersprungen - bereits in Ausführung');
return;
}
// Prüfe ob alle Übungen korrekt beantwortet wurden (effectiveExercises = Kapitel-Prüfung)
const allExercises = this.effectiveExercises;
if (!this.lesson || !allExercises || allExercises.length === 0) {
debugLog('[VocabLessonView] checkLessonCompletion übersprungen - keine Lektion/Übungen');
return;
}
if (allExercises.length === 0) {
debugLog('[VocabLessonView] checkLessonCompletion übersprungen - keine Übungen');
return;
}
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);
if (passed && !this.isCheckingLessonCompletion) {
this.isCheckingLessonCompletion = true;
debugLog('[VocabLessonView] Alle Übungen abgeschlossen - starte Fortschritts-Update');
try {
debugLog('[VocabLessonView] Score berechnet:', score, '%');
// Aktualisiere Fortschritt
const lessonState = this.buildPersistedLessonState();
const { data } = await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, {
completed: score >= this.exerciseTargetScore,
score: score,
lessonState,
timeSpentMinutes: 0 // TODO: Zeit tracken
});
this.lesson.progress = data;
debugLog('[VocabLessonView] Fortschritt aktualisiert - starte Navigation');
// Weiterleitung zur nächsten Lektion
await this.navigateToNextLesson();
} catch (e) {
console.error('[VocabLessonView] Fehler beim Aktualisieren des Fortschritts:', e);
this.isCheckingLessonCompletion = false;
}
}
},
async navigateToNextLesson() {
// Verhindere mehrfache Navigation
if (this.isNavigatingToNext) {
debugLog('[VocabLessonView] Navigation bereits in Gang, überspringe...');
return;
}
this.isNavigatingToNext = true;
try {
// Lade Kurs mit allen Lektionen
const courseRes = await apiClient.get(`/api/vocab/courses/${this.courseId}`);
const course = courseRes.data;
if (!course.lessons || course.lessons.length === 0) {
debugLog('[VocabLessonView] Keine Lektionen gefunden');
this.isNavigatingToNext = false;
this.isCheckingLessonCompletion = false;
return;
}
// Finde aktuelle Lektion
const currentLessonIndex = course.lessons.findIndex(l => l.id === parseInt(this.lessonId));
if (currentLessonIndex >= 0 && currentLessonIndex < course.lessons.length - 1) {
// Nächste Lektion gefunden
const nextLesson = course.lessons[currentLessonIndex + 1];
debugLog('[VocabLessonView] Nächste Lektion gefunden:', nextLesson.id);
// Zeige Erfolgs-Meldung und leite weiter
// Verwende Dialog statt confirm
this.showNextLessonDialog = true;
this.nextLessonId = nextLesson.id;
} else {
// Letzte Lektion - zeige Abschluss-Meldung
debugLog('[VocabLessonView] Letzte Lektion erreicht');
this.showCompletionDialog = true;
this.isNavigatingToNext = false;
this.isCheckingLessonCompletion = false;
}
} catch (e) {
console.error('Fehler beim Laden der nächsten Lektion:', e);
this.isNavigatingToNext = false;
this.isCheckingLessonCompletion = false;
}
},
back() {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}`);
},
confirmNavigateToNextLesson() {
if (this.nextLessonId) {
debugLog('[VocabLessonView] Navigiere zur nächsten Lektion:', this.nextLessonId);
// Setze Flags zurück BEVOR die Navigation stattfindet
this.isNavigatingToNext = false;
this.isCheckingLessonCompletion = false;
this.showNextLessonDialog = false;
// Verwende replace statt push, um die History nicht zu belasten
// Setze activeTab auf 'learn' für die nächste Lektion via Query-Parameter
this.$router.replace(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${this.nextLessonId}?tab=learn`);
}
},
cancelNavigateToNextLesson() {
debugLog('[VocabLessonView] Navigation abgebrochen');
this.isNavigatingToNext = false;
this.isCheckingLessonCompletion = false;
this.showNextLessonDialog = false;
this.nextLessonId = null;
},
closeCompletionDialog() {
this.showCompletionDialog = false;
},
closeErrorDialog() {
this.showErrorDialog = false;
this.errorMessage = '';
},
// Vokabeltrainer-Methoden
startVocabTrainer() {
debugLog('[VocabLessonView] startVocabTrainer aufgerufen');
if (!this.trainableLessonVocab || this.trainableLessonVocab.length === 0) {
debugLog('[VocabLessonView] Keine Vokabeln vorhanden');
return;
}
if (!this.canStartVocabTrainerPrep) {
return;
}
debugLog('[VocabLessonView] Vokabeln gefunden:', this.trainableLessonVocab.length);
debugLog('[VocabLessonView] Alte Vokabeln:', this.previousVocab?.length || 0);
this.vocabTrainerActive = true;
this.vocabTrainerPool = [...this.trainableLessonVocab];
this.vocabTrainerMode = 'multiple_choice';
this.vocabTrainerAutoSwitchedToTyping = false;
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
this.vocabTrainerCurrentAttempts = 0;
this.vocabTrainerReviewAttempts = 0;
this.vocabTrainerStats = {};
this.vocabTrainerRepeatQueue = [];
// Bereite Mixed-Pool aus alten Vokabeln vor (ohne Duplikate aus aktueller Lektion)
this.vocabTrainerMixedPool = this._buildMixedPool();
this.vocabTrainerPhase = 'current';
this.vocabTrainerMixedAttempts = 0;
this.vocabTrainerPool = [...this.trainableLessonVocab];
debugLog('[VocabLessonView] Mixed-Pool:', this.vocabTrainerMixedPool.length, 'Vokabeln');
debugLog('[VocabLessonView] Rufe nextVocabQuestion auf');
this.$nextTick(() => {
this.nextVocabQuestion();
});
},
stopVocabTrainer() {
this.vocabTrainerActive = false;
this.vocabTrainerMode = 'multiple_choice';
this.vocabTrainerAutoSwitchedToTyping = false;
this.vocabTrainerPhase = 'current';
this.vocabTrainerMixedAttempts = 0;
this.vocabTrainerCurrentAttempts = 0;
this.vocabTrainerReviewAttempts = 0;
this.vocabTrainerMixedPool = [];
this.vocabTrainerRepeatQueue = [];
this.currentVocabQuestion = null;
this.vocabTrainerAnswer = '';
this.vocabTrainerSelectedChoice = null;
this.vocabTrainerAnswered = false;
},
/** Erstellt den Mixed-Pool aus vorherigen Lektions-Vokabeln (ohne Duplikate der aktuellen Lektion) */
_buildMixedPool() {
if (!this.previousVocab || this.previousVocab.length === 0) return [];
const currentKeys = new Set(this.trainableLessonVocab.map(v => this.getVocabKey(v)));
const filtered = this.previousVocab.filter(v => !currentKeys.has(this.getVocabKey(v)));
const filteredByKey = new Map(filtered.map((v) => [this.getVocabKey(v), v]));
const weakMap = new Map();
(this.courseProgressList || []).forEach((entry) => {
const lessonId = Number(entry?.lessonId);
if (!Number.isFinite(lessonId) || lessonId === Number(this.lessonId)) return;
const weakList = Array.isArray(entry?.lessonState?.reviewWeakVocab)
? entry.lessonState.reviewWeakVocab
: [];
weakList.forEach((w) => {
const learning = String(w?.learning || '').trim();
const reference = String(w?.reference || '').trim();
if (!learning || !reference) return;
const key = `${learning}|${reference}`;
if (currentKeys.has(key) || !filteredByKey.has(key)) return;
const prev = weakMap.get(key) || 0;
weakMap.set(key, prev + Math.max(1, Number(w?.wrongCount) || 1));
});
});
const boosted = [];
Array.from(weakMap.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 20)
.forEach(([key, score]) => {
const vocab = filteredByKey.get(key);
if (!vocab) return;
const weight = Math.min(4, Math.max(1, Math.round(score)));
for (let i = 0; i < weight; i++) {
boosted.push(vocab);
}
});
const boostedKeySet = new Set(Array.from(weakMap.keys()));
const rest = filtered.filter((v) => !boostedKeySet.has(this.getVocabKey(v)));
const shuffled = [...rest].sort(() => Math.random() - 0.5);
return [...boosted, ...shuffled].slice(0, 40);
},
getVocabKey(vocab) {
return `${vocab.learning}|${vocab.reference}`;
},
getVocabStats(vocab) {
const key = this.getVocabKey(vocab);
if (!this.vocabTrainerStats[key]) {
this.vocabTrainerStats[key] = { attempts: 0, correct: 0, wrong: 0 };
}
return this.vocabTrainerStats[key];
},
normalizeRepeatQueue(queue = []) {
const repeatIntervals = this.getRepeatIntervals();
if (!Array.isArray(queue)) {
return [];
}
return queue
.map((entry) => ({
key: String(entry?.key || '').trim(),
dueAfter: Math.max(0, Number(entry?.dueAfter) || 0),
stageIndex: Math.max(0, Math.min(repeatIntervals.length - 1, Number(entry?.stageIndex) || 0))
}))
.filter((entry) => entry.key);
},
getRepeatIntervals() {
const availableCount = new Set(
[...this.trainableLessonVocab, ...this.vocabTrainerMixedPool].map((vocab) => this.getVocabKey(vocab))
).size;
const maxSpacing = Math.max(1, Math.min(4, availableCount - 1));
if (maxSpacing <= 1) {
return [1, 1, 1];
}
if (maxSpacing === 2) {
return [1, 1, 2];
}
if (maxSpacing === 3) {
return [1, 2, 3];
}
return VOCAB_REPEAT_INTERVALS;
},
queueFailedVocab(vocab) {
const repeatIntervals = this.getRepeatIntervals();
const key = this.getVocabKey(vocab);
const existing = this.vocabTrainerRepeatQueue.find((entry) => entry.key === key);
if (existing) {
existing.dueAfter = repeatIntervals[0];
existing.stageIndex = 0;
return;
}
this.vocabTrainerRepeatQueue.push({
key,
dueAfter: repeatIntervals[0],
stageIndex: 0
});
},
resolveRepeatedVocab(vocab) {
const repeatIntervals = this.getRepeatIntervals();
const key = this.getVocabKey(vocab);
const entryIndex = this.vocabTrainerRepeatQueue.findIndex((entry) => entry.key === key && entry.dueAfter <= 0);
if (entryIndex === -1) {
return;
}
const entry = this.vocabTrainerRepeatQueue[entryIndex];
if (entry.stageIndex >= repeatIntervals.length - 1) {
this.vocabTrainerRepeatQueue.splice(entryIndex, 1);
return;
}
entry.stageIndex += 1;
entry.dueAfter = repeatIntervals[entry.stageIndex];
},
advanceRepeatQueue(completedKey = '') {
this.vocabTrainerRepeatQueue = this.vocabTrainerRepeatQueue
.map((entry) => {
if (entry.key === completedKey) {
return entry;
}
return {
...entry,
dueAfter: Math.max(0, entry.dueAfter - 1)
};
})
.filter((entry) => entry.key);
},
getRepeatDueVocab() {
const dueEntry = this.vocabTrainerRepeatQueue.find((entry) => entry.dueAfter <= 0);
if (!dueEntry) {
return null;
}
const allVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
return allVocabs.find((vocab) => this.getVocabKey(vocab) === dueEntry.key) || null;
},
getPendingRepeatKeys() {
return new Set(
this.vocabTrainerRepeatQueue
.filter((entry) => entry.dueAfter > 0)
.map((entry) => entry.key)
);
},
getUnderexposedCurrentVocabs(pool = this.trainableLessonVocab) {
const uniqueByKey = new Map();
(pool || []).forEach((vocab) => {
const key = this.getVocabKey(vocab);
if (!uniqueByKey.has(key)) {
uniqueByKey.set(key, vocab);
}
});
const underexposed = Array.from(uniqueByKey.values())
.map((vocab) => {
const stats = this.getVocabStats(vocab);
return {
vocab,
attempts: Number(stats.attempts) || 0,
wrong: Number(stats.wrong) || 0
};
})
.filter((entry) => entry.attempts < this.trainerMinimumCurrentExposures)
.sort((a, b) => {
if (a.attempts !== b.attempts) return a.attempts - b.attempts;
if (a.wrong !== b.wrong) return b.wrong - a.wrong;
return this.getVocabKey(a.vocab).localeCompare(this.getVocabKey(b.vocab));
});
return underexposed.map((entry) => entry.vocab);
},
chooseVocabFromPool(pool = []) {
if (!pool.length) return null;
const ranked = pool
.map((vocab) => {
const stats = this.getVocabStats(vocab);
return {
vocab,
attempts: Number(stats.attempts) || 0,
wrong: Number(stats.wrong) || 0,
correct: Number(stats.correct) || 0
};
})
.sort((a, b) => {
if (a.attempts !== b.attempts) return a.attempts - b.attempts;
if (a.wrong !== b.wrong) return b.wrong - a.wrong;
if (a.correct !== b.correct) return a.correct - b.correct;
return Math.random() - 0.5;
});
return ranked[0]?.vocab || pool[Math.floor(Math.random() * pool.length)];
},
continueAfterVocabAnswer() {
const completedKey = this.currentVocabQuestion?.key || '';
this.advanceRepeatQueue(completedKey);
this.nextVocabQuestion();
},
checkVocabModeSwitch() {
this.updateExerciseUnlockState();
this.vocabTrainerPhase = this.hasPreviousVocab && this.currentReviewShare > 0 ? 'mixed' : 'current';
const lessonType = String(this.lesson?.lessonType || '').toLowerCase();
const didacticMode = String(this.lessonPedagogy?.didacticMode || '').toLowerCase();
const isReviewLesson = ['review', 'vocab_review'].includes(lessonType)
|| ['review', 'vocab_review', 'intensive_review'].includes(didacticMode);
// Reviews sollen nicht nur aus Multiple Choice bestehen:
// früherer Wechsel zu Typing, damit aktiver Abruf im Vordergrund steht.
const switchAfterAttempts = isReviewLesson
? Math.max(2, Math.ceil(this.trainerExerciseUnlockAttempts * 0.15))
: this.trainerExerciseUnlockAttempts;
const requiredSuccessRate = isReviewLesson ? 60 : 80;
if (this.vocabTrainerMode === 'multiple_choice' && this.vocabTrainerTotalAttempts >= switchAfterAttempts) {
const successRate = (this.vocabTrainerCorrect / this.vocabTrainerTotalAttempts) * 100;
if (successRate >= requiredSuccessRate) {
debugLog('[VocabLessonView] Wechsle zu Typing (Abruf staerken)');
this.vocabTrainerMode = 'typing';
this.vocabTrainerAutoSwitchedToTyping = true;
this.vocabTrainerPool = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
}
}
},
switchBackToMultipleChoice() {
// Wechsle zurück zu Multiple Choice
this.vocabTrainerMode = 'multiple_choice';
this.vocabTrainerAutoSwitchedToTyping = false;
this.vocabTrainerPool = this.vocabTrainerPhase === 'mixed'
? [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool]
: [...this.trainableLessonVocab];
// Reset Stats für Multiple Choice Modus
this.vocabTrainerCorrect = 0;
this.vocabTrainerWrong = 0;
this.vocabTrainerTotalAttempts = 0;
// Starte neue Frage im Multiple Choice Modus
this.nextVocabQuestion();
},
switchToTyping() {
if (!this.vocabTrainerActive || this.vocabTrainerMode === 'typing') {
return;
}
this.vocabTrainerMode = 'typing';
this.vocabTrainerAutoSwitchedToTyping = false;
this.vocabTrainerPool = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
this.nextVocabQuestion();
},
getEquivalentVocabAnswers(prompt, direction, allVocabs) {
const normalizedPrompt = this.normalizeVocab(prompt);
const matches = [];
allVocabs.forEach((entry) => {
const source = direction === 'L2R' ? entry.learning : entry.reference;
const target = direction === 'L2R' ? entry.reference : entry.learning;
if (!source || !target) return;
if (this.normalizeVocab(source) !== normalizedPrompt) return;
if (!matches.includes(target)) {
matches.push(target);
}
});
return matches;
},
/**
* @param {'L2R'|'R2L'} direction L2R: Antwort = reference (Zielsprache, z. B. Bisaya). R2L: Antwort = learning (Muttersprache, z. B. Deutsch).
* Distraktoren kommen immer nur aus derselben Sprache wie die richtige Antwort.
*/
buildChoiceOptions(correctAnswers, allVocabs, excludePrompt = null, direction = 'L2R') {
const normalizedCorrectAnswers = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
const options = new Set(normalizedCorrectAnswers.filter(Boolean));
const normalizedExcludeSet = new Set();
normalizedCorrectAnswers.forEach((answer) => {
normalizedExcludeSet.add(this.normalizeVocab(answer));
});
if (excludePrompt) {
normalizedExcludeSet.add(this.normalizeVocab(excludePrompt));
}
const answerField = direction === 'L2R' ? 'reference' : 'learning';
const allNativeNorm = new Set();
const allTargetNorm = new Set();
allVocabs.forEach((v) => {
if (v.learning) allNativeNorm.add(this.normalizeVocab(v.learning));
if (v.reference) allTargetNorm.add(this.normalizeVocab(v.reference));
});
/** Verhindert gemischte Sprachen in den Optionen (z. B. deutsche Wörter unter Zielsprachen-Distraktoren). */
const tokenMatchesAnswerLanguage = (token) => {
const n = this.normalizeVocab(token);
if (!n) return false;
if (direction === 'L2R') {
if (allNativeNorm.has(n) && !allTargetNorm.has(n)) return false;
} else {
if (allTargetNorm.has(n) && !allNativeNorm.has(n)) return false;
}
return true;
};
const allDistractors = [];
allVocabs.forEach((vocab) => {
const candidate = vocab[answerField];
if (!candidate || !candidate.trim()) return;
const normalized = this.normalizeVocab(candidate);
if (!normalizedExcludeSet.has(normalized) && tokenMatchesAnswerLanguage(candidate)) {
allDistractors.push(candidate);
}
});
const uniqueDistractors = [...new Set(allDistractors)];
let attempts = 0;
const maxAttempts = 200;
while (options.size < 4 && uniqueDistractors.length > 0 && attempts < maxAttempts) {
attempts++;
const randomIndex = Math.floor(Math.random() * uniqueDistractors.length);
const distractor = uniqueDistractors[randomIndex];
if (distractor && distractor.trim()) {
const normalizedDistractor = this.normalizeVocab(distractor);
if (!normalizedExcludeSet.has(normalizedDistractor) && !options.has(distractor)) {
options.add(distractor);
}
}
}
if (options.size < 4 && allVocabs.length > 0) {
allVocabs.forEach((vocab) => {
if (options.size >= 4) return;
const candidate = vocab[answerField];
if (candidate && candidate.trim()) {
const normalized = this.normalizeVocab(candidate);
if (
!normalizedExcludeSet.has(normalized) &&
!options.has(candidate) &&
tokenMatchesAnswerLanguage(candidate)
) {
options.add(candidate);
}
}
});
}
// Falls immer noch nicht genug Optionen: Verwende weniger Optionen (mindestens 2)
if (options.size < 2) {
console.warn('[buildChoiceOptions] Nicht genug Optionen gefunden, verwende nur', options.size, 'Optionen');
}
// Shuffle
const arr = Array.from(options);
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
},
nextVocabQuestion() {
debugLog('[VocabLessonView] nextVocabQuestion aufgerufen');
if (!this.vocabTrainerPool || this.vocabTrainerPool.length === 0) {
debugLog('[VocabLessonView] Keine Vokabeln im Pool');
this.currentVocabQuestion = null;
return;
}
// Prüfe ob Modus-Wechsel nötig ist
this.checkVocabModeSwitch();
let questionSource = 'current';
let sourcePool = this.trainableLessonVocab;
const dueRepeatVocab = this.getRepeatDueVocab();
if (dueRepeatVocab) {
sourcePool = [dueRepeatVocab];
questionSource = this.vocabTrainerMixedPool.some((entry) => this.getVocabKey(entry) === this.getVocabKey(dueRepeatVocab))
? 'review'
: 'current';
}
if (!dueRepeatVocab && this.vocabTrainerMode === 'typing') {
const typingReviewShare = Math.max(0, 1 - this.typingCurrentLessonShare);
if (this.vocabTrainerMixedPool.length > 0 && typingReviewShare > 0 && Math.random() < typingReviewShare) {
sourcePool = this.vocabTrainerMixedPool;
questionSource = 'review';
} else {
sourcePool = this.trainableLessonVocab;
questionSource = 'current';
}
} else if (!dueRepeatVocab && this.vocabTrainerMixedPool.length > 0 && this.currentReviewShare > 0 && Math.random() < this.currentReviewShare) {
sourcePool = this.vocabTrainerMixedPool;
questionSource = 'review';
}
if (!sourcePool || sourcePool.length === 0) {
sourcePool = this.trainableLessonVocab;
questionSource = 'current';
}
if (!dueRepeatVocab) {
const pendingRepeatKeys = this.getPendingRepeatKeys();
const filteredPool = sourcePool.filter((vocab) => !pendingRepeatKeys.has(this.getVocabKey(vocab)));
if (filteredPool.length > 0) {
sourcePool = filteredPool;
}
}
if (!dueRepeatVocab && questionSource === 'current' && this.vocabTrainerMode === 'multiple_choice') {
const underexposed = this.getUnderexposedCurrentVocabs(sourcePool);
if (underexposed.length > 0) {
sourcePool = underexposed;
}
}
const vocab = this.chooseVocabFromPool(sourcePool);
if (!vocab) {
this.currentVocabQuestion = null;
return;
}
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
const allTrainerVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
const direction = this.vocabTrainerDirection;
const prompt = direction === 'L2R' ? vocab.learning : vocab.reference;
// Akzeptiere mehrere Übersetzungen für denselben Prompt (z. B. Synonyme / Varianten auf der Lernsprache).
// Zusätzlich alle im gesamten Trainer-Pool gleichwertigen Ziele (wie früher via getEquivalentVocabAnswers),
// damit gemischte Pools aus mehreren Lektionen nicht plötzlich gültige Alternativen ablehnen.
const baseAnswers = direction === 'L2R'
? [vocab.reference]
: (Array.isArray(vocab.learningVariants) && vocab.learningVariants.length > 0
? [...vocab.learningVariants]
: [vocab.learning]);
const equivalentAnswers = this.getEquivalentVocabAnswers(prompt, direction, allTrainerVocabs);
const mergedRaw = [...baseAnswers, ...equivalentAnswers].filter(Boolean);
const seenNorm = new Set();
const acceptableAnswers = [];
for (const a of mergedRaw) {
const n = this.normalizeVocab(a);
if (!n || seenNorm.has(n)) continue;
seenNorm.add(n);
acceptableAnswers.push(a);
}
this.currentVocabQuestion = {
vocab: vocab,
prompt,
answers: acceptableAnswers.filter(Boolean),
answer: acceptableAnswers.filter(Boolean).join(' / ') || (this.vocabTrainerDirection === 'L2R' ? vocab.reference : vocab.learning),
key: this.getVocabKey(vocab),
source: questionSource
};
debugLog('[VocabLessonView] Neue Frage erstellt:', this.currentVocabQuestion.prompt);
// Reset UI
this.vocabTrainerAnswer = '';
this.vocabTrainerSelectedChoice = null;
this.vocabTrainerAnswered = false;
// Erstelle Choice-Optionen für Multiple Choice
if (this.vocabTrainerMode === 'multiple_choice') {
debugLog('[VocabLessonView] Erstelle Choice-Optionen...');
debugLog('[VocabLessonView] Prompt:', this.currentVocabQuestion.prompt);
debugLog('[VocabLessonView] Answer:', this.currentVocabQuestion.answer);
// Wichtig: Der Prompt (die Frage) darf nicht in den Optionen erscheinen
this.vocabTrainerChoiceOptions = this.buildChoiceOptions(
this.currentVocabQuestion.answers,
allTrainerVocabs,
this.currentVocabQuestion.prompt,
this.vocabTrainerDirection
);
debugLog('[VocabLessonView] Choice-Optionen erstellt:', this.vocabTrainerChoiceOptions);
}
// Fokussiere Eingabefeld im Typing-Modus
if (this.vocabTrainerMode === 'typing') {
this.$nextTick(() => {
this.$refs.vocabInput?.focus?.();
});
}
},
selectVocabChoice(option) {
this.vocabTrainerSelectedChoice = option;
// Bei Multiple Choice: Sofort prüfen
if (this.vocabTrainerMode === 'multiple_choice') {
this.checkVocabAnswer();
}
},
normalizeComparableText(value) {
const normalized = String(value || '')
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized.replace(/\s+/g, '');
},
stripTrailingParentheticalNotes(value) {
let text = String(value || '').trim();
// Entfernt didaktische Zusätze am Ende, unabhängig vom konkreten Inhalt,
// z. B. "(...)", "[...]" oder vollbreite Klammern "...".
const trailingNoteRegex = /\s*(\([^()]*\)|\[[^\[\]]*\]|[^]*)\s*$/;
while (trailingNoteRegex.test(text)) {
text = text.replace(trailingNoteRegex, '').trim();
}
return text;
},
normalizeVocab(s, options = {}) {
const { ignoreTrailingParentheticalNotes = false } = options;
const source = ignoreTrailingParentheticalNotes
? this.stripTrailingParentheticalNotes(s)
: s;
return this.normalizeComparableText(source);
},
reportSrsReviewForCurrentQuestion(isCorrect) {
if (!this.currentVocabQuestion?.vocab || !this.courseId) {
return;
}
const vocab = this.currentVocabQuestion.vocab;
apiClient.post('/api/vocab/srs/review', {
courseId: this.courseId,
lessonId: vocab.lessonId || this.lessonId,
itemKey: vocab.itemKey || null,
learning: vocab.learning,
reference: vocab.reference,
direction: this.vocabTrainerDirection,
correct: Boolean(isCorrect)
}).catch((error) => {
console.warn('[VocabLessonView] SRS review could not be saved:', error);
});
},
checkVocabAnswer() {
if (!this.currentVocabQuestion) return;
let userAnswer = '';
if (this.vocabTrainerMode === 'multiple_choice') {
if (!this.vocabTrainerSelectedChoice) return;
userAnswer = this.vocabTrainerSelectedChoice;
} else {
if (!this.vocabTrainerAnswer.trim()) return;
userAnswer = this.vocabTrainerAnswer;
}
const useTypingNormalization = this.vocabTrainerMode === 'typing';
const normalizedUser = this.normalizeVocab(userAnswer, {
ignoreTrailingParentheticalNotes: useTypingNormalization
});
const normalizedCorrectAnswers = (this.currentVocabQuestion.answers || [this.currentVocabQuestion.answer])
.map(answer => this.normalizeVocab(answer, {
ignoreTrailingParentheticalNotes: useTypingNormalization
}));
this.vocabTrainerLastCorrect = normalizedCorrectAnswers.includes(normalizedUser);
// Update Stats
const stats = this.getVocabStats(this.currentVocabQuestion.vocab);
stats.attempts++;
this.vocabTrainerTotalAttempts++;
if (this.currentVocabQuestion.source === 'review') {
this.vocabTrainerReviewAttempts++;
} else {
this.vocabTrainerCurrentAttempts++;
}
if (this.vocabTrainerLastCorrect) {
this.vocabTrainerCorrect++;
stats.correct++;
this.resolveRepeatedVocab(this.currentVocabQuestion.vocab);
} else {
this.vocabTrainerWrong++;
stats.wrong++;
this.queueFailedVocab(this.currentVocabQuestion.vocab);
}
this.reportSrsReviewForCurrentQuestion(this.vocabTrainerLastCorrect);
this.vocabTrainerAnswered = true;
// Automatisch zur nächsten Frage nach kurzer Pause (nur bei richtiger Antwort)
if (this.vocabTrainerLastCorrect) {
// Prüfe ob noch Fragen vorhanden sind
if (this.vocabTrainerPool && this.vocabTrainerPool.length > 0) {
const delay = this.vocabTrainerMode === 'multiple_choice' ? 1000 : 500; // 1 Sekunde für Multiple Choice, 500ms für Typing
setTimeout(() => {
// Prüfe erneut, ob noch Fragen vorhanden sind (könnte sich geändert haben)
if (this.vocabTrainerPool && this.vocabTrainerPool.length > 0 && this.vocabTrainerActive) {
this.continueAfterVocabAnswer();
}
}, delay);
}
}
// Im Typing-Modus bei falscher Antwort: Eingabefeld fokussieren für erneuten Versuch
if (this.vocabTrainerMode === 'typing' && !this.vocabTrainerLastCorrect) {
this.$nextTick(() => {
this.$refs.vocabInput?.focus?.();
this.vocabTrainerAnswer = '';
});
}
},
initSpeechRecognition() {
// Prüfe Browser-Support für Speech Recognition
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
this.isSpeechRecognitionSupported = false;
console.warn('Speech Recognition wird von diesem Browser nicht unterstützt');
return;
}
this.isSpeechRecognitionSupported = true;
},
isRecording(exerciseId) {
return !!this.activeRecognition[exerciseId];
},
startReadingAloud(exerciseId) {
const exercise = this.effectiveExercises.find(e => e.id === exerciseId);
if (!exercise) return;
const qData = this.getQuestionData(exercise);
const expectedText = qData.text || '';
this.startRecognition(exerciseId, expectedText);
},
stopReadingAloud(exerciseId) {
this.stopRecognition(exerciseId);
},
startSpeakingFromMemory(exerciseId) {
const exercise = this.effectiveExercises.find(e => e.id === exerciseId);
if (!exercise) return;
const qData = this.getQuestionData(exercise);
const expectedText = qData.expectedText || qData.text || '';
this.startRecognition(exerciseId, expectedText);
},
stopSpeakingFromMemory(exerciseId) {
this.stopRecognition(exerciseId);
},
startRecognition(exerciseId, expectedText) {
if (!this.isSpeechRecognitionSupported) {
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.speechRecognitionNotSupported');
return;
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
// Konfiguriere Recognition
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'de-DE'; // Kann später dynamisch basierend auf Kurs-Sprache gesetzt werden
let finalTranscript = '';
recognition.onresult = (event) => {
let interimTranscript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalTranscript += transcript + ' ';
} else {
interimTranscript += transcript;
}
}
// Aktualisiere erkannten Text
this.recognizedText[exerciseId] = finalTranscript.trim() || interimTranscript;
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.listening');
};
recognition.onerror = (event) => {
console.error('Speech Recognition Fehler:', event.error);
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.recordingError') + ': ' + event.error;
this.stopRecognition(exerciseId);
};
recognition.onend = () => {
// Speichere finalen Text in exerciseAnswers
if (finalTranscript.trim()) {
this.exerciseAnswers[exerciseId] = finalTranscript.trim();
}
this.activeRecognition[exerciseId] = null;
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.recordingStopped');
};
// Starte Recognition
try {
recognition.start();
this.activeRecognition[exerciseId] = recognition;
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.recording');
this.recognizedText[exerciseId] = '';
} catch (error) {
console.error('Fehler beim Starten der Speech Recognition:', error);
this.recordingStatus[exerciseId] = this.$t('socialnetwork.vocab.courses.recordingError') + ': ' + error.message;
}
},
stopRecognition(exerciseId) {
if (this.activeRecognition[exerciseId]) {
try {
this.activeRecognition[exerciseId].stop();
} catch (error) {
console.error('Fehler beim Stoppen der Speech Recognition:', error);
}
this.activeRecognition[exerciseId] = null;
}
}
},
async mounted() {
// Prüfe Speech Recognition Support
this.initSpeechRecognition();
await Promise.all([
this.loadLesson(),
this.loadAssistantSettings()
]);
},
beforeUnmount() {
this.persistLessonState({ immediate: true, lessonIdOverride: this.lesson?.id || this.lessonId });
if (this.lessonStateSaveTimer) {
window.clearTimeout(this.lessonStateSaveTimer);
this.lessonStateSaveTimer = null;
}
// Stoppe alle aktiven Recognition-Instanzen
Object.keys(this.activeRecognition).forEach(exerciseId => {
this.stopRecognition(exerciseId);
});
}
};
</script>
<style scoped>
.vocab-lesson-view {
padding: 20px;
}
.language-assistant-card {
gap: 14px;
}
.language-assistant-card--focused {
border-color: var(--color-primary-orange);
box-shadow: 0 0 0 3px rgba(248, 162, 43, 0.18);
}
.language-assistant-card__header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.language-assistant-card__intro {
margin: 6px 0 0;
color: var(--color-text-secondary);
}
.language-assistant-card__state {
color: var(--color-text-secondary);
}
.language-assistant-panel {
display: grid;
gap: 14px;
}
.language-assistant-panel__modes,
.language-assistant-panel__presets,
.language-assistant-panel__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.assistant-mode-button,
.assistant-preset-button {
border: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.82);
border-radius: var(--radius-md);
padding: 8px 12px;
}
.assistant-mode-button.active {
border-color: var(--color-primary-orange);
background: rgba(248, 162, 43, 0.16);
}
.language-assistant-chat {
display: grid;
gap: 10px;
}
.assistant-message {
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.assistant-message strong {
display: block;
margin-bottom: 4px;
}
.assistant-message p {
margin: 0;
white-space: pre-wrap;
}
.assistant-message--assistant {
background: rgba(248, 162, 43, 0.08);
}
.assistant-message--user {
background: rgba(58, 117, 196, 0.08);
}
.language-assistant-panel__input {
display: grid;
gap: 6px;
}
.language-assistant-panel__input textarea {
width: 100%;
min-height: 112px;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
resize: vertical;
}
.lesson-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
}
.lesson-header h2 {
flex: 1;
min-width: 0;
margin: 0;
}
.btn-reset-lesson {
flex-shrink: 0;
padding: 8px 14px;
border: 1px solid rgba(140, 90, 60, 0.35);
border-radius: 4px;
background: rgba(255, 248, 240, 0.95);
color: #6b4420;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
}
.btn-reset-lesson:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.lesson-overview-card {
display: flex;
flex-direction: column;
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;
}
.lesson-overview-text {
margin: 8px 0 0;
color: #5b4b2f;
}
.lesson-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.lesson-meta-item {
padding: 12px 14px;
background: rgba(255, 255, 255, 0.72);
border-radius: 10px;
}
.lesson-review-status {
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(34, 96, 164, 0.18);
background: rgba(235, 244, 255, 0.86);
color: #21598f;
}
.lesson-review-status__top {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.lesson-review-status__badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
background: rgba(34, 96, 164, 0.14);
font-size: 0.78rem;
font-weight: 700;
}
.lesson-review-status strong,
.lesson-review-status p {
margin: 0;
}
.lesson-review-status p {
margin-top: 8px;
color: inherit;
}
.lesson-review-status--due {
border-color: rgba(185, 99, 24, 0.22);
background: rgba(255, 246, 226, 0.92);
color: #8d5412;
}
.lesson-review-status--due .lesson-review-status__badge {
background: rgba(185, 99, 24, 0.16);
}
.lesson-review-status--done {
border-color: rgba(68, 138, 86, 0.22);
background: rgba(239, 250, 242, 0.92);
color: #2f6b3d;
}
.lesson-review-status--done .lesson-review-status__badge {
background: rgba(68, 138, 86, 0.14);
}
.lesson-meta-label {
display: block;
margin-bottom: 6px;
font-size: 0.82rem;
color: #7a6848;
}
.lesson-intensity-banner {
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(207, 78, 78, 0.24);
background: rgba(255, 242, 242, 0.95);
color: #8e3d3d;
}
.lesson-intensity-banner strong,
.lesson-intensity-banner p {
margin: 0;
}
.lesson-intensity-banner p {
margin-top: 6px;
}
.lesson-enough-banner {
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(34, 96, 164, 0.18);
background: rgba(235, 244, 255, 0.72);
color: #21598f;
}
.lesson-enough-banner strong,
.lesson-enough-banner p {
margin: 0;
}
.lesson-enough-banner p {
margin-top: 6px;
}
.lesson-enough-banner__actions {
margin-top: 12px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.lesson-overview-more {
border-top: 1px solid rgba(160, 120, 40, 0.18);
padding-top: 14px;
}
.lesson-overview-more__summary,
.lesson-deepen-section__summary {
cursor: pointer;
font-weight: 600;
color: #5f4313;
}
.lesson-overview-more__grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.learn-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.didactic-card,
.cultural-notes {
padding: 18px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e7e7e7;
}
.lesson-intro-combined {
grid-column: 1 / -1;
}
.lesson-intro-block + .lesson-intro-block {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e0e4e8;
}
.lesson-intro-block h4 {
margin-top: 0;
}
.didactic-list {
margin: 0;
padding-left: 20px;
}
.didactic-list li + li {
margin-top: 8px;
}
.pattern-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.pattern-item {
padding: 12px 14px;
border-left: 4px solid #d2831f;
background: #fff;
border-radius: 8px;
}
.pattern-target {
font-weight: 600;
color: #1a1a1a;
}
.pattern-gloss {
margin-top: 6px;
font-size: 0.92rem;
color: #555;
}
.core-patterns-hint {
margin: 0 0 12px;
font-size: 0.9rem;
color: #666;
}
.vocab-prep-pass {
margin-bottom: 14px;
background: linear-gradient(180deg, #fffefd 0%, #fff7ec 100%);
border: 1px solid rgba(210, 131, 31, 0.18);
}
.vocab-prep-pass__step {
margin: 0 0 10px;
font-size: 0.9rem;
color: #7a6848;
font-weight: 600;
}
.vocab-prep-card {
margin-top: 12px;
padding: 16px 18px;
background: #fff;
border: 1px solid #e5dccf;
border-radius: 10px;
}
.vocab-prep-card__row {
display: grid;
grid-template-columns: minmax(0, 110px) 1fr;
gap: 10px 14px;
align-items: baseline;
margin-top: 10px;
}
.vocab-prep-card__row:first-of-type {
margin-top: 0;
}
.vocab-prep-card__label {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a7658;
}
.vocab-prep-card__target {
font-size: 1.2rem;
font-weight: 700;
color: #1f1a16;
}
.vocab-prep-card__gloss {
font-size: 1rem;
color: #6a5a44;
}
.vocab-prep-pass .btn-prep-pass {
margin-top: 8px;
}
.vocab-prep-pass__ready {
margin: 0;
color: #2d6a3e;
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;
}
.lesson-grammar-impulse {
margin-top: 16px;
padding: 16px 18px;
border: 1px solid rgba(121, 100, 56, 0.18);
background: rgba(255, 255, 255, 0.72);
}
.lesson-grammar-impulse__header {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 10px;
}
.lesson-grammar-impulse__header h4 {
margin: 0;
color: #3f2d14;
}
.lesson-grammar-impulse__intro {
margin: 0 0 12px;
color: #6b5535;
}
.lesson-grammar-impulse__list {
display: grid;
gap: 12px;
}
.lesson-grammar-impulse__item {
padding: 12px 14px;
border-radius: 10px;
background: rgba(255, 249, 240, 0.88);
border: 1px solid rgba(121, 100, 56, 0.12);
}
.lesson-grammar-impulse__item p {
margin: 6px 0 0;
}
.vocab-trainer-locked-hint {
margin: 0;
color: #8a5a00;
}
.lesson-deepen-section,
.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,
.lesson-assistant-section .language-assistant-card {
margin-top: 16px;
}
.grammar-example,
.speaking-cue,
.pattern-drill-hint {
margin-top: 8px;
color: #66553a;
font-style: italic;
}
.speaking-prompt-item + .speaking-prompt-item,
.practical-task-item + .practical-task-item {
margin-top: 14px;
}
.token-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0;
}
.token-chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: #eef3ff;
border: 1px solid #cfdbff;
border-radius: 999px;
font-size: 0.95rem;
}
.dialog-snippet {
margin: 14px 0;
padding: 14px;
background: #fff;
border-radius: 10px;
border: 1px solid #e6e6e6;
}
.response-textarea {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid #d0d0d0;
border-radius: 8px;
resize: vertical;
}
.btn-back {
padding: 8px 16px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
color: var(--color-text-primary);
cursor: pointer;
box-shadow: none;
}
.grammar-exercises {
margin-top: 30px;
}
.exercise-sequential-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
margin-bottom: 14px;
border: 1px solid rgba(80, 118, 178, 0.14);
background: rgba(255, 255, 255, 0.85);
}
.exercise-sequential-nav__progress {
margin: 0;
font-weight: 600;
color: #2a3f5f;
}
.exercise-sequential-nav__buttons {
display: flex;
gap: 10px;
}
.btn-seq {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(58, 117, 196, 0.35);
background: #fff;
color: #27528f;
font-weight: 600;
cursor: pointer;
}
.btn-seq:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-seq--primary {
background: rgba(58, 117, 196, 0.12);
border-color: rgba(58, 117, 196, 0.45);
}
.exercise-reinforcement-correct {
padding: 10px 12px;
margin: 0 0 12px;
border-radius: 8px;
background: rgba(45, 106, 62, 0.08);
color: #1f3d2a;
}
.dialog-footer--stack {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.dialog-button--primary {
font-weight: 700;
}
.exam-failed-review {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(133, 100, 4, 0.28);
}
.exam-failed-review h4 {
margin: 0 0 8px;
font-size: 0.98rem;
}
.exam-failed-review ul {
margin: 0;
padding-left: 18px;
}
.exam-failed-review li {
margin-bottom: 10px;
}
.exam-failed-review__question,
.exam-failed-review__answer {
margin: 0 0 4px;
}
.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: 18px;
border: 1px solid #ddd;
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 {
color: #666;
font-style: italic;
margin-bottom: 15px;
}
.exercise-question {
font-weight: 600;
margin-bottom: 15px;
font-size: 1.1em;
}
.exercise-text {
margin-bottom: 15px;
line-height: 1.6;
}
.gap {
display: inline-block;
min-width: 100px;
border-bottom: 2px solid #333;
margin: 0 5px;
padding: 0 5px;
}
.multiple-choice-exercise .options {
margin: 15px 0;
}
.option-label {
display: block;
padding: 10px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.option-label:hover {
background-color: #f5f5f5;
}
.option-label input[type="radio"] {
margin-right: 10px;
}
.gap-fill-exercise .gap-inputs {
display: flex;
flex-direction: column;
gap: 10px;
margin: 15px 0;
}
.gap-input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.transformation-input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin: 15px 0;
}
.exercise-item button {
padding: 10px 20px;
background: var(--color-primary);
color: #2b1f14;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
margin-top: 10px;
transition: background-color 0.2s, box-shadow 0.2s;
box-shadow: 0 6px 14px rgba(248, 162, 43, 0.18);
}
.exercise-item button:hover:not(:disabled) {
background: var(--color-primary-hover);
box-shadow: 0 10px 18px rgba(248, 162, 43, 0.22);
}
.exercise-item button:disabled {
background: #ccc;
cursor: not-allowed;
}
.exercise-result {
margin-top: 15px;
padding: 15px;
border-radius: 4px;
}
.exercise-result.correct {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.exercise-result.wrong {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.exercise-explanation {
margin-top: 10px;
font-style: italic;
}
.unknown-exercise {
padding: 15px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
margin-top: 10px;
}
.unknown-exercise__type {
margin-top: 8px;
font-size: 0.9em;
color: var(--color-text-secondary);
}
/* Tabs */
.lesson-tabs {
display: flex;
gap: 10px;
margin: 20px 0;
border-bottom: 2px solid #ddd;
}
.tab-button {
padding: 10px 20px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.72);
cursor: pointer;
font-size: 1em;
color: var(--color-text-secondary);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
box-shadow: none;
}
.tab-button:hover:not(:disabled) {
color: var(--color-text-primary);
background: rgba(255, 255, 255, 0.92);
}
.tab-button.active {
color: #2b1f14;
background: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: bold;
}
.tab-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Lernen-Sektion */
.learn-section {
margin-top: 20px;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
}
.learn-section h3 {
margin-top: 0;
color: #333;
}
.cultural-notes {
margin: 20px 0;
padding: 15px;
background: #e7f3ff;
border-left: 4px solid #007bff;
border-radius: 4px;
}
.cultural-notes h4 {
margin-top: 0;
color: #007bff;
}
.vocab-list {
margin: 20px 0;
}
.vocab-list__summary {
cursor: pointer;
font-weight: 600;
color: #5f4313;
margin-bottom: 12px;
}
.vocab-list[open] .vocab-list__summary {
margin-bottom: 14px;
}
.vocab-list--overview {
padding: 14px 16px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid #eadfcf;
border-radius: 12px;
}
.vocab-list h4 {
margin-bottom: 15px;
color: #333;
}
.vocab-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 10px;
}
.vocab-item {
padding: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
display: flex;
align-items: center;
gap: 10px;
}
.vocab-item strong {
color: #007bff;
}
.separator {
color: #999;
margin: 0 10px;
}
.vocab-items-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px 16px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #eadfcf;
}
.vocab-items-pager__btn {
padding: 6px 14px;
font-size: 0.9rem;
border: 1px solid #c9b896;
border-radius: 8px;
background: #faf8f3;
color: #333;
cursor: pointer;
}
.vocab-items-pager__btn:hover:not(:disabled) {
background: #f0ebe0;
}
.vocab-items-pager__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vocab-items-pager__meta {
font-size: 0.88rem;
color: #555;
text-align: center;
min-width: min(100%, 14rem);
}
.grammar-explanations {
margin: 20px 0;
padding: 15px;
background: #fff3cd;
border-left: 4px solid #ffc107;
border-radius: 4px;
}
.grammar-explanations h4 {
margin-top: 0;
color: #856404;
}
.grammar-explanation-item {
margin: 15px 0;
padding: 10px;
background: white;
border-radius: 4px;
}
.grammar-explanation-item strong {
display: block;
margin-bottom: 5px;
color: #856404;
}
.vocab-trainer-section {
margin: 20px 0;
padding: 18px;
background: #fff;
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 {
margin-top: 0;
color: #333;
}
.review-priority-note,
.exercise-lock-note {
margin-bottom: 12px;
padding: 12px;
border-radius: 4px;
}
.review-priority-note {
background: #eef7ff;
border: 1px solid #b9d8ff;
color: #234a72;
}
.exercise-lock-note {
background: #fff3cd;
border: 1px solid #ffc107;
color: #856404;
}
.review-priority-note strong,
.exercise-lock-note strong {
display: block;
margin-bottom: 4px;
}
.trainer-progress-row {
font-size: 0.92em;
color: #5b4636;
}
.vocab-trainer-start {
text-align: center;
}
.btn-start-trainer {
padding: 10px 20px;
background: var(--color-primary);
color: #2b1f14;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
margin-top: 10px;
box-shadow: 0 6px 14px rgba(248, 162, 43, 0.18);
}
.btn-start-trainer:hover {
background: var(--color-primary-hover);
}
.vocab-trainer-stats {
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
}
.stats-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
margin-bottom: 10px;
}
.stats-row:last-child {
margin-bottom: 0;
}
.success-rate {
font-weight: bold;
color: #28a745;
}
.mode-badge {
padding: 5px 10px;
border-radius: 4px;
font-size: 0.9em;
background: #e9ecef;
color: #6c757d;
}
.mode-badge.mode-clickable {
cursor: pointer;
}
.mode-badge.mode-clickable:hover {
filter: brightness(0.98);
}
.mode-badge.mode-active {
background: var(--color-primary);
color: #2b1f14;
font-weight: bold;
}
.mode-badge.mode-completed {
background: #28a745;
color: white;
}
.btn-stop-trainer {
padding: 5px 15px;
background: rgba(177, 59, 53, 0.92);
color: white;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.btn-stop-trainer:hover {
background: var(--color-danger-hover);
}
.vocab-question {
margin-top: 15px;
}
.vocab-prompt {
padding: 15px;
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 15px;
}
.vocab-direction {
color: #666;
font-size: 0.9em;
margin-bottom: 5px;
}
.vocab-word {
font-size: 1.5em;
font-weight: bold;
color: #333;
}
.vocab-answer-area {
margin-bottom: 15px;
}
.vocab-answer-area.typing {
display: flex;
flex-direction: column;
gap: 10px;
}
.mode-switch-notice {
margin-bottom: 10px;
padding: 10px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
text-align: center;
}
.btn-switch-mode {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.92);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
transition: background 0.2s, border-color 0.2s;
box-shadow: none;
}
.btn-switch-mode:hover {
background: rgba(255, 255, 255, 0.98);
border: 1px solid var(--color-border-strong);
}
.vocab-answer-area.multiple-choice {
display: flex;
flex-direction: column;
gap: 10px;
}
.choice-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
.choice-button {
padding: 12px;
border: 2px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 1em;
transition: all 0.2s;
}
.choice-button:hover {
border-color: var(--color-primary);
background: rgba(248, 162, 43, 0.08);
}
.choice-button.selected {
border-color: var(--color-primary);
background: var(--color-primary);
color: #2b1f14;
}
.vocab-input {
flex: 1;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
}
.btn-check {
padding: 10px 20px;
background: var(--color-primary);
color: #2b1f14;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
}
.btn-check:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-check:disabled {
background: #ccc;
cursor: not-allowed;
}
.vocab-feedback {
padding: 15px;
border-radius: 4px;
margin-bottom: 15px;
}
.vocab-feedback.correct {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.vocab-feedback.wrong {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.vocab-next {
margin-top: 15px;
}
.vocab-next button {
padding: 10px 20px;
background: var(--color-primary);
color: #2b1f14;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
}
.vocab-next button:hover {
background: var(--color-primary-hover);
}
.vocab-info-text {
color: #666;
font-size: 0.9em;
margin-bottom: 10px;
}
.no-vocab-info {
padding: 15px;
background: #fff3cd;
border-left: 4px solid #ffc107;
border-radius: 4px;
color: #856404;
}
.continue-to-exercises {
margin-top: 30px;
text-align: center;
}
.btn-continue {
padding: 12px 24px;
background: var(--color-primary);
color: #2b1f14;
border: 1px solid transparent;
border-radius: 4px;
font-size: 1.1em;
cursor: pointer;
transition: background 0.2s;
}
.btn-continue:hover {
background: var(--color-primary-hover);
}
/* Reading Aloud & Speaking From Memory Styles */
.reading-aloud-exercise,
.speaking-from-memory-exercise,
.sentence-building-exercise,
.dialog-completion-exercise,
.situational-response-exercise,
.pattern-drill-exercise {
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 20px;
}
.reading-aloud-controls,
.speaking-controls {
margin: 20px 0;
display: flex;
gap: 10px;
align-items: center;
}
.btn-record,
.btn-stop-record {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
transition: all 0.2s;
}
.btn-record {
background: var(--color-primary);
color: #2b1f14;
}
.btn-record:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-record:disabled {
background: #6c757d;
cursor: not-allowed;
}
.btn-stop-record {
background: rgba(177, 59, 53, 0.92);
color: white;
}
.btn-stop-record:hover {
background: var(--color-danger-hover);
}
.btn-check {
padding: 10px 20px;
background: var(--color-primary);
color: #2b1f14;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
margin-top: 15px;
}
.btn-check:hover {
background: var(--color-primary-hover);
}
.recording-status {
margin: 15px 0;
padding: 10px;
border-radius: 4px;
font-weight: 500;
}
.recording-status.recording {
background: #fff3cd;
color: #856404;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.recognized-text {
margin: 20px 0;
padding: 15px;
background: white;
border: 2px solid #dee2e6;
border-radius: 6px;
}
.recognized-text strong {
display: block;
margin-bottom: 10px;
color: #495057;
}
.recognized-text p {
margin: 0;
font-size: 1.1em;
color: #212529;
line-height: 1.6;
}
.keywords-hint {
margin: 15px 0;
padding: 12px;
background: #e7f3ff;
border-left: 4px solid #007bff;
border-radius: 4px;
}
.keywords-hint strong {
display: block;
margin-bottom: 8px;
color: #0056b3;
}
.keyword-tag {
display: inline-block;
padding: 4px 10px;
margin: 4px 4px 0 0;
background: #007bff;
color: white;
border-radius: 12px;
font-size: 0.9em;
}
.speech-not-supported {
margin-top: 15px;
padding: 12px;
background: #f8d7da;
border-left: 4px solid #dc3545;
border-radius: 4px;
color: #721c24;
}
/* Dialog Styles */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
background: rgba(0, 0, 0, 0.5);
}
.dialog {
background: white;
display: flex;
flex-direction: column;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
max-width: 90%;
max-height: 90%;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
border-bottom: 1px solid #ddd;
background-color: var(--color-primary-orange);
}
.dialog-title {
flex-grow: 1;
font-size: 1.2em;
font-weight: bold;
}
.dialog-close {
cursor: pointer;
font-size: 1.5em;
margin-left: 10px;
color: #000;
}
.dialog-body {
padding: 20px;
overflow-y: auto;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 10px 20px;
border-top: 1px solid #ddd;
}
.dialog-button {
padding: 8px 16px;
background: var(--color-primary);
color: #2b1f14;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
}
.dialog-button:hover {
background: var(--color-primary-hover);
}
@media (max-width: 900px) {
.lesson-overview-card {
flex-direction: column;
}
.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>