- Redesign top header into clean, uncluttered layout with hamburger drawer trigger, clickable logo, central SRS focus badge, theme toggle and profile avatar - Implement unified responsive Navigation Drawer for both desktop and mobile with touch swipe-to-dismiss, backdrop blur, keyboard Esc support and background scroll lock - Streamline mobile bottom tab bar to 4 core actions (Dictionary, SRS, Reader, Menu) with Menu opening the unified drawer - Synchronize real-time SRS due badges and dictionary word statistics between header, bottom tabs, and drawer - Eliminate obsolete separate mobile-menu-modal in favor of unified drawer - Bump Service Worker cache version to notes-v5.7
738 lines
32 KiB
JavaScript
738 lines
32 KiB
JavaScript
// =======================================================================
|
|
// Notes Application - SRS Training & Analytics Module (Native ES Module)
|
|
// =======================================================================
|
|
|
|
import { api } from './api.js';
|
|
import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio } from './ui.js';
|
|
import { getAllCards } from './dictionary.js';
|
|
|
|
let srsQueue = [];
|
|
let srsCurrentIndex = 0;
|
|
let srsCurrentMode = 'flashcards'; // 'flashcards' | 'quiz' | 'typing' | 'cloze' | 'dictation'
|
|
let srsDirection = 'en-ru';
|
|
let srsSmartMix = true;
|
|
let srsSessionRatings = [];
|
|
let srsIsFlipped = false;
|
|
let quizCurrentOptions = [];
|
|
let quizAnswered = false;
|
|
|
|
export function initSrs() {
|
|
const srsView = document.getElementById('srs-view');
|
|
const srsExitBtn = document.getElementById('srs-exit-btn');
|
|
const srsTabs = document.querySelectorAll('.srs-tab');
|
|
const srsProgressFill = document.getElementById('srs-progress-fill');
|
|
const srsCounter = document.getElementById('srs-counter');
|
|
const srsDirBtn = document.getElementById('srs-dir-btn');
|
|
const srsDirLabel = document.getElementById('srs-dir-label');
|
|
const srsMixBtn = document.getElementById('srs-mix-btn');
|
|
const srsMixLabel = document.getElementById('srs-mix-label');
|
|
|
|
const srsLoading = document.getElementById('srs-loading');
|
|
const srsEmpty = document.getElementById('srs-empty');
|
|
const srsTrainAllBtn = document.getElementById('srs-train-all-btn');
|
|
const srsEmptyBackBtn = document.getElementById('srs-empty-back-btn');
|
|
|
|
// Mode 1: Flashcards
|
|
const srsModeFlashcards = document.getElementById('srs-mode-flashcards');
|
|
const flashcardFlipWrap = document.getElementById('flashcard-flip-wrap');
|
|
const flashcardInner = document.getElementById('flashcard-inner');
|
|
const fcFrontDirBadge = document.getElementById('fc-front-dir-badge');
|
|
const fcFrontPos = document.getElementById('fc-front-pos');
|
|
const fcFrontMain = document.getElementById('fc-front-main');
|
|
const fcFrontPhoneticWrap = document.getElementById('fc-front-phonetic-wrap');
|
|
const fcFrontPhonetic = document.getElementById('fc-front-phonetic');
|
|
const fcFrontAudioBtn = document.getElementById('fc-front-audio-btn');
|
|
const fcHintBtn = document.getElementById('fc-hint-btn');
|
|
const fcHintBox = document.getElementById('fc-hint-box');
|
|
const fcBackPos = document.getElementById('fc-back-pos');
|
|
const fcBackTerm = document.getElementById('fc-back-term');
|
|
const fcBackAudioBtn = document.getElementById('fc-back-audio-btn');
|
|
const fcBackTranslation = document.getElementById('fc-back-translation');
|
|
const fcBackDefBox = document.getElementById('fc-back-def-box');
|
|
const fcBackDef = document.getElementById('fc-back-def');
|
|
const fcBackContextBox = document.getElementById('fc-back-context-box');
|
|
const fcBackContext = document.getElementById('fc-back-context');
|
|
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
|
|
const fcBackNotes = document.getElementById('fc-back-notes');
|
|
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
|
|
const fcFlipBtn = document.getElementById('fc-flip-btn');
|
|
const fcRatingBar = document.getElementById('fc-rating-bar');
|
|
|
|
// Mode 2: Multiple Choice Quiz
|
|
const srsModeQuiz = document.getElementById('srs-mode-quiz');
|
|
const quizPos = document.getElementById('quiz-pos');
|
|
const quizTermDisplay = document.getElementById('quiz-term-display');
|
|
const quizPhoneticDisplay = document.getElementById('quiz-phonetic-display');
|
|
const quizContextHint = document.getElementById('quiz-context-hint');
|
|
const quizAudioBtn = document.getElementById('quiz-audio-btn');
|
|
const quizOptionsGrid = document.getElementById('quiz-options-grid');
|
|
const quizFeedbackBox = document.getElementById('quiz-feedback-box');
|
|
const quizDetailsBox = document.getElementById('quiz-details-box');
|
|
const quizNextBar = document.getElementById('quiz-next-bar');
|
|
const quizNextBtn = document.getElementById('quiz-next-btn');
|
|
|
|
// Mode 3: Active Typing
|
|
const srsModeTyping = document.getElementById('srs-mode-typing');
|
|
const typingPos = document.getElementById('typing-pos');
|
|
const typingPromptRu = document.getElementById('typing-prompt-ru');
|
|
const typingContextHint = document.getElementById('typing-context-hint');
|
|
const typingForm = document.getElementById('typing-form');
|
|
const typingInput = document.getElementById('typing-input');
|
|
const typingFeedbackBox = document.getElementById('typing-feedback-box');
|
|
const typingDetailsBox = document.getElementById('typing-details-box');
|
|
const typingRatingBar = document.getElementById('typing-rating-bar');
|
|
|
|
// Mode 4: Cloze Deletion
|
|
const srsModeCloze = document.getElementById('srs-mode-cloze');
|
|
const clozePos = document.getElementById('cloze-pos');
|
|
const clozeTargetTranslation = document.getElementById('cloze-target-translation');
|
|
const clozeSentenceDisplay = document.getElementById('cloze-sentence-display');
|
|
const clozeForm = document.getElementById('cloze-form');
|
|
const clozeInput = document.getElementById('cloze-input');
|
|
const clozeFeedbackBox = document.getElementById('cloze-feedback-box');
|
|
const clozeDetailsBox = document.getElementById('cloze-details-box');
|
|
const clozeRatingBar = document.getElementById('cloze-rating-bar');
|
|
|
|
// Mode 5: Dictation
|
|
const srsModeDictation = document.getElementById('srs-mode-dictation');
|
|
const dictationBigPlayBtn = document.getElementById('dictation-big-play-btn');
|
|
const dictationReplayBtn = document.getElementById('dictation-replay-btn');
|
|
const dictationToggleHintBtn = document.getElementById('dictation-toggle-hint-btn');
|
|
const dictationHintText = document.getElementById('dictation-hint-text');
|
|
const dictationForm = document.getElementById('dictation-form');
|
|
const dictationInput = document.getElementById('dictation-input');
|
|
const dictationFeedbackBox = document.getElementById('dictation-feedback-box');
|
|
const dictationDetailsBox = document.getElementById('dictation-details-box');
|
|
const dictationRatingBar = document.getElementById('dictation-rating-bar');
|
|
|
|
// Summary
|
|
const srsSummary = document.getElementById('srs-summary');
|
|
const sumCount = document.getElementById('sum-count');
|
|
const sumRetention = document.getElementById('sum-retention');
|
|
const sumRepeatBtn = document.getElementById('sum-repeat-btn');
|
|
const sumReturnBtn = document.getElementById('sum-return-btn');
|
|
|
|
// Stats & Heatmap Modal
|
|
const statsOpenBtn = document.getElementById('stats-open-btn');
|
|
const mobileStatsBtn = document.getElementById('mobile-stats-btn');
|
|
const drawerNavStats = document.getElementById('drawer-nav-stats');
|
|
const statsModal = document.getElementById('stats-modal');
|
|
const closeStatsModalBtn = document.getElementById('close-stats-modal-btn');
|
|
const closeStatsBtn = document.getElementById('close-stats-btn');
|
|
const kpiCurrentStreak = document.getElementById('kpi-current-streak');
|
|
const kpiLongestStreak = document.getElementById('kpi-longest-streak');
|
|
const kpiRetentionRate = document.getElementById('kpi-retention-rate');
|
|
const kpiTotalReviews = document.getElementById('kpi-total-reviews');
|
|
const kpiTotalWords = document.getElementById('kpi-total-words');
|
|
const kpiTodayAdded = document.getElementById('kpi-today-added');
|
|
const kpiCefrLevel = document.getElementById('kpi-cefr-level');
|
|
const kpiCefrDesc = document.getElementById('kpi-cefr-desc');
|
|
const cntMastered = document.getElementById('cnt-mastered');
|
|
const cntReview = document.getElementById('cnt-review');
|
|
const cntLearning = document.getElementById('cnt-learning');
|
|
const cntNew = document.getElementById('cnt-new');
|
|
const cntLeeches = document.getElementById('cnt-leeches');
|
|
const progMastered = document.getElementById('prog-mastered');
|
|
const progReview = document.getElementById('prog-review');
|
|
const progLearning = document.getElementById('prog-learning');
|
|
const progNew = document.getElementById('prog-new');
|
|
const heatmapGrid = document.getElementById('heatmap-grid');
|
|
|
|
// -----------------------------------------------------------------------
|
|
// View & Queue Controls
|
|
// -----------------------------------------------------------------------
|
|
srsExitBtn.addEventListener('click', () => {
|
|
window.location.hash = '#dictionary';
|
|
});
|
|
|
|
srsMixBtn.addEventListener('click', () => {
|
|
srsSmartMix = !srsSmartMix;
|
|
srsMixLabel.textContent = srsSmartMix ? '🎲 Микс дня: ВКЛ' : '📚 Обычная очередь';
|
|
srsMixBtn.classList.toggle('text-success', srsSmartMix);
|
|
startSrsTrainingSession(false);
|
|
});
|
|
|
|
srsTabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
srsTabs.forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
srsCurrentMode = tab.dataset.mode;
|
|
renderSrsCurrentCard();
|
|
});
|
|
});
|
|
|
|
srsDirBtn.addEventListener('click', () => {
|
|
srsDirection = srsDirection === 'en-ru' ? 'ru-en' : 'en-ru';
|
|
srsDirLabel.textContent = srsDirection === 'en-ru' ? 'EN ➔ RU' : 'RU ➔ EN';
|
|
renderSrsCurrentCard();
|
|
});
|
|
|
|
async function startSrsTrainingSession(forceAll = false, tagFilter = null) {
|
|
srsLoading.classList.remove('hidden');
|
|
srsEmpty.classList.add('hidden');
|
|
srsSummary.classList.add('hidden');
|
|
hideAllSrsModeContainers();
|
|
|
|
srsSessionRatings = [];
|
|
srsCurrentIndex = 0;
|
|
|
|
let queue = [];
|
|
try {
|
|
const data = await api.srs.getQueue(forceAll, tagFilter);
|
|
queue = data.cards || [];
|
|
} catch (e) {
|
|
console.error('SRS Queue error:', e);
|
|
}
|
|
|
|
const cards = getAllCards();
|
|
if (queue.length === 0 && forceAll && cards.length > 0) {
|
|
queue = [...cards];
|
|
}
|
|
|
|
srsLoading.classList.add('hidden');
|
|
|
|
if (queue.length === 0) {
|
|
srsEmpty.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
srsQueue = queue;
|
|
renderSrsCurrentCard();
|
|
}
|
|
|
|
function closeSrsView() {
|
|
window.location.hash = '#dictionary';
|
|
}
|
|
|
|
srsTrainAllBtn.addEventListener('click', () => startSrsTrainingSession(true));
|
|
srsEmptyBackBtn.addEventListener('click', closeSrsView);
|
|
sumRepeatBtn.addEventListener('click', () => startSrsTrainingSession(true));
|
|
sumReturnBtn.addEventListener('click', closeSrsView);
|
|
|
|
function hideAllSrsModeContainers() {
|
|
srsModeFlashcards.classList.add('hidden');
|
|
srsModeQuiz.classList.add('hidden');
|
|
srsModeTyping.classList.add('hidden');
|
|
srsModeCloze.classList.add('hidden');
|
|
srsModeDictation.classList.add('hidden');
|
|
}
|
|
|
|
function renderSrsCurrentCard() {
|
|
if (srsCurrentIndex >= srsQueue.length) {
|
|
showSrsSummary();
|
|
return;
|
|
}
|
|
|
|
srsEmpty.classList.add('hidden');
|
|
srsSummary.classList.add('hidden');
|
|
hideAllSrsModeContainers();
|
|
|
|
const total = srsQueue.length;
|
|
const currentNum = srsCurrentIndex + 1;
|
|
srsCounter.textContent = `Карточка ${currentNum} из ${total}`;
|
|
const pct = Math.round(((currentNum - 1) / total) * 100);
|
|
srsProgressFill.style.width = `${pct}%`;
|
|
|
|
const card = srsQueue[srsCurrentIndex];
|
|
|
|
if (srsCurrentMode === 'flashcards') {
|
|
renderFlashcardMode(card);
|
|
} else if (srsCurrentMode === 'quiz') {
|
|
renderQuizMode(card);
|
|
} else if (srsCurrentMode === 'typing') {
|
|
renderTypingMode(card);
|
|
} else if (srsCurrentMode === 'cloze') {
|
|
renderClozeMode(card);
|
|
} else if (srsCurrentMode === 'dictation') {
|
|
renderDictationMode(card);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mode 1: Flashcards
|
|
// -----------------------------------------------------------------------
|
|
function renderFlashcardMode(card) {
|
|
srsModeFlashcards.classList.remove('hidden');
|
|
srsIsFlipped = false;
|
|
flashcardInner.classList.remove('is-flipped');
|
|
fcRatingBar.classList.add('hidden');
|
|
fcHintBox.classList.add('hidden');
|
|
fcFlipBtnWrap.style.display = 'block';
|
|
|
|
if (srsDirection === 'en-ru') {
|
|
fcFrontDirBadge.textContent = 'EN ➔ RU';
|
|
fcFrontMain.textContent = card.term;
|
|
fcFrontPos.textContent = card.part_of_speech || '';
|
|
fcFrontPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
|
|
|
|
if (card.phonetic) {
|
|
fcFrontPhonetic.textContent = card.phonetic;
|
|
fcFrontPhoneticWrap.style.display = 'flex';
|
|
} else {
|
|
fcFrontPhoneticWrap.style.display = 'none';
|
|
}
|
|
|
|
fcHintBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
|
|
fcHintBox.textContent = card.context_sentence ? `“${card.context_sentence}”` : '';
|
|
|
|
fcBackTerm.textContent = card.term;
|
|
fcBackTranslation.textContent = card.translation;
|
|
fcBackPos.textContent = card.part_of_speech || '';
|
|
fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
|
|
} else {
|
|
fcFrontDirBadge.textContent = 'RU ➔ EN';
|
|
fcFrontMain.textContent = card.translation;
|
|
fcFrontPos.textContent = card.part_of_speech || '';
|
|
fcFrontPhoneticWrap.style.display = 'none';
|
|
fcHintBtn.style.display = 'none';
|
|
|
|
fcBackTerm.textContent = card.term;
|
|
fcBackTranslation.textContent = card.translation;
|
|
fcBackPos.textContent = card.part_of_speech || '';
|
|
}
|
|
|
|
fcBackDef.textContent = card.definition_en || '';
|
|
fcBackDefBox.style.display = card.definition_en ? 'block' : 'none';
|
|
fcBackContext.textContent = card.context_sentence ? `“${card.context_sentence}”` : '';
|
|
fcBackContextBox.style.display = card.context_sentence ? 'block' : 'none';
|
|
fcBackNotes.textContent = card.notes || '';
|
|
fcBackNotesBox.style.display = card.notes ? 'block' : 'none';
|
|
|
|
fcFrontAudioBtn.style.display = 'none';
|
|
fcBackAudioBtn.style.display = 'none';
|
|
}
|
|
|
|
function flipFlashcard() {
|
|
if (srsIsFlipped) return;
|
|
srsIsFlipped = true;
|
|
flashcardInner.classList.add('is-flipped');
|
|
fcRatingBar.classList.remove('hidden');
|
|
fcFlipBtnWrap.style.display = 'none';
|
|
}
|
|
|
|
flashcardFlipWrap.addEventListener('click', flipFlashcard);
|
|
fcFlipBtn.addEventListener('click', flipFlashcard);
|
|
fcHintBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
fcHintBox.classList.toggle('hidden');
|
|
});
|
|
|
|
document.querySelectorAll('.btn-rate').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const rating = parseInt(btn.dataset.rating, 10);
|
|
await submitCardReview(rating);
|
|
});
|
|
});
|
|
|
|
async function submitCardReview(rating) {
|
|
const card = srsQueue[srsCurrentIndex];
|
|
if (!card) return;
|
|
|
|
srsSessionRatings.push(rating);
|
|
|
|
try {
|
|
await api.srs.submitReview(card.id, rating);
|
|
} catch (e) {
|
|
console.error('Review submission error:', e);
|
|
}
|
|
|
|
srsCurrentIndex++;
|
|
renderSrsCurrentCard();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mode 2: Multiple Choice Quiz
|
|
// -----------------------------------------------------------------------
|
|
function renderQuizMode(card) {
|
|
srsModeQuiz.classList.remove('hidden');
|
|
quizAnswered = false;
|
|
quizFeedbackBox.classList.add('hidden');
|
|
quizDetailsBox.classList.add('hidden');
|
|
quizNextBar.classList.add('hidden');
|
|
|
|
quizPos.textContent = card.part_of_speech || '';
|
|
quizTermDisplay.textContent = card.term;
|
|
quizPhoneticDisplay.textContent = card.phonetic || '';
|
|
quizPhoneticDisplay.style.display = card.phonetic ? 'block' : 'none';
|
|
|
|
if (card.context_sentence) {
|
|
quizContextHint.textContent = `“${card.context_sentence}”`;
|
|
quizContextHint.classList.remove('hidden');
|
|
} else {
|
|
quizContextHint.classList.add('hidden');
|
|
}
|
|
|
|
quizAudioBtn.classList.add('hidden');
|
|
|
|
const correctText = card.translation;
|
|
const all = getAllCards();
|
|
const candidates = all
|
|
.filter(c => c.id !== card.id && c.translation && c.translation !== correctText)
|
|
.map(c => c.translation);
|
|
|
|
shuffleArray(candidates);
|
|
const options = [correctText, ...candidates.slice(0, 3)];
|
|
while (options.length < 4) {
|
|
options.push(`Вариант ${options.length + 1}`);
|
|
}
|
|
shuffleArray(options);
|
|
quizCurrentOptions = options;
|
|
|
|
quizOptionsGrid.innerHTML = '';
|
|
options.forEach((optText, idx) => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'quiz-option-btn';
|
|
btn.innerHTML = `
|
|
<span class="quiz-opt-num">${idx + 1}</span>
|
|
<span class="quiz-opt-text">${escapeHtml(optText)}</span>
|
|
`;
|
|
btn.onclick = () => handleQuizSelection(optText, correctText, card, btn);
|
|
quizOptionsGrid.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
async function handleQuizSelection(selectedText, correctText, card, clickedBtn) {
|
|
if (quizAnswered) return;
|
|
quizAnswered = true;
|
|
|
|
const isCorrect = selectedText === correctText;
|
|
const allOptionBtns = quizOptionsGrid.querySelectorAll('.quiz-option-btn');
|
|
|
|
allOptionBtns.forEach(btn => {
|
|
btn.disabled = true;
|
|
const text = btn.querySelector('.quiz-opt-text').textContent;
|
|
if (text === correctText) {
|
|
btn.classList.add('opt-correct');
|
|
} else if (btn === clickedBtn && !isCorrect) {
|
|
btn.classList.add('opt-wrong');
|
|
}
|
|
});
|
|
|
|
if (card.audio_url) playAudio(card.audio_url);
|
|
|
|
if (isCorrect) {
|
|
quizFeedbackBox.className = 'trainer-feedback-box feedback-correct';
|
|
quizFeedbackBox.innerHTML = '🎉 <strong>Отлично! Правильный ответ.</strong>';
|
|
srsSessionRatings.push(4);
|
|
api.srs.submitReview(card.id, 4);
|
|
} else {
|
|
quizFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
|
|
quizFeedbackBox.innerHTML = `❌ <strong>Ошибка!</strong> Правильно: <em>${escapeHtml(correctText)}</em>`;
|
|
srsSessionRatings.push(1);
|
|
api.srs.submitReview(card.id, 1);
|
|
}
|
|
quizFeedbackBox.classList.remove('hidden');
|
|
|
|
if (card.definition_en || card.context_sentence) {
|
|
quizDetailsBox.innerHTML = `
|
|
${card.definition_en ? `<p><strong>Толкование:</strong> ${escapeHtml(card.definition_en)}</p>` : ''}
|
|
${card.context_sentence ? `<p><strong>Контекст:</strong> <em>“${escapeHtml(card.context_sentence)}”</em></p>` : ''}
|
|
`;
|
|
quizDetailsBox.classList.remove('hidden');
|
|
}
|
|
|
|
quizNextBar.classList.remove('hidden');
|
|
quizNextBtn.focus();
|
|
}
|
|
|
|
quizNextBtn.addEventListener('click', () => {
|
|
srsCurrentIndex++;
|
|
renderSrsCurrentCard();
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mode 3: Active Typing
|
|
// -----------------------------------------------------------------------
|
|
function renderTypingMode(card) {
|
|
srsModeTyping.classList.remove('hidden');
|
|
typingFeedbackBox.classList.add('hidden');
|
|
typingDetailsBox.classList.add('hidden');
|
|
typingRatingBar.classList.add('hidden');
|
|
|
|
typingPos.textContent = card.part_of_speech || '';
|
|
typingPromptRu.textContent = card.translation;
|
|
if (card.context_sentence) {
|
|
typingContextHint.textContent = `Контекст: “${card.context_sentence}”`;
|
|
typingContextHint.classList.remove('hidden');
|
|
} else {
|
|
typingContextHint.classList.add('hidden');
|
|
}
|
|
|
|
typingInput.value = '';
|
|
typingInput.disabled = false;
|
|
typingInput.focus();
|
|
}
|
|
|
|
typingForm.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const card = srsQueue[srsCurrentIndex];
|
|
if (!card) return;
|
|
|
|
const userInput = typingInput.value.trim().toLowerCase();
|
|
const target = card.term.trim().toLowerCase();
|
|
const isMatch = userInput === target;
|
|
|
|
if (card.audio_url) playAudio(card.audio_url);
|
|
typingInput.disabled = true;
|
|
|
|
if (isMatch) {
|
|
typingFeedbackBox.className = 'trainer-feedback-box feedback-correct';
|
|
typingFeedbackBox.innerHTML = `✅ <strong>Верно!</strong> <em>${escapeHtml(card.term)}</em> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
|
|
} else {
|
|
typingFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
|
|
typingFeedbackBox.innerHTML = `❌ <strong>Не совсем:</strong> правильное написание — <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
|
|
}
|
|
typingFeedbackBox.classList.remove('hidden');
|
|
|
|
renderDynamicRatingBar(typingRatingBar, isMatch ? 3 : 1);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mode 4: Cloze Deletion
|
|
// -----------------------------------------------------------------------
|
|
function renderClozeMode(card) {
|
|
srsModeCloze.classList.remove('hidden');
|
|
clozeFeedbackBox.classList.add('hidden');
|
|
clozeDetailsBox.classList.add('hidden');
|
|
clozeRatingBar.classList.add('hidden');
|
|
|
|
clozePos.textContent = card.part_of_speech || '';
|
|
clozeTargetTranslation.textContent = card.translation;
|
|
|
|
const context = card.context_sentence || `The word is ${card.term}.`;
|
|
const re = new RegExp(escapeRegex(card.term), 'gi');
|
|
const gapText = context.replace(re, '<span class="gap-placeholder">_____</span>');
|
|
clozeSentenceDisplay.innerHTML = gapText;
|
|
|
|
clozeInput.value = '';
|
|
clozeInput.disabled = false;
|
|
clozeInput.focus();
|
|
}
|
|
|
|
clozeForm.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const card = srsQueue[srsCurrentIndex];
|
|
if (!card) return;
|
|
|
|
const userInput = clozeInput.value.trim().toLowerCase();
|
|
const target = card.term.trim().toLowerCase();
|
|
const isMatch = userInput === target;
|
|
|
|
if (card.audio_url) playAudio(card.audio_url);
|
|
clozeInput.disabled = true;
|
|
|
|
if (isMatch) {
|
|
clozeFeedbackBox.className = 'trainer-feedback-box feedback-correct';
|
|
clozeFeedbackBox.innerHTML = `✅ <strong>Верно!</strong> целевое слово: <strong>${escapeHtml(card.term)}</strong>`;
|
|
} else {
|
|
clozeFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
|
|
clozeFeedbackBox.innerHTML = `❌ Правильный ответ: <strong>${escapeHtml(card.term)}</strong>`;
|
|
}
|
|
clozeFeedbackBox.classList.remove('hidden');
|
|
|
|
renderDynamicRatingBar(clozeRatingBar, isMatch ? 3 : 1);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mode 5: Dictation
|
|
// -----------------------------------------------------------------------
|
|
function renderDictationMode(card) {
|
|
srsModeDictation.classList.remove('hidden');
|
|
dictationFeedbackBox.classList.add('hidden');
|
|
dictationDetailsBox.classList.add('hidden');
|
|
dictationRatingBar.classList.add('hidden');
|
|
dictationHintText.classList.add('hidden');
|
|
dictationHintText.textContent = card.translation;
|
|
|
|
dictationInput.value = '';
|
|
dictationInput.disabled = false;
|
|
|
|
const play = () => { if (card.audio_url) playAudio(card.audio_url); };
|
|
dictationBigPlayBtn.onclick = play;
|
|
dictationReplayBtn.onclick = play;
|
|
play();
|
|
|
|
dictationInput.focus();
|
|
}
|
|
|
|
dictationToggleHintBtn.addEventListener('click', () => {
|
|
dictationHintText.classList.toggle('hidden');
|
|
});
|
|
|
|
dictationForm.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const card = srsQueue[srsCurrentIndex];
|
|
if (!card) return;
|
|
|
|
const userInput = dictationInput.value.trim().toLowerCase();
|
|
const target = card.term.trim().toLowerCase();
|
|
const isMatch = userInput === target;
|
|
|
|
dictationInput.disabled = true;
|
|
|
|
if (isMatch) {
|
|
dictationFeedbackBox.className = 'trainer-feedback-box feedback-correct';
|
|
dictationFeedbackBox.innerHTML = `✅ <strong>Идеально!</strong> <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''} — ${escapeHtml(card.translation)}`;
|
|
} else {
|
|
dictationFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
|
|
dictationFeedbackBox.innerHTML = `❌ <strong>Ошибка спеллинга:</strong> правильное слово — <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
|
|
}
|
|
dictationFeedbackBox.classList.remove('hidden');
|
|
|
|
renderDynamicRatingBar(dictationRatingBar, isMatch ? 3 : 1);
|
|
});
|
|
|
|
function renderDynamicRatingBar(container, defaultRating) {
|
|
container.innerHTML = `
|
|
<button class="btn-rate btn-rate-1" data-rating="1"><span class="rate-key">[1]</span> Снова</button>
|
|
<button class="btn-rate btn-rate-2" data-rating="2"><span class="rate-key">[2]</span> Трудно</button>
|
|
<button class="btn-rate btn-rate-3" data-rating="3"><span class="rate-key">[3]</span> Хорошо</button>
|
|
<button class="btn-rate btn-rate-4" data-rating="4"><span class="rate-key">[4]</span> Легко</button>
|
|
`;
|
|
container.classList.remove('hidden');
|
|
|
|
container.querySelectorAll('.btn-rate').forEach(btn => {
|
|
btn.onclick = async () => {
|
|
const r = parseInt(btn.dataset.rating, 10);
|
|
await submitCardReview(r);
|
|
};
|
|
});
|
|
}
|
|
|
|
function showSrsSummary() {
|
|
hideAllSrsModeContainers();
|
|
srsSummary.classList.remove('hidden');
|
|
srsProgressFill.style.width = '100%';
|
|
srsCounter.textContent = 'Тренировка завершена!';
|
|
|
|
const total = srsSessionRatings.length;
|
|
sumCount.textContent = total;
|
|
const goodCount = srsSessionRatings.filter(r => r >= 3).length;
|
|
const retention = total > 0 ? Math.round((goodCount / total) * 100) : 100;
|
|
sumRetention.textContent = `${retention}%`;
|
|
}
|
|
|
|
// Hotkeys handler
|
|
document.addEventListener('keydown', (e) => {
|
|
if (!srsView.classList.contains('hidden')) {
|
|
if (srsCurrentMode === 'flashcards') {
|
|
if (e.code === 'Space' && !srsIsFlipped) {
|
|
e.preventDefault();
|
|
flipFlashcard();
|
|
} else if (srsIsFlipped && ['1', '2', '3', '4'].includes(e.key)) {
|
|
e.preventDefault();
|
|
submitCardReview(parseInt(e.key, 10));
|
|
}
|
|
} else if (srsCurrentMode === 'quiz' && !quizAnswered) {
|
|
if (['1', '2', '3', '4'].includes(e.key)) {
|
|
const idx = parseInt(e.key, 10) - 1;
|
|
const btns = quizOptionsGrid.querySelectorAll('.quiz-option-btn');
|
|
if (btns[idx]) {
|
|
e.preventDefault();
|
|
btns[idx].click();
|
|
}
|
|
}
|
|
} else if (srsCurrentMode === 'quiz' && quizAnswered) {
|
|
if (e.key === 'Enter' || e.code === 'Space') {
|
|
e.preventDefault();
|
|
quizNextBtn.click();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Analytics Dashboard & Heatmap Modal
|
|
// -----------------------------------------------------------------------
|
|
if (statsOpenBtn) statsOpenBtn.addEventListener('click', openStatsDashboard);
|
|
if (mobileStatsBtn) mobileStatsBtn.addEventListener('click', openStatsDashboard);
|
|
if (drawerNavStats) drawerNavStats.addEventListener('click', openStatsDashboard);
|
|
closeStatsModalBtn.addEventListener('click', () => closeModal(statsModal));
|
|
closeStatsBtn.addEventListener('click', () => closeModal(statsModal));
|
|
|
|
async function openStatsDashboard() {
|
|
openModal(statsModal);
|
|
await Promise.all([loadStatsKpis(), loadActivityHeatmap()]);
|
|
}
|
|
|
|
async function loadStatsKpis() {
|
|
try {
|
|
const s = await api.stats.getSrsStats();
|
|
if (s) {
|
|
kpiCurrentStreak.textContent = `🔥 ${s.streak_days}`;
|
|
kpiLongestStreak.textContent = `Рекорд: ${s.longest_streak} дн.`;
|
|
kpiRetentionRate.textContent = `${s.retention_rate}%`;
|
|
kpiTotalReviews.textContent = `Всего повторений: ${s.total_reviews}`;
|
|
kpiTotalWords.textContent = s.total_cards;
|
|
kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`;
|
|
|
|
if (s.cefr) {
|
|
kpiCefrLevel.textContent = s.cefr.level;
|
|
kpiCefrDesc.textContent = `${s.cefr.title} • ${s.cefr.message}`;
|
|
} else {
|
|
const count = s.total_cards;
|
|
let lvl = 'A1';
|
|
let desc = 'Базовый словарный запас';
|
|
if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; }
|
|
else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; }
|
|
else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; }
|
|
else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; }
|
|
kpiCefrLevel.textContent = lvl;
|
|
kpiCefrDesc.textContent = desc;
|
|
}
|
|
|
|
cntMastered.textContent = s.mastered;
|
|
cntReview.textContent = s.review;
|
|
cntLearning.textContent = s.learning;
|
|
cntNew.textContent = s.new_cards;
|
|
cntLeeches.textContent = s.leech_count;
|
|
|
|
const total = s.total_cards || 1;
|
|
progMastered.style.width = `${(s.mastered / total) * 100}%`;
|
|
progReview.style.width = `${(s.review / total) * 100}%`;
|
|
progLearning.style.width = `${(s.learning / total) * 100}%`;
|
|
progNew.style.width = `${(s.new_cards / total) * 100}%`;
|
|
}
|
|
} catch (e) {
|
|
console.error('Stats load error:', e);
|
|
}
|
|
}
|
|
|
|
async function loadActivityHeatmap() {
|
|
try {
|
|
const data = await api.stats.getHeatmap(180);
|
|
renderHeatmap(data.days);
|
|
} catch (e) {
|
|
console.error('Heatmap load error:', e);
|
|
}
|
|
}
|
|
|
|
function renderHeatmap(days) {
|
|
heatmapGrid.innerHTML = '';
|
|
if (!days || days.length === 0) return;
|
|
|
|
days.forEach(d => {
|
|
const cell = document.createElement('div');
|
|
cell.className = `heatmap-cell lvl-${d.level}`;
|
|
cell.title = `${d.date}: ${d.count} повторений / тестов`;
|
|
heatmapGrid.appendChild(cell);
|
|
});
|
|
}
|
|
|
|
initSrs.startSession = startSrsTrainingSession;
|
|
initSrs.openStats = openStatsDashboard;
|
|
}
|
|
|
|
export function startSrsTrainingSession(forceAll = false, tagFilter = null) {
|
|
if (initSrs.startSession) {
|
|
return initSrs.startSession(forceAll, tagFilter);
|
|
}
|
|
}
|
|
|
|
export function openStatsDashboard() {
|
|
if (initSrs.openStats) {
|
|
return initSrs.openStats();
|
|
}
|
|
}
|