repetition woks
This commit is contained in:
1 parent
37b3b61f79
commit
36d2c0b48f
15 files changed
+905
-185
No files matched your search
@@ -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, '<')
|
||||
.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)}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
Reference in new issue
Block a user