2158 lines
73 KiB
Vue
2158 lines
73 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>
|
|
</div>
|
|
|
|
<p v-if="lesson.description" class="lesson-description">{{ lesson.description }}</p>
|
|
|
|
<!-- 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' }"
|
|
@click="activeTab = 'exercises'"
|
|
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">
|
|
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
|
|
|
|
<!-- Lektions-Beschreibung -->
|
|
<div v-if="lesson && lesson.description" class="lesson-description-box">
|
|
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
|
|
<p>{{ lesson.description }}</p>
|
|
</div>
|
|
|
|
<!-- Kulturelle Notizen -->
|
|
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes">
|
|
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
|
|
<p>{{ lesson.culturalNotes }}</p>
|
|
</div>
|
|
|
|
<!-- Grammatik-Erklärungen -->
|
|
<div v-if="grammarExplanations && grammarExplanations.length > 0" class="grammar-explanations">
|
|
<h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4>
|
|
<div v-for="(explanation, index) in grammarExplanations" :key="index" class="grammar-explanation-item">
|
|
<strong>{{ explanation.title }}</strong>
|
|
<p>{{ explanation.text }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Vokabeltrainer -->
|
|
<div v-if="importantVocab && importantVocab.length > 0" class="vocab-trainer-section">
|
|
<h4>{{ $t('socialnetwork.vocab.courses.vocabTrainer') }}</h4>
|
|
<div v-if="!vocabTrainerActive" class="vocab-trainer-start">
|
|
<p>{{ $t('socialnetwork.vocab.courses.vocabTrainerDescription') }}</p>
|
|
<button @click="startVocabTrainer" class="btn-start-trainer">
|
|
{{ $t('socialnetwork.vocab.courses.startVocabTrainer') }}
|
|
</button>
|
|
</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': 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>
|
|
<div v-if="currentVocabQuestion" class="vocab-question">
|
|
<div class="vocab-prompt">
|
|
<div class="vocab-direction">{{ vocabTrainerDirection === 'L2R' ? $t('socialnetwork.vocab.courses.translateTo') : $t('socialnetwork.vocab.courses.translateFrom') }}</div>
|
|
<div class="vocab-word">{{ currentVocabQuestion.prompt }}</div>
|
|
</div>
|
|
<div v-if="vocabTrainerAnswered" class="vocab-feedback" :class="{ correct: vocabTrainerLastCorrect, wrong: !vocabTrainerLastCorrect }">
|
|
<div v-if="vocabTrainerLastCorrect">{{ $t('socialnetwork.vocab.courses.correct') }}!</div>
|
|
<div v-else>
|
|
{{ $t('socialnetwork.vocab.courses.wrong') }}. {{ $t('socialnetwork.vocab.courses.correctAnswer') }}: {{ currentVocabQuestion.answer }}
|
|
</div>
|
|
</div>
|
|
<!-- Multiple Choice Modus -->
|
|
<div v-else-if="vocabTrainerMode === 'multiple_choice'" class="vocab-answer-area multiple-choice">
|
|
<div class="choice-buttons">
|
|
<button
|
|
v-for="(option, index) in vocabTrainerChoiceOptions"
|
|
:key="index"
|
|
@click="selectVocabChoice(option)"
|
|
class="choice-button"
|
|
:class="{ selected: vocabTrainerSelectedChoice === option }"
|
|
>
|
|
{{ option }}
|
|
</button>
|
|
</div>
|
|
<!-- Button entfernt: Prüfung erfolgt automatisch beim Klick auf Option -->
|
|
</div>
|
|
<!-- Texteingabe Modus -->
|
|
<div v-else class="vocab-answer-area typing">
|
|
<div v-if="vocabTrainerAutoSwitchedToTyping" class="mode-switch-notice">
|
|
<button @click="switchBackToMultipleChoice" class="btn-switch-mode">
|
|
{{ $t('socialnetwork.vocab.courses.switchBackToMultipleChoice') }}
|
|
</button>
|
|
</div>
|
|
<input
|
|
v-model="vocabTrainerAnswer"
|
|
@keydown.enter.prevent="checkVocabAnswer"
|
|
:placeholder="$t('socialnetwork.vocab.courses.enterAnswer')"
|
|
class="vocab-input"
|
|
ref="vocabInput"
|
|
/>
|
|
<button @click="checkVocabAnswer" :disabled="!vocabTrainerAnswer.trim()" class="btn-check">
|
|
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
|
|
</button>
|
|
</div>
|
|
<!-- "Weiter"-Button nur bei falscher Antwort (bei richtiger Antwort wird automatisch weiter gemacht) -->
|
|
<div v-if="vocabTrainerAnswered && !vocabTrainerLastCorrect" class="vocab-next">
|
|
<button @click="nextVocabQuestion">{{ $t('socialnetwork.vocab.courses.next') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Wichtige Begriffe Liste (nur Anzeige) -->
|
|
<div v-if="lesson && importantVocab && importantVocab.length > 0 && !vocabTrainerActive" class="vocab-list">
|
|
<h4>{{ $t('socialnetwork.vocab.courses.importantVocab') }}</h4>
|
|
<p class="vocab-info-text">{{ $t('socialnetwork.vocab.courses.vocabInfoText') }}</p>
|
|
<div class="vocab-items">
|
|
<div v-for="(vocab, index) in importantVocab" :key="index" class="vocab-item">
|
|
<strong>{{ vocab.learning }}</strong>
|
|
<span class="separator">→</span>
|
|
<span>{{ vocab.reference }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hinweis wenn keine Vokabeln vorhanden -->
|
|
<div v-else-if="lesson && (!importantVocab || importantVocab.length === 0)" class="no-vocab-info">
|
|
<p>{{ $t('socialnetwork.vocab.courses.noVocabInfo') }}</p>
|
|
</div>
|
|
|
|
<!-- Button um zu Übungen zu wechseln -->
|
|
<div v-if="hasExercises" class="continue-to-exercises">
|
|
<button @click="activeTab = 'exercises'" class="btn-continue">
|
|
{{ $t('socialnetwork.vocab.courses.startExercises') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Übungen-Tab (Kapitel-Prüfung) -->
|
|
<div v-if="activeTab === 'exercises'" class="grammar-exercises">
|
|
<div v-if="lesson && effectiveExercises && effectiveExercises.length > 0">
|
|
<h3>{{ $t('socialnetwork.vocab.courses.grammarExercises') }}</h3>
|
|
<div v-for="exercise in effectiveExercises" :key="exercise.id" class="exercise-item">
|
|
<h4>{{ exercise.title }}</h4>
|
|
<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>
|
|
|
|
<!-- 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">{{ $t('socialnetwork.vocab.courses.readingAloudInstruction') }}</p>
|
|
<div 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-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)"
|
|
@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">{{ $t('socialnetwork.vocab.courses.speakingFromMemoryInstruction') }}</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 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-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)"
|
|
@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>Übungstyp: {{ getExerciseType(exercise) }}</p>
|
|
<pre>{{ JSON.stringify(exercise, null, 2) }}</pre>
|
|
</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>
|
|
</template>
|
|
|
|
<script>
|
|
import { mapGetters } from 'vuex';
|
|
import apiClient from '@/utils/axios.js';
|
|
|
|
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 } }
|
|
vocabTrainerChoiceOptions: [],
|
|
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,
|
|
nextLessonId: null,
|
|
showCompletionDialog: false,
|
|
showErrorDialog: false,
|
|
errorMessage: ''
|
|
};
|
|
},
|
|
computed: {
|
|
...mapGetters(['user']),
|
|
hasExercises() {
|
|
const exercises = this.effectiveExercises;
|
|
return exercises && Array.isArray(exercises) && exercises.length > 0;
|
|
},
|
|
/** 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 [];
|
|
},
|
|
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 [];
|
|
}
|
|
},
|
|
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) {
|
|
console.log('[importantVocab] Keine Übungen vorhanden');
|
|
return [];
|
|
}
|
|
return this._extractVocabFromExercises(exercises);
|
|
} catch (e) {
|
|
console.error('Fehler in importantVocab computed property:', e);
|
|
return [];
|
|
}
|
|
}
|
|
},
|
|
watch: {
|
|
courseId(newVal, oldVal) {
|
|
if (newVal !== oldVal) {
|
|
// Reset Flags beim Kurswechsel
|
|
this.isCheckingLessonCompletion = false;
|
|
this.isNavigatingToNext = false;
|
|
this.loadLesson();
|
|
}
|
|
},
|
|
lessonId(newVal, oldVal) {
|
|
if (newVal !== oldVal) {
|
|
// Reset Flags beim Lektionswechsel
|
|
this.isCheckingLessonCompletion = false;
|
|
this.isNavigatingToNext = false;
|
|
this.loadLesson();
|
|
}
|
|
}
|
|
},
|
|
methods: {
|
|
_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) {
|
|
console.log('[_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 {
|
|
console.log(`[importantVocab] Verarbeite Übung ${idx + 1}:`, exercise.title);
|
|
// Extrahiere aus questionData
|
|
const qData = this.getQuestionData(exercise);
|
|
const aData = this.getAnswerData(exercise);
|
|
|
|
console.log(`[importantVocab] qData:`, qData);
|
|
console.log(`[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] || '';
|
|
|
|
console.log(`[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 || '';
|
|
console.log(`[importantVocab] Frage:`, question);
|
|
|
|
// Pattern 1: "Wie sagt man 'X' auf Bisaya?" -> X ist Muttersprache (z.B. "Großmutter"), correctAnswer ist Bisaya (z.B. "Lola")
|
|
let match = question.match(/Wie sagt man ['"]([^'"]+)['"]/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()) {
|
|
console.log(`[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 {
|
|
console.log(`[importantVocab] Pattern 1 übersprungen - Muttersprache und Bisaya sind gleich:`, nativeWord, correctAnswer);
|
|
}
|
|
} else {
|
|
// Pattern 2: "Was bedeutet 'X'?" -> X ist Bisaya, correctAnswer ist Muttersprache
|
|
match = question.match(/Was bedeutet ['"]([^'"]+)['"]/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()) {
|
|
console.log(`[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 {
|
|
console.log(`[importantVocab] Pattern 2 übersprungen - Bisaya und Muttersprache sind gleich:`, bisayaWord, correctAnswer);
|
|
}
|
|
} else {
|
|
console.log(`[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]) : []);
|
|
console.log(`[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]);
|
|
|
|
console.log(`[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 });
|
|
console.log(`[importantVocab] Gap Fill extrahiert - Muttersprache:`, nativeWord, `Bisaya:`, answer);
|
|
} else {
|
|
console.log(`[importantVocab] Gap Fill übersprungen - keine Muttersprache oder gleich:`, nativeWord, answer);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
console.log(`[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());
|
|
console.log(`[_extractVocabFromExercises] Ergebnis:`, result.length, 'Vokabeln');
|
|
return result;
|
|
},
|
|
async loadLesson() {
|
|
// Verhindere mehrfaches Laden
|
|
if (this.loading) {
|
|
console.log('[VocabLessonView] loadLesson übersprungen - bereits am Laden');
|
|
return;
|
|
}
|
|
|
|
console.log('[VocabLessonView] loadLesson gestartet für lessonId:', this.lessonId);
|
|
this.loading = true;
|
|
// Setze Antworten und Ergebnisse zurück
|
|
this.exerciseAnswers = {};
|
|
this.exerciseResults = {};
|
|
// 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';
|
|
}
|
|
|
|
try {
|
|
const res = await apiClient.get(`/api/vocab/lessons/${this.lessonId}`);
|
|
this.lesson = res.data;
|
|
console.log('[VocabLessonView] Geladene Lektion:', this.lesson?.id, this.lesson?.title);
|
|
// Initialisiere mit effectiveExercises (für Review: reviewVocabExercises, sonst: grammarExercises)
|
|
this.$nextTick(async () => {
|
|
const exercises = this.effectiveExercises;
|
|
if (exercises && exercises.length > 0) {
|
|
console.log('[VocabLessonView] Übungen für Kapitel-Prüfung:', exercises.length);
|
|
this.initializeExercises(exercises);
|
|
} else {
|
|
console.log('[VocabLessonView] Lade Übungen separat...');
|
|
await this.loadGrammarExercises();
|
|
}
|
|
});
|
|
console.log('[VocabLessonView] loadLesson abgeschlossen');
|
|
} catch (e) {
|
|
console.error('[VocabLessonView] Fehler beim Laden der Lektion:', e);
|
|
} finally {
|
|
this.loading = 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;
|
|
this.initializeExercises(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'
|
|
};
|
|
return typeMap[exercise.exerciseTypeId] || 'unknown';
|
|
},
|
|
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;
|
|
},
|
|
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 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') {
|
|
// Multiple Choice: Index als Zahl
|
|
answer = Number(answer);
|
|
} else if (exerciseType === 'transformation') {
|
|
// Transformation: String
|
|
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;
|
|
|
|
// Prüfe ob alle Übungen bestanden sind (mit Verzögerung, um mehrfache Aufrufe zu vermeiden)
|
|
this.$nextTick(() => {
|
|
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) {
|
|
console.log('[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) {
|
|
console.log('[VocabLessonView] checkLessonCompletion übersprungen - keine Lektion/Übungen');
|
|
return;
|
|
}
|
|
if (allExercises.length === 0) {
|
|
console.log('[VocabLessonView] checkLessonCompletion übersprungen - keine Übungen');
|
|
return;
|
|
}
|
|
|
|
// Prüfe ob alle Übungen korrekt beantwortet wurden
|
|
const allCompleted = allExercises.every(exercise => {
|
|
const result = this.exerciseResults[exercise.id];
|
|
return result && result.correct;
|
|
});
|
|
|
|
console.log('[VocabLessonView] checkLessonCompletion - allCompleted:', allCompleted, 'Übungen:', allExercises.length, 'Korrekt:', allExercises.filter(ex => this.exerciseResults[ex.id]?.correct).length);
|
|
|
|
if (allCompleted && !this.isCheckingLessonCompletion) {
|
|
this.isCheckingLessonCompletion = true;
|
|
console.log('[VocabLessonView] Alle Übungen abgeschlossen - starte Fortschritts-Update');
|
|
|
|
try {
|
|
// Berechne Gesamt-Score
|
|
const totalExercises = allExercises.length;
|
|
const correctExercises = allExercises.filter(ex => this.exerciseResults[ex.id]?.correct).length;
|
|
const score = Math.round((correctExercises / totalExercises) * 100);
|
|
|
|
console.log('[VocabLessonView] Score berechnet:', score, '%');
|
|
|
|
// Aktualisiere Fortschritt
|
|
await apiClient.put(`/api/vocab/lessons/${this.lessonId}/progress`, {
|
|
completed: true,
|
|
score: score,
|
|
timeSpentMinutes: 0 // TODO: Zeit tracken
|
|
});
|
|
|
|
console.log('[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) {
|
|
console.log('[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) {
|
|
console.log('[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];
|
|
console.log('[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
|
|
console.log('[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) {
|
|
console.log('[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() {
|
|
console.log('[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() {
|
|
console.log('[VocabLessonView] startVocabTrainer aufgerufen');
|
|
if (!this.importantVocab || this.importantVocab.length === 0) {
|
|
console.log('[VocabLessonView] Keine Vokabeln vorhanden');
|
|
return;
|
|
}
|
|
console.log('[VocabLessonView] Vokabeln gefunden:', this.importantVocab.length);
|
|
this.vocabTrainerActive = true;
|
|
this.vocabTrainerPool = [...this.importantVocab];
|
|
this.vocabTrainerMode = 'multiple_choice';
|
|
this.vocabTrainerAutoSwitchedToTyping = false;
|
|
this.vocabTrainerCorrect = 0;
|
|
this.vocabTrainerWrong = 0;
|
|
this.vocabTrainerTotalAttempts = 0;
|
|
this.vocabTrainerStats = {};
|
|
console.log('[VocabLessonView] Rufe nextVocabQuestion auf');
|
|
this.$nextTick(() => {
|
|
this.nextVocabQuestion();
|
|
});
|
|
},
|
|
stopVocabTrainer() {
|
|
this.vocabTrainerActive = false;
|
|
this.vocabTrainerMode = 'multiple_choice';
|
|
this.vocabTrainerAutoSwitchedToTyping = false;
|
|
this.currentVocabQuestion = null;
|
|
this.vocabTrainerAnswer = '';
|
|
this.vocabTrainerSelectedChoice = null;
|
|
this.vocabTrainerAnswered = false;
|
|
},
|
|
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];
|
|
},
|
|
checkVocabModeSwitch() {
|
|
// Wechsle zu Texteingabe wenn 80% erreicht und mindestens 20 Versuche
|
|
if (this.vocabTrainerMode === 'multiple_choice' && this.vocabTrainerTotalAttempts >= 20) {
|
|
const successRate = (this.vocabTrainerCorrect / this.vocabTrainerTotalAttempts) * 100;
|
|
if (successRate >= 80) {
|
|
this.vocabTrainerMode = 'typing';
|
|
this.vocabTrainerAutoSwitchedToTyping = true; // Markiere als automatisch gewechselt
|
|
// Reset Stats für Texteingabe-Modus
|
|
this.vocabTrainerCorrect = 0;
|
|
this.vocabTrainerWrong = 0;
|
|
this.vocabTrainerTotalAttempts = 0;
|
|
}
|
|
}
|
|
},
|
|
switchBackToMultipleChoice() {
|
|
// Wechsle zurück zu Multiple Choice
|
|
this.vocabTrainerMode = 'multiple_choice';
|
|
this.vocabTrainerAutoSwitchedToTyping = false;
|
|
// 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();
|
|
},
|
|
buildChoiceOptions(correctAnswer, allVocabs, excludePrompt = null) {
|
|
const options = new Set([correctAnswer]);
|
|
// Normalisiere alle Werte für den Vergleich (trim + lowercase)
|
|
const normalizedExcludeSet = new Set();
|
|
normalizedExcludeSet.add(this.normalizeVocab(correctAnswer));
|
|
// Wichtig: Der Prompt (die Frage) darf nicht in den Optionen erscheinen
|
|
if (excludePrompt) {
|
|
normalizedExcludeSet.add(this.normalizeVocab(excludePrompt));
|
|
}
|
|
|
|
// Sammle alle verfügbaren Distraktoren aus beiden Richtungen
|
|
const allDistractors = [];
|
|
allVocabs.forEach(vocab => {
|
|
// Füge beide Richtungen hinzu (learning und reference)
|
|
if (vocab.learning && vocab.learning.trim()) {
|
|
const normalized = this.normalizeVocab(vocab.learning);
|
|
if (!normalizedExcludeSet.has(normalized)) {
|
|
allDistractors.push(vocab.learning);
|
|
}
|
|
}
|
|
if (vocab.reference && vocab.reference.trim()) {
|
|
const normalized = this.normalizeVocab(vocab.reference);
|
|
if (!normalizedExcludeSet.has(normalized)) {
|
|
allDistractors.push(vocab.reference);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Entferne Duplikate
|
|
const uniqueDistractors = [...new Set(allDistractors)];
|
|
|
|
// Füge Distraktoren hinzu, bis wir 4 Optionen haben
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Falls immer noch nicht genug Optionen: Verwende auch die andere Richtung der aktuellen Vokabeln
|
|
if (options.size < 4 && allVocabs.length > 0) {
|
|
allVocabs.forEach(vocab => {
|
|
if (options.size >= 4) return;
|
|
|
|
// Verwende die andere Richtung als Distraktor
|
|
const otherDirection = this.vocabTrainerDirection === 'L2R' ? vocab.learning : vocab.reference;
|
|
if (otherDirection && otherDirection.trim()) {
|
|
const normalized = this.normalizeVocab(otherDirection);
|
|
if (!normalizedExcludeSet.has(normalized) && !options.has(otherDirection)) {
|
|
options.add(otherDirection);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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() {
|
|
console.log('[VocabLessonView] nextVocabQuestion aufgerufen');
|
|
if (!this.vocabTrainerPool || this.vocabTrainerPool.length === 0) {
|
|
console.log('[VocabLessonView] Keine Vokabeln im Pool');
|
|
this.currentVocabQuestion = null;
|
|
return;
|
|
}
|
|
|
|
// Prüfe ob Modus-Wechsel nötig ist
|
|
this.checkVocabModeSwitch();
|
|
|
|
// Wähle zufällige Vokabel
|
|
const randomIndex = Math.floor(Math.random() * this.vocabTrainerPool.length);
|
|
const vocab = this.vocabTrainerPool[randomIndex];
|
|
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
|
|
this.currentVocabQuestion = {
|
|
vocab: vocab,
|
|
prompt: this.vocabTrainerDirection === 'L2R' ? vocab.learning : vocab.reference,
|
|
answer: this.vocabTrainerDirection === 'L2R' ? vocab.reference : vocab.learning,
|
|
key: this.getVocabKey(vocab)
|
|
};
|
|
|
|
console.log('[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') {
|
|
console.log('[VocabLessonView] Erstelle Choice-Optionen...');
|
|
console.log('[VocabLessonView] Prompt:', this.currentVocabQuestion.prompt);
|
|
console.log('[VocabLessonView] Answer:', this.currentVocabQuestion.answer);
|
|
// Wichtig: Der Prompt (die Frage) darf nicht in den Optionen erscheinen
|
|
this.vocabTrainerChoiceOptions = this.buildChoiceOptions(
|
|
this.currentVocabQuestion.answer,
|
|
this.vocabTrainerPool,
|
|
this.currentVocabQuestion.prompt // Exkludiere den Prompt
|
|
);
|
|
console.log('[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();
|
|
}
|
|
},
|
|
normalizeVocab(s) {
|
|
return String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
},
|
|
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 normalizedCorrect = this.normalizeVocab(this.currentVocabQuestion.answer);
|
|
this.vocabTrainerLastCorrect = normalizedUser === normalizedCorrect;
|
|
|
|
// Update Stats
|
|
const stats = this.getVocabStats(this.currentVocabQuestion.vocab);
|
|
stats.attempts++;
|
|
this.vocabTrainerTotalAttempts++;
|
|
|
|
if (this.vocabTrainerLastCorrect) {
|
|
this.vocabTrainerCorrect++;
|
|
stats.correct++;
|
|
} else {
|
|
this.vocabTrainerWrong++;
|
|
stats.wrong++;
|
|
}
|
|
|
|
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.nextVocabQuestion();
|
|
}
|
|
}, 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 this.loadLesson();
|
|
},
|
|
beforeUnmount() {
|
|
// Stoppe alle aktiven Recognition-Instanzen
|
|
Object.keys(this.activeRecognition).forEach(exerciseId => {
|
|
this.stopRecognition(exerciseId);
|
|
});
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.vocab-lesson-view {
|
|
padding: 20px;
|
|
}
|
|
|
|
.lesson-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.btn-back {
|
|
padding: 8px 16px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
background: white;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.lesson-description {
|
|
color: #666;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.grammar-exercises {
|
|
margin-top: 30px;
|
|
}
|
|
|
|
.exercise-item {
|
|
background: white;
|
|
padding: 15px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.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: #4CAF50;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
margin-top: 10px;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.exercise-item button:hover:not(:disabled) {
|
|
background: #45a049;
|
|
}
|
|
|
|
.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 pre {
|
|
margin-top: 10px;
|
|
font-size: 0.85em;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
/* Tabs */
|
|
.lesson-tabs {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin: 20px 0;
|
|
border-bottom: 2px solid #ddd;
|
|
}
|
|
|
|
.tab-button {
|
|
padding: 10px 20px;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
color: #666;
|
|
border-bottom: 2px solid transparent;
|
|
margin-bottom: -2px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.tab-button:hover:not(:disabled) {
|
|
color: #333;
|
|
background: #f5f5f5;
|
|
}
|
|
|
|
.tab-button.active {
|
|
color: #007bff;
|
|
border-bottom-color: #007bff;
|
|
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 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;
|
|
}
|
|
|
|
.lesson-description-box {
|
|
margin: 20px 0;
|
|
padding: 15px;
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.lesson-description-box h4 {
|
|
margin-top: 0;
|
|
color: #333;
|
|
}
|
|
|
|
.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: 15px;
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.vocab-trainer-section h4 {
|
|
margin-top: 0;
|
|
color: #333;
|
|
}
|
|
|
|
.vocab-trainer-start {
|
|
text-align: center;
|
|
}
|
|
|
|
.btn-start-trainer {
|
|
padding: 10px 20px;
|
|
background: #4CAF50;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.btn-start-trainer:hover {
|
|
background: #45a049;
|
|
}
|
|
|
|
.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: #007bff;
|
|
color: white;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.mode-badge.mode-completed {
|
|
background: #28a745;
|
|
color: white;
|
|
}
|
|
|
|
.btn-stop-trainer {
|
|
padding: 5px 15px;
|
|
background: #dc3545;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.btn-stop-trainer:hover {
|
|
background: #c82333;
|
|
}
|
|
|
|
.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: var(--color-primary-orange);
|
|
color: #000000;
|
|
border: 1px solid var(--color-primary-orange);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.9em;
|
|
font-weight: 500;
|
|
transition: background 0.05s;
|
|
}
|
|
|
|
.btn-switch-mode:hover {
|
|
background: var(--color-primary-orange-light);
|
|
color: var(--color-text-secondary);
|
|
border: 1px solid var(--color-text-secondary);
|
|
}
|
|
|
|
.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: #007bff;
|
|
background: #f0f8ff;
|
|
}
|
|
|
|
.choice-button.selected {
|
|
border-color: #007bff;
|
|
background: #007bff;
|
|
color: white;
|
|
}
|
|
|
|
.vocab-input {
|
|
flex: 1;
|
|
padding: 10px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-size: 1em;
|
|
}
|
|
|
|
.btn-check {
|
|
padding: 10px 20px;
|
|
background: #4CAF50;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
}
|
|
|
|
.btn-check:hover:not(:disabled) {
|
|
background: #45a049;
|
|
}
|
|
|
|
.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: #007bff;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
}
|
|
|
|
.vocab-next button:hover {
|
|
background: #0056b3;
|
|
}
|
|
|
|
.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: #007bff;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-size: 1.1em;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.btn-continue:hover {
|
|
background: #0056b3;
|
|
}
|
|
|
|
/* Reading Aloud & Speaking From Memory Styles */
|
|
.reading-aloud-exercise,
|
|
.speaking-from-memory-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: #28a745;
|
|
color: white;
|
|
}
|
|
|
|
.btn-record:hover:not(:disabled) {
|
|
background: #218838;
|
|
}
|
|
|
|
.btn-record:disabled {
|
|
background: #6c757d;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-stop-record {
|
|
background: #dc3545;
|
|
color: white;
|
|
}
|
|
|
|
.btn-stop-record:hover {
|
|
background: #c82333;
|
|
}
|
|
|
|
.btn-check {
|
|
padding: 10px 20px;
|
|
background: #007bff;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.btn-check:hover {
|
|
background: #0056b3;
|
|
}
|
|
|
|
.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: #007bff;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1em;
|
|
}
|
|
|
|
.dialog-button:hover {
|
|
background: #0056b3;
|
|
}
|
|
</style>
|