Files
jap_web_ng/src/sync-page.ts
T
2026-09-21 12:00:42 +03:00

135 lines
4.7 KiB
TypeScript

// Страница «Синхронизация»: токен + Скачать.
// Скачивание: GET /download/{id} -> zstd -> проверка SQLite -> запись в OPFS.
import {
TOKEN_LENGTH,
isValidToken,
loadSavedToken,
normalizeToken,
saveToken,
} from './token.ts';
import { fetchBackupBytes } from './sync.ts';
import { decompressBackup } from './zstd.ts';
import { writeDatabaseFile } from './opfs.ts';
export function renderSyncPage(root: HTMLElement): void {
root.innerHTML = `
<main class="card" aria-labelledby="sync-title">
<h2 id="sync-title" class="card-title">Синхронизация</h2>
<p class="card-hint">Токен сохранения — 24 символа: латинские буквы и цифры.</p>
<label class="field-label" for="token-input">Токен сохранения</label>
<input
id="token-input"
class="token-input"
type="text"
inputmode="latin"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
maxlength="24"
minlength="24"
placeholder="Например: aB3dEf7hJk9mN2pQr5tUvWx1z"
aria-describedby="token-error token-counter"
/>
<div class="token-meta">
<span id="token-error" class="token-error" role="alert"></span>
<span id="token-counter" class="token-counter" aria-live="polite">0 / 24</span>
</div>
<div class="btn-row">
<button id="btn-download" class="btn btn-primary" type="button" disabled>Скачать</button>
</div>
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
</main>
`;
const tokenInput = root.querySelector<HTMLInputElement>('#token-input')!;
const tokenError = root.querySelector<HTMLElement>('#token-error')!;
const tokenCounter = root.querySelector<HTMLElement>('#token-counter')!;
const btnDownload = root.querySelector<HTMLButtonElement>('#btn-download')!;
const statusEl = root.querySelector<HTMLElement>('#status')!;
function setStatus(msg: string): void {
statusEl.textContent = msg;
console.log(`[JapLearn] ${msg}`);
}
function currentToken(): string {
return normalizeToken(tokenInput.value);
}
function setBusy(busy: boolean): void {
btnDownload.disabled = busy || !isValidToken(currentToken());
tokenInput.disabled = busy;
}
function refreshUI(): void {
const token = currentToken();
const len = token.length;
tokenCounter.textContent = `${len} / ${TOKEN_LENGTH}`;
const ok = isValidToken(token);
const empty = len === 0;
tokenInput.classList.toggle('invalid', !empty && !ok);
tokenInput.setAttribute('aria-invalid', String(!empty && !ok));
if (empty) {
tokenError.textContent = '';
} else if (len !== TOKEN_LENGTH) {
tokenError.textContent = `Нужно 24 символа, сейчас ${len}`;
} else {
tokenError.textContent = ok ? '' : 'Только латинские буквы и цифры';
}
btnDownload.disabled = !ok;
}
async function handleDownload(): Promise<void> {
const token = currentToken();
if (!isValidToken(token)) return;
saveToken(token);
setBusy(true);
setStatus('Скачивание сохранения...');
try {
const compressed = await fetchBackupBytes(token);
setStatus(`Скачано ${(compressed.length / 1024).toFixed(1)} КБ, распаковка...`);
const raw = await decompressBackup(compressed);
setStatus(`Распаковано ${(raw.length / 1024).toFixed(1)} КБ, сохранение в OPFS...`);
await writeDatabaseFile(raw);
setStatus(`Готово: база ${(raw.length / 1024).toFixed(1)} КБ сохранена локально (OPFS).`);
} catch (err) {
setStatus(`Ошибка скачивания: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setBusy(false);
refreshUI();
}
}
tokenInput.addEventListener('input', () => {
refreshUI();
});
tokenInput.addEventListener('change', () => {
const token = currentToken();
tokenInput.value = token;
if (isValidToken(token)) {
saveToken(token);
setStatus('Токен сохранён локально. Можно скачивать.');
}
refreshUI();
});
btnDownload.addEventListener('click', () => {
void handleDownload();
});
const saved = loadSavedToken();
if (saved) {
tokenInput.value = saved;
}
refreshUI();
if (isValidToken(normalizeToken(saved))) {
setStatus('Токен восстановлен. Нажми «Скачать», чтобы получить базу.');
}
}