repetition woks

This commit is contained in:
micialware committed 2026-09-15 16:10:52 +03:00
1 parent 37b3b61f79
commit 36d2c0b48f
15 files changed
+905 -185

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
// Страница повторения колоды: карточка за карточкой по случайной выборке.
// Лицевая сторона — поля из deck.forward, по тапу дорисовывается deck.backward.
// Внизу 4 кнопки самооценки: "не получилось" / "тяжело" / "хорошо" / "легко".
// Пока без записи ответов: только навигация по очереди (история — следующий шаг).
import {
getCustomFields,
getDeck,
getWordsByIds,
renderCardSide,
sampleDeckWordIds,
wordFieldValues,
} from './db-types.ts';
import type { CustomField, Word } from './db-types.ts';
import { hasDatabaseFile } from './opfs.ts';
export type ReviewNav = {
goDecks: () => void;
};
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const GRADES = [
{ label: '😞 Не получилось', cls: 'grade-fail' },
{ label: '😟 Тяжело', cls: 'grade-hard' },
{ label: '🙂 Хорошо', cls: 'grade-good' },
{ label: '🤩 Легко', cls: 'grade-easy' },
] as const;
export function renderReviewPage(root: HTMLElement, deckId: number, nav: ReviewNav): void {
root.innerHTML = `
<button id="review-back" class="btn btn-ghost" type="button">← Назад к колодам</button>
<section class="card" aria-labelledby="review-title">
<h2 id="review-title" class="card-title">Повторение…</h2>
<p id="review-progress" class="card-hint" role="status" aria-live="polite"></p>
<div id="review-status" class="status-bar" role="status">Загрузка карточек…</div>
<div id="review-body"></div>
</section>
`;
const backBtn = root.querySelector<HTMLButtonElement>('#review-back')!;
const titleEl = root.querySelector<HTMLElement>('#review-title')!;
const progressEl = root.querySelector<HTMLElement>('#review-progress')!;
const statusEl = root.querySelector<HTMLElement>('#review-status')!;
const bodyEl = root.querySelector<HTMLElement>('#review-body')!;
backBtn.addEventListener('click', () => nav.goDecks());
function setStatus(msg: string): void {
statusEl.textContent = msg;
statusEl.hidden = msg === '';
}
// Очередь сессии: перетасованные id без повторений + подгруженные слова.
let queue: number[] = [];
let words = new Map<number, Word>();
let fields: CustomField[] = [];
let forwardTpl = '';
let backwardTpl = '';
let pos = 0;
let revealed = false;
function sideHtml(values: string[], cls: string): string {
if (values.length === 0) {
return `<div class="review-side ${cls}"><span class="empty-val">—</span></div>`;
}
return `<div class="review-side ${cls}">`
+ values.map((v) => `<p class="review-line">${escapeHtml(v)}</p>`).join('')
+ `</div>`;
}
function paint(): void {
const w = words.get(queue[pos]!);
if (!w) {
bodyEl.innerHTML = '';
progressEl.textContent = '';
setStatus('Слово пропало из базы — пропускаю.');
return;
}
const values = wordFieldValues(w, fields);
const front = renderCardSide(forwardTpl, values);
const back = revealed ? renderCardSide(backwardTpl, values) : null;
progressEl.textContent = `Карточка ${pos + 1} из ${queue.length}`;
setStatus('');
const backHtml = !revealed
? `<p class="review-tap">Тап — показать ответ</p>`
: sideHtml(back ?? [], 'review-back-side');
// Оценки — только после открытия (вместе с backward), до этого лишь тап по карточке.
const gradesHtml = revealed
? `<div class="review-grades" role="group" aria-label="Оценка повторения">
${GRADES.map((g) => `<button class="btn review-grade ${g.cls}" type="button">${g.label}</button>`).join('')}
</div>`
: '';
bodyEl.innerHTML = `
<button id="review-card" class="review-card" type="button"
aria-expanded="${revealed}" aria-label="${revealed ? 'Карточка открыта' : 'Показать ответ'}">
${sideHtml(front, 'review-front')}
${revealed ? `<hr class="review-hr" />` : ''}
${backHtml}
</button>
${gradesHtml}
`;
bodyEl.querySelector<HTMLButtonElement>('#review-card')!.addEventListener('click', () => {
if (!revealed) {
revealed = true;
paint();
}
});
bodyEl.querySelectorAll<HTMLButtonElement>('.review-grade').forEach((btn) => {
btn.addEventListener('click', () => {
// Запись ответов (history/states) — следующим шагом, пока просто дальше.
pos += 1;
revealed = false;
if (pos >= queue.length) paintDone();
else paint();
});
});
}
function paintDone(): void {
progressEl.textContent = `Готово: ${queue.length} из ${queue.length}`;
setStatus('');
bodyEl.innerHTML = `
<p class="card-hint">Все карточки сессии просмотрены. Запись оценок — следующим шагом.</p>
<div class="btn-row">
<button id="review-again" class="btn btn-primary" type="button">🔀 Перемешать заново</button>
</div>
`;
bodyEl.querySelector<HTMLButtonElement>('#review-again')!.addEventListener('click', () => {
void reshuffle();
});
}
async function reshuffle(): Promise<void> {
setStatus('Перемешиваю…');
bodyEl.innerHTML = '';
progressEl.textContent = '';
queue = await sampleDeckWordIds(deckId);
pos = 0;
revealed = false;
if (queue.length === 0) {
setStatus('В колоде нет слов для повторения.');
return;
}
words = await getWordsByIds(queue);
paint();
}
void (async () => {
const has = await hasDatabaseFile();
if (!has) {
titleEl.textContent = 'Повторение недоступно';
setStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
return;
}
try {
const [deck, customFields] = await Promise.all([getDeck(deckId), getCustomFields()]);
if (!deck) {
titleEl.textContent = 'Колода не найдена';
setStatus(`Колоды с id=${deckId} нет в локальной базе. Вернись к списку.`);
return;
}
fields = customFields;
forwardTpl = deck.forward;
backwardTpl = deck.backward;
titleEl.textContent = deck.name || `Колода #${deck.id}`;
queue = await sampleDeckWordIds(deck.id);
if (queue.length === 0) {
progressEl.textContent = '';
setStatus('В колоде нет слов для повторения.');
return;
}
// Слова грузим одним батчем (чанки внутри getWordsByIds), очередь уже перемешана.
words = await getWordsByIds(queue);
pos = 0;
revealed = false;
paint();
} catch (err) {
titleEl.textContent = 'Повторение недоступно';
setStatus(`Не удалось загрузить карточки: ${err instanceof Error ? err.message : String(err)}`);
}
})();
}