All checks were successful
Deploy to production / deploy (push) Successful in 2m45s
- Introduced a new mechanism for calculating token weights based on core patterns and important vocabulary, improving the accuracy of target-gloss pair orientation. - Replaced the previous hint logic with a more robust token-based scoring system, enhancing the quality of vocabulary item representation. - Streamlined the code for better maintainability and clarity in vocabulary preparation processes.
4979 lines
176 KiB
Vue
4979 lines
176 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>
|
|
|
|
<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 lessonVocab" :key="index" class="vocab-item">
|
|
<strong>{{ vocab.learning || '—' }}</strong>
|
|
<span class="separator">→</span>
|
|
<span>{{ vocab.reference }}</span>
|
|
</div>
|
|
</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" :class="{ 'mode-active': vocabTrainerMode === 'multiple_choice', 'mode-completed': vocabTrainerMode === 'typing' }">
|
|
{{ $t('socialnetwork.vocab.courses.modeMultipleChoice') }}
|
|
</span>
|
|
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerMode === 'typing' }">
|
|
{{ $t('socialnetwork.vocab.courses.modeTyping') }}
|
|
</span>
|
|
<button @click="stopVocabTrainer" class="btn-stop-trainer">{{ $t('socialnetwork.vocab.courses.stopTrainer') }}</button>
|
|
</div>
|
|
<div v-if="hasPreviousVocab" class="stats-row trainer-progress-row">
|
|
<span>{{ $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 v-if="vocabTrainerAutoSwitchedToTyping" class="mode-switch-notice">
|
|
<button @click="switchBackToMultipleChoice" class="btn-switch-mode">
|
|
{{ $t('socialnetwork.vocab.courses.switchBackToMultipleChoice') }}
|
|
</button>
|
|
</div>
|
|
<input
|
|
v-model="vocabTrainerAnswer"
|
|
@keydown.enter.prevent="checkVocabAnswer"
|
|
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
|
|
class="vocab-input"
|
|
ref="vocabInput"
|
|
/>
|
|
<button @click="checkVocabAnswer" :disabled="!vocabTrainerAnswer.trim()" class="btn-check">
|
|
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
|
|
</button>
|
|
</div>
|
|
<!-- "Weiter"-Button nur bei falscher Antwort (bei richtiger Antwort wird automatisch weiter gemacht) -->
|
|
<div v-if="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 class="exercise-question">{{ getQuestionText(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"
|
|
/>
|
|
</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 class="exercise-question">{{ getQuestionText(exercise) }}</p>
|
|
<input
|
|
v-model="exerciseAnswers[exercise.id]"
|
|
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
|
|
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].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 class="exercise-question">{{ getQuestionText(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"
|
|
/>
|
|
<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 class="exercise-question">{{ getQuestionText(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"
|
|
/>
|
|
<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 class="exercise-question">{{ getQuestionText(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 class="exercise-question">{{ getQuestionText(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 class="exercise-question">{{ getQuestionText(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 class="exercise-question">{{ getQuestionText(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>
|
|
</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];
|
|
/** Mindest-Erfolgsquote im Vokabeltrainer (gesamt), damit die Kapitel-Prüfung freigeschaltet wird. */
|
|
const EXERCISE_UNLOCK_MIN_SUCCESS_PERCENT = 70;
|
|
|
|
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: '',
|
|
/** Index in scrambledChapterExamExercises bei Ein-Frage-Ansicht */
|
|
exerciseSequentialIndex: 0,
|
|
/** 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
|
|
};
|
|
},
|
|
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 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(120, weightedTarget));
|
|
},
|
|
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 progressPastBlendStart = Math.max(0, this.vocabTrainerCurrentAttempts - this.trainerReviewBlendStart);
|
|
const normalizedRamp = Math.min(1, progressPastBlendStart / this.trainerReviewRampWindow);
|
|
return Math.min(0.55, normalizedRamp * 0.55);
|
|
},
|
|
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]?.correct) && 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 addEntry = (entry) => {
|
|
const reference = String(entry?.reference || '').trim();
|
|
const learning = String(entry?.learning || '').trim();
|
|
if (!reference) return;
|
|
const key = this.normalizeLessonVocabTerm(reference);
|
|
if (!vocabByReference.has(key)) {
|
|
vocabByReference.set(key, { learning, reference });
|
|
return;
|
|
}
|
|
const existing = vocabByReference.get(key);
|
|
if (!existing.learning && learning) {
|
|
existing.learning = learning;
|
|
}
|
|
};
|
|
|
|
this.normalizedCorePatterns.forEach((item) => {
|
|
addEntry({
|
|
learning: item.gloss || '',
|
|
reference: item.target || ''
|
|
});
|
|
});
|
|
|
|
this.importantVocab.forEach((item) => {
|
|
addEntry(item);
|
|
});
|
|
|
|
return Array.from(vocabByReference.values());
|
|
},
|
|
trainableLessonVocab() {
|
|
return this.lessonVocab.filter((entry) => entry.learning && entry.reference && 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 (!t || !g) 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)
|
|
});
|
|
},
|
|
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();
|
|
}
|
|
}
|
|
},
|
|
methods: {
|
|
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();
|
|
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) {
|
|
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.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();
|
|
},
|
|
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();
|
|
},
|
|
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 = '';
|
|
},
|
|
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(() => {
|
|
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) {
|
|
answers.forEach((answer, index) => {
|
|
if (answer && answer.trim()) {
|
|
const nativeWord = nativeWords[index];
|
|
if (nativeWord && nativeWord.trim() && nativeWord !== answer) {
|
|
// Die answer ist normalerweise Bisaya, nativeWord ist Muttersprache
|
|
// Nur hinzufügen, wenn sie unterschiedlich sind (verhindert "ko" -> "ko")
|
|
vocabMap.set(`${nativeWord}-${answer}`, { learning: nativeWord, reference: answer });
|
|
debugLog(`[importantVocab] Gap Fill extrahiert - Muttersprache:`, nativeWord, `Bisaya:`, answer);
|
|
} else {
|
|
debugLog(`[importantVocab] Gap Fill übersprungen - keine Muttersprache oder gleich:`, nativeWord, answer);
|
|
}
|
|
}
|
|
});
|
|
} 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.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 };
|
|
},
|
|
buildMcRandomizedOptions() {
|
|
this.mcRandomizedOptions = {};
|
|
const exercises = this.effectiveExercises;
|
|
if (!exercises) return;
|
|
exercises.forEach((ex) => {
|
|
if (this.getExerciseType(ex) !== 'multiple_choice') return;
|
|
const built = this.randomizeMcOptionsIfPossible(ex);
|
|
if (built) {
|
|
this.mcRandomizedOptions[ex.id] = built;
|
|
}
|
|
});
|
|
},
|
|
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;
|
|
},
|
|
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);
|
|
},
|
|
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');
|
|
} else {
|
|
this.exerciseReinforcementPrepMode = false;
|
|
this.exerciseReinforcementCorrectAnswer = correctText;
|
|
this.exerciseReinforcementMessage = this.$t('socialnetwork.vocab.courses.exerciseReinforcementHint', {
|
|
count: this.exerciseRetryUnlockAttempts
|
|
});
|
|
}
|
|
this.showExerciseReinforcementDialog = true;
|
|
return;
|
|
}
|
|
|
|
// Nächste Frage im Einzel-Panel / Abschluss prüfen
|
|
this.$nextTick(() => {
|
|
if (this.sequentialPanelActive) {
|
|
const list = this.scrambledChapterExamExercises;
|
|
const idx = list.findIndex((e) => e.id === exerciseId);
|
|
if (idx >= 0 && idx < list.length - 1) {
|
|
this.exerciseSequentialIndex = idx + 1;
|
|
}
|
|
}
|
|
this.checkLessonCompletion();
|
|
});
|
|
} 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)
|
|
);
|
|
},
|
|
continueAfterVocabAnswer() {
|
|
const completedKey = this.currentVocabQuestion?.key || '';
|
|
this.advanceRepeatQueue(completedKey);
|
|
this.nextVocabQuestion();
|
|
},
|
|
checkVocabModeSwitch() {
|
|
this.updateExerciseUnlockState();
|
|
|
|
this.vocabTrainerPhase = this.hasPreviousVocab && this.currentReviewShare > 0 ? 'mixed' : 'current';
|
|
|
|
if (this.vocabTrainerMode === 'multiple_choice' && this.vocabTrainerTotalAttempts >= this.trainerExerciseUnlockAttempts) {
|
|
const successRate = (this.vocabTrainerCorrect / this.vocabTrainerTotalAttempts) * 100;
|
|
if (successRate >= 80) {
|
|
debugLog('[VocabLessonView] Mixed-Phase abgeschlossen, wechsle zu Typing');
|
|
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();
|
|
},
|
|
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') {
|
|
sourcePool = this.vocabTrainerPool;
|
|
if (this.vocabTrainerMixedPool.length > 0 && Math.random() < 0.35) {
|
|
questionSource = 'review';
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
const randomIndex = Math.floor(Math.random() * sourcePool.length);
|
|
const vocab = sourcePool[randomIndex];
|
|
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
|
|
const allTrainerVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
|
|
const prompt = this.vocabTrainerDirection === 'L2R' ? vocab.learning : vocab.reference;
|
|
const acceptableAnswers = this.getEquivalentVocabAnswers(
|
|
prompt,
|
|
this.vocabTrainerDirection,
|
|
allTrainerVocabs
|
|
);
|
|
this.currentVocabQuestion = {
|
|
vocab: vocab,
|
|
prompt,
|
|
answers: acceptableAnswers.length > 0
|
|
? acceptableAnswers
|
|
: [this.vocabTrainerDirection === 'L2R' ? vocab.reference : vocab.learning],
|
|
answer: acceptableAnswers.length > 0
|
|
? acceptableAnswers.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, '');
|
|
},
|
|
normalizeVocab(s) {
|
|
return this.normalizeComparableText(s);
|
|
},
|
|
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 normalizedUser = this.normalizeVocab(userAnswer);
|
|
const normalizedCorrectAnswers = (this.currentVocabQuestion.answers || [this.currentVocabQuestion.answer])
|
|
.map(answer => this.normalizeVocab(answer));
|
|
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.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-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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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-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>
|