191 lines
7.5 KiB
TypeScript
191 lines
7.5 KiB
TypeScript
// Страница повторения колоды: карточка за карточкой по случайной выборке.
|
||||
|
|
// Лицевая сторона — поля из 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, '<')
|
|||
|
|
.replace(/>/g, '>')
|
|||
|
|
.replace(/"/g, '"')
|
|||
|
|
.replace(/'/g, ''');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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)}`);
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
}
|