Compare commits
26 Commits
sql-wa-sql
...
search-map
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f43ff363 | ||
|
|
7b1d4c4849 | ||
|
|
0e65431f43 | ||
|
|
68e0fc4976 | ||
| 504056eb90 | |||
| 5a1007c49c | |||
|
|
cbc14e21ec | ||
|
|
3e02b3924a | ||
|
|
8b03789941 | ||
|
|
b4a6b99301 | ||
|
|
e839997f91 | ||
|
|
d8d054a0e1 | ||
|
|
efc720e47f | ||
|
|
0a85bea533 | ||
|
|
47501ae917 | ||
|
|
28634839ec | ||
|
|
28e848e386 | ||
|
|
55f56174a5 | ||
| 1b7c96ed9b | |||
| 41365fab8f | |||
| 5cc42be58a | |||
| 3d1a2eeb8d | |||
| 7b0ee2e44e | |||
| ac018997e8 | |||
| 6f449e9c1f | |||
| 543599a6a1 |
267
.cursor/rules/wa-sqlite.mdc
Normal file
@@ -0,0 +1,267 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# wa-sqlite Usage Guide
|
||||
|
||||
## Table of Contents
|
||||
- [1. Overview](#1-overview)
|
||||
- [2. Installation](#2-installation)
|
||||
- [3. Basic Setup](#3-basic-setup)
|
||||
- [3.1 Import and Initialize](#31-import-and-initialize)
|
||||
- [3.2 Basic Database Operations](#32-basic-database-operations)
|
||||
- [4. Virtual File Systems (VFS)](#4-virtual-file-systems-vfs)
|
||||
- [4.1 Available VFS Options](#41-available-vfs-options)
|
||||
- [4.2 Using a VFS](#42-using-a-vfs)
|
||||
- [5. Best Practices](#5-best-practices)
|
||||
- [5.1 Error Handling](#51-error-handling)
|
||||
- [5.2 Transaction Management](#52-transaction-management)
|
||||
- [5.3 Prepared Statements](#53-prepared-statements)
|
||||
- [6. Performance Considerations](#6-performance-considerations)
|
||||
- [7. Common Issues and Solutions](#7-common-issues-and-solutions)
|
||||
- [8. TypeScript Support](#8-typescript-support)
|
||||
|
||||
## 1. Overview
|
||||
wa-sqlite is a WebAssembly build of SQLite that enables SQLite database operations in web browsers and JavaScript environments. It provides both synchronous and asynchronous builds, with support for custom virtual file systems (VFS) for persistent storage.
|
||||
|
||||
## 2. Installation
|
||||
```bash
|
||||
npm install wa-sqlite
|
||||
# or
|
||||
yarn add wa-sqlite
|
||||
```
|
||||
|
||||
## 3. Basic Setup
|
||||
|
||||
### 3.1 Import and Initialize
|
||||
```javascript
|
||||
// Choose one of these imports based on your needs:
|
||||
// - wa-sqlite.mjs: Synchronous build
|
||||
// - wa-sqlite-async.mjs: Asynchronous build (required for async VFS)
|
||||
// - wa-sqlite-jspi.mjs: JSPI-based async build (experimental, Chromium only)
|
||||
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs';
|
||||
import * as SQLite from 'wa-sqlite';
|
||||
|
||||
async function initDatabase() {
|
||||
// Initialize SQLite module
|
||||
const module = await SQLiteESMFactory();
|
||||
const sqlite3 = SQLite.Factory(module);
|
||||
|
||||
// Open database (returns a Promise)
|
||||
const db = await sqlite3.open_v2('myDatabase');
|
||||
return { sqlite3, db };
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Basic Database Operations
|
||||
```javascript
|
||||
async function basicOperations() {
|
||||
const { sqlite3, db } = await initDatabase();
|
||||
|
||||
try {
|
||||
// Create a table
|
||||
await sqlite3.exec(db, `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert data
|
||||
await sqlite3.exec(db, `
|
||||
INSERT INTO users (name, email)
|
||||
VALUES ('John Doe', 'john@example.com')
|
||||
`);
|
||||
|
||||
// Query data
|
||||
const results = [];
|
||||
await sqlite3.exec(db, 'SELECT * FROM users', (row, columns) => {
|
||||
results.push({ row, columns });
|
||||
});
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
// Always close the database when done
|
||||
await sqlite3.close(db);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Virtual File Systems (VFS)
|
||||
|
||||
### 4.1 Available VFS Options
|
||||
wa-sqlite provides several VFS implementations for persistent storage:
|
||||
|
||||
1. **IDBBatchAtomicVFS** (Recommended for general use)
|
||||
- Uses IndexedDB with batch atomic writes
|
||||
- Works in all contexts (Window, Worker, Service Worker)
|
||||
- Supports WAL mode
|
||||
- Best performance with `PRAGMA synchronous=normal`
|
||||
|
||||
2. **IDBMirrorVFS**
|
||||
- Keeps files in memory, persists to IndexedDB
|
||||
- Works in all contexts
|
||||
- Good for smaller databases
|
||||
|
||||
3. **OPFS-based VFS** (Origin Private File System)
|
||||
- Various implementations available:
|
||||
- AccessHandlePoolVFS
|
||||
- OPFSAdaptiveVFS
|
||||
- OPFSCoopSyncVFS
|
||||
- OPFSPermutedVFS
|
||||
- Better performance but limited to Worker contexts
|
||||
|
||||
### 4.2 Using a VFS
|
||||
```javascript
|
||||
import { IDBBatchAtomicVFS } from 'wa-sqlite/src/examples/IDBBatchAtomicVFS.js';
|
||||
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite-async.mjs';
|
||||
import * as SQLite from 'wa-sqlite';
|
||||
|
||||
async function initDatabaseWithVFS() {
|
||||
const module = await SQLiteESMFactory();
|
||||
const sqlite3 = SQLite.Factory(module);
|
||||
|
||||
// Register VFS
|
||||
const vfs = await IDBBatchAtomicVFS.create('myApp', module);
|
||||
sqlite3.vfs_register(vfs, true);
|
||||
|
||||
// Open database with VFS
|
||||
const db = await sqlite3.open_v2('myDatabase');
|
||||
|
||||
// Configure for better performance
|
||||
await sqlite3.exec(db, 'PRAGMA synchronous = normal');
|
||||
await sqlite3.exec(db, 'PRAGMA journal_mode = WAL');
|
||||
|
||||
return { sqlite3, db };
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Best Practices
|
||||
|
||||
### 5.1 Error Handling
|
||||
```javascript
|
||||
async function safeDatabaseOperation() {
|
||||
const { sqlite3, db } = await initDatabase();
|
||||
|
||||
try {
|
||||
await sqlite3.exec(db, 'SELECT * FROM non_existent_table');
|
||||
} catch (error) {
|
||||
if (error.code === SQLite.SQLITE_ERROR) {
|
||||
console.error('SQL error:', error.message);
|
||||
} else {
|
||||
console.error('Database error:', error);
|
||||
}
|
||||
} finally {
|
||||
await sqlite3.close(db);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Transaction Management
|
||||
```javascript
|
||||
async function transactionExample() {
|
||||
const { sqlite3, db } = await initDatabase();
|
||||
|
||||
try {
|
||||
await sqlite3.exec(db, 'BEGIN TRANSACTION');
|
||||
|
||||
// Perform multiple operations
|
||||
await sqlite3.exec(db, 'INSERT INTO users (name) VALUES (?)', ['Alice']);
|
||||
await sqlite3.exec(db, 'INSERT INTO users (name) VALUES (?)', ['Bob']);
|
||||
|
||||
await sqlite3.exec(db, 'COMMIT');
|
||||
} catch (error) {
|
||||
await sqlite3.exec(db, 'ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
await sqlite3.close(db);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Prepared Statements
|
||||
```javascript
|
||||
async function preparedStatementExample() {
|
||||
const { sqlite3, db } = await initDatabase();
|
||||
|
||||
try {
|
||||
// Prepare statement
|
||||
const stmt = await sqlite3.prepare(db, 'SELECT * FROM users WHERE id = ?');
|
||||
|
||||
// Execute with different parameters
|
||||
await sqlite3.bind(stmt, 1, 1);
|
||||
while (await sqlite3.step(stmt) === SQLite.SQLITE_ROW) {
|
||||
const row = sqlite3.row(stmt);
|
||||
console.log(row);
|
||||
}
|
||||
|
||||
// Reset and reuse
|
||||
await sqlite3.reset(stmt);
|
||||
await sqlite3.bind(stmt, 1, 2);
|
||||
// ... execute again
|
||||
|
||||
await sqlite3.finalize(stmt);
|
||||
} finally {
|
||||
await sqlite3.close(db);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Performance Considerations
|
||||
|
||||
1. **VFS Selection**
|
||||
- Use IDBBatchAtomicVFS for general-purpose applications
|
||||
- Consider OPFS-based VFS for better performance in Worker contexts
|
||||
- Use MemoryVFS for temporary databases
|
||||
|
||||
2. **Configuration**
|
||||
- Set appropriate page size (default is usually fine)
|
||||
- Use WAL mode for better concurrency
|
||||
- Consider `PRAGMA synchronous=normal` for better performance
|
||||
- Adjust cache size based on your needs
|
||||
|
||||
3. **Concurrency**
|
||||
- Use transactions for multiple operations
|
||||
- Be aware of VFS-specific concurrency limitations
|
||||
- Consider using Web Workers for heavy database operations
|
||||
|
||||
## 7. Common Issues and Solutions
|
||||
|
||||
1. **Database Locking**
|
||||
- Use appropriate transaction isolation levels
|
||||
- Implement retry logic for busy errors
|
||||
- Consider using WAL mode
|
||||
|
||||
2. **Storage Limitations**
|
||||
- Be aware of browser storage quotas
|
||||
- Implement cleanup strategies
|
||||
- Monitor database size
|
||||
|
||||
3. **Cross-Context Access**
|
||||
- Use appropriate VFS for your context
|
||||
- Consider message passing for cross-context communication
|
||||
- Be aware of storage access limitations
|
||||
|
||||
## 8. TypeScript Support
|
||||
wa-sqlite includes TypeScript definitions. The main types are:
|
||||
|
||||
```typescript
|
||||
type SQLiteCompatibleType = number | string | Uint8Array | Array<number> | bigint | null;
|
||||
|
||||
interface SQLiteAPI {
|
||||
open_v2(filename: string, flags?: number, zVfs?: string): Promise<number>;
|
||||
exec(db: number, sql: string, callback?: (row: any[], columns: string[]) => void): Promise<number>;
|
||||
close(db: number): Promise<number>;
|
||||
// ... other methods
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Official GitHub Repository](https://github.com/rhashimoto/wa-sqlite)
|
||||
- [Online Demo](https://rhashimoto.github.io/wa-sqlite/demo/)
|
||||
- [API Reference](https://rhashimoto.github.io/wa-sqlite/docs/)
|
||||
- [FAQ](https://github.com/rhashimoto/wa-sqlite/issues?q=is%3Aissue+label%3Afaq+)
|
||||
- [Discussion Forums](https://github.com/rhashimoto/wa-sqlite/discussions)
|
||||
5
.gitignore
vendored
@@ -51,6 +51,7 @@ vendor/
|
||||
# Build logs
|
||||
build_logs/
|
||||
|
||||
android/app/src/main/assets/public
|
||||
android/app/src/main/res
|
||||
# PWA icon files generated by capacitor-assets
|
||||
icons
|
||||
|
||||
|
||||
|
||||
@@ -343,7 +343,11 @@ Prerequisites: macOS with Xcode installed
|
||||
3. Copy the assets:
|
||||
|
||||
```bash
|
||||
# It makes no sense why capacitor-assets will not run without these but it actually changes the contents.
|
||||
mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset
|
||||
echo '{"images":[]}' > ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
|
||||
mkdir -p ios/App/App/Assets.xcassets/Splash.imageset
|
||||
echo '{"images":[]}' > ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
|
||||
npx capacitor-assets generate --ios
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"appId": "app.timesafari",
|
||||
"appId": "app.timesafari.app",
|
||||
"appName": "TimeSafari",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 60 KiB |
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
assets/splash-dark.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
assets/splash.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
554
docs/migration-to-wa-sqlite.md
Normal file
@@ -0,0 +1,554 @@
|
||||
# Migration Guide: Dexie to wa-sqlite
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the migration process from Dexie.js to wa-sqlite for the TimeSafari app's storage implementation. The migration aims to provide a consistent SQLite-based storage solution across all platforms while maintaining data integrity and ensuring a smooth transition for users.
|
||||
|
||||
## Migration Goals
|
||||
|
||||
1. **Data Integrity**
|
||||
- Preserve all existing data
|
||||
- Maintain data relationships
|
||||
- Ensure data consistency
|
||||
|
||||
2. **Performance**
|
||||
- Improve query performance
|
||||
- Reduce storage overhead
|
||||
- Optimize for platform-specific features
|
||||
|
||||
3. **Security**
|
||||
- Maintain or improve encryption
|
||||
- Preserve access controls
|
||||
- Enhance data protection
|
||||
|
||||
4. **User Experience**
|
||||
- Zero data loss
|
||||
- Minimal downtime
|
||||
- Automatic migration where possible
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Backup Requirements**
|
||||
```typescript
|
||||
interface MigrationBackup {
|
||||
timestamp: number;
|
||||
accounts: Account[];
|
||||
settings: Setting[];
|
||||
contacts: Contact[];
|
||||
metadata: {
|
||||
version: string;
|
||||
platform: string;
|
||||
dexieVersion: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
2. **Storage Requirements**
|
||||
- Sufficient IndexedDB quota
|
||||
- Available disk space for SQLite
|
||||
- Backup storage space
|
||||
|
||||
3. **Platform Support**
|
||||
- Web: Modern browser with IndexedDB support
|
||||
- iOS: iOS 13+ with SQLite support
|
||||
- Android: Android 5+ with SQLite support
|
||||
- Electron: Latest version with SQLite support
|
||||
|
||||
## Migration Process
|
||||
|
||||
### 1. Preparation
|
||||
|
||||
```typescript
|
||||
// src/services/storage/migration/MigrationService.ts
|
||||
export class MigrationService {
|
||||
private static instance: MigrationService;
|
||||
private backup: MigrationBackup | null = null;
|
||||
|
||||
async prepare(): Promise<void> {
|
||||
try {
|
||||
// 1. Check prerequisites
|
||||
await this.checkPrerequisites();
|
||||
|
||||
// 2. Create backup
|
||||
this.backup = await this.createBackup();
|
||||
|
||||
// 3. Verify backup integrity
|
||||
await this.verifyBackup();
|
||||
|
||||
// 4. Initialize wa-sqlite
|
||||
await this.initializeWaSqlite();
|
||||
} catch (error) {
|
||||
throw new StorageError(
|
||||
'Migration preparation failed',
|
||||
StorageErrorCodes.MIGRATION_FAILED,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkPrerequisites(): Promise<void> {
|
||||
// Check IndexedDB availability
|
||||
if (!window.indexedDB) {
|
||||
throw new StorageError(
|
||||
'IndexedDB not available',
|
||||
StorageErrorCodes.INITIALIZATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
// Check storage quota
|
||||
const quota = await navigator.storage.estimate();
|
||||
if (quota.quota && quota.usage && quota.usage > quota.quota * 0.9) {
|
||||
throw new StorageError(
|
||||
'Insufficient storage space',
|
||||
StorageErrorCodes.STORAGE_FULL
|
||||
);
|
||||
}
|
||||
|
||||
// Check platform support
|
||||
const capabilities = await PlatformDetection.getCapabilities();
|
||||
if (!capabilities.hasFileSystem) {
|
||||
throw new StorageError(
|
||||
'Platform does not support required features',
|
||||
StorageErrorCodes.INITIALIZATION_FAILED
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createBackup(): Promise<MigrationBackup> {
|
||||
const dexieDB = new Dexie('TimeSafariDB');
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
accounts: await dexieDB.accounts.toArray(),
|
||||
settings: await dexieDB.settings.toArray(),
|
||||
contacts: await dexieDB.contacts.toArray(),
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
platform: await PlatformDetection.getPlatform(),
|
||||
dexieVersion: Dexie.version
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Data Migration
|
||||
|
||||
```typescript
|
||||
// src/services/storage/migration/DataMigration.ts
|
||||
export class DataMigration {
|
||||
async migrate(backup: MigrationBackup): Promise<void> {
|
||||
try {
|
||||
// 1. Create new database schema
|
||||
await this.createSchema();
|
||||
|
||||
// 2. Migrate accounts
|
||||
await this.migrateAccounts(backup.accounts);
|
||||
|
||||
// 3. Migrate settings
|
||||
await this.migrateSettings(backup.settings);
|
||||
|
||||
// 4. Migrate contacts
|
||||
await this.migrateContacts(backup.contacts);
|
||||
|
||||
// 5. Verify migration
|
||||
await this.verifyMigration(backup);
|
||||
} catch (error) {
|
||||
// 6. Handle failure
|
||||
await this.handleMigrationFailure(error, backup);
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateAccounts(accounts: Account[]): Promise<void> {
|
||||
const db = await this.getWaSqliteConnection();
|
||||
|
||||
// Use transaction for atomicity
|
||||
await db.transaction(async (tx) => {
|
||||
for (const account of accounts) {
|
||||
await tx.execute(`
|
||||
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, [
|
||||
account.did,
|
||||
account.publicKeyHex,
|
||||
account.createdAt,
|
||||
account.updatedAt
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async verifyMigration(backup: MigrationBackup): Promise<void> {
|
||||
const db = await this.getWaSqliteConnection();
|
||||
|
||||
// Verify account count
|
||||
const accountCount = await db.selectValue(
|
||||
'SELECT COUNT(*) FROM accounts'
|
||||
);
|
||||
if (accountCount !== backup.accounts.length) {
|
||||
throw new StorageError(
|
||||
'Account count mismatch',
|
||||
StorageErrorCodes.VERIFICATION_FAILED
|
||||
);
|
||||
}
|
||||
|
||||
// Verify data integrity
|
||||
await this.verifyDataIntegrity(backup);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Rollback Strategy
|
||||
|
||||
```typescript
|
||||
// src/services/storage/migration/RollbackService.ts
|
||||
export class RollbackService {
|
||||
async rollback(backup: MigrationBackup): Promise<void> {
|
||||
try {
|
||||
// 1. Stop all database operations
|
||||
await this.stopDatabaseOperations();
|
||||
|
||||
// 2. Restore from backup
|
||||
await this.restoreFromBackup(backup);
|
||||
|
||||
// 3. Verify restoration
|
||||
await this.verifyRestoration(backup);
|
||||
|
||||
// 4. Clean up wa-sqlite
|
||||
await this.cleanupWaSqlite();
|
||||
} catch (error) {
|
||||
throw new StorageError(
|
||||
'Rollback failed',
|
||||
StorageErrorCodes.ROLLBACK_FAILED,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreFromBackup(backup: MigrationBackup): Promise<void> {
|
||||
const dexieDB = new Dexie('TimeSafariDB');
|
||||
|
||||
// Restore accounts
|
||||
await dexieDB.accounts.bulkPut(backup.accounts);
|
||||
|
||||
// Restore settings
|
||||
await dexieDB.settings.bulkPut(backup.settings);
|
||||
|
||||
// Restore contacts
|
||||
await dexieDB.contacts.bulkPut(backup.contacts);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration UI
|
||||
|
||||
```vue
|
||||
<!-- src/components/MigrationProgress.vue -->
|
||||
<template>
|
||||
<div class="migration-progress">
|
||||
<h2>Database Migration</h2>
|
||||
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" :style="{ width: `${progress}%` }" />
|
||||
<div class="progress-text">{{ progress }}%</div>
|
||||
</div>
|
||||
|
||||
<div class="status-message">{{ statusMessage }}</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
<button @click="retryMigration">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { MigrationService } from '@/services/storage/migration/MigrationService';
|
||||
|
||||
const progress = ref(0);
|
||||
const statusMessage = ref('Preparing migration...');
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const migrationService = MigrationService.getInstance();
|
||||
|
||||
async function startMigration() {
|
||||
try {
|
||||
// 1. Preparation
|
||||
statusMessage.value = 'Creating backup...';
|
||||
await migrationService.prepare();
|
||||
progress.value = 20;
|
||||
|
||||
// 2. Data migration
|
||||
statusMessage.value = 'Migrating data...';
|
||||
await migrationService.migrate();
|
||||
progress.value = 80;
|
||||
|
||||
// 3. Verification
|
||||
statusMessage.value = 'Verifying migration...';
|
||||
await migrationService.verify();
|
||||
progress.value = 100;
|
||||
|
||||
statusMessage.value = 'Migration completed successfully!';
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Migration failed';
|
||||
statusMessage.value = 'Migration failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function retryMigration() {
|
||||
error.value = null;
|
||||
progress.value = 0;
|
||||
await startMigration();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startMigration();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.migration-progress {
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
position: relative;
|
||||
height: 20px;
|
||||
background: #eee;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background: #4CAF50;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
line-height: 20px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f44336;
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**
|
||||
```typescript
|
||||
// src/services/storage/migration/__tests__/MigrationService.spec.ts
|
||||
describe('MigrationService', () => {
|
||||
it('should create valid backup', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
const backup = await service.createBackup();
|
||||
|
||||
expect(backup).toBeDefined();
|
||||
expect(backup.accounts).toBeInstanceOf(Array);
|
||||
expect(backup.settings).toBeInstanceOf(Array);
|
||||
expect(backup.contacts).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
it('should migrate data correctly', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
const backup = await service.createBackup();
|
||||
|
||||
await service.migrate(backup);
|
||||
|
||||
// Verify migration
|
||||
const accounts = await service.getMigratedAccounts();
|
||||
expect(accounts).toHaveLength(backup.accounts.length);
|
||||
});
|
||||
|
||||
it('should handle rollback correctly', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
const backup = await service.createBackup();
|
||||
|
||||
// Simulate failed migration
|
||||
await service.migrate(backup);
|
||||
await service.simulateFailure();
|
||||
|
||||
// Perform rollback
|
||||
await service.rollback(backup);
|
||||
|
||||
// Verify rollback
|
||||
const accounts = await service.getOriginalAccounts();
|
||||
expect(accounts).toHaveLength(backup.accounts.length);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
2. **Integration Tests**
|
||||
```typescript
|
||||
// src/services/storage/migration/__tests__/integration/Migration.spec.ts
|
||||
describe('Migration Integration', () => {
|
||||
it('should handle concurrent access during migration', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
|
||||
// Start migration
|
||||
const migrationPromise = service.migrate();
|
||||
|
||||
// Simulate concurrent access
|
||||
const accessPromises = Array(5).fill(null).map(() =>
|
||||
service.getAccount('did:test:123')
|
||||
);
|
||||
|
||||
// Wait for all operations
|
||||
const [migrationResult, ...accessResults] = await Promise.allSettled([
|
||||
migrationPromise,
|
||||
...accessPromises
|
||||
]);
|
||||
|
||||
// Verify results
|
||||
expect(migrationResult.status).toBe('fulfilled');
|
||||
expect(accessResults.some(r => r.status === 'rejected')).toBe(true);
|
||||
});
|
||||
|
||||
it('should maintain data integrity during platform transition', async () => {
|
||||
const service = MigrationService.getInstance();
|
||||
|
||||
// Simulate platform change
|
||||
await service.simulatePlatformChange();
|
||||
|
||||
// Verify data
|
||||
const accounts = await service.getAllAccounts();
|
||||
const settings = await service.getAllSettings();
|
||||
const contacts = await service.getAllContacts();
|
||||
|
||||
expect(accounts).toBeDefined();
|
||||
expect(settings).toBeDefined();
|
||||
expect(contacts).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **Data Integrity**
|
||||
- [ ] All accounts migrated successfully
|
||||
- [ ] All settings preserved
|
||||
- [ ] All contacts transferred
|
||||
- [ ] No data corruption
|
||||
|
||||
2. **Performance**
|
||||
- [ ] Migration completes within acceptable time
|
||||
- [ ] No significant performance degradation
|
||||
- [ ] Efficient storage usage
|
||||
- [ ] Smooth user experience
|
||||
|
||||
3. **Security**
|
||||
- [ ] Encrypted data remains secure
|
||||
- [ ] Access controls maintained
|
||||
- [ ] No sensitive data exposure
|
||||
- [ ] Secure backup process
|
||||
|
||||
4. **User Experience**
|
||||
- [ ] Clear migration progress
|
||||
- [ ] Informative error messages
|
||||
- [ ] Automatic recovery from failures
|
||||
- [ ] No data loss
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
1. **Automatic Rollback**
|
||||
- Triggered by migration failure
|
||||
- Restores from verified backup
|
||||
- Maintains data consistency
|
||||
- Logs rollback reason
|
||||
|
||||
2. **Manual Rollback**
|
||||
- Available through settings
|
||||
- Requires user confirmation
|
||||
- Preserves backup data
|
||||
- Provides rollback status
|
||||
|
||||
3. **Emergency Recovery**
|
||||
- Manual backup restoration
|
||||
- Database repair tools
|
||||
- Data recovery procedures
|
||||
- Support contact information
|
||||
|
||||
## Post-Migration
|
||||
|
||||
1. **Verification**
|
||||
- Data integrity checks
|
||||
- Performance monitoring
|
||||
- Error rate tracking
|
||||
- User feedback collection
|
||||
|
||||
2. **Cleanup**
|
||||
- Remove old database
|
||||
- Clear migration artifacts
|
||||
- Update application state
|
||||
- Archive backup data
|
||||
|
||||
3. **Monitoring**
|
||||
- Track migration success rate
|
||||
- Monitor performance metrics
|
||||
- Collect error reports
|
||||
- Gather user feedback
|
||||
|
||||
## Support
|
||||
|
||||
For assistance with migration:
|
||||
1. Check the troubleshooting guide
|
||||
2. Review error logs
|
||||
3. Contact support team
|
||||
4. Submit issue report
|
||||
|
||||
## Timeline
|
||||
|
||||
1. **Preparation Phase** (1 week)
|
||||
- Backup system implementation
|
||||
- Migration service development
|
||||
- Testing framework setup
|
||||
|
||||
2. **Testing Phase** (2 weeks)
|
||||
- Unit testing
|
||||
- Integration testing
|
||||
- Performance testing
|
||||
- Security testing
|
||||
|
||||
3. **Deployment Phase** (1 week)
|
||||
- Staged rollout
|
||||
- Monitoring
|
||||
- Support preparation
|
||||
- Documentation updates
|
||||
|
||||
4. **Post-Deployment** (2 weeks)
|
||||
- Monitoring
|
||||
- Bug fixes
|
||||
- Performance optimization
|
||||
- User feedback collection
|
||||
13
ios/.gitignore
vendored
@@ -11,3 +11,16 @@ capacitor-cordova-ios-plugins
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
|
||||
# User-specific Xcode files
|
||||
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
|
||||
App/App.xcodeproj/*.xcuserstate
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
|
||||
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
|
||||
App/App/Assets.xcassets/AppIcon.appiconset
|
||||
App/App/Assets.xcassets/Splash.imageset
|
||||
|
||||
@@ -380,6 +380,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
@@ -406,6 +407,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
|
||||
|
Before Width: | Height: | Size: 116 KiB |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"size": "1024x1024",
|
||||
"filename": "AppIcon-512@2x.png",
|
||||
"platform": "ios"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-2.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "splash-2732x2732.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
@@ -165,7 +165,7 @@
|
||||
},
|
||||
"main": "./dist-electron/main.js",
|
||||
"build": {
|
||||
"appId": "app.timesafari",
|
||||
"appId": "app.timesafari.app",
|
||||
"productName": "TimeSafari",
|
||||
"directories": {
|
||||
"output": "dist-electron-packages"
|
||||
|
||||
@@ -51,7 +51,7 @@ const { existsSync } = require('fs');
|
||||
*/
|
||||
function checkCommand(command, errorMessage) {
|
||||
try {
|
||||
execSync(command + ' --version', { stdio: 'ignore' });
|
||||
execSync(command, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`❌ ${errorMessage}`);
|
||||
@@ -164,10 +164,10 @@ function main() {
|
||||
|
||||
// Check required command line tools
|
||||
// These are essential for building and testing the application
|
||||
success &= checkCommand('node', 'Node.js is required');
|
||||
success &= checkCommand('npm', 'npm is required');
|
||||
success &= checkCommand('gradle', 'Gradle is required for Android builds');
|
||||
success &= checkCommand('xcodebuild', 'Xcode is required for iOS builds');
|
||||
success &= checkCommand('node --version', 'Node.js is required');
|
||||
success &= checkCommand('npm --version', 'npm is required');
|
||||
success &= checkCommand('gradle --version', 'Gradle is required for Android builds');
|
||||
success &= checkCommand('xcodebuild --help', 'Xcode is required for iOS builds');
|
||||
|
||||
// Check platform-specific development environments
|
||||
success &= checkAndroidSetup();
|
||||
|
||||
@@ -170,7 +170,7 @@ const executeDeeplink = async (url, description, log) => {
|
||||
|
||||
try {
|
||||
// Stop the app before executing the deep link
|
||||
execSync('adb shell am force-stop app.timesafari');
|
||||
execSync('adb shell am force-stop app.timesafari.app');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s
|
||||
|
||||
execSync(`adb shell am start -W -a android.intent.action.VIEW -d "${url}" -c android.intent.category.BROWSABLE`);
|
||||
|
||||
12
src/App.vue
@@ -4,7 +4,7 @@
|
||||
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
|
||||
<NotificationGroup group="alert">
|
||||
<div
|
||||
class="fixed top-[calc(env(safe-area-inset-top)+1rem)] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
|
||||
class="fixed z-[90] top-[max(1rem,env(safe-area-inset-top))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
|
||||
>
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
@@ -541,13 +541,13 @@ export default class App extends Vue {
|
||||
|
||||
<style>
|
||||
#Content {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: calc(env(safe-area-inset-top) + 1.5rem);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem);
|
||||
padding-left: max(1.5rem, env(safe-area-inset-left));
|
||||
padding-right: max(1.5rem, env(safe-area-inset-right));
|
||||
padding-top: max(1.5rem, env(safe-area-inset-top));
|
||||
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
#QuickNav ~ #Content {
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6rem);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6.333rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -136,7 +136,7 @@ export default class DataExportSection extends Vue {
|
||||
transform: (table, value, key) => {
|
||||
if (table === "contacts") {
|
||||
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them.
|
||||
Object.keys(value).forEach(prop => {
|
||||
Object.keys(value).forEach((prop) => {
|
||||
if (value[prop] === undefined) {
|
||||
delete value[prop];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="text-lg text-center font-bold relative">
|
||||
<h1 id="ViewHeading" class="text-center font-bold">
|
||||
<span v-if="uploading">Uploading Image…</span>
|
||||
<span v-else-if="blob">Crop Image</span>
|
||||
<span v-else-if="blob">{{ crop ? 'Crop Image' : 'Preview Image' }}</span>
|
||||
<span v-else-if="showCameraPreview">Upload Image</span>
|
||||
<span v-else>Add Photo</span>
|
||||
</h1>
|
||||
@@ -119,12 +119,21 @@
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<button
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
<div class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4">
|
||||
<button
|
||||
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
<button
|
||||
v-if="platformCapabilities.isMobile"
|
||||
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="rotateCamera"
|
||||
>
|
||||
<font-awesome icon="rotate" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -262,6 +271,11 @@ const inputImageFileNameRef = ref<Blob>();
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultCameraMode: {
|
||||
type: String,
|
||||
default: 'environment',
|
||||
validator: (value: string) => ['environment', 'user'].includes(value)
|
||||
}
|
||||
},
|
||||
})
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
@@ -303,6 +317,9 @@ export default class ImageMethodDialog extends Vue {
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
/** Current camera facing mode */
|
||||
private currentFacingMode: 'environment' | 'user' = 'environment';
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
@@ -362,15 +379,16 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
|
||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||
logger.debug("ImageMethodDialog.open called");
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
this.imageCallback = setImageFn;
|
||||
this.visible = true;
|
||||
this.currentFacingMode = this.defaultCameraMode as 'environment' | 'user';
|
||||
|
||||
// Start camera preview immediately if not on mobile
|
||||
if (!this.platformCapabilities.isNativeApp) {
|
||||
this.startCameraPreview();
|
||||
}
|
||||
// Start camera preview immediately
|
||||
logger.debug("Starting camera preview from open()");
|
||||
this.startCameraPreview();
|
||||
}
|
||||
|
||||
async uploadImageFile(event: Event) {
|
||||
@@ -439,46 +457,21 @@ export default class ImageMethodDialog extends Vue {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
logger.debug("MediaDevices available:", !!navigator.mediaDevices);
|
||||
logger.debug("getUserMedia available:", !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia));
|
||||
|
||||
if (this.platformCapabilities.isNativeApp) {
|
||||
logger.debug("Using platform service for mobile device");
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Using platform camera service...";
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
this.cameraState = "ready";
|
||||
this.cameraStateMessage = "Photo captured successfully";
|
||||
} catch (error) {
|
||||
logger.error("Error taking picture:", error);
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage =
|
||||
error instanceof Error ? error.message : "Failed to take picture";
|
||||
this.error =
|
||||
error instanceof Error ? error.message : "Failed to take picture";
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to take picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Starting camera preview for desktop browser");
|
||||
try {
|
||||
this.cameraState = "initializing";
|
||||
this.cameraStateMessage = "Requesting camera access...";
|
||||
this.showCameraPreview = true;
|
||||
await this.$nextTick();
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Camera API not available in this browser");
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment" },
|
||||
video: { facingMode: this.currentFacingMode },
|
||||
});
|
||||
logger.debug("Camera access granted");
|
||||
this.cameraStream = stream;
|
||||
@@ -493,31 +486,41 @@ export default class ImageMethodDialog extends Vue {
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
videoElement.play().then(() => {
|
||||
logger.debug("Video element started playing");
|
||||
resolve(true);
|
||||
}).catch(error => {
|
||||
logger.error("Error playing video:", error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
});
|
||||
} else {
|
||||
logger.error("Video element not found");
|
||||
throw new Error("Video element not found");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
let errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to access camera";
|
||||
if (
|
||||
error instanceof Error && (
|
||||
error.name === "NotReadableError" ||
|
||||
error.name === "TrackStartError"
|
||||
) {
|
||||
)) {
|
||||
errorMessage =
|
||||
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
|
||||
} else if (
|
||||
error instanceof Error && (
|
||||
error.name === "NotAllowedError" ||
|
||||
error.name === "PermissionDeniedError"
|
||||
) {
|
||||
)) {
|
||||
errorMessage =
|
||||
"Camera access was denied. Please allow camera access in your browser settings.";
|
||||
}
|
||||
this.cameraState = "error";
|
||||
this.cameraStateMessage = errorMessage;
|
||||
this.error = errorMessage;
|
||||
this.showCameraPreview = false;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -527,7 +530,6 @@ export default class ImageMethodDialog extends Vue {
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,6 +581,20 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async rotateCamera() {
|
||||
// Toggle between front and back cameras
|
||||
this.currentFacingMode = this.currentFacingMode === 'environment' ? 'user' : 'environment';
|
||||
|
||||
// Stop current stream
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach(track => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
|
||||
// Start new stream with updated facing mode
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
@@ -613,6 +629,7 @@ export default class ImageMethodDialog extends Vue {
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||
@@ -667,6 +684,7 @@ export default class ImageMethodDialog extends Vue {
|
||||
);
|
||||
this.uploading = false;
|
||||
this.blob = undefined;
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ export default class MembersList extends Vue {
|
||||
this.decryptedMembers.length === 0 ||
|
||||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId
|
||||
) {
|
||||
return "Your password is not the same as the organizer. Reload or have them check their password.";
|
||||
return "Your password is not the same as the organizer. Retry or have them check their password.";
|
||||
} else {
|
||||
// the first (organizer) member was decrypted OK
|
||||
return "";
|
||||
@@ -337,7 +337,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Contact Exists",
|
||||
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
|
||||
text: "They are in your contacts. To remove them, use the contacts page.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
@@ -347,7 +347,7 @@ export default class MembersList extends Vue {
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Contact Available",
|
||||
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
|
||||
text: "This is to add them to your contacts. To remove them later, use the contacts page.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute right-5 top-[calc(env(safe-area-inset-top)+0.75rem)]">
|
||||
<div class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top))]">
|
||||
<span class="align-center text-red-500 mr-2">{{ message }}</span>
|
||||
<span class="ml-2">
|
||||
<router-link
|
||||
|
||||
@@ -90,7 +90,10 @@ db.on("populate", async () => {
|
||||
try {
|
||||
await db.settings.add(DEFAULT_SETTINGS);
|
||||
} catch (error) {
|
||||
console.error("Error populating the database with default settings:", error);
|
||||
console.error(
|
||||
"Error populating the database with default settings:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,7 +108,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
||||
|
||||
// Create a promise that rejects after 5 seconds
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Database open timed out')), 500);
|
||||
setTimeout(() => reject(new Error("Database open timed out")), 500);
|
||||
});
|
||||
|
||||
// Race between the open operation and the timeout
|
||||
@@ -123,7 +126,7 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
|
||||
console.error(`Attempt ${i + 1}: Database open failed:`, error);
|
||||
if (i < retries - 1) {
|
||||
console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
@@ -145,10 +148,16 @@ export async function updateDefaultSettings(
|
||||
await safeOpenDatabase();
|
||||
} catch (openError: unknown) {
|
||||
console.error("Failed to open database:", openError);
|
||||
const errorMessage = openError instanceof Error ? openError.message : String(openError);
|
||||
throw new Error(`Database connection failed: ${errorMessage}. Please try again or restart the app.`);
|
||||
const errorMessage =
|
||||
openError instanceof Error ? openError.message : String(openError);
|
||||
throw new Error(
|
||||
`Database connection failed: ${errorMessage}. Please try again or restart the app.`,
|
||||
);
|
||||
}
|
||||
const result = await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
|
||||
const result = await db.settings.update(
|
||||
MASTER_SETTINGS_KEY,
|
||||
settingsChanges,
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error updating default settings:", error);
|
||||
|
||||
@@ -549,11 +549,13 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
||||
mnemonic: mnemonic,
|
||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||
});
|
||||
|
||||
|
||||
await updateDefaultSettings({ activeDid: newId.did });
|
||||
} catch (error) {
|
||||
console.error("Failed to update default settings:", error);
|
||||
throw new Error("Failed to set default settings. Please try again or restart the app.");
|
||||
throw new Error(
|
||||
"Failed to set default settings. Please try again or restart the app.",
|
||||
);
|
||||
}
|
||||
await updateAccountSettings(newId.did, { isRegistered: false });
|
||||
return newId.did;
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface PlatformCapabilities {
|
||||
hasFileDownload: boolean;
|
||||
/** Whether the platform requires special file handling instructions */
|
||||
needsFileHandlingInstructions: boolean;
|
||||
/** Whether the platform is a native app (Capacitor, Electron, etc.) */
|
||||
isNativeApp: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +94,12 @@ export interface PlatformService {
|
||||
*/
|
||||
pickImage(): Promise<ImageResult>;
|
||||
|
||||
/**
|
||||
* Rotates the camera between front and back cameras.
|
||||
* @returns Promise that resolves when the camera is rotated
|
||||
*/
|
||||
rotateCamera(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Handles deep link URLs for the application.
|
||||
* @param url - The deep link URL to handle
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
PlatformCapabilities,
|
||||
} from "../PlatformService";
|
||||
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
|
||||
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
|
||||
import { Camera, CameraResultType, CameraSource, CameraDirection } from "@capacitor/camera";
|
||||
import { Share } from "@capacitor/share";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
@@ -16,6 +16,9 @@ import { logger } from "../../utils/logger";
|
||||
* - Platform-specific features
|
||||
*/
|
||||
export class CapacitorPlatformService implements PlatformService {
|
||||
/** Current camera direction */
|
||||
private currentDirection: CameraDirection = 'BACK';
|
||||
|
||||
/**
|
||||
* Gets the capabilities of the Capacitor platform
|
||||
* @returns Platform capabilities object
|
||||
@@ -28,6 +31,7 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
|
||||
hasFileDownload: false,
|
||||
needsFileHandlingInstructions: true,
|
||||
isNativeApp: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -401,6 +405,7 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
allowEditing: true,
|
||||
resultType: CameraResultType.Base64,
|
||||
source: CameraSource.Camera,
|
||||
direction: this.currentDirection,
|
||||
});
|
||||
|
||||
const blob = await this.processImageData(image.base64String);
|
||||
@@ -466,6 +471,15 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
return new Blob(byteArrays, { type: "image/jpeg" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the camera between front and back cameras.
|
||||
* @returns Promise that resolves when the camera is rotated
|
||||
*/
|
||||
async rotateCamera(): Promise<void> {
|
||||
this.currentDirection = this.currentDirection === 'BACK' ? 'FRONT' : 'BACK';
|
||||
logger.debug(`Camera rotated to ${this.currentDirection} camera`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles deep link URLs for the application.
|
||||
* Note: Capacitor handles deep links automatically.
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
<ImageMethodDialog
|
||||
ref="imageMethodDialog"
|
||||
:is-registered="isRegistered"
|
||||
default-camera-mode="user"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
||||
@@ -357,7 +357,8 @@ export default class ContactQRScan extends Vue {
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
if (!contactInfo.did) {
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
@@ -370,7 +371,7 @@ export default class ContactQRScan extends Vue {
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: contactInfo.did,
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
|
||||
@@ -152,30 +152,6 @@
|
||||
@camera-on="onCameraOn"
|
||||
@camera-off="onCameraOff"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute bottom-4 inset-x-0 flex justify-center items-center"
|
||||
>
|
||||
<!-- Camera Stop Button -->
|
||||
<button
|
||||
class="text-center text-slate-600 leading-none bg-white p-2 rounded-full drop-shadow-lg"
|
||||
title="Stop camera"
|
||||
@click="stopScanning"
|
||||
>
|
||||
<font-awesome icon="xmark" class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center aspect-square overflow-hidden bg-slate-800 w-[90vw] max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
|
||||
>
|
||||
<button
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white text-lg px-3 py-2 rounded-lg"
|
||||
@click="startScanning"
|
||||
>
|
||||
Scan QR Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -258,6 +234,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
// Add property to track if we're on desktop
|
||||
private isDesktop = false;
|
||||
private isFrontCamera = false;
|
||||
|
||||
async created() {
|
||||
try {
|
||||
@@ -506,7 +483,8 @@ export default class ContactQRScanShow extends Vue {
|
||||
}
|
||||
|
||||
const contactInfo = decodedJwt.payload.own;
|
||||
if (!contactInfo.did) {
|
||||
const did = contactInfo.did || decodedJwt.payload.iss;
|
||||
if (!did) {
|
||||
logger.warn("Invalid contact info - missing DID");
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
@@ -519,7 +497,7 @@ export default class ContactQRScanShow extends Vue {
|
||||
|
||||
// Create contact object
|
||||
const contact = {
|
||||
did: contactInfo.did,
|
||||
did: did,
|
||||
name: contactInfo.name || "",
|
||||
email: contactInfo.email || "",
|
||||
phone: contactInfo.phone || "",
|
||||
@@ -723,14 +701,6 @@ export default class ContactQRScanShow extends Vue {
|
||||
document.addEventListener("resume", this.handleAppResume);
|
||||
// Start scanning automatically when view is loaded
|
||||
this.startScanning();
|
||||
|
||||
// Apply mirroring after a short delay to ensure video element is ready
|
||||
setTimeout(() => {
|
||||
const videoElement = document.querySelector('.qr-scanner video') as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
videoElement.style.transform = 'scaleX(-1)';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
beforeDestroy() {
|
||||
@@ -877,6 +847,8 @@ export default class ContactQRScanShow extends Vue {
|
||||
onCameraOn(): void {
|
||||
this.cameraState = "active";
|
||||
this.isInitializing = false;
|
||||
this.isFrontCamera = this.preferredCamera === "user";
|
||||
this.applyCameraMirroring();
|
||||
}
|
||||
|
||||
onCameraOff(): void {
|
||||
@@ -922,6 +894,8 @@ export default class ContactQRScanShow extends Vue {
|
||||
toggleCamera(): void {
|
||||
this.preferredCamera =
|
||||
this.preferredCamera === "user" ? "environment" : "user";
|
||||
this.isFrontCamera = this.preferredCamera === "user";
|
||||
this.applyCameraMirroring();
|
||||
}
|
||||
|
||||
private handleError(error: unknown): void {
|
||||
@@ -943,17 +917,21 @@ export default class ContactQRScanShow extends Vue {
|
||||
// Add method to detect desktop browser
|
||||
private detectDesktopBrowser(): boolean {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent);
|
||||
return !/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
// Update the computed property for camera mirroring
|
||||
get shouldMirrorCamera(): boolean {
|
||||
// On desktop, always mirror the webcam
|
||||
if (this.isDesktop) {
|
||||
return true;
|
||||
// Add method to apply camera mirroring
|
||||
private applyCameraMirroring(): void {
|
||||
const videoElement = document.querySelector(
|
||||
".qr-scanner video",
|
||||
) as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
// Mirror if it's desktop or front camera on mobile
|
||||
const shouldMirror = this.isDesktop || (this.isFrontCamera && !this.isDesktop);
|
||||
videoElement.style.transform = shouldMirror ? "scaleX(-1)" : "none";
|
||||
}
|
||||
// On mobile, mirror only for front-facing camera
|
||||
return this.preferredCamera === "user";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -968,8 +946,9 @@ export default class ContactQRScanShow extends Vue {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Remove the default mirroring from CSS since we're handling it in JavaScript */
|
||||
:deep(.qr-scanner video) {
|
||||
transform: scaleX(-1);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Ensure the canvas for QR detection is not mirrored */
|
||||
|
||||
@@ -54,17 +54,12 @@
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex items-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
||||
class="flex items-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 mr-1 rounded-md"
|
||||
>
|
||||
<font-awesome
|
||||
icon="chair"
|
||||
class="fa-fw text-2xl"
|
||||
@click="
|
||||
warning(
|
||||
'You must get registered before you can initiate an onboarding meeting.',
|
||||
'Not Registered',
|
||||
)
|
||||
"
|
||||
@click="this.$router.push({ name: 'onboard-meeting-list' })"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -11,30 +11,9 @@
|
||||
|
||||
<OnboardingDialog ref="onboardingDialog" />
|
||||
|
||||
<!-- Quick Search -->
|
||||
<div
|
||||
id="QuickSearch"
|
||||
class="mt-8 mb-4 flex"
|
||||
:style="{ visibility: isSearchVisible ? 'visible' : 'hidden' }"
|
||||
>
|
||||
<input
|
||||
v-model="searchTerms"
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||
@keyup.enter="searchSelected()"
|
||||
/>
|
||||
<button
|
||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
@click="searchSelected()"
|
||||
>
|
||||
<font-awesome icon="magnifying-glass" class="fa-fw"></font-awesome>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Result Tabs -->
|
||||
<!-- Top Level Selection -->
|
||||
<div class="text-center text-slate-500 border-b border-slate-300 mb-4">
|
||||
<div class="text-center text-slate-500 border-b border-slate-300 mt-4 mb-2">
|
||||
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
|
||||
<li>
|
||||
<a
|
||||
@@ -146,6 +125,27 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Quick Search -->
|
||||
<div
|
||||
id="QuickSearch"
|
||||
class="mt-6 mb-4 flex"
|
||||
:style="{ display: isSearchVisible ? 'flex' : 'none' }"
|
||||
>
|
||||
<input
|
||||
v-model="searchTerms"
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||
@keyup.enter="searchSelected()"
|
||||
/>
|
||||
<button
|
||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
@click="searchSelected()"
|
||||
>
|
||||
<font-awesome icon="magnifying-glass" class="fa-fw"></font-awesome>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLocalActive">
|
||||
<div class="text-center">
|
||||
<button
|
||||
@@ -159,7 +159,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="isMappedActive && !tempSearchBox">
|
||||
<div class="mt-4 h-96 w-5/6 mx-auto">
|
||||
<div class="mt-6 h-96 w-full mx-auto">
|
||||
<l-map
|
||||
ref="projectMap"
|
||||
@ready="onMapReady"
|
||||
@@ -167,6 +167,7 @@
|
||||
@movestart="onMoveStart"
|
||||
@zoomend="onZoomEnd"
|
||||
@zoomstart="onZoomStart"
|
||||
class="z-40"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
@@ -197,14 +198,18 @@
|
||||
-->
|
||||
</span>
|
||||
<span v-else-if="isAnywhereActive"
|
||||
>No projects were found with that search.</span
|
||||
>No {{ isProjectsActive ? 'projects' : 'people' }} were found with that search.</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<ul id="listDiscoverResults">
|
||||
<ul
|
||||
id="listDiscoverResults"
|
||||
class="border-t border-slate-300 mt-6"
|
||||
v-if="projects.length > 0 || userProfiles.length > 0"
|
||||
>
|
||||
<!-- Projects List -->
|
||||
<template v-if="isProjectsActive">
|
||||
<li
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageDialog" />
|
||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||
|
||||
<div class="mt-4 flex justify-between gap-2">
|
||||
<!-- First Column for Giver -->
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageDialog" />
|
||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||
|
||||
<input
|
||||
v-model="agentDid"
|
||||
|
||||
@@ -34,9 +34,13 @@
|
||||
</div>
|
||||
<div v-else-if="hitError">
|
||||
<span class="text-xl">Error Creating Identity</span>
|
||||
<font-awesome icon="exclamation-triangle" class="fa-fw text-red-500 ml-2"></font-awesome>
|
||||
<font-awesome
|
||||
icon="exclamation-triangle"
|
||||
class="fa-fw text-red-500 ml-2"
|
||||
></font-awesome>
|
||||
<p class="text-sm text-gray-500">
|
||||
Try fully restarting the app. If that doesn't work, back up all data (identities and other data) and reinstall the app.
|
||||
Try fully restarting the app. If that doesn't work, back up all data
|
||||
(identities and other data) and reinstall the app.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
@@ -85,7 +89,7 @@ export default class NewIdentifierView extends Vue {
|
||||
.catch((error) => {
|
||||
this.loading = false;
|
||||
this.hitError = true;
|
||||
console.error('Failed to generate identity:', error);
|
||||
console.error("Failed to generate identity:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,16 @@
|
||||
|
||||
<!-- Members List -->
|
||||
<MembersList v-else :password="password" @error="handleError" />
|
||||
|
||||
<!-- Project Link Section -->
|
||||
<div v-if="projectLink" class="mt-8 p-4 border rounded-lg bg-white shadow">
|
||||
<router-link
|
||||
:to="'/project/' + encodeURIComponent(projectLink)"
|
||||
class="text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||||
>
|
||||
Go To Project Page
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UserNameDialog
|
||||
@@ -69,6 +79,7 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
firstName = "";
|
||||
isRegistered = false;
|
||||
isLoading = true;
|
||||
projectLink = "";
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
$router!: Router;
|
||||
|
||||
@@ -85,10 +96,12 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
async created() {
|
||||
if (!this.groupId) {
|
||||
this.errorMessage = "The group info is missing. Go back and try again.";
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
if (!this.password) {
|
||||
this.errorMessage = "The password is missing. Go back and try again.";
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
@@ -129,6 +142,15 @@ export default class OnboardMeetingMembersView extends Vue {
|
||||
// updateMemberInMeeting sets isLoading to false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the meeting details to get the project link
|
||||
const meetingResponse = await this.axios.get(
|
||||
`${this.apiServer}/api/partner/groupOnboard/${this.groupId}`,
|
||||
{ headers }
|
||||
);
|
||||
if (meetingResponse.data?.data?.projectLink) {
|
||||
this.projectLink = meetingResponse.data.data.projectLink;
|
||||
}
|
||||
} catch (error) {
|
||||
this.errorMessage =
|
||||
serverMessageForUser(error) ||
|
||||
|
||||
@@ -49,12 +49,13 @@
|
||||
|
||||
<div v-if="currentMeeting.password" class="mt-4">
|
||||
<p class="text-gray-600">
|
||||
Share the password with the people you want to onboard.
|
||||
Share the password with the members. You can also send them the
|
||||
"shortcut page for members" link below.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="text-red-600">
|
||||
Your copy of the password is not saved. Edit the meeting, or delete it
|
||||
and create a new meeting.
|
||||
You must reenter your password. Edit this meeting, or delete it and
|
||||
create a new meeting.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +93,7 @@
|
||||
v-if="
|
||||
!isLoading &&
|
||||
isInEditOrCreateMode() &&
|
||||
newOrUpdatedMeeting != null /* duplicate check is for typechecks */
|
||||
newOrUpdatedMeetingInputs != null /* duplicate check is for typechecks */
|
||||
"
|
||||
class="mt-8"
|
||||
>
|
||||
@@ -115,7 +116,7 @@
|
||||
>
|
||||
<input
|
||||
id="meetingName"
|
||||
v-model="newOrUpdatedMeeting.name"
|
||||
v-model="newOrUpdatedMeetingInputs.name"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||
@@ -131,7 +132,7 @@
|
||||
>
|
||||
<input
|
||||
id="expirationTime"
|
||||
v-model="newOrUpdatedMeeting.expiresAt"
|
||||
v-model="newOrUpdatedMeetingInputs.expiresAt"
|
||||
type="datetime-local"
|
||||
required
|
||||
:min="minDateTime"
|
||||
@@ -145,7 +146,7 @@
|
||||
>
|
||||
<input
|
||||
id="password"
|
||||
v-model="newOrUpdatedMeeting.password"
|
||||
v-model="newOrUpdatedMeetingInputs.password"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||
@@ -159,7 +160,7 @@
|
||||
>
|
||||
<input
|
||||
id="userName"
|
||||
v-model="newOrUpdatedMeeting.userFullName"
|
||||
v-model="newOrUpdatedMeetingInputs.userFullName"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||
@@ -167,6 +168,19 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="projectLink" class="block text-sm font-medium text-gray-700"
|
||||
>Project Link</label
|
||||
>
|
||||
<input
|
||||
id="projectLink"
|
||||
v-model="newOrUpdatedMeetingInputs.projectLink"
|
||||
type="text"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
||||
placeholder="Project ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md hover:from-green-500 hover:to-green-800"
|
||||
@@ -201,15 +215,25 @@
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl">Meeting Members</h2>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="!!currentMeeting.password"
|
||||
:to="onboardMeetingMembersLink()"
|
||||
class="inline-block text-blue-600"
|
||||
target="_blank"
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer text-blue-600"
|
||||
@click="copyMembersLinkToClipboard"
|
||||
title="Click to copy link for members"
|
||||
>
|
||||
• Open shortcut page for members
|
||||
<font-awesome icon="external-link" />
|
||||
</router-link>
|
||||
<span>
|
||||
• Page for Members
|
||||
<font-awesome icon="link" />
|
||||
</span>
|
||||
<router-link
|
||||
v-if="!!currentMeeting.password"
|
||||
:to="onboardMeetingMembersLink()"
|
||||
class="inline-block text-blue-600 ml-4"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
>
|
||||
<font-awesome icon="external-link" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<MembersList
|
||||
:password="currentMeeting.password || ''"
|
||||
@@ -219,6 +243,21 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentMeeting?.projectLink"
|
||||
class="mt-8 p-4 border rounded-lg bg-white shadow"
|
||||
>
|
||||
<!-- Project Link Section -->
|
||||
<div>
|
||||
<router-link
|
||||
:to="'/project/' + encodeURIComponent(currentMeeting.projectLink)"
|
||||
class="text-blue-600 hover:text-blue-800 transition-colors duration-200"
|
||||
>
|
||||
Go To Project Page
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isLoading">
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||
@@ -229,6 +268,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import MembersList from "../components/MembersList.vue";
|
||||
@@ -240,19 +281,22 @@ import {
|
||||
} from "../libs/endorserServer";
|
||||
import { encryptMessage } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface ServerMeeting {
|
||||
groupId: number; // from the server
|
||||
name: string; // from the server
|
||||
expiresAt: string; // from the server
|
||||
name: string; // to & from the server
|
||||
expiresAt: string; // to & from the server
|
||||
userFullName?: string; // from the user's session
|
||||
password?: string; // from the user's session
|
||||
projectLink?: string; // to & from the server
|
||||
}
|
||||
|
||||
interface MeetingSetupInfo {
|
||||
interface MeetingSetupInputs {
|
||||
name: string;
|
||||
expiresAt: string;
|
||||
userFullName: string;
|
||||
password: string;
|
||||
projectLink: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -269,7 +313,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
) => void;
|
||||
|
||||
currentMeeting: ServerMeeting | null = null;
|
||||
newOrUpdatedMeeting: MeetingSetupInfo | null = null;
|
||||
newOrUpdatedMeetingInputs: MeetingSetupInputs | null = null;
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
isDeleting = false;
|
||||
@@ -295,11 +339,11 @@ export default class OnboardMeetingView extends Vue {
|
||||
}
|
||||
|
||||
isInCreateMode(): boolean {
|
||||
return this.newOrUpdatedMeeting != null && this.currentMeeting == null;
|
||||
return this.newOrUpdatedMeetingInputs != null && this.currentMeeting == null;
|
||||
}
|
||||
|
||||
isInEditOrCreateMode(): boolean {
|
||||
return this.newOrUpdatedMeeting != null;
|
||||
return this.newOrUpdatedMeetingInputs != null;
|
||||
}
|
||||
|
||||
getDefaultExpirationTime(): string {
|
||||
@@ -324,13 +368,14 @@ export default class OnboardMeetingView extends Vue {
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
blankMeeting(): MeetingSetupInfo {
|
||||
blankMeeting(): MeetingSetupInputs {
|
||||
return {
|
||||
// no groupId yet
|
||||
name: "",
|
||||
expiresAt: this.getDefaultExpirationTime(),
|
||||
userFullName: this.fullName,
|
||||
password: (this.currentMeeting?.password as string) || "",
|
||||
projectLink: (this.currentMeeting?.projectLink as string) || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,19 +387,20 @@ export default class OnboardMeetingView extends Vue {
|
||||
{ headers },
|
||||
);
|
||||
|
||||
const queryPassword = this.$route.query["password"] as string;
|
||||
if (response?.data?.data) {
|
||||
this.currentMeeting = {
|
||||
...response.data.data,
|
||||
userFullName: this.fullName,
|
||||
password: this.currentMeeting?.password || "",
|
||||
password: this.currentMeeting?.password || queryPassword || "",
|
||||
};
|
||||
} else {
|
||||
// no meeting found
|
||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
}
|
||||
} catch (error) {
|
||||
// no meeting found
|
||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,14 +408,14 @@ export default class OnboardMeetingView extends Vue {
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
if (!this.newOrUpdatedMeeting) {
|
||||
if (!this.newOrUpdatedMeetingInputs) {
|
||||
throw Error(
|
||||
"There was no meeting data to create. We should never get here.",
|
||||
);
|
||||
}
|
||||
|
||||
// Convert local time to UTC for comparison and server submission
|
||||
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
|
||||
const localExpiresAt = new Date(this.newOrUpdatedMeetingInputs.expiresAt);
|
||||
const now = new Date();
|
||||
if (localExpiresAt <= now) {
|
||||
this.$notify(
|
||||
@@ -383,7 +429,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeeting.userFullName) {
|
||||
if (!this.newOrUpdatedMeetingInputs.userFullName) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -395,7 +441,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeeting.password) {
|
||||
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -408,35 +454,36 @@ export default class OnboardMeetingView extends Vue {
|
||||
return;
|
||||
}
|
||||
|
||||
// create content with user's name and DID encrypted with password
|
||||
// create content with user's name & DID encrypted with password
|
||||
const content = {
|
||||
name: this.newOrUpdatedMeeting.userFullName,
|
||||
name: this.newOrUpdatedMeetingInputs.userFullName,
|
||||
did: this.activeDid,
|
||||
isRegistered: this.isRegistered,
|
||||
};
|
||||
const encryptedContent = await encryptMessage(
|
||||
JSON.stringify(content),
|
||||
this.newOrUpdatedMeeting.password,
|
||||
this.newOrUpdatedMeetingInputs.password,
|
||||
);
|
||||
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
const response = await this.axios.post(
|
||||
this.apiServer + "/api/partner/groupOnboard",
|
||||
{
|
||||
name: this.newOrUpdatedMeeting.name,
|
||||
name: this.newOrUpdatedMeetingInputs.name,
|
||||
expiresAt: localExpiresAt.toISOString(),
|
||||
content: encryptedContent,
|
||||
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
this.currentMeeting = {
|
||||
...this.newOrUpdatedMeeting,
|
||||
...this.newOrUpdatedMeetingInputs,
|
||||
groupId: response.data.success.groupId,
|
||||
};
|
||||
|
||||
this.newOrUpdatedMeeting = null;
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -502,7 +549,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
});
|
||||
|
||||
this.currentMeeting = null;
|
||||
this.newOrUpdatedMeeting = this.blankMeeting();
|
||||
this.newOrUpdatedMeetingInputs = this.blankMeeting();
|
||||
this.showDeleteConfirm = false;
|
||||
|
||||
this.$notify(
|
||||
@@ -534,11 +581,12 @@ export default class OnboardMeetingView extends Vue {
|
||||
// Populate form with existing meeting data
|
||||
if (this.currentMeeting) {
|
||||
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
||||
this.newOrUpdatedMeeting = {
|
||||
this.newOrUpdatedMeetingInputs = {
|
||||
name: this.currentMeeting.name,
|
||||
expiresAt: this.formatDateForInput(localExpiresAt),
|
||||
userFullName: this.currentMeeting.userFullName || "",
|
||||
password: this.currentMeeting.password || "",
|
||||
projectLink: this.currentMeeting.projectLink || "",
|
||||
};
|
||||
} else {
|
||||
logger.error(
|
||||
@@ -549,18 +597,18 @@ export default class OnboardMeetingView extends Vue {
|
||||
|
||||
cancelEditing() {
|
||||
// Reset form data
|
||||
this.newOrUpdatedMeeting = null;
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
}
|
||||
|
||||
async updateMeeting() {
|
||||
this.isLoading = true;
|
||||
if (!this.newOrUpdatedMeeting) {
|
||||
if (!this.newOrUpdatedMeetingInputs) {
|
||||
throw Error("There was no meeting data to update.");
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert local time to UTC for comparison and server submission
|
||||
const localExpiresAt = new Date(this.newOrUpdatedMeeting.expiresAt);
|
||||
const localExpiresAt = new Date(this.newOrUpdatedMeetingInputs.expiresAt);
|
||||
const now = new Date();
|
||||
if (localExpiresAt <= now) {
|
||||
this.$notify(
|
||||
@@ -574,7 +622,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeeting.userFullName) {
|
||||
if (!this.newOrUpdatedMeetingInputs.userFullName) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -586,7 +634,7 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this.newOrUpdatedMeeting.password) {
|
||||
if (!this.newOrUpdatedMeetingInputs.password) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
@@ -598,15 +646,15 @@ export default class OnboardMeetingView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// create content with user's name and DID encrypted with password
|
||||
// create content with user's name & DID encrypted with password
|
||||
const content = {
|
||||
name: this.newOrUpdatedMeeting.userFullName,
|
||||
name: this.newOrUpdatedMeetingInputs.userFullName,
|
||||
did: this.activeDid,
|
||||
isRegistered: this.isRegistered,
|
||||
};
|
||||
const encryptedContent = await encryptMessage(
|
||||
JSON.stringify(content),
|
||||
this.newOrUpdatedMeeting.password,
|
||||
this.newOrUpdatedMeetingInputs.password,
|
||||
);
|
||||
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
@@ -614,9 +662,10 @@ export default class OnboardMeetingView extends Vue {
|
||||
this.apiServer + "/api/partner/groupOnboard",
|
||||
{
|
||||
// the groupId is in the currentMeeting but it's not necessary while users only have one meeting
|
||||
name: this.newOrUpdatedMeeting.name,
|
||||
name: this.newOrUpdatedMeetingInputs.name,
|
||||
expiresAt: localExpiresAt.toISOString(),
|
||||
content: encryptedContent,
|
||||
projectLink: this.newOrUpdatedMeetingInputs.projectLink,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
@@ -624,10 +673,17 @@ export default class OnboardMeetingView extends Vue {
|
||||
if (response.data && response.data.success) {
|
||||
// Update the current meeting with only the necessary fields
|
||||
this.currentMeeting = {
|
||||
...this.newOrUpdatedMeeting,
|
||||
...this.newOrUpdatedMeetingInputs,
|
||||
groupId: (this.currentMeeting?.groupId as number) || -1,
|
||||
};
|
||||
this.newOrUpdatedMeeting = null;
|
||||
this.newOrUpdatedMeetingInputs = null;
|
||||
|
||||
if (this.currentMeeting?.password) {
|
||||
this.$router.push({
|
||||
name: "onboard-meeting-setup",
|
||||
query: { password: this.currentMeeting?.password },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw { response: response };
|
||||
}
|
||||
@@ -673,5 +729,21 @@ export default class OnboardMeetingView extends Vue {
|
||||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
copyMembersLinkToClipboard() {
|
||||
useClipboard()
|
||||
.copy(this.onboardMeetingMembersLink())
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Copied",
|
||||
text: "The member link is copied to the clipboard.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,6 +88,8 @@ import { test, expect } from '@playwright/test';
|
||||
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
||||
|
||||
test('Record 9 new gifts', async ({ page }) => {
|
||||
test.slow(); // Set timeout longer
|
||||
|
||||
const giftCount = 9;
|
||||
const standardTitle = 'Gift ';
|
||||
const finalTitles = [];
|
||||
@@ -127,6 +129,6 @@ test('Record 9 new gifts', async ({ page }) => {
|
||||
await expect(page.locator('ul#listLatestActivity li')
|
||||
.filter({ hasText: finalTitles[i] })
|
||||
.first())
|
||||
.toBeVisible({ timeout: 10000 });
|
||||
.toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||