// ======================================================================= // Notes Application - Comprehensive Lesson Builder & Runner Module (Beta) // Enables custom daily workouts: Words -> Grammar -> Listening -> Reading -> AI // ======================================================================= import { api } from './api.js'; import { ui, escapeHtml, showToast, switchView, shuffleArray, escapeRegex, playAudio, playTextSpeech } from './ui.js'; let presets = []; let currentPreset = null; let customSteps = []; let grammarRecs = null; let articlesList = []; let userBooks = []; // Active Session State let session = { active: false, presetName: '', steps: [], currentStepIdx: 0, startTime: 0, timerInterval: null, elapsedSeconds: 0, stepResults: [], errors: [], currentStepState: null, }; export async function initLessonModule() { setupEventListeners(); await loadInitialData(); } async function loadInitialData() { try { const [presetsRes, recsRes, articlesRes] = await Promise.all([ api.lessons.getPresets().catch(() => ({ presets: [] })), api.lessons.getGrammarRecommendations().catch(() => null), api.lessons.getArticles().catch(() => ({ articles: [] })), ]); presets = presetsRes.presets || []; grammarRecs = recsRes; articlesList = articlesRes.articles || []; // Select first preset by default if (presets.length > 0 && !currentPreset) { selectPreset(presets[0].id); } } catch (e) { console.error('Error loading lesson initial data:', e); } } function setupEventListeners() { // Top-level tabs / navigation const startBtn = document.getElementById('lesson-start-btn'); if (startBtn) { startBtn.addEventListener('click', () => startLessonSession()); } const savePresetBtn = document.getElementById('lesson-save-preset-btn'); if (savePresetBtn) { savePresetBtn.addEventListener('click', () => promptSavePreset()); } // Runner controls const exitBtn = document.getElementById('lesson-runner-exit-btn'); if (exitBtn) { exitBtn.addEventListener('click', () => confirmExitSession()); } const nextStepBtn = document.getElementById('lesson-next-step-btn'); if (nextStepBtn) { nextStepBtn.addEventListener('click', () => advanceStep()); } // History modal const historyBtn = document.getElementById('lesson-history-btn'); 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); } }); } // --------------------------------------------------------------------------- // Configurator UI & Presets // --------------------------------------------------------------------------- export async function openLessonConfigurator(presetId = null) { ui.switchView('lesson-view'); await loadInitialData(); if (presetId) { selectPreset(presetId); } else if (currentPreset) { renderConfigurator(); } } export function selectPreset(presetId) { const found = presets.find(p => p.id === presetId); if (!found) return; currentPreset = found; // Deep clone steps customSteps = JSON.parse(JSON.stringify(found.steps || [])); renderConfigurator(); } function renderConfigurator() { // 1. Render Preset Chips const chipsContainer = document.getElementById('lesson-presets-bar'); if (chipsContainer) { chipsContainer.innerHTML = presets.map(p => ` `).join(''); chipsContainer.querySelectorAll('.lesson-preset-chip').forEach(btn => { btn.addEventListener('click', (e) => { if (e.target.classList.contains('preset-del-btn')) { e.stopPropagation(); deleteCustomPreset(parseInt(e.target.dataset.del, 10)); return; } selectPreset(btn.dataset.id); }); }); } // 2. Preset Description & Duration const descEl = document.getElementById('lesson-preset-desc'); if (descEl && currentPreset) { descEl.textContent = currentPreset.description || ''; } const timeEl = document.getElementById('lesson-preset-duration'); if (timeEl && currentPreset) { timeEl.textContent = `~${currentPreset.estimated_min || 15} мин`; } // 3. Render Configurable Step Cards renderStepCards(); } function renderStepCards() { const list = document.getElementById('lesson-steps-list'); if (!list) return; if (!customSteps || customSteps.length === 0) { list.innerHTML = `
Нет шагов в уроке. Добавьте блоки.
`; return; } list.innerHTML = customSteps.map((step, idx) => { const isFirst = idx === 0; const isLast = idx === customSteps.length - 1; const isAi = step.type === 'ai_debrief'; return `
${idx + 1} ${getStepIcon(step.type)} ${escapeHtml(step.title || getStepDefaultTitle(step.type))}
${!isAi ? ` ` : 'Финальный ИИ-анализ'}
${renderStepControls(step, idx)}
`; }).join(''); // Attach step card events list.querySelectorAll('.move-step-up').forEach(btn => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.idx, 10); if (idx > 0) { const tmp = customSteps[idx]; customSteps[idx] = customSteps[idx - 1]; customSteps[idx - 1] = tmp; renderStepCards(); } }); }); list.querySelectorAll('.move-step-down').forEach(btn => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.idx, 10); if (idx < customSteps.length - 1 && customSteps[idx + 1].type !== 'ai_debrief') { const tmp = customSteps[idx]; customSteps[idx] = customSteps[idx + 1]; customSteps[idx + 1] = tmp; renderStepCards(); } }); }); list.querySelectorAll('.step-toggle-checkbox').forEach(cb => { cb.addEventListener('change', () => { const idx = parseInt(cb.dataset.idx, 10); customSteps[idx].enabled = cb.checked; renderStepCards(); }); }); attachStepControlsEvents(list); } function getStepIcon(type) { switch (type) { case 'vocab': return '📇'; case 'grammar_review': return '🎯'; case 'grammar_new': return '📘'; case 'listening': return '🎧'; case 'reading': return '📖'; case 'ai_debrief': return '🤖'; default: return '📌'; } } function getStepDefaultTitle(type) { switch (type) { case 'vocab': return 'Вспоминаем слова (SRS)'; case 'grammar_review': return 'Закрепление грамматики'; case 'grammar_new': return 'Новая тема Мерфи'; case 'listening': return 'Аудирование на слух'; case 'reading': return 'Чтение и словарная работа'; case 'ai_debrief': return 'ИИ-Итог и рекомендации'; default: return 'Этап урока'; } } function renderStepControls(step, idx) { if (step.type === 'vocab') { const currentMode = step.mode || 'flashcards'; return `
${[5, 10, 15, 20].map(c => ` `).join('')}
`; } if (step.type === 'grammar_review') { const weakest = grammarRecs?.weakest_unit; const prev = grammarRecs?.previous_unit; const allUnits = grammarRecs?.all_units || []; return `
${[3, 5, 10].map(c => ` `).join('')}
`; } if (step.type === 'grammar_new') { const next = grammarRecs?.next_unit; const allUnits = grammarRecs?.all_units || []; return `
${[3, 5, 8].map(c => ` `).join('')}
`; } if (step.type === 'listening') { return `
${[1, 2, 3].map(c => ` `).join('')}
`; } if (step.type === 'reading') { return `
`; } if (step.type === 'ai_debrief') { return `
🤖
Педагогический анализ сессии: нейросеть сведет все результаты воедино, сопоставит ошибки с историей прошлых уроков, объяснит разницу по формулам Мерфи и сформирует план на завтра.
`; } return ''; } function attachStepControlsEvents(container) { container.querySelectorAll('.segmented-control').forEach(ctrl => { ctrl.querySelectorAll('.seg-btn').forEach(btn => { btn.addEventListener('click', () => { const idx = parseInt(ctrl.dataset.idx, 10); const param = ctrl.dataset.param; 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(); }); }); }); container.querySelectorAll('.step-select-param').forEach(sel => { sel.addEventListener('change', () => { const idx = parseInt(sel.dataset.idx, 10); const param = sel.dataset.param; 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; } }); }); } async function promptSavePreset() { const name = prompt('Введите название вашего нового шаблона урока:', 'Мой тренировочный план'); if (!name || !name.strip?.() && !name.trim()) return; try { const res = await api.lessons.savePreset({ name: name.trim(), steps: customSteps, icon: '⚡', description: 'Пользовательский шаблон', estimated_min: calculateEstimatedTime(customSteps) }); ui.showToast('✅ Шаблон урока успешно сохранён!', 'success'); await loadInitialData(); if (res.preset) { selectPreset(res.preset.id); } } catch (e) { ui.showToast(`Ошибка сохранения: ${e.message}`, 'error'); } } async function deleteCustomPreset(customId) { if (!confirm('Вы действительно хотите удалить этот пользовательский шаблон?')) return; try { await api.lessons.deletePreset(customId); ui.showToast('Шаблон удален', 'info'); await loadInitialData(); selectPreset(presets[0]?.id); } catch (e) { ui.showToast(`Ошибка удаления: ${e.message}`, 'error'); } } function calculateEstimatedTime(steps) { let minutes = 0; for (const s of steps) { if (!s.enabled) continue; if (s.type === 'vocab') minutes += Math.ceil((s.count || 10) * 0.4); else if (s.type === 'grammar_review') minutes += Math.ceil((s.count || 5) * 0.6); else if (s.type === 'grammar_new') minutes += 6; else if (s.type === 'listening') minutes += (s.count || 1) * 2; else if (s.type === 'reading') minutes += 4; else if (s.type === 'ai_debrief') minutes += 1; } return Math.max(5, minutes); } // --------------------------------------------------------------------------- // Lesson Session Runner // --------------------------------------------------------------------------- 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'); return; } session = { active: true, presetName: currentPreset?.name || 'Персональный урок', steps: stepsToRun, currentStepIdx: 0, startTime: Date.now(), timerInterval: null, elapsedSeconds: 0, stepResults: [], errors: [], currentStepState: null, }; // Show Runner View ui.switchView('lesson-runner-view'); // Start timer clearInterval(session.timerInterval); session.timerInterval = setInterval(() => { session.elapsedSeconds++; updateRunnerTimer(); }, 1000); renderRunnerTopBar(); await launchStep(0); } function updateRunnerTimer() { const el = document.getElementById('lesson-runner-timer'); if (!el) return; const mins = Math.floor(session.elapsedSeconds / 60); const secs = session.elapsedSeconds % 60; el.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; } function renderRunnerTopBar() { const bar = document.getElementById('lesson-runner-breadcrumbs'); if (!bar) return; bar.innerHTML = session.steps.map((st, idx) => { let statusClass = 'step-pending'; if (idx < session.currentStepIdx) statusClass = 'step-done'; else if (idx === session.currentStepIdx) statusClass = 'step-active'; return `
${idx < session.currentStepIdx ? '✓' : idx + 1} ${getStepIcon(st.type)} ${escapeHtml(st.title || st.type)}
`; }).join('
➔
'); // Progress bar fill const progressFill = document.getElementById('lesson-runner-progress-fill'); if (progressFill) { const pct = Math.round((session.currentStepIdx / Math.max(1, session.steps.length)) * 100); progressFill.style.width = `${pct}%`; } } async function launchStep(idx) { session.currentStepIdx = idx; renderRunnerTopBar(); const step = session.steps[idx]; const container = document.getElementById('lesson-step-content'); if (!container) return; container.innerHTML = `

Загрузка этапа: ${escapeHtml(step.title || step.type)}...

`; try { if (step.type === 'vocab') { await runVocabStep(step, container); } else if (step.type === 'grammar_review') { await runGrammarReviewStep(step, container); } else if (step.type === 'grammar_new') { await runGrammarNewStep(step, container); } else if (step.type === 'listening') { await runListeningStep(step, container); } else if (step.type === 'reading') { await runReadingStep(step, container); } else if (step.type === 'ai_debrief') { await runAiDebriefStep(step, container); } } catch (err) { console.error('Error running step:', err); container.innerHTML = `

Ошибка загрузки этапа: ${escapeHtml(err.message)}

`; } } export function advanceStep() { if (session.currentStepIdx + 1 < session.steps.length) { launchStep(session.currentStepIdx + 1); } else { finishSession(); } } window.advanceLessonStep = advanceStep; function confirmExitSession() { if (confirm('Прервать текущий урок? Набранный прогресс по пройденным этапам сохранится.')) { clearInterval(session.timerInterval); session.active = false; ui.switchView('lesson-view'); renderConfigurator(); } } // --------------------------------------------------------------------------- // Step 1: Vocab Runner (SRS) // --------------------------------------------------------------------------- async function runVocabStep(step, container) { const limit = step.count || 10; const mode = step.mode || 'flashcards'; // Fetch queue const res = await api.srs.getQueue({ limit: limit }); let cards = res.cards || (Array.isArray(res) ? res : []); if (cards.length === 0) { // Fallback: fetch random cards from library const allRes = await api.cards.getAll({ limit: limit }); 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 = `

📇 Вспоминаем слова

В вашем словаре пока нет слов для повторения. Добавьте первые слова через поиск или читалку!

`; session.stepResults.push({ type: 'vocab', title: step.title, score: 100, correct: 0, total: 0 }); return; } let cardIdx = 0; let correctCount = 0; const totalCards = cards.length; let stepActive = true; function renderCurrentCard() { if (cardIdx >= totalCards) { 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, mode: mode }); container.innerHTML = `
🎉

Блок слов завершён!

Результат: ${correctCount} из ${totalCards} (${score}%)

✓ Верно: ${correctCount} ${totalCards - correctCount > 0 ? `✗ Ошибок: ${totalCards - correctCount}` : ''}
`; 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 = `
Слово ${cardIdx + 1} из ${totalCards} 🎯 4 Варианта

${escapeHtml(card.term)}

${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}
${card.context_sentence ? `
"${escapeHtml(card.context_sentence)}"
` : ''}
${options.map((opt, oIdx) => ` `).join('')}
`; 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 = '🎉 Отлично! Правильный перевод.'; 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 = `❌ Ошибка! Правильно: ${escapeHtml(correctText)}`; 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 = `
Слово ${cardIdx + 1} из ${totalCards} 📇 Карточки

${escapeHtml(card.term)}

${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}
${card.context_sentence ? `
"${escapeHtml(card.context_sentence)}"
` : ''}
`; const playCardAudio = () => { if (card.audio_url) playAudio(card.audio_url); else playTextSpeech(card.term, 'en'); }; document.getElementById('srs-tts-btn')?.addEventListener('click', playCardAudio); 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(); }; container.querySelectorAll('.srs-btn-rating').forEach(btn => { 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 = `
Слово ${cardIdx + 1} из ${totalCards} ✍️ Набор слова
Переведите на английский:

${escapeHtml(card.translation)}

${card.context_sentence ? `
"${escapeHtml(card.context_sentence.replace(new RegExp(escapeRegex(card.term), 'gi'), '_____'))}"
` : ''}
`; 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 = `✅ Верно! ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`; 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 = `❌ Ошибка: правильное написание — ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`; 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 = `
Слово ${cardIdx + 1} из ${totalCards} 🎧 Диктант
${card.translation ? `

Подсказка перевода: ${escapeHtml(card.translation)}

` : ''}
`; 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 = `✅ Верно на слух! ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`; 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 = `❌ Ошибка: правильное слово — ${escapeHtml(card.term)} (${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(); } // --------------------------------------------------------------------------- // Step 2: Grammar Review Runner // --------------------------------------------------------------------------- async function runGrammarReviewStep(step, container) { let targetUnitNum = 1; if (step.topic_mode === 'weakest') { targetUnitNum = grammarRecs?.weakest_unit?.unit_num || 1; } else if (step.topic_mode === 'previous') { targetUnitNum = grammarRecs?.previous_unit?.unit_num || 1; } else if (step.topic_mode && step.topic_mode.startsWith('unit_')) { targetUnitNum = parseInt(step.topic_mode.replace('unit_', ''), 10) || 1; } const unitDetail = await api.grammar.getUnit(targetUnitNum); const exercises = unitDetail.exercises || []; const limit = Math.min(step.count || 5, exercises.length); const selectedExercises = exercises.slice(0, limit); let exIdx = 0; let correctCount = 0; function renderCurrentExercise() { if (exIdx >= selectedExercises.length) { const score = Math.round((correctCount / Math.max(1, selectedExercises.length)) * 100); session.stepResults.push({ type: 'grammar_review', title: step.title, score: score, correct: correctCount, total: selectedExercises.length, unit_num: targetUnitNum, unit_title: unitDetail.title }); container.innerHTML = `
🎯

Закрепление темы завершено!

Юнит ${unitDetail.unit_num}: ${escapeHtml(unitDetail.title)}

Результат: ${correctCount} из ${selectedExercises.length} (${score}%)

`; return; } const ex = selectedExercises[exIdx]; container.innerHTML = `
Повторение: Юнит ${unitDetail.unit_num} (${exIdx + 1} из ${selectedExercises.length}) ${escapeHtml(unitDetail.golden_formula || '')}

${escapeHtml(ex.sentence)}

${ex.clue ? `
💡 Подсказка: ${escapeHtml(ex.clue)}
` : ''}
${(ex.options || []).map((opt, oIdx) => ` `).join('')}
`; container.querySelectorAll('.grammar-opt-btn').forEach(btn => { btn.addEventListener('click', async () => { // Disable all options container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true); const userChoice = btn.dataset.val; // Submit answer to backend let isCorrect = false; let explanation = ''; try { const ansRes = await api.grammar.submitAnswer(targetUnitNum, exIdx, userChoice); isCorrect = ansRes.correct; explanation = ansRes.explanation || ex.explanation || ''; } catch { isCorrect = userChoice.trim().toLowerCase() === ex.target.trim().toLowerCase(); explanation = ex.explanation || ''; } if (isCorrect) { btn.classList.add('opt-correct'); correctCount++; } else { btn.classList.add('opt-wrong'); // Highlight correct button container.querySelectorAll('.grammar-opt-btn').forEach(b => { if (b.dataset.val === ex.target) b.classList.add('opt-correct'); }); // Record error session.errors.push({ type: 'grammar', unit_num: targetUnitNum, unit_title: unitDetail.title, formula: unitDetail.golden_formula, user_answer: userChoice, correct_answer: ex.target, sentence: ex.sentence, explanation: explanation }); } const fb = document.getElementById('grammar-feedback-box'); if (fb) { fb.classList.remove('hidden'); fb.innerHTML = `
${isCorrect ? '✅ Абсолютно верно!' : '❌ Не совсем точно'}
${escapeHtml(explanation)}
`; document.getElementById('next-grammar-btn')?.addEventListener('click', () => { exIdx++; renderCurrentExercise(); }); } }); }); } renderCurrentExercise(); } // --------------------------------------------------------------------------- // Step 3: Grammar New Topic Runner // --------------------------------------------------------------------------- async function runGrammarNewStep(step, container) { let targetUnitNum = 2; if (step.unit_mode === 'auto_next') { targetUnitNum = grammarRecs?.next_unit?.unit_num || 2; } else if (step.unit_mode && step.unit_mode.startsWith('unit_')) { targetUnitNum = parseInt(step.unit_mode.replace('unit_', ''), 10) || 2; } const unitDetail = await api.grammar.getUnit(targetUnitNum); let stage = 'theory'; // theory -> exercises function renderTheory() { const contrasts = unitDetail.contrast_explanation || []; container.innerHTML = `
Новая тема: Юнит ${unitDetail.unit_num} ${escapeHtml(unitDetail.category)}

${escapeHtml(unitDetail.title)}

${escapeHtml(unitDetail.subtitle || '')}

⚡ Золотая формула для тетради:
${escapeHtml(unitDetail.golden_formula)}
${contrasts.map(c => `
${escapeHtml(c.situation_a)}
${escapeHtml(c.example_a)}
${escapeHtml(c.trans_a)}
${escapeHtml(c.situation_b)}
${escapeHtml(c.example_b)}
${escapeHtml(c.trans_b)}
💡 Ключевая разница: ${escapeHtml(c.key_difference)}
`).join('')}
`; document.getElementById('start-new-exercises-btn')?.addEventListener('click', () => { const hwChecked = document.getElementById('hw-checkbox')?.checked || false; try { api.grammar.updateHandwritten(unitDetail.id, hwChecked); } catch {} stage = 'exercises'; renderExercises(); }); } function renderExercises() { const exercises = unitDetail.exercises || []; const limit = Math.min(step.exercises_count || 5, exercises.length); const selectedExercises = exercises.slice(0, limit); let exIdx = 0; let correctCount = 0; function renderEx() { if (exIdx >= selectedExercises.length) { const score = Math.round((correctCount / Math.max(1, selectedExercises.length)) * 100); session.stepResults.push({ type: 'grammar_new', title: step.title, score: score, correct: correctCount, total: selectedExercises.length, unit_num: targetUnitNum, unit_title: unitDetail.title }); // Record unit completion try { api.grammar.recordCompletion(unitDetail.id, score, true); } catch {} container.innerHTML = `
📘

Тема успешно освоена!

Юнит ${unitDetail.unit_num}: ${escapeHtml(unitDetail.title)}

Результат практики: ${correctCount} из ${selectedExercises.length} (${score}%)

`; return; } const ex = selectedExercises[exIdx]; container.innerHTML = `
Практика темы (${exIdx + 1} из ${selectedExercises.length})

${escapeHtml(ex.sentence)}

${ex.clue ? `
💡 Подсказка: ${escapeHtml(ex.clue)}
` : ''}
${(ex.options || []).map((opt, oIdx) => ` `).join('')}
`; container.querySelectorAll('.grammar-opt-btn').forEach(btn => { btn.addEventListener('click', async () => { container.querySelectorAll('.grammar-opt-btn').forEach(b => b.disabled = true); const userChoice = btn.dataset.val; let isCorrect = false; let explanation = ''; try { const ansRes = await api.grammar.submitAnswer(targetUnitNum, exIdx, userChoice); isCorrect = ansRes.correct; explanation = ansRes.explanation || ex.explanation || ''; } catch { isCorrect = userChoice.trim().toLowerCase() === ex.target.trim().toLowerCase(); explanation = ex.explanation || ''; } if (isCorrect) { btn.classList.add('opt-correct'); correctCount++; } else { btn.classList.add('opt-wrong'); container.querySelectorAll('.grammar-opt-btn').forEach(b => { if (b.dataset.val === ex.target) b.classList.add('opt-correct'); }); session.errors.push({ type: 'grammar', unit_num: targetUnitNum, unit_title: unitDetail.title, formula: unitDetail.golden_formula, user_answer: userChoice, correct_answer: ex.target, sentence: ex.sentence, explanation: explanation }); } const fb = document.getElementById('grammar-feedback-box'); if (fb) { fb.classList.remove('hidden'); fb.innerHTML = `
${isCorrect ? '✅ Верно!' : '❌ Ошибка в форме'}
${escapeHtml(explanation)}
`; document.getElementById('next-ex-btn')?.addEventListener('click', () => { exIdx++; renderEx(); }); } }); }); } renderEx(); } renderTheory(); } // --------------------------------------------------------------------------- // Step 4: Listening Runner // --------------------------------------------------------------------------- async function runListeningStep(step, container) { const totalScenarios = step.count || 1; let scenarioIdx = 0; let correctCount = 0; async function renderScenario() { if (scenarioIdx >= totalScenarios) { const score = Math.round((correctCount / Math.max(1, totalScenarios)) * 100); session.stepResults.push({ type: 'listening', title: step.title, score: score, correct: correctCount, total: totalScenarios }); container.innerHTML = `
🎧

Аудирование завершено!

Результат: ${correctCount} из ${totalScenarios} (${score}%)

`; return; } const data = await api.listening.getExercise(step.topic || 'all'); const scenario = data.scenario; container.innerHTML = `
Ситуация ${scenarioIdx + 1} из ${totalScenarios} ${escapeHtml(scenario.topic || 'General')} (${escapeHtml(scenario.level || 'B1')})

Вопрос диктора:

"${escapeHtml(scenario.question)}"

(${escapeHtml(scenario.question_ru || '')})

`; let storyAudio = scenario.story_audio_url ? new Audio(scenario.story_audio_url) : null; let questionAudio = scenario.question_audio_url ? new Audio(scenario.question_audio_url) : null; let currentPlaybackRate = 1.0; document.getElementById('play-story-btn')?.addEventListener('click', () => { if (storyAudio) { storyAudio.playbackRate = currentPlaybackRate; storyAudio.play().catch(() => {}); } }); document.getElementById('play-question-btn')?.addEventListener('click', () => { if (questionAudio) { questionAudio.playbackRate = currentPlaybackRate; questionAudio.play().catch(() => {}); } }); document.getElementById('audio-speed-btn')?.addEventListener('click', (e) => { currentPlaybackRate = currentPlaybackRate === 1.0 ? 0.8 : 1.0; e.target.textContent = `Скорость: ${currentPlaybackRate}x`; if (storyAudio) storyAudio.playbackRate = currentPlaybackRate; if (questionAudio) questionAudio.playbackRate = currentPlaybackRate; }); document.getElementById('submit-listening-btn')?.addEventListener('click', async () => { const input = document.getElementById('listening-answer-input'); const userAns = input?.value.trim() || ''; const res = await api.listening.checkAnswer({ scenario_id: scenario.id, user_answer: userAns }); if (res.is_correct) { correctCount++; } else { session.errors.push({ type: 'listening', question: scenario.question, user_answer: userAns, ideal_answer: scenario.ideal_answer, topic: scenario.topic }); } const fb = document.getElementById('listening-feedback-box'); if (fb) { fb.classList.remove('hidden'); fb.innerHTML = `
${escapeHtml(res.feedback)}
Правильный ответ: ${escapeHtml(scenario.ideal_answer)}
Показать полный текст истории (транскрипт)

EN: ${escapeHtml(scenario.story)}

RU: ${escapeHtml(scenario.story_ru)}

`; document.getElementById('next-listening-btn')?.addEventListener('click', () => { scenarioIdx++; renderScenario(); }); } }); } await renderScenario(); } // --------------------------------------------------------------------------- // Step 5: Reading & Vocabulary Miner Runner // --------------------------------------------------------------------------- async function runReadingStep(step, container) { let article = null; if (step.article_id && step.article_id !== 'random') { article = articlesList.find(a => a.id === step.article_id); } if (!article && articlesList.length > 0) { article = articlesList[Math.floor(Math.random() * articlesList.length)]; } if (!article) { article = { title: "Exploring New Horizons", title_ru: "Исследуя новые горизонты", topic: "Science", paragraphs: [ "Scientific exploration expands the boundaries of human knowledge through curious observation and rigorous testing.", "Every breakthrough in technology begins with an inquisitive mind willing to question established assumptions." ], paragraphs_ru: [ "Научные исследования расширяют границы человеческих знаний благодаря любознательному наблюдению и строгой проверке.", "Каждый прорыв в технологиях начинается с пытливого ума, готового подвергнуть сомнению устоявшиеся предположения." ], vocabulary_highlights: [ {"term": "boundaries", "trans": "границы"}, {"term": "rigorous", "trans": "строгий, тщательный"}, {"term": "inquisitive", "trans": "пытливый, любознательный"} ] }; } let wordsSavedCount = 0; container.innerHTML = `
Чтение и пополнение словаря ${escapeHtml(article.topic || 'Article')}

${escapeHtml(article.title)}

${escapeHtml(article.title_ru || '')}

${article.paragraphs.map((p, pIdx) => `

${p.split(' ').map(w => `${escapeHtml(w)}`).join(' ')}

`).join('')}
${article.vocabulary_highlights?.length ? `
💡 Ключевые выражения из статьи:
${article.vocabulary_highlights.map(vh => ` ${escapeHtml(vh.term)} — ${escapeHtml(vh.trans)} `).join('')}
` : ''}
`; // Toggle paragraph translations container.querySelectorAll('.toggle-para-trans-btn').forEach(btn => { btn.addEventListener('click', () => { const pIdx = btn.dataset.pidx; const paraRu = document.getElementById(`para-ru-${pIdx}`); if (paraRu) { paraRu.classList.toggle('hidden'); btn.textContent = paraRu.classList.contains('hidden') ? '👁️ Показать перевод абзаца' : '🙈 Скрыть перевод'; } }); }); // Word popup interaction const popup = document.getElementById('lesson-word-popup'); let selectedWordData = null; container.querySelectorAll('.clickable-reader-word').forEach(span => { span.addEventListener('click', async (e) => { const term = span.dataset.word; if (!term || term.length < 2) return; const rect = span.getBoundingClientRect(); popup.style.top = `${rect.bottom + window.scrollY + 6}px`; popup.style.left = `${Math.min(window.innerWidth - 260, Math.max(10, rect.left))}px`; popup.classList.remove('hidden'); document.getElementById('popup-term').textContent = term; document.getElementById('popup-trans').textContent = 'Поиск в словаре...'; try { const lookupRes = await api.lookup.search(term); selectedWordData = { term: term, translation: lookupRes.translation || 'Перевод', phonetic: lookupRes.phonetic || '', pos: lookupRes.pos || '', context_sentence: span.closest('.article-para-en')?.textContent.trim() || '' }; document.getElementById('popup-trans').textContent = selectedWordData.translation; document.getElementById('popup-phonetic').textContent = selectedWordData.phonetic ? `/${selectedWordData.phonetic}/` : ''; } catch { document.getElementById('popup-trans').textContent = 'Перевод не найден'; } }); }); document.getElementById('popup-close-btn')?.addEventListener('click', () => { popup?.classList.add('hidden'); }); document.getElementById('popup-save-btn')?.addEventListener('click', async () => { if (!selectedWordData) return; try { await api.cards.create({ term: selectedWordData.term, translation: selectedWordData.translation, phonetic: selectedWordData.phonetic, part_of_speech: selectedWordData.pos, context_sentence: selectedWordData.context_sentence, collection: 'General', tags: 'reading' }); wordsSavedCount++; ui.showToast(`✅ Слово «${selectedWordData.term}» сохранено в словарь!`, 'success'); popup.classList.add('hidden'); } catch (e) { ui.showToast(`Ошибка сохранения: ${e.message}`, 'error'); } }); document.getElementById('finish-reading-step-btn')?.addEventListener('click', () => { session.stepResults.push({ type: 'reading', title: step.title, score: 100, words_saved: wordsSavedCount, article_title: article.title }); advanceStep(); }); } // --------------------------------------------------------------------------- // Step 6: AI Debrief Runner // --------------------------------------------------------------------------- async function runAiDebriefStep(step, container) { clearInterval(session.timerInterval); container.innerHTML = `

🤖 Локальный ИИ Qwen 2.5 формирует ваш персональный разбор...

Анализируем правильные ответы, паттерны ошибок и подбираем рекомендации по грамматике.

`; // Calculate total score let totalScore = 100; if (session.stepResults.length > 0) { const sum = session.stepResults.reduce((acc, s) => acc + (s.score || 0), 0); totalScore = Math.round(sum / session.stepResults.length); } const payload = { preset_name: session.presetName, duration_seconds: session.elapsedSeconds, total_score: totalScore, steps: session.stepResults, errors: session.errors }; let report = null; try { const res = await api.lessons.submitDebrief(payload); report = res.ai_report; } catch (e) { console.error('Debrief error:', e); report = { grade: totalScore >= 75 ? 'A' : 'B', score: totalScore, headline: 'Урок успешно завершён!', strengths: ['Вы успешно прошли все запланированные блоки.'], error_patterns: ['Ошибки зафиксированы и добавлены в повторение.'], tomorrow_focus: ['Продолжайте ежедневную практику.'] }; } renderFinalDebriefCard(container, report, totalScore); } function renderFinalDebriefCard(container, report, totalScore) { const mins = Math.max(1, Math.round(session.elapsedSeconds / 60)); container.innerHTML = `
${escapeHtml(report.grade || 'A')} ${totalScore}%

${escapeHtml(report.headline || 'Отличная работа!')}

⏱️ Время сессии: ${mins} мин | 🎯 Блоков: ${session.stepResults.length} | ❌ Ошибок: ${session.errors.length}

📊 Результаты по блокам урока:

${session.stepResults.map(s => `
${getStepIcon(s.type)} ${escapeHtml(s.title || s.type)} ${s.score}%
`).join('')}
${report.strengths?.length ? `

🌟 Что получилось лучше всего:

` : ''} ${report.error_patterns?.length ? `

🔍 Педагогический анализ ошибок:

${report.error_patterns.map(ep => `
💡
${escapeHtml(ep)}
`).join('')}
` : ''} ${report.tomorrow_focus?.length ? `

🎯 Персональный фокус на завтра:

` : ''}
`; document.getElementById('debrief-finish-btn')?.addEventListener('click', () => { session.active = false; ui.switchView('lesson-view'); renderConfigurator(); }); } function finishSession() { session.active = false; clearInterval(session.timerInterval); ui.switchView('lesson-view'); renderConfigurator(); } async function openHistoryModal() { try { const data = await api.lessons.getHistory(15); const modal = document.getElementById('lesson-history-modal'); if (!modal) return; const list = document.getElementById('lesson-history-list'); if (list) { if (!data.history || data.history.length === 0) { list.innerHTML = `
История комплексных уроков пока пуста. Пройдите ваш первый урок!
`; } else { list.innerHTML = data.history.map(h => `
🚀 ${escapeHtml(h.preset_name)} ${h.completed_at}
${h.grade} (${h.total_score}%) ⏱️ ${Math.round((h.duration_seconds || 0) / 60)} мин ❌ ${h.errors_count} ошибок
${h.headline ? `
"${escapeHtml(h.headline)}"
` : ''}
`).join(''); } } modal.classList.remove('hidden'); document.getElementById('lesson-history-close-btn')?.addEventListener('click', () => { modal.classList.add('hidden'); }, { once: true }); } catch (e) { ui.showToast(`Ошибка загрузки истории: ${e.message}`, 'error'); } }