166 lines
5.8 KiB
TypeScript
166 lines
5.8 KiB
TypeScript
// Страница «Синхронизация»: токен + Скачать/Выгрузить.
|
|||
|
|
// Скачивание: 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();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|