All checks were successful
Deploy to production / deploy (push) Successful in 1m55s
- Added event dispatching for 'yourpart:hardvocab:changed' in both VocabPracticeDialog and VocabCourseView components to notify changes in hard vocabulary items. - Implemented event handling in VocabCourseView to refresh the hard vocabulary list when the event is triggered, ensuring UI consistency across components. - Included error handling for environments that do not support CustomEvent, enhancing robustness.
1362 lines
46 KiB
Vue
1362 lines
46 KiB
Vue
<template>
|
|
<DialogWidget
|
|
ref="dialog"
|
|
:title="$t('socialnetwork.vocab.practice.title')"
|
|
:show-close="false"
|
|
:buttons="buttons"
|
|
:modal="true"
|
|
:isTitleTranslated="false"
|
|
width="64em"
|
|
height="40em"
|
|
name="VocabPracticeDialog"
|
|
display="flex"
|
|
>
|
|
<div class="layout">
|
|
<div class="left">
|
|
<div class="opts">
|
|
<label class="chk">
|
|
<input type="checkbox" v-model="allVocabs" :disabled="srsMode" @change="reloadPool" />
|
|
{{ $t('socialnetwork.vocab.practice.allVocabs') }}
|
|
</label>
|
|
<label class="chk">
|
|
<input type="checkbox" v-model="simpleMode" @change="onSimpleModeChanged" />
|
|
{{ $t('socialnetwork.vocab.practice.simple') }}
|
|
</label>
|
|
</div>
|
|
|
|
<div v-if="loading">{{ $t('general.loading') }}</div>
|
|
<div v-else-if="pool.length === 0">
|
|
{{ $t('socialnetwork.vocab.practice.noPool') }}
|
|
</div>
|
|
<div v-else-if="srsMode && srsQueueIds.length === 0" class="srs-finished">
|
|
<div class="srs-finished__title">{{ $t('socialnetwork.vocab.practice.srsFinishedTitle') }}</div>
|
|
<div class="srs-finished__desc">{{ $t('socialnetwork.vocab.practice.srsFinishedDesc') }}</div>
|
|
</div>
|
|
<div v-else>
|
|
<div class="prompt">
|
|
<div class="dir">{{ directionLabel }}</div>
|
|
<div class="word">{{ currentPrompt }}</div>
|
|
</div>
|
|
|
|
<div v-if="answered" class="feedback" :class="{ ok: lastCorrect, bad: !lastCorrect }">
|
|
<div v-if="lastCorrect">{{ $t('socialnetwork.vocab.practice.correct') }}</div>
|
|
<div v-else>
|
|
{{ $t('socialnetwork.vocab.practice.wrong') }}
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showSrsRatingButtons" class="srs-rating">
|
|
<div class="srs-rating__title">{{ $t('socialnetwork.vocab.practice.srsRateTitle') }}</div>
|
|
<button
|
|
v-for="option in srsRatingOptions"
|
|
:key="option.value"
|
|
type="button"
|
|
class="srs-rating__button"
|
|
:class="`srs-rating__button--${option.value}`"
|
|
:disabled="locked"
|
|
@click="submitSrsRating(option.value)"
|
|
>
|
|
<strong>{{ option.label }}</strong>
|
|
<span>{{ option.hint }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="!answered" class="answerArea">
|
|
<div v-if="simpleMode" class="choices">
|
|
<button
|
|
v-for="opt in choiceOptions"
|
|
:key="opt"
|
|
class="choiceBtn"
|
|
:disabled="locked"
|
|
@click="submitChoice(opt)"
|
|
>
|
|
{{ opt }}
|
|
</button>
|
|
</div>
|
|
<div v-else class="typing">
|
|
<input
|
|
ref="answerInput"
|
|
v-model="typedAnswer"
|
|
type="text"
|
|
:disabled="locked"
|
|
@keydown.enter.prevent="submitTyped"
|
|
/>
|
|
<button :disabled="locked || typedAnswer.trim().length === 0" @click="submitTyped">
|
|
{{ $t('socialnetwork.vocab.practice.check') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<button v-if="showNextButton" @click="next">
|
|
{{ $t('socialnetwork.vocab.practice.next') }}
|
|
</button>
|
|
<button v-else-if="showSkipButton" @click="skip">
|
|
{{ $t('socialnetwork.vocab.practice.skip') }}
|
|
</button>
|
|
<button v-if="current && !isCurrentMarkedHard" @click="markCurrentAsHard">
|
|
{{ $t('socialnetwork.vocab.practice.markHard') }}
|
|
</button>
|
|
<button v-else-if="current && isCurrentMarkedHard" @click="unmarkCurrentAsHard">
|
|
{{ $t('socialnetwork.vocab.practice.unmarkHard') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="lastWrongReview" class="solution-card solution-card--persistent">
|
|
<div class="answersTitle">{{ $t('socialnetwork.vocab.practice.wrongReviewTitle') }}</div>
|
|
<div class="solution-row">
|
|
<span>{{ $t('socialnetwork.vocab.practice.askedVocab') }}</span>
|
|
<strong>{{ lastWrongReview.prompt }}</strong>
|
|
</div>
|
|
<div v-if="lastWrongReview.submittedAnswer" class="solution-row">
|
|
<span>{{ $t('socialnetwork.vocab.practice.yourAnswer') }}</span>
|
|
<strong>{{ lastWrongReview.submittedAnswer }}</strong>
|
|
</div>
|
|
<div class="answersTitle">{{ $t('socialnetwork.vocab.practice.correctSolutions') }}</div>
|
|
<ul>
|
|
<li v-for="a in lastWrongReview.answers" :key="a">{{ a }}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="right">
|
|
<div class="stat">
|
|
<div class="statTitle">{{ $t('socialnetwork.vocab.practice.stats') }}</div>
|
|
<div v-if="srsMode" class="statRow">
|
|
<span class="k">{{ $t('socialnetwork.vocab.practice.dueToday') }}</span>
|
|
<span class="v">{{ srsTotalDue }}</span>
|
|
</div>
|
|
<div v-if="srsMode" class="statRow">
|
|
<span class="k">{{ $t('socialnetwork.vocab.practice.done') }}</span>
|
|
<span class="v">{{ srsDoneCount }}</span>
|
|
</div>
|
|
<div v-if="srsMode" class="statRow">
|
|
<span class="k">{{ $t('socialnetwork.vocab.practice.remaining') }}</span>
|
|
<span class="v">{{ srsRemainingCount }}</span>
|
|
</div>
|
|
<div class="statRow">
|
|
<span class="k">{{ $t('socialnetwork.vocab.practice.success') }}</span>
|
|
<span class="v">{{ correctCount }} ({{ successPercent }}%)</span>
|
|
</div>
|
|
<div class="statRow">
|
|
<span class="k">{{ $t('socialnetwork.vocab.practice.fail') }}</span>
|
|
<span class="v">{{ wrongCount }} ({{ failPercent }}%)</span>
|
|
</div>
|
|
<div class="statRow">
|
|
<span class="k">{{ $t('socialnetwork.vocab.practice.hardCount') }}</span>
|
|
<span class="v">{{ hardCount }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogWidget>
|
|
</template>
|
|
|
|
<script>
|
|
import DialogWidget from '@/components/DialogWidget.vue';
|
|
import apiClient from '@/utils/axios.js';
|
|
|
|
const PRACTICE_MIN_EXPOSURES = 3;
|
|
const SRS_SESSION_STORAGE_VERSION = 2;
|
|
const HARD_REQUIRED_CONSECUTIVE_CORRECT = 5;
|
|
const SRS_AGAIN_REINSERT_OFFSET = 3;
|
|
|
|
export default {
|
|
name: 'VocabPracticeDialog',
|
|
components: { DialogWidget },
|
|
data() {
|
|
return {
|
|
openParams: null, // { languageId, chapterId, lessonId, courseId }
|
|
onClose: null,
|
|
loading: false,
|
|
allVocabs: false,
|
|
srsMode: false,
|
|
initialPool: null,
|
|
simpleMode: false,
|
|
pool: [],
|
|
|
|
// SRS session (Langzeitgedächtnis) progress & persistence
|
|
// Stored per day and course so the user can close/reopen without starting over.
|
|
srsSession: null, // { version, dateKey, courseId, initialTotalDue, initialDueIds, doneIds, correctCount, wrongCount }
|
|
srsQueueIds: [], // remaining due ids for this session, in due order
|
|
|
|
// session stats
|
|
correctCount: 0,
|
|
wrongCount: 0,
|
|
perId: {}, // { [id]: { c, w, streak, lastAsked } }
|
|
lastIds: [],
|
|
|
|
// current question
|
|
current: null, // { id, learning, reference }
|
|
direction: 'L2R', // L2R: learning->reference, R2L: reference->learning
|
|
pendingRetry: null, // { id, direction } for immediate retry after wrong answers
|
|
acceptableAnswers: [],
|
|
submittedAnswer: '',
|
|
lastWrongReview: null,
|
|
choiceOptions: [],
|
|
typedAnswer: '',
|
|
answered: false,
|
|
lastCorrect: false,
|
|
locked: false,
|
|
autoAdvanceTimer: null,
|
|
hardVocabMap: {}, // { [normalizedPairKey]: { learning, reference, markedAt } }
|
|
hardPhaseActive: false,
|
|
hardMasteryByKey: {}, // { [hardKey]: consecutiveCorrect }
|
|
cycleAskedIds: [],
|
|
};
|
|
},
|
|
computed: {
|
|
buttons() {
|
|
return [{ text: this.$t('message.close'), action: this.close }];
|
|
},
|
|
totalCount() {
|
|
return this.correctCount + this.wrongCount;
|
|
},
|
|
successPercent() {
|
|
if (this.totalCount === 0) return 0;
|
|
return Math.round((this.correctCount / this.totalCount) * 100);
|
|
},
|
|
failPercent() {
|
|
if (this.totalCount === 0) return 0;
|
|
return Math.round((this.wrongCount / this.totalCount) * 100);
|
|
},
|
|
currentPrompt() {
|
|
if (!this.current) return '';
|
|
return this.direction === 'L2R' ? this.current.learning : this.current.reference;
|
|
},
|
|
directionLabel() {
|
|
return this.direction === 'L2R'
|
|
? this.$t('socialnetwork.vocab.practice.dirLearningToRef')
|
|
: this.$t('socialnetwork.vocab.practice.dirRefToLearning');
|
|
},
|
|
showNextButton() {
|
|
// Nur bei falscher Antwort auf "Weiter" warten
|
|
return this.answered && !this.lastCorrect && !this.srsMode;
|
|
},
|
|
showSkipButton() {
|
|
return !this.answered;
|
|
},
|
|
showSrsRatingButtons() {
|
|
return this.srsMode && this.answered && !this.locked;
|
|
},
|
|
visibleCorrectAnswers() {
|
|
const answers = Array.isArray(this.acceptableAnswers) ? this.acceptableAnswers.filter(Boolean) : [];
|
|
if (answers.length > 0) {
|
|
return answers;
|
|
}
|
|
if (!this.current) {
|
|
return [];
|
|
}
|
|
const fallback = this.direction === 'L2R' ? this.current.reference : this.current.learning;
|
|
return this.expandAnswerVariants(fallback);
|
|
},
|
|
srsRatingOptions() {
|
|
if (!this.answered) {
|
|
return [];
|
|
}
|
|
if (!this.lastCorrect) {
|
|
return [
|
|
{
|
|
value: 'again',
|
|
label: this.$t('socialnetwork.vocab.practice.srsAgain'),
|
|
hint: this.$t('socialnetwork.vocab.practice.srsAgainHint')
|
|
}
|
|
];
|
|
}
|
|
return [
|
|
{
|
|
value: 'hard',
|
|
label: this.$t('socialnetwork.vocab.practice.srsHard'),
|
|
hint: this.$t('socialnetwork.vocab.practice.srsHardHint')
|
|
},
|
|
{
|
|
value: 'good',
|
|
label: this.$t('socialnetwork.vocab.practice.srsGood'),
|
|
hint: this.$t('socialnetwork.vocab.practice.srsGoodHint')
|
|
},
|
|
{
|
|
value: 'easy',
|
|
label: this.$t('socialnetwork.vocab.practice.srsEasy'),
|
|
hint: this.$t('socialnetwork.vocab.practice.srsEasyHint')
|
|
}
|
|
];
|
|
},
|
|
srsTotalDue() {
|
|
return Number(this.srsSession?.initialTotalDue || 0) || 0;
|
|
},
|
|
srsDoneCount() {
|
|
const done = Array.isArray(this.srsSession?.doneIds) ? this.srsSession.doneIds.length : 0;
|
|
return Math.min(done, this.srsTotalDue || 0);
|
|
},
|
|
srsRemainingCount() {
|
|
const total = this.srsTotalDue || 0;
|
|
return Math.max(0, total - this.srsDoneCount);
|
|
},
|
|
hardCount() {
|
|
return Object.keys(this.hardVocabMap || {}).length;
|
|
},
|
|
isCurrentMarkedHard() {
|
|
if (!this.current) return false;
|
|
return this.isItemMarkedHard(this.current);
|
|
},
|
|
hardPoolItems() {
|
|
if (!Array.isArray(this.pool) || this.pool.length === 0) return [];
|
|
return this.pool.filter((item) => this.isItemMarkedHard(item));
|
|
},
|
|
hardRemainingCount() {
|
|
return this.hardPoolItems.filter((item) => {
|
|
const key = this.getHardKey(item);
|
|
return (Number(this.hardMasteryByKey[key]) || 0) < HARD_REQUIRED_CONSECUTIVE_CORRECT;
|
|
}).length;
|
|
}
|
|
},
|
|
methods: {
|
|
hardStorageKey() {
|
|
const courseId = this.openParams?.courseId;
|
|
if (!courseId) return null;
|
|
return `yourpart:vocab:hardList:${courseId}`;
|
|
},
|
|
loadHardVocabMap() {
|
|
const key = this.hardStorageKey();
|
|
if (!key) {
|
|
this.hardVocabMap = {};
|
|
return;
|
|
}
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
const parsed = raw ? JSON.parse(raw) : {};
|
|
this.hardVocabMap = parsed && typeof parsed === 'object' ? parsed : {};
|
|
} catch (_) {
|
|
this.hardVocabMap = {};
|
|
}
|
|
},
|
|
saveHardVocabMap() {
|
|
const key = this.hardStorageKey();
|
|
if (!key) return;
|
|
try {
|
|
localStorage.setItem(key, JSON.stringify(this.hardVocabMap || {}));
|
|
} catch (_) {
|
|
// ignore quota/private-mode issues
|
|
}
|
|
try {
|
|
window.dispatchEvent(new CustomEvent('yourpart:hardvocab:changed', {
|
|
detail: {
|
|
courseId: this.openParams?.courseId || null,
|
|
storageKey: key
|
|
}
|
|
}));
|
|
} catch (_) {
|
|
// ignore environments without CustomEvent
|
|
}
|
|
},
|
|
getHardKey(item) {
|
|
const learning = this.normalize(item?.learning || '');
|
|
const reference = this.normalize(item?.reference || '');
|
|
return `${learning}|${reference}`;
|
|
},
|
|
findMatchingHardKey(item) {
|
|
const exactKey = this.getHardKey(item);
|
|
if (this.hardVocabMap[exactKey]) return exactKey;
|
|
|
|
const itemLearning = this.normalize(item?.learning || '');
|
|
const itemReference = this.normalize(item?.reference || '');
|
|
if (!itemLearning || !itemReference) return null;
|
|
|
|
const entries = Object.entries(this.hardVocabMap || {});
|
|
for (const [key, value] of entries) {
|
|
const learningAlts = this.splitPhraseAlternatives(value?.learning || '')
|
|
.map((part) => this.normalize(part))
|
|
.filter(Boolean);
|
|
const referenceAlts = this.splitPhraseAlternatives(value?.reference || '')
|
|
.map((part) => this.normalize(part))
|
|
.filter(Boolean);
|
|
if (!learningAlts.length || !referenceAlts.length) continue;
|
|
if (learningAlts.includes(itemLearning) && referenceAlts.includes(itemReference)) {
|
|
return key;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
isItemMarkedHard(item) {
|
|
return Boolean(this.findMatchingHardKey(item));
|
|
},
|
|
removeHardEntriesForItem(item) {
|
|
const itemLearning = this.normalize(item?.learning || '');
|
|
const itemReference = this.normalize(item?.reference || '');
|
|
if (!itemLearning || !itemReference) return;
|
|
|
|
const toDelete = [];
|
|
Object.entries(this.hardVocabMap || {}).forEach(([key, value]) => {
|
|
const learningAlts = this.splitPhraseAlternatives(value?.learning || '')
|
|
.map((part) => this.normalize(part))
|
|
.filter(Boolean);
|
|
const referenceAlts = this.splitPhraseAlternatives(value?.reference || '')
|
|
.map((part) => this.normalize(part))
|
|
.filter(Boolean);
|
|
if (learningAlts.includes(itemLearning) && referenceAlts.includes(itemReference)) {
|
|
toDelete.push(key);
|
|
}
|
|
});
|
|
if (!toDelete.length) return;
|
|
|
|
const next = { ...this.hardVocabMap };
|
|
const nextMastery = { ...this.hardMasteryByKey };
|
|
toDelete.forEach((key) => {
|
|
delete next[key];
|
|
delete nextMastery[key];
|
|
});
|
|
this.hardVocabMap = next;
|
|
this.hardMasteryByKey = nextMastery;
|
|
},
|
|
markCurrentAsHard() {
|
|
if (!this.current) return;
|
|
this.removeHardEntriesForItem(this.current);
|
|
const key = this.getHardKey(this.current);
|
|
this.hardVocabMap = {
|
|
...this.hardVocabMap,
|
|
[key]: {
|
|
learning: this.current.learning,
|
|
reference: this.current.reference,
|
|
markedAt: new Date().toISOString()
|
|
}
|
|
};
|
|
if (this.hardMasteryByKey[key] == null) {
|
|
this.hardMasteryByKey[key] = 0;
|
|
}
|
|
this.saveHardVocabMap();
|
|
},
|
|
unmarkCurrentAsHard() {
|
|
if (!this.current) return;
|
|
const matchingKey = this.findMatchingHardKey(this.current);
|
|
if (!matchingKey) return;
|
|
this.removeHardEntriesForItem(this.current);
|
|
if (this.hardPhaseActive && this.hardRemainingCount <= 0) {
|
|
this.hardPhaseActive = false;
|
|
}
|
|
this.saveHardVocabMap();
|
|
},
|
|
addCurrentToCycle() {
|
|
if (!this.current?.id) return;
|
|
if (this.cycleAskedIds.includes(this.current.id)) return;
|
|
this.cycleAskedIds = [...this.cycleAskedIds, this.current.id];
|
|
},
|
|
maybeStartHardPhase() {
|
|
if (this.srsMode || this.hardPhaseActive) return;
|
|
if (!this.pool.length || !this.hardPoolItems.length) return;
|
|
if (this.cycleAskedIds.length < this.pool.length) return;
|
|
this.hardPhaseActive = true;
|
|
},
|
|
maybeFinishHardPhase() {
|
|
if (!this.hardPhaseActive) return;
|
|
if (this.hardRemainingCount > 0) return;
|
|
this.hardPhaseActive = false;
|
|
this.cycleAskedIds = [];
|
|
},
|
|
getLocalDateKey() {
|
|
const d = new Date();
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${day}`;
|
|
},
|
|
srsStorageKey() {
|
|
const courseId = this.openParams?.courseId;
|
|
if (!courseId) return null;
|
|
return `yourpart:vocab:srsSession:v${SRS_SESSION_STORAGE_VERSION}:${courseId}:${this.getLocalDateKey()}`;
|
|
},
|
|
loadSrsSession() {
|
|
const key = this.srsStorageKey();
|
|
if (!key) return null;
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || parsed.version !== SRS_SESSION_STORAGE_VERSION) return null;
|
|
if (parsed.courseId !== this.openParams?.courseId) return null;
|
|
if (parsed.dateKey !== this.getLocalDateKey()) return null;
|
|
return parsed;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
},
|
|
saveSrsSession() {
|
|
if (!this.srsMode || !this.openParams?.courseId) return;
|
|
const key = this.srsStorageKey();
|
|
if (!key) return;
|
|
try {
|
|
const session = this.srsSession || null;
|
|
if (!session) return;
|
|
session.correctCount = this.correctCount;
|
|
session.wrongCount = this.wrongCount;
|
|
localStorage.setItem(key, JSON.stringify(session));
|
|
} catch (_) {
|
|
// ignore storage issues (private mode, quota, etc.)
|
|
}
|
|
},
|
|
initSrsSessionFromPool() {
|
|
if (!this.srsMode || !this.openParams?.courseId) {
|
|
this.srsSession = null;
|
|
this.srsQueueIds = [];
|
|
return;
|
|
}
|
|
const dueIds = (this.pool || []).map((it) => it.id);
|
|
const stored = this.loadSrsSession();
|
|
|
|
// If the previous session is already complete, start fresh (new batch of due items).
|
|
if (stored && Number(stored.initialTotalDue || 0) > 0 && Array.isArray(stored.doneIds) && stored.doneIds.length >= stored.initialTotalDue) {
|
|
this.srsSession = null;
|
|
} else if (stored) {
|
|
this.srsSession = stored;
|
|
} else {
|
|
this.srsSession = null;
|
|
}
|
|
|
|
if (!this.srsSession) {
|
|
this.srsSession = {
|
|
version: SRS_SESSION_STORAGE_VERSION,
|
|
dateKey: this.getLocalDateKey(),
|
|
courseId: this.openParams.courseId,
|
|
initialTotalDue: dueIds.length,
|
|
initialDueIds: dueIds,
|
|
doneIds: [],
|
|
correctCount: 0,
|
|
wrongCount: 0,
|
|
};
|
|
this.correctCount = 0;
|
|
this.wrongCount = 0;
|
|
} else {
|
|
// Resume: keep initialTotalDue stable (what the user started with), and prune queue to the currently due ids.
|
|
this.correctCount = Number(this.srsSession.correctCount || 0) || 0;
|
|
this.wrongCount = Number(this.srsSession.wrongCount || 0) || 0;
|
|
}
|
|
|
|
const doneSet = new Set(Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : []);
|
|
this.srsQueueIds = dueIds.filter((id) => !doneSet.has(id));
|
|
this.saveSrsSession();
|
|
},
|
|
open({ languageId, chapterId, lessonId, courseId, initialPool = null, srsMode = false, onClose = null }) {
|
|
if (this.autoAdvanceTimer) {
|
|
clearTimeout(this.autoAdvanceTimer);
|
|
this.autoAdvanceTimer = null;
|
|
}
|
|
this.openParams = { languageId, chapterId, lessonId, courseId };
|
|
this.onClose = typeof onClose === 'function' ? onClose : null;
|
|
this.srsMode = Boolean(srsMode);
|
|
this.initialPool = Array.isArray(initialPool) ? initialPool : null;
|
|
this.allVocabs = false;
|
|
this.simpleMode = false;
|
|
this.srsSession = null;
|
|
this.srsQueueIds = [];
|
|
this.correctCount = 0;
|
|
this.wrongCount = 0;
|
|
this.perId = {};
|
|
this.lastIds = [];
|
|
this.lastWrongReview = null;
|
|
this.pendingRetry = null;
|
|
this.pool = [];
|
|
this.hardVocabMap = {};
|
|
this.hardPhaseActive = false;
|
|
this.hardMasteryByKey = {};
|
|
this.cycleAskedIds = [];
|
|
this.locked = false;
|
|
this.resetQuestion();
|
|
this.$refs.dialog.open();
|
|
this.$nextTick(() => {
|
|
document.addEventListener('keydown', this.handleKeyDown);
|
|
});
|
|
this.loadHardVocabMap();
|
|
this.reloadPool();
|
|
},
|
|
close() {
|
|
if (this.autoAdvanceTimer) {
|
|
clearTimeout(this.autoAdvanceTimer);
|
|
this.autoAdvanceTimer = null;
|
|
}
|
|
this.saveSrsSession();
|
|
const cb = this.onClose;
|
|
this.onClose = null;
|
|
document.removeEventListener('keydown', this.handleKeyDown);
|
|
this.$refs.dialog.close();
|
|
try {
|
|
if (cb) cb();
|
|
} catch (_) {}
|
|
},
|
|
handleKeyDown(event) {
|
|
// Enter soll bei "Weiter" (falsch beantwortet) funktionieren.
|
|
// Im Tippmodus soll Enter weiterhin "Prüfen" auslösen (Input hat eigenen handler).
|
|
if (event.key !== 'Enter' && event.keyCode !== 13) return;
|
|
|
|
if (this.showNextButton) {
|
|
event.preventDefault();
|
|
this.next();
|
|
return;
|
|
}
|
|
|
|
// Falls man im Tippmodus ist und der Fokus NICHT im Input liegt, erlauben wir Enter als "Prüfen".
|
|
if (!this.answered && !this.simpleMode && !this.locked) {
|
|
const tag = event.target?.tagName?.toLowerCase?.();
|
|
if (tag !== 'input' && tag !== 'textarea') {
|
|
event.preventDefault();
|
|
this.submitTyped();
|
|
}
|
|
}
|
|
},
|
|
normalize(s) {
|
|
const normalized = String(s || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize('NFKC')
|
|
.replace(/[\p{P}\p{S}]+/gu, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
return normalized.replace(/\s+/g, '');
|
|
},
|
|
isInstructionLikeText(value) {
|
|
const text = String(value || '').trim();
|
|
if (!text) return false;
|
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
if (wordCount < 3) return false;
|
|
|
|
const normalized = text.toLowerCase().normalize('NFKC');
|
|
const startsWithTaskVerb = /^(sage|sag|frage|frag|bitte|stelle|sprich|erzähle|erzaehle|beschreibe|bilde|wähle|waehle|ordne|übersetze|uebersetze|nenne|nenn|beginne|verwende|nutze|reagiere|kombiniere|spiele|löse|loese|beantworte|ergänze|ergaenze|formuliere|lies|entscheide|zeige)\b/i.test(normalized);
|
|
const startsWithTakeTask = /^nimm\b/i.test(normalized)
|
|
&& (
|
|
/\b(ein|eine|einen|zwei|drei|vier|fünf|fuenf|sechs|sieben|acht|neun|zehn|\d+)\b/i.test(normalized)
|
|
|| /\b(w[oö]rter|verben|gegenstände|gegenstaende|sätze|saetze|muster|beispiele)\b/i.test(normalized)
|
|
);
|
|
const containsTaskChain = /\b(und|,)\s*(sage|sag|frage|frag|bitte|stelle|sprich|erzähle|erzaehle|beschreibe|bilde|wähle|waehle|ordne|übersetze|uebersetze|nenne|nenn|verwende|nutze|reagiere|kombiniere|spiele|löse|loese|beantworte|ergänze|ergaenze|formuliere|lies|entscheide|zeige)\b/i.test(normalized);
|
|
const containsPracticeMarker = /\b(laut|jeweils|zu jedem|zu jeder|umgebung|alltagsszene|rollenspiel|mini-dialog|szene)\b/i.test(normalized);
|
|
|
|
return startsWithTaskVerb || startsWithTakeTask || (containsTaskChain && containsPracticeMarker);
|
|
},
|
|
isTrainablePair(learning, reference) {
|
|
if (!learning || !reference || this.normalize(learning) === this.normalize(reference)) {
|
|
return false;
|
|
}
|
|
if (this.looksLikeFragmentMismatch(learning, reference)) {
|
|
return false;
|
|
}
|
|
return !this.isInstructionLikeText(learning) && !this.isInstructionLikeText(reference);
|
|
},
|
|
wordCount(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/[\p{P}\p{S}]+/gu, ' ')
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.length;
|
|
},
|
|
looksLikeFragmentMismatch(left, right) {
|
|
const leftWords = this.wordCount(left);
|
|
const rightWords = this.wordCount(right);
|
|
const leftText = String(left || '').trim();
|
|
const rightText = String(right || '').trim();
|
|
const leftLooksSentence = leftWords >= 3 || /[?!.].+\S/.test(leftText);
|
|
const rightLooksSentence = rightWords >= 3 || /[?!.].+\S/.test(rightText);
|
|
const leftLooksShortFragment = leftWords <= 1 && leftText.length <= 12;
|
|
const rightLooksShortFragment = rightWords <= 1 && rightText.length <= 12;
|
|
|
|
return (leftLooksShortFragment && rightLooksSentence) || (rightLooksShortFragment && leftLooksSentence);
|
|
},
|
|
splitPhraseAlternatives(value) {
|
|
const text = String(value || '').trim();
|
|
if (!text) return [];
|
|
const parts = text
|
|
.split(/\s+\/\s+/)
|
|
.map((part) => String(part || '').trim())
|
|
.filter(Boolean);
|
|
return parts.length >= 2 ? parts : [text];
|
|
},
|
|
expandPoolItemAlternatives(item) {
|
|
const learning = String(item?.learning || '').trim();
|
|
const reference = String(item?.reference || '').trim();
|
|
const learningParts = this.splitPhraseAlternatives(learning);
|
|
const referenceParts = this.splitPhraseAlternatives(reference);
|
|
|
|
// Common case: one prompt with multiple valid translations.
|
|
// Split into separate vocab cards so each answer is trainable.
|
|
if (learningParts.length === 1 && referenceParts.length > 1) {
|
|
return referenceParts.map((ref) => ({ ...item, learning, reference: ref }));
|
|
}
|
|
if (referenceParts.length === 1 && learningParts.length > 1) {
|
|
return learningParts.map((lrn) => ({ ...item, learning: lrn, reference }));
|
|
}
|
|
|
|
// If both sides contain parallel alternatives of equal length, keep pairs aligned.
|
|
if (learningParts.length > 1 && learningParts.length === referenceParts.length) {
|
|
return learningParts.map((lrn, idx) => ({ ...item, learning: lrn, reference: referenceParts[idx] }));
|
|
}
|
|
|
|
return [{ ...item, learning, reference }];
|
|
},
|
|
normalizePool(items = []) {
|
|
const seen = new Set();
|
|
return (Array.isArray(items) ? items : [])
|
|
.flatMap((item, index) => this.expandPoolItemAlternatives(item).map((candidate, altIndex) => ({ candidate, index, altIndex })))
|
|
.map(({ candidate, index, altIndex }) => {
|
|
const learning = String(candidate?.learning || '').trim();
|
|
const reference = String(candidate?.reference || '').trim();
|
|
if (!this.isTrainablePair(learning, reference)) return null;
|
|
const key = `${this.normalize(learning)}|${this.normalize(reference)}`;
|
|
if (seen.has(key)) return null;
|
|
seen.add(key);
|
|
return {
|
|
...candidate,
|
|
id: candidate?.id || candidate?.itemKey || candidate?.key || `${key}|${index}|${altIndex}`,
|
|
learning,
|
|
reference
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
},
|
|
resetQuestion() {
|
|
this.current = null;
|
|
this.direction = this.openParams?.lessonId ? 'L2R' : (Math.random() < 0.5 ? 'L2R' : 'R2L');
|
|
this.acceptableAnswers = [];
|
|
this.submittedAnswer = '';
|
|
this.choiceOptions = [];
|
|
this.typedAnswer = '';
|
|
this.answered = false;
|
|
this.lastCorrect = false;
|
|
this.locked = false;
|
|
},
|
|
onSimpleModeChanged() {
|
|
if (this.autoAdvanceTimer) {
|
|
clearTimeout(this.autoAdvanceTimer);
|
|
this.autoAdvanceTimer = null;
|
|
}
|
|
this.locked = false;
|
|
this.answered = false;
|
|
this.lastCorrect = false;
|
|
this.typedAnswer = '';
|
|
this.submittedAnswer = '';
|
|
|
|
if (!this.pool || this.pool.length === 0) return;
|
|
|
|
// Wenn wir aktuell keine Frage haben, sofort eine neue ziehen.
|
|
if (!this.current) {
|
|
this.next();
|
|
return;
|
|
}
|
|
|
|
// Aktuelle Frage behalten, nur UI/Antwortmodus neu aufbauen
|
|
const prompt = this.currentPrompt;
|
|
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
|
|
if (this.simpleMode) {
|
|
this.buildChoices();
|
|
} else {
|
|
this.choiceOptions = [];
|
|
this.$nextTick(() => this.$refs.answerInput?.focus?.());
|
|
}
|
|
},
|
|
async reloadPool() {
|
|
if (!this.openParams) return;
|
|
if (this.initialPool) {
|
|
this.loading = false;
|
|
this.pool = this.normalizePool(this.initialPool);
|
|
if (this.srsMode) {
|
|
this.initSrsSessionFromPool();
|
|
}
|
|
this.next();
|
|
return;
|
|
}
|
|
this.loading = true;
|
|
try {
|
|
let res;
|
|
if (this.openParams.lessonId) {
|
|
if (this.allVocabs && this.openParams.courseId) {
|
|
res = await apiClient.get(`/api/vocab/courses/${this.openParams.courseId}/completed-lesson-vocabs`, {
|
|
params: {
|
|
untilLessonId: this.openParams.lessonId
|
|
}
|
|
});
|
|
this.pool = this.normalizePool(res.data?.vocabs || []);
|
|
} else {
|
|
res = await apiClient.get(`/api/vocab/lessons/${this.openParams.lessonId}/vocab-pool`);
|
|
this.pool = this.normalizePool(res.data?.vocabs || []);
|
|
}
|
|
} else if (this.allVocabs) {
|
|
res = await apiClient.get(`/api/vocab/languages/${this.openParams.languageId}/vocabs`);
|
|
this.pool = this.normalizePool(res.data?.vocabs || []);
|
|
} else {
|
|
res = await apiClient.get(`/api/vocab/chapters/${this.openParams.chapterId}/vocabs`);
|
|
this.pool = this.normalizePool(res.data?.vocabs || []);
|
|
}
|
|
} catch (e) {
|
|
console.error('Reload pool failed:', e);
|
|
this.pool = [];
|
|
} finally {
|
|
this.loading = false;
|
|
if (this.srsMode) {
|
|
this.initSrsSessionFromPool();
|
|
}
|
|
this.next();
|
|
}
|
|
},
|
|
getAnswersForPrompt(prompt, direction) {
|
|
const p = this.normalize(prompt);
|
|
const answers = new Set();
|
|
for (const item of this.pool) {
|
|
const itemPrompt = direction === 'L2R' ? item.learning : item.reference;
|
|
if (this.normalize(itemPrompt) === p) {
|
|
const a = direction === 'L2R' ? item.reference : item.learning;
|
|
this.expandAnswerVariants(a).forEach((variant) => answers.add(variant));
|
|
}
|
|
}
|
|
return Array.from(answers);
|
|
},
|
|
expandSingleAnswerVariants(answer) {
|
|
const base = String(answer || '').trim();
|
|
if (!base) return [];
|
|
|
|
const words = base.split(/\s+/).map((word) => {
|
|
if (!word.includes('/')) {
|
|
return [word];
|
|
}
|
|
|
|
const match = word.match(/^([([{„"'“‘]*)(.*?)([)\]}.,!?;:»"'”’]*)$/);
|
|
const prefix = match?.[1] || '';
|
|
const core = match?.[2] || word;
|
|
const suffix = match?.[3] || '';
|
|
const parts = core
|
|
.split('/')
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
|
|
if (parts.length < 2 || parts.length > 4) {
|
|
return [word];
|
|
}
|
|
|
|
return parts.map((part) => `${prefix}${part}${suffix}`);
|
|
});
|
|
|
|
const variants = [''];
|
|
for (const options of words) {
|
|
const next = [];
|
|
for (const current of variants) {
|
|
for (const option of options) {
|
|
next.push(`${current}${current ? ' ' : ''}${option}`);
|
|
}
|
|
}
|
|
if (next.length > 24) {
|
|
return [base];
|
|
}
|
|
variants.splice(0, variants.length, ...next);
|
|
}
|
|
|
|
return [...new Set([base, ...variants])];
|
|
},
|
|
expandAnswerVariants(answer) {
|
|
const base = String(answer || '').trim();
|
|
if (!base) return [];
|
|
|
|
// Handle full-answer alternatives like "A / B" as separate valid answers.
|
|
// Word-level slash expansion alone does not cover this reliably.
|
|
const phraseAlternatives = this.splitPhraseAlternatives(base);
|
|
|
|
if (phraseAlternatives.length >= 2) {
|
|
const expanded = phraseAlternatives.flatMap((part) => this.expandSingleAnswerVariants(part));
|
|
return [...new Set(expanded)];
|
|
}
|
|
|
|
return this.expandSingleAnswerVariants(base);
|
|
},
|
|
computeWeight(item) {
|
|
const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
|
|
let w = 1;
|
|
w += st.w * 2.5;
|
|
w *= Math.pow(0.7, st.c);
|
|
if (st.streak > 0) {
|
|
w *= Math.pow(0.8, st.streak);
|
|
} else if (st.streak < 0) {
|
|
w *= 1 + Math.min(5, Math.abs(st.streak));
|
|
}
|
|
if (this.lastIds.includes(item.id)) w *= 0.1;
|
|
return Math.max(0.05, Math.min(50, w));
|
|
},
|
|
pickNextItem() {
|
|
const items = this.pool;
|
|
if (!items || items.length === 0) return null;
|
|
if (this.srsMode) {
|
|
const nextId = Array.isArray(this.srsQueueIds) ? this.srsQueueIds[0] : null;
|
|
if (!nextId) return null;
|
|
return items.find((it) => it.id === nextId) || null;
|
|
}
|
|
if (this.hardPhaseActive) {
|
|
const hardItems = this.hardPoolItems.filter((item) => {
|
|
const key = this.getHardKey(item);
|
|
return (Number(this.hardMasteryByKey[key]) || 0) < HARD_REQUIRED_CONSECUTIVE_CORRECT;
|
|
});
|
|
if (hardItems.length === 0) {
|
|
this.hardPhaseActive = false;
|
|
return null;
|
|
}
|
|
const rankedHard = hardItems
|
|
.map((item) => {
|
|
const key = this.getHardKey(item);
|
|
const mastery = Number(this.hardMasteryByKey[key]) || 0;
|
|
const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
|
|
return {
|
|
item,
|
|
mastery,
|
|
wrong: Number(st.w) || 0,
|
|
attempts: (Number(st.c) || 0) + (Number(st.w) || 0),
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
if (a.mastery !== b.mastery) return a.mastery - b.mastery;
|
|
if (a.wrong !== b.wrong) return b.wrong - a.wrong;
|
|
if (a.attempts !== b.attempts) return a.attempts - b.attempts;
|
|
return Math.random() - 0.5;
|
|
});
|
|
return rankedHard[0]?.item || hardItems[Math.floor(Math.random() * hardItems.length)];
|
|
}
|
|
if (this.pendingRetry?.id) {
|
|
const retryItem = items.find((it) => it.id === this.pendingRetry.id);
|
|
if (retryItem) {
|
|
return retryItem;
|
|
}
|
|
this.pendingRetry = null;
|
|
}
|
|
const recent = new Set(this.lastIds);
|
|
const underexposed = items
|
|
.map((item) => {
|
|
const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
|
|
return {
|
|
item,
|
|
attempts: (Number(st.c) || 0) + (Number(st.w) || 0),
|
|
wrong: Number(st.w) || 0
|
|
};
|
|
})
|
|
.filter((entry) => entry.attempts < (this.srsMode ? 1 : PRACTICE_MIN_EXPOSURES) && !recent.has(entry.item.id))
|
|
.sort((a, b) => {
|
|
if (a.attempts !== b.attempts) return a.attempts - b.attempts;
|
|
if (a.wrong !== b.wrong) return b.wrong - a.wrong;
|
|
return String(a.item.id).localeCompare(String(b.item.id));
|
|
});
|
|
if (underexposed.length > 0) {
|
|
return underexposed[0].item;
|
|
}
|
|
|
|
const weights = items.map((it) => this.computeWeight(it));
|
|
const sum = weights.reduce((a, b) => a + b, 0);
|
|
let r = Math.random() * sum;
|
|
for (let i = 0; i < items.length; i++) {
|
|
r -= weights[i];
|
|
if (r <= 0) return items[i];
|
|
}
|
|
return items[items.length - 1];
|
|
},
|
|
buildChoices() {
|
|
const prompt = this.currentPrompt;
|
|
const acceptable = this.getAnswersForPrompt(prompt, this.direction);
|
|
this.acceptableAnswers = acceptable;
|
|
|
|
const options = new Set();
|
|
// 1) mindestens eine richtige Übersetzung
|
|
options.add(acceptable[0] || (this.direction === 'L2R' ? this.current.reference : this.current.learning));
|
|
// 2) weitere Übersetzungen (Mehrdeutigkeiten) fürs gleiche Wort
|
|
for (const a of acceptable) {
|
|
if (options.size >= 3) break;
|
|
options.add(a);
|
|
}
|
|
// 3) Distraktoren aus anderen Wörtern
|
|
const allAnswers = this.pool.map((it) => (this.direction === 'L2R' ? it.reference : it.learning));
|
|
for (let i = 0; i < 50 && options.size < 4; i++) {
|
|
const cand = allAnswers[Math.floor(Math.random() * allAnswers.length)];
|
|
if (!acceptable.map(this.normalize).includes(this.normalize(cand))) {
|
|
options.add(cand);
|
|
}
|
|
}
|
|
|
|
const arr = Array.from(options);
|
|
// shuffle
|
|
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]];
|
|
}
|
|
this.choiceOptions = arr;
|
|
},
|
|
async playSound(ok) {
|
|
try {
|
|
const audio = new Audio(ok ? '/sounds/success.mp3' : '/sounds/fail.mp3');
|
|
await audio.play();
|
|
} catch (_) {
|
|
// ignore autoplay issues
|
|
}
|
|
},
|
|
reportSrsReview(isCorrect, rating = null) {
|
|
if (!this.current || !this.openParams?.courseId) {
|
|
return Promise.resolve();
|
|
}
|
|
return apiClient.post('/api/vocab/srs/review', {
|
|
courseId: this.openParams.courseId || this.current.courseId,
|
|
lessonId: this.openParams.lessonId || this.current.lessonId || null,
|
|
itemKey: this.current.itemKey || null,
|
|
learning: this.current.learning,
|
|
reference: this.current.reference,
|
|
direction: this.direction,
|
|
correct: Boolean(isCorrect),
|
|
rating
|
|
}).catch((error) => {
|
|
console.warn('[VocabPracticeDialog] SRS review could not be saved:', error);
|
|
});
|
|
},
|
|
markResult(isCorrect) {
|
|
this.answered = true;
|
|
this.lastCorrect = isCorrect;
|
|
if (isCorrect) {
|
|
this.correctCount += 1;
|
|
this.lastWrongReview = null;
|
|
this.pendingRetry = null;
|
|
} else {
|
|
this.wrongCount += 1;
|
|
const answers = this.visibleCorrectAnswers.length > 0
|
|
? [...this.visibleCorrectAnswers]
|
|
: (this.acceptableAnswers || []).filter(Boolean);
|
|
this.lastWrongReview = {
|
|
prompt: this.currentPrompt,
|
|
submittedAnswer: this.submittedAnswer,
|
|
answers
|
|
};
|
|
if (!this.srsMode && this.current?.id) {
|
|
this.pendingRetry = {
|
|
id: this.current.id,
|
|
direction: this.direction
|
|
};
|
|
}
|
|
}
|
|
if (!this.srsMode) {
|
|
this.reportSrsReview(isCorrect);
|
|
}
|
|
|
|
const id = this.current?.id;
|
|
if (!id) return;
|
|
const st = this.perId[id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
|
|
if (isCorrect) {
|
|
// Hard-phase drills are tracked separately via hardMasteryByKey.
|
|
// We avoid inflating normal success counters here, otherwise the
|
|
// graduated vocab can become too unlikely in regular rotation.
|
|
if (!this.hardPhaseActive) {
|
|
st.c += 1;
|
|
st.streak = st.streak >= 0 ? st.streak + 1 : 1;
|
|
}
|
|
if (this.hardPhaseActive && this.current) {
|
|
const key = this.getHardKey(this.current);
|
|
this.hardMasteryByKey[key] = Math.max(0, Number(this.hardMasteryByKey[key]) || 0) + 1;
|
|
const mapKey = this.findMatchingHardKey(this.current);
|
|
if ((Number(this.hardMasteryByKey[key]) || 0) >= HARD_REQUIRED_CONSECUTIVE_CORRECT && mapKey) {
|
|
const next = { ...this.hardVocabMap };
|
|
delete next[mapKey];
|
|
this.hardVocabMap = next;
|
|
this.saveHardVocabMap();
|
|
}
|
|
}
|
|
} else {
|
|
st.w += 1;
|
|
st.streak = st.streak <= 0 ? st.streak - 1 : -1;
|
|
if (this.hardPhaseActive && this.current) {
|
|
const key = this.getHardKey(this.current);
|
|
this.hardMasteryByKey[key] = 0;
|
|
}
|
|
}
|
|
st.lastAsked = Date.now();
|
|
this.perId[id] = st;
|
|
|
|
this.lastIds.unshift(id);
|
|
this.lastIds = this.lastIds.slice(0, 3);
|
|
},
|
|
async submitSrsRating(rating) {
|
|
if (!this.srsMode || !this.answered || this.locked) {
|
|
return;
|
|
}
|
|
this.locked = true;
|
|
await this.reportSrsReview(this.lastCorrect, rating);
|
|
|
|
const id = this.current?.id;
|
|
const treatAsAgain = rating === 'again' || !this.lastCorrect;
|
|
|
|
if (id && this.srsSession) {
|
|
if (treatAsAgain) {
|
|
// Wrong answers are not done yet: requeue near the end so the user
|
|
// sees them again before the session finishes.
|
|
if (Array.isArray(this.srsQueueIds)) {
|
|
const remaining = this.srsQueueIds.filter((x) => x !== id);
|
|
const insertAt = Math.min(remaining.length, SRS_AGAIN_REINSERT_OFFSET);
|
|
remaining.splice(insertAt, 0, id);
|
|
this.srsQueueIds = remaining;
|
|
}
|
|
if (Array.isArray(this.srsSession.doneIds)) {
|
|
this.srsSession.doneIds = this.srsSession.doneIds.filter((x) => x !== id);
|
|
}
|
|
} else {
|
|
const done = Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : [];
|
|
if (!done.includes(id)) {
|
|
done.push(id);
|
|
this.srsSession.doneIds = done;
|
|
}
|
|
if (Array.isArray(this.srsQueueIds) && this.srsQueueIds[0] === id) {
|
|
this.srsQueueIds = this.srsQueueIds.slice(1);
|
|
} else if (Array.isArray(this.srsQueueIds)) {
|
|
this.srsQueueIds = this.srsQueueIds.filter((x) => x !== id);
|
|
}
|
|
}
|
|
this.saveSrsSession();
|
|
}
|
|
|
|
this.next();
|
|
},
|
|
submitChoice(opt) {
|
|
if (this.locked) return;
|
|
this.submittedAnswer = String(opt || '').trim();
|
|
const ok = this.acceptableAnswers.map(this.normalize).includes(this.normalize(opt));
|
|
this.markResult(ok);
|
|
this.playSound(ok);
|
|
if (ok && !this.srsMode) {
|
|
// Direkt weiter zur nächsten Frage (kein Klick nötig)
|
|
this.locked = true;
|
|
this.autoAdvanceTimer = setTimeout(() => {
|
|
this.autoAdvanceTimer = null;
|
|
this.next();
|
|
}, 350);
|
|
}
|
|
},
|
|
submitTyped() {
|
|
if (this.locked) return;
|
|
this.submittedAnswer = String(this.typedAnswer || '').trim();
|
|
const ans = this.normalize(this.typedAnswer);
|
|
const ok = this.acceptableAnswers.map(this.normalize).includes(ans);
|
|
this.markResult(ok);
|
|
this.playSound(ok);
|
|
if (ok && !this.srsMode) {
|
|
this.locked = true;
|
|
this.autoAdvanceTimer = setTimeout(() => {
|
|
this.autoAdvanceTimer = null;
|
|
this.next();
|
|
}, 350);
|
|
}
|
|
},
|
|
skip() {
|
|
this.pendingRetry = null;
|
|
this.next();
|
|
},
|
|
next() {
|
|
if (this.autoAdvanceTimer) {
|
|
clearTimeout(this.autoAdvanceTimer);
|
|
this.autoAdvanceTimer = null;
|
|
}
|
|
if (!this.pool || this.pool.length === 0) {
|
|
this.resetQuestion();
|
|
return;
|
|
}
|
|
if (this.srsMode && Array.isArray(this.srsQueueIds) && this.srsQueueIds.length === 0) {
|
|
this.resetQuestion();
|
|
return;
|
|
}
|
|
if (this.current?.id) {
|
|
this.addCurrentToCycle();
|
|
}
|
|
this.maybeStartHardPhase();
|
|
this.maybeFinishHardPhase();
|
|
const retryDirection = this.pendingRetry?.direction || null;
|
|
this.resetQuestion();
|
|
if (retryDirection) {
|
|
this.direction = retryDirection;
|
|
}
|
|
this.current = this.pickNextItem();
|
|
if (!this.current) {
|
|
this.maybeFinishHardPhase();
|
|
if (!this.hardPhaseActive) {
|
|
this.current = this.pickNextItem();
|
|
}
|
|
}
|
|
if (!this.current) return;
|
|
const prompt = this.currentPrompt;
|
|
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
|
|
if (this.simpleMode) this.buildChoices();
|
|
this.$nextTick(() => {
|
|
if (!this.simpleMode) this.$refs.answerInput?.focus?.();
|
|
});
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.layout {
|
|
display: flex;
|
|
gap: 16px;
|
|
height: 100%;
|
|
}
|
|
.left {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.right {
|
|
width: 16em;
|
|
border-left: 1px solid #ddd;
|
|
padding-left: 12px;
|
|
}
|
|
.opts {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.chk {
|
|
display: inline-flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
}
|
|
.prompt {
|
|
padding: 10px;
|
|
background: #fff;
|
|
border: 1px solid #ccc;
|
|
margin-bottom: 10px;
|
|
}
|
|
.dir {
|
|
color: #555;
|
|
font-size: 0.9em;
|
|
}
|
|
.word {
|
|
font-size: 1.8em;
|
|
font-weight: bold;
|
|
}
|
|
.typing {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
.typing input {
|
|
flex: 1;
|
|
padding: 6px;
|
|
}
|
|
.choices {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
.choiceBtn {
|
|
padding: 8px;
|
|
}
|
|
.controls {
|
|
margin-top: 12px;
|
|
}
|
|
.srs-finished {
|
|
padding: 14px;
|
|
background: #fff;
|
|
border: 1px solid #ccc;
|
|
}
|
|
.srs-finished__title {
|
|
font-size: 1.1em;
|
|
font-weight: 700;
|
|
margin-bottom: 6px;
|
|
}
|
|
.srs-finished__desc {
|
|
color: #555;
|
|
}
|
|
.srs-rating {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
|
gap: 8px;
|
|
margin: 12px 0;
|
|
}
|
|
.srs-rating__title {
|
|
grid-column: 1 / -1;
|
|
font-size: 0.82rem;
|
|
font-weight: 700;
|
|
color: var(--color-text-secondary, #5f554e);
|
|
}
|
|
.srs-rating__button {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
align-items: flex-start;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--color-border, #d7d0c8);
|
|
border-radius: 10px;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
.srs-rating__button span {
|
|
font-size: 0.74rem;
|
|
color: var(--color-text-secondary, #6b625b);
|
|
}
|
|
.srs-rating__button--again {
|
|
border-color: rgba(198, 75, 75, 0.45);
|
|
}
|
|
.srs-rating__button--hard {
|
|
border-color: rgba(210, 153, 74, 0.5);
|
|
}
|
|
.srs-rating__button--good {
|
|
border-color: rgba(90, 145, 95, 0.45);
|
|
}
|
|
.srs-rating__button--easy {
|
|
border-color: rgba(78, 139, 188, 0.45);
|
|
}
|
|
.feedback {
|
|
padding: 10px;
|
|
border: 1px solid #ccc;
|
|
margin-bottom: 10px;
|
|
}
|
|
.feedback.ok {
|
|
background: #e8ffe8;
|
|
border-color: #7bbe55;
|
|
}
|
|
.feedback.bad {
|
|
background: #ffecec;
|
|
border-color: #d33;
|
|
}
|
|
.solution-card {
|
|
margin: 0 0 12px;
|
|
padding: 12px;
|
|
border: 1px solid #d33;
|
|
background: #fff8f8;
|
|
}
|
|
.solution-card--persistent {
|
|
margin-top: 12px;
|
|
}
|
|
.solution-card ul {
|
|
margin: 6px 0 0;
|
|
padding-left: 20px;
|
|
}
|
|
.solution-row {
|
|
display: grid;
|
|
gap: 3px;
|
|
margin: 8px 0;
|
|
color: #5f554e;
|
|
}
|
|
.solution-row strong {
|
|
color: #2b2520;
|
|
}
|
|
.submitted-answer {
|
|
display: grid;
|
|
gap: 3px;
|
|
margin-bottom: 10px;
|
|
color: #5f554e;
|
|
}
|
|
.submitted-answer strong {
|
|
color: #2b2520;
|
|
}
|
|
.answersTitle {
|
|
margin-top: 6px;
|
|
font-weight: bold;
|
|
}
|
|
.statTitle {
|
|
font-weight: bold;
|
|
margin-bottom: 8px;
|
|
}
|
|
.statRow {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 6px;
|
|
}
|
|
.k {
|
|
color: #333;
|
|
}
|
|
.v {
|
|
font-weight: bold;
|
|
}
|
|
</style>
|