From 388ed84fff93aca22cbf74f116af7e901af733ed Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Sat, 13 Jun 2026 13:10:13 +0300 Subject: [PATCH] pre training page --- src/db-manager.ts | 67 ++++++++++++++- src/main.ts | 108 +++++++++++++++++++++-- src/style.css | 91 ++++++++++++++++++++ src/training.ts | 213 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 src/training.ts diff --git a/src/db-manager.ts b/src/db-manager.ts index 7ce1d9e..cbc9e6a 100644 --- a/src/db-manager.ts +++ b/src/db-manager.ts @@ -1,4 +1,4 @@ -import { execute } from './sqlite-manager.ts'; +import { execute, executeVoid } from './sqlite-manager.ts'; export interface Word { id: number; @@ -17,6 +17,14 @@ export interface CardSetRecord { filter: string; } +export interface CardStatRecord { + id: number; + word_id: number; + set_id: number; + score: number; + last_opened: number; +} + /** * Получает все колоды (card_set). */ @@ -55,3 +63,60 @@ export async function getWordsForReview(): Promise { group_id: row[5], })) as Word[]; } + +/** + * Получает слова и card_stats для указанной колоды. + */ +export async function getWordsAndStatsForSet( + setId: number, +): Promise<{ words: Word[]; stats: CardStatRecord[] }> { + const wordRows: any[] = await execute( + `SELECT w.id, w.key, w.value, w.tags, w.more, w.group_id + FROM words w + INNER JOIN card_stats cs ON cs.word_id = w.id + WHERE cs.set_id = ? + ORDER BY w.id ASC`, + [setId], + ); + + const statRows: any[] = await execute( + `SELECT id, word_id, set_id, score, last_opened + FROM card_stats + WHERE set_id = ? + ORDER BY word_id ASC`, + [setId], + ); + + const words: Word[] = wordRows.map((r: any[]) => ({ + id: r[0], + key: r[1], + value: r[2], + tags: r[3], + more: r[4], + group_id: r[5], + })); + + const stats: CardStatRecord[] = statRows.map((r: any[]) => ({ + id: r[0], + word_id: r[1], + set_id: r[2], + score: r[3], + last_opened: r[4], + })); + + return { words, stats }; +} + +/** + * Обновляет card_stats после ответа. + */ +export async function updateCardStat( + statId: number, + score: number, + lastOpened: number, +): Promise { + await executeVoid( + `UPDATE card_stats SET score = ?, last_opened = ? WHERE id = ?`, + [score, lastOpened, statId], + ); +} diff --git a/src/main.ts b/src/main.ts index 13c882c..2012a5a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,12 @@ import './style.css'; import { initDB, closeDb, getDatabaseFile, replaceDatabaseFile } from './sqlite-manager.ts'; import { uploadDatabase, downloadDatabase } from './sync.ts'; -import { getWordsForReview, getCardSets } from './db-manager.ts'; +import { getWordsForReview, getCardSets, getWordsAndStatsForSet } from './db-manager.ts'; import type { CardSetRecord } from './db-manager.ts'; import type { Word } from './db-manager.ts'; import { SetOrderMode } from './algorithm.ts'; +import { TrainingPage } from './training.ts'; +import type { WordItem } from './training.ts'; // ----- Конфигурация ----- const API_BASE_URL = 'https://learning.micialware.ru'; @@ -12,7 +14,7 @@ const API_BASE_URL = 'https://learning.micialware.ru'; // ----- Элементы UI ----- const container = document.getElementById('page-container') as HTMLElement; const navBtns = document.querySelectorAll('.nav-btn'); -let currentPage: 'words' | 'sets' = 'words'; +let currentPage: 'words' | 'sets' | 'training' = 'words'; // ----- Вспомогательные функции ----- @@ -137,7 +139,6 @@ function renderSetsList(sets: CardSetRecord[]) { listEl.appendChild(li); } - // Выделяем первую const first = listEl.querySelector('li') as HTMLLIElement | null; if (first) { first.classList.add('selected'); @@ -148,7 +149,6 @@ function renderSetsList(sets: CardSetRecord[]) { } function showSetDetail(set: CardSetRecord, listItem: HTMLLIElement) { - // Обновляем выделение document.querySelectorAll('#sets-list li').forEach(el => el.classList.remove('selected')); listItem.classList.add('selected'); @@ -168,16 +168,92 @@ function showSetDetail(set: CardSetRecord, listItem: HTMLLIElement) { - + `; } +// ----- Тренировка ----- + +async function startTraining(setId: number) { + const modeSelect = document.getElementById('mode-select') as HTMLSelectElement | null; + const mode = (modeSelect?.value as SetOrderMode) || SetOrderMode.Default; + + // Получаем колоду + const sets = await getCardSets(); + const set = sets.find(s => s.id === setId); + if (!set) { + setStatus('Колода не найдена', true); + return; + } + + // Получаем слова и статистику + const { words, stats } = await getWordsAndStatsForSet(setId); + if (words.length === 0) { + setStatus('Нет слов для тренировки', true); + return; + } + + const settings = { + id: set.id, + name: set.name, + forward: set.forward, + backward: set.backward, + filter: set.filter, + count: null, + worst_words_list: null, + open_mode: mode, + }; + + const wordItems: WordItem[] = words.map((w) => { + let parsed: Record = {}; + if (w.more) { + try { parsed = JSON.parse(w.more); } catch { /* ignore */ } + } + return { + id: w.id, + key: w.key, + value: w.value, + tags: w.tags, + more: parsed, + group_id: w.group_id, + }; + }); + + const cardStats = stats.map((s) => ({ + id: s.id, + word_id: s.word_id, + set_id: s.set_id, + last_open: s.last_opened * 1000, + score: s.score, + })); + + // Показываем страницу тренировки + hideNav(); + currentPage = 'training'; + + container.innerHTML = '
'; + const trainingRoot = document.getElementById('training-root') as HTMLElement; + + new TrainingPage( + trainingRoot, + settings, + wordItems, + cardStats, + { + onFinish: () => { + showNav(); + switchPage('sets'); + }, + }, + ); +} + // ----- Навигация ----- function switchPage(page: 'words' | 'sets') { currentPage = page; + showNav(); - // Обновляем активную кнопку navBtns.forEach(btn => { btn.classList.toggle('active', btn.dataset.page === page); }); @@ -191,6 +267,14 @@ function switchPage(page: 'words' | 'sets') { } } +function hideNav() { + document.getElementById('app-nav')!.style.display = 'none'; +} + +function showNav() { + document.getElementById('app-nav')!.style.display = 'flex'; +} + async function loadWords() { try { const words = await getWordsForReview(); @@ -265,18 +349,26 @@ async function main() { // Навигация navBtns.forEach(btn => { btn.addEventListener('click', () => { - switchPage(btn.dataset.page as 'words' | 'sets'); + if (currentPage !== 'training') { + switchPage(btn.dataset.page as 'words' | 'sets'); + } }); }); // Стартовая страница switchPage('words'); - // Глобальные обработчики кликов (для кнопок на динамических страницах) + // Глобальные обработчики container.addEventListener('click', async (e) => { const target = e.target as HTMLElement; if (target.id === 'btnDownload') await onDownload(); if (target.id === 'btnUpload') await onUpload(); + + const trainingBtn = target.closest('.btn-start-training'); + if (trainingBtn) { + const setId = Number((trainingBtn as HTMLElement).dataset.setId); + await startTraining(setId); + } }); } catch (err) { diff --git a/src/style.css b/src/style.css index bdefd2e..cca2132 100644 --- a/src/style.css +++ b/src/style.css @@ -402,3 +402,94 @@ body { max-height: 200px; } } + +/* ──── Страница тренировки ──── */ + +.training-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; +} + +.training-card { + flex: 1; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 16px; + padding: 2rem 1.5rem; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + justify-content: center; + min-height: 200px; +} + +.training-content { + text-align: center; +} + +.training-field { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-h); + padding: 0.5rem 0; + line-height: 1.4; +} + +.training-side { + padding: 0.5rem 0; +} + +.training-side-back .training-field { + color: var(--accent); + font-weight: 500; +} + +.training-divider { + border: none; + border-top: 1px solid var(--border); + margin: 0.75rem 0; +} + +.training-actions { + padding: 0.75rem 0; +} + +.training-vote { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.vote-btn { + font-size: 0.85rem; + padding: 0.75rem 0.5rem; +} + +.vote-easy { + border-color: #16a34a; + color: #16a34a; +} + +.vote-ok { + border-color: #ca8a04; + color: #ca8a04; +} + +.vote-hard { + border-color: #ea580c; + color: #ea580c; +} + +.vote-none { + border-color: #dc2626; + color: #dc2626; +} + +@media (prefers-color-scheme: dark) { + .vote-easy { color: #4ade80; border-color: #4ade80; } + .vote-ok { color: #facc15; border-color: #facc15; } + .vote-hard { color: #fb923c; border-color: #fb923c; } + .vote-none { color: #f87171; border-color: #f87171; } +} diff --git a/src/training.ts b/src/training.ts new file mode 100644 index 0000000..1b0dfd7 --- /dev/null +++ b/src/training.ts @@ -0,0 +1,213 @@ +import { CardSet } from './algorithm.ts'; +import type { CardSetSettings, CardStatistics, WordData, WordOpenMode } from './algorithm.ts'; +import { updateCardStat } from './db-manager.ts'; + +export interface WordItem { + id: number; + key: string; + value: string; + tags: string; + more: Record; + group_id: number; +} + +function getFieldValues(more: Record, fields: string): string[] { + return fields + .split(/\s+/) + .filter(Boolean) + .map((f) => more[f] || f); +} + +export interface TrainingCallbacks { + onFinish: () => void; +} + +export class TrainingPage { + private container: HTMLElement; + private cardSet: CardSet; + private words: WordItem[]; + private callbacks: TrainingCallbacks; + private totalCards: number; + + constructor( + container: HTMLElement, + settings: CardSetSettings, + words: WordItem[], + stats: CardStatistics[], + callbacks: TrainingCallbacks, + ) { + this.container = container; + this.callbacks = callbacks; + this.totalCards = stats.length; + + const wordData: WordData[] = words.map((w) => ({ + id: w.id, + key: w.key, + value: w.value, + tags: w.tags, + additional: w.more as unknown as Record, + group_id: w.group_id, + })); + + this.words = words; + this.cardSet = new CardSet(settings, wordData, stats); + this.render(); + } + + private render(): void { + const current = this.cardSet.currentWordIndex; + const total = this.totalCards; + + this.container.innerHTML = ` +
+ + ${current !== null ? current + 1 : 0} / ${total} +
+
+
+
+
+
+
+ `; + + this.renderCard(); + + document.getElementById('btnTrainingBack')?.addEventListener('click', () => { + this.callbacks.onFinish(); + }); + } + + private renderCard(): void { + const result = this.cardSet.next(); + const word = this.words.find((w) => w.id === result.word.id); + if (!word) return; + + const settings = (this.cardSet as any).settings as CardSetSettings | undefined; + const forwardFields = settings?.forward || 'key'; + const backwardFields = settings?.backward || 'value'; + + this.renderSide(word, forwardFields, backwardFields, false); + this.renderActions(false); + } + + private renderSide( + word: WordItem, + forwardFields: string, + backwardFields: string, + showBack: boolean, + ): void { + const more = word.more; + + const contentEl = document.getElementById('training-content'); + if (!contentEl) return; + + if (!showBack) { + const values = getFieldValues(more, forwardFields); + contentEl.innerHTML = values + .map((v) => `
${this.escapeHtml(v)}
`) + .join(''); + } else { + const fwdVals = getFieldValues(more, forwardFields); + const bwdVals = getFieldValues(more, backwardFields); + + contentEl.innerHTML = ` +
+ ${fwdVals.map((v) => `
${this.escapeHtml(v)}
`).join('')} +
+
+
+ ${bwdVals.map((v) => `
${this.escapeHtml(v)}
`).join('')} +
+ `; + } + } + + private renderActions(showReveal: boolean): void { + const actionsEl = document.getElementById('training-actions'); + if (!actionsEl) return; + + if (!showReveal) { + actionsEl.innerHTML = ` + + `; + document.getElementById('btnReveal')?.addEventListener('click', () => this.reveal()); + } else { + actionsEl.innerHTML = ` +
+ + + + +
+ `; + actionsEl.querySelectorAll('.vote-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const mode = (btn as HTMLElement).dataset.mode as WordOpenMode; + this.vote(mode); + }); + }); + } + } + + private reveal(): void { + const current = this.cardSet.currentWordIndex; + if (current === null) return; + + const word = this.words[current]; + if (!word) return; + + const settings = (this.cardSet as any).settings as CardSetSettings | undefined; + const forwardFields = settings?.forward || 'key'; + const backwardFields = settings?.backward || 'value'; + + this.renderSide(word, forwardFields, backwardFields, true); + this.renderActions(true); + } + + private async vote(mode: WordOpenMode): Promise { + this.cardSet.open(mode); + + const current = this.cardSet.currentWordIndex; + if (current === null) return; + const stat = this.cardSet.set[current]; + + try { + await updateCardStat(stat.id, stat.score, Math.floor(stat.last_open / 1000)); + } catch (err) { + console.error('[Training] Ошибка сохранения:', err); + } + + if (current < this.totalCards - 1) { + this.renderCard(); + } else { + this.finish(); + } + } + + private finish(): void { + this.container.innerHTML = ` +
+ +
+
+
+
🎉
+

Тренировка завершена!

+

Все ${this.totalCards} карточек пройдены.

+
+
+
+ `; + + document.getElementById('btnTrainingFinish')?.addEventListener('click', () => { + this.callbacks.onFinish(); + }); + } + + private escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} \ No newline at end of file