first commit

This commit is contained in:
micialware committed 2026-09-13 18:43:42 +03:00
commit 4646dad2b8
18 files changed
+1971

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# Зависимости
node_modules
# Сборка
dist
dist-ssr
# Логи
*.log
# Окружение
.env
.env.*
# Редактор / ОС
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cross-Origin-Embedder-Policy" content="require-corp" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>JapLearn — интервальные повторения</title>
</head>
<body>
<main id="app">
<header class="app-header">
<h1>JapLearn</h1>
<p class="subtitle">Интервальные повторения · офлайн-первая</p>
</header>
<section class="card" aria-labelledby="sync-title">
<h2 id="sync-title">Сохранение</h2>
<p class="card-note">
База данных хранится локально на устройстве. Токен связывает её
с копией на сервере — скачивайте и выгружайте файл по мере
необходимости.
</p>
<form id="sync-form" novalidate>
<div class="field">
<label for="save-token">Токен сохранения</label>
<input
id="save-token"
name="save-token"
type="text"
maxlength="24"
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
placeholder="24 буквы или цифры"
aria-describedby="token-hint token-error"
/>
<p id="token-hint" class="hint">
Последовательность из 24 символов: латинские буквы (A–Z)
и цифры (0–9).
</p>
<p id="token-error" class="error" hidden>
Нужно ровно 24 символа — латинские буквы и цифры, без пробелов
и знаков.
</p>
</div>
<div class="actions">
<button type="button" id="btn-download" class="btn btn-primary" disabled>
Скачать
</button>
<button type="button" id="btn-upload" class="btn btn-secondary" disabled>
Выгрузить
</button>
</div>
</form>
<p id="sync-status" class="status" role="status" aria-live="polite"></p>
<p id="db-state" class="status" role="status" aria-live="polite"></p>
</section>
<p id="sqlite-status" class="footer-status"></p>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1219
View File
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "japlearn",
"private": true,
"version": "0.1.0",
"description": "Мобильное PWA для интервальных повторений (offline-first, OPFS + wa-sqlite)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^7.0.2",
"vite": "^8.3.0"
},
"dependencies": {
"@hpcc-js/wasm-zstd": "^1.16.0",
"wa-sqlite": "^1.0.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#4f46e5"/>
<text x="16" y="22" font-size="16" text-anchor="middle" fill="#ffffff" font-family="sans-serif">J</text>
</svg>

After

Width:  |  Height:  |  Size: 229 B

+15
View File
@@ -0,0 +1,15 @@
// Бэкенд для переноса сохранения между устройствами.
// Единственная сетевая функция приложения — скачивание и выгрузка
// бинарного файла .sqlite по токену. Протокол менять нельзя.
export const BACKEND_BASE_URL = 'https://learning.micialware.ru';
/** URL скачивания сохранения: https://…/download/{id} */
export function downloadUrl(token: string): string {
return `${BACKEND_BASE_URL}/download/${token}`;
}
/** URL выгрузки сохранения: https://…/upload/stream/{id} */
export function uploadUrl(token: string): string {
return `${BACKEND_BASE_URL}/upload/stream/${token}`;
}
+38
View File
@@ -0,0 +1,38 @@
import './style.css';
import * as sqlite from 'wa-sqlite';
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs';
import { SyncPanel } from './sync-panel';
// Смоук-тест: запускаем SQLite (сборка wa-sqlite) в памяти.
// Запись и чтение БД идут через OPFS (src/storage.ts); подключение
// SQLite к файлу из OPFS сделаем при работе с самими карточками.
const sqliteStatus = document.querySelector<HTMLParagraphElement>('#sqlite-status');
async function initSqlite(): Promise<void> {
try {
// 1. Загрузчик сам скачивает wa-sqlite.wasm (рядом с wa-sqlite.mjs).
const module = await SQLiteESMFactory();
// 2. Оборачиваем Emscripten-модуль в JS API.
const sqlite3 = sqlite.Factory(module);
// 3. Открываем базу в памяти и выполняем запрос.
const db = await sqlite3.open_v2(':memory:');
let result = 'нет данных';
await sqlite3.exec(db, "SELECT 'Привет, мир!'", (row) => {
result = String(row[0]);
});
await sqlite3.close(db);
if (sqliteStatus) sqliteStatus.textContent = `SQLite работает: ${result}`;
} catch (error) {
if (sqliteStatus) sqliteStatus.textContent = `SQLite: ошибка инициализации — ${error}`;
console.error(error);
}
}
// Главный экран: токен сохранения + кнопки «Скачать» / «Выгрузить».
// HTTP-обвязка появится на следующем шаге.
const syncPanel = new SyncPanel(document);
syncPanel.init();
void initSqlite();
+34
View File
@@ -0,0 +1,34 @@
// Минимальные типы OPFS (Origin Private File System) по WICG
// «File System Access», в том виде, как они используются в wa-sqlite v1.0.0.
// Используем собственные имена (Opfs*), чтобы не пересекаться с типами
// из DOM-библиотеки TypeScript.
/** Файл в OPFS. */
interface OpfsFileHandle {
getFile(): Promise<File>;
createWritable(): Promise<OpfsWritableFileHandle>;
}
/** Записываемый файловый дескриптор OPFS. */
interface OpfsWritableFileHandle {
write(data: Uint8Array, at?: number): Promise<number>;
truncate?(newSize: number): Promise<void>;
close(): Promise<void>;
}
/** Каталог OPFS (корень приватного хранилища origin). */
interface OpfsDirectoryHandle {
getFileHandle(
name: string,
options?: { create?: boolean },
): Promise<OpfsFileHandle | null>;
removeEntry(name: string): Promise<void>;
}
interface OpfsStorage {
getDirectory(id?: string): Promise<OpfsDirectoryHandle>;
}
interface Navigator {
readonly storage?: OpfsStorage;
}
+24
View File
@@ -0,0 +1,24 @@
// Небольшие пользовательские предпочтения, переживающие перезагрузку.
// Храним ТОЛЬКО состояние интерфейса (токен для авто-заполнения поля),
// а не данные приложения. Сама база данных живёт в OPFS (src/storage.ts).
const TOKEN_KEY = 'japlearn.token';
/** localStorage с защитой от недоступного хранилища (приватный режим и т.п.). */
function memory(): Storage | null {
try {
return window.localStorage;
} catch {
return null;
}
}
/** Сохранённый ранее токен (null, если его нет). */
export function loadToken(): string | null {
return memory()?.getItem(TOKEN_KEY) ?? null;
}
/** Запоминаем токен, как ввёл пользователь (для восстановления в поле). */
export function saveToken(raw: string): void {
memory()?.setItem(TOKEN_KEY, raw);
}
+68
View File
@@ -0,0 +1,68 @@
// Хранение бинарного файла базы данных в Origin Private File System (OPFS).
// Файл живёт в приватном хранилище origin браузера и не теряется при
// перезагрузке — ровно тот же «настоящий» .sqlite, что и в десктопной версии.
/** Имя файла базы данных в OPFS. */
export const DB_FILENAME = 'japlearn.sqlite';
export interface DatabaseInfo {
/** Есть ли файл БД. */
exists: boolean;
/** Размер файла в байтах (null, если файла нет). */
size: number | null;
}
/** Поддерживает ли браузер OPFS (navigator.storage). */
export function isOpfsSupported(): boolean {
return typeof navigator.storage?.getDirectory === 'function';
}
async function rootDirectory(): Promise<OpfsDirectoryHandle> {
if (!isOpfsSupported()) {
throw new Error(
'OPFS (navigator.storage) недоступен — откройте приложение в Chrome (десктоп или Android)',
);
}
// У root есть стрелка вниз: тип зафиксирован в opfs.d.ts.
return navigator.storage!.getDirectory();
}
/** Текущее состояние файла БД в OPFS. */
export async function statDatabase(): Promise<DatabaseInfo> {
const dir = await rootDirectory();
const fileHandle = await dir.getFileHandle(DB_FILENAME);
if (!fileHandle) {
return { exists: false, size: null };
}
const file = await fileHandle.getFile();
return { exists: true, size: file.size };
}
/** Полностью перезаписывает файл БД в OPFS содержимым database. */
export async function writeDatabase(database: Uint8Array): Promise<DatabaseInfo> {
const dir = await rootDirectory();
const fileHandle = await dir.getFileHandle(DB_FILENAME, { create: true });
if (!fileHandle) {
throw new Error('не удалось создать файл БД в OPFS');
}
const writable = await fileHandle.createWritable();
try {
// Записываем с явным смещением и проверяем прогресс (write может
// вернуть число реально записанных байт).
let offset = 0;
while (offset < database.length) {
const written = await writable.write(database.subarray(offset), offset);
if (written === 0) {
throw new Error('OPFS: запись не продвигается');
}
offset += written;
}
// Если новый файл короче старого, убираем хвост.
writable.truncate?.(database.length);
} finally {
await writable.close();
}
return { exists: true, size: database.length };
}
+170
View File
@@ -0,0 +1,170 @@
:root {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
color-scheme: light dark;
--accent: #4f46e5;
--text: #1f2430;
--muted: #6b7280;
--danger: #dc2626;
--surface: #ffffff;
--surface-shadow: 0 1px 3px rgb(0 0 0 / 0.12);
--border: #9ca3af;
--radius: 16px;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #e5e7eb;
--muted: #9ca3af;
--danger: #f87171;
--surface: #111827;
--surface-shadow: 0 1px 3px rgb(0 0 0 / 0.45);
--border: #4b5563;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
color: var(--text);
}
#app {
width: 100%;
max-width: 480px;
padding: 24px 16px;
}
/* Шапка */
.app-header h1 {
margin: 0;
font-size: 28px;
}
.subtitle {
margin: 4px 0 0;
color: var(--muted);
}
/* Карточка сохранения */
.card {
margin-top: 24px;
padding: 20px;
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--surface-shadow);
}
.card h2 {
margin: 0 0 8px;
font-size: 18px;
}
.card-note {
margin: 0 0 16px;
font-size: 14px;
color: var(--muted);
}
.field label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
#save-token {
display: block;
width: 100%;
padding: 12px 14px;
font-size: 16px; /* 16px — чтобы iOS не зумила страницу */
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1.5px solid var(--border);
border-radius: 12px;
background: transparent;
color: var(--text);
}
#save-token:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: var(--accent);
}
#save-token[aria-invalid='true'] {
border-color: var(--danger);
}
.hint {
margin: 6px 0 0;
font-size: 13px;
color: var(--muted);
}
.error {
margin: 6px 0 0;
font-size: 13px;
color: var(--danger);
}
.error[hidden] {
display: none;
}
/* Кнопки */
.actions {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.btn {
min-height: 48px; /* комфортная область касания */
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
border-radius: 12px;
cursor: pointer;
}
.btn-primary {
border: none;
background: var(--accent);
color: #ffffff;
}
.btn-secondary {
border: 1.5px solid var(--accent);
background: transparent;
color: var(--accent);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* Статусы и подвал */
.status {
margin: 12px 0 0;
min-height: 1.5em;
font-size: 14px;
color: var(--muted);
}
.footer-status {
margin-top: 24px;
font-size: 13px;
text-align: center;
color: var(--muted);
}
+136
View File
@@ -0,0 +1,136 @@
import { downloadSave, type DownloadedSave } from './sync';
import { isValidToken, normalizeToken } from './token';
import { loadToken, saveToken } from './prefs';
import { DB_FILENAME, isOpfsSupported, statDatabase, writeDatabase } from './storage';
/** Находит элемент по селектору и бросает ошибку, если его нет. */
function element<T extends Element>(doc: Document, selector: string): T {
const el = doc.querySelector(selector);
if (!el) {
throw new Error(`Элемент не найден: ${selector}`);
}
return el as T;
}
/** Человекочитаемый размер в байтах. */
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
const kib = bytes / 1024;
if (kib < 1024) return `${kib.toFixed(1)} КБ`;
return `${(kib / 1024).toFixed(1)} МБ`;
}
/**
* Контроллер главного экрана: токен сохранения (запоминается в
* localStorage), скачивание + запись БД в OPFS, состояние базы данных
* на экране. Выгрузка — HTTP-обвязка на следующем шаге.
*/
export class SyncPanel {
readonly #input: HTMLInputElement;
readonly #error: HTMLElement;
readonly #downloadBtn: HTMLButtonElement;
readonly #uploadBtn: HTMLButtonElement;
readonly #status: HTMLElement;
readonly #dbState: HTMLElement;
/** Идёт ли сейчас сетевая операция (блокирует кнопки). */
#busy = false;
/** Последнее скачанное и распакованное сохранение. */
#lastSave: DownloadedSave | null = null;
constructor(doc: Document) {
this.#input = element<HTMLInputElement>(doc, '#save-token');
this.#error = element(doc, '#token-error');
this.#downloadBtn = element<HTMLButtonElement>(doc, '#btn-download');
this.#uploadBtn = element<HTMLButtonElement>(doc, '#btn-upload');
this.#status = element(doc, '#sync-status');
this.#dbState = element(doc, '#db-state');
}
init(): void {
// Восстанавливаем сохранённый токен в поле ввода.
const savedToken = loadToken();
if (savedToken) {
this.#input.value = savedToken;
}
this.#input.addEventListener('input', () => this.#onTokenChanged());
this.#downloadBtn.addEventListener('click', () => void this.#onDownload());
this.#uploadBtn.addEventListener('click', () => void this.#onUpload());
this.#onTokenChanged();
// Состояние БД в OPFS при загрузке.
void this.#refreshDatabaseState();
}
/** Канонический токен из поля ввода (uppercase, без пробелов). */
get token(): string {
return normalizeToken(this.#input.value);
}
/** Последнее успешно скачанное и распакованное сохранение. */
get lastSave(): DownloadedSave | null {
return this.#lastSave;
}
#onTokenChanged(): void {
saveToken(this.#input.value);
const valid = isValidToken(this.#input.value);
this.#error.hidden = valid;
this.#input.setAttribute('aria-invalid', valid ? 'false' : 'true');
this.#updateButtons();
if (this.#status.textContent) this.#status.textContent = '';
}
#updateButtons(): void {
const enabled = isValidToken(this.#input.value) && !this.#busy;
this.#downloadBtn.disabled = !enabled;
this.#uploadBtn.disabled = !enabled;
}
async #refreshDatabaseState(): Promise<void> {
if (!isOpfsSupported()) {
this.#dbState.textContent =
'База данных: OPFS недоступен в этом браузере — нужен Chrome';
return;
}
try {
const info = await statDatabase();
this.#dbState.textContent = info.exists
? `База данных: ${DB_FILENAME} · ${formatBytes(info.size ?? 0)}`
: 'База данных: отсутствует';
} catch (error) {
this.#dbState.textContent = `База данных: ошибка — ${error}`;
}
}
async #onDownload(): Promise<void> {
if (this.#busy || !isValidToken(this.#input.value)) return;
this.#busy = true;
this.#updateButtons();
this.#status.textContent = `Скачивание «${this.token}»…`;
try {
this.#lastSave = await downloadSave(this.token);
const info = await writeDatabase(this.#lastSave.database);
this.#dbState.textContent = `База данных: ${DB_FILENAME} · ${formatBytes(info.size ?? 0)}`;
this.#status.textContent =
`Скачано ${formatBytes(this.#lastSave.compressedBytes)} → ` +
`распаковано и записано в OPFS ${formatBytes(this.#lastSave.database.length)}.`;
} catch (error) {
this.#lastSave = null;
this.#status.textContent = `Ошибка скачивания: ${error}`;
} finally {
this.#busy = false;
this.#updateButtons();
}
}
async #onUpload(): Promise<void> {
if (this.#busy || !isValidToken(this.#input.value)) return;
this.#status.textContent = `Выгрузка «${this.token}» — будет реализована на следующем шаге.`;
}
}
+80
View File
@@ -0,0 +1,80 @@
import { Zstd } from '@hpcc-js/wasm-zstd';
import { downloadUrl } from './config';
// Магический заголовок файла SQLite: "SQLite format 3\0"
const SQLITE_MAGIC = new Uint8Array([
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00,
]);
// Магическое число кадра Zstandard: 0x28 B5 2F FD
const ZSTD_MAGIC = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd]);
// Zstd.load() — процессный синглтон; держим один экземпляр на всё приложение.
let zstdCodec: Promise<Zstd> | null = null;
function getZstd(): Promise<Zstd> {
zstdCodec ??= Zstd.load();
return zstdCodec;
}
export interface DownloadedSave {
/** Размер загруженного (сжатого zstd) файла в байтах. */
compressedBytes: number;
/** Распакованный бинарный файл базы данных SQLite. */
database: Uint8Array;
}
/** Скачивает бинарное тело с сервера. */
export async function downloadRaw(url: string): Promise<Uint8Array> {
const response = await fetch(url, {
method: 'GET',
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`сервер ответил HTTP ${response.status}`);
}
return new Uint8Array(await response.arrayBuffer());
}
/** Похоже ли содержимое на кадр Zstandard. */
export function isZstdFrame(data: Uint8Array): boolean {
return (
data.length >= ZSTD_MAGIC.length &&
ZSTD_MAGIC.every((byte, index) => data[index] === byte)
);
}
/** Похоже ли содержимое на файл SQLite (проверка магических байтов). */
export function isSqliteDatabase(data: Uint8Array): boolean {
return (
data.length >= SQLITE_MAGIC.length &&
SQLITE_MAGIC.every((byte, index) => data[index] === byte)
);
}
/**
* Скачивает сохранение по токену и распаковывает zstd-сжатие
* (бинарный файл базы данных SQLite).
* @param token Токен сохранения (24 латинские буквы).
*/
export async function downloadSave(token: string): Promise<DownloadedSave> {
const compressed = await downloadRaw(downloadUrl(token));
if (!isZstdFrame(compressed)) {
throw new Error(
'ответ сервера не похож на zstd-сжатый файл — возможно, токен неверный или сохранение не существует',
);
}
const codec = await getZstd();
const database = codec.decompress(compressed);
if (!isSqliteDatabase(database)) {
throw new Error(
'распакованные данные не похожи на базу данных SQLite (магический заголовок не совпал)',
);
}
return { compressedBytes: compressed.length, database };
}
+20
View File
@@ -0,0 +1,20 @@
// Токен сохранения. Это НЕ JWT и не что-то подобное: последовательность
// из ровно 24 символов — буквы латинского алфавита (a–z, A–Z) и цифры (0–9),
// по которой бэкенд отдаёт/принимает бинарный файл базы данных.
export const TOKEN_LENGTH = 24;
const TOKEN_RE = /^[A-Za-z0-9]{24}$/;
/**
* Приводит сырой ввод пользователя к каноническому виду токена:
* обрезает пробелы и приводит к верхнему регистру.
*/
export function normalizeToken(raw: string): string {
return raw.trim().toUpperCase();
}
/** Валиден ли токен: ровно 24 латинские буквы или цифры. */
export function isValidToken(raw: string): boolean {
return TOKEN_RE.test(normalizeToken(raw));
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+11
View File
@@ -0,0 +1,11 @@
// Типы для ESM-загрузчика wa-sqlite: в пакете он лежит как .mjs без
// собственных деклараций. Загрузчик сам подгружает wa-sqlite.wasm.
declare module 'wa-sqlite/dist/wa-sqlite.mjs' {
/**
* Загружает Emscripten-модуль SQLite.
* @param options Опции загрузчика (необязательны).
* @returns Promise с модулем для передачи в SQLite.Factory(module).
*/
const SQLiteESMFactory: (options?: Record<string, unknown>) => Promise<any>;
export default SQLiteESMFactory;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
// Настройка проекта под wa-sqlite (SQLite в браузере через WebAssembly).
export default defineConfig({
// Файлы .wasm (сборка SQLite из пакета wa-sqlite) считаем статическими
// ассетами: Vite копирует их в dist/ без трансформации и отдаёт в dev
// как есть. Импорт вида `import url from '...wasm?url'` работает всегда.
assetsInclude: ['**/*.wasm'],
optimizeDeps: {
// wa-sqlite сам находит свой .wasm-бинарник по соседству с .mjs-загрузчиком
// (new URL("wa-sqlite.wasm", import.meta.url)). Если позволить Vite
// пре-баблить пакет в .vite/deps, import.meta.url укажет не туда и загрузка
// SQLite упадёт. Поэтому пакет исключаем из пре-баблинга.
exclude: ['wa-sqlite'],
},
server: {
headers: {
// Требуется для OPFS (Origin Private File System): без этого заголовка
// браузер блокирует доступ к OPFS из встроенного (cross-origin)
// контекста. Дублируется через <meta> в index.html.
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});