fix(lesson): support quiz mode and improve responsive layout for mobile and laptop
This commit is contained in:
+565
-55
@@ -80,6 +80,14 @@ function setupEventListeners() {
|
||||
if (historyBtn) {
|
||||
historyBtn.addEventListener('click', () => openHistoryModal());
|
||||
}
|
||||
|
||||
// Runner keyboard shortcuts listener
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!session.active) return;
|
||||
if (typeof session.currentStepKeyHandler === 'function') {
|
||||
session.currentStepKeyHandler(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -250,6 +258,7 @@ function getStepDefaultTitle(type) {
|
||||
|
||||
function renderStepControls(step, idx) {
|
||||
if (step.type === 'vocab') {
|
||||
const currentMode = step.mode || 'flashcards';
|
||||
return `
|
||||
<div class="step-param-row">
|
||||
<div class="param-group">
|
||||
@@ -260,14 +269,14 @@ function renderStepControls(step, idx) {
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-group">
|
||||
<label>Режим тренировки:</label>
|
||||
<select class="input-styled step-select-param" data-param="mode" data-idx="${idx}">
|
||||
<option value="flashcards" ${step.mode === 'flashcards' ? 'selected' : ''}>📇 Карточки</option>
|
||||
<option value="quiz" ${step.mode === 'quiz' ? 'selected' : ''}>🎯 4 Варианта</option>
|
||||
<option value="typing" ${step.mode === 'typing' ? 'selected' : ''}>✍️ Набор слова</option>
|
||||
<option value="dictation" ${step.mode === 'dictation' ? 'selected' : ''}>🎧 Диктант</option>
|
||||
</select>
|
||||
<div class="param-group flex-2">
|
||||
<label>Режим тренировки слов:</label>
|
||||
<div class="segmented-control mode-segmented-control" data-param="mode" data-idx="${idx}">
|
||||
<button type="button" class="seg-btn ${currentMode === 'flashcards' ? 'active' : ''}" data-val="flashcards">📇 Карточки</button>
|
||||
<button type="button" class="seg-btn ${currentMode === 'quiz' ? 'active' : ''}" data-val="quiz">🎯 4 Варианта</button>
|
||||
<button type="button" class="seg-btn ${currentMode === 'typing' ? 'active' : ''}" data-val="typing">✍️ Набор</button>
|
||||
<button type="button" class="seg-btn ${currentMode === 'dictation' ? 'active' : ''}" data-val="dictation">🎧 Диктант</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -418,10 +427,14 @@ function attachStepControlsEvents(container) {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = parseInt(ctrl.dataset.idx, 10);
|
||||
const param = ctrl.dataset.param;
|
||||
const val = parseInt(btn.dataset.val, 10);
|
||||
customSteps[idx][param] = val;
|
||||
const rawVal = btn.dataset.val;
|
||||
const val = (rawVal !== '' && !isNaN(Number(rawVal))) ? Number(rawVal) : rawVal;
|
||||
if (customSteps[idx]) {
|
||||
customSteps[idx][param] = val;
|
||||
}
|
||||
ctrl.querySelectorAll('.seg-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
updateEstimatedDuration();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -430,7 +443,45 @@ function attachStepControlsEvents(container) {
|
||||
sel.addEventListener('change', () => {
|
||||
const idx = parseInt(sel.dataset.idx, 10);
|
||||
const param = sel.dataset.param;
|
||||
customSteps[idx][param] = sel.value;
|
||||
if (customSteps[idx]) {
|
||||
customSteps[idx][param] = sel.value;
|
||||
}
|
||||
updateEstimatedDuration();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateEstimatedDuration() {
|
||||
const timeEl = document.getElementById('lesson-preset-duration');
|
||||
if (timeEl) {
|
||||
timeEl.textContent = `~${calculateEstimatedTime(customSteps)} мин`;
|
||||
}
|
||||
}
|
||||
|
||||
function syncDomToCustomSteps() {
|
||||
const list = document.getElementById('lesson-steps-list');
|
||||
if (!list) return;
|
||||
list.querySelectorAll('.lesson-step-card').forEach(cardEl => {
|
||||
const idx = parseInt(cardEl.dataset.idx, 10);
|
||||
if (isNaN(idx) || !customSteps[idx]) return;
|
||||
|
||||
const cb = cardEl.querySelector('.step-toggle-checkbox');
|
||||
if (cb) customSteps[idx].enabled = cb.checked;
|
||||
|
||||
cardEl.querySelectorAll('.segmented-control').forEach(ctrl => {
|
||||
const param = ctrl.dataset.param;
|
||||
const activeBtn = ctrl.querySelector('.seg-btn.active');
|
||||
if (param && activeBtn) {
|
||||
const rawVal = activeBtn.dataset.val;
|
||||
customSteps[idx][param] = (rawVal !== '' && !isNaN(Number(rawVal))) ? Number(rawVal) : rawVal;
|
||||
}
|
||||
});
|
||||
|
||||
cardEl.querySelectorAll('.step-select-param').forEach(sel => {
|
||||
const param = sel.dataset.param;
|
||||
if (param && sel.value !== undefined) {
|
||||
customSteps[idx][param] = sel.value;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -488,6 +539,9 @@ function calculateEstimatedTime(steps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startLessonSession(preset = null) {
|
||||
if (!preset) {
|
||||
syncDomToCustomSteps();
|
||||
}
|
||||
const stepsToRun = (preset ? preset.steps : customSteps).filter(s => s.enabled);
|
||||
if (!stepsToRun || stepsToRun.length === 0) {
|
||||
ui.showToast('В уроке нет активных блоков. Включите хотя бы один блок.', 'warning');
|
||||
@@ -622,7 +676,7 @@ async function runVocabStep(step, container) {
|
||||
|
||||
// Fetch queue
|
||||
const res = await api.srs.getQueue({ limit: limit });
|
||||
let cards = res.cards || [];
|
||||
let cards = res.cards || (Array.isArray(res) ? res : []);
|
||||
|
||||
if (cards.length === 0) {
|
||||
// Fallback: fetch random cards from library
|
||||
@@ -630,6 +684,16 @@ async function runVocabStep(step, container) {
|
||||
cards = Array.isArray(allRes) ? allRes : (allRes.cards || []);
|
||||
}
|
||||
|
||||
// Distractor candidate pool for quiz mode
|
||||
let distractorPool = [];
|
||||
if (mode === 'quiz') {
|
||||
try {
|
||||
const extraRes = await api.cards.getAll({ limit: 80 });
|
||||
const extraCards = Array.isArray(extraRes) ? extraRes : (extraRes.cards || []);
|
||||
distractorPool = extraCards.map(c => c.translation).filter(Boolean);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (cards.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="step-card-box text-center">
|
||||
@@ -645,17 +709,20 @@ async function runVocabStep(step, container) {
|
||||
let cardIdx = 0;
|
||||
let correctCount = 0;
|
||||
const totalCards = cards.length;
|
||||
let stepActive = true;
|
||||
|
||||
function renderCurrentCard() {
|
||||
if (cardIdx >= totalCards) {
|
||||
// Finished step
|
||||
stepActive = false;
|
||||
session.currentStepKeyHandler = null;
|
||||
const score = Math.round((correctCount / totalCards) * 100);
|
||||
session.stepResults.push({
|
||||
type: 'vocab',
|
||||
title: step.title,
|
||||
score: score,
|
||||
correct: correctCount,
|
||||
total: totalCards
|
||||
total: totalCards,
|
||||
mode: mode
|
||||
});
|
||||
|
||||
container.innerHTML = `
|
||||
@@ -663,46 +730,228 @@ async function runVocabStep(step, container) {
|
||||
<div class="celebrate-badge">🎉</div>
|
||||
<h3>Блок слов завершён!</h3>
|
||||
<p class="score-highlight">Результат: <strong>${correctCount} из ${totalCards}</strong> (${score}%)</p>
|
||||
<button class="btn btn-primary btn-lg mt-3" onclick="window.advanceLessonStep()">
|
||||
<div class="step-summary-badges mt-2">
|
||||
<span class="badge badge-success">✓ Верно: ${correctCount}</span>
|
||||
${totalCards - correctCount > 0 ? `<span class="badge badge-danger">✗ Ошибок: ${totalCards - correctCount}</span>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg mt-4" id="vocab-finish-btn">
|
||||
Перейти к следующему блоку ➔
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('vocab-finish-btn')?.addEventListener('click', () => {
|
||||
advanceStep();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const card = cards[cardIdx];
|
||||
|
||||
if (mode === 'quiz') {
|
||||
renderQuizCard(card);
|
||||
} else if (mode === 'typing') {
|
||||
renderTypingCard(card);
|
||||
} else if (mode === 'dictation') {
|
||||
renderDictationCard(card);
|
||||
} else {
|
||||
renderFlashcardCard(card);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mode A: 4 Options Quiz
|
||||
// -----------------------------------------------------------------------
|
||||
function renderQuizCard(card) {
|
||||
const correctText = card.translation;
|
||||
const candidates = [
|
||||
...cards.filter(c => c.id !== card.id && c.translation).map(c => c.translation),
|
||||
...distractorPool.filter(t => t && t !== correctText)
|
||||
];
|
||||
const uniqueCandidates = [...new Set(candidates.filter(t => t !== correctText))];
|
||||
shuffleArray(uniqueCandidates);
|
||||
const options = [correctText, ...uniqueCandidates.slice(0, 3)];
|
||||
const fallbackOptions = ['определять', 'проверять', 'исследовать', 'сохранять', 'показывать', 'понимать', 'находить', 'создавать'];
|
||||
while (options.length < 4) {
|
||||
const f = fallbackOptions.find(o => !options.includes(o)) || `Вариант ${options.length + 1}`;
|
||||
options.push(f);
|
||||
}
|
||||
shuffleArray(options);
|
||||
|
||||
let answered = false;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="step-card-box">
|
||||
<div class="step-card-box lesson-quiz-card">
|
||||
<div class="step-mini-header">
|
||||
<span class="badge">Слово ${cardIdx + 1} из ${totalCards}</span>
|
||||
<button class="icon-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
|
||||
<div class="step-tags-wrap">
|
||||
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
|
||||
<span class="badge badge-outline">🎯 4 Варианта</span>
|
||||
</div>
|
||||
<button class="icon-btn audio-play-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
|
||||
</div>
|
||||
|
||||
<div class="step-term-display">
|
||||
<div class="step-term-display mt-3">
|
||||
<h2 class="display-term">${escapeHtml(card.term)}</h2>
|
||||
${card.phonetic ? `<span class="display-phonetic">${escapeHtml(card.phonetic)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
${card.context_sentence ? `
|
||||
<div class="step-context-box">
|
||||
<div class="step-context-box mt-3">
|
||||
<em class="context-quote">"${escapeHtml(card.context_sentence)}"</em>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="lesson-quiz-options-grid mt-4">
|
||||
${options.map((opt, oIdx) => `
|
||||
<button type="button" class="lesson-quiz-opt-btn" data-val="${escapeHtml(opt)}" data-num="${oIdx + 1}">
|
||||
<span class="quiz-opt-num">${oIdx + 1}</span>
|
||||
<span class="quiz-opt-text">${escapeHtml(opt)}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<div id="lesson-quiz-feedback" class="trainer-feedback-box hidden mt-3"></div>
|
||||
|
||||
<div id="lesson-quiz-next-bar" class="hidden mt-3">
|
||||
<button id="lesson-quiz-next-btn" class="btn btn-primary btn-lg w-100">
|
||||
Продолжить ➔ (Enter или Пробел)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const playCardAudio = () => {
|
||||
if (card.audio_url) playAudio(card.audio_url);
|
||||
else playTextSpeech(card.term, 'en');
|
||||
};
|
||||
document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio);
|
||||
|
||||
const optBtns = container.querySelectorAll('.lesson-quiz-opt-btn');
|
||||
const handleOptionClick = (btn) => {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
|
||||
const selectedText = btn.dataset.val;
|
||||
const isCorrect = selectedText === correctText;
|
||||
|
||||
optBtns.forEach(b => {
|
||||
b.disabled = true;
|
||||
if (b.dataset.val === correctText) {
|
||||
b.classList.add('opt-correct');
|
||||
} else if (b === btn && !isCorrect) {
|
||||
b.classList.add('opt-wrong');
|
||||
}
|
||||
});
|
||||
|
||||
playCardAudio();
|
||||
|
||||
const fb = document.getElementById('lesson-quiz-feedback');
|
||||
const nextBar = document.getElementById('lesson-quiz-next-bar');
|
||||
const nextBtn = document.getElementById('lesson-quiz-next-btn');
|
||||
|
||||
if (isCorrect) {
|
||||
correctCount++;
|
||||
if (fb) {
|
||||
fb.className = 'trainer-feedback-box feedback-correct';
|
||||
fb.innerHTML = '🎉 <strong>Отлично! Правильный перевод.</strong>';
|
||||
fb.classList.remove('hidden');
|
||||
}
|
||||
api.srs.submitReview(card.id, 4).catch(() => {});
|
||||
} else {
|
||||
session.errors.push({
|
||||
type: 'vocab',
|
||||
term: card.term,
|
||||
translation: card.translation,
|
||||
context: card.context_sentence || '',
|
||||
user_answer: selectedText,
|
||||
mode: 'quiz'
|
||||
});
|
||||
if (fb) {
|
||||
fb.className = 'trainer-feedback-box feedback-wrong';
|
||||
fb.innerHTML = `❌ <strong>Ошибка!</strong> Правильно: <em>${escapeHtml(correctText)}</em>`;
|
||||
fb.classList.remove('hidden');
|
||||
}
|
||||
api.srs.submitReview(card.id, 1).catch(() => {});
|
||||
}
|
||||
|
||||
if (nextBar) nextBar.classList.remove('hidden');
|
||||
if (nextBtn) nextBtn.focus();
|
||||
};
|
||||
|
||||
optBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => handleOptionClick(btn));
|
||||
});
|
||||
|
||||
const nextAction = () => {
|
||||
cardIdx++;
|
||||
renderCurrentCard();
|
||||
};
|
||||
|
||||
document.getElementById('lesson-quiz-next-btn')?.addEventListener('click', nextAction);
|
||||
|
||||
session.currentStepKeyHandler = (e) => {
|
||||
if (!stepActive) return;
|
||||
if (!answered) {
|
||||
if (['1', '2', '3', '4'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
const n = parseInt(e.key, 10);
|
||||
if (optBtns[n - 1]) handleOptionClick(optBtns[n - 1]);
|
||||
} else if (e.code === 'KeyS' || e.code === 'KeyA') {
|
||||
e.preventDefault();
|
||||
playCardAudio();
|
||||
}
|
||||
} else {
|
||||
if (e.key === 'Enter' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
nextAction();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mode B: Traditional Flashcards
|
||||
// -----------------------------------------------------------------------
|
||||
function renderFlashcardCard(card) {
|
||||
let isFlipped = false;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="step-card-box lesson-flashcard-card">
|
||||
<div class="step-mini-header">
|
||||
<div class="step-tags-wrap">
|
||||
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
|
||||
<span class="badge badge-outline">📇 Карточки</span>
|
||||
</div>
|
||||
<button class="icon-btn audio-play-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
|
||||
</div>
|
||||
|
||||
<div class="step-term-display mt-3">
|
||||
<h2 class="display-term">${escapeHtml(card.term)}</h2>
|
||||
${card.phonetic ? `<span class="display-phonetic">${escapeHtml(card.phonetic)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
${card.context_sentence ? `
|
||||
<div class="step-context-box mt-3">
|
||||
<em class="context-quote">"${escapeHtml(card.context_sentence)}"</em>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Reveal / Options -->
|
||||
<div id="vocab-answer-box" class="mt-4">
|
||||
<button id="vocab-show-btn" class="btn btn-outline btn-lg w-100">Показать перевод (Пробел)</button>
|
||||
<button id="vocab-show-btn" class="btn btn-outline btn-lg w-100">
|
||||
👁️ Показать перевод (Пробел)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="vocab-rating-box" class="srs-actions-grid hidden mt-4">
|
||||
<div class="translation-reveal-banner mb-3">
|
||||
<strong>${escapeHtml(card.translation)}</strong>
|
||||
<div class="tr-label text-muted text-sm">Перевод:</div>
|
||||
<strong class="tr-text">${escapeHtml(card.translation)}</strong>
|
||||
${card.definition_en ? `<div class="tr-definition text-muted mt-1">${escapeHtml(card.definition_en)}</div>` : ''}
|
||||
</div>
|
||||
<div class="rating-buttons-row">
|
||||
<button class="btn btn-danger srs-btn-rating" data-rating="1">
|
||||
<span class="rating-key">1</span>
|
||||
<span class="rating-label">Снова (Забыл)</span>
|
||||
<span class="rating-label">Снова</span>
|
||||
</button>
|
||||
<button class="btn btn-warning srs-btn-rating" data-rating="2">
|
||||
<span class="rating-key">2</span>
|
||||
@@ -721,45 +970,306 @@ async function runVocabStep(step, container) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// TTS button
|
||||
document.getElementById('srs-tts-btn')?.addEventListener('click', () => {
|
||||
if (card.audio_url) new Audio(card.audio_url).play().catch(() => {});
|
||||
});
|
||||
const playCardAudio = () => {
|
||||
if (card.audio_url) playAudio(card.audio_url);
|
||||
else playTextSpeech(card.term, 'en');
|
||||
};
|
||||
document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio);
|
||||
|
||||
// Show button
|
||||
document.getElementById('vocab-show-btn')?.addEventListener('click', () => {
|
||||
const flipCard = () => {
|
||||
if (isFlipped) return;
|
||||
isFlipped = true;
|
||||
document.getElementById('vocab-answer-box')?.classList.add('hidden');
|
||||
document.getElementById('vocab-rating-box')?.classList.remove('hidden');
|
||||
});
|
||||
playCardAudio();
|
||||
};
|
||||
|
||||
document.getElementById('vocab-show-btn')?.addEventListener('click', flipCard);
|
||||
|
||||
const handleRating = async (rating) => {
|
||||
if (rating >= 3) {
|
||||
correctCount++;
|
||||
} else {
|
||||
session.errors.push({
|
||||
type: 'vocab',
|
||||
term: card.term,
|
||||
translation: card.translation,
|
||||
context: card.context_sentence || '',
|
||||
rating: rating,
|
||||
mode: 'flashcards'
|
||||
});
|
||||
}
|
||||
|
||||
api.srs.submitReview(card.id, rating).catch(() => {});
|
||||
|
||||
cardIdx++;
|
||||
renderCurrentCard();
|
||||
};
|
||||
|
||||
// Rating buttons
|
||||
container.querySelectorAll('.srs-btn-rating').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const rating = parseInt(btn.dataset.rating, 10);
|
||||
if (rating >= 3) {
|
||||
correctCount++;
|
||||
} else {
|
||||
// Record error
|
||||
session.errors.push({
|
||||
type: 'vocab',
|
||||
term: card.term,
|
||||
translation: card.translation,
|
||||
context: card.context_sentence || '',
|
||||
rating: rating,
|
||||
});
|
||||
}
|
||||
|
||||
// Submit to SRS engine
|
||||
try {
|
||||
await api.srs.submitReview({ card_id: card.id, rating: rating });
|
||||
} catch (e) {
|
||||
console.warn('SRS review log deferred:', e);
|
||||
}
|
||||
|
||||
cardIdx++;
|
||||
renderCurrentCard();
|
||||
btn.addEventListener('click', () => {
|
||||
const r = parseInt(btn.dataset.rating, 10);
|
||||
handleRating(r);
|
||||
});
|
||||
});
|
||||
|
||||
session.currentStepKeyHandler = (e) => {
|
||||
if (!stepActive) return;
|
||||
if (!isFlipped) {
|
||||
if (e.code === 'Space' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
flipCard();
|
||||
} else if (e.code === 'KeyS' || e.code === 'KeyA') {
|
||||
e.preventDefault();
|
||||
playCardAudio();
|
||||
}
|
||||
} else {
|
||||
if (['1', '2', '3', '4'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
handleRating(parseInt(e.key, 10));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mode C: Active Typing
|
||||
// -----------------------------------------------------------------------
|
||||
function renderTypingCard(card) {
|
||||
let answered = false;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="step-card-box lesson-typing-card">
|
||||
<div class="step-mini-header">
|
||||
<div class="step-tags-wrap">
|
||||
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
|
||||
<span class="badge badge-outline">✍️ Набор слова</span>
|
||||
</div>
|
||||
<button class="icon-btn audio-play-btn" id="srs-tts-btn" title="Озвучить слово">🔊</button>
|
||||
</div>
|
||||
|
||||
<div class="step-prompt-display text-center mt-3">
|
||||
<span class="text-muted text-sm">Переведите на английский:</span>
|
||||
<h2 class="display-term display-prompt">${escapeHtml(card.translation)}</h2>
|
||||
</div>
|
||||
|
||||
${card.context_sentence ? `
|
||||
<div class="step-context-box mt-3 text-center">
|
||||
<em class="context-quote">"${escapeHtml(card.context_sentence.replace(new RegExp(escapeRegex(card.term), 'gi'), '_____'))}"</em>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<form id="lesson-typing-form" class="mt-4">
|
||||
<div class="typing-input-wrap">
|
||||
<input type="text" id="lesson-typing-input" class="input-styled input-lg typing-main-input"
|
||||
placeholder="Введите английское слово..." autocomplete="off" autocapitalize="off" spellcheck="false" autofocus>
|
||||
<button type="submit" id="lesson-typing-submit-btn" class="btn btn-primary btn-lg">
|
||||
Проверить (Enter)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="lesson-typing-feedback" class="trainer-feedback-box hidden mt-3"></div>
|
||||
|
||||
<div id="lesson-typing-next-bar" class="hidden mt-3">
|
||||
<button id="lesson-typing-next-btn" class="btn btn-primary btn-lg w-100">
|
||||
Продолжить ➔ (Enter или Пробел)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const inputEl = document.getElementById('lesson-typing-input');
|
||||
inputEl?.focus();
|
||||
|
||||
const playCardAudio = () => {
|
||||
if (card.audio_url) playAudio(card.audio_url);
|
||||
else playTextSpeech(card.term, 'en');
|
||||
};
|
||||
document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio);
|
||||
|
||||
const checkAnswer = () => {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
|
||||
const userVal = inputEl?.value.trim().toLowerCase() || '';
|
||||
const targetVal = card.term.trim().toLowerCase();
|
||||
const isMatch = userVal === targetVal;
|
||||
|
||||
if (inputEl) inputEl.disabled = true;
|
||||
playCardAudio();
|
||||
|
||||
const fb = document.getElementById('lesson-typing-feedback');
|
||||
const nextBar = document.getElementById('lesson-typing-next-bar');
|
||||
const nextBtn = document.getElementById('lesson-typing-next-btn');
|
||||
|
||||
if (isMatch) {
|
||||
correctCount++;
|
||||
if (fb) {
|
||||
fb.className = 'trainer-feedback-box feedback-correct';
|
||||
fb.innerHTML = `✅ <strong>Верно!</strong> <em>${escapeHtml(card.term)}</em> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
|
||||
fb.classList.remove('hidden');
|
||||
}
|
||||
api.srs.submitReview(card.id, 4).catch(() => {});
|
||||
} else {
|
||||
session.errors.push({
|
||||
type: 'vocab',
|
||||
term: card.term,
|
||||
translation: card.translation,
|
||||
user_answer: userVal,
|
||||
mode: 'typing'
|
||||
});
|
||||
if (fb) {
|
||||
fb.className = 'trainer-feedback-box feedback-wrong';
|
||||
fb.innerHTML = `❌ <strong>Ошибка:</strong> правильное написание — <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
|
||||
fb.classList.remove('hidden');
|
||||
}
|
||||
api.srs.submitReview(card.id, 1).catch(() => {});
|
||||
}
|
||||
|
||||
if (nextBar) nextBar.classList.remove('hidden');
|
||||
if (nextBtn) nextBtn.focus();
|
||||
};
|
||||
|
||||
document.getElementById('lesson-typing-form')?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
checkAnswer();
|
||||
});
|
||||
|
||||
const nextAction = () => {
|
||||
cardIdx++;
|
||||
renderCurrentCard();
|
||||
};
|
||||
|
||||
document.getElementById('lesson-typing-next-btn')?.addEventListener('click', nextAction);
|
||||
|
||||
session.currentStepKeyHandler = (e) => {
|
||||
if (!stepActive) return;
|
||||
if (answered && (e.key === 'Enter' || e.code === 'Space')) {
|
||||
e.preventDefault();
|
||||
nextAction();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mode D: Dictation
|
||||
// -----------------------------------------------------------------------
|
||||
function renderDictationCard(card) {
|
||||
let answered = false;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="step-card-box lesson-dictation-card">
|
||||
<div class="step-mini-header">
|
||||
<div class="step-tags-wrap">
|
||||
<span class="badge badge-primary">Слово ${cardIdx + 1} из ${totalCards}</span>
|
||||
<span class="badge badge-outline">🎧 Диктант</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dictation-hero-play text-center mt-3">
|
||||
<button id="dictation-big-audio-btn" class="btn btn-secondary btn-lg dictation-play-btn" type="button">
|
||||
🔊 Прослушать слово (Пробел)
|
||||
</button>
|
||||
${card.translation ? `<p class="dictation-hint text-muted mt-2">Подсказка перевода: ${escapeHtml(card.translation)}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<form id="lesson-dictation-form" class="mt-4">
|
||||
<div class="typing-input-wrap">
|
||||
<input type="text" id="lesson-dictation-input" class="input-styled input-lg typing-main-input"
|
||||
placeholder="Запишите услышанное английское слово..." autocomplete="off" autocapitalize="off" spellcheck="false" autofocus>
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
Проверить (Enter)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="lesson-dictation-feedback" class="trainer-feedback-box hidden mt-3"></div>
|
||||
|
||||
<div id="lesson-dictation-next-bar" class="hidden mt-3">
|
||||
<button id="lesson-dictation-next-btn" class="btn btn-primary btn-lg w-100">
|
||||
Продолжить ➔ (Enter или Пробел)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const playCardAudio = () => {
|
||||
if (card.audio_url) playAudio(card.audio_url);
|
||||
else playTextSpeech(card.term, 'en');
|
||||
};
|
||||
|
||||
setTimeout(playCardAudio, 200);
|
||||
document.getElementById('dictation-big-audio-btn')?.addEventListener('click', playCardAudio);
|
||||
|
||||
const inputEl = document.getElementById('lesson-dictation-input');
|
||||
inputEl?.focus();
|
||||
|
||||
const checkAnswer = () => {
|
||||
if (answered) return;
|
||||
answered = true;
|
||||
|
||||
const userVal = inputEl?.value.trim().toLowerCase() || '';
|
||||
const targetVal = card.term.trim().toLowerCase();
|
||||
const isMatch = userVal === targetVal;
|
||||
|
||||
if (inputEl) inputEl.disabled = true;
|
||||
playCardAudio();
|
||||
|
||||
const fb = document.getElementById('lesson-dictation-feedback');
|
||||
const nextBar = document.getElementById('lesson-dictation-next-bar');
|
||||
const nextBtn = document.getElementById('lesson-dictation-next-btn');
|
||||
|
||||
if (isMatch) {
|
||||
correctCount++;
|
||||
if (fb) {
|
||||
fb.className = 'trainer-feedback-box feedback-correct';
|
||||
fb.innerHTML = `✅ <strong>Верно на слух!</strong> <em>${escapeHtml(card.term)}</em> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
|
||||
fb.classList.remove('hidden');
|
||||
}
|
||||
api.srs.submitReview(card.id, 4).catch(() => {});
|
||||
} else {
|
||||
session.errors.push({
|
||||
type: 'vocab',
|
||||
term: card.term,
|
||||
translation: card.translation,
|
||||
user_answer: userVal,
|
||||
mode: 'dictation'
|
||||
});
|
||||
if (fb) {
|
||||
fb.className = 'trainer-feedback-box feedback-wrong';
|
||||
fb.innerHTML = `❌ <strong>Ошибка:</strong> правильное слово — <strong>${escapeHtml(card.term)}</strong> (${escapeHtml(card.translation)})`;
|
||||
fb.classList.remove('hidden');
|
||||
}
|
||||
api.srs.submitReview(card.id, 1).catch(() => {});
|
||||
}
|
||||
|
||||
if (nextBar) nextBar.classList.remove('hidden');
|
||||
if (nextBtn) nextBtn.focus();
|
||||
};
|
||||
|
||||
document.getElementById('lesson-dictation-form')?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
checkAnswer();
|
||||
});
|
||||
|
||||
const nextAction = () => {
|
||||
cardIdx++;
|
||||
renderCurrentCard();
|
||||
};
|
||||
|
||||
document.getElementById('lesson-dictation-next-btn')?.addEventListener('click', nextAction);
|
||||
|
||||
session.currentStepKeyHandler = (e) => {
|
||||
if (!stepActive) return;
|
||||
if (answered && (e.key === 'Enter' || e.code === 'Space')) {
|
||||
e.preventDefault();
|
||||
nextAction();
|
||||
} else if (!answered && e.code === 'Space' && document.activeElement !== inputEl) {
|
||||
e.preventDefault();
|
||||
playCardAudio();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
renderCurrentCard();
|
||||
|
||||
@@ -182,11 +182,21 @@ export function initSrs() {
|
||||
startSrsTrainingSession(false);
|
||||
});
|
||||
|
||||
// Restore saved SRS mode if present
|
||||
const savedSrsMode = localStorage.getItem('srs_active_mode');
|
||||
if (savedSrsMode && ['flashcards', 'quiz', 'typing', 'cloze', 'dictation', 'listening'].includes(savedSrsMode)) {
|
||||
srsCurrentMode = savedSrsMode;
|
||||
srsTabs.forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.mode === savedSrsMode);
|
||||
});
|
||||
}
|
||||
|
||||
srsTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
srsTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
srsCurrentMode = tab.dataset.mode;
|
||||
localStorage.setItem('srs_active_mode', srsCurrentMode);
|
||||
renderSrsCurrentCard();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user