first commit
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
// ──── Constants ────
|
||||
const MAX_HISTORY_LEN = 20;
|
||||
const MAX_HISTORY_LEN_PART = 0.33;
|
||||
const MAX_SCORE = 25;
|
||||
const FADE_PER_DAY = 0.95;
|
||||
|
||||
// ──── Data types ────
|
||||
|
||||
export interface WordData {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
tags: string;
|
||||
additional: Record<string, string>;
|
||||
group_id: number;
|
||||
}
|
||||
|
||||
export interface WordGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CardStatistics {
|
||||
id: number;
|
||||
word_id: number;
|
||||
set_id: number;
|
||||
last_open: number; // unix timestamp ms
|
||||
score: number;
|
||||
}
|
||||
|
||||
export type WordOpenMode = 'easy' | 'ok' | 'hard' | 'none';
|
||||
|
||||
export const SetOrderMode = {
|
||||
Default: 'Default',
|
||||
TrainWorstFirst: 'TrainWorstFirst',
|
||||
FullRandom: 'FullRandom',
|
||||
} as const;
|
||||
|
||||
export type SetOrderMode = (typeof SetOrderMode)[keyof typeof SetOrderMode];
|
||||
|
||||
export interface CardSetSettings {
|
||||
id: number;
|
||||
name: string;
|
||||
forward: string;
|
||||
backward: string;
|
||||
filter: string;
|
||||
count: number | null;
|
||||
worst_words_list: WordData[] | null;
|
||||
open_mode: SetOrderMode;
|
||||
}
|
||||
|
||||
// ──── CardStatistics helpers ────
|
||||
|
||||
export function calculatedScore(stat: CardStatistics): number {
|
||||
const now = Date.now();
|
||||
const diffMs = now - stat.last_open;
|
||||
const days = diffMs / (1000 * 60 * 60 * 24);
|
||||
const multiplier = Math.pow(FADE_PER_DAY, Math.max(0, days));
|
||||
return stat.score * multiplier;
|
||||
}
|
||||
|
||||
export function updateStatScore(stat: CardStatistics, status: WordOpenMode): void {
|
||||
const cs = calculatedScore(stat);
|
||||
switch (status) {
|
||||
case 'easy':
|
||||
stat.score = Math.round(cs + 5);
|
||||
break;
|
||||
case 'ok':
|
||||
stat.score = Math.round(cs + 2);
|
||||
break;
|
||||
case 'hard':
|
||||
stat.score = Math.round(cs - 1);
|
||||
break;
|
||||
case 'none':
|
||||
stat.score = Math.round(cs * 0.5);
|
||||
break;
|
||||
}
|
||||
|
||||
if (stat.score < 1) stat.score = 1;
|
||||
else if (stat.score > MAX_SCORE) stat.score = MAX_SCORE;
|
||||
|
||||
stat.last_open = Date.now();
|
||||
}
|
||||
|
||||
// ──── WeightedIndex (simple O(n) implementation) ────
|
||||
|
||||
class WeightedIndex {
|
||||
private weights: number[];
|
||||
|
||||
constructor(weights: number[]) {
|
||||
this.weights = [...weights];
|
||||
}
|
||||
|
||||
sample(): number {
|
||||
const total = this.weights.reduce((a, b) => a + b, 0);
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < this.weights.length; i++) {
|
||||
r -= this.weights[i];
|
||||
if (r <= 0) return i;
|
||||
}
|
||||
return this.weights.length - 1;
|
||||
}
|
||||
|
||||
updateWeight(index: number, weight: number): void {
|
||||
this.weights[index] = weight;
|
||||
}
|
||||
}
|
||||
|
||||
// ──── SRSModule interface ────
|
||||
|
||||
interface SRSModule {
|
||||
next(set: { words: WordData[]; set: CardStatistics[] }): number;
|
||||
open(status: WordOpenMode, index: number, updatedWord: CardStatistics): void;
|
||||
init(set: { words: WordData[]; set: CardStatistics[] }): void;
|
||||
}
|
||||
|
||||
// ──── RandomSRSModule ────
|
||||
|
||||
class RandomSRSModule implements SRSModule {
|
||||
private backet: number[] = [];
|
||||
private initialized = false;
|
||||
|
||||
next(set: { words: WordData[]; set: CardStatistics[] }): number {
|
||||
if (this.backet.length === 0) {
|
||||
this.backet = Array.from({ length: set.words.length }, (_, i) => i);
|
||||
this.shuffle(this.backet);
|
||||
}
|
||||
return this.backet.pop()!;
|
||||
}
|
||||
|
||||
open(_status: WordOpenMode, _index: number, _updatedWord: CardStatistics): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
init(set: { words: WordData[]; set: CardStatistics[] }): void {
|
||||
this.initialized = true;
|
||||
this.backet = Array.from({ length: set.words.length }, (_, i) => i);
|
||||
this.shuffle(this.backet);
|
||||
}
|
||||
|
||||
private shuffle(arr: number[]): void {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──── SemiRandomSRSModule ────
|
||||
|
||||
class SemiRandomSRSModule implements SRSModule {
|
||||
private history: number[] = [];
|
||||
private lastWeights!: WeightedIndex;
|
||||
private initialized = false;
|
||||
|
||||
next(set: { words: WordData[]; set: CardStatistics[] }): number {
|
||||
const index = this.lastWeights.sample();
|
||||
|
||||
if (this.history.includes(index)) {
|
||||
return this.next(set);
|
||||
}
|
||||
|
||||
if (this.history.length >= this.historyLen(set.set.length)) {
|
||||
this.history.shift();
|
||||
}
|
||||
this.history.push(index);
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
open(_status: WordOpenMode, index: number, word: CardStatistics): void {
|
||||
const newWeight = Math.pow(100.0 / Math.max(0.1, calculatedScore(word)), 2.0);
|
||||
this.lastWeights.updateWeight(index, newWeight);
|
||||
}
|
||||
|
||||
init(set: { words: WordData[]; set: CardStatistics[] }): void {
|
||||
this.initialized = true;
|
||||
const weights = set.set.map(
|
||||
(s) => Math.pow(100.0 / Math.max(0.1, calculatedScore(s)), 2.0) * 2.0,
|
||||
);
|
||||
this.lastWeights = new WeightedIndex(weights);
|
||||
}
|
||||
|
||||
private historyLen(setLen: number): number {
|
||||
return Math.min(MAX_HISTORY_LEN, Math.floor(setLen * MAX_HISTORY_LEN_PART));
|
||||
}
|
||||
}
|
||||
|
||||
// ──── WorstWordsSRSModule (stub) ────
|
||||
|
||||
class WorstWordsSRSModule implements SRSModule {
|
||||
private initialized = false;
|
||||
|
||||
next(_set: { words: WordData[]; set: CardStatistics[] }): number {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
open(_status: WordOpenMode, _index: number, _updatedWord: CardStatistics): void {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
init(_set: { words: WordData[]; set: CardStatistics[] }): void {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
// ──── OrderModule ────
|
||||
|
||||
type OrderModule =
|
||||
| { type: 'SemiRandomSRS'; module: SemiRandomSRSModule }
|
||||
| { type: 'RandomSRS'; module: RandomSRSModule }
|
||||
| { type: 'WorstWordsSRS'; module: WorstWordsSRSModule };
|
||||
|
||||
// ──── CardSet ────
|
||||
|
||||
export class CardSet {
|
||||
words: WordData[];
|
||||
set: CardStatistics[];
|
||||
currentWordIndex: number | null = null;
|
||||
private orderModule: OrderModule;
|
||||
|
||||
constructor(settings: CardSetSettings, words: WordData[], stats: CardStatistics[]) {
|
||||
this.words = words;
|
||||
this.set = stats;
|
||||
|
||||
const openMode = settings.open_mode;
|
||||
|
||||
this.orderModule =
|
||||
openMode === SetOrderMode.FullRandom
|
||||
? { type: 'RandomSRS', module: new RandomSRSModule() }
|
||||
: openMode === SetOrderMode.TrainWorstFirst
|
||||
? { type: 'WorstWordsSRS', module: new WorstWordsSRSModule() }
|
||||
: { type: 'SemiRandomSRS', module: new SemiRandomSRSModule() };
|
||||
}
|
||||
|
||||
private getOrderContext() {
|
||||
return { words: this.words, set: this.set };
|
||||
}
|
||||
|
||||
next(): { word: WordData; stat: CardStatistics } {
|
||||
const ctx = this.getOrderContext();
|
||||
|
||||
let index: number;
|
||||
const mod = this.orderModule;
|
||||
|
||||
switch (mod.type) {
|
||||
case 'SemiRandomSRS': {
|
||||
if (!mod.module['initialized']) {
|
||||
mod.module.init(ctx);
|
||||
}
|
||||
index = mod.module.next(ctx);
|
||||
break;
|
||||
}
|
||||
case 'RandomSRS': {
|
||||
if (!mod.module['initialized']) {
|
||||
mod.module.init(ctx);
|
||||
}
|
||||
index = mod.module.next(ctx);
|
||||
break;
|
||||
}
|
||||
case 'WorstWordsSRS': {
|
||||
if (!mod.module['initialized']) {
|
||||
mod.module.init(ctx);
|
||||
}
|
||||
index = mod.module.next(ctx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentWordIndex = index;
|
||||
return { word: this.words[index], stat: this.set[index] };
|
||||
}
|
||||
|
||||
open(status: WordOpenMode): void {
|
||||
if (this.currentWordIndex === null) return;
|
||||
const index = this.currentWordIndex;
|
||||
|
||||
const word = this.set[index];
|
||||
updateStatScore(word, status);
|
||||
|
||||
const mod = this.orderModule;
|
||||
switch (mod.type) {
|
||||
case 'SemiRandomSRS':
|
||||
mod.module.open(status, index, word);
|
||||
break;
|
||||
case 'RandomSRS':
|
||||
mod.module.open(status, index, word);
|
||||
break;
|
||||
case 'WorstWordsSRS':
|
||||
mod.module.open(status, index, word);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.set.length;
|
||||
}
|
||||
}
|
||||
|
||||
// ──── Factory helper ────
|
||||
|
||||
export function createCardSet(
|
||||
settings: CardSetSettings,
|
||||
words: WordData[],
|
||||
stats: CardStatistics[],
|
||||
): CardSet {
|
||||
return new CardSet(settings, words, stats);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,9 @@
|
||||
export function setupCounter(element: HTMLButtonElement) {
|
||||
let counter = 0
|
||||
const setCounter = (count: number) => {
|
||||
counter = count
|
||||
element.innerHTML = `Count is ${counter}`
|
||||
}
|
||||
element.addEventListener('click', () => setCounter(counter + 1))
|
||||
setCounter(0)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { execute } from './sqlite-manager.ts';
|
||||
|
||||
export interface Word {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
tags: string;
|
||||
more: string | null;
|
||||
group_id: number;
|
||||
}
|
||||
|
||||
export interface CardSetRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
forward: string;
|
||||
backward: string;
|
||||
filter: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает все колоды (card_set).
|
||||
*/
|
||||
export async function getCardSets(): Promise<CardSetRecord[]> {
|
||||
const rows: any[] = await execute(`
|
||||
SELECT id, name, forward, backward, filter
|
||||
FROM card_set
|
||||
ORDER BY id ASC
|
||||
`);
|
||||
|
||||
return rows.map((row: any[]) => ({
|
||||
id: row[0],
|
||||
name: row[1],
|
||||
forward: row[2],
|
||||
backward: row[3],
|
||||
filter: row[4],
|
||||
})) as CardSetRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает все слова для повторения.
|
||||
*/
|
||||
export async function getWordsForReview(): Promise<Word[]> {
|
||||
const rows: any[] = await execute(`
|
||||
SELECT id, key, value, tags, more, group_id
|
||||
FROM words
|
||||
ORDER BY id ASC
|
||||
`);
|
||||
|
||||
return rows.map((row: any[]) => ({
|
||||
id: row[0],
|
||||
key: row[1],
|
||||
value: row[2],
|
||||
tags: row[3],
|
||||
more: row[4],
|
||||
group_id: row[5],
|
||||
})) as Word[];
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Web Worker для работы с wa-sqlite + OPFS.
|
||||
* Воркер необходим, так как createSyncAccessHandle() доступен только в Worker'ах.
|
||||
*/
|
||||
|
||||
let sqlite: any = null;
|
||||
let db: number | null = null;
|
||||
let vfs: any = null;
|
||||
let initialized = false;
|
||||
|
||||
const DB_FILENAME = '/japanese_app.db';
|
||||
const SQLITE_OPEN_CREATE = 0x0004;
|
||||
const SQLITE_OPEN_READWRITE = 0x0002;
|
||||
const OPEN_FLAGS = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
|
||||
|
||||
async function init() {
|
||||
if (initialized) return;
|
||||
|
||||
const { Factory } = await import('wa-sqlite/src/sqlite-api.js');
|
||||
const { OriginPrivateFileSystemVFS } = await import(
|
||||
'wa-sqlite/src/examples/OriginPrivateFileSystemVFS.js'
|
||||
);
|
||||
const ModuleConstructor = (await import('wa-sqlite/dist/wa-sqlite-async.mjs')).default;
|
||||
|
||||
const module = await ModuleConstructor();
|
||||
const api = Factory(module);
|
||||
sqlite = api;
|
||||
|
||||
const opfsVfs = new OriginPrivateFileSystemVFS();
|
||||
vfs = opfsVfs;
|
||||
|
||||
const rc = api.vfs_register(opfsVfs, true);
|
||||
if (rc) {
|
||||
throw new Error(`Не удалось зарегистрировать OPFS VFS, код ${rc}`);
|
||||
}
|
||||
|
||||
const handle = await api.open_v2(DB_FILENAME, OPEN_FLAGS);
|
||||
db = handle;
|
||||
|
||||
initialized = true;
|
||||
self.postMessage({ type: 'ready', ok: true });
|
||||
}
|
||||
|
||||
async function execSchema() {
|
||||
if (!sqlite || db === null) return;
|
||||
|
||||
const schema = `
|
||||
create table if not exists card_set
|
||||
(
|
||||
id INTEGER primary key autoincrement,
|
||||
name TEXT not null,
|
||||
forward TEXT not null,
|
||||
backward TEXT not null,
|
||||
filter TEXT not null
|
||||
);
|
||||
|
||||
create table if not exists settings
|
||||
(
|
||||
id text primary key,
|
||||
value text
|
||||
);
|
||||
|
||||
create table if not exists word_group
|
||||
(
|
||||
id INTEGER primary key autoincrement,
|
||||
name TEXT not null
|
||||
);
|
||||
|
||||
create table if not exists words
|
||||
(
|
||||
id INTEGER primary key autoincrement,
|
||||
key TEXT not null,
|
||||
value TEXT not null,
|
||||
tags TEXT not null,
|
||||
more TEXT,
|
||||
group_id integer default 1 not null
|
||||
constraint words_word_group_id_fk references word_group on update cascade on delete cascade
|
||||
);
|
||||
|
||||
create table if not exists card_stats
|
||||
(
|
||||
id INTEGER primary key autoincrement,
|
||||
word_id INTEGER not null references words on delete cascade,
|
||||
set_id TEXT not null references card_set on delete cascade,
|
||||
score INTEGER default 1 not null,
|
||||
last_opened integer not null
|
||||
);
|
||||
`;
|
||||
|
||||
const statements = schema
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
for (const stmt of statements) {
|
||||
await sqlite.exec(db!, stmt + ';');
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = async (e: MessageEvent) => {
|
||||
const { type, id, sql, params } = e.data;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'init': {
|
||||
await init();
|
||||
await execSchema();
|
||||
self.postMessage({ type: 'result', id, ok: true, data: null });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'execute': {
|
||||
if (!sqlite || db === null) throw new Error('БД не инициализирована');
|
||||
const result = await sqlite.execWithParams(db, sql, params);
|
||||
self.postMessage({ type: 'result', id, ok: true, data: result.rows });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'executeVoid': {
|
||||
if (!sqlite || db === null) throw new Error('БД не инициализирована');
|
||||
await sqlite.run(db, sql, params);
|
||||
self.postMessage({ type: 'result', id, ok: true, data: null });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'closeDb': {
|
||||
if (sqlite && db !== null) {
|
||||
await sqlite.close(db);
|
||||
db = null;
|
||||
}
|
||||
if (vfs) {
|
||||
await vfs.close();
|
||||
vfs = null;
|
||||
}
|
||||
initialized = false;
|
||||
self.postMessage({ type: 'result', id, ok: true, data: null });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'getFile': {
|
||||
// Получаем файл из OPFS
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const fileHandle = await root.getFileHandle('japanese_app.db');
|
||||
const file = await fileHandle.getFile();
|
||||
const buffer = await file.arrayBuffer();
|
||||
// Передаём ArrayBuffer как Transferable
|
||||
self.postMessage(
|
||||
{ type: 'result', id, ok: true, data: buffer },
|
||||
{ transfer: [buffer] },
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'replaceFile': {
|
||||
// Закрываем текущее соединение
|
||||
if (sqlite && db !== null) {
|
||||
await sqlite.close(db);
|
||||
db = null;
|
||||
}
|
||||
if (vfs) {
|
||||
await vfs.close();
|
||||
vfs = null;
|
||||
}
|
||||
initialized = false;
|
||||
|
||||
// Перезаписываем файл в OPFS
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const fileHandle = await root.getFileHandle('japanese_app.db', { create: true });
|
||||
const accessHandle = await (fileHandle as any).createSyncAccessHandle();
|
||||
try {
|
||||
const data = new Uint8Array(params);
|
||||
accessHandle.truncate(0);
|
||||
accessHandle.write(data, { at: 0 });
|
||||
accessHandle.flush();
|
||||
} finally {
|
||||
accessHandle.close();
|
||||
}
|
||||
|
||||
// Переинициализируем
|
||||
await init();
|
||||
self.postMessage({ type: 'result', id, ok: true, data: null });
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Неизвестный тип сообщения: ${type}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
self.postMessage({ type: 'result', id, ok: false, error: err.message });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
// ──── Database schema ────
|
||||
// Imported from db_schema.sql
|
||||
|
||||
export function getSchema(): string {
|
||||
return `
|
||||
create table card_set
|
||||
(
|
||||
id INTEGER
|
||||
primary key autoincrement,
|
||||
name TEXT not null,
|
||||
forward TEXT not null,
|
||||
backward TEXT not null,
|
||||
filter TEXT not null
|
||||
);
|
||||
|
||||
create table settings
|
||||
(
|
||||
id text
|
||||
primary key,
|
||||
value text
|
||||
);
|
||||
|
||||
create table word_group
|
||||
(
|
||||
id INTEGER
|
||||
primary key autoincrement,
|
||||
name TEXT not null
|
||||
);
|
||||
|
||||
create table words
|
||||
(
|
||||
id INTEGER
|
||||
primary key autoincrement,
|
||||
key TEXT not null,
|
||||
value TEXT not null,
|
||||
tags TEXT not null,
|
||||
more TEXT,
|
||||
group_id integer default 1 not null
|
||||
constraint words_word_group_id_fk
|
||||
references word_group
|
||||
on update cascade on delete cascade
|
||||
);
|
||||
|
||||
create table card_stats
|
||||
(
|
||||
id INTEGER
|
||||
primary key autoincrement,
|
||||
word_id INTEGER not null
|
||||
references words
|
||||
on delete cascade,
|
||||
set_id TEXT not null
|
||||
references card_set
|
||||
on delete cascade,
|
||||
score INTEGER default 1 not null,
|
||||
last_opened integer not null
|
||||
);
|
||||
`.trim();
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import './style.css';
|
||||
import { initDB, closeDb, getDatabaseFile, replaceDatabaseFile } from './sqlite-manager.ts';
|
||||
import { uploadDatabase, downloadDatabase } from './sync.ts';
|
||||
import { getWordsForReview, getCardSets } from './db-manager.ts';
|
||||
import type { CardSetRecord } from './db-manager.ts';
|
||||
import type { Word } from './db-manager.ts';
|
||||
import { SetOrderMode } from './algorithm.ts';
|
||||
|
||||
// ----- Конфигурация -----
|
||||
const API_BASE_URL = 'https://learning.micialware.ru';
|
||||
|
||||
// ----- Элементы UI -----
|
||||
const container = document.getElementById('page-container') as HTMLElement;
|
||||
const navBtns = document.querySelectorAll<HTMLButtonElement>('.nav-btn');
|
||||
let currentPage: 'words' | 'sets' = 'words';
|
||||
|
||||
// ----- Вспомогательные функции -----
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function setStatus(msg: string, isError = false) {
|
||||
const statusEl = document.getElementById('status');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'status-bar';
|
||||
if (isError) statusEl.classList.add('error');
|
||||
else statusEl.classList.add('success');
|
||||
}
|
||||
console.log(`[App] ${msg}`);
|
||||
}
|
||||
|
||||
function getSyncId(): string {
|
||||
const input = document.getElementById('syncId') as HTMLInputElement | null;
|
||||
const id = input?.value.trim();
|
||||
if (!id) throw new Error('Введите ID синхронизации');
|
||||
return id;
|
||||
}
|
||||
|
||||
// ----- Рендеринг страниц -----
|
||||
|
||||
function renderWordsPage() {
|
||||
container.innerHTML = `
|
||||
<div class="card">
|
||||
<h2 class="card-title">📡 Синхронизация</h2>
|
||||
<div class="sync-input-group">
|
||||
<input type="text" id="syncId" placeholder="Введите ID синхронизации" autocomplete="off" spellcheck="false" />
|
||||
<div class="sync-buttons">
|
||||
<button id="btnDownload" class="btn">📥 Скачать</button>
|
||||
<button id="btnUpload" class="btn btn-primary">📤 Загрузить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="flex: 1;">
|
||||
<h2 class="card-title">📚 Слова</h2>
|
||||
<ul id="word-list" class="word-list"></ul>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status-bar">Загрузка...</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderWordsList(words: Word[]) {
|
||||
const wordListEl = document.getElementById('word-list') as HTMLUListElement | null;
|
||||
if (!wordListEl) return;
|
||||
|
||||
wordListEl.innerHTML = '';
|
||||
|
||||
if (words.length === 0) {
|
||||
wordListEl.innerHTML = '<li class="word-item word-item-empty">Нет слов для повторения 🎉</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const word of words) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'word-item';
|
||||
|
||||
let reading = word.key;
|
||||
let translation = word.value;
|
||||
|
||||
if (word.more) {
|
||||
try {
|
||||
const parsed = JSON.parse(word.more);
|
||||
reading = parsed.reading || word.key;
|
||||
translation = parsed.translation || word.value;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
li.innerHTML = `
|
||||
<span class="word-key">${reading}</span>
|
||||
<span class="word-value">${escapeHtml(translation)}</span>
|
||||
${word.tags ? `<span class="word-tags">${escapeHtml(word.tags)}</span>` : ''}
|
||||
`;
|
||||
wordListEl.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSetsPage() {
|
||||
container.innerHTML = `
|
||||
<div class="card" style="flex:1; display:flex; flex-direction:column; padding:0; overflow:hidden;">
|
||||
<div class="sets-layout" style="flex:1;">
|
||||
<ul id="sets-list" class="sets-list"></ul>
|
||||
<div id="sets-detail" class="sets-detail">
|
||||
<div class="placeholder-area">
|
||||
<div class="placeholder-icon">🗂️</div>
|
||||
<div>Выберите колоду</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status" class="status-bar">Загрузка...</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSetsList(sets: CardSetRecord[]) {
|
||||
const listEl = document.getElementById('sets-list') as HTMLUListElement | null;
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.innerHTML = '';
|
||||
|
||||
if (sets.length === 0) {
|
||||
listEl.innerHTML = '<li style="opacity:0.5;text-align:center;padding:20px;">Нет колод</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const set of sets) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = set.name;
|
||||
li.dataset.setId = String(set.id);
|
||||
li.addEventListener('click', () => showSetDetail(set, li));
|
||||
listEl.appendChild(li);
|
||||
}
|
||||
|
||||
// Выделяем первую
|
||||
const first = listEl.querySelector('li') as HTMLLIElement | null;
|
||||
if (first) {
|
||||
first.classList.add('selected');
|
||||
const id = Number(first.dataset.setId);
|
||||
const set = sets.find(s => s.id === id);
|
||||
if (set) showSetDetail(set, first);
|
||||
}
|
||||
}
|
||||
|
||||
function showSetDetail(set: CardSetRecord, listItem: HTMLLIElement) {
|
||||
// Обновляем выделение
|
||||
document.querySelectorAll('#sets-list li').forEach(el => el.classList.remove('selected'));
|
||||
listItem.classList.add('selected');
|
||||
|
||||
const detailEl = document.getElementById('sets-detail');
|
||||
if (!detailEl) return;
|
||||
|
||||
detailEl.innerHTML = `
|
||||
<h3>${escapeHtml(set.name)}</h3>
|
||||
<p><strong>Прямой порядок:</strong> ${escapeHtml(set.forward)}</p>
|
||||
<p><strong>Обратный порядок:</strong> ${escapeHtml(set.backward)}</p>
|
||||
<p><strong>Фильтр:</strong> ${escapeHtml(set.filter)}</p>
|
||||
|
||||
<label style="font-size:0.8rem;color:var(--text);display:block;margin-top:12px;">Режим тренировки</label>
|
||||
<select id="mode-select" class="mode-select">
|
||||
<option value="${SetOrderMode.Default}">${SetOrderMode.Default} — взвешенная случайность</option>
|
||||
<option value="${SetOrderMode.TrainWorstFirst}">${SetOrderMode.TrainWorstFirst} — слабые слова</option>
|
||||
<option value="${SetOrderMode.FullRandom}">${SetOrderMode.FullRandom} — полная случайность</option>
|
||||
</select>
|
||||
|
||||
<button class="btn btn-primary" data-set-id="${set.id}">▶ Начать тренировку</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// ----- Навигация -----
|
||||
|
||||
function switchPage(page: 'words' | 'sets') {
|
||||
currentPage = page;
|
||||
|
||||
// Обновляем активную кнопку
|
||||
navBtns.forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.page === page);
|
||||
});
|
||||
|
||||
if (page === 'words') {
|
||||
renderWordsPage();
|
||||
loadWords();
|
||||
} else {
|
||||
renderSetsPage();
|
||||
loadSets();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWords() {
|
||||
try {
|
||||
const words = await getWordsForReview();
|
||||
renderWordsList(words);
|
||||
const count = words.length;
|
||||
setStatus(`Слов: ${count}`);
|
||||
} catch (err) {
|
||||
setStatus('Ошибка загрузки слов', true);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSets() {
|
||||
try {
|
||||
const sets = await getCardSets();
|
||||
renderSetsList(sets);
|
||||
setStatus(`Колод: ${sets.length}`);
|
||||
} catch (err) {
|
||||
setStatus('Ошибка загрузки колод', true);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Обработчики синхронизации -----
|
||||
|
||||
async function onDownload() {
|
||||
try {
|
||||
const id = getSyncId();
|
||||
const btn = document.getElementById('btnDownload') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
setStatus('Скачивание...');
|
||||
await downloadDatabase(id, API_BASE_URL, replaceDatabaseFile, initDB);
|
||||
|
||||
if (currentPage === 'words') await loadWords();
|
||||
setStatus('База данных скачана.');
|
||||
} catch {
|
||||
// ошибка уже обработана в sync.ts
|
||||
} finally {
|
||||
const btn = document.getElementById('btnDownload') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
try {
|
||||
const id = getSyncId();
|
||||
const btn = document.getElementById('btnUpload') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
setStatus('Загрузка...');
|
||||
await uploadDatabase(id, API_BASE_URL, closeDb, getDatabaseFile);
|
||||
|
||||
await initDB();
|
||||
if (currentPage === 'words') await loadWords();
|
||||
setStatus('База данных загружена.');
|
||||
} catch {
|
||||
// ошибка уже обработана в sync.ts
|
||||
} finally {
|
||||
const btn = document.getElementById('btnUpload') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Инициализация -----
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await initDB();
|
||||
console.log('[App] База данных инициализирована.');
|
||||
|
||||
// Навигация
|
||||
navBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
switchPage(btn.dataset.page as 'words' | 'sets');
|
||||
});
|
||||
});
|
||||
|
||||
// Стартовая страница
|
||||
switchPage('words');
|
||||
|
||||
// Глобальные обработчики кликов (для кнопок на динамических страницах)
|
||||
container.addEventListener('click', async (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.id === 'btnDownload') await onDownload();
|
||||
if (target.id === 'btnUpload') await onUpload();
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[App] Ошибка инициализации:', err);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,120 @@
|
||||
import DbWorker from './db-worker.ts?worker';
|
||||
|
||||
// ----- Прокси к Worker -----
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let requestId = 0;
|
||||
|
||||
type WorkerRequest = {
|
||||
resolve: (data: any) => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
const pending = new Map<number, WorkerRequest>();
|
||||
|
||||
function sendToWorker(type: string, payload?: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!worker) {
|
||||
reject(new Error('Worker не создан'));
|
||||
return;
|
||||
}
|
||||
|
||||
const id = ++requestId;
|
||||
pending.set(id, { resolve, reject });
|
||||
|
||||
worker.postMessage({ type, id, ...payload });
|
||||
});
|
||||
}
|
||||
|
||||
function initWorker(): Promise<void> {
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = new Promise((resolve, reject) => {
|
||||
worker = new DbWorker();
|
||||
|
||||
worker.onmessage = (e: MessageEvent) => {
|
||||
const msg = e.data;
|
||||
|
||||
if (msg.type === 'ready') {
|
||||
// Готов — запрос init уже обработан
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'result') {
|
||||
const pendingReq = pending.get(msg.id);
|
||||
if (!pendingReq) return;
|
||||
pending.delete(msg.id);
|
||||
|
||||
if (msg.ok) {
|
||||
pendingReq.resolve(msg.data);
|
||||
} else {
|
||||
pendingReq.reject(new Error(msg.error || 'Неизвестная ошибка Worker'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (err) => {
|
||||
console.error('[SQLite Worker] Ошибка:', err);
|
||||
reject(new Error('Ошибка Worker'));
|
||||
};
|
||||
|
||||
// Отправляем init
|
||||
const id = ++requestId;
|
||||
pending.set(id, { resolve, reject });
|
||||
worker.postMessage({ type: 'init', id });
|
||||
});
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
// ----- Публичные функции -----
|
||||
|
||||
export async function initDB(): Promise<void> {
|
||||
await initWorker();
|
||||
console.log('[SQLite] База данных инициализирована.');
|
||||
}
|
||||
|
||||
export async function execute(sql: string, params?: any[]): Promise<any[]> {
|
||||
const result = await sendToWorker('execute', { sql, params });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function executeVoid(sql: string, params?: any[]): Promise<void> {
|
||||
await sendToWorker('executeVoid', { sql, params });
|
||||
}
|
||||
|
||||
export async function getDatabaseFile(): Promise<ArrayBuffer> {
|
||||
const buffer = await sendToWorker('getFile');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрывает соединение с БД и освобождает блокировку файла OPFS.
|
||||
*/
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (!worker) return;
|
||||
await sendToWorker('closeDb');
|
||||
worker.terminate();
|
||||
worker = null;
|
||||
initPromise = null;
|
||||
}
|
||||
|
||||
export async function replaceDatabaseFile(arrayBuffer: ArrayBuffer): Promise<void> {
|
||||
// ArrayBuffer передаётся через transferable
|
||||
await sendToWorkerRaw('replaceFile', arrayBuffer);
|
||||
}
|
||||
|
||||
async function sendToWorkerRaw(type: string, buffer: ArrayBuffer): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!worker) {
|
||||
reject(new Error('Worker не создан'));
|
||||
return;
|
||||
}
|
||||
|
||||
const id = ++requestId;
|
||||
pending.set(id, { resolve, reject });
|
||||
|
||||
worker.postMessage({ type, id, params: buffer }, [buffer]);
|
||||
});
|
||||
}
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--card-bg: #f8f7f9;
|
||||
--border: #e5e4e7;
|
||||
--accent: #7c3aed;
|
||||
--accent-light: rgba(124, 58, 237, 0.1);
|
||||
--success: #16a34a;
|
||||
--error: #dc2626;
|
||||
--shadow: rgba(0, 0, 0, 0.08) 0 4px 12px;
|
||||
|
||||
--sans: -apple-system, 'Segoe UI', system-ui, Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 16px/1.5 var(--sans);
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #0f0f13;
|
||||
--card-bg: #1a1b23;
|
||||
--border: #2e303a;
|
||||
--accent: #a78bfa;
|
||||
--accent-light: rgba(167, 139, 250, 0.15);
|
||||
--shadow: rgba(0, 0, 0, 0.4) 0 4px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#app {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 1.5rem 1.25rem 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-family: var(--sans);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-h);
|
||||
margin: 0;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.sync-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sync-input-group input {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sync-input-group input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
.sync-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
font-family: var(--sans);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-h);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-light);
|
||||
color: var(--text);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.status-bar.error {
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.status-bar.success {
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
padding: 1rem 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.placeholder-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.5rem;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.word-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.word-item {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
box-shadow: var(--shadow);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.word-item:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.word-item-empty {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
opacity: 0.6;
|
||||
font-size: 0.9rem;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.word-key {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-h);
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.word-value {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.word-tags {
|
||||
font-size: 0.7rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
flex-basis: 100%;
|
||||
order: 1;
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ──── Навигация ──── */
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
flex: 1;
|
||||
font-family: var(--sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-btn.active {
|
||||
color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ──── Страница колод ──── */
|
||||
|
||||
.sets-layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sets-list {
|
||||
flex: 1;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sets-list li {
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.15s;
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.sets-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sets-list li:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.sets-list li.selected {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sets-detail {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sets-detail h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.sets-detail p {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sets-detail .mode-select {
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sets-detail .btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.sets-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sets-list {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { init, compress, decompress } from '@bokuweb/zstd-wasm';
|
||||
|
||||
/**
|
||||
* Загружает (upload) базу данных на сервер.
|
||||
*
|
||||
* @param id - Идентификатор для синхронизации.
|
||||
* @param apiBaseUrl - Базовый URL API (без слеша на конце).
|
||||
* @param closeDb - Функция для закрытия соединения с БД.
|
||||
* @param getDbBuffer - Функция, возвращающая ArrayBuffer с бинарным файлом БД.
|
||||
*/
|
||||
export async function uploadDatabase(
|
||||
id: string,
|
||||
apiBaseUrl: string,
|
||||
closeDb: () => Promise<void>,
|
||||
getDbBuffer: () => Promise<ArrayBuffer>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. Закрываем соединение, чтобы снять блокировку файла OPFS
|
||||
await closeDb();
|
||||
|
||||
// 2. Получаем бинарный файл БД
|
||||
const dbBuffer = await getDbBuffer();
|
||||
const plainBytes = new Uint8Array(dbBuffer);
|
||||
|
||||
// 3. Сжимаем zstd
|
||||
await init();
|
||||
const compressed = compress(plainBytes);
|
||||
|
||||
// 4. Создаём FormData и добавляем сжатый файл
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([compressed.buffer as ArrayBuffer], { type: 'application/octet-stream' });
|
||||
formData.append('db', blob, 'database.sqlite.zst');
|
||||
|
||||
// 5. POST-запрос
|
||||
const url = `${apiBaseUrl.replace(/\/+$/, '')}/upload/${encodeURIComponent(id)}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ошибка загрузки: сервер ответил ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
console.log('[Sync] База данных успешно загружена.');
|
||||
} catch (err) {
|
||||
console.error('[Sync] Ошибка загрузки:', err);
|
||||
alert(`Ошибка загрузки: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивает (download) базу данных с сервера.
|
||||
*
|
||||
* @param id - Идентификатор для синхронизации.
|
||||
* @param apiBaseUrl - Базовый URL API (без слеша на конце).
|
||||
* @param replaceDbBuffer - Функция, перезаписывающая файл БД новым ArrayBuffer.
|
||||
* @param initDb - Функция для повторной инициализации соединения с БД.
|
||||
*/
|
||||
export async function downloadDatabase(
|
||||
id: string,
|
||||
apiBaseUrl: string,
|
||||
replaceDbBuffer: (buffer: ArrayBuffer) => Promise<void>,
|
||||
initDb: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 1. GET-запрос
|
||||
const url = `${apiBaseUrl.replace(/\/+$/, '')}/download/${encodeURIComponent(id)}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ошибка скачивания: сервер ответил ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 2. Получаем сжатые данные
|
||||
const compressedBuffer = await response.arrayBuffer();
|
||||
const compressedBytes = new Uint8Array(compressedBuffer);
|
||||
|
||||
// 3. Распаковываем zstd
|
||||
await init();
|
||||
const decompressedBytes = decompress(compressedBytes);
|
||||
|
||||
// 4. Перезаписываем файл в OPFS
|
||||
await replaceDbBuffer(decompressedBytes.buffer as ArrayBuffer);
|
||||
|
||||
// 5. Переинициализируем соединение с БД
|
||||
await initDb();
|
||||
|
||||
console.log('[Sync] База данных успешно скачана и применена.');
|
||||
} catch (err) {
|
||||
console.error('[Sync] Ошибка скачивания:', err);
|
||||
alert(`Ошибка скачивания: ${err instanceof Error ? err.message : String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
declare module 'wa-sqlite/dist/wa-sqlite-async.mjs' {
|
||||
export default function ModuleConstructor(options?: any): Promise<any>;
|
||||
}
|
||||
|
||||
declare module 'wa-sqlite/src/sqlite-api.js' {
|
||||
export interface SQLiteAPI {
|
||||
vfs_register(vfs: any, makeDefault: boolean): number;
|
||||
open_v2(
|
||||
filename: string,
|
||||
flags: number,
|
||||
vfs?: string,
|
||||
): Promise<number>;
|
||||
close(db: number): Promise<void>;
|
||||
exec(
|
||||
db: number,
|
||||
sql: string,
|
||||
callback?: (row: any, columns: string[]) => void,
|
||||
): Promise<number>;
|
||||
execWithParams(
|
||||
db: number,
|
||||
sql: string,
|
||||
params?: any[] | Record<string, any>,
|
||||
): Promise<{ rows: any[]; columns: string[] }>;
|
||||
run(
|
||||
db: number,
|
||||
sql: string,
|
||||
params?: any[] | Record<string, any>,
|
||||
): Promise<number>;
|
||||
}
|
||||
|
||||
export function Factory(Module: any): SQLiteAPI;
|
||||
|
||||
export class SQLiteError extends Error {
|
||||
code: number;
|
||||
}
|
||||
|
||||
export const SQLITE_OPEN_READWRITE: number;
|
||||
export const SQLITE_OPEN_CREATE: number;
|
||||
}
|
||||
|
||||
declare module 'wa-sqlite/src/examples/OriginPrivateFileSystemVFS.js' {
|
||||
class OriginPrivateFileSystemVFS {
|
||||
constructor();
|
||||
close(): Promise<void>;
|
||||
name: string;
|
||||
}
|
||||
export { OriginPrivateFileSystemVFS };
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*?worker' {
|
||||
const workerConstructor: { new (): Worker };
|
||||
export default workerConstructor;
|
||||
}
|
||||
Reference in New Issue
Block a user