diff --git a/src/db-manager.ts b/src/db-manager.ts index c404429..3112843 100644 --- a/src/db-manager.ts +++ b/src/db-manager.ts @@ -9,6 +9,11 @@ export interface Word { group_id: number; } +export interface WordGroup { + id: number; + name: string; +} + export interface CardSetRecord { id: number; name: string; @@ -25,6 +30,16 @@ export interface CardStatRecord { last_opened: number; } +export interface TagGroup { + tag: string; + words: Word[]; +} + +export interface GroupedWords { + group: WordGroup; + tags: TagGroup[]; +} + /** * Парсит last_opened из SQLite. * SQLite может вернуть ISO-строку "2026-05-22 10:52:06.823259+00:00", @@ -129,10 +144,64 @@ export async function updateCardStat( score: number, lastOpened: number, ): Promise { - // Убеждаемся, что score — число >= 1 const safeScore = Math.max(1, Math.round(score || 1)); await executeVoid( `UPDATE card_stats SET score = ?, last_opened = ? WHERE id = ?`, [safeScore, lastOpened, statId], ); } + +/** + * Возвращает все группы (word_group). + */ +export async function getWordGroups(): Promise { + const rows: any[] = await execute(` + SELECT id, name FROM word_group ORDER BY id ASC + `); + return rows.map((r: any[]) => ({ id: r[0], name: r[1] })) as WordGroup[]; +} + +/** + * Группирует все слова по группам и тегам. + * Если у слова несколько тегов (через пробел), оно попадает в каждую теговую группу. + */ +export async function getGroupedWords(): Promise { + const groups = await getWordGroups(); + const words = await getWordsForReview(); + + const result: GroupedWords[] = []; + + for (const group of groups) { + const groupWords = words.filter((w) => w.group_id === group.id); + + // Собираем все уникальные теги в этой группе + const tagMap = new Map(); + + for (const word of groupWords) { + if (!word.tags || word.tags.trim() === '') { + // Слова без тегов — в группу "без тега" + const tag = '(без тега)'; + if (!tagMap.has(tag)) tagMap.set(tag, []); + tagMap.get(tag)!.push(word); + continue; + } + + const tags = word.tags.split(',').map((t) => t.trim()).filter(Boolean); + for (const tag of tags) { + if (!tagMap.has(tag)) tagMap.set(tag, []); + tagMap.get(tag)!.push(word); + } + } + + const tagEntries: TagGroup[] = []; + for (const [tag, tagWords] of tagMap) { + tagEntries.push({ tag, words: tagWords }); + } + // Сортируем теги: сначала с наибольшим количеством слов + tagEntries.sort((a, b) => b.words.length - a.words.length); + + result.push({ group, tags: tagEntries }); + } + + return result; +} diff --git a/src/main.ts b/src/main.ts index 10e2dac..2a3690a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,9 @@ import './style.css'; import { initDB, closeDb, getDatabaseFile, replaceDatabaseFile } from './sqlite-manager.ts'; import { uploadDatabase, downloadDatabase } from './sync.ts'; -import { getWordsForReview, getCardSets, getWordsAndStatsForSet } from './db-manager.ts'; +import { getCardSets, getWordsAndStatsForSet, getGroupedWords } from './db-manager.ts'; +import type { GroupedWords } 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'; @@ -40,7 +40,6 @@ function getSyncId(): string { const input = document.getElementById('syncId') as HTMLInputElement | null; const id = input?.value.trim(); if (!id) throw new Error('Введите ID синхронизации'); - // Сохраняем в localStorage localStorage.setItem(SYNC_ID_STORAGE_KEY, id); return id; } @@ -70,37 +69,98 @@ function renderWordsPage() {

📚 Слова

-
    +
    Загрузка...
    `; } -function renderWordsList(words: Word[]) { - const wordListEl = document.getElementById('word-list') as HTMLUListElement | null; - if (!wordListEl) return; +function renderWordGroups(grouped: GroupedWords[]) { + const containerEl = document.getElementById('word-groups'); + if (!containerEl) return; - wordListEl.innerHTML = ''; + containerEl.innerHTML = ''; - if (words.length === 0) { - wordListEl.innerHTML = '
  • Нет слов для повторения 🎉
  • '; + if (grouped.length === 0) { + containerEl.innerHTML = '

    Нет слов 🎉

    '; return; } - for (const word of words) { - const li = document.createElement('li'); - li.className = 'word-item'; + for (const entry of grouped) { + const groupEl = document.createElement('div'); + groupEl.className = 'word-group'; - let reading = word.key; - let translation = word.value; - - li.innerHTML = ` - ${reading} - ${escapeHtml(translation)} - ${word.tags ? `${escapeHtml(word.tags)}` : ''} + const header = document.createElement('div'); + header.className = 'word-group-header'; + header.innerHTML = ` + ${escapeHtml(entry.group.name)} + ${entry.tags.reduce((s, t) => s + t.words.length, 0)} слов `; - wordListEl.appendChild(li); + + // Коллапс по клику на заголовке + let collapsed = false; + header.addEventListener('click', () => { + collapsed = !collapsed; + tagsContainer.style.display = collapsed ? 'none' : ''; + header.classList.toggle('collapsed', collapsed); + }); + + groupEl.appendChild(header); + + const tagsContainer = document.createElement('div'); + tagsContainer.className = 'word-group-tags'; + + for (const tagEntry of entry.tags) { + const tagBlock = document.createElement('div'); + tagBlock.className = 'word-tag-block'; + + const tagHeader = document.createElement('div'); + tagHeader.className = 'word-tag-header'; + tagHeader.innerHTML = ` + #${escapeHtml(tagEntry.tag)} + ${tagEntry.words.length} + `; + + let tagCollapsed = true; + const wordsContainer = document.createElement('div'); + wordsContainer.className = 'word-tag-words'; + wordsContainer.style.display = 'none'; + + tagHeader.addEventListener('click', () => { + tagCollapsed = !tagCollapsed; + wordsContainer.style.display = tagCollapsed ? 'none' : ''; + tagHeader.classList.toggle('collapsed', tagCollapsed); + }); + + for (const word of tagEntry.words) { + const wordEl = document.createElement('div'); + wordEl.className = 'word-item'; + + let reading = word.key; + let translation = word.value; + if (word.more) { + try { + const parsed = JSON.parse(word.more); + reading = parsed.reading || word.key; + translation = parsed.translation || word.value; + } catch { /* ignore */ } + } + + wordEl.innerHTML = ` + ${escapeHtml(reading)} + ${escapeHtml(translation)} + `; + wordsContainer.appendChild(wordEl); + } + + tagBlock.appendChild(tagHeader); + tagBlock.appendChild(wordsContainer); + tagsContainer.appendChild(tagBlock); + } + + groupEl.appendChild(tagsContainer); + containerEl.appendChild(groupEl); } } @@ -179,7 +239,6 @@ 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) { @@ -187,14 +246,13 @@ async function startTraining(setId: number) { return; } - // Получаем слова и статистику const { words, stats } = await getWordsAndStatsForSet(setId); if (words.length === 0) { setStatus('Нет слов для тренировки', true); return; } - console.log(`[Training] Запуск тренировки: ${words.length} слов, ${stats.length} записей статистики, режим: ${mode}`); + console.log(`[Training] Запуск тренировки: ${words.length} слов, ${stats.length} записей, режим: ${mode}`); const settings = { id: set.id, @@ -230,7 +288,6 @@ async function startTraining(setId: number) { score: s.score, })); - // Показываем страницу тренировки hideNav(); currentPage = 'training'; @@ -263,7 +320,7 @@ function switchPage(page: 'words' | 'sets') { if (page === 'words') { renderWordsPage(); - loadWords(); + loadGroupedWords(); } else { renderSetsPage(); loadSets(); @@ -278,12 +335,13 @@ function showNav() { document.getElementById('app-nav')!.style.display = 'flex'; } -async function loadWords() { +async function loadGroupedWords() { try { - const words = await getWordsForReview(); - renderWordsList(words); - const count = words.length; - setStatus(`Слов: ${count}`); + const grouped = await getGroupedWords(); + renderWordGroups(grouped); + + const totalWords = grouped.reduce((s, g) => s + g.tags.reduce((s2, t) => s2 + t.words.length, 0), 0); + setStatus(`Слов: ${totalWords}`); } catch (err) { setStatus('Ошибка загрузки слов', true); console.error(err); @@ -312,7 +370,7 @@ async function onDownload() { setStatus('Скачивание...'); await downloadDatabase(id, API_BASE_URL, replaceDatabaseFile, initDB); - if (currentPage === 'words') await loadWords(); + if (currentPage === 'words') await loadGroupedWords(); setStatus('База данных скачана.'); } catch { // ошибка уже обработана в sync.ts @@ -332,7 +390,7 @@ async function onUpload() { await uploadDatabase(id, API_BASE_URL, closeDb, getDatabaseFile); await initDB(); - if (currentPage === 'words') await loadWords(); + if (currentPage === 'words') await loadGroupedWords(); setStatus('База данных загружена.'); } catch { // ошибка уже обработана в sync.ts @@ -349,7 +407,6 @@ async function main() { await initDB(); console.log('[App] База данных инициализирована.'); - // Навигация navBtns.forEach(btn => { btn.addEventListener('click', () => { if (currentPage !== 'training') { @@ -358,13 +415,9 @@ async function main() { }); }); - // Стартовая страница switchPage('words'); - - // Восстанавливаем сохранённый ID синхронизации restoreSyncId(); - // Глобальные обработчики container.addEventListener('click', async (e) => { const target = e.target as HTMLElement; if (target.id === 'btnDownload') await onDownload(); diff --git a/src/style.css b/src/style.css index 7df27c9..4640919 100644 --- a/src/style.css +++ b/src/style.css @@ -300,6 +300,114 @@ body { opacity: 0.6; } +/* ──── Группировка слов ──── */ + +.word-group { + margin-bottom: 1rem; +} + +.word-group-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: background 0.15s; + min-height: 48px; +} + +.word-group-header:hover { + background: var(--accent-light); +} + +.word-group-header::after { + content: '▾'; + font-size: 0.8rem; + color: var(--text); + transition: transform 0.2s; +} + +.word-group-header.collapsed::after { + transform: rotate(-90deg); +} + +.word-group-name { + font-weight: 600; + color: var(--text-h); + font-size: 0.95rem; +} + +.word-group-count { + font-size: 0.8rem; + color: var(--text); + margin-right: 0.5rem; +} + +.word-group-tags { + padding-top: 0.5rem; +} + +.word-tag-block { + margin-bottom: 0.5rem; +} + +.word-tag-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + border-radius: 8px; + transition: background 0.15s; +} + +.word-tag-header:hover { + background: var(--accent-light); +} + +.word-tag-header::after { + content: '▸'; + font-size: 0.75rem; + color: var(--text); + transition: transform 0.2s; +} + +.word-tag-header.collapsed::after { + transform: rotate(90deg); +} + +.word-tag-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--accent); +} + +.word-tag-count { + font-size: 0.75rem; + color: var(--text); + background: var(--accent-light); + padding: 1px 8px; + border-radius: 10px; +} + +.word-tag-words { + padding: 0.25rem 0 0.25rem 0.5rem; +} + +.word-tag-words .word-item { + margin-bottom: 6px; + padding: 10px 12px; +} + +.word-tag-words .word-item:last-child { + margin-bottom: 0; +} + /* ──── Навигация ──── */ .app-nav {