feat: decompose frontend into native ES modules (zero bundlers, zero npm)
This commit is contained in:
1 parent
869bf23948
commit
21d94acf4e
9 files changed
+4307
-3893
No files matched your search
@@ -274,14 +274,17 @@
|
||||
|
||||
---
|
||||
|
||||
### 12. 🧩 Модульность фронтенда на нативных ES-модулях (без Webpack/Vite)
|
||||
- [ ] Перевести [`app/static/js/app.js`](file:///home/ai-projects/services/study.qpid.pro/app/static/js/app.js) на нативный браузерный синтаксис `<script type="module">`.
|
||||
- [ ] Выделить модули с нативным `import/export`:
|
||||
- `modules/reader.js` — читалку, блок-парсер и навигацию по главам.
|
||||
- `modules/srs.js` — алгоритм SM-2, флэшкарты и квиз.
|
||||
- `modules/api.js` — клиентские сетевые вызовы.
|
||||
- `modules/ui.js` — тосты, модальные окна, темы оформления.
|
||||
- [ ] Сохранить полное отсутствие внешних сборщиков (0 npm-пакетов, чистая платформа).
|
||||
### 12. 🧩 Модульность фронтенда на нативных ES-модулях (без Webpack/Vite) (Completed)
|
||||
- [x] Перевести [`app/static/js/app.js`](file:///home/ai-projects/services/study.qpid.pro/app/static/js/app.js) на нативный браузерный синтаксис `<script type="module">`.
|
||||
- [x] Выделить модули с нативным `import/export`:
|
||||
- `modules/api.js` — централизованный клиент сетевых вызовов с авто-интерцептором токена.
|
||||
- `modules/ui.js` — тосты, модальные окна, темы оформления, утилиты экранирования и шафла.
|
||||
- `modules/dictionary.js` — поиск, карточки, коллекции, теги, пакетные операции, модалки редактирования и экспорта.
|
||||
- `modules/srs.js` — алгоритм SM-2, 5 режимов тренировки (флэшкарты, квиз, тайпинг, клоуз, диктант), статистика KPI и гитхаб-хитмап.
|
||||
- `modules/reader.js` — книжная полка, читалка, блок-парсер, виртуализация глав, интерактивный попап слова, настройки Aa.
|
||||
- `app.js` — главный оркестратор, роутер хэшей `#dictionary`, `#srs`, `#reader/book/:id`, префлайт вида без мерцания, мобильный таббар.
|
||||
- [x] Сохранить полное отсутствие внешних сборщиков (0 npm-пакетов, чистая платформа, нативные ES6 модули браузера).
|
||||
- [x] Обновлен Service Worker (`sw.js`) с прекэшированием всех модулей и версией кэша `notes-v5.0`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1075,7 +1075,7 @@
|
||||
<!-- Audio Element for native streaming -->
|
||||
<audio id="global-audio-player" preload="none"></audio>
|
||||
|
||||
<script src="/static/js/app.js?v=4.0"></script>
|
||||
<script type="module" src="/static/js/app.js?v=5.0"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+431
-3883
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,184 @@
|
||||
// =======================================================================
|
||||
// Notes Application - API Client Module (Native ES Module)
|
||||
// =======================================================================
|
||||
|
||||
// Persistent session: auto-inject Authorization header if token exists in localStorage
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(url, options = {}) {
|
||||
const token = localStorage.getItem('notes_token');
|
||||
if (token && typeof url === 'string' && url.startsWith('/api/')) {
|
||||
options.headers = options.headers || {};
|
||||
if (options.headers instanceof Headers) {
|
||||
if (!options.headers.has('Authorization')) {
|
||||
options.headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
} else if (Array.isArray(options.headers)) {
|
||||
if (!options.headers.some(([k]) => k.toLowerCase() === 'authorization')) {
|
||||
options.headers.push(['Authorization', `Bearer ${token}`]);
|
||||
}
|
||||
} else {
|
||||
if (!options.headers['Authorization']) {
|
||||
options.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return originalFetch.call(this, url, options);
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Auth endpoints
|
||||
auth: {
|
||||
async check() {
|
||||
const resp = await fetch('/api/me');
|
||||
if (resp.ok) {
|
||||
return await resp.json();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async login(username, password) {
|
||||
return await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
},
|
||||
async logout() {
|
||||
return await fetch('/api/logout', { method: 'POST' });
|
||||
},
|
||||
async changePassword(currentPassword, newPassword) {
|
||||
return await fetch('/api/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
old_password: currentPassword,
|
||||
new_password: newPassword
|
||||
})
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Cards & Vocabulary
|
||||
cards: {
|
||||
async getAll(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const url = qs ? `/api/cards?${qs}` : '/api/cards';
|
||||
const resp = await fetch(url);
|
||||
return resp.ok ? await resp.json() : [];
|
||||
},
|
||||
async lookup(term) {
|
||||
const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`);
|
||||
if (resp.ok) {
|
||||
return await resp.json();
|
||||
}
|
||||
throw new Error(`Lookup failed: ${resp.status}`);
|
||||
},
|
||||
async create(payload) {
|
||||
return await fetch('/api/cards', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
async update(id, payload) {
|
||||
return await fetch(`/api/cards/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
async delete(id) {
|
||||
return await fetch(`/api/cards/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
async batch(payload) {
|
||||
return await fetch('/api/cards/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Collections & Tags
|
||||
collections: {
|
||||
async getAll() {
|
||||
const resp = await fetch('/api/collections');
|
||||
return resp.ok ? await resp.json() : [];
|
||||
}
|
||||
},
|
||||
tags: {
|
||||
async getAll() {
|
||||
const resp = await fetch('/api/tags');
|
||||
return resp.ok ? await resp.json() : [];
|
||||
}
|
||||
},
|
||||
|
||||
// Statistics & Analytics
|
||||
stats: {
|
||||
async getStats() {
|
||||
const resp = await fetch('/api/stats');
|
||||
return resp.ok ? await resp.json() : null;
|
||||
},
|
||||
async getSrsStats() {
|
||||
const resp = await fetch('/api/srs/stats');
|
||||
return resp.ok ? await resp.json() : null;
|
||||
},
|
||||
async getHeatmap(days = 180) {
|
||||
const resp = await fetch(`/api/srs/activity-heatmap?days=${days}`);
|
||||
return resp.ok ? await resp.json() : { days: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// SRS Spaced Repetition
|
||||
srs: {
|
||||
async getQueue(forceAll = false, tagFilter = null) {
|
||||
let url = '/api/srs/queue?limit=50';
|
||||
if (forceAll) url += '&force_all=true';
|
||||
if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`;
|
||||
const resp = await fetch(url);
|
||||
return resp.ok ? await resp.json() : [];
|
||||
},
|
||||
async submitReview(cardId, rating) {
|
||||
return await fetch('/api/srs/review', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ card_id: cardId, rating })
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Bookshelf & Reader
|
||||
books: {
|
||||
async getAll() {
|
||||
const resp = await fetch('/api/books');
|
||||
return resp.ok ? await resp.json() : [];
|
||||
},
|
||||
async get(id) {
|
||||
const resp = await fetch(`/api/books/${id}`);
|
||||
if (resp.ok) return await resp.json();
|
||||
throw new Error(`Failed to load book ${id}: ${resp.status}`);
|
||||
},
|
||||
async uploadFile(formData) {
|
||||
return await fetch('/api/books', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
},
|
||||
async createManual(payload) {
|
||||
return await fetch('/api/books', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
async delete(id) {
|
||||
return await fetch(`/api/books/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
async saveProgress(bookId, payload) {
|
||||
return await fetch(`/api/books/${bookId}/progress`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,732 @@
|
||||
// =======================================================================
|
||||
// Notes Application - Dictionary Module (Native ES Module)
|
||||
// =======================================================================
|
||||
|
||||
import { api } from './api.js';
|
||||
import { escapeHtml, showToast, openModal, closeModal } from './ui.js';
|
||||
|
||||
export let allCards = [];
|
||||
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
|
||||
export let selectedTag = '';
|
||||
export let selectedCollection = '';
|
||||
export let searchQuery = '';
|
||||
|
||||
let currentLookupData = null;
|
||||
let foundDuplicateCard = null;
|
||||
let duplicateCheckTimer = null;
|
||||
let isBatchMode = false;
|
||||
let selectedIds = new Set();
|
||||
|
||||
export function getAllCards() {
|
||||
return allCards;
|
||||
}
|
||||
|
||||
export function initDictionary() {
|
||||
// Elements - Search & Capture
|
||||
const termInput = document.getElementById('term-input');
|
||||
const clearSearchBtn = document.getElementById('clear-search-btn');
|
||||
const translateBtn = document.getElementById('translate-btn');
|
||||
const duplicateWarningBox = document.getElementById('duplicate-warning-box');
|
||||
const dupTermLabel = document.getElementById('dup-term-label');
|
||||
const dupTransLabel = document.getElementById('dup-trans-label');
|
||||
const dupOpenBtn = document.getElementById('dup-open-btn');
|
||||
|
||||
const lookupCard = document.getElementById('lookup-card');
|
||||
const resTerm = document.getElementById('res-term');
|
||||
const resPhonetic = document.getElementById('res-phonetic');
|
||||
const resInLibrary = document.getElementById('res-in-library');
|
||||
const audioPlayBtn = document.getElementById('audio-play-btn');
|
||||
const resPos = document.getElementById('res-pos');
|
||||
const resDefinitionWrap = document.getElementById('res-definition-wrap');
|
||||
const resDefinition = document.getElementById('res-definition');
|
||||
const editTranslation = document.getElementById('edit-translation');
|
||||
const editContext = document.getElementById('edit-context');
|
||||
const editCollection = document.getElementById('edit-collection');
|
||||
const collectionsDatalist = document.getElementById('collections-datalist');
|
||||
const editTags = document.getElementById('edit-tags');
|
||||
const editNotes = document.getElementById('edit-notes');
|
||||
const saveCardBtn = document.getElementById('save-card-btn');
|
||||
|
||||
// Elements - Vocabulary List
|
||||
const filterInput = document.getElementById('filter-input');
|
||||
const cardsCountLabel = document.getElementById('cards-count-label');
|
||||
const collectionsFilterBar = document.getElementById('collections-filter-bar');
|
||||
const newCollectionBtn = document.getElementById('new-collection-btn');
|
||||
const tagsFilterBar = document.getElementById('tags-filter-bar');
|
||||
const cardsList = document.getElementById('cards-list');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
|
||||
// Elements - Batch Action Bar
|
||||
const batchToggleBtn = document.getElementById('batch-toggle-btn');
|
||||
const batchActionBar = document.getElementById('batch-action-bar');
|
||||
const batchSelectAll = document.getElementById('batch-select-all');
|
||||
const batchCountLabel = document.getElementById('batch-count-label');
|
||||
const batchMoveBtn = document.getElementById('batch-move-btn');
|
||||
const batchTagBtn = document.getElementById('batch-tag-btn');
|
||||
const batchDeleteBtn = document.getElementById('batch-delete-btn');
|
||||
const batchCancelBtn = document.getElementById('batch-cancel-btn');
|
||||
|
||||
// Elements - Modals
|
||||
const editModal = document.getElementById('edit-card-modal');
|
||||
const closeModalBtn = document.getElementById('close-modal-btn');
|
||||
const modalCancelBtn = document.getElementById('modal-cancel-btn');
|
||||
const modalSaveBtn = document.getElementById('modal-save-btn');
|
||||
const modalCardId = document.getElementById('modal-card-id');
|
||||
const modalTerm = document.getElementById('modal-term');
|
||||
const modalTranslation = document.getElementById('modal-translation');
|
||||
const modalContext = document.getElementById('modal-context');
|
||||
const modalPhonetic = document.getElementById('modal-phonetic');
|
||||
const modalPos = document.getElementById('modal-pos');
|
||||
const modalDef = document.getElementById('modal-def');
|
||||
const modalCollection = document.getElementById('modal-collection');
|
||||
const modalTags = document.getElementById('modal-tags');
|
||||
const modalNotes = document.getElementById('modal-notes');
|
||||
|
||||
const newColModal = document.getElementById('new-col-modal');
|
||||
const newColNameInput = document.getElementById('new-col-name-input');
|
||||
const closeNewColModalBtn = document.getElementById('close-new-col-modal-btn');
|
||||
const newColCancelBtn = document.getElementById('new-col-cancel-btn');
|
||||
const newColSaveBtn = document.getElementById('new-col-save-btn');
|
||||
|
||||
const moveColModal = document.getElementById('move-col-modal');
|
||||
const moveColInput = document.getElementById('move-col-input');
|
||||
const moveColQuickList = document.getElementById('move-col-quick-list');
|
||||
const closeMoveModalBtn = document.getElementById('close-move-modal-btn');
|
||||
const moveColCancelBtn = document.getElementById('move-col-cancel-btn');
|
||||
const moveColConfirmBtn = document.getElementById('move-col-confirm-btn');
|
||||
|
||||
const exportOpenBtn = document.getElementById('export-open-btn');
|
||||
const exportModal = document.getElementById('export-modal');
|
||||
const closeExportModalBtn = document.getElementById('close-export-modal-btn');
|
||||
const exportCancelBtn = document.getElementById('export-cancel-btn');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Duplicate Detection & Search Term
|
||||
// -----------------------------------------------------------------------
|
||||
termInput.addEventListener('input', () => {
|
||||
const val = termInput.value.trim();
|
||||
clearSearchBtn.classList.toggle('hidden', val.length === 0);
|
||||
|
||||
clearTimeout(duplicateCheckTimer);
|
||||
if (val.length >= 2) {
|
||||
duplicateCheckTimer = setTimeout(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/cards/check-duplicate?term=${encodeURIComponent(val)}`);
|
||||
if (resp.ok) {
|
||||
const res = await resp.json();
|
||||
if (res.exists && res.card) {
|
||||
foundDuplicateCard = res.card;
|
||||
dupTermLabel.textContent = res.card.term;
|
||||
dupTransLabel.textContent = res.card.translation;
|
||||
duplicateWarningBox.classList.remove('hidden');
|
||||
} else {
|
||||
foundDuplicateCard = null;
|
||||
duplicateWarningBox.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}, 300);
|
||||
} else {
|
||||
foundDuplicateCard = null;
|
||||
duplicateWarningBox.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
dupOpenBtn.addEventListener('click', () => {
|
||||
if (foundDuplicateCard) {
|
||||
openEditModal(foundDuplicateCard);
|
||||
}
|
||||
});
|
||||
|
||||
clearSearchBtn.addEventListener('click', () => {
|
||||
termInput.value = '';
|
||||
clearSearchBtn.classList.add('hidden');
|
||||
duplicateWarningBox.classList.add('hidden');
|
||||
lookupCard.classList.add('hidden');
|
||||
termInput.focus();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Dictionary Lookup & Capture
|
||||
// -----------------------------------------------------------------------
|
||||
translateBtn.addEventListener('click', performLookup);
|
||||
termInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
performLookup();
|
||||
} else if (e.key === 'Escape') {
|
||||
termInput.value = '';
|
||||
clearSearchBtn.classList.add('hidden');
|
||||
duplicateWarningBox.classList.add('hidden');
|
||||
lookupCard.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function performLookup() {
|
||||
const term = termInput.value.trim();
|
||||
if (!term) return;
|
||||
|
||||
translateBtn.disabled = true;
|
||||
translateBtn.textContent = 'Ищем...';
|
||||
|
||||
try {
|
||||
const data = await api.cards.lookup(term);
|
||||
currentLookupData = data;
|
||||
displayLookupResult(data);
|
||||
} catch (e) {
|
||||
console.error('Lookup error:', e);
|
||||
showToast('Не удалось получить перевод', 'error');
|
||||
} finally {
|
||||
translateBtn.disabled = false;
|
||||
translateBtn.textContent = 'Перевести';
|
||||
}
|
||||
}
|
||||
|
||||
function displayLookupResult(data) {
|
||||
resTerm.textContent = data.term;
|
||||
resPhonetic.textContent = data.phonetic || '';
|
||||
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
|
||||
resPos.textContent = data.part_of_speech || 'Phrase';
|
||||
audioPlayBtn.classList.add('hidden');
|
||||
|
||||
if (data.definition_en) {
|
||||
resDefinition.textContent = data.definition_en;
|
||||
resDefinitionWrap.classList.remove('hidden');
|
||||
} else {
|
||||
resDefinitionWrap.classList.add('hidden');
|
||||
}
|
||||
|
||||
const inLib = Boolean(data.in_library || foundDuplicateCard);
|
||||
if (resInLibrary) {
|
||||
resInLibrary.classList.toggle('hidden', !inLib);
|
||||
}
|
||||
|
||||
if (inLib) {
|
||||
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
|
||||
const lib = data.library_card || foundDuplicateCard;
|
||||
if (lib) {
|
||||
if (lib.translation) editTranslation.value = lib.translation;
|
||||
if (lib.context_sentence) editContext.value = lib.context_sentence;
|
||||
if (lib.collection) editCollection.value = lib.collection;
|
||||
if (lib.tags) editTags.value = lib.tags;
|
||||
if (lib.notes) editNotes.value = lib.notes;
|
||||
} else {
|
||||
editTranslation.value = data.translation || '';
|
||||
}
|
||||
} else {
|
||||
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
|
||||
editTranslation.value = data.translation || '';
|
||||
editContext.value = '';
|
||||
editNotes.value = '';
|
||||
editCollection.value = selectedCollection || 'General';
|
||||
}
|
||||
|
||||
lookupCard.classList.remove('hidden');
|
||||
editTranslation.focus();
|
||||
}
|
||||
|
||||
// Save Card Action
|
||||
saveCardBtn.addEventListener('click', saveNewCard);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
if (!lookupCard.classList.contains('hidden')) {
|
||||
e.preventDefault();
|
||||
saveNewCard();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function saveNewCard() {
|
||||
if (!currentLookupData) return;
|
||||
const term = currentLookupData.term.trim();
|
||||
const translation = editTranslation.value.trim();
|
||||
if (!translation) {
|
||||
alert('Пожалуйста, укажите русский перевод');
|
||||
editTranslation.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
term: term,
|
||||
translation: translation,
|
||||
context_sentence: editContext.value.trim(),
|
||||
phonetic: currentLookupData.phonetic || '',
|
||||
audio_url: currentLookupData.audio_url || '',
|
||||
part_of_speech: currentLookupData.part_of_speech || '',
|
||||
definition_en: currentLookupData.definition_en || '',
|
||||
tags: editTags.value.trim() || 'general',
|
||||
notes: editNotes.value.trim(),
|
||||
collection: editCollection.value.trim() || 'General',
|
||||
};
|
||||
|
||||
saveCardBtn.disabled = true;
|
||||
saveCardBtn.textContent = 'Сохраняем...';
|
||||
|
||||
try {
|
||||
const resp = await api.cards.create(payload);
|
||||
if (resp.ok) {
|
||||
termInput.value = '';
|
||||
clearSearchBtn.classList.add('hidden');
|
||||
duplicateWarningBox.classList.add('hidden');
|
||||
lookupCard.classList.add('hidden');
|
||||
currentLookupData = null;
|
||||
|
||||
showToast(`Слово "${term}" сохранено в словарь!`, 'success');
|
||||
await loadInitialData();
|
||||
termInput.focus();
|
||||
} else {
|
||||
const err = await resp.json();
|
||||
alert('Ошибка сохранения: ' + (err.detail || 'Не удалось сохранить'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Сетевая ошибка при сохранении');
|
||||
} finally {
|
||||
saveCardBtn.disabled = false;
|
||||
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Cards List & Filtering
|
||||
// -----------------------------------------------------------------------
|
||||
filterInput.addEventListener('input', () => {
|
||||
searchQuery = filterInput.value.trim();
|
||||
fetchCards();
|
||||
});
|
||||
|
||||
function renderCollectionsFilter(cols) {
|
||||
collectionsFilterBar.innerHTML = '';
|
||||
const allBtn = document.createElement('button');
|
||||
allBtn.className = `col-chip ${selectedCollection === '' ? 'active' : ''}`;
|
||||
allBtn.textContent = 'Все коллекции';
|
||||
allBtn.onclick = () => {
|
||||
selectedCollection = '';
|
||||
updateFilterChips();
|
||||
fetchCards();
|
||||
};
|
||||
collectionsFilterBar.appendChild(allBtn);
|
||||
|
||||
cols.forEach(c => {
|
||||
const chip = document.createElement('button');
|
||||
chip.className = `col-chip ${selectedCollection === c.name ? 'active' : ''}`;
|
||||
chip.textContent = `${c.name} (${c.count})`;
|
||||
chip.onclick = () => {
|
||||
selectedCollection = c.name;
|
||||
updateFilterChips();
|
||||
fetchCards();
|
||||
};
|
||||
collectionsFilterBar.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTagsFilter(tags) {
|
||||
tagsFilterBar.innerHTML = '';
|
||||
const allBtn = document.createElement('button');
|
||||
allBtn.className = `filter-chip ${selectedTag === '' ? 'active' : ''}`;
|
||||
allBtn.textContent = 'Все теги';
|
||||
allBtn.onclick = () => {
|
||||
selectedTag = '';
|
||||
updateFilterChips();
|
||||
fetchCards();
|
||||
};
|
||||
tagsFilterBar.appendChild(allBtn);
|
||||
|
||||
tags.slice(0, 15).forEach(t => {
|
||||
const chip = document.createElement('button');
|
||||
chip.className = `filter-chip ${selectedTag === t.tag ? 'active' : ''}`;
|
||||
chip.textContent = `#${t.tag} (${t.count})`;
|
||||
chip.onclick = () => {
|
||||
selectedTag = selectedTag === t.tag ? '' : t.tag;
|
||||
updateFilterChips();
|
||||
fetchCards();
|
||||
};
|
||||
tagsFilterBar.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function updateFilterChips() {
|
||||
document.querySelectorAll('.col-chip').forEach(el => {
|
||||
el.classList.toggle('active', el.textContent.startsWith(selectedCollection || 'Все коллекции'));
|
||||
});
|
||||
document.querySelectorAll('.filter-chip').forEach(el => {
|
||||
el.classList.toggle('active', el.textContent.startsWith(`#${selectedTag}`) || (!selectedTag && el.textContent.startsWith('Все теги')));
|
||||
});
|
||||
}
|
||||
|
||||
function renderCardsList() {
|
||||
cardsCountLabel.textContent = `(${allCards.length} записей)`;
|
||||
cardsList.innerHTML = '';
|
||||
|
||||
if (allCards.length === 0) {
|
||||
emptyState.classList.remove('hidden');
|
||||
cardsList.appendChild(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
allCards.forEach(c => {
|
||||
const cardEl = document.createElement('div');
|
||||
cardEl.className = `vocab-card ${isBatchMode && selectedIds.has(c.id) ? 'is-selected' : ''}`;
|
||||
|
||||
const srsStateClass = c.srs_state === 'mastered' ? 'dot-mastered' : (c.srs_state === 'review' ? 'dot-review' : (c.srs_state === 'learning' ? 'dot-learning' : 'dot-new'));
|
||||
|
||||
cardEl.innerHTML = `
|
||||
<div class="card-top">
|
||||
<div class="card-term-group">
|
||||
${isBatchMode ? `<input type="checkbox" class="card-select-checkbox batch-card-check" data-id="${c.id}" ${selectedIds.has(c.id) ? 'checked' : ''}>` : ''}
|
||||
<span class="dot ${srsStateClass}" title="SRS статус: ${c.srs_state || 'new'}"></span>
|
||||
<span class="card-term">${escapeHtml(c.term)}</span>
|
||||
${c.phonetic ? `<span class="card-phonetic">${escapeHtml(c.phonetic)}</span>` : ''}
|
||||
${c.part_of_speech ? `<span class="card-pos">${escapeHtml(c.part_of_speech)}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button class="icon-btn edit-card-btn" data-id="${c.id}" title="Редактировать">✏️</button>
|
||||
<button class="icon-btn delete-card-btn" data-id="${c.id}" title="Удалить">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-translation">${escapeHtml(c.translation)}</div>
|
||||
${c.context_sentence ? `<div class="card-context">“${escapeHtml(c.context_sentence)}”</div>` : ''}
|
||||
<div class="card-bottom">
|
||||
<div class="card-tags">
|
||||
<span class="card-tag">📁 ${escapeHtml(c.collection || 'General')}</span>
|
||||
${c.tags ? c.tags.split(',').map(t => `<span class="card-tag">#${escapeHtml(t.trim())}</span>`).join(' ') : ''}
|
||||
</div>
|
||||
${c.notes ? `<div class="card-notes">💡 ${escapeHtml(c.notes)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Edit button
|
||||
cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openEditModal(c);
|
||||
});
|
||||
|
||||
// Delete button
|
||||
cardEl.querySelector('.delete-card-btn').addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(`Удалить слово "${c.term}" из словаря?`)) {
|
||||
await api.cards.delete(c.id);
|
||||
showToast(`Слово "${c.term}" удалено`, 'info');
|
||||
await loadInitialData();
|
||||
}
|
||||
});
|
||||
|
||||
// Batch selection click
|
||||
if (isBatchMode) {
|
||||
cardEl.addEventListener('click', () => {
|
||||
toggleBatchSelection(c.id);
|
||||
});
|
||||
}
|
||||
|
||||
cardsList.appendChild(cardEl);
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Batch Operations
|
||||
// -----------------------------------------------------------------------
|
||||
batchToggleBtn.addEventListener('click', () => {
|
||||
isBatchMode = !isBatchMode;
|
||||
batchToggleBtn.textContent = isBatchMode ? '✓ Завершить выбор' : '☑️ Выбрать';
|
||||
batchToggleBtn.classList.toggle('btn-primary', isBatchMode);
|
||||
batchToggleBtn.classList.toggle('btn-outline', !isBatchMode);
|
||||
batchActionBar.classList.toggle('hidden', !isBatchMode);
|
||||
if (!isBatchMode) {
|
||||
selectedIds.clear();
|
||||
}
|
||||
renderCardsList();
|
||||
updateBatchCount();
|
||||
});
|
||||
|
||||
batchCancelBtn.addEventListener('click', () => {
|
||||
isBatchMode = false;
|
||||
batchToggleBtn.textContent = '☑️ Выбрать';
|
||||
batchToggleBtn.classList.remove('btn-primary');
|
||||
batchToggleBtn.classList.add('btn-outline');
|
||||
batchActionBar.classList.add('hidden');
|
||||
selectedIds.clear();
|
||||
renderCardsList();
|
||||
});
|
||||
|
||||
function toggleBatchSelection(id) {
|
||||
if (selectedIds.has(id)) selectedIds.delete(id);
|
||||
else selectedIds.add(id);
|
||||
updateBatchCount();
|
||||
renderCardsList();
|
||||
}
|
||||
|
||||
batchSelectAll.addEventListener('change', () => {
|
||||
if (batchSelectAll.checked) {
|
||||
allCards.forEach(c => selectedIds.add(c.id));
|
||||
} else {
|
||||
selectedIds.clear();
|
||||
}
|
||||
updateBatchCount();
|
||||
renderCardsList();
|
||||
});
|
||||
|
||||
function updateBatchCount() {
|
||||
batchCountLabel.textContent = `Выбрано: ${selectedIds.size}`;
|
||||
batchSelectAll.checked = selectedIds.size > 0 && selectedIds.size === allCards.length;
|
||||
}
|
||||
|
||||
batchDeleteBtn.addEventListener('click', async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
if (confirm(`Удалить выбранные карточки (${selectedIds.size} шт.)?`)) {
|
||||
await api.cards.batch({
|
||||
card_ids: Array.from(selectedIds),
|
||||
action: 'delete'
|
||||
});
|
||||
showToast(`Удалено карточек: ${selectedIds.size}`, 'info');
|
||||
selectedIds.clear();
|
||||
await loadInitialData();
|
||||
}
|
||||
});
|
||||
|
||||
batchMoveBtn.addEventListener('click', () => {
|
||||
if (selectedIds.size === 0) {
|
||||
alert('Сначала выберите хотя бы одну карточку');
|
||||
return;
|
||||
}
|
||||
openModal(moveColModal);
|
||||
});
|
||||
|
||||
batchTagBtn.addEventListener('click', async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const tagVal = prompt('Введите тег(и) для добавления (через запятую):');
|
||||
if (tagVal && tagVal.trim()) {
|
||||
await api.cards.batch({
|
||||
card_ids: Array.from(selectedIds),
|
||||
action: 'add_tags',
|
||||
value: tagVal.trim()
|
||||
});
|
||||
showToast(`Теги добавлены`, 'success');
|
||||
await loadInitialData();
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edit Card Modal
|
||||
// -----------------------------------------------------------------------
|
||||
function openEditModal(card) {
|
||||
modalCardId.value = card.id;
|
||||
modalTerm.value = card.term;
|
||||
modalTranslation.value = card.translation;
|
||||
modalContext.value = card.context_sentence || '';
|
||||
modalPhonetic.value = card.phonetic || '';
|
||||
modalPos.value = card.part_of_speech || '';
|
||||
modalDef.value = card.definition_en || '';
|
||||
modalCollection.value = card.collection || 'General';
|
||||
modalTags.value = card.tags || '';
|
||||
modalNotes.value = card.notes || '';
|
||||
openModal(editModal);
|
||||
}
|
||||
|
||||
closeModalBtn.addEventListener('click', () => closeModal(editModal));
|
||||
modalCancelBtn.addEventListener('click', () => closeModal(editModal));
|
||||
|
||||
modalSaveBtn.addEventListener('click', async () => {
|
||||
const id = modalCardId.value;
|
||||
const payload = {
|
||||
term: modalTerm.value.trim(),
|
||||
translation: modalTranslation.value.trim(),
|
||||
context_sentence: modalContext.value.trim(),
|
||||
phonetic: modalPhonetic.value.trim(),
|
||||
part_of_speech: modalPos.value.trim(),
|
||||
definition_en: modalDef.value.trim(),
|
||||
collection: modalCollection.value.trim() || 'General',
|
||||
tags: modalTags.value.trim(),
|
||||
notes: modalNotes.value.trim(),
|
||||
};
|
||||
|
||||
const resp = await api.cards.update(id, payload);
|
||||
if (resp.ok) {
|
||||
closeModal(editModal);
|
||||
showToast('Карточка успешно обновлена', 'success');
|
||||
await loadInitialData();
|
||||
} else {
|
||||
alert('Ошибка при сохранении карточки');
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Export Modal
|
||||
// -----------------------------------------------------------------------
|
||||
if (exportOpenBtn) {
|
||||
exportOpenBtn.addEventListener('click', () => openModal(exportModal));
|
||||
}
|
||||
closeExportModalBtn.addEventListener('click', () => closeModal(exportModal));
|
||||
exportCancelBtn.addEventListener('click', () => closeModal(exportModal));
|
||||
|
||||
document.querySelectorAll('.export-tile').forEach(tile => {
|
||||
tile.addEventListener('click', () => {
|
||||
const format = tile.dataset.format;
|
||||
let url = `/api/export/${format}`;
|
||||
if (selectedCollection) url += `?collection=${encodeURIComponent(selectedCollection)}`;
|
||||
window.location.href = url;
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Collections Management
|
||||
// -----------------------------------------------------------------------
|
||||
newCollectionBtn.addEventListener('click', () => {
|
||||
openModal(newColModal);
|
||||
newColNameInput.value = '';
|
||||
newColNameInput.focus();
|
||||
});
|
||||
|
||||
closeNewColModalBtn.addEventListener('click', () => closeModal(newColModal));
|
||||
newColCancelBtn.addEventListener('click', () => closeModal(newColModal));
|
||||
|
||||
newColSaveBtn.addEventListener('click', () => {
|
||||
const name = newColNameInput.value.trim();
|
||||
if (name) {
|
||||
selectedCollection = name;
|
||||
closeModal(newColModal);
|
||||
fetchCards();
|
||||
}
|
||||
});
|
||||
|
||||
closeMoveModalBtn.addEventListener('click', () => closeModal(moveColModal));
|
||||
moveColCancelBtn.addEventListener('click', () => closeModal(moveColModal));
|
||||
|
||||
moveColConfirmBtn.addEventListener('click', async () => {
|
||||
const col = moveColInput.value.trim();
|
||||
if (col && selectedIds.size > 0) {
|
||||
await api.cards.batch({
|
||||
card_ids: Array.from(selectedIds),
|
||||
action: 'set_collection',
|
||||
value: col,
|
||||
});
|
||||
closeModal(moveColModal);
|
||||
selectedIds.clear();
|
||||
showToast(`Перемещено в "${col}"`, 'success');
|
||||
await loadInitialData();
|
||||
}
|
||||
});
|
||||
|
||||
function populateDatalists() {
|
||||
collectionsDatalist.innerHTML = '';
|
||||
const dl = document.getElementById('move-collections-datalist');
|
||||
if (dl) dl.innerHTML = '';
|
||||
|
||||
collectionsList.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c;
|
||||
collectionsDatalist.appendChild(opt);
|
||||
if (dl) dl.appendChild(opt.cloneNode(true));
|
||||
});
|
||||
|
||||
if (moveColQuickList) {
|
||||
moveColQuickList.innerHTML = '';
|
||||
collectionsList.forEach(c => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-xs btn-outline';
|
||||
btn.textContent = c;
|
||||
btn.onclick = () => { moveColInput.value = c; };
|
||||
moveColQuickList.appendChild(btn);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Data Loading Functions
|
||||
// -----------------------------------------------------------------------
|
||||
async function fetchCards() {
|
||||
try {
|
||||
const params = { limit: 500 };
|
||||
if (selectedCollection) params.collection = selectedCollection;
|
||||
if (selectedTag) params.tag = selectedTag;
|
||||
if (searchQuery) params.search = searchQuery;
|
||||
|
||||
const data = await api.cards.getAll(params);
|
||||
allCards = data.cards || [];
|
||||
renderCardsList();
|
||||
} catch (e) {
|
||||
console.error('Error fetching cards:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCollections() {
|
||||
try {
|
||||
const cols = await api.collections.getAll();
|
||||
collectionsList = cols.map(c => c.name);
|
||||
renderCollectionsFilter(cols);
|
||||
populateDatalists();
|
||||
} catch (e) {
|
||||
console.error('Error fetching collections:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTags() {
|
||||
try {
|
||||
const tags = await api.tags.getAll();
|
||||
renderTagsFilter(tags);
|
||||
} catch (e) {
|
||||
console.error('Error fetching tags:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const data = await api.stats.getStats();
|
||||
if (data) {
|
||||
const statTotal = document.getElementById('stat-total');
|
||||
const statToday = document.getElementById('stat-today');
|
||||
if (statTotal) statTotal.textContent = data.total_cards;
|
||||
if (statToday) statToday.textContent = `+${data.today_added}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function fetchSrsStats() {
|
||||
try {
|
||||
const stats = await api.stats.getSrsStats();
|
||||
if (stats) {
|
||||
const due = stats.due_cards || 0;
|
||||
const srsBadge = document.getElementById('srs-badge');
|
||||
if (srsBadge) srsBadge.textContent = due;
|
||||
const tabSrsBadge = document.getElementById('tab-srs-badge');
|
||||
if (tabSrsBadge) {
|
||||
tabSrsBadge.textContent = due;
|
||||
tabSrsBadge.classList.toggle('hidden', due === 0);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
await Promise.all([
|
||||
fetchCards(),
|
||||
fetchCollections(),
|
||||
fetchTags(),
|
||||
fetchStats(),
|
||||
fetchSrsStats(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Attach to module exports
|
||||
initDictionary.fetchCards = fetchCards;
|
||||
initDictionary.loadInitialData = loadInitialData;
|
||||
initDictionary.openEditModal = openEditModal;
|
||||
}
|
||||
|
||||
export async function loadInitialData() {
|
||||
if (initDictionary.loadInitialData) {
|
||||
return await initDictionary.loadInitialData();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCards() {
|
||||
if (initDictionary.fetchCards) {
|
||||
return await initDictionary.fetchCards();
|
||||
}
|
||||
}
|
||||
|
||||
export function openEditModal(card) {
|
||||
if (initDictionary.openEditModal) {
|
||||
initDictionary.openEditModal(card);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,735 @@
|
||||
// =======================================================================
|
||||
// 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 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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// =======================================================================
|
||||
// Notes Application - UI & Utilities Module (Native ES Module)
|
||||
// =======================================================================
|
||||
|
||||
export function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function escapeRegex(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function slugify(text) {
|
||||
if (!text) return 'book';
|
||||
return text.toString().toLowerCase().trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function shuffleArray(array) {
|
||||
const arr = [...array];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Toast Notifications
|
||||
// -----------------------------------------------------------------------
|
||||
export function showToast(message, type = 'info') {
|
||||
let toastContainer = document.getElementById('notes-toast-container');
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.id = 'notes-toast-container';
|
||||
toastContainer.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
const colors = {
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
info: '#3b82f6'
|
||||
};
|
||||
const bg = colors[type] || colors.info;
|
||||
|
||||
toast.style.cssText = `
|
||||
background: ${bg};
|
||||
color: #ffffff;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 14px rgba(0,0,0,0.25);
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
transition: all 0.25s ease;
|
||||
pointer-events: auto;
|
||||
max-width: 340px;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
toastContainer.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.style.opacity = '1';
|
||||
toast.style.transform = 'translateY(0)';
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateY(12px)';
|
||||
setTimeout(() => toast.remove(), 250);
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Theme Management
|
||||
// -----------------------------------------------------------------------
|
||||
export function initTheme() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const mobileThemeBtn = document.getElementById('mobile-theme-btn');
|
||||
const savedTheme = localStorage.getItem('notes_theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
function toggle() {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('notes_theme', next);
|
||||
}
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', toggle);
|
||||
}
|
||||
if (mobileThemeBtn) {
|
||||
mobileThemeBtn.addEventListener('click', () => {
|
||||
toggle();
|
||||
showToast(`Тема переключена`, 'info');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Modal Open / Close Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
export function openModal(modalElem) {
|
||||
if (!modalElem) return;
|
||||
modalElem.classList.remove('hidden');
|
||||
}
|
||||
|
||||
export function closeModal(modalElem) {
|
||||
if (!modalElem) return;
|
||||
modalElem.classList.add('hidden');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Header Stat Counters
|
||||
// -----------------------------------------------------------------------
|
||||
export function updateHeaderStats(total = 0, today = 0, srsCount = 0) {
|
||||
const statTotal = document.getElementById('stat-total');
|
||||
const statToday = document.getElementById('stat-today');
|
||||
const srsBadge = document.getElementById('srs-badge');
|
||||
|
||||
if (statTotal) statTotal.textContent = total;
|
||||
if (statToday) statToday.textContent = today;
|
||||
if (srsBadge) {
|
||||
srsBadge.textContent = srsCount;
|
||||
srsBadge.style.display = srsCount > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Audio Player Helper (Safe stub / audio stream)
|
||||
// -----------------------------------------------------------------------
|
||||
export function playAudio(url) {
|
||||
if (!url) return;
|
||||
try {
|
||||
const audio = new Audio(url);
|
||||
audio.play().catch(e => console.warn('Audio playback not supported or blocked:', e));
|
||||
} catch (e) {
|
||||
console.warn('Audio play error:', e);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -1,13 +1,18 @@
|
||||
// Service Worker for Notes English PWA (Stage 2)
|
||||
// Strategy: Cache-first for static assets, Network-first for API calls.
|
||||
|
||||
const CACHE_NAME = 'notes-v2.2';
|
||||
const CACHE_NAME = 'notes-v5.0';
|
||||
const OFFLINE_URL = '/';
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/static/css/style.css',
|
||||
'/static/js/app.js',
|
||||
'/static/js/modules/api.js',
|
||||
'/static/js/modules/ui.js',
|
||||
'/static/js/modules/dictionary.js',
|
||||
'/static/js/modules/srs.js',
|
||||
'/static/js/modules/reader.js',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user