From 145b548a9b3778e99456f6ffb7b222e14e749af2 Mon Sep 17 00:00:00 2001 From: Daniele Briggi <=> Date: Wed, 9 Jul 2025 14:53:53 +0200 Subject: [PATCH 1/2] feat(example): sport tracker app initial commit --- .gitignore | 6 +- examples/sport-tracker-app/.env.example | 13 + examples/sport-tracker-app/README.md | 176 ++++ examples/sport-tracker-app/index.html | 13 + examples/sport-tracker-app/package-lock.json | 912 ++++++++++++++++++ examples/sport-tracker-app/package.json | 23 + examples/sport-tracker-app/public/vite.svg | 1 + examples/sport-tracker-app/rls-policies.md | 78 ++ .../sport-tracker-schema.sql | 30 + examples/sport-tracker-app/src/App.tsx | 594 ++++++++++++ examples/sport-tracker-app/src/SQLiteSync.ts | 215 +++++ .../components/Activities/ActivityCard.tsx | 53 + .../src/components/AppFooter.tsx | 64 ++ .../src/components/DatabaseStatus.tsx | 68 ++ .../src/components/Statistics/StatCard.tsx | 22 + .../src/components/UserCreation.tsx | 172 ++++ .../src/components/UserLogin.tsx | 231 +++++ .../src/components/Workouts/WorkoutCard.tsx | 57 ++ .../src/context/DatabaseContext.tsx | 56 ++ examples/sport-tracker-app/src/db/database.ts | 185 ++++ .../src/db/databaseOperations.ts | 315 ++++++ .../src/db/sqliteSyncOperations.ts | 101 ++ examples/sport-tracker-app/src/db/worker.ts | 76 ++ examples/sport-tracker-app/src/main.tsx | 9 + examples/sport-tracker-app/src/style.css | 885 +++++++++++++++++ examples/sport-tracker-app/src/vite-env.d.ts | 1 + examples/sport-tracker-app/tsconfig.json | 26 + examples/sport-tracker-app/vite.config.ts | 21 + 28 files changed, 4401 insertions(+), 2 deletions(-) create mode 100644 examples/sport-tracker-app/.env.example create mode 100644 examples/sport-tracker-app/README.md create mode 100644 examples/sport-tracker-app/index.html create mode 100644 examples/sport-tracker-app/package-lock.json create mode 100644 examples/sport-tracker-app/package.json create mode 100644 examples/sport-tracker-app/public/vite.svg create mode 100644 examples/sport-tracker-app/rls-policies.md create mode 100644 examples/sport-tracker-app/sport-tracker-schema.sql create mode 100644 examples/sport-tracker-app/src/App.tsx create mode 100644 examples/sport-tracker-app/src/SQLiteSync.ts create mode 100644 examples/sport-tracker-app/src/components/Activities/ActivityCard.tsx create mode 100644 examples/sport-tracker-app/src/components/AppFooter.tsx create mode 100644 examples/sport-tracker-app/src/components/DatabaseStatus.tsx create mode 100644 examples/sport-tracker-app/src/components/Statistics/StatCard.tsx create mode 100644 examples/sport-tracker-app/src/components/UserCreation.tsx create mode 100644 examples/sport-tracker-app/src/components/UserLogin.tsx create mode 100644 examples/sport-tracker-app/src/components/Workouts/WorkoutCard.tsx create mode 100644 examples/sport-tracker-app/src/context/DatabaseContext.tsx create mode 100644 examples/sport-tracker-app/src/db/database.ts create mode 100644 examples/sport-tracker-app/src/db/databaseOperations.ts create mode 100644 examples/sport-tracker-app/src/db/sqliteSyncOperations.ts create mode 100644 examples/sport-tracker-app/src/db/worker.ts create mode 100644 examples/sport-tracker-app/src/main.tsx create mode 100644 examples/sport-tracker-app/src/style.css create mode 100644 examples/sport-tracker-app/src/vite-env.d.ts create mode 100644 examples/sport-tracker-app/tsconfig.json create mode 100644 examples/sport-tracker-app/vite.config.ts diff --git a/.gitignore b/.gitignore index 4b4f0e5..7f1db0d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,12 @@ *.xcbkptlist *.plist /build -/dist +**/dist/** /coverage *.sqlite *.a unittest /curl/src -.vscode \ No newline at end of file +.vscode +**/node_modules/** +.env \ No newline at end of file diff --git a/examples/sport-tracker-app/.env.example b/examples/sport-tracker-app/.env.example new file mode 100644 index 0000000..ce5c7cc --- /dev/null +++ b/examples/sport-tracker-app/.env.example @@ -0,0 +1,13 @@ +# Copy from from the SQLite Cloud Dashboard +# eg: sqlitecloud://myhost.cloud:8860/my-remote-database.sqlite +VITE_SQLITECLOUD_CONNECTION_STRING= +# The database name +# eg: my-remote-database.sqlite +VITE_SQLITECLOUD_DATABASE= +# Your SQLite Cloud API key +# Copy it from the SQLite Cloud Dashboard -> Settings -> API Keys +VITE_SQLITECLOUD_API_KEY= +# Your SQLite Cloud url for APIs +# Get it from the SQLite Cloud Dashboard in the Weblite section +# eg: https://myhost.cloud +VITE_SQLITECLOUD_API_URL= \ No newline at end of file diff --git a/examples/sport-tracker-app/README.md b/examples/sport-tracker-app/README.md new file mode 100644 index 0000000..5fc5ba1 --- /dev/null +++ b/examples/sport-tracker-app/README.md @@ -0,0 +1,176 @@ +# Sport Tracker app with SQLite Sync 🚵 + +A Vite/React demonstration app showcasing [**SQLite Sync**](https://github.com/sqliteai/sqlite-sync) implementation for **offline-first** data synchronization across multiple devices. This example illustrates how to integrate SQLite AI's sync capabilities into modern web applications with proper authentication via [Access Token](https://docs.sqlitecloud.io/docs/access-tokens) and [Row-Level Security (RLS)](https://docs.sqlitecloud.io/docs/rls). + + +## Features + +From a **user experience** perspective, this is a simple sport tracking application where users can: +- Create accounts and log activities (running, cycling, swimming, etc.) +- View personal statistics and workout history +- Access "Coach Mode" for managing multiple users' workouts + +From a **developer perspective**, this app showcases: +- **Offline-first** architecture with sync to the remote database using **SQLite Sync** extension for SQLite +- **Row-Level Security (RLS)** implementation for data isolation and access control on the SQLite Cloud database +- **Access Tokens** for secure user authentication with SQLite Sync and RLS policy enforcement +- **Multi-user** data isolation and sharing patterns across different user sessions + +## Setup Instructions + +### 1. Prerequisites +- Node.js 18+ +- [SQLite Cloud account](https://sqlitecloud.io) + +### 2. Database Setup +1. Create database in [SQLite Cloud Dashboard](https://dashboard.sqlitecloud.io/). +2. Execute the exact schema from `sport-tracker-schema.sql`. +3. Enable OffSync for all tables on the remote database from the **SQLite Cloud Dashboard -> Databases**. +4. Enable and configure RLS policies on the **SQLite Cloud Dashboard -> Databases**. See the file `rls-policies.md`. + +### 3. Environment Configuration + +Rename the `.env.example` into `.env` and fill with your values. + +### 4. Installation & Run + +```bash +npm install +npm run dev +``` + +> This app uses the packed WASM version of SQLite with the [SQLite Sync extension enabled](https://www.npmjs.com/package/@sqliteai/sqlite-sync-wasm). + +## Demo Use Case: Multi-User Sync Scenario + +This walkthrough demonstrates how SQLite Sync handles offline-first synchronization between multiple users: + +### The Story: Bob the Runner & Coach Sarah + +1. **Bob starts tracking offline** 📱 + - Open [localhost:5173](http://localhost:5173) in your browser + - Create user `bob` and add some activities + - Notice Bob's data is stored locally - no internet required! + +2. **Bob goes online and syncs** 🌐 + - Click `SQLite Sync` to authenticate SQLite Sync + - Click `Sync & Refresh` - this generates an Access Token and synchronizes Bob's local data to the cloud + - Bob's activities are now replicated in the cloud + +3. **Coach Sarah joins from another device** 👩‍💼 + - Open a new private/incognito browser window at [localhost:5173](http://localhost:5173) + - Create user `coach` (this triggers special coach privileges via RLS) + - Enable `SQLite Sync` and click `Sync & Refresh`. Coach can now see Bob's synced activities thanks to RLS policies + +4. **Coach creates a workout for Bob** 💪 + - Coach creates a workout assigned to Bob + - Click `Sync & Refresh` to upload the workout to the cloud + +5. **Bob receives his workout** 📲 + - Go back to Bob's browser window + - Click `Sync & Refresh` - Bob's local database downloads the new workout from Coach + - Bob can now see his personalized workout + +6. **Bob gets a new device** 📱➡️💻 + - Log out Bob, then select it and click `Restore from cloud` + - This simulates Bob logging in from a completely new device with no local data + - Enable `SQLite Sync` and sync - all of Bob's activities and workouts are restored from the cloud + +**Key takeaway**: Users can work offline, sync when convenient, and seamlessly restore data on new devices! + + +## SQLite Sync Implementation + +### 1. Database Initialization + +```typescript +// database.ts - Initialize sync for each table +export class Database { + async initSync() { + await this.exec('SELECT cloudsync_init("users")'); + await this.exec('SELECT cloudsync_init("activities")'); + await this.exec('SELECT cloudsync_init("workouts")'); + } +} +``` + +### 2. Token Management + +```typescript +// SQLiteSync.ts - Access token handling +private async getValidToken(userId: string, name: string): Promise { + const storedTokenData = localStorage.getItem('token'); + + if (storedTokenData) { + const parsed: TokenData = JSON.parse(storedTokenData); + const tokenExpiry = new Date(parsed.expiresAt); + + if (tokenExpiry > new Date()) { + return parsed.token; // Use cached token + } + } + + // Fetch new token from API + const tokenData = await this.fetchNewToken(userId, name); + localStorage.setItem('token', JSON.stringify(tokenData)); + return tokenData.token; +} +``` + +Then authorize SQLite Sync with the token. This operation is executed again when tokens expire and a new one is provided. + +```typescript +async sqliteSyncSetToken(token: string) { + await this.exec(`SELECT cloudsync_network_set_token('${token}')`); +} +``` + +### 3. Synchronization + +The sync operation sends local changes to the cloud and receives remote changes: + +```typescript +async sqliteSyncNetworkSync() { + await this.exec('SELECT cloudsync_network_sync()'); +} +``` + +## Row-Level Security (RLS) + +This app demonstrates **Row-Level Security** configured in the SQLite Cloud Dashboard. RLS policies ensure: + +- **Users** can only see their own activities and workouts +- **Coaches** can access all users' data and create workouts for the users +- **Data isolation** is enforced at the database level + +### Example RLS Policies + +```sql +-- Policy for selecting activities +auth_userid() = user_id OR json_extract(auth_json(), '$.name') = 'coach' + +-- Policy for inserting into workouts table +json_extract(auth_json(), '$.name') = 'coach' +``` + +> **Note**: Configure RLS policies in your SQLite Cloud Dashboard under Databases → RLS + +## Security Considerations + +⚠️ **Important**: This demo includes client-side API key usage for simplicity. In production: + +- Never expose API keys in client code +- Use **server-side generation** for Access Tokens +- Implement a proper authentication flow + +## Documentation Links + +Explore the code and learn more: + +- **SQLite Sync API**: [sqlite-sync](https://github.com/sqliteai/sqlite-sync/blob/main/API.md) +- **Access Tokens Guide**: [SQLite Cloud Access Tokens](https://docs.sqlitecloud.io/docs/access-tokens) +- **Row-Level Security**: [SQLite Cloud RLS](https://docs.sqlitecloud.io/docs/rls) + +## Performance considerations + +The database is persisted in the Origin-Private FileSystem OPFS (if available) but performance is much lower. Read more [here](https://sqlite.org/wasm/doc/trunk/persistence.md) diff --git a/examples/sport-tracker-app/index.html b/examples/sport-tracker-app/index.html new file mode 100644 index 0000000..c4d403e --- /dev/null +++ b/examples/sport-tracker-app/index.html @@ -0,0 +1,13 @@ + + + + + + + Sport Tracker + + +
+ + + diff --git a/examples/sport-tracker-app/package-lock.json b/examples/sport-tracker-app/package-lock.json new file mode 100644 index 0000000..ce0eab8 --- /dev/null +++ b/examples/sport-tracker-app/package-lock.json @@ -0,0 +1,912 @@ +{ + "name": "sport-tracking-app", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sport-tracking-app", + "version": "0.0.1", + "dependencies": { + "@sqliteai/sqlite-sync-wasm": "^3.49.2-sync-0.8.9", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^7.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", + "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", + "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@sqliteai/sqlite-sync-wasm": { + "version": "3.49.2-sync-0.8.9", + "resolved": "https://registry.npmjs.org/@sqliteai/sqlite-sync-wasm/-/sqlite-sync-wasm-3.49.2-sync-0.8.9.tgz", + "integrity": "sha512-VodMSrVW7AgpS4Bqvt+3rl3UKXBvGUG2riyt07i7aAYdPRYVDfioyO+KuCpAn/plRhlO4LnEqccjdzBMKbU/9Q==" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==" + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", + "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.1", + "@rollup/rollup-android-arm64": "4.44.1", + "@rollup/rollup-darwin-arm64": "4.44.1", + "@rollup/rollup-darwin-x64": "4.44.1", + "@rollup/rollup-freebsd-arm64": "4.44.1", + "@rollup/rollup-freebsd-x64": "4.44.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", + "@rollup/rollup-linux-arm-musleabihf": "4.44.1", + "@rollup/rollup-linux-arm64-gnu": "4.44.1", + "@rollup/rollup-linux-arm64-musl": "4.44.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-musl": "4.44.1", + "@rollup/rollup-linux-s390x-gnu": "4.44.1", + "@rollup/rollup-linux-x64-gnu": "4.44.1", + "@rollup/rollup-linux-x64-musl": "4.44.1", + "@rollup/rollup-win32-arm64-msvc": "4.44.1", + "@rollup/rollup-win32-ia32-msvc": "4.44.1", + "@rollup/rollup-win32-x64-msvc": "4.44.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.1.tgz", + "integrity": "sha512-BiKOQoW5HGR30E6JDeNsati6HnSPMVEKbkIWbCiol+xKeu3g5owrjy7kbk/QEMuzCV87dSUTvycYKmlcfGKq3Q==", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.2", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + } + } +} diff --git a/examples/sport-tracker-app/package.json b/examples/sport-tracker-app/package.json new file mode 100644 index 0000000..5897d20 --- /dev/null +++ b/examples/sport-tracker-app/package.json @@ -0,0 +1,23 @@ +{ + "name": "sport-tracking-app", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^7.0.0" + }, + "dependencies": { + "@sqliteai/sqlite-sync-wasm": "^3.49.2-sync-0.8.9", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "react": "^19.1.0", + "react-dom": "^19.1.0" + } +} diff --git a/examples/sport-tracker-app/public/vite.svg b/examples/sport-tracker-app/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/sport-tracker-app/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/sport-tracker-app/rls-policies.md b/examples/sport-tracker-app/rls-policies.md new file mode 100644 index 0000000..4ccd145 --- /dev/null +++ b/examples/sport-tracker-app/rls-policies.md @@ -0,0 +1,78 @@ +## RLS Policies + + +### Users + +#### SELECT + +```sql +auth_userid() = user_id OR json_extract(auth_json(), '$.name') = 'coach' +``` + +#### INSERT + +```sql +auth_userid() = NEW.id +``` + +#### UPDATE + +_No policy_ + +#### DELETE + +_No policy_ + +--- + +### Activities + +#### SELECT + +```sql +auth_userid() = user_id OR json_extract(auth_json(), '$.name') = 'coach' +``` + +#### INSERT + +```sql +auth_userid() = NEW.user_id +``` + +#### UPDATE + +_No policy_ + +#### DELETE + +```sql +auth_userid() = OLD.user_id OR json_extract(auth_json(), '$.name') = 'coach' +``` + +--- + +### Workouts + +#### SELECT + +```sql +auth_userid() = user_id OR json_extract(auth_json(), '$.name') = 'coach' +``` + +#### INSERT + +```sql +json_extract(auth_json(), '$.name') = 'coach' +``` + +#### UPDATE + +```sql +OLD.user_id = auth_userid() OR json_extract(auth_json(), '$.name') = 'coach' +``` + +#### DELETE + +```sql +json_extract(auth_json(), '$.name') = 'coach' +``` diff --git a/examples/sport-tracker-app/sport-tracker-schema.sql b/examples/sport-tracker-app/sport-tracker-schema.sql new file mode 100644 index 0000000..9e8ce1a --- /dev/null +++ b/examples/sport-tracker-app/sport-tracker-schema.sql @@ -0,0 +1,30 @@ +-- SQL schema +-- Use this exact schema to create the remote database on the on SQLite Cloud + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, -- UUID's HIGHLY RECOMMENDED for global uniqueness + name TEXT UNIQUE NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS activities ( + id TEXT PRIMARY KEY NOT NULL, -- UUID's HIGHLY RECOMMENDED for global uniqueness + type TEXT NOT NULL DEFAULT 'runnning', + duration INTEGER, + distance REAL, + calories INTEGER, + date TEXT, + notes TEXT, + user_id TEXT, + FOREIGN KEY (user_id) REFERENCES users (id) +); + +CREATE TABLE IF NOT EXISTS workouts ( + id TEXT PRIMARY KEY NOT NULL, -- UUID's HIGHLY RECOMMENDED for global uniqueness + name TEXT, + type TEXT, + duration INTEGER, + exercises TEXT, + date TEXT, + completed INTEGER DEFAULT 0, + user_id TEXT +); diff --git a/examples/sport-tracker-app/src/App.tsx b/examples/sport-tracker-app/src/App.tsx new file mode 100644 index 0000000..2cd6603 --- /dev/null +++ b/examples/sport-tracker-app/src/App.tsx @@ -0,0 +1,594 @@ +import React, { useCallback, useEffect, useState } from "react"; +import ActivityCard from "./components/Activities/ActivityCard"; +import AppFooter from "./components/AppFooter"; +import DatabaseStatus from "./components/DatabaseStatus"; +import StatCard from "./components/Statistics/StatCard"; +import UserCreation from "./components/UserCreation"; +import UserLogin from "./components/UserLogin"; +import WorkoutCard from "./components/Workouts/WorkoutCard"; +import { DatabaseProvider, useDatabase } from "./context/DatabaseContext"; +import type { + Activity, + DatabaseCounts, + User, + UserStats, + Workout, +} from "./db/database"; +import "./style.css"; + +interface UserSession { + userId: string; + name: string; +} + +const AppContent: React.FC = () => { + const { db, isInitialized, error } = useDatabase(); + const [activities, setActivities] = useState([]); + const [workouts, setWorkouts] = useState([]); + const [stats, setStats] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + const [users, setUsers] = useState([]); + const [currentSession, setCurrentSession] = useState( + null + ); + const [selectedWorkoutUser, setSelectedWorkoutUser] = useState(""); + const [loading, setLoading] = useState(true); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const [sqliteSyncVersion, setSqliteSyncVersion] = useState(""); + const [sqliteVersion, setSqliteVersion] = useState(""); + + // Coach mode - true when logged in user is named "coach" + const isCoachMode = (session: UserSession | null): boolean => { + return session?.name === "coach"; + }; + + const coachMode = isCoachMode(currentSession); + + const getUserName = (userId?: string): string | undefined => { + if (!userId) return undefined; + const user = users.find((u) => u.id === userId); + return user?.name; + }; + + useEffect(() => { + // Reload session from localStorage + const savedSession = localStorage.getItem("userSession"); + if (savedSession) { + try { + const session: UserSession = JSON.parse(savedSession); + setCurrentSession(session); + loadUser(session.userId); + } catch (error) { + console.error("Failed to parse saved session:", error); + localStorage.removeItem("userSession"); + } + } + + // Fetch database versions + if (db) { + db.sqliteSyncVersion() + .then((version: string) => { + setSqliteSyncVersion(version); + }) + .catch((error: any) => { + console.error("Failed to get SQLite Sync version:", error); + }); + + db.sqliteVersion() + .then((version: string) => { + setSqliteVersion(version); + }) + .catch((error: any) => { + console.error("Failed to get SQLite version:", error); + }); + } + }, [db]); + + const loadUser = async (userId: string) => { + if (!db) return; + try { + const user = await db.getUserById(userId); + if (user) { + setCurrentUser(user); + } else { + // User doesn't exist anymore, clear session + await handleLogout(); + } + } catch (error) { + console.error("Failed to load user:", error); + await handleLogout(); + } + }; + + const loadUsers = useCallback(async () => { + if (!db) return; + try { + const usersList = await db.getUsers(); + setUsers(usersList); + } catch (error) { + console.error("Failed to load users:", error); + } + }, [db, currentSession, selectedWorkoutUser]); + + const loadCounts = useCallback(async (): Promise => { + const defaultCounts = { + users: 0, + activities: 0, + workouts: 0, + totalUsers: 0, + totalActivities: 0, + totalWorkouts: 0, + }; + + if (!db) return defaultCounts; + + try { + const userId = currentSession?.userId; + const isCoach = coachMode; + return await db.getCounts(userId, isCoach); + } catch (error) { + console.error("Failed to load database counts:", error); + return defaultCounts; + } + }, [db, currentSession, coachMode]); + + const triggerRefresh = () => { + setRefreshTrigger((prev) => prev + 1); + }; + + const handleRefreshData = async () => { + await Promise.all([loadUsers(), loadAllData()]); + triggerRefresh(); + }; + + useEffect(() => { + if (db && isInitialized) { + loadAllData(); + loadUsers(); + } + }, [db, isInitialized, loadUsers]); + + // Reload data when session or coach mode changes + useEffect(() => { + if (db && isInitialized) { + loadAllData(); + } + }, [currentSession?.userId, coachMode]); + + const loadAllData = async () => { + if (!db) return; + try { + if (!currentSession) { + // No user logged in - clear all data + setActivities([]); + setWorkouts([]); + setStats(null); + setLoading(false); + return; + } + + const userId = currentSession.userId; + const isCoach = coachMode; + + const [activitiesList, workoutsList, userStats] = await Promise.all([ + db.getActivities(userId, isCoach), + db.getWorkouts(userId, isCoach), + db.getStats(userId, isCoach), + ]); + setActivities(activitiesList); + setWorkouts(workoutsList); + setStats(userStats); + } catch (error) { + console.error("Failed to load data:", error); + } finally { + setLoading(false); + } + }; + + const handleAddActivity = async () => { + if (!db || !currentSession) return; + try { + // Generate random activity + const activityTypes = [ + "running", + "cycling", + "swimming", + "walking", + "gym", + "other", + ]; + const randomActivity: Activity = { + type: activityTypes[Math.floor(Math.random() * activityTypes.length)], + duration: Math.floor(Math.random() * 120) + 15, // 15-135 minutes + distance: Math.round((Math.random() * 20 + 1) * 10) / 10, // 1-21 km, 1 decimal + calories: Math.floor(Math.random() * 800) + 100, // 100-900 calories + date: new Date().toISOString().split("T")[0], + notes: `Random activity #${Date.now()}`, + user_id: currentSession.userId, + }; + + await db.addActivity(randomActivity); + // Optionally, you can send changes to SQLite Cloud if enabled + // await db.sqliteSyncSendChanges(); + + await loadAllData(); + triggerRefresh(); + } catch (error) { + console.error("Failed to add activity:", error); + alert("Failed to add activity. Please try again."); + } + }; + + const handleAddWorkout = async () => { + if (!db || !selectedWorkoutUser || !coachMode) return; + try { + // Generate random workout + const workoutTypes = [ + "strength", + "cardio", + "flexibility", + "endurance", + "HIIT", + ]; + const workoutNames = [ + "Morning Routine", + "Power Session", + "Quick Blast", + "Intense Training", + "Focus Session", + ]; + + const randomWorkout: Workout = { + name: workoutNames[Math.floor(Math.random() * workoutNames.length)], + type: workoutTypes[Math.floor(Math.random() * workoutTypes.length)], + duration: Math.floor(Math.random() * 60) + 15, // 15-75 minutes + date: new Date().toISOString().split("T")[0], + user_id: selectedWorkoutUser, + }; + + await db.addWorkout(randomWorkout); + // Optionally, you can send changes to SQLite Cloud if enabled + // await db.sqliteSyncSendChanges(); + + await loadAllData(); + triggerRefresh(); + } catch (error) { + console.error("Failed to add workout:", error); + alert("Failed to add workout. Please try again."); + } + }; + + const handleCompleteWorkout = async (id: string) => { + if (!db) return; + try { + await db.completeWorkout(id); + await loadAllData(); + } catch (error) { + console.error("Failed to complete workout:", error); + alert("Failed to complete workout. Please try again."); + } + }; + + const handleDeleteActivity = async (activityId: string) => { + if (!db) return; + try { + await db.deleteActivity(activityId); + await loadAllData(); + triggerRefresh(); + } catch (error) { + console.error("Failed to delete activity:", error); + alert("Failed to delete activity. Please try again."); + } + }; + + const handleDeleteWorkout = async (workoutId: string) => { + if (!db) return; + try { + await db.deleteWorkout(workoutId); + await loadAllData(); + triggerRefresh(); + } catch (error) { + console.error("Failed to delete workout:", error); + alert("Failed to delete workout. Please try again."); + } + }; + + const handleUserCreate = async (userData: User) => { + if (!db) return; + try { + const newUser = await db.createUser(userData); + // Optionally, you can send changes to SQLite Cloud if enabled + // await db.sqliteSyncSendChanges(); + + setCurrentUser(newUser); + // Update the users list + await loadUsers(); + triggerRefresh(); + // Auto-login the newly created user + handleLogin({ + userId: newUser.id, + name: newUser.name, + }); + } catch (error) { + console.error("Failed to create user:", error); + if ( + error instanceof Error && + error.message.includes("UNIQUE constraint failed") + ) { + alert( + "A user with this name already exists. Please choose a different name." + ); + } else { + alert("Failed to create user. Please try again."); + } + throw error; + } + }; + + const handleUserRestore = async (userData: User) => { + if (!db) return; + try { + // First, try to create the user in the local database + const restoredUser = await db.createUser(userData); + setCurrentUser(restoredUser); + // Update the users list + await loadUsers(); + triggerRefresh(); + // Auto-login the restored user + handleLogin({ + userId: restoredUser.id, + name: restoredUser.name, + }); + } catch (error) { + console.error("Failed to restore user:", error); + if ( + error instanceof Error && + error.message.includes("UNIQUE constraint failed") + ) { + alert( + "A user with this name already exists locally. Please choose a different user." + ); + } else { + alert("Failed to restore user. Please try again."); + } + throw error; + } + }; + + const handleLogin = (session: UserSession) => { + setCurrentSession(session); + localStorage.setItem("userSession", JSON.stringify(session)); + loadUser(session.userId); + loadAllData(); + // Populate workout user selector with all users when coach logs in + if (isCoachMode(session)) { + setSelectedWorkoutUser(users.length > 0 ? users[0].id : ""); + } else { + setSelectedWorkoutUser(""); + } + }; + + const handleLogout = async () => { + setCurrentSession(null); + setCurrentUser(null); + localStorage.removeItem("userSession"); + // Clear the data when logged out + setActivities([]); + setWorkouts([]); + setStats(null); + // Clear workout user selector + setSelectedWorkoutUser(""); + // Update user list and trigger refresh to reflect database state + await loadUsers(); + triggerRefresh(); + }; + + if (error) { + return ( +
+

Database Error

+

{error}

+
+ ); + } + + if (!isInitialized || loading) { + return ( +
+

Initializing Database...

+

Please wait while we set up your sport tracking database.

+
+ ); + } + + return ( +
+
+

Sport Tracker

+
+ +
+ {!currentSession && ( +
+ +
+ )} + + + +
+ +
+ {/* Welcome message when no user is logged in */} + {!currentSession && ( +
+
+

+ Please create a user and log in to start tracking your + activities and workouts. +
+ Alternatively, you can restore a user from the remote database + on SQLite Cloud. +

+

+ 💡 Tip: The "coach" user has special privileges + to create workouts for all users and to manage users' + activities. +

+
+
+ )} + + {/* Statistics Section - only show when logged in */} + {currentSession && ( +
+

Statistics

+
+ + + + +
+
+ )} + + {/* Activities Section - only show when logged in */} + {currentSession && ( +
+
+

Activities

+ +
+ +
+ {activities.length === 0 ? ( +

+ No activities yet. Add your first activity! +

+ ) : ( + activities + .slice(0, 6) + .map((activity) => ( + + )) + )} +
+
+ )} + + {/* Workouts Section - only show when logged in */} + {currentSession && ( +
+
+

+ Workouts{" "} + {coachMode && ( + 👨‍💼 Coach Mode + )} +

+ {coachMode && ( +
+ + +
+ )} +
+ +
+ {workouts.length === 0 ? ( +

+ No workouts yet. Add your first workout! +

+ ) : ( + workouts + .slice(0, 6) + .map((workout) => ( + + )) + )} +
+
+ )} +
+ +
+ ); +}; + +const App: React.FC = () => { + return ( + + + + ); +}; + +export default App; diff --git a/examples/sport-tracker-app/src/SQLiteSync.ts b/examples/sport-tracker-app/src/SQLiteSync.ts new file mode 100644 index 0000000..27cae56 --- /dev/null +++ b/examples/sport-tracker-app/src/SQLiteSync.ts @@ -0,0 +1,215 @@ +import { Database } from "./db/database"; + +export interface UserSession { + userId: string; + name: string; +} + +export interface TokenData { + name: string; + token: string; + userId: string; + attributes?: Record | null; + expiresAt: string | null; +} + +export class SQLiteSync { + private db: Database; + private static readonly TOKEN_EXPIRY_MINUTES = 10; + private static readonly TOKEN_KEY_PREFIX = "token"; + + constructor(db: Database) { + this.db = db; + } + + + /** + * Sets up SQLite Sync using Access Tokens authentication for a specific user. + */ + async setupWithToken(currentSession: UserSession): Promise { + if (!this.db || !currentSession) { + throw new Error("Database or session not available"); + } + + try { + const { userId, name } = currentSession; + + // Get valid token (from session or fetch new one) + const token = await this.getValidToken(userId, name); + + // Authenticate SQLite Sync with the token + await this.db.sqliteSyncSetToken(token); + console.log("SQLite Sync setup completed with token for user:", name); + } catch (error) { + console.error("Failed to setup SQLite Sync with token:", error); + throw error; + } + } + + /** + * Send and receive changes to/from the database on SQLite Cloud. + * + * Sync happens in these steps: + * 1. Send local changes to the server (`cloudsync_network_send_changes()`). + * 2. Check for changes from the server (`cloudsync_network_check_changes()`). + * 3. Waits a moment for the server to prepare changes if any. + * 4. Check again for changes from the server and apply them to the local database (`cloudsync_network_check_changes()`). + */ + async sync() { + if (!this.db) { + throw new Error("Database not available"); + } + + try { + await this.db.sqliteSyncNetworkSync(); + } catch (e) { + console.error("Error checking SQLite Sync changes:", e); + } + } + + /** + * Logs out the user from SQLite Sync. + * This operation will wipe oute the SQLite Sync data and ALL the user's synced data. + * The method checks before that there a no changes made on the local database not yet sent to the server. + */ + async logout(): Promise { + if (!this.db) { + throw new Error("Database not available"); + } + + // Check for unsent changes before logout. + const hasUnsentChanges = await this.db.sqliteSyncHasUnsentChanges(); + if (hasUnsentChanges) { + console.warn("There are unsent changes, consider syncing before logout"); + throw new Error( + "There are unsent changes. Please sync before logging out." + ); + } + + await this.db.sqliteSyncLogout(); + + console.log("SQLite Sync - Logout and cleanup completed"); + } + + /** + * Gets a valid token for the user, either from localStorage or by fetching a new one + */ + private async getValidToken(userId: string, name: string): Promise { + const storedTokenData = localStorage.getItem(SQLiteSync.TOKEN_KEY_PREFIX); + + let token = null; + let tokenExpiry = null; + + if (storedTokenData) { + try { + const parsed: TokenData = JSON.parse(storedTokenData); + token = parsed.token; + tokenExpiry = parsed.expiresAt ? new Date(parsed.expiresAt) : null; + console.log("SQLite Sync: Found stored token in localStorage"); + } catch (e) { + console.error("SQLite Sync: Failed to parse stored token:", e); + } + } else { + console.log("SQLite Sync: No token found in localStorage"); + } + + const now = new Date(); + if (!token) { + console.log("SQLite Sync: No token available, requesting new one from API"); + const tokenData = await this.fetchNewToken(userId, name); + localStorage.setItem( + SQLiteSync.TOKEN_KEY_PREFIX, + JSON.stringify(tokenData) + ); + token = tokenData.token; + console.log("SQLite Sync: New token obtained and stored in localStorage"); + } else if (tokenExpiry && tokenExpiry <= now) { + console.warn("SQLite Sync: Token expired, requesting new one from API"); + const tokenData = await this.fetchNewToken(userId, name); + localStorage.setItem( + SQLiteSync.TOKEN_KEY_PREFIX, + JSON.stringify(tokenData) + ); + token = tokenData.token; + console.log("SQLite Sync: New token obtained and stored in localStorage"); + } else { + console.log("SQLite Sync: Using valid token from localStorage"); + } + + return token; + } + + /** + * Fetches a new token from the SQLite Cloud API. + * + * !! NOTE - DEMOSTRATION PURPOSES ONLY !! + * This operation should only be done in trusted environments (server-side). + */ + private async fetchNewToken( + userId: string, + name: string + ): Promise> { + const response = await fetch( + `${import.meta.env.VITE_SQLITECLOUD_API_URL}/v2/tokens`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${import.meta.env.VITE_SQLITECLOUD_API_KEY}`, + }, + body: JSON.stringify({ + userId, + name, + expiresAt: new Date( + Date.now() + SQLiteSync.TOKEN_EXPIRY_MINUTES * 60 * 1000 + ).toISOString(), + }), + } + ); + + if (!response.ok) { + throw new Error(`Failed to get token: ${response.status}`); + } + + const result = await response.json(); + return result.data; + } + + /** + * Checks if a valid token exists in localStorage + */ + static hasValidToken(): boolean { + const storedTokenData = localStorage.getItem(SQLiteSync.TOKEN_KEY_PREFIX); + + if (!storedTokenData) { + console.log("SQLite Sync: No token data found in localStorage"); + return false; + } + + try { + const parsed: TokenData = JSON.parse(storedTokenData); + + // Check if token exists + if (!parsed.token) { + console.log("SQLite Sync: Token data exists but no token found"); + return false; + } + + // Check if token is expired + if (parsed.expiresAt) { + const tokenExpiry = new Date(parsed.expiresAt); + const now = new Date(); + if (tokenExpiry <= now) { + console.log("SQLite Sync: Token found but expired"); + return false; + } + } + + console.log("SQLite Sync: Valid token found in localStorage"); + return true; + } catch (e) { + console.error("SQLite Sync: Failed to parse stored token:", e); + return false; + } + } +} diff --git a/examples/sport-tracker-app/src/components/Activities/ActivityCard.tsx b/examples/sport-tracker-app/src/components/Activities/ActivityCard.tsx new file mode 100644 index 0000000..4e87636 --- /dev/null +++ b/examples/sport-tracker-app/src/components/Activities/ActivityCard.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import type { Activity } from "../../db/database"; + +interface ActivityCardProps { + activity: Activity; + onDelete?: (id: string) => void; + canDelete?: boolean; + userName?: string; +} + +const ActivityCard: React.FC = ({ + activity, + onDelete, + canDelete = false, + userName, +}) => { + return ( +
+
+

+ {activity.type.charAt(0).toUpperCase() + activity.type.slice(1)} +

+
+ + {new Date(activity.date).toLocaleDateString()} + + {canDelete && onDelete && activity.id && ( + + )} +
+
+ {userName &&
👤 {userName}
} +
+ {activity.duration} min + {activity.distance && ( + {activity.distance} km + )} + {activity.calories && ( + {activity.calories} cal + )} +
+ {activity.notes &&

{activity.notes}

} +
+ ); +}; + +export default ActivityCard; diff --git a/examples/sport-tracker-app/src/components/AppFooter.tsx b/examples/sport-tracker-app/src/components/AppFooter.tsx new file mode 100644 index 0000000..96787fa --- /dev/null +++ b/examples/sport-tracker-app/src/components/AppFooter.tsx @@ -0,0 +1,64 @@ +import React from "react"; + +interface AppFooterProps { + sqliteSyncVersion: string; + sqliteVersion: string; +} + +const AppFooter: React.FC = ({ + sqliteSyncVersion, + sqliteVersion, +}) => { + return ( +
+
+
+
+ 🚵 + Sport Tracker +
+
Powered by SQLite AI
+
+ +
+
+ {sqliteSyncVersion && ( +
+ SQLite Sync + v{sqliteSyncVersion} +
+ )} + {sqliteVersion && ( +
+ SQLite + v{sqliteVersion} +
+ )} +
+
+ + +
+
+ ); +}; + +export default AppFooter; diff --git a/examples/sport-tracker-app/src/components/DatabaseStatus.tsx b/examples/sport-tracker-app/src/components/DatabaseStatus.tsx new file mode 100644 index 0000000..5d1e5a0 --- /dev/null +++ b/examples/sport-tracker-app/src/components/DatabaseStatus.tsx @@ -0,0 +1,68 @@ +import React, { useEffect, useState } from "react"; +import type { DatabaseCounts } from "../db/database"; + +interface DatabaseStatusProps { + onCountsLoad: () => Promise; + refreshTrigger?: number; // Optional prop to trigger refresh +} + +const DatabaseStatus: React.FC = ({ + onCountsLoad, + refreshTrigger, +}) => { + const [counts, setCounts] = useState({ + users: 0, + activities: 0, + workouts: 0, + totalUsers: 0, + totalActivities: 0, + totalWorkouts: 0, + }); + const [loading, setLoading] = useState(true); + + const loadCounts = async () => { + try { + setLoading(true); + const newCounts = await onCountsLoad(); + setCounts(newCounts); + } catch (error) { + console.error("Failed to load database counts:", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadCounts(); + }, [refreshTrigger]); + + if (loading) { + return
On local Database: Loading...
; + } + + const hasWarnings = + counts.totalUsers > counts.users || + counts.totalActivities > counts.activities || + counts.totalWorkouts > counts.workouts; + + return ( +
+ On local Database:  + {hasWarnings && ⚠️ } + Users: {counts.users} + {counts.totalUsers > counts.users && ( + (Total: {counts.totalUsers}) + )}{" "} + | Activities: {counts.activities} + {counts.totalActivities > counts.activities && ( + (Total: {counts.totalActivities}) + )}{" "} + | Workouts: {counts.workouts} + {counts.totalWorkouts > counts.workouts && ( + (Total: {counts.totalWorkouts}) + )} +
+ ); +}; + +export default DatabaseStatus; diff --git a/examples/sport-tracker-app/src/components/Statistics/StatCard.tsx b/examples/sport-tracker-app/src/components/Statistics/StatCard.tsx new file mode 100644 index 0000000..45484be --- /dev/null +++ b/examples/sport-tracker-app/src/components/Statistics/StatCard.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +interface StatCardProps { + title: string; + value: string | number; + className?: string; +} + +const StatCard: React.FC = ({ + title, + value, + className = "", +}) => { + return ( +
+

{title}

+

{value}

+
+ ); +}; + +export default StatCard; diff --git a/examples/sport-tracker-app/src/components/UserCreation.tsx b/examples/sport-tracker-app/src/components/UserCreation.tsx new file mode 100644 index 0000000..05defb1 --- /dev/null +++ b/examples/sport-tracker-app/src/components/UserCreation.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState } from "react"; +import type { User } from "../db/database"; + +interface UserCreationProps { + onUserCreate: (user: User) => void; + onUserRestore: (user: User) => void; + currentUser: User | null; +} + +/** + * !! NOTE - DEMOSTRATION PURPOSES ONLY !! + * Never perform requests using the SQLite Cloud API Key from the client-side. + * This is just an example to dimostrate the restore of a user and its data + * from the synced SQLite Cloud database. + */ +const fetchRemoteUsers = async (): Promise => { + const response = await fetch( + `${import.meta.env.VITE_SQLITECLOUD_API_URL}/v2/weblite/sql`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${import.meta.env.VITE_SQLITECLOUD_API_KEY}`, + }, + body: JSON.stringify({ + sql: "SELECT id, name FROM users;", + database: import.meta.env.VITE_SQLITECLOUD_DATABASE || "", + }), + } + ); + + if (!response.ok) { + throw new Error(`Failed to fetch users: ${response.status}`); + } + + const result = await response.json(); + return result.data; +}; + +const UserCreation: React.FC = ({ + onUserCreate, + onUserRestore, + currentUser, +}) => { + const [userName, setUserName] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [remoteUsers, setRemoteUsers] = useState([]); + const [selectedRemoteUser, setSelectedRemoteUser] = useState(""); + const [isLoadingRemoteUsers, setIsLoadingRemoteUsers] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + + // Load remote users when component mounts + useEffect(() => { + const loadRemoteUsers = async () => { + setIsLoadingRemoteUsers(true); + try { + const users = await fetchRemoteUsers(); + setRemoteUsers(users); + } catch (error) { + console.error("Failed to load remote users:", error); + } finally { + setIsLoadingRemoteUsers(false); + } + }; + + loadRemoteUsers(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!userName.trim()) return; + + setIsCreating(true); + try { + onUserCreate({ id: "", name: userName.trim() } as User); + setUserName(""); + } catch (error) { + console.error("Failed to create user:", error); + alert("Failed to create user. Please try again."); + } finally { + setIsCreating(false); + } + }; + + const handleRestore = async () => { + if (!selectedRemoteUser) return; + + const userToRestore = remoteUsers.find( + (user) => user.id === selectedRemoteUser + ); + if (!userToRestore) return; + + setIsRestoring(true); + try { + // Restore user by adding to local database and logging in + onUserRestore(userToRestore); + setSelectedRemoteUser(""); + } catch (error) { + console.error("Failed to restore user:", error); + alert("Failed to restore user. Please try again."); + } finally { + setIsRestoring(false); + } + }; + + if (currentUser && currentUser.name) { + return null; + } + + return ( +
+
+ {/* Create User Section */} +
+
+ setUserName(e.target.value)} + placeholder="Enter your name" + className="user-input" + disabled={isCreating} + required + /> + +
+
+ + {/* Restore User Section */} +
+
+ + +
+
+
+
+ ); +}; + +export default UserCreation; diff --git a/examples/sport-tracker-app/src/components/UserLogin.tsx b/examples/sport-tracker-app/src/components/UserLogin.tsx new file mode 100644 index 0000000..e03208d --- /dev/null +++ b/examples/sport-tracker-app/src/components/UserLogin.tsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect, useCallback } from "react"; +import type { User } from "../db/database"; +import { useDatabase } from "../context/DatabaseContext"; +import { SQLiteSync } from "../SQLiteSync"; + +interface UserSession { + userId: string; + name: string; +} + +interface UserLoginProps { + users: User[]; + currentSession: UserSession | null; + onLogin: (session: UserSession) => void; + onLogout: () => Promise; + onUsersLoad: () => void; + onRefresh: () => void; +} + +const UserLogin: React.FC = ({ + users, + currentSession, + onLogin, + onLogout, + onUsersLoad, + onRefresh, +}) => { + const { db } = useDatabase(); + const [selectedUserId, setSelectedUserId] = useState(""); + const [sqliteSyncEnabled, setSqliteSyncEnabled] = useState(false); + const [sqliteSync, setSqliteSync] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + useEffect(() => { + onUsersLoad(); + }, [onUsersLoad]); + + useEffect(() => { + if (db) { + setSqliteSync(new SQLiteSync(db)); + } + }, [db]); + + // Initialize sync state when user logs in + useEffect(() => { + // No user logged in - disable sync + if (!currentSession) { + setSqliteSyncEnabled(false); + return; + } + + // Check if there's a valid token available + const hasValidToken = SQLiteSync.hasValidToken(); + if (hasValidToken) { + console.log( + "Valid token found in localStorage for user:", + currentSession.name + ); + } else { + console.log( + "No valid token found in localStorage for user:", + currentSession.name + ); + } + + setSqliteSyncEnabled(false); + }, [currentSession]); + + // Handle SQLite Sync enable/disable toggle + const handleSyncToggle = async (checked: boolean) => { + setSqliteSyncEnabled(checked); + + if (checked) { + console.log("SQLite Sync enabled for user:", currentSession?.name); + } else { + console.log("SQLite Sync disabled"); + } + }; + + const handleLogin = () => { + const selectedUser = users.find((user) => user.id === selectedUserId); + if (selectedUser) { + const session: UserSession = { + userId: selectedUser.id, + name: selectedUser.name, + }; + onLogin(session); + } + }; + + const formatUserDisplay = (user: User) => { + const shortId = user.id ? user.id.slice(0, 6) : "no-id"; + const display = `${user.name} [${shortId}]`; + return display; + }; + + const handleRefreshClick = async () => { + setIsRefreshing(true); + + try { + // If SQLite Sync is enabled, sync with cloud before refreshing + if (sqliteSyncEnabled && sqliteSync && currentSession) { + try { + await sqliteSync.setupWithToken(currentSession); + + console.log("SQLite Sync - Starting sync..."); + await sqliteSync.sync(); + console.log("SQLite Sync - Sync completed successfully"); + } catch (error) { + console.error( + "SQLite Sync - Failed to sync with SQLite Cloud:", + error + ); + console.warn("SQLite Sync: Falling back to local refresh only"); + } + } else { + console.log( + "SQLite Sync disabled - refreshing from local database only" + ); + } + + // Refresh data from database + onRefresh(); + } finally { + setIsRefreshing(false); + } + }; + + const handleLogout = async () => { + setIsLoggingOut(true); + + try { + // If SQLite Sync is enabled, perform complete logout + if (sqliteSyncEnabled && sqliteSync) { + try { + console.log("Performing SQLite Sync logout..."); + await sqliteSync.logout(); + } catch (error) { + console.error("SQLite Sync logout error:", error); + alert("Logout: " + error); + return; + } + } + + // Clear tokens from localStorage + if (currentSession) { + localStorage.clear(); + } + + // Reset SQLite Sync state + setSqliteSyncEnabled(false); + + await onLogout(); + } finally { + setIsLoggingOut(false); + } + }; + + if (currentSession) { + return ( +
+
+ + Logged in as: {currentSession.name} [ + {currentSession.userId + ? currentSession.userId.slice(0, 6) + : "no-id"} + ] + + +
+
+ + +
+
+ ); + } + + return ( +
+ + +
+ ); +}; + +export default UserLogin; diff --git a/examples/sport-tracker-app/src/components/Workouts/WorkoutCard.tsx b/examples/sport-tracker-app/src/components/Workouts/WorkoutCard.tsx new file mode 100644 index 0000000..4ac1266 --- /dev/null +++ b/examples/sport-tracker-app/src/components/Workouts/WorkoutCard.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import type { Workout } from "../../db/database"; + +interface WorkoutCardProps { + workout: Workout; + onComplete: (id: string) => void; + onDelete?: (id: string) => void; + canDelete?: boolean; + userName?: string; +} + +const WorkoutCard: React.FC = ({ + workout, + onComplete, + onDelete, + canDelete = false, + userName, +}) => { + return ( +
+
+

{workout.name}

+
+ {workout.type} + {canDelete && onDelete && workout.id && ( + + )} +
+
+ {userName &&
👤 {userName}
} +
+ {workout.duration} min + + {new Date(workout.date).toLocaleDateString()} + +
+ {!workout.completed ? ( + + ) : ( + ✓ Completed + )} +
+ ); +}; + +export default WorkoutCard; diff --git a/examples/sport-tracker-app/src/context/DatabaseContext.tsx b/examples/sport-tracker-app/src/context/DatabaseContext.tsx new file mode 100644 index 0000000..75293c8 --- /dev/null +++ b/examples/sport-tracker-app/src/context/DatabaseContext.tsx @@ -0,0 +1,56 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; +import { Database } from "../db/database"; + +interface DatabaseContextType { + db: Database | null; + isInitialized: boolean; + error: string | null; +} + +const DatabaseContext = createContext( + undefined +); + +export const DatabaseProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [db, setDb] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(null); + + /** Database Initialization */ + useEffect(() => { + const initializeDatabase = () => { + const database = new Database(); + database + .init() + .then(() => { + console.log("Database initialized successfully"); + setDb(database); + setIsInitialized(true); + }) + .catch((err) => { + console.error("Database initialization failed:", err); + setError( + err instanceof Error ? err.message : "Failed to initialize database" + ); + }); + }; + + initializeDatabase(); + }, []); + + return ( + + {children} + + ); +}; + +export const useDatabase = (): DatabaseContextType => { + const context = useContext(DatabaseContext); + if (context === undefined) { + throw new Error("useDatabase must be used within a DatabaseProvider"); + } + return context; +}; diff --git a/examples/sport-tracker-app/src/db/database.ts b/examples/sport-tracker-app/src/db/database.ts new file mode 100644 index 0000000..ca71e17 --- /dev/null +++ b/examples/sport-tracker-app/src/db/database.ts @@ -0,0 +1,185 @@ +export interface Activity { + id?: string; + type: string; + duration: number; + distance?: number; + calories?: number; + date: string; + notes?: string; + user_id?: string; +} + +export interface Workout { + id?: string; + name: string; + type: string; + duration: number; + date: string; + completed?: boolean; + user_id?: string; +} + +export interface User { + id: string; + name: string; +} + +export interface UserStats { + id: number; + total_activities: number; + total_duration: number; + total_distance: number; + total_calories: number; + last_updated: string; +} + +export interface DatabaseCounts { + users: number; + activities: number; + workouts: number; + totalUsers: number; + totalActivities: number; + totalWorkouts: number; +} + +export class Database { + private worker: Worker; + private messageId = 0; + private pendingMessages = new Map< + number, + { resolve: Function; reject: Function } + >(); + + constructor() { + this.worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + }); + + // Response message from the worker + this.worker.onmessage = (e) => { + const { result, error, id } = e.data; + const pending = this.pendingMessages.get(id); + + if (pending) { + this.pendingMessages.delete(id); + if (error) { + pending.reject(new Error(error)); + } else { + pending.resolve(result); + } + } + }; + } + + private sendMessage(type: string, data?: any): Promise { + const id = ++this.messageId; + + return new Promise((resolve, reject) => { + this.pendingMessages.set(id, { resolve, reject }); + this.worker.postMessage({ type, data, id }); + }); + } + + async init(): Promise { + await this.sendMessage("init"); + await this.sendMessage("sqliteSyncVersion"); + } + + async sqliteSyncVersion(): Promise { + return this.sendMessage("sqliteSyncVersion"); + } + + async sqliteVersion(): Promise { + return this.sendMessage("sqliteVersion"); + } + + async sqliteSyncSetToken(token: string): Promise { + return this.sendMessage("sqliteSyncSetToken", token); + } + + + async sqliteSyncNetworkSync(): Promise { + return this.sendMessage("sqliteSyncNetworkSync"); + } + + async sqliteSyncSendChanges(): Promise { + return this.sendMessage("sqliteSyncSendChanges"); + } + + async sqliteSyncLogout(): Promise { + return this.sendMessage("sqliteSyncLogout"); + } + + async sqliteSyncHasUnsentChanges(): Promise { + return this.sendMessage("sqliteSyncHasUnsentChanges"); + } + + async addActivity(activity: Activity): Promise { + return this.sendMessage("addActivity", activity); + } + + async getActivities(userId?: string, isCoach?: boolean): Promise { + return this.sendMessage("getActivities", { + user_id: userId, + is_coach: isCoach, + }); + } + + async addWorkout(workout: Workout): Promise { + return this.sendMessage("addWorkout", workout); + } + + async getWorkouts(userId?: string, isCoach?: boolean): Promise { + return this.sendMessage("getWorkouts", { + user_id: userId, + is_coach: isCoach, + }); + } + + async completeWorkout(id: string): Promise { + return this.sendMessage("completeWorkout", id); + } + + async deleteActivity(id: string): Promise { + return this.sendMessage("deleteActivity", { id }); + } + + async deleteWorkout(id: string): Promise { + return this.sendMessage("deleteWorkout", { id }); + } + + async getStats(userId?: string, isCoach?: boolean): Promise { + return this.sendMessage("getStats", { + user_id: userId, + is_coach: isCoach, + }); + } + + async createUser(user: User): Promise { + return this.sendMessage("createUser", user); + } + + async getUsers(): Promise { + return this.sendMessage("getUsers"); + } + + async getUserById(id: string): Promise { + return this.sendMessage("getUserById", { id }); + } + + async getCounts(userId?: string, isCoach?: boolean): Promise { + return this.sendMessage("getCounts", { + user_id: userId, + is_coach: isCoach, + }); + } + + async close(): Promise { + // Clear any pending messages + this.pendingMessages.clear(); + + // Terminate the worker + this.worker.terminate(); + console.log("Database connection closed"); + } +} diff --git a/examples/sport-tracker-app/src/db/databaseOperations.ts b/examples/sport-tracker-app/src/db/databaseOperations.ts new file mode 100644 index 0000000..5bae19a --- /dev/null +++ b/examples/sport-tracker-app/src/db/databaseOperations.ts @@ -0,0 +1,315 @@ +// Regular database operations +// These operations handle local SQLite database functionality + +function generateUUID() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c == "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export const getDatabaseOperations = (db: any) => ({ + sqliteVersion() { + let version = ""; + db.exec({ + sql: "SELECT sqlite_version();", + callback: (row: any) => { + version = row[0]; + console.log("SQLite - Version:", version); + }, + }); + return version; + }, + + addActivity(data: any) { + const { type, duration, distance, calories, date, notes, user_id } = data; + const id = generateUUID(); + + db.exec({ + sql: `INSERT INTO activities (id, type, duration, distance, calories, date, notes, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + bind: [ + id, + type, + duration, + distance || null, + calories || null, + date, + notes || null, + user_id, + ], + }); + + return { id, ...data }; + }, + + getActivities(data?: any) { + const { user_id, is_coach } = data || {}; + const activities: any[] = []; + + let sql = + "SELECT id, type, duration, distance, calories, date, notes, user_id FROM activities"; + let bind: any[] = []; + + if (user_id && !is_coach) { + sql += " WHERE user_id = ?"; + bind.push(user_id); + } + + sql += " ORDER BY date DESC"; + + db.exec({ + sql, + bind: bind.length > 0 ? bind : undefined, + callback: (row: any) => { + activities.push({ + id: row[0], + type: row[1], + duration: row[2], + distance: row[3], + calories: row[4], + date: row[5], + notes: row[6], + user_id: row[7], + }); + }, + }); + return activities; + }, + + addWorkout(data: any) { + const { name, type, duration, exercises, date, user_id } = data; + const id = generateUUID(); + + db.exec({ + sql: `INSERT INTO workouts (id, name, type, duration, exercises, date, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + bind: [ + id, + name, + type, + duration, + JSON.stringify(exercises), + date, + user_id, + ], + }); + return { id, ...data }; + }, + + getWorkouts(data?: any) { + const { user_id, is_coach } = data || {}; + const workouts: any[] = []; + + let sql = + "SELECT id, name, type, duration, exercises, date, completed, user_id FROM workouts"; + let bind: any[] = []; + + if (user_id && !is_coach) { + sql += " WHERE user_id = ?"; + bind.push(user_id); + } + + sql += " ORDER BY date DESC"; + + db.exec({ + sql, + bind: bind.length > 0 ? bind : undefined, + callback: (row: any) => { + workouts.push({ + id: row[0], + name: row[1], + type: row[2], + duration: row[3], + exercises: JSON.parse(row[4] || "[]"), + date: row[5], + completed: row[6], + user_id: row[7], + }); + }, + }); + return workouts; + }, + + completeWorkout(id: string) { + return db.exec({ + sql: "UPDATE workouts SET completed = 1 WHERE id = ?", + bind: [id], + }); + }, + + deleteActivity(data: any) { + const { id } = data; + try { + db.exec({ + sql: "DELETE FROM activities WHERE id = ?", + bind: [id], + }); + return { success: true }; + } catch (error) { + throw new Error(`Failed to delete activity: ${error}`); + } + }, + + deleteWorkout(data: any) { + const { id } = data; + try { + db.exec({ + sql: "DELETE FROM workouts WHERE id = ?", + bind: [id], + }); + return { success: true }; + } catch (error) { + throw new Error(`Failed to delete workout: ${error}`); + } + }, + + getStats(data?: any) { + const { user_id, is_coach } = data || {}; + let stats = { + id: 1, + total_activities: 0, + total_duration: 0, + total_distance: 0, + total_calories: 0, + last_updated: new Date().toISOString(), + }; + + // Calculate stats from activities table + let sql = `SELECT + COUNT(*) as count, + COALESCE(SUM(duration), 0) as total_duration, + COALESCE(SUM(distance), 0) as total_distance, + COALESCE(SUM(calories), 0) as total_calories + FROM activities`; + + let bind: any[] = []; + + // Filter by user if not coach and user_id is provided + if (user_id && !is_coach) { + sql += ` WHERE user_id = ?`; + bind = [user_id]; + } + + db.exec({ + sql, + bind: bind.length > 0 ? bind : undefined, + callback: (row: any) => { + stats = { + id: 1, + total_activities: row[0], + total_duration: row[1], + total_distance: row[2], + total_calories: row[3], + last_updated: new Date().toISOString(), + }; + }, + }); + + return stats; + }, + + createUser(data: any) { + const { id, name } = data; + const userId = id || generateUUID(); + + try { + db.exec({ + sql: "INSERT INTO users (id, name) VALUES (?, ?)", + bind: [userId, name], + }); + return { id: userId, name }; + } catch (error) { + throw new Error(`Failed to create user: ${error}`); + } + }, + + getUsers() { + const users: any[] = []; + db.exec({ + sql: "SELECT id, name FROM users ORDER BY name", + callback: (row: any) => { + users.push({ + id: row[0], + name: row[1], + }); + }, + }); + return users; + }, + + getUserById(data: any) { + const { id } = data; + let user = null; + db.exec({ + sql: "SELECT id, name FROM users WHERE id = ?", + bind: [id], + callback: (row: any) => { + user = { + id: row[0], + name: row[1], + }; + }, + }); + return user; + }, + + getCounts(data?: any) { + const { user_id, is_coach } = data || {}; + const counts = { + users: 0, + activities: 0, + workouts: 0, + totalActivities: 0, + totalWorkouts: 0, + totalUsers: 0, + }; + + // Get total counts (always show total for comparison) + db.exec({ + sql: "SELECT COUNT(*) FROM users WHERE name != ?", + bind: ["coach"], + callback: (row: any) => (counts.totalUsers = row[0]), + }); + + db.exec({ + sql: "SELECT COUNT(*) FROM activities", + callback: (row: any) => (counts.totalActivities = row[0]), + }); + + db.exec({ + sql: "SELECT COUNT(*) FROM workouts", + callback: (row: any) => (counts.totalWorkouts = row[0]), + }); + + // Get user-specific or all counts depending on role + if (user_id && !is_coach) { + // Regular user - count only their data + db.exec({ + sql: "SELECT COUNT(*) FROM users WHERE name != ?", + bind: ["coach"], + callback: (row: any) => (counts.users = row[0]), + }); + + db.exec({ + sql: "SELECT COUNT(*) FROM activities WHERE user_id = ?", + bind: [user_id], + callback: (row: any) => (counts.activities = row[0]), + }); + + db.exec({ + sql: "SELECT COUNT(*) FROM workouts WHERE user_id = ?", + bind: [user_id], + callback: (row: any) => (counts.workouts = row[0]), + }); + } else { + // Coach or no user - show all data + counts.users = counts.totalUsers; + counts.activities = counts.totalActivities; + counts.workouts = counts.totalWorkouts; + } + + return counts; + }, +}); diff --git a/examples/sport-tracker-app/src/db/sqliteSyncOperations.ts b/examples/sport-tracker-app/src/db/sqliteSyncOperations.ts new file mode 100644 index 0000000..97329db --- /dev/null +++ b/examples/sport-tracker-app/src/db/sqliteSyncOperations.ts @@ -0,0 +1,101 @@ +// SQLite Sync specific operations +// These operations handle SQLite Sync functionality + +export const getSqliteSyncOperations = (db: any) => ({ + sqliteSyncVersion() { + let version = ""; + db.exec({ + sql: "SELECT cloudsync_version();", + callback: (row: any) => { + version = row[0]; + console.log("SQLite Sync - Version:", version); + }, + }); + return version; + }, + + /** Authorize SQLite Sync with the user's Access Token. */ + sqliteSyncSetToken(token: string) { + db.exec(`SELECT cloudsync_network_set_token('${token}')`); + console.log("SQLite Sync - Token set", token); + }, + + + /** + * Syncs the local SQLite database with the remote SQLite Cloud database. + * This will push changes to the cloud and check for changes from the cloud. + * The first attempt may not find anything to apply, but subsequent attempts + * will find changes if they exist. + */ + sqliteSyncNetworkSync() { + console.log("SQLite Sync - Starting sync..."); + db.exec("SELECT cloudsync_network_sync(1000, 2);"); + console.log("SQLite Sync - Sync completed"); + }, + + /** + * Sends local changes to the SQLite Cloud database. + */ + sqliteSyncSendChanges() { + console.log( + "SQLite Sync - Sending changes to your the SQLite Cloud node..." + ); + db.exec("SELECT cloudsync_network_send_changes();"); + console.log("SQLite Sync - Changes sent"); + }, + + /** + * Logs out the user from SQLite Sync. + */ + sqliteSyncLogout() { + db.exec("SELECT cloudsync_network_logout();"); + console.log("SQLite Sync - Logged out"); + }, + + /** + * Checks if there are unsent changes in the local SQLite database. + */ + sqliteSyncHasUnsentChanges() { + let unsynchedChanges; + + db.exec({ + sql: "SELECT cloudsync_network_has_unsent_changes();", + callback: (row: any) => { + unsynchedChanges = row[0] === 1; + }, + }); + + console.log("SQLite Sync - Unsynched changes:", unsynchedChanges); + + if (unsynchedChanges === undefined) { + throw new Error("SQLite Sync - Failed to check unsent changes"); + } + + return unsynchedChanges; + }, +}); + +/** + * Initialize SQLite Sync. + */ +export const initSQLiteSync = (db: any) => { + if (!db) { + throw new Error("Database not initialized"); + } + + // Initialize SQLite Sync + db.exec(`SELECT cloudsync_init('users');`); + db.exec(`SELECT cloudsync_init('activities');`); + db.exec(`SELECT cloudsync_init('workouts');`); + // ...or initialize all tables at once + // db.exec('SELECT cloudsync_init("*");'); + + // Initialize SQLite Sync with the SQLite Cloud Connection String. + // On the SQLite Cloud Dashboard, enable OffSync (SQLite Sync) + // on the remote database and copy the Connection String. + db.exec( + `SELECT cloudsync_network_init('${ + import.meta.env.VITE_SQLITECLOUD_CONNECTION_STRING + }')` + ); +}; diff --git a/examples/sport-tracker-app/src/db/worker.ts b/examples/sport-tracker-app/src/db/worker.ts new file mode 100644 index 0000000..73b09f3 --- /dev/null +++ b/examples/sport-tracker-app/src/db/worker.ts @@ -0,0 +1,76 @@ +import sqlite3InitModule from "@sqliteai/sqlite-sync-wasm"; +import schemaSQL from "../../sport-tracker-schema.sql?raw"; +import { getDatabaseOperations as getDatabaseOperations } from "./databaseOperations"; +import { + getSqliteSyncOperations, + initSQLiteSync, +} from "./sqliteSyncOperations"; + +let db: any = null; +let operations: any = null; + +/** + * Initializes the SQLite database and sets up SQLite Sync operations. + */ +const initDB = async () => { + const sqlite3 = await sqlite3InitModule(); + if ("opfs" in sqlite3) { + // Database is persisted in OPFS but performance is much lower + // See more: https://sqlite.org/wasm/doc/trunk/persistence.md + db = new sqlite3.oo1.OpfsDb("/sqlite-sync-db.sqlite3", "c"); + console.log( + "OPFS is available, created persisted database at", + db.filename + ); + } else { + db = new sqlite3.oo1.DB("/sqlite-sync-db.sqlite3", "c"); + console.log( + "OPFS is not available, created transient database", + db.filename + ); + } + + console.log("Create db schema..."); + db.exec(schemaSQL); + + // For simplicity, tables are already initialized for SQLitte Sync + console.log("SQLite Sync - Initializing..."); + try { + initSQLiteSync(db); + console.log("SQLite Sync - Initialized successfully"); + } catch (error) { + console.error("SQLite Sync - Initialization failed:", error); + } + + // Initialize operations with db instance + const sqliteSyncOps = getSqliteSyncOperations(db); + const databaseOps = getDatabaseOperations(db); + operations = { ...sqliteSyncOps, ...databaseOps }; +}; + +self.onmessage = async (e) => { + const { type, data, id } = e.data; + + try { + if (type === "init") { + console.log("Initializing database on worker..."); + await initDB(); + self.postMessage({ type: "init", success: true, id }); + return; + } + + if (!db) { + throw new Error("Database not initialized"); + } + + if (!operations) { + throw new Error("Operations not initialized"); + } + + const result = operations[type as keyof typeof operations](data); + self.postMessage({ type, result, id }); + } catch (error) { + console.error(`Operation ${type} failed:`, error); + self.postMessage({ type, error: (error as Error).message, id }); + } +}; diff --git a/examples/sport-tracker-app/src/main.tsx b/examples/sport-tracker-app/src/main.tsx new file mode 100644 index 0000000..eaad244 --- /dev/null +++ b/examples/sport-tracker-app/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/examples/sport-tracker-app/src/style.css b/examples/sport-tracker-app/src/style.css new file mode 100644 index 0000000..b345260 --- /dev/null +++ b/examples/sport-tracker-app/src/style.css @@ -0,0 +1,885 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +#root { + max-width: 1200px; + margin: 0 auto; + padding: 1rem; +} + +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app-main { + flex: 1; +} + +.app-header { + padding: 2rem 1rem; + border-radius: 8px; + margin-bottom: 2rem; + text-align: center; +} + +.app-header h1 { + margin: 0; + font-size: 2.5rem; + color: #646cff; +} + +.user-management-section { + margin-bottom: 2rem; + /* padding-bottom: 2rem; */ + border-bottom: 1px solid #333; +} + +.user-creation, +.user-info { + margin-bottom: 1.5rem; +} + +.user-form { + display: flex; + gap: 0.75rem; + align-items: center; + justify-content: center; + flex-wrap: wrap; +} + +.user-input { + padding: 0.75rem; + border: 1px solid #333; + border-radius: 4px; + background: #2a2a2a; + color: #fff; + font-size: 1rem; + min-width: 200px; + max-width: 300px; +} + +.user-input:focus { + outline: none; + border-color: #646cff; +} + +.user-input::placeholder { + color: #888; +} + +.btn-create-user { + background: #4caf50; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + font-size: 1rem; + transition: background 0.25s; + white-space: nowrap; +} + +.btn-create-user:hover:not(:disabled) { + background: #45a049; +} + +.btn-create-user:disabled { + background: #666; + cursor: not-allowed; +} + +.current-user { + color: #4caf50; + font-weight: 500; + font-size: 1.1rem; + padding: 0.5rem 1rem; + background: rgba(76, 175, 80, 0.1); + border: 1px solid rgba(76, 175, 80, 0.3); + border-radius: 4px; + display: inline-block; +} + +/* User Actions Layout */ +.user-actions { + display: flex; + gap: 2rem; + align-items: flex-start; + flex-wrap: wrap; +} + +.create-user-section, +.restore-user-section { + flex: 1; + min-width: 300px; +} + +.restore-form { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.btn-restore-user { + background: #2196f3; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + font-size: 1rem; + transition: background 0.25s; + white-space: nowrap; +} + +.btn-restore-user:hover:not(:disabled) { + background: #1976d2; +} + +.btn-restore-user:disabled { + background: #666; + cursor: not-allowed; +} + +.login-section { + background: #1a1a1a; + padding: 1rem; + border-radius: 8px; + margin-bottom: 2rem; + border: 1px solid #333; +} + +.user-login { + display: flex; + gap: 0.75rem; + align-items: center; + justify-content: center; + flex-wrap: wrap; +} + +.user-login.logged-in { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; +} + +.user-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.sync-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.sync-checkbox { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.9rem; + cursor: pointer; +} + +.sync-checkbox input { + cursor: pointer; +} + +.user-selector { + padding: 0.75rem; + border: 1px solid #333; + border-radius: 4px; + background: #2a2a2a; + color: #fff; + font-size: 1rem; + min-width: 250px; + max-width: 400px; +} + +.user-selector:focus { + outline: none; + border-color: #646cff; +} + +.btn-login { + background: #646cff; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + font-size: 1rem; + transition: background 0.25s; + white-space: nowrap; +} + +.btn-login:hover:not(:disabled) { + background: #535bf2; +} + +.btn-login:disabled { + background: #666; + cursor: not-allowed; + opacity: 0.6; +} + +.btn-logout { + background: #ff6b6b; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + font-size: 1rem; + transition: background 0.25s; + white-space: nowrap; +} + +.btn-logout:hover:not(:disabled) { + background: #ff5252; +} + +.btn-logout:disabled { + background: #666; + cursor: not-allowed; + opacity: 0.6; +} + +.logged-user { + color: #4caf50; + font-weight: 500; + font-size: 1rem; + padding: 0.5rem 1rem; + background: rgba(76, 175, 80, 0.1); + border: 1px solid rgba(76, 175, 80, 0.3); + border-radius: 4px; +} + +.database-status { + margin-top: 1rem; + padding: 0.5rem 1rem; + text-align: center; + font-size: 0.9rem; + color: #888; + background: rgba(100, 108, 255, 0.05); + border: 1px solid rgba(100, 108, 255, 0.1); + border-radius: 4px; + font-family: monospace; +} + +.database-status.warning { + background: rgba(255, 107, 107, 0.1); + border-color: rgba(255, 107, 107, 0.3); + color: #ff6b6b; +} + +.warning-text { + color: #ff6b6b; + font-weight: 500; +} + +.welcome-message { + text-align: center; + padding: 2rem; +} + +.welcome-message h2 { + color: #646cff; + margin-bottom: 1rem; +} + +.welcome-message p { + color: #888; + margin-bottom: 0.75rem; + line-height: 1.6; +} + +.welcome-message p:last-child { + margin-bottom: 0; +} + +.app-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.section { + padding: 1.5rem; + border-radius: 8px; + border: 1px solid #333; +} + +.section h2 { + margin: 0 0 1rem 0; + color: #646cff; + font-size: 1.5rem; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.workout-controls { + display: flex; + gap: 1rem; + align-items: center; +} + +.coach-badge { + background: #4caf50; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + margin-left: 0.5rem; +} + +.section-header h2 { + margin: 0; + color: #646cff; +} + +.items-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} + +.btn-primary { + background: #646cff; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: background 0.25s; +} + +.btn-primary:hover:not(:disabled) { + background: #535bf2; +} + +.btn-primary:disabled { + background: #666; + cursor: not-allowed; + opacity: 0.6; +} + +.btn-secondary { + background: transparent; + color: #646cff; + border: 1px solid #646cff; + padding: 0.75rem 1.5rem; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: all 0.25s; +} + +.btn-secondary:hover { + background: #646cff; + color: white; +} + +.btn-complete { + background: #4caf50; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.25s; +} + +.btn-complete:hover { + background: #45a049; +} + +.form-container { + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1rem; +} + +.form-container.hidden { + display: none; +} + +.form-container h3 { + margin: 0 0 1rem 0; + color: #646cff; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: #ccc; + font-weight: 500; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #333; + border-radius: 4px; + color: #fff; + font-size: 1rem; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #646cff; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; +} + + +.activity-card, +.workout-card { + padding: 1rem; + border-radius: 8px; + border: 1px solid #333; + transition: border-color 0.25s; +} + +.activity-card:hover, +.workout-card:hover { + border-color: #646cff; +} + +.activity-header, +.workout-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.activity-header-right, +.workout-header-right { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.activity-header h4, +.workout-header h4 { + margin: 0; + color: #646cff; +} + +.activity-date, +.workout-date { + color: #888; + font-size: 0.9rem; +} + +.activity-details, +.workout-details { + display: flex; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.activity-details span, +.workout-details span { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.9rem; +} + +.activity-notes { + margin: 0.5rem 0 0 0; + color: #ccc; + font-style: italic; +} + +.activity-user, +.workout-user { + color: #888; + font-size: 0.85rem; + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.workout-card.completed { + border-color: #4caf50; + opacity: 0.8; +} + +.workout-type { + background: #646cff; + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; +} + +.completed-badge { + color: #4caf50; + font-weight: 500; +} + +.btn-delete { + background: #ff6b6b; + color: white; + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.25s; + line-height: 1; +} + +.btn-delete:hover { + background: #ff5252; +} + +.btn-delete:active { + transform: scale(0.95); +} + +.stats-container { + padding: 1rem; +} + +.stats-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.stat-card { + padding: 1.5rem; + border-radius: 8px; + text-align: center; + border: 1px solid #333; +} + +.stat-card h3 { + margin: 0 0 0.5rem 0; + color: #646cff; + font-size: 1rem; +} + +.stat-value { + margin: 0; + font-size: 2rem; + font-weight: bold; + color: #4caf50; +} + +.empty-state { + text-align: center; + color: #888; + font-style: italic; + padding: 2rem; +} + +.loading-container, +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 50vh; + text-align: center; +} + +.loading-container h2, +.error-container h2 { + color: #646cff; + margin-bottom: 1rem; +} + +.loading-container p, +.error-container p { + color: #888; + max-width: 500px; +} + +.error-container { + color: #ff6b6b; +} + +.error-container h2 { + color: #ff6b6b; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + + header, + .form-container, + .activity-card, + .workout-card, + .stat-card, + .login-section { + background: #f9f9f9; + border-color: #e0e0e0; + } + + .user-management-section { + border-bottom-color: #e0e0e0; + } + + .form-group input, + .form-group select, + .form-group textarea, + .user-input, + .user-selector { + background: #ffffff; + border-color: #e0e0e0; + color: #213547; + } + + .activity-details span, + .workout-details span { + background: #e0e0e0; + color: #213547; + } + + .database-status { + background: rgba(100, 108, 255, 0.1); + border-color: rgba(100, 108, 255, 0.2); + color: #666; + } +} + +@media (max-width: 768px) { + .section-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .form-actions { + flex-direction: column; + } + + .items-grid { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .app-header h1 { + font-size: 2rem; + } + + .user-login { + flex-direction: column; + gap: 1rem; + } + + .user-login.logged-in { + flex-direction: column; + align-items: center; + } + + .user-selector { + min-width: auto; + width: 100%; + max-width: none; + } +} + +/* Footer Styles */ +.app-footer { + margin-top: auto; + padding: 2rem 1rem; + border-top: 1px solid #333; + background: rgba(0, 0, 0, 0.05); + backdrop-filter: blur(10px); +} + +.footer-content { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + gap: 2rem; +} + +.footer-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.footer-brand { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: #646cff; +} + +.footer-icon { + font-size: 1.2rem; +} + +.footer-title { + font-size: 1.1rem; +} + +.footer-tagline { + font-size: 0.85rem; + color: #888; + font-style: italic; +} + +.footer-versions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.version-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: #ccc; + padding: 0.25rem 0.75rem; + border-radius: 12px; + border: 1px solid #333; + background: rgba(100, 108, 255, 0.1); +} + +.version-icon { + font-size: 0.9rem; +} + +.version-label { + color: #888; +} + +.version-value { + font-weight: 600; + color: #646cff; + font-family: 'Courier New', monospace; +} + +.footer-links { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; +} + +.footer-links a { + color: #646cff; + text-decoration: none; + transition: color 0.25s; +} + +.footer-links a:hover { + color: #4338ca; + text-decoration: underline; +} + +.footer-separator { + color: #666; +} + +/* Light theme footer */ +@media (prefers-color-scheme: light) { + .app-footer { + background: rgba(255, 255, 255, 0.8); + border-top-color: #e0e0e0; + } + + .version-item { + border-color: #e0e0e0; + background: rgba(100, 108, 255, 0.05); + } + + .footer-tagline { + color: #666; + } +} + +/* Mobile footer */ +@media (max-width: 768px) { + .footer-content { + flex-direction: column; + text-align: center; + gap: 1.5rem; + } + + .footer-section { + align-items: center; + } + + .footer-versions { + flex-direction: row; + justify-content: center; + flex-wrap: wrap; + } + + .app-footer { + padding: 1.5rem 1rem; + } +} diff --git a/examples/sport-tracker-app/src/vite-env.d.ts b/examples/sport-tracker-app/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/sport-tracker-app/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/sport-tracker-app/tsconfig.json b/examples/sport-tracker-app/tsconfig.json new file mode 100644 index 0000000..bd8873b --- /dev/null +++ b/examples/sport-tracker-app/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/examples/sport-tracker-app/vite.config.ts b/examples/sport-tracker-app/vite.config.ts new file mode 100644 index 0000000..ceb9363 --- /dev/null +++ b/examples/sport-tracker-app/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, + watch: { + ignored: ["**/node_modules/**", "**/dist/**"], + }, + }, + optimizeDeps: { + exclude: [ + /* '@sqlite.org/sqlite-wasm', */ + "@sqliteai/sqlite-sync-wasm", + ], + }, +}); From 1968c533cb3536d4019552ba8f734396e891ff9c Mon Sep 17 00:00:00 2001 From: Daniele Briggi <=> Date: Mon, 28 Jul 2025 11:35:18 +0200 Subject: [PATCH 2/2] chore(npm): remove and ignore lock file --- .gitignore | 3 +- README.md | 2 +- examples/sport-tracker-app/package-lock.json | 912 ------------------- 3 files changed, 3 insertions(+), 914 deletions(-) delete mode 100644 examples/sport-tracker-app/package-lock.json diff --git a/.gitignore b/.gitignore index 7f1db0d..7a56c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ unittest /curl/src .vscode **/node_modules/** -.env \ No newline at end of file +.env +package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 61778c0..7e1cd59 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ SELECT cloudsync_terminate(); ### For a Complete Example -See the [Simple Todo Database example](./examples/simple-todo-db/) for a comprehensive walkthrough including: +See the [examples](./examples/simple-todo-db/) directory for a comprehensive walkthrough including: - Multi-device collaboration - Offline scenarios - Row-level security setup diff --git a/examples/sport-tracker-app/package-lock.json b/examples/sport-tracker-app/package-lock.json deleted file mode 100644 index ce0eab8..0000000 --- a/examples/sport-tracker-app/package-lock.json +++ /dev/null @@ -1,912 +0,0 @@ -{ - "name": "sport-tracking-app", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "sport-tracking-app", - "version": "0.0.1", - "dependencies": { - "@sqliteai/sqlite-sync-wasm": "^3.49.2-sync-0.8.9", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", - "react": "^19.1.0", - "react-dom": "^19.1.0" - }, - "devDependencies": { - "typescript": "~5.8.3", - "vite": "^7.0.0" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", - "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", - "dependencies": { - "@babel/types": "^7.28.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.19", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", - "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==" - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", - "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", - "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@sqliteai/sqlite-sync-wasm": { - "version": "3.49.2-sync-0.8.9", - "resolved": "https://registry.npmjs.org/@sqliteai/sqlite-sync-wasm/-/sqlite-sync-wasm-3.49.2-sync-0.8.9.tgz", - "integrity": "sha512-VodMSrVW7AgpS4Bqvt+3rl3UKXBvGUG2riyt07i7aAYdPRYVDfioyO+KuCpAn/plRhlO4LnEqccjdzBMKbU/9Q==" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" - }, - "node_modules/@types/react": { - "version": "19.1.8", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", - "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dependencies": { - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.1.6", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", - "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "peerDependencies": { - "@types/react": "^19.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", - "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.19", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" - } - }, - "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001726", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", - "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.179", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", - "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==" - }, - "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" - }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "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" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "dependencies": { - "scheduler": "^0.26.0" - }, - "peerDependencies": { - "react": "^19.1.0" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.44.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", - "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.44.1", - "@rollup/rollup-android-arm64": "4.44.1", - "@rollup/rollup-darwin-arm64": "4.44.1", - "@rollup/rollup-darwin-x64": "4.44.1", - "@rollup/rollup-freebsd-arm64": "4.44.1", - "@rollup/rollup-freebsd-x64": "4.44.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", - "@rollup/rollup-linux-arm-musleabihf": "4.44.1", - "@rollup/rollup-linux-arm64-gnu": "4.44.1", - "@rollup/rollup-linux-arm64-musl": "4.44.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", - "@rollup/rollup-linux-riscv64-gnu": "4.44.1", - "@rollup/rollup-linux-riscv64-musl": "4.44.1", - "@rollup/rollup-linux-s390x-gnu": "4.44.1", - "@rollup/rollup-linux-x64-gnu": "4.44.1", - "@rollup/rollup-linux-x64-musl": "4.44.1", - "@rollup/rollup-win32-arm64-msvc": "4.44.1", - "@rollup/rollup-win32-ia32-msvc": "4.44.1", - "@rollup/rollup-win32-x64-msvc": "4.44.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "bin": { - "semver": "bin/semver.js" - } - }, - "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==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.1.tgz", - "integrity": "sha512-BiKOQoW5HGR30E6JDeNsati6HnSPMVEKbkIWbCiol+xKeu3g5owrjy7kbk/QEMuzCV87dSUTvycYKmlcfGKq3Q==", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.6", - "picomatch": "^4.0.2", - "postcss": "^8.5.6", - "rollup": "^4.40.0", - "tinyglobby": "^0.2.14" - }, - "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", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.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 - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "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/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" - } - } -}