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

+185 -1
View File
@@ -7,7 +7,7 @@
// т.е. количество дней с 1970-01-01, без времени;
// - states.fsrs_memory: сырое INTEGER-значение FSRS (не дата, не трогаем).
import { query } from "./sqlite-api.ts";
import { command, query } from "./sqlite-api.ts";
export interface CustomField {
id: number;
@@ -177,6 +177,190 @@ export async function getDecks(): Promise<Deck[]> {
}));
}
/**
* Обновляет редактируемые поля колоды (forward / backward / filter).
* whitelist колонок захардкожен — sql-инъекция через имя поля невозможна.
* Пустые строки разрешены: схема decks требует NOT NULL, но не запрещает ''.
*/
export async function updateDeckFields(
id: number,
patch: { forward: string; backward: string; filter: string },
): Promise<Deck | null> {
await command("UPDATE decks SET forward = ?, backward = ?, filter = ? WHERE id = ?", [
patch.forward,
patch.backward,
patch.filter,
id,
] as never[]);
return getDeck(id);
}
/** Одна колода по id. null — нет такой строки. */
export async function getDeck(id: number): Promise<Deck | null> {
const rows = await query(
"SELECT id, name, forward, backward, filter FROM decks WHERE id = ? LIMIT 1",
[id] as never[],
);
const r = (rows as Record<string, unknown>[])[0];
if (!r) return null;
return {
id: Number(r["id"]),
name: String(r["name"] ?? ""),
forward: String(r["forward"] ?? ""),
backward: String(r["backward"] ?? ""),
filter: String(r["filter"] ?? ""),
};
}
/**
* Сколько строк states привязано к каждой колоде (states.deck_id).
* Один запрос на весь список — кладёт counts.get(deckId) ?? 0 в строку колоды.
*/
export async function getDeckStateCounts(): Promise<Map<number, number>> {
const rows = await query(
"SELECT deck_id, COUNT(*) as cnt FROM states WHERE deck_id IS NOT NULL GROUP BY deck_id",
);
const m = new Map<number, number>();
for (const r of rows as Record<string, unknown>[]) {
if (r["deck_id"] == null) continue;
const cnt = r["cnt"];
m.set(Number(r["deck_id"]), typeof cnt === "number" ? cnt : Number(cnt ?? 0));
}
return m;
}
/** Сколько states у одной колоды (для списка колод). */
export async function getDeckStateCount(deckId: number): Promise<number> {
const rows = await query("SELECT COUNT(*) as cnt FROM states WHERE deck_id = ?", [
deckId,
] as never[]);
const cnt = (rows[0] as Record<string, unknown> | undefined)?.["cnt"];
return typeof cnt === "number" ? cnt : Number(cnt ?? 0);
}
/**
* Сколько уникальных слов связано с колодой (states.word_id при deck_id).
* Именно это показываем на странице колоды как «количество слов».
*/
export async function getDeckWordCount(deckId: number): Promise<number> {
const rows = await query(
"SELECT COUNT(DISTINCT word_id) as cnt FROM states WHERE deck_id = ?",
[deckId] as never[],
);
const cnt = (rows[0] as Record<string, unknown> | undefined)?.["cnt"];
return typeof cnt === "number" ? cnt : Number(cnt ?? 0);
}
/** Все строки states одной колоды, свежие первыми. */
export async function getDeckStates(deckId: number): Promise<WordState[]> {
const rows = await query(
"SELECT id, word_id, deck_id, score, last_opened, fsrs_opened, fsrs_memory, fsrs_open_day FROM states WHERE deck_id = ? ORDER BY last_opened DESC, id DESC",
[deckId] as never[],
);
return (rows as Record<string, unknown>[]).map((r) => ({
id: Number(r["id"]),
word_id: Number(r["word_id"]),
deck_id: r["deck_id"] == null ? null : Number(r["deck_id"]),
score: Number(r["score"] ?? 0),
last_opened: Number(r["last_opened"] ?? 0),
fsrs_opened: Number(r["fsrs_opened"] ?? 0),
fsrs_memory: Number(r["fsrs_memory"] ?? 0),
fsrs_open_day: Number(r["fsrs_open_day"] ?? 0),
}));
}
/**
* Пакетная подгрузка слов по id (для страницы колоды: показать key/value рядом со states).
* Возвращает Map word_id -> Word, чтобы не делать N+1 запросов.
*/
export async function getWordsByIds(ids: number[]): Promise<Map<number, Word>> {
const m = new Map<number, Word>();
const uniq = [...new Set(ids.filter((n) => Number.isFinite(n)))];
if (uniq.length === 0) return m;
// SQLite-плейсхолдеры строим чанками, чтобы не упереться в лимиты.
const CHUNK = 400;
for (let i = 0; i < uniq.length; i += CHUNK) {
const part = uniq.slice(i, i + CHUNK);
const holders = part.map(() => "?").join(",");
const rows = await query(
`SELECT id, key, value, tags, more, group_id FROM words WHERE id IN (${holders})`,
part as never[],
);
for (const w of (rows as Record<string, unknown>[]).map(mapRowToWord)) m.set(w.id, w);
}
return m;
}
/**
* Значения «именованных» полей слова для шаблонов forward/backward.
* Ключи: встроенные key/value/tags + identifier и name каждого custom_fields (lowercase).
* Пустые значения пропускаем на этапе рендера.
*/
export function wordFieldValues(
word: Word,
fields: CustomField[],
): Map<string, string> {
const m = new Map<string, string>();
m.set("key", word.key);
m.set("value", word.value);
m.set("tags", word.tagsList.join(", "));
for (const f of fields) {
const v = word.more.get(f.id) ?? "";
if (!v) continue;
// identifier — каноническое имя из десктопа (напр. "reading").
// name тоже кладём — вдруг шаблон ссылается на отображаемое имя.
// Первое непустое побеждает: не затираем уже положенное.
for (const k of [f.identifier, f.name]) {
const key = k.trim().toLowerCase();
if (!key || m.has(key)) continue;
m.set(key, v);
}
}
return m;
}
/**
* Рендер стороны карточки: список имён полей через пробел ("reading value").
* Неизвестные/пустые поля пропускаем. Возвращает непустые значения по порядку.
*/
export function renderCardSide(
template: string,
values: Map<string, string>,
): string[] {
const out: string[] = [];
for (const tok of template.split(/\s+/)) {
const t = tok.trim().toLowerCase();
if (!t) continue;
const v = (values.get(t) ?? "").trim();
if (v) out.push(v);
}
return out;
}
/**
* Случайная выборка word_id из states колоды без повторений (Fisher–Yates).
* Один запрос DISTINCT word_id, тасуем в памяти, режем до limit.
*/
export async function sampleDeckWordIds(
deckId: number,
limit?: number,
): Promise<number[]> {
const rows = await query(
"SELECT DISTINCT word_id as word_id FROM states WHERE deck_id = ?",
[deckId] as never[],
);
const ids = (rows as Record<string, unknown>[])
.map((r) => Number(r["word_id"]))
.filter((n) => Number.isFinite(n));
// Тасование Фишера–Йетса: равномерная перестановка за O(n).
for (let i = ids.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[ids[i], ids[j]] = [ids[i]!, ids[j]!];
}
if (limit != null && limit >= 0) return ids.slice(0, limit);
return ids;
}
export async function getCustomFields(): Promise<CustomField[]> {
const rows = await query("SELECT id, name, identifier FROM custom_fields ORDER BY id");
return (rows as Record<string, unknown>[]).map((r) => ({
+1 -1
View File
@@ -36,7 +36,7 @@ self.onmessage = async (e) => {
}
case 'command': {
if (db === null) throw new Error('БД не инициализирована');
db.exec(sql, params ?? []);
db.exec({ sql, bind: params ?? [] });
self.postMessage({ type: 'success', id, data: null });
break;
}
+171
View File
@@ -0,0 +1,171 @@
// Страница одной колоды: данные строки decks + количество связанных слов.
// forward / backward / filter — редактируемые, сохраняются в локальную БД.
// Никакого списка states здесь нет. Навигация: «← Назад к колодам» в список.
import { getDeck, getDeckWordCount, updateDeckFields } from './db-types.ts';
import { hasDatabaseFile } from './opfs.ts';
export type DeckNav = {
goDecks: () => void;
goReview: (deckId: number) => void;
};
function fmtOrDash(s: string): string {
return s ? escapeHtml(s) : '<span class="empty-val">—</span>';
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** attribute-escape для подстановки текущих значений в value="...". */
function escapeAttr(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function pluralWords(n: number): string {
const m10 = n % 10;
const m100 = n % 100;
if (m10 === 1 && m100 !== 11) return 'слово';
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'слова';
return 'слов';
}
export function renderDeckPage(root: HTMLElement, deckId: number, nav: DeckNav): void {
root.innerHTML = `
<button id="deck-back" class="btn btn-ghost" type="button">← Назад к колодам</button>
<section class="card" aria-labelledby="deck-title">
<h2 id="deck-title" class="card-title">Колода…</h2>
<div id="deck-status" class="status-bar" role="status">Загрузка колоды…</div>
<div id="deck-body"></div>
</section>
`;
const backBtn = root.querySelector<HTMLButtonElement>('#deck-back')!;
const titleEl = root.querySelector<HTMLElement>('#deck-title')!;
const statusEl = root.querySelector<HTMLElement>('#deck-status')!;
const bodyEl = root.querySelector<HTMLElement>('#deck-body')!;
backBtn.addEventListener('click', () => nav.goDecks());
void (async () => {
const has = await hasDatabaseFile();
if (!has) {
titleEl.textContent = 'Колода недоступна';
statusEl.textContent = 'Нет локальной базы — скачай сохранение на странице «Синхронизация».';
return;
}
let deck = null;
try {
deck = await getDeck(deckId);
} catch (err) {
titleEl.textContent = 'Колода недоступна';
statusEl.textContent = `Не удалось загрузить колоду: ${err instanceof Error ? err.message : String(err)}`;
return;
}
if (!deck) {
titleEl.textContent = 'Колода не найдена';
statusEl.textContent = `Колоды с id=${deckId} нет в локальной базе. Возможно, база обновилась — вернись к списку.`;
return;
}
const d = deck;
titleEl.textContent = d.name || `Колода #${d.id}`;
statusEl.hidden = true;
let wordCount: number | null = null;
try {
wordCount = await getDeckWordCount(d.id);
} catch (err) {
statusEl.hidden = false;
statusEl.textContent = `Данные колоды загружены, а количество слов — нет: ${err instanceof Error ? err.message : String(err)}`;
}
bodyEl.innerHTML = `
<dl class="word-extra-list">
<div class="word-extra-row"><dt>id</dt><dd>${d.id}</dd></div>
<div class="word-extra-row"><dt>name</dt><dd>${fmtOrDash(d.name)}</dd></div>
</dl>
<form id="deck-form" class="deck-form" autocomplete="off">
<label class="field-label" for="deck-forward">forward</label>
<input id="deck-forward" class="token-input" type="text" value="${escapeAttr(d.forward)}" />
<label class="field-label" for="deck-backward">backward</label>
<input id="deck-backward" class="token-input" type="text" value="${escapeAttr(d.backward)}" />
<label class="field-label" for="deck-filter">filter</label>
<input id="deck-filter" class="token-input" type="text" value="${escapeAttr(d.filter)}" />
<div class="btn-row">
<button id="deck-save" class="btn btn-primary" type="submit">💾 Сохранить</button>
</div>
<p id="deck-form-status" class="card-hint" role="status" aria-live="polite"></p>
</form>
<h3 class="deck-sub">📚 ${wordCount == null ? 'Количество слов неизвестно' : `${wordCount} ${pluralWords(wordCount)}`}</h3>
<div class="btn-row">
<button id="deck-review" class="btn btn-primary" type="button"${wordCount === 0 ? ' disabled' : ''}>▶ Повторять</button>
</div>
`;
const form = bodyEl.querySelector<HTMLFormElement>('#deck-form')!;
const forwardInput = bodyEl.querySelector<HTMLInputElement>('#deck-forward')!;
const backwardInput = bodyEl.querySelector<HTMLInputElement>('#deck-backward')!;
const filterInput = bodyEl.querySelector<HTMLInputElement>('#deck-filter')!;
const saveBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-save')!;
const formStatus = bodyEl.querySelector<HTMLElement>('#deck-form-status')!;
const reviewBtn = bodyEl.querySelector<HTMLButtonElement>('#deck-review')!;
reviewBtn.addEventListener('click', () => nav.goReview(d.id));
// Кнопка активна только при отличии от сохранённого.
const snapshot = (): string =>
`${forwardInput.value}\n${backwardInput.value}\n${filterInput.value}`;
let savedSnapshot = snapshot();
function refreshDirty(): void {
saveBtn.disabled = snapshot() === savedSnapshot || saveBtn.dataset.busy === '1';
}
for (const el of [forwardInput, backwardInput, filterInput]) {
el.addEventListener('input', refreshDirty);
}
refreshDirty();
form.addEventListener('submit', (e) => {
e.preventDefault();
if (saveBtn.disabled) return;
saveBtn.dataset.busy = '1';
saveBtn.disabled = true;
saveBtn.textContent = 'Сохранение…';
formStatus.textContent = '';
void (async () => {
try {
const updated = await updateDeckFields(d.id, {
forward: forwardInput.value,
backward: backwardInput.value,
filter: filterInput.value,
});
if (!updated) {
formStatus.textContent = 'Колода пропала из базы — вернись к списку.';
return;
}
forwardInput.value = updated.forward;
backwardInput.value = updated.backward;
filterInput.value = updated.filter;
savedSnapshot = snapshot();
formStatus.textContent = '✅ Сохранено в локальную базу.';
} catch (err) {
formStatus.textContent = `Не удалось сохранить: ${err instanceof Error ? err.message : String(err)}`;
} finally {
delete saveBtn.dataset.busy;
saveBtn.textContent = '💾 Сохранить';
refreshDirty();
}
})();
});
})();
}
+34 -76
View File
@@ -1,10 +1,15 @@
// Страница «Колоды»: плоский список строк из таблицы decks 1:1.
// Никаких связей (states/history/words) здесь не додумываем — только колонки таблицы.
// Страница «Колоды»: плоский список — только название + количество states.
// Раскрытия по нажатию нет: тап переходит на страницу колоды, где все данные.
import { getDecks } from './db-types.ts';
import { getDecks, getDeckStateCounts } from './db-types.ts';
import type { Deck } from './db-types.ts';
import { hasDatabaseFile } from './opfs.ts';
export type DecksNav = {
goDeck: (deckId: number) => void;
goReview: (deckId: number) => void;
};
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
@@ -14,34 +19,28 @@ function escapeHtml(s: string): string {
.replace(/'/g, '&#39;');
}
// Одна колода — плоская строка: имя слева, направление справа.
function deckRowHtml(d: Deck, expanded: boolean): string {
const dir = [d.forward, d.backward].filter((s) => s.trim().length > 0).join(' → ');
return `<div class="word-row-wrap" data-deck-id="${d.id}">`
+ `<button class="word-row" type="button" data-deck-id="${d.id}"`
+ ` aria-expanded="${expanded}" aria-controls="deck-extra-${d.id}">`
+ `<span class="word-main"><span class="word-key">${escapeHtml(d.name)}</span>`
+ (dir ? `<span class="word-sep"> · </span><span class="word-value">${escapeHtml(dir)}</span>` : '')
+ `</span></button>`
+ `<div class="word-extra" id="deck-extra-${d.id}"${expanded ? '' : ' hidden'}>`
+ (expanded ? deckExtraHtml(d) : '')
+ `</div></div>`;
function plural(n: number): string {
const m10 = n % 10;
const m100 = n % 100;
if (m10 === 1 && m100 !== 11) return 'запись';
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return 'записи';
return 'записей';
}
// Раскрытие — сырые поля строки decks (имена колонок как в dbschema.sql).
function deckExtraHtml(d: Deck): string {
const rows: Array<[string, string]> = [
['forward', d.forward],
['backward', d.backward],
['filter', d.filter],
];
const html = rows
.map(([k, v]) => `<div class="word-extra-row"><dt>${escapeHtml(k)}</dt><dd>${v ? escapeHtml(v) : '<span class="empty-val">—</span>'}</dd></div>`)
.join('');
return `<dl class="word-extra-list">${html}</dl>`;
// Одна колода — плоская строка: имя + количество слева, «▶» справа.
// Тап по имени — на страницу колоды, тап по «▶» — сразу в повторение.
function deckRowHtml(d: Deck, count: number): string {
const name = escapeHtml(d.name) || `<span class="empty-val">Колода #${d.id}</span>`;
return `<div class="word-row-wrap deck-row" data-deck-id="${d.id}">`
+ `<button class="word-row deck-name" type="button" data-act="open" data-deck-id="${d.id}">`
+ `<span class="word-main"><span class="word-key">${name}</span></span>`
+ `<span class="word-tag">${count} ${plural(count)}</span>`
+ `</button>`
+ `<button class="deck-play" type="button" data-act="review" data-deck-id="${d.id}" aria-label="Повторять: ${escapeHtml(d.name) || `колода ${d.id}`}">▶</button>`
+ `</div>`;
}
export function renderDecksPage(root: HTMLElement): void {
export function renderDecksPage(root: HTMLElement, nav: DecksNav): void {
root.innerHTML = `
<section class="card" aria-labelledby="decks-title">
<h2 id="decks-title" class="card-title">🗂️ Колоды</h2>
@@ -55,62 +54,18 @@ export function renderDecksPage(root: HTMLElement): void {
const statusEl = root.querySelector<HTMLElement>('#decks-status')!;
const listEl = root.querySelector<HTMLElement>('#decks-list')!;
let decks: Deck[] = [];
let expandedId: number | null = null;
function setStatus(msg: string): void {
statusEl.textContent = msg;
statusEl.hidden = msg === '';
}
function collapseRow(id: number): void {
const wrap = listEl.querySelector(`.word-row-wrap[data-deck-id="${id}"]`);
if (!wrap) return;
const btn = wrap.querySelector<HTMLButtonElement>('.word-row');
const extra = wrap.querySelector<HTMLElement>('.word-extra');
if (btn) {
btn.classList.remove('open');
btn.setAttribute('aria-expanded', 'false');
}
if (extra) {
extra.hidden = true;
extra.innerHTML = '';
}
}
function expandRow(id: number): void {
const d = decks.find((x) => x.id === id);
const wrap = listEl.querySelector(`.word-row-wrap[data-deck-id="${id}"]`);
if (!d || !wrap) return;
const btn = wrap.querySelector<HTMLButtonElement>('.word-row');
const extra = wrap.querySelector<HTMLElement>('.word-extra');
if (btn) {
btn.classList.add('open');
btn.setAttribute('aria-expanded', 'true');
}
if (extra) {
extra.hidden = false;
extra.innerHTML = deckExtraHtml(d);
}
}
function setExpanded(id: number | null): void {
if (expandedId === id) {
if (id != null) collapseRow(id);
expandedId = null;
return;
}
if (expandedId != null) collapseRow(expandedId);
expandedId = id;
if (id != null) expandRow(id);
}
listEl.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.word-row');
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-act]');
if (!btn) return;
const id = Number(btn.dataset.deckId);
if (!Number.isFinite(id)) return;
setExpanded(expandedId === id ? null : id);
if (btn.dataset.act === 'review') nav.goReview(id);
else nav.goDeck(id);
});
void (async () => {
@@ -122,7 +77,7 @@ export function renderDecksPage(root: HTMLElement): void {
}
setStatus('Загрузка колод…');
try {
decks = await getDecks();
const [decks, counts] = await Promise.all([getDecks(), getDeckStateCounts()]);
if (decks.length === 0) {
countEl.textContent = 'Колоды пока отсутствуют.';
setStatus('');
@@ -131,9 +86,12 @@ export function renderDecksPage(root: HTMLElement): void {
}
countEl.textContent = `Всего: ${decks.length}`;
setStatus('');
listEl.innerHTML = decks.map((d) => deckRowHtml(d, d.id === expandedId)).join('');
listEl.innerHTML = decks
.map((d) => deckRowHtml(d, counts.get(d.id) ?? 0))
.join('');
} catch (err) {
setStatus(`Не удалось загрузить колоды: ${err instanceof Error ? err.message : String(err)}`);
}
})();
}
+20 -5
View File
@@ -1,16 +1,20 @@
import './style.css';
import { renderHomePage } from './home-page.ts';
import { renderDecksPage } from './decks-page.ts';
import { renderDeckPage } from './deck-page.ts';
import { renderReviewPage } from './review-page.ts';
import { renderSyncPage } from './sync-page.ts';
import { initWorker } from './sqlite-api.ts';
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Синхронизация).
// Каркас приложения: топ-бар + бургер-меню + страницы (Главная / Колоды / Колода / Повторение / Синхронизация).
type Page = 'home' | 'decks' | 'sync';
type Page = 'home' | 'decks' | 'deck' | 'review' | 'sync';
const PAGE_TITLES: Record<Page, string> = {
home: 'Главная',
decks: 'Колоды',
deck: 'Колода',
review: 'Повторение',
sync: 'Синхронизация',
};
@@ -43,13 +47,24 @@ function setActiveButton(page: Page): void {
});
}
function renderPage(page: Page): void {
function renderPage(page: Page, deckId?: number): void {
pageTitle.textContent = PAGE_TITLES[page];
setActiveButton(page);
// Страницы колоды и повторения — подразделы «Колод»: в меню подсвечиваем «Колоды».
setActiveButton(page === 'deck' || page === 'review' ? 'decks' : page);
if (page === 'home') {
renderHomePage(pageRoot, {});
} else if (page === 'decks') {
renderDecksPage(pageRoot);
renderDecksPage(pageRoot, {
goDeck: (id) => renderPage('deck', id),
goReview: (id) => renderPage('review', id),
});
} else if (page === 'deck') {
renderDeckPage(pageRoot, deckId ?? 0, {
goDecks: () => renderPage('decks'),
goReview: (id) => renderPage('review', id),
});
} else if (page === 'review') {
renderReviewPage(pageRoot, deckId ?? 0, { goDecks: () => renderPage('decks') });
} else {
renderSyncPage(pageRoot);
}
+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, '&amp;')
.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)}`);
}
})();
}
+182
View File
@@ -477,3 +477,185 @@ body.drawer-open {
.empty-val {
opacity: 0.5;
}
/* Кнопка-строка колоды: div-обёртка + button в одном элементе.
.word-row сбрасывает border — возвращаем разделитель. */
button.word-row-wrap {
border: none;
border-bottom: 1px solid #eee9fb;
}
button.word-row-wrap:last-child {
border-bottom: none;
}
button.word-row-wrap:not(:disabled):hover {
background: #f4f1fa;
}
/* Призрачная кнопка «назад» + подзаголовок на странице колоды */
.btn-ghost {
align-self: flex-start;
flex: none;
border: 2px solid #d9d2f0;
background: #fff;
color: #4c446b;
box-shadow: none;
}
.btn-ghost:active {
background: #f1eafe;
border-color: #7c3aed;
}
/* Форма редактирования колоды: инпуты + статус. id/name — только чтение. */
.deck-form {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.deck-form .field-label {
margin-top: 4px;
}
.deck-form .btn-row {
margin-top: 8px;
}
.deck-form .card-hint:empty {
display: none;
}
.deck-sub {
margin: 16px 0 8px;
font-size: 1rem;
color: #4c446b;
}
/* Строка колоды: div-обёртка — name слева тянется, «▶» справа фиксирован. */
.deck-row {
display: flex;
align-items: stretch;
gap: 4px;
}
.deck-name {
flex: 1;
min-width: 0;
}
.deck-play {
flex: none;
align-self: center;
width: 44px;
height: 44px;
border: 2px solid #d9d2f0;
border-radius: 12px;
background: #f1eafe;
color: #6d28d9;
font-size: 1rem;
cursor: pointer;
touch-action: manipulation;
}
.deck-play:active {
background: #e9defc;
border-color: #7c3aed;
}
/* --- Повторение: карточка + 4 кнопки оценок --- */
#review-body {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
/* Карточка — вся кнопка: сначала только forward, по тапу дорисовывается backward. */
.review-card {
display: block;
width: 100%;
border: 2px solid #d9d2f0;
border-radius: 14px;
background: #fbfaff;
padding: 20px 16px;
font: inherit;
color: inherit;
text-align: center;
cursor: pointer;
touch-action: manipulation;
}
.review-card:active {
border-color: #7c3aed;
}
.review-side {
display: flex;
flex-direction: column;
gap: 8px;
}
.review-line {
margin: 0;
overflow-wrap: break-word;
}
.review-front .review-line:first-child {
font-size: 1.5rem;
font-weight: 800;
}
.review-hr {
border: none;
border-top: 1px solid #eee9fb;
margin: 14px 0;
}
.review-tap {
margin: 12px 0 0;
font-size: 0.85rem;
color: #6d6390;
}
.review-grades {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.review-grade {
margin: 0;
}
/* Оценки — плоские оттенки смысла, текст всегда тёмный для контраста. */
.grade-fail {
background: #fee2e2;
color: #991b1b;
box-shadow: none;
}
.grade-hard {
background: #ffedd5;
color: #9a3412;
box-shadow: none;
}
.grade-good {
background: #dcfce7;
color: #166534;
box-shadow: none;
}
.grade-easy {
background: #dbeafe;
color: #1e40af;
box-shadow: none;
}
.review-grade:active {
filter: brightness(0.95);
}
+5 -37
View File
@@ -1,6 +1,5 @@
// Страница «Синхронизация»: токен + Скачать/Выгрузить.
// Страница «Синхронизация»: токен + Скачать.
// Скачивание: GET /download/{id} -> zstd -> проверка SQLite -> запись в OPFS.
// Выгрузка: следующий шаг (POST /upload/stream/{id}).
import {
TOKEN_LENGTH,
@@ -11,7 +10,7 @@ import {
} from './token.ts';
import { fetchBackupBytes } from './sync.ts';
import { decompressBackup } from './zstd.ts';
import { hasDatabaseFile, writeDatabaseFile } from './opfs.ts';
import { writeDatabaseFile } from './opfs.ts';
export function renderSyncPage(root: HTMLElement): void {
root.innerHTML = `
@@ -40,8 +39,7 @@ export function renderSyncPage(root: HTMLElement): void {
</div>
<div class="btn-row">
<button id="btn-download" class="btn" type="button" disabled>📥 Скачать</button>
<button id="btn-upload" class="btn btn-primary" type="button" disabled>📤 Выгрузить</button>
<button id="btn-download" class="btn btn-primary" type="button" disabled>📥 Скачать</button>
</div>
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
@@ -52,7 +50,6 @@ export function renderSyncPage(root: HTMLElement): void {
const tokenError = root.querySelector<HTMLElement>('#token-error')!;
const tokenCounter = root.querySelector<HTMLElement>('#token-counter')!;
const btnDownload = root.querySelector<HTMLButtonElement>('#btn-download')!;
const btnUpload = root.querySelector<HTMLButtonElement>('#btn-upload')!;
const statusEl = root.querySelector<HTMLElement>('#status')!;
function setStatus(msg: string): void {
@@ -66,15 +63,7 @@ export function renderSyncPage(root: HTMLElement): void {
function setBusy(busy: boolean): void {
btnDownload.disabled = busy || !isValidToken(currentToken());
btnUpload.disabled = true;
tokenInput.disabled = busy;
if (!busy) {
void hasDatabaseFile().then((has) => {
if (!tokenInput.disabled && isValidToken(currentToken())) {
btnUpload.disabled = !has;
}
});
}
}
function refreshUI(): void {
@@ -93,13 +82,6 @@ export function renderSyncPage(root: HTMLElement): void {
tokenError.textContent = ok ? '' : 'Только латинские буквы и цифры';
}
btnDownload.disabled = !ok;
if (ok) {
void hasDatabaseFile().then((has) => {
if (!tokenInput.disabled) btnUpload.disabled = !has;
});
} else {
btnUpload.disabled = true;
}
}
async function handleDownload(): Promise<void> {
@@ -132,7 +114,7 @@ export function renderSyncPage(root: HTMLElement): void {
tokenInput.value = token;
if (isValidToken(token)) {
saveToken(token);
setStatus('Токен сохранён локально. Можно скачивать или выгружать.');
setStatus('Токен сохранён локально. Можно скачивать.');
}
refreshUI();
});
@@ -141,26 +123,12 @@ export function renderSyncPage(root: HTMLElement): void {
void handleDownload();
});
btnUpload.addEventListener('click', () => {
const token = currentToken();
if (!isValidToken(token)) return;
saveToken(token);
setStatus('Выгрузка — следующий шаг (POST /upload/stream/{id}).');
});
const saved = loadSavedToken();
if (saved) {
tokenInput.value = saved;
}
refreshUI();
if (isValidToken(normalizeToken(saved))) {
void hasDatabaseFile().then((has) => {
setStatus(
has
? 'Токен восстановлен, локальная база на месте.'
: 'Токен восстановлен. Нажми «Скачать», чтобы получить базу.',
);
refreshUI();
});
setStatus('Токен восстановлен. Нажми «Скачать», чтобы получить базу.');
}
}
-1
View File
@@ -1,6 +1,5 @@
// Сеть: скачивание сохранения с бэкенда.
// GET https://learning.micialware.ru/download/{id} -> zstd-сжатый .sqlite (бинарь).
// Выгрузка (POST /upload/stream/{id}) — в следующем шаге.
export const API_BASE_URL = 'https://learning.micialware.ru';
+1 -1
View File
@@ -1,5 +1,5 @@
// Токен сохранения: ровно 24 символа, только латиница и цифры.
// Это НЕ JWT — просто короткий идентификатор для download/upload .sqlite.
// Просто короткий идентификатор для download .sqlite.
export const TOKEN_LENGTH = 24;