Files
jap_web_ng/src/sync-page.ts
T

166 lines
5.8 KiB
TypeScript
Raw Normal View History

2026-09-14 14:32:38 +03:00
// Страница «Синхронизация»: токен + Скачать/Выгрузить.
// Скачивание: GET /download/{id} -> zstd -> проверка SQLite -> запись в OPFS.
// Выгрузка: следующий шаг (POST /upload/stream/{id}).
import {
TOKEN_LENGTH,
isValidToken,
loadSavedToken,
normalizeToken,
saveToken,
} from './token.ts';
import { fetchBackupBytes } from './sync.ts';
import { decompressBackup } from './zstd.ts';
import { hasDatabaseFile, 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" type="button" disabled>📥 Скачать</button>
<button id="btn-upload" 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 btnUpload = root.querySelector<HTMLButtonElement>('#btn-upload')!;
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());
btnUpload.disabled = true;
tokenInput.disabled = busy;
if (!busy) {
void hasDatabaseFile().then((has) => {
if (!tokenInput.disabled && isValidToken(currentToken())) {
btnUpload.disabled = !has;
}
});
}
}
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;
if (ok) {
void hasDatabaseFile().then((has) => {
if (!tokenInput.disabled) btnUpload.disabled = !has;
});
} else {
btnUpload.disabled = true;
}
}
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();
});
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();
});
}
}