Files
jap_web_ng/src/review-page.ts
T
2026-09-15 16:10:52 +03:00

192 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Страница повторения колоды: карточка за карточкой по случайной выборке.
// Лицевая сторона — поля из 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)}`);
}
})();
}