repetition woks
This commit is contained in:
1 parent
37b3b61f79
commit
36d2c0b48f
15 files changed
+905
-185
No files matched your search
+185
-1
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** attribute-escape для подстановки текущих значений в value="...". */
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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
@@ -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, '&')
|
||||
@@ -14,34 +19,28 @@ function escapeHtml(s: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Одна колода — плоская строка: имя слева, направление справа.
|
||||
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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
+182
@@ -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
@@ -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,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
@@ -1,5 +1,5 @@
|
||||
// Токен сохранения: ровно 24 символа, только латиница и цифры.
|
||||
// Это НЕ JWT — просто короткий идентификатор для download/upload .sqlite.
|
||||
// Просто короткий идентификатор для download .sqlite.
|
||||
|
||||
export const TOKEN_LENGTH = 24;
|
||||
|
||||
|
||||
Reference in new issue
Block a user