From 4646dad2b8a4161d9656a6dbf660b2955a646bb2 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Sun, 13 Sep 2026 18:43:42 +0300 Subject: [PATCH] first commit --- .gitignore | 19 + index.html | 65 +++ package-lock.json | 1219 ++++++++++++++++++++++++++++++++++++++++ package.json | 20 + public/favicon.svg | 4 + src/config.ts | 15 + src/main.ts | 38 ++ src/opfs.d.ts | 34 ++ src/prefs.ts | 24 + src/storage.ts | 68 +++ src/style.css | 170 ++++++ src/sync-panel.ts | 136 +++++ src/sync.ts | 80 +++ src/token.ts | 20 + src/vite-env.d.ts | 1 + src/wa-sqlite-mjs.d.ts | 11 + tsconfig.json | 21 + vite.config.ts | 26 + 18 files changed, 1971 insertions(+) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 src/config.ts create mode 100644 src/main.ts create mode 100644 src/opfs.d.ts create mode 100644 src/prefs.ts create mode 100644 src/storage.ts create mode 100644 src/style.css create mode 100644 src/sync-panel.ts create mode 100644 src/sync.ts create mode 100644 src/token.ts create mode 100644 src/vite-env.d.ts create mode 100644 src/wa-sqlite-mjs.d.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7f9a3c --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Зависимости +node_modules + +# Сборка +dist +dist-ssr + +# Логи +*.log + +# Окружение +.env +.env.* + +# Редактор / ОС +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..0186332 --- /dev/null +++ b/index.html @@ -0,0 +1,65 @@ + + + + + + + + JapLearn — интервальные повторения + + +
+
+

JapLearn

+

Интервальные повторения · офлайн-первая

+
+ +
+

Сохранение

+

+ База данных хранится локально на устройстве. Токен связывает её + с копией на сервере — скачивайте и выгружайте файл по мере + необходимости. +

+
+
+ + +

+ Последовательность из 24 символов: латинские буквы (A–Z) + и цифры (0–9). +

+ +
+ +
+ + +
+
+

+

+
+ + +
+ + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3edff25 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1219 @@ +{ + "name": "japlearn", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "japlearn", + "version": "0.1.0", + "dependencies": { + "@hpcc-js/wasm-zstd": "^1.16.0", + "wa-sqlite": "^1.0.0" + }, + "devDependencies": { + "typescript": "^7.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@hpcc-js/wasm-zstd": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@hpcc-js/wasm-zstd/-/wasm-zstd-1.16.0.tgz", + "integrity": "sha512-+F+GRnqsbcVvr6vlSivB+YYOYNaDt21CAwfp0ru9mPeYeVPX6YFCu8fZaARuyklZZ9/9uUAs5qymEfnXKBZl8g==", + "license": "Apache-2.0" + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wa-sqlite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wa-sqlite/-/wa-sqlite-1.0.0.tgz", + "integrity": "sha512-Kyybo5/BaJp76z7gDWGk2J6Hthl4NIPsE+swgraEjy3IY6r5zIR02wAs1OJH4XtJp1y3puj3Onp5eMGS0z7nUA==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cbe3930 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..cf3f10b --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + J + \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..dc0605b --- /dev/null +++ b/src/config.ts @@ -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}`; +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..65f86c4 --- /dev/null +++ b/src/main.ts @@ -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('#sqlite-status'); + +async function initSqlite(): Promise { + 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(); \ No newline at end of file diff --git a/src/opfs.d.ts b/src/opfs.d.ts new file mode 100644 index 0000000..bbcca47 --- /dev/null +++ b/src/opfs.d.ts @@ -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; + createWritable(): Promise; +} + +/** Записываемый файловый дескриптор OPFS. */ +interface OpfsWritableFileHandle { + write(data: Uint8Array, at?: number): Promise; + truncate?(newSize: number): Promise; + close(): Promise; +} + +/** Каталог OPFS (корень приватного хранилища origin). */ +interface OpfsDirectoryHandle { + getFileHandle( + name: string, + options?: { create?: boolean }, + ): Promise; + removeEntry(name: string): Promise; +} + +interface OpfsStorage { + getDirectory(id?: string): Promise; +} + +interface Navigator { + readonly storage?: OpfsStorage; +} \ No newline at end of file diff --git a/src/prefs.ts b/src/prefs.ts new file mode 100644 index 0000000..dcfbaf9 --- /dev/null +++ b/src/prefs.ts @@ -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); +} \ No newline at end of file diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..7cdfbb4 --- /dev/null +++ b/src/storage.ts @@ -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 { + if (!isOpfsSupported()) { + throw new Error( + 'OPFS (navigator.storage) недоступен — откройте приложение в Chrome (десктоп или Android)', + ); + } + // У root есть стрелка вниз: тип зафиксирован в opfs.d.ts. + return navigator.storage!.getDirectory(); +} + +/** Текущее состояние файла БД в OPFS. */ +export async function statDatabase(): Promise { + 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 { + 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 }; +} \ No newline at end of file diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..4ffd353 --- /dev/null +++ b/src/style.css @@ -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); +} \ No newline at end of file diff --git a/src/sync-panel.ts b/src/sync-panel.ts new file mode 100644 index 0000000..5f1edd9 --- /dev/null +++ b/src/sync-panel.ts @@ -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(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(doc, '#save-token'); + this.#error = element(doc, '#token-error'); + this.#downloadBtn = element(doc, '#btn-download'); + this.#uploadBtn = element(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 { + 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 { + 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 { + if (this.#busy || !isValidToken(this.#input.value)) return; + this.#status.textContent = `Выгрузка «${this.token}» — будет реализована на следующем шаге.`; + } +} \ No newline at end of file diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..2339e97 --- /dev/null +++ b/src/sync.ts @@ -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 | null = null; + +function getZstd(): Promise { + zstdCodec ??= Zstd.load(); + return zstdCodec; +} + +export interface DownloadedSave { + /** Размер загруженного (сжатого zstd) файла в байтах. */ + compressedBytes: number; + /** Распакованный бинарный файл базы данных SQLite. */ + database: Uint8Array; +} + +/** Скачивает бинарное тело с сервера. */ +export async function downloadRaw(url: string): Promise { + 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 { + 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 }; +} \ No newline at end of file diff --git a/src/token.ts b/src/token.ts new file mode 100644 index 0000000..380cef6 --- /dev/null +++ b/src/token.ts @@ -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)); +} \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/src/wa-sqlite-mjs.d.ts b/src/wa-sqlite-mjs.d.ts new file mode 100644 index 0000000..d3a2770 --- /dev/null +++ b/src/wa-sqlite-mjs.d.ts @@ -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) => Promise; + export default SQLiteESMFactory; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b6af4bf --- /dev/null +++ b/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ce66fef --- /dev/null +++ b/vite.config.ts @@ -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) + // контекста. Дублируется через в index.html. + 'Cross-Origin-Embedder-Policy': 'require-corp', + }, + }, +}); \ No newline at end of file