All checks were successful
Deploy to production / deploy (push) Successful in 2m17s
- 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.
5752 lines
208 KiB
Vue
5752 lines
208 KiB
Vue
<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>
|