Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3a91e56d | ||
|
|
3214c79dbc | ||
|
|
e5518cd47c | ||
|
|
62553a37aa | ||
|
|
ea13250e5d | ||
|
|
b79cccc591 | ||
|
|
ff75fa5349 | ||
|
|
56f945d29f | ||
|
|
8a9f7d4231 | ||
|
|
3487f49f49 | ||
|
|
f5846cbe78 | ||
|
|
243f6f7798 | ||
|
|
72cf0211ce | ||
|
|
febbd4054a | ||
|
|
3ca82fe762 | ||
|
|
5fea1cf530 |
@@ -1,292 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# TimeSafari Cross-Platform Architecture Guide
|
||||
|
||||
## 1. Platform Support Matrix
|
||||
|
||||
| Feature | Web (PWA) | Capacitor (Mobile) | Electron (Desktop) | PyWebView (Desktop) |
|
||||
|---------|-----------|-------------------|-------------------|-------------------|
|
||||
| QR Code Scanning | WebInlineQRScanner | @capacitor-mlkit/barcode-scanning | Not Implemented | Not Implemented |
|
||||
| Deep Linking | URL Parameters | App URL Open Events | Not Implemented | Not Implemented |
|
||||
| File System | Limited (Browser API) | Capacitor Filesystem | Electron fs | PyWebView Python Bridge |
|
||||
| Camera Access | MediaDevices API | Capacitor Camera | Not Implemented | Not Implemented |
|
||||
| Platform Detection | Web APIs | Capacitor.isNativePlatform() | process.env checks | process.env checks |
|
||||
|
||||
## 2. Project Structure
|
||||
|
||||
### 2.1 Core Directories
|
||||
```
|
||||
src/
|
||||
├── components/ # Vue components
|
||||
├── services/ # Platform services and business logic
|
||||
├── views/ # Page components
|
||||
├── router/ # Vue router configuration
|
||||
├── types/ # TypeScript type definitions
|
||||
├── utils/ # Utility functions
|
||||
├── lib/ # Core libraries
|
||||
├── platforms/ # Platform-specific implementations
|
||||
├── electron/ # Electron-specific code
|
||||
├── constants/ # Application constants
|
||||
├── db/ # Database related code
|
||||
├── interfaces/ # TypeScript interfaces and type definitions
|
||||
└── assets/ # Static assets
|
||||
```
|
||||
|
||||
### 2.2 Entry Points
|
||||
```
|
||||
src/
|
||||
├── main.ts # Base entry
|
||||
├── main.common.ts # Shared initialization
|
||||
├── main.capacitor.ts # Mobile entry
|
||||
├── main.electron.ts # Electron entry
|
||||
├── main.pywebview.ts # PyWebView entry
|
||||
└── main.web.ts # Web/PWA entry
|
||||
```
|
||||
|
||||
### 2.3 Build Configurations
|
||||
```
|
||||
root/
|
||||
├── vite.config.common.mts # Shared config
|
||||
├── vite.config.capacitor.mts # Mobile build
|
||||
├── vite.config.electron.mts # Electron build
|
||||
├── vite.config.pywebview.mts # PyWebView build
|
||||
├── vite.config.web.mts # Web/PWA build
|
||||
└── vite.config.utils.mts # Build utilities
|
||||
```
|
||||
|
||||
## 3. Service Architecture
|
||||
|
||||
### 3.1 Service Organization
|
||||
```
|
||||
services/
|
||||
├── QRScanner/ # QR code scanning service
|
||||
│ ├── WebInlineQRScanner.ts
|
||||
│ └── interfaces.ts
|
||||
├── platforms/ # Platform-specific services
|
||||
│ ├── WebPlatformService.ts
|
||||
│ ├── CapacitorPlatformService.ts
|
||||
│ ├── ElectronPlatformService.ts
|
||||
│ └── PyWebViewPlatformService.ts
|
||||
└── factory/ # Service factories
|
||||
└── PlatformServiceFactory.ts
|
||||
```
|
||||
|
||||
### 3.2 Service Factory Pattern
|
||||
```typescript
|
||||
// PlatformServiceFactory.ts
|
||||
export class PlatformServiceFactory {
|
||||
private static instance: PlatformService | null = null;
|
||||
|
||||
public static getInstance(): PlatformService {
|
||||
if (!PlatformServiceFactory.instance) {
|
||||
const platform = process.env.VITE_PLATFORM || "web";
|
||||
PlatformServiceFactory.instance = createPlatformService(platform);
|
||||
}
|
||||
return PlatformServiceFactory.instance;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Feature Implementation Guidelines
|
||||
|
||||
### 4.1 QR Code Scanning
|
||||
|
||||
1. **Service Interface**
|
||||
```typescript
|
||||
interface QRScannerService {
|
||||
checkPermissions(): Promise<boolean>;
|
||||
requestPermissions(): Promise<boolean>;
|
||||
isSupported(): Promise<boolean>;
|
||||
startScan(): Promise<void>;
|
||||
stopScan(): Promise<void>;
|
||||
addListener(listener: ScanListener): void;
|
||||
onStream(callback: (stream: MediaStream | null) => void): void;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Platform-Specific Implementation**
|
||||
```typescript
|
||||
// WebInlineQRScanner.ts
|
||||
export class WebInlineQRScanner implements QRScannerService {
|
||||
private scanListener: ScanListener | null = null;
|
||||
private isScanning = false;
|
||||
private stream: MediaStream | null = null;
|
||||
private events = new EventEmitter();
|
||||
|
||||
// Implementation of interface methods
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Deep Linking
|
||||
|
||||
1. **URL Structure**
|
||||
```typescript
|
||||
// Format: timesafari://<route>[/<param>][?queryParam1=value1]
|
||||
interface DeepLinkParams {
|
||||
route: string;
|
||||
params?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Platform Handlers**
|
||||
```typescript
|
||||
// Capacitor
|
||||
App.addListener("appUrlOpen", handleDeepLink);
|
||||
|
||||
// Web
|
||||
router.beforeEach((to, from, next) => {
|
||||
handleWebDeepLink(to.query);
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Build Process
|
||||
|
||||
### 5.1 Environment Configuration
|
||||
```typescript
|
||||
// vite.config.common.mts
|
||||
export function createBuildConfig(mode: string) {
|
||||
return {
|
||||
define: {
|
||||
'process.env.VITE_PLATFORM': JSON.stringify(mode),
|
||||
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative),
|
||||
__IS_MOBILE__: JSON.stringify(isCapacitor),
|
||||
__USE_QR_READER__: JSON.stringify(!isCapacitor)
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Platform-Specific Builds
|
||||
|
||||
```bash
|
||||
# Build commands from package.json
|
||||
"build:web": "vite build --config vite.config.web.mts",
|
||||
"build:capacitor": "vite build --config vite.config.capacitor.mts",
|
||||
"build:electron": "vite build --config vite.config.electron.mts",
|
||||
"build:pywebview": "vite build --config vite.config.pywebview.mts"
|
||||
```
|
||||
|
||||
## 6. Testing Strategy
|
||||
|
||||
### 6.1 Test Configuration
|
||||
```typescript
|
||||
// playwright.config-local.ts
|
||||
const config: PlaywrightTestConfig = {
|
||||
projects: [
|
||||
{
|
||||
name: 'web',
|
||||
use: { browserName: 'chromium' }
|
||||
},
|
||||
{
|
||||
name: 'mobile',
|
||||
use: { ...devices['Pixel 5'] }
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### 6.2 Platform-Specific Tests
|
||||
```typescript
|
||||
test('QR scanning works on mobile', async ({ page }) => {
|
||||
test.skip(!process.env.MOBILE_TEST, 'Mobile-only test');
|
||||
// Test implementation
|
||||
});
|
||||
```
|
||||
|
||||
## 7. Error Handling
|
||||
|
||||
### 7.1 Global Error Handler
|
||||
```typescript
|
||||
function setupGlobalErrorHandler(app: VueApp) {
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
logger.error("[App Error]", {
|
||||
error: err,
|
||||
info,
|
||||
component: instance?.$options.name
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Platform-Specific Error Handling
|
||||
```typescript
|
||||
// API error handling for Capacitor
|
||||
if (process.env.VITE_PLATFORM === 'capacitor') {
|
||||
logger.error(`[Capacitor API Error] ${endpoint}:`, {
|
||||
message: error.message,
|
||||
status: error.response?.status
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Best Practices
|
||||
|
||||
### 8.1 Code Organization
|
||||
- Use platform-specific directories for unique implementations
|
||||
- Share common code through service interfaces
|
||||
- Implement feature detection before using platform capabilities
|
||||
- Keep platform-specific code isolated in dedicated directories
|
||||
- Use TypeScript interfaces for cross-platform compatibility
|
||||
|
||||
### 8.2 Platform Detection
|
||||
```typescript
|
||||
const platformService = PlatformServiceFactory.getInstance();
|
||||
const capabilities = platformService.getCapabilities();
|
||||
|
||||
if (capabilities.hasCamera) {
|
||||
// Implement camera features
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Feature Implementation
|
||||
1. Define platform-agnostic interface
|
||||
2. Create platform-specific implementations
|
||||
3. Use factory pattern for instantiation
|
||||
4. Implement graceful fallbacks
|
||||
5. Add comprehensive error handling
|
||||
6. Use dependency injection for better testability
|
||||
|
||||
## 9. Dependency Management
|
||||
|
||||
### 9.1 Platform-Specific Dependencies
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@capacitor/core": "^6.2.0",
|
||||
"electron": "^33.2.1",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Conditional Loading
|
||||
```typescript
|
||||
if (process.env.VITE_PLATFORM === 'capacitor') {
|
||||
await import('@capacitor/core');
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Security Considerations
|
||||
|
||||
### 10.1 Permission Handling
|
||||
```typescript
|
||||
async checkPermissions(): Promise<boolean> {
|
||||
if (platformService.isCapacitor()) {
|
||||
return await checkNativePermissions();
|
||||
}
|
||||
return await checkWebPermissions();
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 Data Storage
|
||||
- Use secure storage mechanisms for sensitive data
|
||||
- Implement proper encryption for stored data
|
||||
- Follow platform-specific security guidelines
|
||||
- Regular security audits and updates
|
||||
|
||||
This document should be updated as new features are added or platform-specific implementations change. Regular reviews ensure it remains current with the codebase.
|
||||
@@ -1,222 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Camera Implementation Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes how camera functionality is implemented across the TimeSafari application. The application uses cameras for two main purposes:
|
||||
|
||||
1. QR Code scanning
|
||||
2. Photo capture
|
||||
|
||||
## Components
|
||||
|
||||
### QRScannerDialog.vue
|
||||
|
||||
Primary component for QR code scanning in web browsers.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Uses `qrcode-stream` for web-based QR scanning
|
||||
- Supports both front and back cameras
|
||||
- Provides real-time camera status feedback
|
||||
- Implements error handling with user-friendly messages
|
||||
- Includes camera switching functionality
|
||||
|
||||
**Camera Access Flow:**
|
||||
|
||||
1. Checks for camera API availability
|
||||
2. Enumerates available video devices
|
||||
3. Requests camera permissions
|
||||
4. Initializes camera stream with preferred settings
|
||||
5. Handles various error conditions with specific messages
|
||||
|
||||
### PhotoDialog.vue
|
||||
|
||||
Component for photo capture and selection.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Cross-platform photo capture interface
|
||||
- Image cropping capabilities
|
||||
- File selection fallback
|
||||
- Unified interface for different platforms
|
||||
|
||||
## Services
|
||||
|
||||
### QRScanner Services
|
||||
|
||||
#### WebDialogQRScanner
|
||||
|
||||
Web-based implementation of QR scanning.
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
- `checkPermissions()`: Verifies camera permission status
|
||||
- `requestPermissions()`: Requests camera access
|
||||
- `isSupported()`: Checks for camera API support
|
||||
- Handles various error conditions with specific messages
|
||||
|
||||
#### CapacitorQRScanner
|
||||
|
||||
Native implementation using Capacitor's MLKit.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Uses `@capacitor-mlkit/barcode-scanning`
|
||||
- Supports both front and back cameras
|
||||
- Implements permission management
|
||||
- Provides continuous scanning capability
|
||||
|
||||
### Platform Services
|
||||
|
||||
#### WebPlatformService
|
||||
|
||||
Web-specific implementation of platform features.
|
||||
|
||||
**Camera Capabilities:**
|
||||
|
||||
- Uses HTML5 file input with capture attribute
|
||||
- Falls back to file selection if camera unavailable
|
||||
- Processes captured images for consistent format
|
||||
|
||||
#### CapacitorPlatformService
|
||||
|
||||
Native implementation using Capacitor.
|
||||
|
||||
**Camera Features:**
|
||||
|
||||
- Uses `Camera.getPhoto()` for native camera access
|
||||
- Supports image editing
|
||||
- Configures high-quality image capture
|
||||
- Handles base64 image processing
|
||||
|
||||
#### ElectronPlatformService
|
||||
|
||||
Desktop implementation (currently unimplemented).
|
||||
|
||||
**Status:**
|
||||
|
||||
- Camera functionality not yet implemented
|
||||
- Planned to use Electron's media APIs
|
||||
|
||||
## Platform-Specific Considerations
|
||||
|
||||
### iOS
|
||||
|
||||
- Requires `NSCameraUsageDescription` in Info.plist
|
||||
- Supports both front and back cameras
|
||||
- Implements proper permission handling
|
||||
|
||||
### Android
|
||||
|
||||
- Requires camera permissions in manifest
|
||||
- Supports both front and back cameras
|
||||
- Handles permission requests through Capacitor
|
||||
|
||||
### Web
|
||||
|
||||
- Requires HTTPS for camera access
|
||||
- Implements fallback mechanisms
|
||||
- Handles browser compatibility issues
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Scenarios
|
||||
|
||||
1. No camera found
|
||||
2. Permission denied
|
||||
3. Camera in use by another application
|
||||
4. HTTPS required
|
||||
5. Browser compatibility issues
|
||||
|
||||
### Error Response
|
||||
|
||||
- User-friendly error messages
|
||||
- Troubleshooting tips
|
||||
- Clear instructions for resolution
|
||||
- Platform-specific guidance
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Permission Management
|
||||
|
||||
- Explicit permission requests
|
||||
- Permission state tracking
|
||||
- Graceful handling of denied permissions
|
||||
|
||||
### Data Handling
|
||||
|
||||
- Secure image processing
|
||||
- Proper cleanup of camera resources
|
||||
- No persistent storage of camera data
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Camera Access
|
||||
|
||||
1. Always check for camera availability
|
||||
2. Request permissions explicitly
|
||||
3. Handle all error conditions
|
||||
4. Provide clear user feedback
|
||||
5. Implement proper cleanup
|
||||
|
||||
### Performance
|
||||
|
||||
1. Optimize camera resolution
|
||||
2. Implement proper resource cleanup
|
||||
3. Handle camera switching efficiently
|
||||
4. Manage memory usage
|
||||
|
||||
### User Experience
|
||||
|
||||
1. Clear status indicators
|
||||
2. Intuitive camera controls
|
||||
3. Helpful error messages
|
||||
4. Smooth camera switching
|
||||
5. Responsive UI feedback
|
||||
|
||||
## Future Improvements
|
||||
|
||||
### Planned Enhancements
|
||||
|
||||
1. Implement Electron camera support
|
||||
2. Add advanced camera features
|
||||
3. Improve error handling
|
||||
4. Enhance user feedback
|
||||
5. Optimize performance
|
||||
|
||||
### Known Issues
|
||||
|
||||
1. Electron camera implementation pending
|
||||
2. Some browser compatibility limitations
|
||||
3. Platform-specific quirks to address
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Key Packages
|
||||
|
||||
- `@capacitor-mlkit/barcode-scanning`
|
||||
- `qrcode-stream`
|
||||
- `vue-picture-cropper`
|
||||
- Platform-specific camera APIs
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
1. Permission handling
|
||||
2. Camera switching
|
||||
3. Error conditions
|
||||
4. Platform compatibility
|
||||
5. Performance metrics
|
||||
|
||||
### Test Environment
|
||||
|
||||
- Multiple browsers
|
||||
- iOS and Android devices
|
||||
- Desktop platforms
|
||||
- Various network conditions
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
description: General project rules that applies to all file types. Should be most general
|
||||
globs: **/*
|
||||
---
|
||||
- Naming Conventions: Follow clear and consistent naming conventions.
|
||||
- Performance Optimization: Optimize code for performance.
|
||||
- Key Conventions: Adhere to project-specific key conventions.
|
||||
- Error Handling and Validation: implement comprehensive error handling and validation.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Applies general TypeScript best practices and style guidelines to all TypeScript files in the project.
|
||||
globs: **/*.ts
|
||||
---
|
||||
- You are an expert in TypeScript.
|
||||
- TypeScript Usage: Follow TypeScript best practices for type safety and code maintainability.
|
||||
- Syntax and Formatting: Adhere to consistent coding style and formatting guidelines for TypeScript.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
- make reports chronologically in paragraph form without using pronouns or references to people
|
||||
- use this git command to make a report: git log --since="12 hours ago" --pretty=format:"%H - %s (%an)" -p --color --all > output.txt
|
||||
- the output.txt is the basis of work in the last 12 hours
|
||||
- reports should always include pending issues and next steps along with urls to commits that day
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Apply Tailwind CSS styling conventions in all relevant files.
|
||||
globs: **/*.tsx
|
||||
---
|
||||
- You are an expert in Tailwind.
|
||||
- UI and Styling: Use Tailwind CSS for consistent UI styling.
|
||||
@@ -3,11 +3,6 @@ description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Time Safari Context
|
||||
|
||||
## Project Overview
|
||||
@@ -44,7 +39,7 @@ This application is built on a privacy-preserving claims architecture (via endor
|
||||
- **Cryptographic Verification**: All claims and confirmations are cryptographically signed
|
||||
- **User-Controlled Visibility**: Users explicitly control who can see their identifiers and data
|
||||
- **Merkle-Chained Claims**: Claims are cryptographically chained for verification and integrity
|
||||
- **Native and Web App**: Works on Capacitor (iOS, Android), Desktop (Electron and CEFPython), and web browsers
|
||||
- **Native and Web App**: Works on iOS, Android, and web browsers
|
||||
|
||||
## User Journey
|
||||
|
||||
@@ -102,7 +97,7 @@ When developing new features, be mindful of these constraints:
|
||||
|
||||
## Project Technologies
|
||||
|
||||
- Typescript using ES6 classes using vue-facing-decorator
|
||||
- Typescript using ES6 classes
|
||||
- TailwindCSS
|
||||
- Vite Build Tool
|
||||
- Playwright E2E testing
|
||||
@@ -273,4 +268,3 @@ This expanded documentation provides:
|
||||
3. Real-world examples
|
||||
4. TypeScript integration
|
||||
5. Best practices for Time Safari
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
- all cross platform builds need to conform to [PlatformService.ts](mdc:src/services/PlatformService.ts), and [PlatformServiceFactory.ts](mdc:src/services/PlatformServiceFactory.ts)
|
||||
- [CapacitorPlatformService.ts](mdc:src/services/platforms/CapacitorPlatformService.ts) is used for mobile both iOS and Android
|
||||
- [ElectronPlatformService.ts](mdc:src/services/platforms/ElectronPlatformService.ts) is used for cross-platform (Windows, MacOS, and Linux) desktop builds using Electron.
|
||||
- [WebPlatformService.ts](mdc:src/services/platforms/WebPlatformService.ts) is used for traditional web browsers and PWA (Progressive Web Applications)
|
||||
- [PyWebViewPlatformService.ts](mdc:src/services/platforms/PyWebViewPlatformService.ts) is used for handling a electron-like desktop application which can run Python
|
||||
- Vite is used to differentiate builds for platforms
|
||||
- @vite.config.mts is used for general configuration which uses environment variables to determine next actions
|
||||
- @vite.config.common.mts handles common features in vite builds.
|
||||
- [vite.config.capacitor.mts](mdc:vite.config.capacitor.mts) handles features of Vite builds for capacitor
|
||||
- [vite.config.electron.mts](mdc:vite.config.electron.mts) handles features of Vite builds for electron
|
||||
- [vite.config.web.mts](mdc:vite.config.web.mts) handles features of Vite builds for traditional web browsers and PWAs
|
||||
@@ -1,32 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/vue3-recommended",
|
||||
"eslint:recommended",
|
||||
"@vue/typescript/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
// parserOptions: {
|
||||
// ecmaVersion: 2020,
|
||||
// },
|
||||
rules: {
|
||||
"max-len": ["warn", {
|
||||
code: 100,
|
||||
ignoreComments: true,
|
||||
ignorePattern: '^\\s*class="[^"]*"$',
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
ignoreUrls: true,
|
||||
}],
|
||||
"no-console": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true,
|
||||
"browser": true,
|
||||
"es2022": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-recommended",
|
||||
"eslint:recommended",
|
||||
"@vue/typescript/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"parser": "vue-eslint-parser",
|
||||
"parserOptions": {
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module",
|
||||
"extraFileExtensions": [".vue"],
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"vue",
|
||||
"prettier"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"no-debugger": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
||||
"vue/no-parsing-error": ["error", {
|
||||
"x-invalid-end-tag": false,
|
||||
"invalid-first-character-of-tag-name": false
|
||||
}],
|
||||
"vue/no-v-html": "warn",
|
||||
"prettier/prettier": ["error", {
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"trailingComma": "none"
|
||||
}]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.mts"],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx", "*.mjs"],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export default {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
browser: true,
|
||||
es2022: true
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/vue3-recommended',
|
||||
'eslint:recommended',
|
||||
'@vue/typescript/recommended'
|
||||
],
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: {
|
||||
'ts': '@typescript-eslint/parser',
|
||||
'js': '@typescript-eslint/parser',
|
||||
'<template>': 'espree'
|
||||
},
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions: ['.vue'],
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
'@typescript-eslint',
|
||||
'vue'
|
||||
],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }]
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.ts', '*.tsx', '*.mts'],
|
||||
parser: '@typescript-eslint/parser'
|
||||
},
|
||||
{
|
||||
files: ['*.js', '*.jsx', '*.mjs'],
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue', '.mjs', '.mts']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
+3
-2
@@ -42,7 +42,7 @@ dist-electron-packages
|
||||
.ruby-version
|
||||
+.env
|
||||
|
||||
# Test files generated by scripts test-ios.js & test-android.js
|
||||
# Generated test files
|
||||
.generated/
|
||||
|
||||
.env.default
|
||||
@@ -53,4 +53,5 @@ build_logs/
|
||||
|
||||
android/app/src/main/assets/public
|
||||
android/app/src/main/res
|
||||
|
||||
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
|
||||
android/.gradle/file-system.probe
|
||||
+268
-213
@@ -11,7 +11,7 @@ For a quick dev environment setup, use [pkgx](https://pkgx.dev).
|
||||
- Git
|
||||
- For Android builds: Android Studio with SDK installed
|
||||
- For iOS builds: macOS with Xcode and ruby gems & bundle
|
||||
- `pkgx +rubygems.org sh`
|
||||
- pkgx +rubygems.org sh
|
||||
|
||||
- ... and you may have to fix these, especially with pkgx
|
||||
|
||||
@@ -54,7 +54,7 @@ Install dependencies:
|
||||
1. Run the production build:
|
||||
|
||||
```bash
|
||||
npm run build:web
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built files will be in the `dist` directory.
|
||||
@@ -111,106 +111,8 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
|
||||
|
||||
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
The application can be containerized using Docker for consistent deployment across environments.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker installed on your system
|
||||
- Docker Compose (optional, for multi-container setups)
|
||||
|
||||
### Building the Docker Image
|
||||
|
||||
1. Build the Docker image:
|
||||
|
||||
```bash
|
||||
docker build -t timesafari:latest .
|
||||
```
|
||||
|
||||
2. For development builds with specific environment variables:
|
||||
|
||||
```bash
|
||||
docker build --build-arg NODE_ENV=development -t timesafari:dev .
|
||||
```
|
||||
|
||||
### Running the Container
|
||||
|
||||
1. Run the container:
|
||||
|
||||
```bash
|
||||
docker run -d -p 80:80 timesafari:latest
|
||||
```
|
||||
|
||||
2. For development with hot-reloading:
|
||||
|
||||
```bash
|
||||
docker run -d -p 80:80 -v $(pwd):/app timesafari:dev
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
Create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
timesafari:
|
||||
build: .
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Run with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For production deployment, consider the following:
|
||||
|
||||
1. Use specific version tags instead of 'latest'
|
||||
2. Implement health checks
|
||||
3. Configure proper logging
|
||||
4. Set up reverse proxy with SSL termination
|
||||
5. Use Docker secrets for sensitive data
|
||||
|
||||
Example production deployment:
|
||||
|
||||
```bash
|
||||
# Build with specific version
|
||||
docker build -t timesafari:1.0.0 .
|
||||
|
||||
# Run with production settings
|
||||
docker run -d \
|
||||
--name timesafari \
|
||||
-p 80:80 \
|
||||
--restart unless-stopped \
|
||||
-e NODE_ENV=production \
|
||||
timesafari:1.0.0
|
||||
```
|
||||
|
||||
### Troubleshooting Docker
|
||||
|
||||
1. **Container fails to start**
|
||||
- Check logs: `docker logs <container_id>`
|
||||
- Verify port availability
|
||||
- Check environment variables
|
||||
|
||||
2. **Build fails**
|
||||
- Ensure all dependencies are in package.json
|
||||
- Check Dockerfile syntax
|
||||
- Verify build context
|
||||
|
||||
3. **Performance issues**
|
||||
- Monitor container resources: `docker stats`
|
||||
- Check nginx configuration
|
||||
- Verify caching settings
|
||||
|
||||
## Desktop Build (Electron)
|
||||
|
||||
@@ -236,75 +138,21 @@ docker run -d \
|
||||
- AppImage: `dist-electron-packages/TimeSafari-x.x.x.AppImage`
|
||||
- DEB: `dist-electron-packages/timesafari_x.x.x_amd64.deb`
|
||||
|
||||
### macOS Build
|
||||
|
||||
1. Build the electron app in production mode:
|
||||
|
||||
```bash
|
||||
npm run build:electron-prod
|
||||
```
|
||||
|
||||
2. Package the Electron app for macOS:
|
||||
|
||||
```bash
|
||||
# For Intel Macs
|
||||
npm run electron:build-mac
|
||||
|
||||
# For Universal build (Intel + Apple Silicon)
|
||||
npm run electron:build-mac-universal
|
||||
```
|
||||
|
||||
3. The packaged applications will be in `dist-electron-packages/`:
|
||||
- `.app` bundle: `TimeSafari.app`
|
||||
- `.dmg` installer: `TimeSafari-x.x.x.dmg`
|
||||
- `.zip` archive: `TimeSafari-x.x.x-mac.zip`
|
||||
|
||||
### Code Signing and Notarization (macOS)
|
||||
|
||||
For public distribution on macOS, you need to code sign and notarize your app:
|
||||
|
||||
1. Set up environment variables:
|
||||
```bash
|
||||
export CSC_LINK=/path/to/your/certificate.p12
|
||||
export CSC_KEY_PASSWORD=your_certificate_password
|
||||
export APPLE_ID=your_apple_id
|
||||
export APPLE_ID_PASSWORD=your_app_specific_password
|
||||
```
|
||||
|
||||
2. Build with signing:
|
||||
```bash
|
||||
npm run electron:build-mac
|
||||
```
|
||||
|
||||
### Running the Packaged App
|
||||
|
||||
- **Linux**:
|
||||
- AppImage: Make executable and run
|
||||
```bash
|
||||
chmod +x dist-electron-packages/TimeSafari-*.AppImage
|
||||
./dist-electron-packages/TimeSafari-*.AppImage
|
||||
```
|
||||
- DEB: Install and run
|
||||
```bash
|
||||
sudo dpkg -i dist-electron-packages/timesafari_*_amd64.deb
|
||||
timesafari
|
||||
```
|
||||
- AppImage: Make executable and run
|
||||
|
||||
- **macOS**:
|
||||
- `.app` bundle: Double-click `TimeSafari.app` in Finder
|
||||
- `.dmg` installer:
|
||||
1. Double-click the `.dmg` file
|
||||
2. Drag the app to your Applications folder
|
||||
3. Launch from Applications
|
||||
- `.zip` archive:
|
||||
1. Extract the `.zip` file
|
||||
2. Move `TimeSafari.app` to your Applications folder
|
||||
3. Launch from Applications
|
||||
```bash
|
||||
chmod +x dist-electron-packages/TimeSafari-*.AppImage
|
||||
./dist-electron-packages/TimeSafari-*.AppImage
|
||||
```
|
||||
|
||||
Note: If you get a security warning when running the app:
|
||||
1. Right-click the app
|
||||
2. Select "Open"
|
||||
3. Click "Open" in the security dialog
|
||||
- DEB: Install and run
|
||||
|
||||
```bash
|
||||
sudo dpkg -i dist-electron-packages/timesafari_*_amd64.deb
|
||||
timesafari
|
||||
```
|
||||
|
||||
### Development Testing
|
||||
|
||||
@@ -327,7 +175,6 @@ Prerequisites: macOS with Xcode installed
|
||||
1. Build the web assets:
|
||||
|
||||
```bash
|
||||
npm run build:web
|
||||
npm run build:capacitor
|
||||
```
|
||||
|
||||
@@ -337,8 +184,6 @@ Prerequisites: macOS with Xcode installed
|
||||
npx cap sync ios
|
||||
```
|
||||
|
||||
- If that fails with "Could not find..." then look at the "gem_path" instructions above.
|
||||
|
||||
3. Copy the assets:
|
||||
|
||||
```bash
|
||||
@@ -346,38 +191,13 @@ Prerequisites: macOS with Xcode installed
|
||||
npx capacitor-assets generate --ios
|
||||
```
|
||||
|
||||
4. Bump the version to match Android
|
||||
|
||||
```
|
||||
cd ios/App
|
||||
xcrun agvtool new-version 15
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.4.5;/g" > temp
|
||||
mv temp App.xcodeproj/project.pbxproj
|
||||
cd -
|
||||
```
|
||||
|
||||
5. Open the project in Xcode:
|
||||
3. Open the project in Xcode:
|
||||
|
||||
```bash
|
||||
npx cap open ios
|
||||
```
|
||||
|
||||
6. Use Xcode to build and run on simulator or device.
|
||||
|
||||
7. Release
|
||||
|
||||
* Under "General" renamed a bunch of things to "Time Safari"
|
||||
* Choose Product -> Destination -> Build Any iOS
|
||||
* Choose Product -> Archive
|
||||
* This will trigger a build and take time, needing user's "login" keychain password which is just their login password, repeatedly.
|
||||
* If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`).
|
||||
* Click Distribute -> App Store Connect
|
||||
* In AppStoreConnect, add the build to the distribution: remove the current build with the "-" when you hover over it, then "Add Build" with the new build.
|
||||
* It can take 15 minutes for the build to show up in the list of builds.
|
||||
* You'll probably have to "Manage" something about encryption, disallowed in France.
|
||||
* Then "Save" and "Add to Review" and "Resubmit to App Review".
|
||||
4. Use Xcode to build and run on simulator or device.
|
||||
|
||||
#### First-time iOS Configuration
|
||||
|
||||
@@ -398,7 +218,6 @@ Prerequisites: Android Studio with SDK installed
|
||||
cd android
|
||||
./gradlew clean
|
||||
./gradlew assembleDebug
|
||||
cd ..
|
||||
```
|
||||
|
||||
2. Update Android project with latest build:
|
||||
@@ -413,15 +232,13 @@ Prerequisites: Android Studio with SDK installed
|
||||
npx capacitor-assets generate --android
|
||||
```
|
||||
|
||||
4. Bump version to match iOS, in android/app/build.gradleq
|
||||
|
||||
5. Open the project in Android Studio:
|
||||
4. Open the project in Android Studio:
|
||||
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
6. Use Android Studio to build and run on emulator or device.
|
||||
5. Use Android Studio to build and run on emulator or device.
|
||||
|
||||
## Android Build from the console
|
||||
|
||||
@@ -439,23 +256,12 @@ Prerequisites: Android Studio with SDK installed
|
||||
./gradlew bundleDebug -Dlint.baselines.continue=true
|
||||
```
|
||||
|
||||
... or, to create a signed release:
|
||||
|
||||
* Setup by adding the app/gradle.properties.secrets file (see properties at top of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
|
||||
* In app/build.gradle, bump the versionCode and maybe the versionName
|
||||
* Then `bundleRelease`:
|
||||
... or, to create a signed release, add the app/gradle.properties.secrets file (see properties at top of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file, then `bundleRelease`:
|
||||
|
||||
```bash
|
||||
./gradlew bundleRelease -Dlint.baselines.continue=true
|
||||
```
|
||||
|
||||
... and find your `aab` file at app/build/outputs/bundle/release
|
||||
|
||||
At play.google.com/console:
|
||||
|
||||
- Create new release, upload, hit Next.
|
||||
|
||||
- Save & send changes for review.
|
||||
|
||||
|
||||
## First-time Android Configuration for deep links
|
||||
@@ -469,4 +275,253 @@ You must add the following intent filter to the `android/app/src/main/AndroidMan
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="timesafari" />
|
||||
</intent-filter>
|
||||
```
|
||||
```
|
||||
|
||||
You must also add the following to the `android/app/build.gradle` file:
|
||||
|
||||
```gradle
|
||||
android {
|
||||
// ... existing config ...
|
||||
|
||||
lintOptions {
|
||||
disable 'UnsanitizedFilenameFromContentProvider'
|
||||
abortOnError false
|
||||
baseline file("lint-baseline.xml")
|
||||
|
||||
// Ignore Capacitor module issues
|
||||
ignore 'DefaultLocale'
|
||||
ignore 'UnsanitizedFilenameFromContentProvider'
|
||||
ignore 'LintBaseline'
|
||||
ignore 'LintBaselineFixed'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Modify `/android/build.gradle` to use a stable version of AGP and make sure Kotlin version is compatible.
|
||||
|
||||
```gradle
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
// Use a stable version of AGP
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
|
||||
// Make sure Kotlin version is compatible
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
// Add this to handle version conflicts
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0'
|
||||
force 'org.jetbrains.kotlin:kotlin-stdlib-common:1.8.0'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PyWebView Desktop Build
|
||||
|
||||
### Prerequisites for PyWebView
|
||||
|
||||
- Python 3.8 or higher
|
||||
- pip (Python package manager)
|
||||
- virtualenv (recommended)
|
||||
- System dependencies:
|
||||
|
||||
```bash
|
||||
# For Ubuntu/Debian
|
||||
sudo apt-get install python3-webview
|
||||
# or
|
||||
sudo apt-get install python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-webkit2-4.0
|
||||
|
||||
# For Arch Linux
|
||||
sudo pacman -S webkit2gtk python-gobject python-cairo
|
||||
|
||||
# For Fedora
|
||||
sudo dnf install python3-webview
|
||||
# or
|
||||
sudo dnf install python3-gobject python3-cairo webkit2gtk3
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
1. Create and activate a virtual environment (recommended):
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Linux/macOS
|
||||
# or
|
||||
.venv\Scripts\activate # On Windows
|
||||
```
|
||||
|
||||
2. Install Python dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If encountering PyInstaller version errors:
|
||||
|
||||
```bash
|
||||
# Try installing the latest stable version
|
||||
pip install --upgrade pyinstaller
|
||||
```
|
||||
|
||||
### Development of PyWebView
|
||||
|
||||
1. Start the PyWebView development build:
|
||||
|
||||
```bash
|
||||
npm run pywebview:dev
|
||||
```
|
||||
|
||||
### Building for Distribution
|
||||
|
||||
#### Linux
|
||||
|
||||
```bash
|
||||
npm run pywebview:package-linux
|
||||
```
|
||||
|
||||
The packaged application will be in `dist/TimeSafari`
|
||||
|
||||
#### Windows
|
||||
|
||||
```bash
|
||||
npm run pywebview:package-win
|
||||
```
|
||||
|
||||
The packaged application will be in `dist/TimeSafari`
|
||||
|
||||
#### macOS
|
||||
|
||||
```bash
|
||||
npm run pywebview:package-mac
|
||||
```
|
||||
|
||||
The packaged application will be in `dist/TimeSafari`
|
||||
|
||||
## Testing
|
||||
|
||||
Run all tests (requires XCode and Android Studio/device):
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
See [TESTING.md](test-playwright/TESTING.md) for more details.
|
||||
|
||||
## Linting
|
||||
|
||||
Check code style:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Fix code style issues:
|
||||
|
||||
```bash
|
||||
npm run lint-fix
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
See `.env.*` files for configuration.
|
||||
|
||||
## Notes
|
||||
|
||||
- The application uses PWA (Progressive Web App) features for web builds
|
||||
- Electron builds disable PWA features automatically
|
||||
- Build output directories:
|
||||
- Web: `dist/`
|
||||
- Electron: `dist-electron/`
|
||||
- Capacitor: `dist-capacitor/`
|
||||
|
||||
## Deployment
|
||||
|
||||
### Version Management
|
||||
|
||||
1. Update CHANGELOG.md with new changes
|
||||
2. Update version in package.json
|
||||
3. Commit changes and tag release:
|
||||
|
||||
```bash
|
||||
git tag <VERSION_TAG>
|
||||
git push origin <VERSION_TAG>
|
||||
```
|
||||
|
||||
4. After deployment, update package.json with next version + "-beta"
|
||||
|
||||
### Test Server
|
||||
|
||||
```bash
|
||||
# Build using staging environment
|
||||
npm run build -- --mode staging
|
||||
|
||||
# Deploy to test server
|
||||
rsync -azvu -e "ssh -i ~/.ssh/<YOUR_KEY>" dist ubuntutest@test.timesafari.app:time-safari/
|
||||
```
|
||||
|
||||
### Production Server
|
||||
|
||||
```bash
|
||||
# On the production server:
|
||||
pkgx +npm sh
|
||||
cd crowd-funder-for-time-pwa
|
||||
git checkout master && git pull
|
||||
git checkout <VERSION_TAG>
|
||||
npm install
|
||||
npm run build
|
||||
cd -
|
||||
|
||||
# Backup and deploy
|
||||
mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/
|
||||
```
|
||||
|
||||
## Troubleshooting Builds
|
||||
|
||||
### Common Build Issues
|
||||
|
||||
1. **Missing Environment Variables**
|
||||
- Check that all required variables are set in your .env file
|
||||
- For development, ensure local services are running on correct ports
|
||||
|
||||
2. **Electron Build Failures**
|
||||
- Verify Node.js version compatibility
|
||||
- Check that all required dependencies are installed
|
||||
- Ensure proper paths in electron/main.js
|
||||
|
||||
3. **Mobile Build Issues**
|
||||
- For iOS: Xcode command line tools must be installed
|
||||
- For Android: Correct SDK version must be installed
|
||||
- Check Capacitor configuration in capacitor.config.ts
|
||||
|
||||
|
||||
# List all installed packages
|
||||
adb shell pm list packages | grep timesafari
|
||||
|
||||
# Force stop the app (if it's running)
|
||||
adb shell am force-stop app.timesafari
|
||||
|
||||
# Clear app data (if you don't want to fully uninstall)
|
||||
adb shell pm clear app.timesafari
|
||||
|
||||
# Uninstall for all users
|
||||
adb shell pm uninstall -k --user 0 app.timesafari
|
||||
|
||||
# Check if app is installed
|
||||
adb shell pm path app.timesafari
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
py3-pip \
|
||||
py3-setuptools \
|
||||
make \
|
||||
g++ \
|
||||
gcc
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build:web
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration if needed
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,322 @@
|
||||
# Time Safari Context
|
||||
|
||||
## Project Overview
|
||||
|
||||
Time Safari is an application designed to foster community building through gifts, gratitude, and collaborative projects. The app should make it extremely easy and intuitive for users of any age and capability to recognize contributions, build trust networks, and organize collective action. It is built on services that preserve privacy and data sovereignty.
|
||||
|
||||
The ultimate goals of Time Safari are two-fold:
|
||||
|
||||
1. **Connect** Make it easy, rewarding, and non-threatening for people to connect with others who have similar interests, and to initiate activities together. This helps people accomplish and learn from other individuals in less-structured environments; moreover, it helps them discover who they want to continue to support and with whom they want to maintain relationships.
|
||||
|
||||
2. **Reveal** Widely advertise the great support and rewards that are being given and accepted freely, especially non-monetary ones. Using visuals and text, display the kind of impact that gifts are making in the lives of others. Also show useful and engaging reports of project statistics and personal accomplishments.
|
||||
|
||||
|
||||
## Core Approaches
|
||||
|
||||
Time Safari should help everyday users build meaningful connections and organize collective efforts by:
|
||||
|
||||
1. **Recognizing Contributions**: Creating permanent, verifiable records of gifts and contributions people give to each other and their communities.
|
||||
|
||||
2. **Facilitating Collaboration**: Making it ridiculously easy for people to ask for or propose help on projects and interests that matter to them.
|
||||
|
||||
3. **Building Trust Networks**: Enabling users to maintain their network and activity visibility. Developing reputation through verified contributions and references, which can be selectively shown to others outside the network.
|
||||
|
||||
4. **Preserving Privacy**: Ensuring personal identifiers are only shared with explicitly authorized contacts, allowing private individuals including children to participate safely.
|
||||
|
||||
5. **Engaging Content**: Displaying people's records in compelling stories, and highlighting those projects that are lifting people's lives long-term, both in physical support and in emotional-spiritual-creative thriving.
|
||||
|
||||
|
||||
## Technical Foundation
|
||||
|
||||
This application is built on a privacy-preserving claims architecture (via endorser.ch) with these key characteristics:
|
||||
|
||||
- **Decentralized Identifiers (DIDs)**: User identities are based on public/private key pairs stored on their devices
|
||||
- **Cryptographic Verification**: All claims and confirmations are cryptographically signed
|
||||
- **User-Controlled Visibility**: Users explicitly control who can see their identifiers and data
|
||||
- **Merkle-Chained Claims**: Claims are cryptographically chained for verification and integrity
|
||||
- **Native and Web App**: Works on iOS, Android, and web browsers
|
||||
|
||||
## User Journey
|
||||
|
||||
The typical progression of usage follows these stages:
|
||||
|
||||
1. **Gratitude & Recognition**: Users begin by expressing and recording gratitude for gifts received, building a foundation of acknowledgment.
|
||||
|
||||
2. **Project Proposals**: Users propose projects and ideas, reaching out to connect with others who share similar interests.
|
||||
|
||||
3. **Action Triggers**: Offers of help serve as triggers and motivations to execute proposed projects, moving from ideas to action.
|
||||
|
||||
## Context for LLM Development
|
||||
|
||||
When developing new functionality for Time Safari, consider these design principles:
|
||||
|
||||
1. **Accessibility First**: Features should be usable by non-technical users with minimal learning curve.
|
||||
|
||||
2. **Privacy by Design**: All features must respect user privacy and data sovereignty.
|
||||
|
||||
3. **Progressive Enhancement**: Core functionality should work across all devices, with richer experiences where supported.
|
||||
|
||||
4. **Voluntary Collaboration**: The system should enable but never coerce participation.
|
||||
|
||||
5. **Trust Building**: Features should help build verifiable trust between users.
|
||||
|
||||
6. **Network Effects**: Consider how features scale as more users join the platform.
|
||||
|
||||
7. **Low Resource Requirements**: The system should be lightweight enough to run on inexpensive devices users already own.
|
||||
|
||||
## Use Cases to Support
|
||||
|
||||
LLM development should focus on enhancing these key use cases:
|
||||
|
||||
1. **Community Building**: Tools that help people find others with shared interests and values.
|
||||
|
||||
2. **Project Coordination**: Features that make it easy to propose collaborative projects and to submit suggestions and offers to existing ones.
|
||||
|
||||
3. **Reputation Building**: Methods for users to showcase their contributions and reliability, in contexts where they explicitly reveal that information.
|
||||
|
||||
4. **Governance Experimentation**: Features that facilitate decision-making and collective governance.
|
||||
|
||||
## Constraints
|
||||
|
||||
When developing new features, be mindful of these constraints:
|
||||
|
||||
1. **Privacy Preservation**: User identifiers must remain private except when explicitly shared.
|
||||
|
||||
2. **Platform Limitations**: Features must work within the constraints of the target app platforms, while aiming to leverage the best platform technology available.
|
||||
|
||||
3. **Endorser API Limitations**: Backend features are constrained by the endorser.ch API capabilities.
|
||||
|
||||
4. **Performance on Low-End Devices**: The application should remain performant on older/simpler devices.
|
||||
|
||||
5. **Offline-First When Possible**: Key functionality should work offline when feasible.
|
||||
|
||||
## Project Technologies
|
||||
|
||||
- Typescript using ES6 classes
|
||||
- TailwindCSS
|
||||
- Vite Build Tool
|
||||
- Playwright E2E testing
|
||||
- IndexDB
|
||||
- Camera, Image uploads, QR Code reader, ...
|
||||
|
||||
## Mobile Features
|
||||
|
||||
- Deep Linking
|
||||
- Local Notifications via a custom Capacitor plugin
|
||||
|
||||
## Project Architecture
|
||||
|
||||
- The application must work on web browser, PWA (Progressive Web Application), desktop via Electron, and mobile via Capacitor
|
||||
- Building for each platform is managed via Vite
|
||||
|
||||
## Core Development Principles
|
||||
|
||||
### DRY development
|
||||
- **Code Reuse**
|
||||
- Extract common functionality into utility functions
|
||||
- Create reusable components for UI patterns
|
||||
- Implement service classes for shared business logic
|
||||
- Use mixins for cross-cutting concerns
|
||||
- Leverage TypeScript interfaces for shared type definitions
|
||||
|
||||
- **Component Patterns**
|
||||
- Create base components for common UI elements
|
||||
- Implement higher-order components for shared behavior
|
||||
- Use slot patterns for flexible component composition
|
||||
- Create composable services for business logic
|
||||
- Implement factory patterns for component creation
|
||||
|
||||
- **State Management**
|
||||
- Centralize state in Pinia stores
|
||||
- Use computed properties for derived state
|
||||
- Implement shared state selectors
|
||||
- Create reusable state mutations
|
||||
- Use action creators for common operations
|
||||
|
||||
- **Error Handling**
|
||||
- Implement centralized error handling
|
||||
- Create reusable error components
|
||||
- Use error boundary components
|
||||
- Implement consistent error logging
|
||||
- Create error type definitions
|
||||
|
||||
- **Type Definitions**
|
||||
- Create shared interfaces for common data structures
|
||||
- Use type aliases for complex types
|
||||
- Implement generic types for reusable components
|
||||
- Create utility types for common patterns
|
||||
- Use discriminated unions for state management
|
||||
|
||||
- **API Integration**
|
||||
- Create reusable API client classes
|
||||
- Implement request/response interceptors
|
||||
- Use consistent error handling patterns
|
||||
- Create type-safe API endpoints
|
||||
- Implement caching strategies
|
||||
|
||||
- **Platform Services**
|
||||
- Abstract platform-specific code behind interfaces
|
||||
- Create platform-agnostic service layers
|
||||
- Implement feature detection
|
||||
- Use dependency injection for services
|
||||
- Create service factories
|
||||
|
||||
- **Testing**
|
||||
- Create reusable test utilities
|
||||
- Implement test factories
|
||||
- Use shared test configurations
|
||||
- Create reusable test helpers
|
||||
- Implement consistent test patterns
|
||||
|
||||
### SOLID Principles
|
||||
- **Single Responsibility**: Each class/component should have only one reason to change
|
||||
- Components should focus on one specific feature (e.g., QR scanning, DID management)
|
||||
- Services should handle one type of functionality (e.g., platform services, crypto services)
|
||||
- Utilities should provide focused helper functions
|
||||
|
||||
- **Open/Closed**: Software entities should be open for extension but closed for modification
|
||||
- Use interfaces for service definitions
|
||||
- Implement plugin architecture for platform-specific features
|
||||
- Allow component behavior extension through props and events
|
||||
|
||||
- **Liskov Substitution**: Objects should be replaceable with their subtypes
|
||||
- Platform services should work consistently across web/mobile
|
||||
- Authentication providers should be interchangeable
|
||||
- Storage implementations should be swappable
|
||||
|
||||
- **Interface Segregation**: Clients shouldn't depend on interfaces they don't use
|
||||
- Break down large service interfaces into smaller, focused ones
|
||||
- Component props should be minimal and purposeful
|
||||
- Event emissions should be specific and targeted
|
||||
|
||||
- **Dependency Inversion**: High-level modules shouldn't depend on low-level modules
|
||||
- Use dependency injection for services
|
||||
- Abstract platform-specific code behind interfaces
|
||||
- Implement factory patterns for component creation
|
||||
|
||||
### Law of Demeter
|
||||
- Components should only communicate with immediate dependencies
|
||||
- Avoid chaining method calls (e.g., `this.service.getUser().getProfile().getName()`)
|
||||
- Use mediator patterns for complex component interactions
|
||||
- Implement facade patterns for subsystem access
|
||||
- Keep component communication through defined events and props
|
||||
|
||||
### Composition over Inheritance
|
||||
- Prefer building components through composition
|
||||
- Use mixins for shared functionality
|
||||
- Implement feature toggles through props
|
||||
- Create higher-order components for common patterns
|
||||
- Use service composition for complex features
|
||||
|
||||
### Interface Segregation
|
||||
- Define clear interfaces for services
|
||||
- Keep component APIs minimal and focused
|
||||
- Split large interfaces into smaller, specific ones
|
||||
- Use TypeScript interfaces for type definitions
|
||||
- Implement role-based interfaces for different use cases
|
||||
|
||||
### Fail Fast
|
||||
- Validate inputs early in the process
|
||||
- Use TypeScript strict mode
|
||||
- Implement comprehensive error handling
|
||||
- Add runtime checks for critical operations
|
||||
- Use assertions for development-time validation
|
||||
|
||||
### Principle of Least Astonishment
|
||||
- Follow Vue.js conventions consistently
|
||||
- Use familiar naming patterns
|
||||
- Implement predictable component behaviors
|
||||
- Maintain consistent error handling
|
||||
- Keep UI interactions intuitive
|
||||
|
||||
### Information Hiding
|
||||
- Encapsulate implementation details
|
||||
- Use private class members
|
||||
- Implement proper access modifiers
|
||||
- Hide complex logic behind simple interfaces
|
||||
- Use TypeScript's access modifiers effectively
|
||||
|
||||
### Single Source of Truth
|
||||
- Use Pinia for state management
|
||||
- Maintain one source for user data
|
||||
- Centralize configuration management
|
||||
- Use computed properties for derived state
|
||||
- Implement proper state synchronization
|
||||
|
||||
### Principle of Least Privilege
|
||||
- Implement proper access control
|
||||
- Use minimal required permissions
|
||||
- Follow privacy-by-design principles
|
||||
- Restrict component access to necessary data
|
||||
- Implement proper authentication/authorization
|
||||
|
||||
### Continuous Integration/Continuous Deployment (CI/CD)
|
||||
- Automated testing on every commit
|
||||
- Consistent build process across platforms
|
||||
- Automated deployment pipelines
|
||||
- Quality gates for code merging
|
||||
- Environment-specific configurations
|
||||
|
||||
This expanded documentation provides:
|
||||
1. Clear principles for development
|
||||
2. Practical implementation guidelines
|
||||
3. Real-world examples
|
||||
4. TypeScript integration
|
||||
5. Best practices for Time Safari
|
||||
|
||||
|
||||
## Vue Component Structure
|
||||
|
||||
- Use `@Options`, `@Ref`, `@Prop`, `@Emit`, and `@Watch` Typescript decorators for clear component structure
|
||||
- Extend `Vue` class with proper type annotations for props, refs, and methods
|
||||
- Use Tailwind utility classes for accessible and responsive design
|
||||
- Avoid `setup()` or Composition API; use class syntax consistently
|
||||
- Keep methods pure when possible; extract logic into utilities
|
||||
- Ensure lifecycle methods are clearly defined inside class
|
||||
- Use semantic HTML + Tailwind classes for styling
|
||||
- Pinia for state management
|
||||
|
||||
## Vue Facing Decorators
|
||||
|
||||
- Ensure all Vue 3 components are written using TypeScript with strict type checking enabled.
|
||||
- Always include explicit types for props, emits, and reactive properties.
|
||||
- When using @Options, ensure it includes metadata like name, template, or styles.
|
||||
- Use @Prop for defining props with validation and default values.
|
||||
- Use @Emit for emitting events with proper payload typing.
|
||||
- Use @Watch for reactive property changes, and @Ref for DOM references."
|
||||
- Organize Vue 3 components with a clear structure: imports at the top, followed by @Options metadata, then class properties (props, refs, reactive state), lifecycle hooks, methods, and finally @Watch or @Emit handlers.
|
||||
- Ensure all props have explicit types and optional validation.
|
||||
- Use TypeScript interfaces or types for complex prop structures.
|
||||
- Validate default values for props where applicable.
|
||||
- Use lifecycle hooks (e.g., onMounted, onUnmounted) sparingly and document their purpose.
|
||||
- Avoid side effects in lifecycle hooks unless absolutely necessary.
|
||||
- Use @Emit for emitting events with strongly typed payloads.
|
||||
- Ensure event names are descriptive and match the action being performed.
|
||||
- Use ref or reactive for managing internal state.
|
||||
- Avoid overusing reactive state for simple values. Prefer computed properties for derived state.
|
||||
- Write unit tests for components using Vue Test Utils and Jest/Vitest.
|
||||
- Ensure tests cover props, events, and lifecycle behavior.
|
||||
- Avoid unnecessary re-renders by using v-once for static content and memoizing expensive computations with computed properties.
|
||||
- Ensure components are accessible by using semantic HTML and ARIA attributes.
|
||||
- Use scoped styles or CSS modules to encapsulate styles.
|
||||
|
||||
## es6 classes
|
||||
|
||||
- Use ES6 class syntax with decorators (@Options, @Prop, @Emit).
|
||||
- Use modular imports and default exports.
|
||||
- Use arrow functions for methods and callbacks.
|
||||
- Use destructuring for props and state.
|
||||
- Provide default parameters for optional props or arguments.
|
||||
- Use template literals for dynamic strings.
|
||||
- Use spread/rest operators for object manipulation and arguments.
|
||||
- Use const/let appropriately for variable declarations.
|
||||
- Use enhanced object literals for cleaner syntax.
|
||||
- Use async/await for asynchronous operations.
|
||||
- Add scoped styles for encapsulation.
|
||||
- Ensure accessibility with semantic HTML and ARIA attributes.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Include JSDoc comments for all public methods and props.
|
||||
- Files must have comments explaing contents and workflow of file
|
||||
- Methods and props should explain role and workflow of each
|
||||
@@ -31,9 +31,7 @@ See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
|
||||
|
||||
## Icons
|
||||
|
||||
Application icons are in the `assets` directory, processed by the `capacitor-assets` command.
|
||||
|
||||
To add a Font Awesome icon, add to main.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name.
|
||||
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name.
|
||||
|
||||
## Other
|
||||
|
||||
@@ -46,24 +44,6 @@ To add a Font Awesome icon, add to main.ts and reference with `font-awesome` ele
|
||||
|
||||
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
||||
|
||||
### Code Organization
|
||||
|
||||
The project uses a centralized approach to type definitions and interfaces:
|
||||
|
||||
* `src/interfaces/` - Contains all TypeScript interfaces and type definitions
|
||||
* `deepLinks.ts` - Deep linking type system and Zod validation schemas
|
||||
* `give.ts` - Give-related interfaces and type definitions
|
||||
* `claims.ts` - Claim-related interfaces and verifiable credentials
|
||||
* `common.ts` - Shared interfaces and utility types
|
||||
* Other domain-specific interface files
|
||||
|
||||
Key principles:
|
||||
- All interfaces and types are defined in the interfaces folder
|
||||
- Zod schemas are used for runtime validation and type generation
|
||||
- Domain-specific interfaces are separated into their own files
|
||||
- Common interfaces are shared through `common.ts`
|
||||
- Type definitions are generated from Zod schemas where possible
|
||||
|
||||
### Kudos
|
||||
|
||||
Gifts make the world go 'round!
|
||||
|
||||
+8
-12
@@ -1,17 +1,5 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
app/build/*
|
||||
!app/build/.npmkeep
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
|
||||
# secrets
|
||||
app/gradle.properties.secrets
|
||||
app/time-safari-upload-key-pkcs12.jks
|
||||
|
||||
@@ -106,3 +94,11 @@ lint/tmp/
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
#Wed Apr 09 09:01:13 UTC 2025
|
||||
gradle.version=8.11.1
|
||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -9,12 +9,13 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-mlkit-barcode-scanning')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-camera')
|
||||
implementation project(':capacitor-clipboard')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capawesome-capacitor-file-picker')
|
||||
implementation project(':capacitor-mlkit-barcode-scanning')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "123456789000",
|
||||
"project_id": "timesafari-app",
|
||||
"storage_bucket": "timesafari-app.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:123456789000:android:1234567890abcdef",
|
||||
"android_client_info": {
|
||||
"package_name": "app.timesafari.app"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDummyKeyForBuildPurposesOnly12345"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -42,5 +42,4 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||
</manifest>
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"MLKitBarcodeScanner": {
|
||||
"formats": [
|
||||
"QR_CODE"
|
||||
],
|
||||
"detectorSize": 1,
|
||||
"lensFacing": "back",
|
||||
"googleBarcodeScannerModuleInstallState": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@capacitor-mlkit/barcode-scanning",
|
||||
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
@@ -11,6 +7,10 @@
|
||||
"pkg": "@capacitor/camera",
|
||||
"classpath": "com.capacitorjs.plugins.camera.CameraPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/clipboard",
|
||||
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/filesystem",
|
||||
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
|
||||
@@ -22,5 +22,9 @@
|
||||
{
|
||||
"pkg": "@capawesome/capacitor-file-picker",
|
||||
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor-mlkit/barcode-scanning",
|
||||
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-mlkit-barcode-scanning'
|
||||
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-camera'
|
||||
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
|
||||
|
||||
include ':capacitor-clipboard'
|
||||
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
@@ -19,3 +19,6 @@ project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/sh
|
||||
|
||||
include ':capawesome-capacitor-file-picker'
|
||||
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
|
||||
|
||||
include ':capacitor-mlkit-barcode-scanning'
|
||||
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Djdk.io.File.enableADS=true -Dsun.net.inetaddr.ttl=0 -Dsun.net.inetaddr.negative.ttl=0 -Dsun.net.client.defaultConnectTimeout=10000 -Dsun.net.client.defaultReadTimeout=10000
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
@@ -20,4 +20,8 @@ org.gradle.jvmargs=-Xmx1536m
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
android.suppressUnsupportedCompileSdk=34
|
||||
# Automatically convert third-party libraries to use AndroidX
|
||||
android.enableJetifier=true
|
||||
# Use DNS servers that can resolve Google's Maven repository
|
||||
systemProp.sun.net.spi.nameservice.nameservers=8.8.8.8,8.8.4.4
|
||||
systemProp.sun.net.spi.nameservice.provider.1=dns,sun
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
Application icons are here. They are processed for android & ios by the `capacitor-assets` command, as indicated in the BUILDING.md file.
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
export IMAGENAME="$(basename $PWD):1.0"
|
||||
|
||||
docker build . --network=host -t $IMAGENAME --no-cache
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"appId": "app.timesafari",
|
||||
"appName": "TimeSafari",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
"server": {
|
||||
"cleartext": true
|
||||
},
|
||||
"plugins": {
|
||||
"App": {
|
||||
"appUrlOpen": {
|
||||
"handlers": [
|
||||
{
|
||||
"url": "timesafari://*",
|
||||
"autoVerify": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'app.timesafari',
|
||||
appName: 'TimeSafari',
|
||||
webDir: 'dist',
|
||||
bundledWebRuntime: false,
|
||||
server: {
|
||||
cleartext: true,
|
||||
},
|
||||
plugins: {
|
||||
App: {
|
||||
appUrlOpen: {
|
||||
handlers: [
|
||||
{
|
||||
url: "timesafari://*",
|
||||
autoVerify: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
MLKitBarcodeScanner: {
|
||||
formats: ['QR_CODE'], // Only enable QR code scanning to improve performance
|
||||
detectorSize: 1.0, // Use full camera view for detection
|
||||
lensFacing: 'back', // Default to back camera
|
||||
googleBarcodeScannerModuleInstallState: true // Enable Google Play Services barcode module installation if needed
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,805 +0,0 @@
|
||||
# QR Code Implementation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the QR code scanning and generation implementation in the TimeSafari application. The system uses a platform-agnostic design with specific implementations for web and mobile platforms.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ └── QRScanner/
|
||||
│ ├── types.ts # Core interfaces and types
|
||||
│ ├── QRScannerFactory.ts # Factory for creating scanner instances
|
||||
│ ├── CapacitorQRScanner.ts # Mobile implementation using MLKit
|
||||
│ ├── WebInlineQRScanner.ts # Web implementation using MediaDevices API
|
||||
│ └── interfaces.ts # Additional interfaces
|
||||
├── components/
|
||||
│ └── QRScanner/
|
||||
│ └── QRScannerDialog.vue # Shared UI component
|
||||
└── views/
|
||||
├── ContactQRScanView.vue # Dedicated scanning view
|
||||
└── ContactQRScanShowView.vue # Combined QR display and scanning view
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Factory Pattern**
|
||||
- `QRScannerFactory` - Creates appropriate scanner instance based on platform
|
||||
- Common interface `QRScannerService` implemented by all scanners
|
||||
- Platform detection via Capacitor and build flags
|
||||
|
||||
2. **Platform-Specific Implementations**
|
||||
- `CapacitorQRScanner` - Native mobile implementation using MLKit
|
||||
- `WebInlineQRScanner` - Web browser implementation using MediaDevices API
|
||||
- `QRScannerDialog.vue` - Shared UI component
|
||||
|
||||
3. **View Components**
|
||||
- `ContactQRScanView` - Dedicated view for scanning QR codes
|
||||
- `ContactQRScanShowView` - Combined view for displaying and scanning QR codes
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Core Interfaces
|
||||
|
||||
```typescript
|
||||
interface QRScannerService {
|
||||
checkPermissions(): Promise<boolean>;
|
||||
requestPermissions(): Promise<boolean>;
|
||||
isSupported(): Promise<boolean>;
|
||||
startScan(options?: QRScannerOptions): Promise<void>;
|
||||
stopScan(): Promise<void>;
|
||||
addListener(listener: ScanListener): void;
|
||||
onStream(callback: (stream: MediaStream | null) => void): void;
|
||||
cleanup(): Promise<void>;
|
||||
getAvailableCameras(): Promise<MediaDeviceInfo[]>;
|
||||
switchCamera(deviceId: string): Promise<void>;
|
||||
getCurrentCamera(): Promise<MediaDeviceInfo | null>;
|
||||
}
|
||||
|
||||
interface ScanListener {
|
||||
onScan: (result: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface QRScannerOptions {
|
||||
camera?: "front" | "back";
|
||||
showPreview?: boolean;
|
||||
playSound?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Platform-Specific Implementations
|
||||
|
||||
#### Mobile (Capacitor)
|
||||
- Uses `@capacitor-mlkit/barcode-scanning`
|
||||
- Native camera access through platform APIs
|
||||
- Optimized for mobile performance
|
||||
- Supports both iOS and Android
|
||||
- Real-time QR code detection
|
||||
- Back camera preferred for scanning
|
||||
|
||||
Configuration:
|
||||
```typescript
|
||||
// capacitor.config.ts
|
||||
const config: CapacitorConfig = {
|
||||
plugins: {
|
||||
MLKitBarcodeScanner: {
|
||||
formats: ['QR_CODE'],
|
||||
detectorSize: 1.0,
|
||||
lensFacing: 'back',
|
||||
googleBarcodeScannerModuleInstallState: true,
|
||||
// Additional camera options
|
||||
cameraOptions: {
|
||||
quality: 0.8,
|
||||
allowEditing: false,
|
||||
resultType: 'uri',
|
||||
sourceType: 'CAMERA',
|
||||
saveToGallery: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### Web
|
||||
- Uses browser's MediaDevices API
|
||||
- Vue.js components for UI
|
||||
- EventEmitter for stream management
|
||||
- Browser-based camera access
|
||||
- Inline camera preview
|
||||
- Responsive design
|
||||
- Cross-browser compatibility
|
||||
|
||||
### View Components
|
||||
|
||||
#### ContactQRScanView
|
||||
- Dedicated view for scanning QR codes
|
||||
- Full-screen camera interface
|
||||
- Simple UI focused on scanning
|
||||
- Used primarily on native platforms
|
||||
- Streamlined scanning experience
|
||||
|
||||
#### ContactQRScanShowView
|
||||
- Combined view for QR code display and scanning
|
||||
- Shows user's own QR code
|
||||
- Handles user registration status
|
||||
- Provides options to copy contact information
|
||||
- Platform-specific scanning implementation:
|
||||
- Native: Button to navigate to ContactQRScanView
|
||||
- Web: Built-in scanning functionality
|
||||
|
||||
### QR Code Workflow
|
||||
|
||||
1. **Initiation**
|
||||
- User selects "Scan QR Code" option
|
||||
- Platform-specific scanner is initialized
|
||||
- Camera permissions are verified
|
||||
- Appropriate scanner component is loaded
|
||||
|
||||
2. **Platform-Specific Implementation**
|
||||
- Web: Uses `qrcode-stream` for real-time scanning
|
||||
- Native: Uses `@capacitor-mlkit/barcode-scanning`
|
||||
|
||||
3. **Scanning Process**
|
||||
- Camera stream initialization
|
||||
- Real-time frame analysis
|
||||
- QR code detection and decoding
|
||||
- Validation of QR code format
|
||||
- Processing of contact information
|
||||
|
||||
4. **Contact Processing**
|
||||
- Decryption of contact data
|
||||
- Validation of user information
|
||||
- Verification of timestamp
|
||||
- Check for duplicate contacts
|
||||
- Processing of shared data
|
||||
|
||||
## Build Configuration
|
||||
|
||||
### Common Vite Configuration
|
||||
```typescript
|
||||
// vite.config.common.mts
|
||||
export async function createBuildConfig(mode: string) {
|
||||
const isCapacitor = mode === "capacitor";
|
||||
|
||||
return defineConfig({
|
||||
define: {
|
||||
'process.env.VITE_PLATFORM': JSON.stringify(mode),
|
||||
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative),
|
||||
__IS_MOBILE__: JSON.stringify(isCapacitor),
|
||||
__USE_QR_READER__: JSON.stringify(!isCapacitor)
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'@capacitor-mlkit/barcode-scanning',
|
||||
'vue-qrcode-reader'
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Platform-Specific Builds
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"build:web": "vite build --config vite.config.web.mts",
|
||||
"build:capacitor": "vite build --config vite.config.capacitor.mts",
|
||||
"build:all": "npm run build:web && npm run build:capacitor"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Scenarios
|
||||
1. No camera found
|
||||
2. Permission denied
|
||||
3. Camera in use by another application
|
||||
4. HTTPS required
|
||||
5. Browser compatibility issues
|
||||
6. Invalid QR code format
|
||||
7. Expired QR codes
|
||||
8. Duplicate contact attempts
|
||||
9. Network connectivity issues
|
||||
|
||||
### Error Response
|
||||
- User-friendly error messages
|
||||
- Troubleshooting tips
|
||||
- Clear instructions for resolution
|
||||
- Platform-specific guidance
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### QR Code Security
|
||||
- Encryption of contact data
|
||||
- Timestamp validation
|
||||
- Version checking
|
||||
- User verification
|
||||
- Rate limiting for scans
|
||||
|
||||
### Data Protection
|
||||
- Secure transmission of contact data
|
||||
- Validation of QR code authenticity
|
||||
- Prevention of duplicate scans
|
||||
- Protection against malicious codes
|
||||
- Secure storage of contact information
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Camera Access
|
||||
1. Always check for camera availability
|
||||
2. Request permissions explicitly
|
||||
3. Handle all error conditions
|
||||
4. Provide clear user feedback
|
||||
5. Implement proper cleanup
|
||||
|
||||
### Performance
|
||||
1. Optimize camera resolution
|
||||
2. Implement proper resource cleanup
|
||||
3. Handle camera switching efficiently
|
||||
4. Manage memory usage
|
||||
5. Battery usage optimization
|
||||
|
||||
### User Experience
|
||||
1. Clear visual feedback
|
||||
2. Camera preview
|
||||
3. Scanning status indicators
|
||||
4. Error messages
|
||||
5. Success confirmations
|
||||
6. Intuitive camera controls
|
||||
7. Smooth camera switching
|
||||
8. Responsive UI feedback
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Scenarios
|
||||
1. Permission handling
|
||||
2. Camera switching
|
||||
3. Error conditions
|
||||
4. Platform compatibility
|
||||
5. Performance metrics
|
||||
6. QR code detection
|
||||
7. Contact processing
|
||||
8. Security validation
|
||||
|
||||
### Test Environment
|
||||
- Multiple browsers
|
||||
- iOS and Android devices
|
||||
- Various network conditions
|
||||
- Different camera configurations
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Key Packages
|
||||
- `@capacitor-mlkit/barcode-scanning`
|
||||
- `qrcode-stream`
|
||||
- `vue-qrcode-reader`
|
||||
- Platform-specific camera APIs
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Regular Updates
|
||||
- Keep dependencies updated
|
||||
- Monitor platform changes
|
||||
- Update documentation
|
||||
- Review security patches
|
||||
|
||||
### Performance Monitoring
|
||||
- Track memory usage
|
||||
- Monitor camera performance
|
||||
- Check error rates
|
||||
- Analyze user feedback
|
||||
|
||||
## Camera Handling
|
||||
|
||||
### Camera Switching Implementation
|
||||
|
||||
The QR scanner supports camera switching on both mobile and desktop platforms through a unified interface.
|
||||
|
||||
#### Platform-Specific Implementations
|
||||
|
||||
1. **Mobile (Capacitor)**
|
||||
- Uses `@capacitor-mlkit/barcode-scanning`
|
||||
- Supports front/back camera switching
|
||||
- Native camera access through platform APIs
|
||||
- Optimized for mobile performance
|
||||
|
||||
```typescript
|
||||
// CapacitorQRScanner.ts
|
||||
async startScan(options?: QRScannerOptions): Promise<void> {
|
||||
const scanOptions: StartScanOptions = {
|
||||
formats: [BarcodeFormat.QrCode],
|
||||
lensFacing: options?.camera === "front" ?
|
||||
LensFacing.Front : LensFacing.Back
|
||||
};
|
||||
await BarcodeScanner.startScan(scanOptions);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Web (Desktop)**
|
||||
- Uses browser's MediaDevices API
|
||||
- Supports multiple camera devices
|
||||
- Dynamic camera enumeration
|
||||
- Real-time camera switching
|
||||
|
||||
```typescript
|
||||
// WebInlineQRScanner.ts
|
||||
async getAvailableCameras(): Promise<MediaDeviceInfo[]> {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.filter(device => device.kind === 'videoinput');
|
||||
}
|
||||
|
||||
async switchCamera(deviceId: string): Promise<void> {
|
||||
// Stop current stream
|
||||
await this.stopScan();
|
||||
|
||||
// Start new stream with selected camera
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
deviceId: { exact: deviceId },
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
}
|
||||
});
|
||||
|
||||
// Update video and restart scanning
|
||||
if (this.video) {
|
||||
this.video.srcObject = this.stream;
|
||||
await this.video.play();
|
||||
}
|
||||
this.scanQRCode();
|
||||
}
|
||||
```
|
||||
|
||||
### Core Interfaces
|
||||
|
||||
```typescript
|
||||
interface QRScannerService {
|
||||
// ... existing methods ...
|
||||
|
||||
/** Get available cameras */
|
||||
getAvailableCameras(): Promise<MediaDeviceInfo[]>;
|
||||
|
||||
/** Switch to a specific camera */
|
||||
switchCamera(deviceId: string): Promise<void>;
|
||||
|
||||
/** Get current camera info */
|
||||
getCurrentCamera(): Promise<MediaDeviceInfo | null>;
|
||||
}
|
||||
|
||||
interface QRScannerOptions {
|
||||
/** Camera to use ('front' or 'back' for mobile) */
|
||||
camera?: "front" | "back";
|
||||
/** Whether to show a preview of the camera feed */
|
||||
showPreview?: boolean;
|
||||
/** Whether to play a sound on successful scan */
|
||||
playSound?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### UI Components
|
||||
|
||||
The camera switching UI adapts to the platform:
|
||||
|
||||
1. **Mobile Interface**
|
||||
- Simple toggle button for front/back cameras
|
||||
- Positioned in bottom-right corner
|
||||
- Clear visual feedback during switching
|
||||
- Native camera controls
|
||||
|
||||
```vue
|
||||
<button
|
||||
v-if="isNativePlatform"
|
||||
@click="toggleMobileCamera"
|
||||
class="camera-switch-btn"
|
||||
>
|
||||
<font-awesome icon="camera-rotate" />
|
||||
Switch Camera
|
||||
</button>
|
||||
```
|
||||
|
||||
2. **Desktop Interface**
|
||||
- Dropdown menu with all available cameras
|
||||
- Camera labels and device IDs
|
||||
- Real-time camera switching
|
||||
- Responsive design
|
||||
|
||||
```vue
|
||||
<select
|
||||
v-model="selectedCameraId"
|
||||
@change="onCameraChange"
|
||||
class="camera-select-dropdown"
|
||||
>
|
||||
<option
|
||||
v-for="camera in availableCameras"
|
||||
:key="camera.deviceId"
|
||||
:value="camera.deviceId"
|
||||
>
|
||||
{{ camera.label || `Camera ${camera.deviceId.slice(0, 4)}` }}
|
||||
</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The camera switching implementation includes comprehensive error handling:
|
||||
|
||||
1. **Common Error Scenarios**
|
||||
- Camera in use by another application
|
||||
- Permission denied during switch
|
||||
- Device not available
|
||||
- Stream initialization failure
|
||||
- Camera switch timeout
|
||||
|
||||
2. **Error Response**
|
||||
```typescript
|
||||
private async handleCameraSwitch(deviceId: string): Promise<void> {
|
||||
try {
|
||||
this.updateCameraState("initializing", "Switching camera...");
|
||||
await this.switchCamera(deviceId);
|
||||
this.updateCameraState("active", "Camera switched successfully");
|
||||
} catch (error) {
|
||||
this.updateCameraState("error", "Failed to switch camera");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **User Feedback**
|
||||
- Visual indicators during switching
|
||||
- Error notifications
|
||||
- Camera state updates
|
||||
- Permission request dialogs
|
||||
|
||||
### State Management
|
||||
|
||||
The camera system maintains several states:
|
||||
|
||||
1. **Camera States**
|
||||
```typescript
|
||||
type CameraState =
|
||||
| "initializing" // Camera is being initialized
|
||||
| "ready" // Camera is ready to use
|
||||
| "active" // Camera is actively streaming
|
||||
| "in_use" // Camera is in use by another application
|
||||
| "permission_denied" // Camera permission was denied
|
||||
| "not_found" // No camera found on device
|
||||
| "error" // Generic error state
|
||||
| "off"; // Camera is off
|
||||
```
|
||||
|
||||
2. **State Transitions**
|
||||
- Initialization → Ready
|
||||
- Ready → Active
|
||||
- Active → Switching
|
||||
- Switching → Active/Error
|
||||
- Any state → Off (on cleanup)
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Camera Access**
|
||||
- Always check permissions before switching
|
||||
- Handle camera busy states
|
||||
- Implement proper cleanup
|
||||
- Monitor camera state changes
|
||||
|
||||
2. **Performance**
|
||||
- Optimize camera resolution
|
||||
- Handle stream switching efficiently
|
||||
- Manage memory usage
|
||||
- Implement proper cleanup
|
||||
|
||||
3. **User Experience**
|
||||
- Clear visual feedback
|
||||
- Smooth camera transitions
|
||||
- Intuitive camera controls
|
||||
- Responsive UI updates
|
||||
- Accessible camera selection
|
||||
|
||||
4. **Security**
|
||||
- Secure camera access
|
||||
- Permission management
|
||||
- Device validation
|
||||
- Stream security
|
||||
|
||||
### Testing
|
||||
|
||||
1. **Test Scenarios**
|
||||
- Camera switching on both platforms
|
||||
- Permission handling
|
||||
- Error conditions
|
||||
- Multiple camera devices
|
||||
- Camera busy states
|
||||
- Stream initialization
|
||||
- UI responsiveness
|
||||
|
||||
2. **Test Environment**
|
||||
- Multiple mobile devices
|
||||
- Various desktop browsers
|
||||
- Different camera configurations
|
||||
- Network conditions
|
||||
- Permission states
|
||||
|
||||
### Capacitor Implementation Details
|
||||
|
||||
#### MLKit Barcode Scanner Configuration
|
||||
|
||||
1. **Plugin Setup**
|
||||
```typescript
|
||||
// capacitor.config.ts
|
||||
const config: CapacitorConfig = {
|
||||
plugins: {
|
||||
MLKitBarcodeScanner: {
|
||||
formats: ['QR_CODE'],
|
||||
detectorSize: 1.0,
|
||||
lensFacing: 'back',
|
||||
googleBarcodeScannerModuleInstallState: true,
|
||||
// Additional camera options
|
||||
cameraOptions: {
|
||||
quality: 0.8,
|
||||
allowEditing: false,
|
||||
resultType: 'uri',
|
||||
sourceType: 'CAMERA',
|
||||
saveToGallery: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
2. **Camera Management**
|
||||
```typescript
|
||||
// CapacitorQRScanner.ts
|
||||
export class CapacitorQRScanner implements QRScannerService {
|
||||
private currentLensFacing: LensFacing = LensFacing.Back;
|
||||
|
||||
async getAvailableCameras(): Promise<MediaDeviceInfo[]> {
|
||||
// On mobile, we have two fixed cameras
|
||||
return [
|
||||
{
|
||||
deviceId: 'back',
|
||||
label: 'Back Camera',
|
||||
kind: 'videoinput'
|
||||
},
|
||||
{
|
||||
deviceId: 'front',
|
||||
label: 'Front Camera',
|
||||
kind: 'videoinput'
|
||||
}
|
||||
] as MediaDeviceInfo[];
|
||||
}
|
||||
|
||||
async switchCamera(deviceId: string): Promise<void> {
|
||||
if (!this.isScanning) return;
|
||||
|
||||
const newLensFacing = deviceId === 'front' ?
|
||||
LensFacing.Front : LensFacing.Back;
|
||||
|
||||
// Stop current scan
|
||||
await this.stopScan();
|
||||
|
||||
// Update lens facing
|
||||
this.currentLensFacing = newLensFacing;
|
||||
|
||||
// Restart scan with new camera
|
||||
await this.startScan({
|
||||
camera: deviceId as 'front' | 'back'
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrentCamera(): Promise<MediaDeviceInfo | null> {
|
||||
return {
|
||||
deviceId: this.currentLensFacing === LensFacing.Front ? 'front' : 'back',
|
||||
label: this.currentLensFacing === LensFacing.Front ?
|
||||
'Front Camera' : 'Back Camera',
|
||||
kind: 'videoinput'
|
||||
} as MediaDeviceInfo;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Camera State Management**
|
||||
```typescript
|
||||
// CapacitorQRScanner.ts
|
||||
private async handleCameraState(): Promise<void> {
|
||||
try {
|
||||
// Check if camera is available
|
||||
const { camera } = await BarcodeScanner.checkPermissions();
|
||||
|
||||
if (camera === 'denied') {
|
||||
this.updateCameraState('permission_denied');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if camera is in use
|
||||
const isInUse = await this.isCameraInUse();
|
||||
if (isInUse) {
|
||||
this.updateCameraState('in_use');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateCameraState('ready');
|
||||
} catch (error) {
|
||||
this.updateCameraState('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
private async isCameraInUse(): Promise<boolean> {
|
||||
try {
|
||||
// Try to start a test scan
|
||||
await BarcodeScanner.startScan({
|
||||
formats: [BarcodeFormat.QrCode],
|
||||
lensFacing: this.currentLensFacing
|
||||
});
|
||||
// If successful, stop it immediately
|
||||
await BarcodeScanner.stopScan();
|
||||
return false;
|
||||
} catch (error) {
|
||||
return error.message.includes('camera in use');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Error Handling**
|
||||
```typescript
|
||||
// CapacitorQRScanner.ts
|
||||
private async handleCameraError(error: Error): Promise<void> {
|
||||
switch (error.name) {
|
||||
case 'CameraPermissionDenied':
|
||||
this.updateCameraState('permission_denied');
|
||||
break;
|
||||
case 'CameraInUse':
|
||||
this.updateCameraState('in_use');
|
||||
break;
|
||||
case 'CameraUnavailable':
|
||||
this.updateCameraState('not_found');
|
||||
break;
|
||||
default:
|
||||
this.updateCameraState('error', error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Platform-Specific Considerations
|
||||
|
||||
1. **iOS Implementation**
|
||||
- Camera permissions in Info.plist
|
||||
- Privacy descriptions
|
||||
- Camera usage description
|
||||
- Background camera access
|
||||
|
||||
```xml
|
||||
<!-- ios/App/App/Info.plist -->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need access to your camera to scan QR codes</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>We need access to save scanned QR codes</string>
|
||||
```
|
||||
|
||||
2. **Android Implementation**
|
||||
- Camera permissions in AndroidManifest.xml
|
||||
- Runtime permission handling
|
||||
- Camera features declaration
|
||||
- Hardware feature requirements
|
||||
|
||||
```xml
|
||||
<!-- android/app/src/main/AndroidManifest.xml -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" />
|
||||
```
|
||||
|
||||
3. **Platform-Specific Features**
|
||||
- iOS: Camera orientation handling
|
||||
- Android: Camera resolution optimization
|
||||
- Both: Battery usage optimization
|
||||
- Both: Memory management
|
||||
|
||||
```typescript
|
||||
// Platform-specific optimizations
|
||||
private getPlatformSpecificOptions(): StartScanOptions {
|
||||
const baseOptions: StartScanOptions = {
|
||||
formats: [BarcodeFormat.QrCode],
|
||||
lensFacing: this.currentLensFacing
|
||||
};
|
||||
|
||||
if (Capacitor.getPlatform() === 'ios') {
|
||||
return {
|
||||
...baseOptions,
|
||||
// iOS-specific options
|
||||
cameraOptions: {
|
||||
quality: 0.7, // Lower quality for better performance
|
||||
allowEditing: false,
|
||||
resultType: 'uri'
|
||||
}
|
||||
};
|
||||
} else if (Capacitor.getPlatform() === 'android') {
|
||||
return {
|
||||
...baseOptions,
|
||||
// Android-specific options
|
||||
cameraOptions: {
|
||||
quality: 0.8,
|
||||
allowEditing: false,
|
||||
resultType: 'uri',
|
||||
saveToGallery: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}
|
||||
```
|
||||
|
||||
#### Performance Optimization
|
||||
|
||||
1. **Battery Usage**
|
||||
```typescript
|
||||
// CapacitorQRScanner.ts
|
||||
private optimizeBatteryUsage(): void {
|
||||
// Reduce scan frequency when battery is low
|
||||
if (this.isLowBattery()) {
|
||||
this.scanInterval = 2000; // 2 seconds between scans
|
||||
} else {
|
||||
this.scanInterval = 1000; // 1 second between scans
|
||||
}
|
||||
}
|
||||
|
||||
private isLowBattery(): boolean {
|
||||
// Check battery level if available
|
||||
if (Capacitor.isPluginAvailable('Battery')) {
|
||||
const { level } = await Battery.getBatteryLevel();
|
||||
return level < 0.2; // 20% or lower
|
||||
}
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Memory Management**
|
||||
```typescript
|
||||
// CapacitorQRScanner.ts
|
||||
private async cleanupResources(): Promise<void> {
|
||||
// Stop scanning
|
||||
await this.stopScan();
|
||||
|
||||
// Clear any stored camera data
|
||||
this.currentLensFacing = LensFacing.Back;
|
||||
|
||||
// Remove listeners
|
||||
this.listenerHandles.forEach(handle => handle());
|
||||
this.listenerHandles = [];
|
||||
|
||||
// Reset state
|
||||
this.isScanning = false;
|
||||
this.updateCameraState('off');
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing on Capacitor
|
||||
|
||||
1. **Device Testing**
|
||||
- Test on multiple iOS devices
|
||||
- Test on multiple Android devices
|
||||
- Test different camera configurations
|
||||
- Test with different screen sizes
|
||||
- Test with different OS versions
|
||||
|
||||
2. **Camera Testing**
|
||||
- Test front camera switching
|
||||
- Test back camera switching
|
||||
- Test camera permissions
|
||||
- Test camera in use scenarios
|
||||
- Test low light conditions
|
||||
- Test different QR code sizes
|
||||
- Test different QR code distances
|
||||
|
||||
3. **Performance Testing**
|
||||
- Battery usage monitoring
|
||||
- Memory usage monitoring
|
||||
- Camera switching speed
|
||||
- QR code detection speed
|
||||
- App responsiveness
|
||||
- Background/foreground transitions
|
||||
@@ -9,95 +9,21 @@ The deep linking system uses a multi-layered type safety approach:
|
||||
- Enforces parameter requirements
|
||||
- Sanitizes input data
|
||||
- Provides detailed validation errors
|
||||
- Generates TypeScript types automatically
|
||||
|
||||
2. **TypeScript Types**
|
||||
- Generated from Zod schemas using `z.infer`
|
||||
- Generated from Zod schemas
|
||||
- Ensures compile-time type safety
|
||||
- Provides IDE autocompletion
|
||||
- Catches type errors during development
|
||||
- Maintains single source of truth for types
|
||||
|
||||
3. **Router Integration**
|
||||
- Type-safe parameter passing
|
||||
- Route-specific parameter validation
|
||||
- Query parameter type checking
|
||||
- Automatic type inference for route parameters
|
||||
|
||||
## Type System Implementation
|
||||
|
||||
### Zod Schema to TypeScript Type Generation
|
||||
|
||||
```typescript
|
||||
// Define the schema
|
||||
const claimSchema = z.object({
|
||||
id: z.string(),
|
||||
view: z.enum(["details", "certificate", "raw"]).optional()
|
||||
});
|
||||
|
||||
// TypeScript type is automatically generated
|
||||
type ClaimParams = z.infer<typeof claimSchema>;
|
||||
// Equivalent to:
|
||||
// type ClaimParams = {
|
||||
// id: string;
|
||||
// view?: "details" | "certificate" | "raw";
|
||||
// }
|
||||
```
|
||||
|
||||
### Type Safety Layers
|
||||
|
||||
1. **Schema Definition**
|
||||
```typescript
|
||||
// src/interfaces/deepLinks.ts
|
||||
export const deepLinkSchemas = {
|
||||
claim: z.object({
|
||||
id: z.string(),
|
||||
view: z.enum(["details", "certificate", "raw"]).optional()
|
||||
}),
|
||||
// Other route schemas...
|
||||
};
|
||||
```
|
||||
|
||||
2. **Type Generation**
|
||||
```typescript
|
||||
// Types are automatically generated from schemas
|
||||
export type DeepLinkParams = {
|
||||
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
|
||||
};
|
||||
```
|
||||
|
||||
3. **Runtime Validation**
|
||||
```typescript
|
||||
// In DeepLinkHandler
|
||||
const result = deepLinkSchemas.claim.safeParse(params);
|
||||
if (!result.success) {
|
||||
// Handle validation errors
|
||||
console.error(result.error);
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling Types
|
||||
|
||||
```typescript
|
||||
export interface DeepLinkError extends Error {
|
||||
code: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
// Usage in error handling
|
||||
try {
|
||||
await handler.handleDeepLink(url);
|
||||
} catch (error) {
|
||||
if (error instanceof DeepLinkError) {
|
||||
// Type-safe error handling
|
||||
console.error(error.code, error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Files
|
||||
|
||||
- `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
|
||||
- `src/types/deepLinks.ts`: Type definitions and validation schemas
|
||||
- `src/services/deepLinks.ts`: Deep link processing service
|
||||
- `src/main.capacitor.ts`: Capacitor integration
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<title>TimeSafari</title>
|
||||
</head>
|
||||
|
||||
@@ -4,6 +4,7 @@ App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
*.xcuserstate
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
@@ -11,3 +12,12 @@ 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
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:App.xcodeproj">
|
||||
location = "group:Time Safari.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
|
||||
+11
-5
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TimeSafari</string>
|
||||
<string>TimeSafari</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -22,10 +22,6 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Time Safari allows you to take photos, and also scan QR codes from contacts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Time Safari allows you to upload photos.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -49,5 +45,15 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>UISupportsDocumentBrowser</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>This app needs access to save exported files to your photo library.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app needs access to save exported files to your photo library.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.debugger</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.addressbook</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.calendars</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+2
-1
@@ -11,12 +11,13 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
|
||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
|
||||
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
|
||||
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
|
||||
pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning'
|
||||
end
|
||||
|
||||
target 'App' do
|
||||
|
||||
+5
-121
@@ -1,144 +1,28 @@
|
||||
PODS:
|
||||
- Capacitor (6.2.1):
|
||||
- Capacitor (6.2.0):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (6.0.2):
|
||||
- Capacitor
|
||||
- CapacitorCamera (6.1.2):
|
||||
- Capacitor
|
||||
- CapacitorCordova (6.2.1)
|
||||
- CapacitorFilesystem (6.0.3):
|
||||
- Capacitor
|
||||
- CapacitorMlkitBarcodeScanning (6.2.0):
|
||||
- Capacitor
|
||||
- GoogleMLKit/BarcodeScanning (= 5.0.0)
|
||||
- CapacitorShare (6.0.3):
|
||||
- Capacitor
|
||||
- CapawesomeCapacitorFilePicker (6.2.0):
|
||||
- Capacitor
|
||||
- GoogleDataTransport (9.4.1):
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleMLKit/BarcodeScanning (5.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 4.0.0)
|
||||
- GoogleMLKit/MLKitCore (5.0.0):
|
||||
- MLKitCommon (~> 10.0.0)
|
||||
- GoogleToolboxForMac/DebugUtils (2.3.2):
|
||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
||||
- GoogleToolboxForMac/Defines (2.3.2)
|
||||
- GoogleToolboxForMac/Logger (2.3.2):
|
||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
||||
- "GoogleToolboxForMac/NSData+zlib (2.3.2)":
|
||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
||||
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)":
|
||||
- GoogleToolboxForMac/DebugUtils (= 2.3.2)
|
||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
||||
- "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)"
|
||||
- "GoogleToolboxForMac/NSString+URLArguments (2.3.2)"
|
||||
- GoogleUtilities/Environment (7.13.3):
|
||||
- GoogleUtilities/Privacy
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/Logger (7.13.3):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (7.13.3)
|
||||
- GoogleUtilities/UserDefaults (7.13.3):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilitiesComponents (1.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- MLImage (1.0.0-beta5)
|
||||
- MLKitBarcodeScanning (4.0.0):
|
||||
- MLKitCommon (~> 10.0)
|
||||
- MLKitVision (~> 6.0)
|
||||
- MLKitCommon (10.0.0):
|
||||
- GoogleDataTransport (~> 9.0)
|
||||
- GoogleToolboxForMac/Logger (~> 2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
|
||||
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)"
|
||||
- GoogleUtilities/UserDefaults (~> 7.0)
|
||||
- GoogleUtilitiesComponents (~> 1.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
|
||||
- MLKitVision (6.0.0):
|
||||
- GoogleToolboxForMac/Logger (~> 2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
|
||||
- MLImage (= 1.0.0-beta5)
|
||||
- MLKitCommon (~> 10.0)
|
||||
- nanopb (2.30910.0):
|
||||
- nanopb/decode (= 2.30910.0)
|
||||
- nanopb/encode (= 2.30910.0)
|
||||
- nanopb/decode (2.30910.0)
|
||||
- nanopb/encode (2.30910.0)
|
||||
- PromisesObjC (2.4.0)
|
||||
- CapacitorCordova (6.2.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)"
|
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
|
||||
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
|
||||
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
|
||||
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUtilities
|
||||
- GoogleUtilitiesComponents
|
||||
- GTMSessionFetcher
|
||||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Capacitor:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorApp:
|
||||
:path: "../../node_modules/@capacitor/app"
|
||||
CapacitorCamera:
|
||||
:path: "../../node_modules/@capacitor/camera"
|
||||
CapacitorCordova:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorFilesystem:
|
||||
:path: "../../node_modules/@capacitor/filesystem"
|
||||
CapacitorMlkitBarcodeScanning:
|
||||
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning"
|
||||
CapacitorShare:
|
||||
:path: "../../node_modules/@capacitor/share"
|
||||
CapawesomeCapacitorFilePicker:
|
||||
:path: "../../node_modules/@capawesome/capacitor-file-picker"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
|
||||
Capacitor: 05d35014f4425b0740fc8776481f6a369ad071bf
|
||||
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
|
||||
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79
|
||||
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
|
||||
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
|
||||
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
|
||||
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
|
||||
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
|
||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711
|
||||
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
|
||||
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
|
||||
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
|
||||
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e
|
||||
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2
|
||||
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9
|
||||
|
||||
PODFILE CHECKSUM: 7e7e09e6937de7f015393aecf2cf7823645689b3
|
||||
PODFILE CHECKSUM: 4233f5c5f414604460ff96d372542c311b0fb7a8
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
+18
-16
@@ -20,7 +20,7 @@
|
||||
/* Begin PBXFileReference section */
|
||||
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
|
||||
504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3041FED79650016851F /* Time Safari.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Time Safari.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -65,7 +65,7 @@
|
||||
504EC3051FED79650016851F /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
504EC3041FED79650016851F /* App.app */,
|
||||
504EC3041FED79650016851F /* Time Safari.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -97,9 +97,9 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
504EC3031FED79650016851F /* App */ = {
|
||||
504EC3031FED79650016851F /* Time Safari */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */;
|
||||
buildPhases = (
|
||||
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
@@ -111,9 +111,9 @@
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
name = "Time Safari";
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productReference = 504EC3041FED79650016851F /* Time Safari.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -132,7 +132,7 @@
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
|
||||
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */;
|
||||
compatibilityVersion = "Xcode 8.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
@@ -147,7 +147,7 @@
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
504EC3031FED79650016851F /* App */,
|
||||
504EC3031FED79650016851F /* Time Safari */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -348,19 +348,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -370,25 +371,26 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
|
||||
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3141FED79650016851F /* Debug */,
|
||||
@@ -397,7 +399,7 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
|
||||
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
504EC3171FED79650016851F /* Debug */,
|
||||
@@ -0,0 +1,22 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Build and deploy iOS app"
|
||||
lane :beta do
|
||||
build_ios_app(
|
||||
scheme: "App",
|
||||
workspace: "App.xcworkspace",
|
||||
export_method: "app-store"
|
||||
)
|
||||
upload_to_testflight
|
||||
end
|
||||
|
||||
lane :release do
|
||||
build_ios_app(
|
||||
scheme: "App",
|
||||
workspace: "App.xcworkspace",
|
||||
export_method: "app-store"
|
||||
)
|
||||
upload_to_app_store
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
JWT Creation & Verification
|
||||
|
||||
To run this in a script, see /scripts/openssl_signing_console.sh
|
||||
To run this in a script, see ./openssl_signing_console.sh
|
||||
|
||||
Prerequisites: openssl, jq
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
#
|
||||
# Prerequisites: openssl, jq
|
||||
#
|
||||
# Usage: source /scripts/openssl_signing_console.sh
|
||||
# Usage: source ./openssl_signing_console.sh
|
||||
#
|
||||
# For a more complete explanation, see /doc/openssl_signing_console.rst
|
||||
# For a more complete explanation, see ./openssl_signing_console.rst
|
||||
|
||||
|
||||
# Generate a key and extract the public part
|
||||
Generated
+6120
-2293
File diff suppressed because it is too large
Load Diff
+34
-51
@@ -1,16 +1,21 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.4",
|
||||
"description": "Time Safari Application",
|
||||
"type": "module",
|
||||
"author": {
|
||||
"name": "Time Safari Team"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --config vite.config.dev.mts --host",
|
||||
"dev": "vite",
|
||||
"dev:web": "vite --config vite.config.web.mts",
|
||||
"serve": "vite preview",
|
||||
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||
"build": "vite build",
|
||||
"build:mobile": "VITE_PLATFORM=capacitor vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "NODE_OPTIONS='--experimental-vm-modules --experimental-specifier-resolution=node' eslint --ext .vue,.js,.jsx,.mjs,.ts,.tsx,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/",
|
||||
"lint-fix": "NODE_OPTIONS='--experimental-vm-modules --experimental-specifier-resolution=node' eslint \"src/**/*.{vue,js,jsx,ts,tsx,mjs,mts}\" --fix",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
|
||||
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
|
||||
"test:prerequisites": "node scripts/check-prerequisites.js",
|
||||
@@ -22,11 +27,11 @@
|
||||
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
|
||||
"clean:electron": "rimraf dist-electron",
|
||||
"build:pywebview": "vite build --config vite.config.pywebview.mts",
|
||||
"build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
|
||||
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||
"build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts",
|
||||
"electron:dev": "npm run build && electron .",
|
||||
"electron:start": "electron .",
|
||||
"build:electron": "npm run clean:electron && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
|
||||
"build:capacitor": "vite build --config vite.config.capacitor.mts",
|
||||
"build:web": "vite build --config vite.config.web.mts",
|
||||
"electron:dev": "npm run build && electron dist-electron",
|
||||
"electron:start": "electron dist-electron",
|
||||
"clean:android": "adb uninstall app.timesafari.app || true",
|
||||
"build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
|
||||
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
|
||||
@@ -41,17 +46,15 @@
|
||||
"fastlane:ios:beta": "cd ios && fastlane beta",
|
||||
"fastlane:ios:release": "cd ios && fastlane release",
|
||||
"fastlane:android:beta": "cd android && fastlane beta",
|
||||
"fastlane:android:release": "cd android && fastlane release",
|
||||
"electron:build-mac": "npm run build:electron-prod && electron-builder --mac",
|
||||
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
|
||||
"fastlane:android:release": "cd android && fastlane release"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
|
||||
"@capacitor/android": "^6.2.0",
|
||||
"@capacitor/app": "^6.0.0",
|
||||
"@capacitor/camera": "^6.0.0",
|
||||
"@capacitor/cli": "^6.2.0",
|
||||
"@capacitor/core": "^6.2.0",
|
||||
"@capacitor/clipboard": "^6.0.2",
|
||||
"@capacitor/core": "^6.2.1",
|
||||
"@capacitor/filesystem": "^6.0.0",
|
||||
"@capacitor/ios": "^6.2.0",
|
||||
"@capacitor/share": "^6.0.3",
|
||||
@@ -83,8 +86,10 @@
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"asn1-ber": "^1.2.2",
|
||||
"axios": "^1.6.8",
|
||||
"buffer": "^6.0.3",
|
||||
"cbor-x": "^1.5.9",
|
||||
"class-transformer": "^0.5.1",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"dexie": "^3.2.7",
|
||||
"dexie-export-import": "^4.1.4",
|
||||
"did-jwt": "^7.4.7",
|
||||
@@ -101,7 +106,7 @@
|
||||
"lru-cache": "^10.2.0",
|
||||
"luxon": "^3.4.4",
|
||||
"merkletreejs": "^0.3.11",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"nostr-tools": "^2.12.0",
|
||||
"notiwind": "^2.0.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"pina": "^0.20.2204228",
|
||||
@@ -127,6 +132,7 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor-mlkit/barcode-scanning": "^6.2.0",
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@playwright/test": "^1.45.2",
|
||||
"@types/dom-webcodecs": "^0.1.7",
|
||||
@@ -142,21 +148,23 @@
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"eslint-import-resolver-node": "^0.3.9",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"espree": "^10.3.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"markdownlint": "^0.37.4",
|
||||
"markdownlint-cli": "^0.44.0",
|
||||
"npm-check-updates": "^17.1.13",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.5.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "~5.2.2",
|
||||
@@ -172,12 +180,13 @@
|
||||
},
|
||||
"files": [
|
||||
"dist-electron/**/*",
|
||||
"dist/**/*"
|
||||
"src/electron/**/*",
|
||||
"main.js"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist",
|
||||
"to": "www"
|
||||
"from": "dist-electron",
|
||||
"to": "."
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
@@ -188,32 +197,6 @@
|
||||
"category": "Office",
|
||||
"icon": "build/icon.png"
|
||||
},
|
||||
"asar": true,
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "build/icon.png",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "ios/App/App/entitlements.mac.plist",
|
||||
"entitlementsInherit": "ios/App/App/entitlements.mac.plist"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
}
|
||||
"asar": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
dependencies:
|
||||
- gradle
|
||||
- java
|
||||
- pod
|
||||
|
||||
# other dependencies are discovered via package.json & requirements.txt & Gemfile (I'm guessing).
|
||||
|
||||
+80
-225
@@ -1,243 +1,98 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('Starting electron build process...');
|
||||
|
||||
// Create dist directory if it doesn't exist
|
||||
const distElectronDir = path.resolve(__dirname, '../dist-electron');
|
||||
await fs.ensureDir(distElectronDir);
|
||||
|
||||
// Copy web files
|
||||
const webDistPath = path.join(__dirname, '..', 'dist');
|
||||
const electronDistPath = path.join(__dirname, '..', 'dist-electron');
|
||||
const wwwPath = path.join(electronDistPath, 'www');
|
||||
const wwwDir = path.join(distElectronDir, 'www');
|
||||
await fs.ensureDir(wwwDir);
|
||||
await fs.copy('dist', wwwDir);
|
||||
|
||||
// Create www directory if it doesn't exist
|
||||
if (!fs.existsSync(wwwPath)) {
|
||||
fs.mkdirSync(wwwPath, { recursive: true });
|
||||
}
|
||||
// Copy and fix index.html
|
||||
const indexPath = path.join(wwwDir, 'index.html');
|
||||
let indexContent = await fs.readFile(indexPath, 'utf8');
|
||||
|
||||
// Copy web files to www directory
|
||||
fs.cpSync(webDistPath, wwwPath, { recursive: true });
|
||||
|
||||
// Fix asset paths in index.html
|
||||
const indexPath = path.join(wwwPath, 'index.html');
|
||||
let indexContent = fs.readFileSync(indexPath, 'utf8');
|
||||
|
||||
// Fix asset paths
|
||||
// More comprehensive path fixing
|
||||
indexContent = indexContent
|
||||
.replace(/\/assets\//g, './assets/')
|
||||
.replace(/href="\//g, 'href="./')
|
||||
.replace(/src="\//g, 'src="./');
|
||||
// Fix absolute paths to be relative
|
||||
.replace(/src="\//g, 'src="\./')
|
||||
.replace(/href="\//g, 'href="\./')
|
||||
// Fix modulepreload paths
|
||||
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/')
|
||||
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\.\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/')
|
||||
// Fix stylesheet paths
|
||||
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/')
|
||||
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\.\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/')
|
||||
// Fix script paths
|
||||
.replace(/src="\/assets\//g, 'src="./assets/')
|
||||
.replace(/src="\.\/assets\//g, 'src="./assets/')
|
||||
// Fix any remaining asset paths
|
||||
.replace(/(['"]\/?)(assets\/)/g, '"./assets/');
|
||||
|
||||
fs.writeFileSync(indexPath, indexContent);
|
||||
|
||||
// Check for remaining /assets/ paths
|
||||
// Debug output
|
||||
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/'));
|
||||
console.log('Sample of fixed content:', indexContent.substring(0, 500));
|
||||
console.log('Sample of fixed content:', indexContent.slice(0, 500));
|
||||
|
||||
await fs.writeFile(indexPath, indexContent);
|
||||
|
||||
console.log('Copied and fixed web files in:', wwwPath);
|
||||
console.log('Copied and fixed web files in:', wwwDir);
|
||||
|
||||
// Copy main process files
|
||||
console.log('Copying main process files...');
|
||||
const mainProcessFiles = [
|
||||
['src/electron/main.js', 'main.js'],
|
||||
['src/electron/preload.js', 'preload.js']
|
||||
];
|
||||
|
||||
// Create the main process file with inlined logger
|
||||
const mainContent = `const { app, BrowserWindow } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// Inline logger implementation
|
||||
const logger = {
|
||||
log: (...args) => console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
info: (...args) => console.info(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
debug: (...args) => console.debug(...args),
|
||||
};
|
||||
|
||||
// Check if running in dev mode
|
||||
const isDev = process.argv.includes("--inspect");
|
||||
|
||||
function createWindow() {
|
||||
// Add before createWindow function
|
||||
const preloadPath = path.join(__dirname, "preload.js");
|
||||
logger.log("Checking preload path:", preloadPath);
|
||||
logger.log("Preload exists:", fs.existsSync(preloadPath));
|
||||
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
// Always open DevTools for now
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
||||
// Intercept requests to fix asset paths
|
||||
mainWindow.webContents.session.webRequest.onBeforeRequest(
|
||||
{
|
||||
urls: [
|
||||
"file://*/*/assets/*",
|
||||
"file://*/assets/*",
|
||||
"file:///assets/*", // Catch absolute paths
|
||||
"<all_urls>", // Catch all URLs as a fallback
|
||||
],
|
||||
},
|
||||
(details, callback) => {
|
||||
let url = details.url;
|
||||
|
||||
// Handle paths that don't start with file://
|
||||
if (!url.startsWith("file://") && url.includes("/assets/")) {
|
||||
url = \`file://\${path.join(__dirname, "www", url)}\`;
|
||||
}
|
||||
|
||||
// Handle absolute paths starting with /assets/
|
||||
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
|
||||
const baseDir = url.includes("dist-electron")
|
||||
? url.substring(
|
||||
0,
|
||||
url.indexOf("/dist-electron") + "/dist-electron".length,
|
||||
)
|
||||
: \`file://\${__dirname}\`;
|
||||
const assetPath = url.split("/assets/")[1];
|
||||
const newUrl = \`\${baseDir}/www/assets/\${assetPath}\`;
|
||||
callback({ redirectURL: newUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
callback({}); // No redirect for other URLs
|
||||
},
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
// Debug info
|
||||
logger.log("Debug Info:");
|
||||
logger.log("Running in dev mode:", isDev);
|
||||
logger.log("App is packaged:", app.isPackaged);
|
||||
logger.log("Process resource path:", process.resourcesPath);
|
||||
logger.log("App path:", app.getAppPath());
|
||||
logger.log("__dirname:", __dirname);
|
||||
logger.log("process.cwd():", process.cwd());
|
||||
}
|
||||
|
||||
const indexPath = path.join(__dirname, "www", "index.html");
|
||||
|
||||
if (isDev) {
|
||||
logger.log("Loading index from:", indexPath);
|
||||
logger.log("www path:", path.join(__dirname, "www"));
|
||||
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
logger.error(\`Index file not found at: \${indexPath}\`);
|
||||
throw new Error("Index file not found");
|
||||
}
|
||||
|
||||
// Add CSP headers to allow API connections, Google Fonts, and zxing-wasm
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self';" +
|
||||
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app https://*.jsdelivr.net;" +
|
||||
"img-src 'self' data: https: blob:;" +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.jsdelivr.net;" +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
|
||||
"font-src 'self' data: https://fonts.gstatic.com;" +
|
||||
"style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
|
||||
"worker-src 'self' blob:;",
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Load the index.html
|
||||
mainWindow
|
||||
.loadFile(indexPath)
|
||||
.then(() => {
|
||||
logger.log("Successfully loaded index.html");
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
logger.log("DevTools opened - running in dev mode");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to load index.html:", err);
|
||||
logger.error("Attempted path:", indexPath);
|
||||
});
|
||||
|
||||
// Listen for console messages from the renderer
|
||||
mainWindow.webContents.on("console-message", (_event, _level, message) => {
|
||||
logger.log("Renderer Console:", message);
|
||||
});
|
||||
|
||||
// Add right after creating the BrowserWindow
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription) => {
|
||||
logger.error("Page failed to load:", errorCode, errorDescription);
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
logger.error("Preload script error:", preloadPath, error);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"console-message",
|
||||
(_event, _level, message, line, sourceId) => {
|
||||
logger.log("Renderer Console:", line, sourceId, message);
|
||||
},
|
||||
);
|
||||
|
||||
// Enable remote debugging when in dev mode
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
for (const [src, dest] of mainProcessFiles) {
|
||||
const destPath = path.join(distElectronDir, dest);
|
||||
console.log(`Copying ${src} to ${destPath}`);
|
||||
await fs.copy(src, destPath);
|
||||
}
|
||||
|
||||
// Create package.json for production
|
||||
const devPackageJson = require('../package.json');
|
||||
const prodPackageJson = {
|
||||
name: devPackageJson.name,
|
||||
version: devPackageJson.version,
|
||||
description: devPackageJson.description,
|
||||
author: devPackageJson.author,
|
||||
main: 'main.js',
|
||||
private: true,
|
||||
};
|
||||
|
||||
await fs.writeJson(
|
||||
path.join(distElectronDir, 'package.json'),
|
||||
prodPackageJson,
|
||||
{ spaces: 2 }
|
||||
);
|
||||
|
||||
// Verify the build
|
||||
console.log('\nVerifying build structure:');
|
||||
const files = await fs.readdir(distElectronDir);
|
||||
console.log('Files in dist-electron:', files);
|
||||
|
||||
if (!files.includes('main.js')) {
|
||||
throw new Error('main.js not found in build directory');
|
||||
}
|
||||
if (!files.includes('preload.js')) {
|
||||
throw new Error('preload.js not found in build directory');
|
||||
}
|
||||
if (!files.includes('package.json')) {
|
||||
throw new Error('package.json not found in build directory');
|
||||
}
|
||||
|
||||
console.log('Build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle app ready
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
// Handle all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle any errors
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("Uncaught Exception:", error);
|
||||
});
|
||||
`;
|
||||
|
||||
// Write the main process file
|
||||
const mainDest = path.join(electronDistPath, 'main.js');
|
||||
fs.writeFileSync(mainDest, mainContent);
|
||||
|
||||
// Copy preload script if it exists
|
||||
const preloadSrc = path.join(__dirname, '..', 'src', 'electron', 'preload.js');
|
||||
const preloadDest = path.join(electronDistPath, 'preload.js');
|
||||
if (fs.existsSync(preloadSrc)) {
|
||||
console.log(`Copying ${preloadSrc} to ${preloadDest}`);
|
||||
fs.copyFileSync(preloadSrc, preloadDest);
|
||||
}
|
||||
|
||||
// Verify build structure
|
||||
console.log('\nVerifying build structure:');
|
||||
console.log('Files in dist-electron:', fs.readdirSync(electronDistPath));
|
||||
|
||||
console.log('Build completed successfully!');
|
||||
main();
|
||||
+31
-31
@@ -103,7 +103,7 @@ const cleanIosPlatform = async (log) => {
|
||||
// Get app name from package.json
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
const appName = packageJson.name || 'App';
|
||||
const appId = packageJson.build.appId || 'io.ionic.starter';
|
||||
const appId = packageJson.capacitor?.appId || 'io.ionic.starter';
|
||||
|
||||
// Create a minimal capacitor config
|
||||
const capacitorConfig = `
|
||||
@@ -441,8 +441,8 @@ const configureIosProject = async (log) => {
|
||||
try {
|
||||
// Try to run pod install normally first
|
||||
log('🔄 Running "pod install" in ios/App directory...');
|
||||
execSync('cd ios/App && pod install', { stdio: 'inherit' });
|
||||
log('✅ CocoaPods installation completed');
|
||||
execSync('cd ios/App && pod install', { stdio: 'inherit' });
|
||||
log('✅ CocoaPods installation completed');
|
||||
} catch (error) {
|
||||
// If that fails, provide detailed instructions
|
||||
log(`⚠️ CocoaPods installation failed: ${error.message}`);
|
||||
@@ -467,12 +467,12 @@ const configureIosProject = async (log) => {
|
||||
// Build and test iOS project
|
||||
const buildAndTestIos = async (log, simulator) => {
|
||||
const simulatorName = simulator[0].name;
|
||||
log('🏗️ Building iOS project...', simulator[0]);
|
||||
log('🏗️ Building iOS project...');
|
||||
execSync('cd ios/App && xcodebuild clean -workspace App.xcworkspace -scheme App', { stdio: 'inherit' });
|
||||
log('✅ Xcode clean completed');
|
||||
|
||||
log(`🏗️ Building for simulator: ${simulatorName}`);
|
||||
execSync(`cd ios/App && xcodebuild build -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,OS=17.2,name=${simulatorName}"`, { stdio: 'inherit' });
|
||||
execSync(`cd ios/App && xcodebuild build -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,name=${simulatorName}"`, { stdio: 'inherit' });
|
||||
log('✅ Xcode build completed');
|
||||
|
||||
// Check if the project is configured for testing by querying the scheme capabilities
|
||||
@@ -623,28 +623,28 @@ const runDeeplinkTests = async (log) => {
|
||||
}
|
||||
|
||||
// Now we can safely create the deeplink tests knowing we have valid data
|
||||
const deeplinkTests = [
|
||||
{
|
||||
const deeplinkTests = [
|
||||
{
|
||||
url: `timesafari://claim/${testEnv.CLAIM_ID}`,
|
||||
description: 'Claim view'
|
||||
},
|
||||
{
|
||||
description: 'Claim view'
|
||||
},
|
||||
{
|
||||
url: `timesafari://claim-cert/${testEnv.CERT_ID || testEnv.CLAIM_ID}`,
|
||||
description: 'Claim certificate view'
|
||||
},
|
||||
{
|
||||
description: 'Claim certificate view'
|
||||
},
|
||||
{
|
||||
url: `timesafari://claim-add-raw/${testEnv.RAW_CLAIM_ID || testEnv.CLAIM_ID}`,
|
||||
description: 'Raw claim addition'
|
||||
},
|
||||
{
|
||||
url: 'timesafari://did/test',
|
||||
description: 'DID view with test identifier'
|
||||
},
|
||||
{
|
||||
url: `timesafari://did/${testEnv.CONTACT1_DID}`,
|
||||
description: 'DID view with contact DID'
|
||||
},
|
||||
{
|
||||
description: 'Raw claim addition'
|
||||
},
|
||||
{
|
||||
url: 'timesafari://did/test',
|
||||
description: 'DID view with test identifier'
|
||||
},
|
||||
{
|
||||
url: `timesafari://did/${testEnv.CONTACT1_DID}`,
|
||||
description: 'DID view with contact DID'
|
||||
},
|
||||
{
|
||||
url: (() => {
|
||||
if (!testEnv?.CONTACT1_DID) {
|
||||
throw new Error('Cannot construct contact-edit URL: CONTACT1_DID is missing');
|
||||
@@ -653,13 +653,13 @@ const runDeeplinkTests = async (log) => {
|
||||
log('Created contact-edit URL:', url);
|
||||
return url;
|
||||
})(),
|
||||
description: 'Contact editing'
|
||||
},
|
||||
{
|
||||
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
|
||||
description: 'Contacts import'
|
||||
}
|
||||
];
|
||||
description: 'Contact editing'
|
||||
},
|
||||
{
|
||||
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
|
||||
description: 'Contacts import'
|
||||
}
|
||||
];
|
||||
|
||||
// Log the final test configuration
|
||||
log('\n5. Final Test Configuration:');
|
||||
|
||||
+141
-152
@@ -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 top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
|
||||
>
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
@@ -29,7 +29,9 @@
|
||||
>
|
||||
<div class="w-full px-4 py-3">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ notification.text }}</p>
|
||||
<p class="text-sm">
|
||||
{{ notification.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,21 +42,20 @@
|
||||
<div
|
||||
class="flex items-center justify-center w-12 bg-slate-600 text-slate-100"
|
||||
>
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="fa-fw fa-xl"
|
||||
></font-awesome>
|
||||
<font-awesome icon="circle-info" class="fa-fw fa-xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
|
||||
<p class="text-sm">
|
||||
{{ truncateLongWords(notification.text) }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600"
|
||||
@click="close(notification.id)"
|
||||
>
|
||||
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
|
||||
<font-awesome icon="xmark" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,21 +67,20 @@
|
||||
<div
|
||||
class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100"
|
||||
>
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="fa-fw fa-xl"
|
||||
></font-awesome>
|
||||
<font-awesome icon="circle-info" class="fa-fw fa-xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
|
||||
<p class="text-sm">
|
||||
{{ truncateLongWords(notification.text) }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600"
|
||||
@click="close(notification.id)"
|
||||
>
|
||||
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
|
||||
<font-awesome icon="xmark" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,21 +92,20 @@
|
||||
<div
|
||||
class="flex items-center justify-center w-12 bg-amber-600 text-amber-100"
|
||||
>
|
||||
<font-awesome
|
||||
icon="triangle-exclamation"
|
||||
class="fa-fw fa-xl"
|
||||
></font-awesome>
|
||||
<font-awesome icon="triangle-exclamation" class="fa-fw fa-xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
|
||||
<p class="text-sm">
|
||||
{{ truncateLongWords(notification.text) }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600"
|
||||
@click="close(notification.id)"
|
||||
>
|
||||
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
|
||||
<font-awesome icon="xmark" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,21 +117,20 @@
|
||||
<div
|
||||
class="flex items-center justify-center w-12 bg-rose-600 text-rose-100"
|
||||
>
|
||||
<font-awesome
|
||||
icon="triangle-exclamation"
|
||||
class="fa-fw fa-xl"
|
||||
></font-awesome>
|
||||
<font-awesome icon="triangle-exclamation" class="fa-fw fa-xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
|
||||
<span class="font-semibold">{{ notification.title }}</span>
|
||||
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
|
||||
<p class="text-sm">
|
||||
{{ truncateLongWords(notification.text) }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600"
|
||||
@click="close(notification.id)"
|
||||
>
|
||||
<font-awesome icon="xmark" class="fa-fw"></font-awesome>
|
||||
<font-awesome icon="xmark" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,10 +142,10 @@
|
||||
<!--
|
||||
This "group" of "modal" is the prompt for an answer.
|
||||
Set "type" as follows: "confirm" for yes/no, and "notification" ones:
|
||||
"-permission", "-mute", "-off"
|
||||
"-permission", "-mute", "-off"
|
||||
-->
|
||||
<NotificationGroup group="modal">
|
||||
<div class="fixed z-[100] top-[env(safe-area-inset-top)] inset-x-0 w-full">
|
||||
<div class="fixed z-[100] top-0 inset-x-0 w-full">
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
enter="transform ease-out duration-300 transition"
|
||||
@@ -167,10 +165,10 @@
|
||||
role="alert"
|
||||
>
|
||||
<!--
|
||||
Type of "confirm" will post a message.
|
||||
With onYes function, show a "Yes" button to call that function.
|
||||
With onNo function, show a "No" button to call that function,
|
||||
and pass it state of "askAgain" field shown if you set promptToStopAsking.
|
||||
Type of "confirm" will post a message.
|
||||
With onYes function, show a "Yes" button to call that function.
|
||||
With onNo function, show a "No" button to call that function,
|
||||
and pass it state of "askAgain" field shown if you set promptToStopAsking.
|
||||
-->
|
||||
<div
|
||||
v-if="notification.type === 'confirm'"
|
||||
@@ -183,31 +181,33 @@
|
||||
<span class="font-semibold text-lg">
|
||||
{{ notification.title }}
|
||||
</span>
|
||||
<p class="text-sm mb-2">{{ notification.text }}</p>
|
||||
<p class="text-sm mb-2">
|
||||
{{ notification.text }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="notification.onYes"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
@click="
|
||||
@click="{
|
||||
notification.onYes();
|
||||
close(notification.id);
|
||||
"
|
||||
}"
|
||||
>
|
||||
Yes{{
|
||||
notification.yesText ? ", " + notification.yesText : ""
|
||||
notification.yesText ? ', ' + notification.yesText : ''
|
||||
}}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="notification.onNo"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
@click="
|
||||
@click="{
|
||||
notification.onNo(stopAsking);
|
||||
close(notification.id);
|
||||
stopAsking = false; // reset value
|
||||
"
|
||||
}"
|
||||
>
|
||||
No{{ notification.noText ? ", " + notification.noText : "" }}
|
||||
No{{ notification.noText ? ', ' + notification.noText : '' }}
|
||||
</button>
|
||||
|
||||
<label
|
||||
@@ -228,25 +228,25 @@
|
||||
class="sr-only"
|
||||
/>
|
||||
<!-- line -->
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
||||
<!-- dot -->
|
||||
<div
|
||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||
></div>
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
@click="
|
||||
@click="{
|
||||
notification.onCancel
|
||||
? notification.onCancel(stopAsking)
|
||||
: null;
|
||||
close(notification.id);
|
||||
stopAsking = false; // reset value for next time they open this modal
|
||||
"
|
||||
}"
|
||||
>
|
||||
{{ notification.onYes ? "Cancel" : "Close" }}
|
||||
{{ notification.onYes ? 'Cancel' : 'Close' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,10 +306,10 @@
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
@click="
|
||||
@click="{
|
||||
close(notification.id);
|
||||
turnOffNotifications(notification);
|
||||
"
|
||||
}"
|
||||
>
|
||||
Turn Off Notification
|
||||
</button>
|
||||
@@ -329,21 +329,21 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index";
|
||||
import { NotificationIface } from "./constants/app";
|
||||
import { logger } from "./utils/logger";
|
||||
import { Vue, Component } from 'vue-facing-decorator'
|
||||
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from './db/index'
|
||||
import { NotificationIface } from './constants/app'
|
||||
import { logger } from './utils/logger'
|
||||
|
||||
interface Settings {
|
||||
notifyingNewActivityTime?: string;
|
||||
notifyingReminderTime?: string;
|
||||
notifyingNewActivityTime?: string
|
||||
notifyingReminderTime?: string
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class App extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
stopAsking = false;
|
||||
stopAsking = false
|
||||
|
||||
// created() {
|
||||
// logger.log(
|
||||
@@ -382,172 +382,161 @@ export default class App extends Vue {
|
||||
|
||||
truncateLongWords(sentence: string) {
|
||||
return sentence
|
||||
.split(" ")
|
||||
.map((word) => (word.length > 30 ? word.slice(0, 30) + "..." : word))
|
||||
.join(" ");
|
||||
.split(' ')
|
||||
.map((word) => (word.length > 30 ? word.slice(0, 30) + '...' : word))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
async turnOffNotifications(
|
||||
notification: NotificationIface,
|
||||
notification: NotificationIface
|
||||
): Promise<boolean> {
|
||||
logger.log("Starting turnOffNotifications...");
|
||||
let subscription: PushSubscriptionJSON | null = null;
|
||||
let allGoingOff = false;
|
||||
logger.log('Starting turnOffNotifications...')
|
||||
let subscription: PushSubscriptionJSON | null = null
|
||||
let allGoingOff = false
|
||||
|
||||
try {
|
||||
logger.log("Retrieving settings for the active account...");
|
||||
const settings: Settings = await retrieveSettingsForActiveAccount();
|
||||
logger.log("Retrieved settings:", settings);
|
||||
logger.log('Retrieving settings for the active account...')
|
||||
const settings: Settings = await retrieveSettingsForActiveAccount()
|
||||
logger.log('Retrieved settings:', settings)
|
||||
|
||||
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
|
||||
const notifyingReminder = !!settings?.notifyingReminderTime;
|
||||
const notifyingNewActivity = !!settings?.notifyingNewActivityTime
|
||||
const notifyingReminder = !!settings?.notifyingReminderTime
|
||||
|
||||
if (!notifyingNewActivity || !notifyingReminder) {
|
||||
allGoingOff = true;
|
||||
logger.log("Both notifications are being turned off.");
|
||||
allGoingOff = true
|
||||
logger.log('Both notifications are being turned off.')
|
||||
}
|
||||
|
||||
logger.log("Checking service worker readiness...");
|
||||
logger.log('Checking service worker readiness...')
|
||||
await navigator.serviceWorker?.ready
|
||||
.then((registration) => {
|
||||
logger.log("Service worker is ready. Fetching subscription...");
|
||||
return registration.pushManager.getSubscription();
|
||||
logger.log('Service worker is ready. Fetching subscription...')
|
||||
return registration.pushManager.getSubscription()
|
||||
})
|
||||
.then(async (subscript: PushSubscription | null) => {
|
||||
if (subscript) {
|
||||
subscription = subscript.toJSON();
|
||||
logger.log("PushSubscription retrieved:", subscription);
|
||||
subscription = subscript.toJSON()
|
||||
logger.log('PushSubscription retrieved:', subscription)
|
||||
|
||||
if (allGoingOff) {
|
||||
logger.log("Unsubscribing from push notifications...");
|
||||
await subscript.unsubscribe();
|
||||
logger.log("Successfully unsubscribed.");
|
||||
logger.log('Unsubscribing from push notifications...')
|
||||
await subscript.unsubscribe()
|
||||
logger.log('Successfully unsubscribed.')
|
||||
}
|
||||
} else {
|
||||
logConsoleAndDb("Subscription object is not available.");
|
||||
logger.log("No subscription found.");
|
||||
logConsoleAndDb('Subscription object is not available.')
|
||||
logger.log('No subscription found.')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logConsoleAndDb(
|
||||
"Push provider server communication failed: " +
|
||||
'Push provider server communication failed: ' +
|
||||
JSON.stringify(error),
|
||||
true,
|
||||
);
|
||||
logger.error("Error during subscription fetch:", error);
|
||||
});
|
||||
true
|
||||
)
|
||||
logger.error('Error during subscription fetch:', error)
|
||||
})
|
||||
|
||||
if (!subscription) {
|
||||
logger.log("No subscription available. Notifying user...");
|
||||
logger.log('No subscription available. Notifying user...')
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Finished",
|
||||
text: "Notifications are off.",
|
||||
group: 'alert',
|
||||
type: 'info',
|
||||
title: 'Finished',
|
||||
text: 'Notifications are off.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
logger.log("Exiting as there is no subscription to process.");
|
||||
return true;
|
||||
5000
|
||||
)
|
||||
logger.log('Exiting as there is no subscription to process.')
|
||||
return true
|
||||
}
|
||||
|
||||
const serverSubscription = {
|
||||
...subscription,
|
||||
};
|
||||
...subscription
|
||||
}
|
||||
if (!allGoingOff) {
|
||||
serverSubscription["notifyType"] = notification.title;
|
||||
serverSubscription['notifyType'] = notification.title
|
||||
logger.log(
|
||||
`Server subscription updated with notifyType: ${notification.title}`,
|
||||
);
|
||||
`Server subscription updated with notifyType: ${notification.title}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.log("Sending unsubscribe request to the server...");
|
||||
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
|
||||
method: "POST",
|
||||
logger.log('Sending unsubscribe request to the server...')
|
||||
const pushServerSuccess = await fetch('/web-push/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(serverSubscription),
|
||||
body: JSON.stringify(serverSubscription)
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
const errorBody = await response.text()
|
||||
logConsoleAndDb(
|
||||
`Push server failed: ${response.status} ${errorBody}`,
|
||||
true,
|
||||
);
|
||||
logger.error("Push server error response:", errorBody);
|
||||
true
|
||||
)
|
||||
logger.error('Push server error response:', errorBody)
|
||||
}
|
||||
logger.log(`Server response status: ${response.status}`);
|
||||
return response.ok;
|
||||
logger.log(`Server response status: ${response.status}`)
|
||||
return response.ok
|
||||
})
|
||||
.catch((error) => {
|
||||
logConsoleAndDb(
|
||||
"Push server communication failed: " + JSON.stringify(error),
|
||||
true,
|
||||
);
|
||||
logger.error("Error during server communication:", error);
|
||||
return false;
|
||||
});
|
||||
'Push server communication failed: ' + JSON.stringify(error),
|
||||
true
|
||||
)
|
||||
logger.error('Error during server communication:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
const message = pushServerSuccess
|
||||
? "Notification is off."
|
||||
: "Notification is still on. Try to turn it off again.";
|
||||
logger.log("Server response processed. Message:", message);
|
||||
? 'Notification is off.'
|
||||
: 'Notification is still on. Try to turn it off again.'
|
||||
logger.log('Server response processed. Message:', message)
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Finished",
|
||||
text: message,
|
||||
group: 'alert',
|
||||
type: 'info',
|
||||
title: 'Finished',
|
||||
text: message
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
|
||||
if (notification.callback) {
|
||||
logger.log("Executing notification callback...");
|
||||
notification.callback(pushServerSuccess);
|
||||
logger.log('Executing notification callback...')
|
||||
notification.callback(pushServerSuccess)
|
||||
}
|
||||
|
||||
logger.log(
|
||||
"Completed turnOffNotifications with success:",
|
||||
pushServerSuccess,
|
||||
);
|
||||
return pushServerSuccess;
|
||||
'Completed turnOffNotifications with success:',
|
||||
pushServerSuccess
|
||||
)
|
||||
return pushServerSuccess
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
"Error turning off notifications: " + JSON.stringify(error),
|
||||
true,
|
||||
);
|
||||
logger.error("Critical error in turnOffNotifications:", error);
|
||||
'Error turning off notifications: ' + JSON.stringify(error),
|
||||
true
|
||||
)
|
||||
logger.error('Critical error in turnOffNotifications:', error)
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "error",
|
||||
title: "Error",
|
||||
text: "Failed to turn off notifications. Please try again.",
|
||||
group: 'alert',
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
text: 'Failed to turn off notifications. Please try again.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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);
|
||||
}
|
||||
|
||||
#QuickNav ~ #Content {
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6rem);
|
||||
}
|
||||
</style>
|
||||
<style></style>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold">
|
||||
{{ record.issuer.known ? record.issuer.displayName : "" }}
|
||||
{{ record.issuer.known ? record.issuer.displayName : '' }}
|
||||
</h3>
|
||||
<p class="ms-auto text-xs text-slate-500 italic">
|
||||
{{ friendlyDate }}
|
||||
@@ -57,7 +57,7 @@
|
||||
class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md"
|
||||
:src="record.image"
|
||||
alt="Activity image"
|
||||
@load="cacheImage(record.image)"
|
||||
@load="$emit('cacheImage', record.image)"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@
|
||||
|
||||
<div
|
||||
class="shrink-0 w-0 h-0 border border-slate-300 border-t-[20px] sm:border-t-[25px] border-t-transparent border-b-[20px] sm:border-b-[25px] border-b-transparent border-s-[27px] sm:border-s-[34px] border-e-0"
|
||||
></div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,64 +182,59 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import { GiveRecordWithContactInfo } from "../types";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
|
||||
import { containsHiddenDid } from "../libs/endorserServer";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
import { Component, Prop, Vue } from 'vue-facing-decorator'
|
||||
import { GiveRecordWithContactInfo } from '../types'
|
||||
import EntityIcon from './EntityIcon.vue'
|
||||
import { isGiveClaimType, notifyWhyCannotConfirm } from '../libs/util'
|
||||
import { containsHiddenDid } from '../libs/endorserServer'
|
||||
import ProjectIcon from './ProjectIcon.vue'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EntityIcon,
|
||||
ProjectIcon,
|
||||
},
|
||||
ProjectIcon
|
||||
}
|
||||
})
|
||||
export default class ActivityListItem extends Vue {
|
||||
@Prop() record!: GiveRecordWithContactInfo;
|
||||
@Prop() lastViewedClaimId?: string;
|
||||
@Prop() isRegistered!: boolean;
|
||||
@Prop() activeDid!: string;
|
||||
@Prop() confirmerIdList?: string[];
|
||||
|
||||
@Emit()
|
||||
cacheImage(image: string) {
|
||||
return image;
|
||||
}
|
||||
@Prop() record!: GiveRecordWithContactInfo
|
||||
@Prop() lastViewedClaimId?: string
|
||||
@Prop() isRegistered!: boolean
|
||||
@Prop() activeDid!: string
|
||||
@Prop() confirmerIdList?: string[]
|
||||
|
||||
get fetchAmount(): string {
|
||||
const claim =
|
||||
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
|
||||
(this.record.fullClaim as unknown).claim || this.record.fullClaim
|
||||
|
||||
const amount = claim.object?.amountOfThisGood
|
||||
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
||||
: "";
|
||||
: ''
|
||||
|
||||
return amount;
|
||||
return amount
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
const claim =
|
||||
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
|
||||
(this.record.fullClaim as unknown).claim || this.record.fullClaim
|
||||
|
||||
return `${claim.description}`;
|
||||
return `${claim.description}`
|
||||
}
|
||||
|
||||
private displayAmount(code: string, amt: number) {
|
||||
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`;
|
||||
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`
|
||||
}
|
||||
|
||||
private currencyShortWordForCode(unitCode: string, single: boolean) {
|
||||
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
||||
return unitCode === 'HUR' ? (single ? 'hour' : 'hours') : unitCode
|
||||
}
|
||||
|
||||
get canConfirm(): boolean {
|
||||
if (!this.isRegistered) return false;
|
||||
if (!isGiveClaimType(this.record.fullClaim?.["@type"])) return false;
|
||||
if (this.confirmerIdList?.includes(this.activeDid)) return false;
|
||||
if (this.record.issuerDid === this.activeDid) return false;
|
||||
if (containsHiddenDid(this.record.fullClaim)) return false;
|
||||
return true;
|
||||
if (!this.isRegistered) return false
|
||||
if (!isGiveClaimType(this.record.fullClaim?.['@type'])) return false
|
||||
if (this.confirmerIdList?.includes(this.activeDid)) return false
|
||||
if (this.record.issuerDid === this.activeDid) return false
|
||||
if (containsHiddenDid(this.record.fullClaim)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
handleConfirmClick() {
|
||||
@@ -247,24 +242,24 @@ export default class ActivityListItem extends Vue {
|
||||
notifyWhyCannotConfirm(
|
||||
this.$notify,
|
||||
this.isRegistered,
|
||||
this.record.fullClaim?.["@type"],
|
||||
this.record.fullClaim?.['@type'],
|
||||
this.record,
|
||||
this.activeDid,
|
||||
this.confirmerIdList,
|
||||
);
|
||||
return;
|
||||
this.confirmerIdList
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit("confirmClaim", this.record);
|
||||
this.$emit('confirmClaim', this.record)
|
||||
}
|
||||
|
||||
get friendlyDate(): string {
|
||||
const date = new Date(this.record.issuedAt);
|
||||
const date = new Date(this.record.issuedAt)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -26,7 +26,9 @@
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<span class="font-semibold text-lg">{{ title }}</span>
|
||||
<p class="text-sm mb-2">{{ text }}</p>
|
||||
<p class="text-sm mb-2">
|
||||
{{ text }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2"
|
||||
@@ -65,48 +67,48 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { Component, Vue } from 'vue-facing-decorator'
|
||||
import { NotificationIface } from '../constants/app'
|
||||
|
||||
@Component
|
||||
export default class PromptDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
title = "";
|
||||
text = "";
|
||||
option1Text = "";
|
||||
option2Text = "";
|
||||
option3Text = "";
|
||||
onOption1?: () => void;
|
||||
onOption2?: () => void;
|
||||
onOption3?: () => void;
|
||||
onCancel?: () => Promise<void>;
|
||||
title = ''
|
||||
text = ''
|
||||
option1Text = ''
|
||||
option2Text = ''
|
||||
option3Text = ''
|
||||
onOption1?: () => void
|
||||
onOption2?: () => void
|
||||
onOption3?: () => void
|
||||
onCancel?: () => Promise<void>
|
||||
|
||||
open(options: {
|
||||
title: string;
|
||||
text: string;
|
||||
option1Text?: string;
|
||||
option2Text?: string;
|
||||
option3Text?: string;
|
||||
onOption1?: () => void;
|
||||
onOption2?: () => void;
|
||||
onOption3?: () => void;
|
||||
onCancel?: () => Promise<void>;
|
||||
title: string
|
||||
text: string
|
||||
option1Text?: string
|
||||
option2Text?: string
|
||||
option3Text?: string
|
||||
onOption1?: () => void
|
||||
onOption2?: () => void
|
||||
onOption3?: () => void
|
||||
onCancel?: () => Promise<void>
|
||||
}) {
|
||||
this.title = options.title;
|
||||
this.text = options.text;
|
||||
this.option1Text = options.option1Text || "";
|
||||
this.option2Text = options.option2Text || "";
|
||||
this.option3Text = options.option3Text || "";
|
||||
this.onOption1 = options.onOption1;
|
||||
this.onOption2 = options.onOption2;
|
||||
this.onOption3 = options.onOption3;
|
||||
this.onCancel = options.onCancel;
|
||||
this.title = options.title
|
||||
this.text = options.text
|
||||
this.option1Text = options.option1Text || ''
|
||||
this.option2Text = options.option2Text || ''
|
||||
this.option3Text = options.option3Text || ''
|
||||
this.onOption1 = options.onOption1
|
||||
this.onOption2 = options.onOption2
|
||||
this.onOption3 = options.onOption3
|
||||
this.onCancel = options.onCancel
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "customModal",
|
||||
type: "confirm",
|
||||
group: 'customModal',
|
||||
type: 'confirm',
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
option1Text: this.option1Text,
|
||||
@@ -115,38 +117,38 @@ export default class PromptDialog extends Vue {
|
||||
onOption1: this.onOption1,
|
||||
onOption2: this.onOption2,
|
||||
onOption3: this.onOption3,
|
||||
onCancel: this.onCancel,
|
||||
onCancel: this.onCancel
|
||||
} as NotificationIface,
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
}
|
||||
|
||||
handleOption1(close: (id: string) => void) {
|
||||
if (this.onOption1) {
|
||||
this.onOption1();
|
||||
this.onOption1()
|
||||
}
|
||||
close("string that does not matter");
|
||||
close('string that does not matter')
|
||||
}
|
||||
|
||||
handleOption2(close: (id: string) => void) {
|
||||
if (this.onOption2) {
|
||||
this.onOption2();
|
||||
this.onOption2()
|
||||
}
|
||||
close("string that does not matter");
|
||||
close('string that does not matter')
|
||||
}
|
||||
|
||||
handleOption3(close: (id: string) => void) {
|
||||
if (this.onOption3) {
|
||||
this.onOption3();
|
||||
this.onOption3()
|
||||
}
|
||||
close("string that does not matter");
|
||||
close('string that does not matter')
|
||||
}
|
||||
|
||||
handleCancel(close: (id: string) => void) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
this.onCancel()
|
||||
}
|
||||
close("string that does not matter");
|
||||
close('string that does not matter')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">{{ title }}</h1>
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
{{ title }}
|
||||
</h1>
|
||||
{{ message }}
|
||||
Note that their name is only stored on this device.
|
||||
<input
|
||||
@@ -35,43 +37,43 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import { Vue, Component } from 'vue-facing-decorator'
|
||||
|
||||
@Component
|
||||
export default class ContactNameDialog extends Vue {
|
||||
cancelCallback: () => void = () => {};
|
||||
saveCallback: (name?: string) => void = () => {};
|
||||
message = "";
|
||||
newText = "";
|
||||
title = "Contact Name";
|
||||
visible = false;
|
||||
cancelCallback: () => void = () => {}
|
||||
saveCallback: (name?: string) => void = () => {}
|
||||
message = ''
|
||||
newText = ''
|
||||
title = 'Contact Name'
|
||||
visible = false
|
||||
|
||||
async open(
|
||||
title?: string,
|
||||
message?: string,
|
||||
saveCallback?: (name?: string) => void,
|
||||
cancelCallback?: () => void,
|
||||
defaultName?: string,
|
||||
defaultName?: string
|
||||
) {
|
||||
this.cancelCallback = cancelCallback || this.cancelCallback;
|
||||
this.saveCallback = saveCallback || this.saveCallback;
|
||||
this.message = message ?? this.message;
|
||||
this.newText = defaultName ?? "";
|
||||
this.title = title ?? this.title;
|
||||
this.visible = true;
|
||||
this.cancelCallback = cancelCallback || this.cancelCallback
|
||||
this.saveCallback = saveCallback || this.saveCallback
|
||||
this.message = message ?? this.message
|
||||
this.newText = defaultName ?? ''
|
||||
this.title = title ?? this.title
|
||||
this.visible = true
|
||||
}
|
||||
|
||||
async onClickSaveChanges() {
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
if (this.saveCallback) {
|
||||
this.saveCallback(this.newText);
|
||||
this.saveCallback(this.newText)
|
||||
}
|
||||
}
|
||||
|
||||
onClickCancel() {
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
if (this.cancelCallback) {
|
||||
this.cancelCallback();
|
||||
this.cancelCallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,15 +61,15 @@ backup and database export, with platform-specific download instructions. * *
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { db } from "../db/index";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import { Component, Prop, Vue } from 'vue-facing-decorator'
|
||||
import { NotificationIface } from '../constants/app'
|
||||
import { db } from '../db/index'
|
||||
import { logger } from '../utils/logger'
|
||||
import { PlatformServiceFactory } from '../services/PlatformServiceFactory'
|
||||
import {
|
||||
PlatformService,
|
||||
PlatformCapabilities,
|
||||
} from "../services/PlatformService";
|
||||
PlatformCapabilities
|
||||
} from '../services/PlatformService'
|
||||
|
||||
/**
|
||||
* @vue-component
|
||||
@@ -82,33 +82,33 @@ export default class DataExportSection extends Vue {
|
||||
* Notification function injected by Vue
|
||||
* Used to show success/error messages to the user
|
||||
*/
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
/**
|
||||
* Active DID (Decentralized Identifier) of the user
|
||||
* Controls visibility of seed backup option
|
||||
* @required
|
||||
*/
|
||||
@Prop({ required: true }) readonly activeDid!: string;
|
||||
@Prop({ required: true }) readonly activeDid!: string
|
||||
|
||||
/**
|
||||
* URL for the database export download
|
||||
* Created and revoked dynamically during export process
|
||||
* Only used in web platform
|
||||
*/
|
||||
downloadUrl = "";
|
||||
downloadUrl = ''
|
||||
|
||||
/**
|
||||
* Platform service instance for platform-specific operations
|
||||
*/
|
||||
private platformService: PlatformService =
|
||||
PlatformServiceFactory.getInstance();
|
||||
PlatformServiceFactory.getInstance()
|
||||
|
||||
/**
|
||||
* Platform capabilities for the current platform
|
||||
*/
|
||||
private get platformCapabilities(): PlatformCapabilities {
|
||||
return this.platformService.getCapabilities();
|
||||
return this.platformService.getCapabilities()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +117,7 @@ export default class DataExportSection extends Vue {
|
||||
*/
|
||||
beforeUnmount() {
|
||||
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) {
|
||||
URL.revokeObjectURL(this.downloadUrl);
|
||||
URL.revokeObjectURL(this.downloadUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,45 +131,45 @@ export default class DataExportSection extends Vue {
|
||||
*/
|
||||
public async exportDatabase() {
|
||||
try {
|
||||
const blob = await db.export({ prettyJson: true });
|
||||
const fileName = `${db.name}-backup.json`;
|
||||
const blob = await db.export({ prettyJson: true })
|
||||
const fileName = `${db.name}-backup.json`
|
||||
|
||||
if (this.platformCapabilities.hasFileDownload) {
|
||||
// Web platform: Use download link
|
||||
this.downloadUrl = URL.createObjectURL(blob);
|
||||
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
|
||||
downloadAnchor.href = this.downloadUrl;
|
||||
downloadAnchor.download = fileName;
|
||||
downloadAnchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
|
||||
this.downloadUrl = URL.createObjectURL(blob)
|
||||
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement
|
||||
downloadAnchor.href = this.downloadUrl
|
||||
downloadAnchor.download = fileName
|
||||
downloadAnchor.click()
|
||||
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000)
|
||||
} else if (this.platformCapabilities.hasFileSystem) {
|
||||
// Native platform: Write to app directory
|
||||
const content = await blob.text();
|
||||
await this.platformService.writeAndShareFile(fileName, content);
|
||||
const content = await blob.text()
|
||||
await this.platformService.writeAndShareFile(fileName, content)
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Export Successful",
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Export Successful',
|
||||
text: this.platformCapabilities.hasFileDownload
|
||||
? "See your downloads directory for the backup. It is in the Dexie format."
|
||||
: "Please choose a location to save your backup file.",
|
||||
? 'See your downloads directory for the backup. It is in the Dexie format.'
|
||||
: 'Please choose a location to save your backup file.'
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error("Export Error:", error);
|
||||
logger.error('Export Error:', error)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Export Error",
|
||||
text: "There was an error exporting the data.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Export Error',
|
||||
text: 'There was an error exporting the data.'
|
||||
},
|
||||
3000,
|
||||
);
|
||||
3000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +179,8 @@ export default class DataExportSection extends Vue {
|
||||
*/
|
||||
public computedStartDownloadLinkClassNames() {
|
||||
return {
|
||||
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload,
|
||||
};
|
||||
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,8 +189,8 @@ export default class DataExportSection extends Vue {
|
||||
*/
|
||||
public computedDownloadLinkClassNames() {
|
||||
return {
|
||||
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload,
|
||||
};
|
||||
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="w-fit" v-html="generateIcon()"></div>
|
||||
<div class="w-fit" v-html="generateIcon()" />
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { createAvatar, StyleOptions } from "@dicebear/core";
|
||||
import { avataaars } from "@dicebear/collection";
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { createAvatar, StyleOptions } from '@dicebear/core'
|
||||
import { avataaars } from '@dicebear/collection'
|
||||
import { Vue, Component, Prop } from 'vue-facing-decorator'
|
||||
import { Contact } from '../db/tables/contacts'
|
||||
|
||||
@Component
|
||||
export default class EntityIcon extends Vue {
|
||||
@Prop contact: Contact;
|
||||
@Prop entityId = ""; // overridden by contact.did or profileImageUrl
|
||||
@Prop iconSize = 0;
|
||||
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
|
||||
@Prop contact: Contact
|
||||
@Prop entityId = '' // overridden by contact.did or profileImageUrl
|
||||
@Prop iconSize = 0
|
||||
@Prop profileImageUrl = '' // overridden by contact.profileImageUrl
|
||||
|
||||
generateIcon() {
|
||||
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
|
||||
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl
|
||||
if (imageUrl) {
|
||||
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
||||
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`
|
||||
} else {
|
||||
const identifier = this.contact?.did || this.entityId;
|
||||
const identifier = this.contact?.did || this.entityId
|
||||
if (!identifier) {
|
||||
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
||||
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`
|
||||
}
|
||||
// https://api.dicebear.com/8.x/avataaars/svg?seed=
|
||||
// ... does not render things with the same seed as this library.
|
||||
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
|
||||
// ... which looks similar to '' at the dicebear site but which is different.
|
||||
const options: StyleOptions<object> = {
|
||||
seed: (identifier as string) || "",
|
||||
size: this.iconSize,
|
||||
};
|
||||
const avatar = createAvatar(avataaars, options);
|
||||
const svgString = avatar.toString();
|
||||
return svgString;
|
||||
seed: (identifier as string) || '',
|
||||
size: this.iconSize
|
||||
}
|
||||
const avatar = createAvatar(avataaars, options)
|
||||
const svgString = avatar.toString()
|
||||
return svgString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
class="sr-only"
|
||||
/>
|
||||
<!-- line -->
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
||||
<!-- dot -->
|
||||
<div
|
||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||
></div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,11 +52,11 @@
|
||||
class="sr-only"
|
||||
/>
|
||||
<!-- line -->
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
||||
<div class="block bg-slate-500 w-14 h-8 rounded-full" />
|
||||
<!-- dot -->
|
||||
<div
|
||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
||||
></div>
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative ml-2">
|
||||
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500">
|
||||
@@ -91,101 +91,96 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import {
|
||||
LMap,
|
||||
LMarker,
|
||||
LRectangle,
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
import { Router } from "vue-router";
|
||||
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Vue, Component } from 'vue-facing-decorator'
|
||||
import { LMap, LMarker, LRectangle, LTileLayer } from '@vue-leaflet/vue-leaflet'
|
||||
import { Router } from 'vue-router'
|
||||
import { MASTER_SETTINGS_KEY } from '../db/tables/settings'
|
||||
import { db, retrieveSettingsForActiveAccount } from '../db/index'
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
LRectangle,
|
||||
LMap,
|
||||
LMarker,
|
||||
LTileLayer,
|
||||
},
|
||||
LTileLayer
|
||||
}
|
||||
})
|
||||
export default class FeedFilters extends Vue {
|
||||
$router!: Router;
|
||||
onCloseIfChanged = () => {};
|
||||
hasSearchBox = false;
|
||||
hasVisibleDid = false;
|
||||
isNearby = false;
|
||||
settingChanged = false;
|
||||
visible = false;
|
||||
$router!: Router
|
||||
onCloseIfChanged = () => {}
|
||||
hasSearchBox = false
|
||||
hasVisibleDid = false
|
||||
isNearby = false
|
||||
settingChanged = false
|
||||
visible = false
|
||||
|
||||
async open(onCloseIfChanged: () => void) {
|
||||
this.onCloseIfChanged = onCloseIfChanged;
|
||||
this.onCloseIfChanged = onCloseIfChanged
|
||||
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.hasVisibleDid = !!settings.filterFeedByVisible;
|
||||
this.isNearby = !!settings.filterFeedByNearby;
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
this.hasVisibleDid = !!settings.filterFeedByVisible
|
||||
this.isNearby = !!settings.filterFeedByNearby
|
||||
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
|
||||
this.hasSearchBox = true;
|
||||
this.hasSearchBox = true
|
||||
}
|
||||
|
||||
this.settingChanged = false;
|
||||
this.visible = true;
|
||||
this.settingChanged = false
|
||||
this.visible = true
|
||||
}
|
||||
|
||||
async toggleHasVisibleDid() {
|
||||
this.settingChanged = true;
|
||||
this.hasVisibleDid = !this.hasVisibleDid;
|
||||
this.settingChanged = true
|
||||
this.hasVisibleDid = !this.hasVisibleDid
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByVisible: this.hasVisibleDid,
|
||||
});
|
||||
filterFeedByVisible: this.hasVisibleDid
|
||||
})
|
||||
}
|
||||
|
||||
async toggleNearby() {
|
||||
this.settingChanged = true;
|
||||
this.isNearby = !this.isNearby;
|
||||
this.settingChanged = true
|
||||
this.isNearby = !this.isNearby
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: this.isNearby,
|
||||
});
|
||||
filterFeedByNearby: this.isNearby
|
||||
})
|
||||
}
|
||||
|
||||
async clearAll() {
|
||||
if (this.hasVisibleDid || this.isNearby) {
|
||||
this.settingChanged = true;
|
||||
this.settingChanged = true
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: false,
|
||||
filterFeedByVisible: false,
|
||||
});
|
||||
filterFeedByVisible: false
|
||||
})
|
||||
|
||||
this.hasVisibleDid = false;
|
||||
this.isNearby = false;
|
||||
this.hasVisibleDid = false
|
||||
this.isNearby = false
|
||||
}
|
||||
|
||||
async setAll() {
|
||||
if (!this.hasVisibleDid || !this.isNearby) {
|
||||
this.settingChanged = true;
|
||||
this.settingChanged = true
|
||||
}
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
filterFeedByNearby: true,
|
||||
filterFeedByVisible: true,
|
||||
});
|
||||
filterFeedByVisible: true
|
||||
})
|
||||
|
||||
this.hasVisibleDid = true;
|
||||
this.isNearby = true;
|
||||
this.hasVisibleDid = true
|
||||
this.isNearby = true
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.settingChanged) {
|
||||
this.onCloseIfChanged();
|
||||
this.onCloseIfChanged()
|
||||
}
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
}
|
||||
|
||||
done() {
|
||||
this.close();
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+132
-135
@@ -51,8 +51,8 @@
|
||||
providerProjectId: fromProjectId,
|
||||
recipientDid: receiver?.did,
|
||||
recipientName: receiver?.name,
|
||||
unitCode,
|
||||
},
|
||||
unitCode
|
||||
}
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
@@ -87,44 +87,44 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { Vue, Component, Prop } from 'vue-facing-decorator'
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface } from '../constants/app'
|
||||
import {
|
||||
createAndSubmitGive,
|
||||
didInfo,
|
||||
serverMessageForUser,
|
||||
} from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { retrieveAccountDids } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
serverMessageForUser
|
||||
} from '../libs/endorserServer'
|
||||
import * as libsUtil from '../libs/util'
|
||||
import { db, retrieveSettingsForActiveAccount } from '../db/index'
|
||||
import { Contact } from '../db/tables/contacts'
|
||||
import { retrieveAccountDids } from '../libs/util'
|
||||
|
||||
@Component
|
||||
export default class GiftedDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
@Prop() fromProjectId = "";
|
||||
@Prop() toProjectId = "";
|
||||
@Prop() fromProjectId = ''
|
||||
@Prop() toProjectId = ''
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
activeDid = ''
|
||||
allContacts: Array<Contact> = []
|
||||
allMyDids: Array<string> = []
|
||||
apiServer = ''
|
||||
|
||||
amountInput = "0";
|
||||
callbackOnSuccess?: (amount: number) => void = () => {};
|
||||
customTitle?: string;
|
||||
description = "";
|
||||
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||
offerId = "";
|
||||
prompt = "";
|
||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||
unitCode = "HUR";
|
||||
visible = false;
|
||||
amountInput = '0'
|
||||
callbackOnSuccess?: (amount: number) => void = () => {}
|
||||
customTitle?: string
|
||||
description = ''
|
||||
giver?: libsUtil.GiverReceiverInputInfo // undefined means no identified giver agent
|
||||
isTrade = false
|
||||
offerId = ''
|
||||
prompt = ''
|
||||
receiver?: libsUtil.GiverReceiverInputInfo
|
||||
unitCode = 'HUR'
|
||||
visible = false
|
||||
|
||||
libsUtil = libsUtil;
|
||||
libsUtil = libsUtil
|
||||
|
||||
async open(
|
||||
giver?: libsUtil.GiverReceiverInputInfo,
|
||||
@@ -132,146 +132,143 @@ export default class GiftedDialog extends Vue {
|
||||
offerId?: string,
|
||||
customTitle?: string,
|
||||
prompt?: string,
|
||||
callbackOnSuccess: (amount: number) => void = () => {},
|
||||
callbackOnSuccess: (amount: number) => void = () => {}
|
||||
) {
|
||||
this.customTitle = customTitle;
|
||||
this.giver = giver;
|
||||
this.prompt = prompt || "";
|
||||
this.receiver = receiver;
|
||||
this.customTitle = customTitle
|
||||
this.giver = giver
|
||||
this.prompt = prompt || ''
|
||||
this.receiver = receiver
|
||||
// if we show "given to user" selection, default checkbox to true
|
||||
this.amountInput = "0";
|
||||
this.callbackOnSuccess = callbackOnSuccess;
|
||||
this.offerId = offerId || "";
|
||||
this.amountInput = '0'
|
||||
this.callbackOnSuccess = callbackOnSuccess
|
||||
this.offerId = offerId || ''
|
||||
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.activeDid = settings.activeDid || "";
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
this.apiServer = settings.apiServer || ''
|
||||
this.activeDid = settings.activeDid || ''
|
||||
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.allContacts = await db.contacts.toArray()
|
||||
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
this.allMyDids = await retrieveAccountDids()
|
||||
|
||||
if (this.giver && !this.giver.name) {
|
||||
this.giver.name = didInfo(
|
||||
this.giver.did,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
this.allContacts
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
logger.error("Error retrieving settings from database:", err);
|
||||
logger.error('Error retrieving settings from database:', err)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: err.message || "There was an error retrieving your settings.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: err.message || 'There was an error retrieving your settings.'
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
}
|
||||
|
||||
this.visible = true;
|
||||
this.visible = true
|
||||
}
|
||||
|
||||
close() {
|
||||
// close the dialog but don't change values (since it might be submitting info)
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
}
|
||||
|
||||
changeUnitCode() {
|
||||
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
||||
const index = units.indexOf(this.unitCode);
|
||||
this.unitCode = units[(index + 1) % units.length];
|
||||
const units = Object.keys(this.libsUtil.UNIT_SHORT)
|
||||
const index = units.indexOf(this.unitCode)
|
||||
this.unitCode = units[(index + 1) % units.length]
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.amountInput = `${Math.max(
|
||||
0,
|
||||
(parseFloat(this.amountInput) || 1) - 1,
|
||||
)}`;
|
||||
this.amountInput = `${Math.max(0, (parseFloat(this.amountInput) || 1) - 1)}`
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.close();
|
||||
this.eraseValues();
|
||||
this.close()
|
||||
this.eraseValues()
|
||||
}
|
||||
|
||||
eraseValues() {
|
||||
this.description = "";
|
||||
this.giver = undefined;
|
||||
this.amountInput = "0";
|
||||
this.prompt = "";
|
||||
this.unitCode = "HUR";
|
||||
this.description = ''
|
||||
this.giver = undefined
|
||||
this.amountInput = '0'
|
||||
this.prompt = ''
|
||||
this.unitCode = 'HUR'
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identifier before you can record a give.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: 'You must select an identifier before you can record a give.'
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return;
|
||||
3000
|
||||
)
|
||||
return
|
||||
}
|
||||
if (parseFloat(this.amountInput) < 0) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
text: "You may not send a negative number.",
|
||||
title: "",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
text: 'You may not send a negative number.',
|
||||
title: ''
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
2000
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.description && !parseFloat(this.amountInput)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: `You must enter a description or some number of ${
|
||||
this.libsUtil.UNIT_LONG[this.unitCode]
|
||||
}.`,
|
||||
}.`
|
||||
},
|
||||
2000,
|
||||
);
|
||||
return;
|
||||
2000
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.close();
|
||||
this.close()
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "Recording the give...",
|
||||
title: "",
|
||||
group: 'alert',
|
||||
type: 'toast',
|
||||
text: 'Recording the give...',
|
||||
title: ''
|
||||
},
|
||||
1000,
|
||||
);
|
||||
1000
|
||||
)
|
||||
// this is asynchronous, but we don't need to wait for it to complete
|
||||
await this.recordGive(
|
||||
(this.giver?.did as string) || null,
|
||||
(this.receiver?.did as string) || null,
|
||||
this.description,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
this.unitCode
|
||||
).then(() => {
|
||||
this.eraseValues();
|
||||
});
|
||||
this.eraseValues()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,7 +284,7 @@ export default class GiftedDialog extends Vue {
|
||||
recipientDid: string | null,
|
||||
description: string,
|
||||
amount: number,
|
||||
unitCode: string = "HUR",
|
||||
unitCode: string = 'HUR'
|
||||
) {
|
||||
try {
|
||||
const result = await createAndSubmitGive(
|
||||
@@ -301,56 +298,56 @@ export default class GiftedDialog extends Vue {
|
||||
unitCode,
|
||||
this.toProjectId,
|
||||
this.offerId,
|
||||
false,
|
||||
this.isTrade,
|
||||
undefined,
|
||||
this.fromProjectId,
|
||||
);
|
||||
this.fromProjectId
|
||||
)
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
result.type === 'error' ||
|
||||
this.isGiveCreationError(result.response)
|
||||
) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
logger.error("Error with give creation result:", result);
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result)
|
||||
logger.error('Error with give creation result:', result)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error creating the give.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: errorMessage || 'There was an error creating the give.'
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: `That gift was recorded.`,
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Success',
|
||||
text: `That ${this.isTrade ? 'trade' : 'gift'} was recorded.`
|
||||
},
|
||||
7000,
|
||||
);
|
||||
7000
|
||||
)
|
||||
if (this.callbackOnSuccess) {
|
||||
this.callbackOnSuccess(amount);
|
||||
this.callbackOnSuccess(amount)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
logger.error("Error with give recordation caught:", error);
|
||||
logger.error('Error with give recordation caught:', error)
|
||||
const errorMessage =
|
||||
error.userMessage ||
|
||||
serverMessageForUser(error) ||
|
||||
"There was an error recording the give.";
|
||||
'There was an error recording the give.'
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: errorMessage
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +359,7 @@ export default class GiftedDialog extends Vue {
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isGiveCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
return result.status !== 201 || result.data?.error
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,19 +372,19 @@ export default class GiftedDialog extends Vue {
|
||||
result.error?.userMessage ||
|
||||
result.error?.error ||
|
||||
result.response?.data?.error?.message
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
explainData() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Data Sharing",
|
||||
text: libsUtil.PRIVACY_MESSAGE,
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Data Sharing',
|
||||
text: libsUtil.PRIVACY_MESSAGE
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
|
||||
@click="cancel"
|
||||
>
|
||||
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
|
||||
<font-awesome icon="xmark" class="w-[1em]" />
|
||||
</div>
|
||||
</h1>
|
||||
<span class="mt-2 flex justify-between">
|
||||
@@ -71,92 +71,92 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { Vue, Component } from 'vue-facing-decorator'
|
||||
import { Router } from 'vue-router'
|
||||
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { db } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { GiverReceiverInputInfo } from "../libs/util";
|
||||
import { AppString, NotificationIface } from '../constants/app'
|
||||
import { db } from '../db/index'
|
||||
import { Contact } from '../db/tables/contacts'
|
||||
import { GiverReceiverInputInfo } from '../libs/util'
|
||||
|
||||
@Component
|
||||
export default class GivenPrompts extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$router!: Router;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
$router!: Router
|
||||
|
||||
CATEGORY_CONTACTS = 1;
|
||||
CATEGORY_IDEAS = 0;
|
||||
CATEGORY_CONTACTS = 1
|
||||
CATEGORY_IDEAS = 0
|
||||
IDEAS = [
|
||||
"What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)",
|
||||
"What did a family member do? (How did you take better action because it made you feel loved?)",
|
||||
"What compliment did someone give you? (What task could you tackle because it boosted your confidence?)",
|
||||
"Who is someone you can always rely on, and how did they demonstrate that? (What project tasks were enabled because you could depend on them?)",
|
||||
"What did you see someone give to someone else? (What is the effect of the positivity you gained from seeing that?)",
|
||||
"What is a way that someone helped you even though you have never met? (What different action did you take due to that newfound perspective or inspiration?)",
|
||||
"How did a musician or author or artist inspire you? (What were you motivated to do more creatively because of that?)",
|
||||
"What inspiration did you get from someone who handled tragedy well? (What could you accomplish with better grace or resilience after seeing their example?)",
|
||||
"What is something worth respect that an organization gave you? (How did their contribution improve the situation or enable new activities?)",
|
||||
"Who last gave you a good laugh? (What kind of bond or revitalization did that bring to a situation?)",
|
||||
"What do you recall someone giving you while you were young? (How did it bring excitement or teach a skill or ignite a passion that resulted in improvements in your life?)",
|
||||
"Who forgave you or overlooked a mistake? (How did that free you or build trust that enabled better relationships?)",
|
||||
"What is a way an ancestor contributed to your life? (What in your life is now possible because of their efforts? What challenges are you undertaking knowing of their lives?)",
|
||||
"What kind of help did someone at work give you? (How did that help with team progress? How did that lift your professional growth?)",
|
||||
"How did a teacher or mentor or great example help you? (How did their guidance enhance your attitude or actions?)",
|
||||
"What is a surprise gift you received? (What extra possibilities did it give you?)",
|
||||
];
|
||||
'What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)',
|
||||
'What did a family member do? (How did you take better action because it made you feel loved?)',
|
||||
'What compliment did someone give you? (What task could you tackle because it boosted your confidence?)',
|
||||
'Who is someone you can always rely on, and how did they demonstrate that? (What project tasks were enabled because you could depend on them?)',
|
||||
'What did you see someone give to someone else? (What is the effect of the positivity you gained from seeing that?)',
|
||||
'What is a way that someone helped you even though you have never met? (What different action did you take due to that newfound perspective or inspiration?)',
|
||||
'How did a musician or author or artist inspire you? (What were you motivated to do more creatively because of that?)',
|
||||
'What inspiration did you get from someone who handled tragedy well? (What could you accomplish with better grace or resilience after seeing their example?)',
|
||||
'What is something worth respect that an organization gave you? (How did their contribution improve the situation or enable new activities?)',
|
||||
'Who last gave you a good laugh? (What kind of bond or revitalization did that bring to a situation?)',
|
||||
'What do you recall someone giving you while you were young? (How did it bring excitement or teach a skill or ignite a passion that resulted in improvements in your life?)',
|
||||
'Who forgave you or overlooked a mistake? (How did that free you or build trust that enabled better relationships?)',
|
||||
'What is a way an ancestor contributed to your life? (What in your life is now possible because of their efforts? What challenges are you undertaking knowing of their lives?)',
|
||||
'What kind of help did someone at work give you? (How did that help with team progress? How did that lift your professional growth?)',
|
||||
'How did a teacher or mentor or great example help you? (How did their guidance enhance your attitude or actions?)',
|
||||
'What is a surprise gift you received? (What extra possibilities did it give you?)'
|
||||
]
|
||||
|
||||
callbackOnFullGiftInfo?: (
|
||||
contactInfo?: GiverReceiverInputInfo,
|
||||
description?: string,
|
||||
) => void;
|
||||
currentCategory = this.CATEGORY_IDEAS; // 0 = IDEAS, 1 = CONTACTS
|
||||
currentContact: Contact | undefined = undefined;
|
||||
currentIdeaIndex = 0;
|
||||
numContacts = 0;
|
||||
shownContactDbIndices: Array<boolean> = [];
|
||||
visible = false;
|
||||
description?: string
|
||||
) => void
|
||||
currentCategory = this.CATEGORY_IDEAS // 0 = IDEAS, 1 = CONTACTS
|
||||
currentContact: Contact | undefined = undefined
|
||||
currentIdeaIndex = 0
|
||||
numContacts = 0
|
||||
shownContactDbIndices: Array<boolean> = []
|
||||
visible = false
|
||||
|
||||
AppString = AppString;
|
||||
AppString = AppString
|
||||
|
||||
async open(
|
||||
callbackOnFullGiftInfo?: (
|
||||
contactInfo?: GiverReceiverInputInfo,
|
||||
description?: string,
|
||||
) => void,
|
||||
description?: string
|
||||
) => void
|
||||
) {
|
||||
this.visible = true;
|
||||
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo;
|
||||
this.visible = true
|
||||
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo
|
||||
|
||||
await db.open();
|
||||
this.numContacts = await db.contacts.count();
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start
|
||||
await db.open()
|
||||
this.numContacts = await db.contacts.count()
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts) // all undefined to start
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.currentCategory = this.CATEGORY_IDEAS;
|
||||
this.currentContact = undefined;
|
||||
this.currentIdeaIndex = 0;
|
||||
this.numContacts = 0;
|
||||
this.shownContactDbIndices = [];
|
||||
this.currentCategory = this.CATEGORY_IDEAS
|
||||
this.currentContact = undefined
|
||||
this.currentIdeaIndex = 0
|
||||
this.numContacts = 0
|
||||
this.shownContactDbIndices = []
|
||||
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
}
|
||||
|
||||
proceed() {
|
||||
// proceed with logic but don't change values (just in case some actions are added later)
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
if (this.currentCategory === this.CATEGORY_IDEAS) {
|
||||
this.$router.push({
|
||||
name: "contact-gift",
|
||||
name: 'contact-gift',
|
||||
query: {
|
||||
prompt: this.IDEAS[this.currentIdeaIndex],
|
||||
},
|
||||
});
|
||||
prompt: this.IDEAS[this.currentIdeaIndex]
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// must be this.CATEGORY_CONTACTS
|
||||
this.callbackOnFullGiftInfo?.(
|
||||
this.currentContact as GiverReceiverInputInfo,
|
||||
);
|
||||
this.currentContact as GiverReceiverInputInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,14 +167,14 @@ export default class GivenPrompts extends Vue {
|
||||
async nextIdea() {
|
||||
// check if the next one is an idea or a contact
|
||||
if (this.currentCategory === this.CATEGORY_IDEAS) {
|
||||
this.currentIdeaIndex++;
|
||||
this.currentIdeaIndex++
|
||||
if (this.currentIdeaIndex === this.IDEAS.length) {
|
||||
// must have just finished ideas so move to contacts
|
||||
this.findNextUnshownContact();
|
||||
this.findNextUnshownContact()
|
||||
}
|
||||
} else {
|
||||
// must be this.CATEGORY_CONTACTS
|
||||
this.findNextUnshownContact();
|
||||
this.findNextUnshownContact()
|
||||
// when that's finished, it'll reset to ideas
|
||||
}
|
||||
}
|
||||
@@ -186,54 +186,52 @@ export default class GivenPrompts extends Vue {
|
||||
async prevIdea() {
|
||||
// check if the next one is an idea or a contact
|
||||
if (this.currentCategory === this.CATEGORY_IDEAS) {
|
||||
this.currentIdeaIndex--;
|
||||
this.currentIdeaIndex--
|
||||
if (this.currentIdeaIndex < 0) {
|
||||
// must have just finished ideas so move to contacts
|
||||
this.findNextUnshownContact();
|
||||
this.findNextUnshownContact()
|
||||
}
|
||||
} else {
|
||||
// must be this.CATEGORY_CONTACTS
|
||||
this.findNextUnshownContact();
|
||||
this.findNextUnshownContact()
|
||||
// when that's finished, it'll reset to ideas
|
||||
}
|
||||
}
|
||||
|
||||
nextIdeaPastContacts() {
|
||||
this.currentContact = undefined;
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts);
|
||||
this.currentContact = undefined
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts)
|
||||
|
||||
this.currentCategory = this.CATEGORY_IDEAS;
|
||||
this.currentCategory = this.CATEGORY_IDEAS
|
||||
// look at the previous idea and switch to the other side of the list
|
||||
this.currentIdeaIndex =
|
||||
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1;
|
||||
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1
|
||||
}
|
||||
|
||||
async findNextUnshownContact() {
|
||||
if (this.currentCategory === this.CATEGORY_IDEAS) {
|
||||
// we're not in the contact prompts, so reset index array
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts);
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts)
|
||||
}
|
||||
this.currentCategory = this.CATEGORY_CONTACTS;
|
||||
this.currentCategory = this.CATEGORY_CONTACTS
|
||||
|
||||
let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
|
||||
let count = 0;
|
||||
let someContactDbIndex = Math.floor(Math.random() * this.numContacts)
|
||||
let count = 0
|
||||
// as long as the index has an entry, loop
|
||||
while (
|
||||
this.shownContactDbIndices[someContactDbIndex] != null &&
|
||||
count++ < this.numContacts
|
||||
) {
|
||||
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts;
|
||||
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts
|
||||
}
|
||||
if (count >= this.numContacts) {
|
||||
// all contacts have been shown
|
||||
this.nextIdeaPastContacts();
|
||||
this.nextIdeaPastContacts()
|
||||
} else {
|
||||
// get the contact at that offset
|
||||
await db.open();
|
||||
this.currentContact = await db.contacts
|
||||
.offset(someContactDbIndex)
|
||||
.first();
|
||||
this.shownContactDbIndices[someContactDbIndex] = true;
|
||||
await db.open()
|
||||
this.currentContact = await db.contacts.offset(someContactDbIndex).first()
|
||||
this.shownContactDbIndices[someContactDbIndex] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,33 +100,33 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import * as R from "ramda";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { Component, Vue } from 'vue-facing-decorator'
|
||||
import * as R from 'ramda'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { Contact } from '../db/tables/contacts'
|
||||
import * as serverUtil from '../libs/endorserServer'
|
||||
import { NotificationIface } from '../constants/app'
|
||||
|
||||
@Component
|
||||
export default class HiddenDidDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
isOpen = false;
|
||||
roleName = "";
|
||||
visibleToDids: string[] = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
canShare = false;
|
||||
windowLocation = window.location.href;
|
||||
isOpen = false
|
||||
roleName = ''
|
||||
visibleToDids: string[] = []
|
||||
allContacts: Array<Contact> = []
|
||||
activeDid = ''
|
||||
allMyDids: Array<string> = []
|
||||
canShare = false
|
||||
windowLocation = window.location.href
|
||||
|
||||
R = R;
|
||||
serverUtil = serverUtil;
|
||||
R = R
|
||||
serverUtil = serverUtil
|
||||
|
||||
created() {
|
||||
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
|
||||
// then use this truer check: navigator.canShare && navigator.canShare()
|
||||
this.canShare = !!navigator.share;
|
||||
this.canShare = !!navigator.share
|
||||
}
|
||||
|
||||
open(
|
||||
@@ -134,18 +134,18 @@ export default class HiddenDidDialog extends Vue {
|
||||
visibleToDids: string[],
|
||||
allContacts: Array<Contact>,
|
||||
activeDid: string,
|
||||
allMyDids: Array<string>,
|
||||
allMyDids: Array<string>
|
||||
) {
|
||||
this.roleName = roleName;
|
||||
this.visibleToDids = visibleToDids;
|
||||
this.allContacts = allContacts;
|
||||
this.activeDid = activeDid;
|
||||
this.allMyDids = allMyDids;
|
||||
this.isOpen = true;
|
||||
this.roleName = roleName
|
||||
this.visibleToDids = visibleToDids
|
||||
this.allContacts = allContacts
|
||||
this.activeDid = activeDid
|
||||
this.allMyDids = allMyDids
|
||||
this.isOpen = true
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.isOpen = false
|
||||
}
|
||||
|
||||
didInfo(did: string) {
|
||||
@@ -153,8 +153,8 @@ export default class HiddenDidDialog extends Vue {
|
||||
did,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
this.allContacts
|
||||
)
|
||||
}
|
||||
|
||||
copyToClipboard(name: string, text: string) {
|
||||
@@ -163,23 +163,23 @@ export default class HiddenDidDialog extends Vue {
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: (name || "That") + " was copied to the clipboard.",
|
||||
group: 'alert',
|
||||
type: 'toast',
|
||||
title: 'Copied',
|
||||
text: (name || 'That') + ' was copied to the clipboard.'
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
2000
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onClickShareClaim() {
|
||||
this.copyToClipboard("A link to this page", this.windowLocation);
|
||||
this.copyToClipboard('A link to this page', this.windowLocation)
|
||||
window.navigator.share({
|
||||
title: "Help Connect Me",
|
||||
title: 'Help Connect Me',
|
||||
text: "I'm trying to find the people who recorded this. Can you help me?",
|
||||
url: this.windowLocation,
|
||||
});
|
||||
url: this.windowLocation
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,537 +1,157 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div class="dialog relative">
|
||||
<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="showCameraPreview">Upload Image</span>
|
||||
<span v-else>Add Photo</span>
|
||||
</h1>
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div
|
||||
class="text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0"
|
||||
id="ViewHeading"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||
>
|
||||
Add Photo
|
||||
</div>
|
||||
<div
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||
@click="close()"
|
||||
>
|
||||
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
|
||||
<font-awesome icon="xmark" class="w-[1em]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<template v-if="isRegistered">
|
||||
<div v-if="!blob">
|
||||
<div
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
Take a photo with your camera
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCameraPreview"
|
||||
class="camera-preview relative flex bg-black overflow-hidden mb-4"
|
||||
>
|
||||
<div class="camera-container w-full h-full relative">
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="camera-video w-full h-full object-cover"
|
||||
autoplay
|
||||
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>
|
||||
</div>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
OR choose a file from your device
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input
|
||||
type="file"
|
||||
class="w-full file:text-center file:bg-gradient-to-b file:from-slate-400 file:to-slate-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:rounded-md file:border-none file:cursor-pointer file:me-2"
|
||||
@change="uploadImageFile"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
OR paste an image URL
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<input
|
||||
v-model="imageUrl"
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 px-4 py-2"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
<button
|
||||
v-if="imageUrl"
|
||||
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 px-3 py-2 rounded-md cursor-pointer"
|
||||
@click="acceptUrl"
|
||||
>
|
||||
<font-awesome icon="check" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-center mt-8">
|
||||
<div>
|
||||
<font-awesome
|
||||
icon="camera"
|
||||
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 px-2 py-2 rounded-md"
|
||||
@click="openPhotoDialog()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="uploading" class="flex justify-center">
|
||||
<div class="mt-4">
|
||||
<input type="file" @change="uploadImageFile" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="mt-2">
|
||||
... or paste a URL:
|
||||
<input v-model="imageUrl" type="text" class="border-2" />
|
||||
</span>
|
||||
<span class="ml-2">
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin fa-3x text-center block px-12 py-12"
|
||||
v-if="imageUrl"
|
||||
icon="check"
|
||||
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 px-2 py-2 rounded-md cursor-pointer"
|
||||
@click="acceptUrl"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="crop">
|
||||
<VuePictureCropper
|
||||
:box-style="{
|
||||
backgroundColor: '#f8f8f8',
|
||||
margin: 'auto',
|
||||
}"
|
||||
:img="createBlobURL(blob)"
|
||||
:options="{
|
||||
viewMode: 1,
|
||||
dragMode: 'crop',
|
||||
aspectRatio: 1 / 1,
|
||||
}"
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="createBlobURL(blob)"
|
||||
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'grid gap-2 mt-2',
|
||||
showRetry ? 'grid-cols-2' : 'grid-cols-1',
|
||||
]"
|
||||
>
|
||||
<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 py-2 px-3 rounded-md"
|
||||
@click="uploadImage"
|
||||
>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showRetry"
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
@click="retryImage"
|
||||
>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- so that there's no shifting when it becomes visible -->
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="check"
|
||||
class="text-white bg-white px-2 py-2"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
id="noticeBeforeUpload"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p class="mb-2">
|
||||
Before you can upload a photo, a friend needs to register you.
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
Share Your Info
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PhotoDialog ref="photoDialog" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import { ref } from "vue";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
import axios from 'axios'
|
||||
import { ref } from 'vue'
|
||||
import { Component, Vue } from 'vue-facing-decorator'
|
||||
|
||||
const inputImageFileNameRef = ref<Blob>();
|
||||
import PhotoDialog from '../components/PhotoDialog.vue'
|
||||
import { NotificationIface } from '../constants/app'
|
||||
|
||||
const inputImageFileNameRef = ref<Blob>()
|
||||
|
||||
@Component({
|
||||
components: { VuePictureCropper },
|
||||
props: {
|
||||
isRegistered: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
components: { PhotoDialog }
|
||||
})
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
/** Active DID for user authentication */
|
||||
activeDid = "";
|
||||
|
||||
/** Current image blob being processed */
|
||||
blob?: Blob;
|
||||
|
||||
/** Type of claim for the image */
|
||||
claimType: string = "";
|
||||
|
||||
/** Whether to show cropping interface */
|
||||
crop: boolean = false;
|
||||
|
||||
/** Name of the selected file */
|
||||
fileName?: string;
|
||||
|
||||
/** Callback function to set image URL after upload */
|
||||
imageCallback: (imageUrl?: string) => void = () => {};
|
||||
|
||||
/** URL for image input */
|
||||
imageUrl?: string;
|
||||
|
||||
/** Whether to show retry button */
|
||||
showRetry = true;
|
||||
|
||||
/** Upload progress state */
|
||||
uploading = false;
|
||||
|
||||
/** Dialog visibility state */
|
||||
visible = false;
|
||||
|
||||
/** Whether to show camera preview */
|
||||
showCameraPreview = false;
|
||||
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
private platformCapabilities = this.platformService.getCapabilities();
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Initializes component and retrieves user settings
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
console.log("ImageMethodDialog mounted");
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error retrieving settings from database:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "There was an error retrieving your settings.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Cleans up camera stream when component is destroyed
|
||||
*/
|
||||
beforeDestroy() {
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
claimType: string
|
||||
crop: boolean = false
|
||||
imageCallback: (imageUrl?: string) => void = () => {}
|
||||
imageUrl?: string
|
||||
visible = false
|
||||
|
||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
this.imageCallback = setImageFn;
|
||||
this.visible = true;
|
||||
this.claimType = claimType
|
||||
this.crop = !!crop
|
||||
this.imageCallback = setImageFn
|
||||
|
||||
// Start camera preview immediately if not on mobile
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
this.startCameraPreview();
|
||||
}
|
||||
this.visible = true
|
||||
}
|
||||
|
||||
openPhotoDialog(blob?: Blob, fileName?: string) {
|
||||
this.visible = false
|
||||
;(this.$refs.photoDialog as PhotoDialog).open(
|
||||
this.imageCallback,
|
||||
this.claimType,
|
||||
this.crop,
|
||||
blob,
|
||||
fileName
|
||||
)
|
||||
}
|
||||
|
||||
async uploadImageFile(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files) return;
|
||||
this.visible = false
|
||||
|
||||
inputImageFileNameRef.value = target.files[0];
|
||||
const file = inputImageFileNameRef.value;
|
||||
inputImageFileNameRef.value = event.target.files[0]
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/File
|
||||
// ... plus it has a `type` property from my testing
|
||||
const file = inputImageFileNameRef.value
|
||||
if (file != null) {
|
||||
const reader = new FileReader();
|
||||
const reader = new FileReader()
|
||||
reader.onload = async (e) => {
|
||||
const data = e.target?.result as ArrayBuffer;
|
||||
const data = e.target?.result as ArrayBuffer
|
||||
if (data) {
|
||||
const blob = new Blob([new Uint8Array(data)], {
|
||||
type: file.type,
|
||||
});
|
||||
this.blob = blob;
|
||||
this.fileName = file.name;
|
||||
this.showRetry = false;
|
||||
type: file.type
|
||||
})
|
||||
this.openPhotoDialog(blob, file.name as string)
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file as Blob);
|
||||
}
|
||||
reader.readAsArrayBuffer(file as Blob)
|
||||
}
|
||||
}
|
||||
|
||||
async acceptUrl() {
|
||||
this.visible = false
|
||||
if (this.crop) {
|
||||
try {
|
||||
const urlBlobResponse = await axios.get(this.imageUrl as string, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const fullUrl = new URL(this.imageUrl as string);
|
||||
const fileName = fullUrl.pathname.split("/").pop() as string;
|
||||
this.blob = urlBlobResponse.data as Blob;
|
||||
this.fileName = fileName;
|
||||
this.showRetry = false;
|
||||
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
|
||||
responseType: 'blob' // This ensures the data is returned as a Blob
|
||||
})
|
||||
const fullUrl = new URL(this.imageUrl as string)
|
||||
const fileName = fullUrl.pathname.split('/').pop() as string
|
||||
;(this.$refs.photoDialog as PhotoDialog).open(
|
||||
this.imageCallback,
|
||||
this.claimType,
|
||||
this.crop,
|
||||
urlBlobResponse.data as Blob,
|
||||
fileName
|
||||
)
|
||||
} catch (error) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error retrieving that image.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: 'There was an error retrieving that image.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.imageCallback(this.imageUrl);
|
||||
this.close();
|
||||
this.imageCallback(this.imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
this.stopCameraPreview();
|
||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||
if (bottomNav) {
|
||||
bottomNav.style.display = "";
|
||||
}
|
||||
this.blob = undefined;
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
|
||||
async startCameraPreview() {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
|
||||
if (this.platformCapabilities.isMobile) {
|
||||
logger.debug("Using platform service for mobile device");
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
} catch (error) {
|
||||
logger.error("Error taking picture:", error);
|
||||
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.showCameraPreview = true;
|
||||
await this.$nextTick();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
this.cameraStream = stream;
|
||||
|
||||
await this.$nextTick();
|
||||
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
videoElement.play().then(() => {
|
||||
resolve(true);
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to access camera. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
stopCameraPreview() {
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach((track) => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
|
||||
async capturePhoto() {
|
||||
if (!this.cameraStream) return;
|
||||
|
||||
try {
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = videoElement.videoWidth;
|
||||
canvas.height = videoElement.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = `photo_${Date.now()}.jpg`;
|
||||
this.showRetry = true;
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error capturing photo:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to capture photo. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
async retryImage() {
|
||||
this.blob = undefined;
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage() {
|
||||
this.uploading = true;
|
||||
|
||||
if (this.crop) {
|
||||
this.blob = (await cropper?.getBlob()) || undefined;
|
||||
}
|
||||
|
||||
const token = await accessToken(this.activeDid);
|
||||
const headers = {
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const formData = new FormData();
|
||||
if (!this.blob) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error finding the picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
return;
|
||||
}
|
||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||
formData.append("claimType", this.claimType);
|
||||
try {
|
||||
if (
|
||||
window.location.hostname === "localhost" &&
|
||||
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
||||
) {
|
||||
logger.log(
|
||||
"Using shared image API server, so only users on that server can play with images.",
|
||||
);
|
||||
}
|
||||
const response = await axios.post(
|
||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
||||
formData,
|
||||
{ headers },
|
||||
);
|
||||
this.uploading = false;
|
||||
|
||||
this.close();
|
||||
this.imageCallback(response.data.url as string);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "There was an error saving the picture.";
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
|
||||
if (status === 401) {
|
||||
errorMessage = "Authentication failed. Please try logging in again.";
|
||||
} else if (status === 413) {
|
||||
errorMessage = "Image file is too large. Please try a smaller image.";
|
||||
} else if (status === 415) {
|
||||
errorMessage =
|
||||
"Unsupported image format. Please try a different image.";
|
||||
} else if (status && status >= 500) {
|
||||
errorMessage = "Server error. Please try again later.";
|
||||
} else if (data?.message) {
|
||||
errorMessage = data.message;
|
||||
}
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.blob = undefined;
|
||||
}
|
||||
this.visible = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -557,9 +177,5 @@ export default class ImageMethodDialog extends Vue {
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,45 +38,44 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Component, Vue, Prop } from 'vue-facing-decorator'
|
||||
import { UAParser } from 'ua-parser-js'
|
||||
|
||||
@Component({ emits: ["update:isOpen"] })
|
||||
@Component({ emits: ['update:isOpen'] })
|
||||
export default class ImageViewer extends Vue {
|
||||
@Prop() imageUrl!: string;
|
||||
@Prop() imageData!: Blob | null;
|
||||
@Prop() isOpen!: boolean;
|
||||
@Prop() imageUrl!: string
|
||||
@Prop() imageData!: Blob | null
|
||||
@Prop() isOpen!: boolean
|
||||
|
||||
userAgent = new UAParser();
|
||||
userAgent = new UAParser()
|
||||
|
||||
get isMobile() {
|
||||
const os = this.userAgent.getOS().name;
|
||||
return os === "iOS" || os === "Android";
|
||||
const os = this.userAgent.getOS().name
|
||||
return os === 'iOS' || os === 'Android'
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit("update:isOpen", false);
|
||||
this.$emit('update:isOpen', false)
|
||||
}
|
||||
|
||||
async handleShare() {
|
||||
const os = this.userAgent.getOS().name;
|
||||
const os = this.userAgent.getOS().name
|
||||
|
||||
try {
|
||||
if (os === "iOS" || os === "Android") {
|
||||
if (os === 'iOS' || os === 'Android') {
|
||||
if (navigator.share) {
|
||||
// Always share the URL since it's more reliable across platforms
|
||||
await navigator.share({
|
||||
url: this.imageUrl,
|
||||
});
|
||||
url: this.imageUrl
|
||||
})
|
||||
} else {
|
||||
// Fallback for browsers without share API
|
||||
window.open(this.imageUrl, "_blank");
|
||||
window.open(this.imageUrl, '_blank')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Share failed, opening in new tab:", error);
|
||||
window.open(this.imageUrl, "_blank");
|
||||
logger.warn('Share failed, opening in new tab:', error)
|
||||
window.open(this.imageUrl, '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ loading state management. * * @author Matthew Raymer * @version 1.0.0 */
|
||||
<template>
|
||||
<div ref="scrollContainer">
|
||||
<slot />
|
||||
<div ref="sentinel" style="height: 1px"></div>
|
||||
<div ref="sentinel" style="height: 1px" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Prop, Vue } from "vue-facing-decorator";
|
||||
import { Component, Emit, Prop, Vue } from 'vue-facing-decorator'
|
||||
|
||||
/**
|
||||
* InfiniteScroll Component
|
||||
@@ -38,19 +38,19 @@ import { Component, Emit, Prop, Vue } from "vue-facing-decorator";
|
||||
export default class InfiniteScroll extends Vue {
|
||||
/** Distance in pixels from the bottom at which to trigger the reached-bottom event */
|
||||
@Prop({ default: 200 })
|
||||
readonly distance!: number;
|
||||
readonly distance!: number
|
||||
|
||||
/** Intersection Observer instance for detecting scroll position */
|
||||
private observer!: IntersectionObserver;
|
||||
private observer!: IntersectionObserver
|
||||
|
||||
/** Flag to track initial render state */
|
||||
private isInitialRender = true;
|
||||
private isInitialRender = true
|
||||
|
||||
/** Flag to prevent multiple simultaneous loading states */
|
||||
private isLoading = false;
|
||||
private isLoading = false
|
||||
|
||||
/** Timeout ID for debouncing scroll events */
|
||||
private debounceTimeout: number | null = null;
|
||||
private debounceTimeout: number | null = null
|
||||
|
||||
/**
|
||||
* Vue lifecycle hook that runs after component updates.
|
||||
@@ -64,13 +64,10 @@ export default class InfiniteScroll extends Vue {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: `0px 0px ${this.distance}px 0px`,
|
||||
threshold: 1.0,
|
||||
};
|
||||
this.observer = new IntersectionObserver(
|
||||
this.handleIntersection,
|
||||
options,
|
||||
);
|
||||
this.observer.observe(this.$refs.sentinel as HTMLElement);
|
||||
threshold: 1.0
|
||||
}
|
||||
this.observer = new IntersectionObserver(this.handleIntersection, options)
|
||||
this.observer.observe(this.$refs.sentinel as HTMLElement)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +80,10 @@ export default class InfiniteScroll extends Vue {
|
||||
*/
|
||||
beforeUnmount() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer.disconnect()
|
||||
}
|
||||
if (this.debounceTimeout) {
|
||||
window.clearTimeout(this.debounceTimeout);
|
||||
window.clearTimeout(this.debounceTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,25 +98,25 @@ export default class InfiniteScroll extends Vue {
|
||||
* Used internally by the Intersection Observer
|
||||
* @emits reached-bottom - Emitted when the user scrolls near the bottom
|
||||
*/
|
||||
@Emit("reached-bottom")
|
||||
@Emit('reached-bottom')
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
const entry = entries[0];
|
||||
const entry = entries[0]
|
||||
if (entry.isIntersecting && !this.isLoading) {
|
||||
// Debounce the intersection event
|
||||
if (this.debounceTimeout) {
|
||||
window.clearTimeout(this.debounceTimeout);
|
||||
window.clearTimeout(this.debounceTimeout)
|
||||
}
|
||||
|
||||
this.debounceTimeout = window.setTimeout(() => {
|
||||
this.isLoading = true;
|
||||
this.$emit("reached-bottom", true);
|
||||
this.isLoading = true
|
||||
this.$emit('reached-bottom', true)
|
||||
// Reset loading state after a short delay
|
||||
setTimeout(() => {
|
||||
this.isLoading = false;
|
||||
}, 1000);
|
||||
}, 300);
|
||||
this.isLoading = false
|
||||
}, 1000)
|
||||
}, 300)
|
||||
}
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -46,50 +46,50 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import { Vue, Component } from 'vue-facing-decorator'
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface } from '../constants/app'
|
||||
|
||||
@Component
|
||||
export default class InviteDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
callback: (text: string, expiresAt: string) => void = () => {};
|
||||
inviteIdentifier = "";
|
||||
text = "";
|
||||
visible = false;
|
||||
callback: (text: string, expiresAt: string) => void = () => {}
|
||||
inviteIdentifier = ''
|
||||
text = ''
|
||||
visible = false
|
||||
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
|
||||
.toISOString()
|
||||
.substring(0, 10);
|
||||
.substring(0, 10)
|
||||
|
||||
async open(
|
||||
inviteIdentifier: string,
|
||||
aCallback: (text: string, expiresAt: string) => void,
|
||||
aCallback: (text: string, expiresAt: string) => void
|
||||
) {
|
||||
this.callback = aCallback;
|
||||
this.inviteIdentifier = inviteIdentifier;
|
||||
this.visible = true;
|
||||
this.callback = aCallback
|
||||
this.inviteIdentifier = inviteIdentifier
|
||||
this.visible = true
|
||||
}
|
||||
|
||||
async onClickSaveChanges() {
|
||||
if (!this.expiresAt) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Needs Expiration",
|
||||
text: "You must select an expiration date.",
|
||||
group: 'alert',
|
||||
type: 'warning',
|
||||
title: 'Needs Expiration',
|
||||
text: 'You must select an expiration date.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
} else {
|
||||
this.callback(this.text, this.expiresAt);
|
||||
this.visible = false;
|
||||
this.callback(this.text, this.expiresAt)
|
||||
this.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
onClickCancel() {
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+169
-167
@@ -80,7 +80,9 @@
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<h3 class="text-lg font-medium">{{ member.name }}</h3>
|
||||
<h3 class="text-lg font-medium">
|
||||
{{ member.name }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="!getContactFor(member.did) && member.did !== activeDid"
|
||||
class="flex justify-end"
|
||||
@@ -99,7 +101,7 @@
|
||||
title="Contact info"
|
||||
@click="
|
||||
informAboutAddingContact(
|
||||
getContactFor(member.did) !== undefined,
|
||||
getContactFor(member.did) !== undefined
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -157,138 +159,138 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { Component, Vue, Prop } from 'vue-facing-decorator'
|
||||
|
||||
import {
|
||||
logConsoleAndDb,
|
||||
retrieveSettingsForActiveAccount,
|
||||
db,
|
||||
} from "../db/index";
|
||||
db
|
||||
} from '../db/index'
|
||||
import {
|
||||
errorStringForLog,
|
||||
getHeaders,
|
||||
register,
|
||||
serverMessageForUser,
|
||||
} from "../libs/endorserServer";
|
||||
import { decryptMessage } from "../libs/crypto";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
serverMessageForUser
|
||||
} from '../libs/endorserServer'
|
||||
import { decryptMessage } from '../libs/crypto'
|
||||
import { Contact } from '../db/tables/contacts'
|
||||
import * as libsUtil from '../libs/util'
|
||||
import { NotificationIface } from '../constants/app'
|
||||
|
||||
interface Member {
|
||||
admitted: boolean;
|
||||
content: string;
|
||||
memberId: number;
|
||||
admitted: boolean
|
||||
content: string
|
||||
memberId: number
|
||||
}
|
||||
|
||||
interface DecryptedMember {
|
||||
member: Member;
|
||||
name: string;
|
||||
did: string;
|
||||
isRegistered: boolean;
|
||||
member: Member
|
||||
name: string
|
||||
did: string
|
||||
isRegistered: boolean
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class MembersList extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
libsUtil = libsUtil;
|
||||
libsUtil = libsUtil
|
||||
|
||||
@Prop({ required: true }) password!: string;
|
||||
@Prop({ default: false }) showOrganizerTools!: boolean;
|
||||
@Prop({ required: true }) password!: string
|
||||
@Prop({ default: false }) showOrganizerTools!: boolean
|
||||
|
||||
decryptedMembers: DecryptedMember[] = [];
|
||||
firstName = "";
|
||||
isLoading = true;
|
||||
isOrganizer = false;
|
||||
members: Member[] = [];
|
||||
missingPassword = false;
|
||||
missingMyself = false;
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
contacts: Array<Contact> = [];
|
||||
decryptedMembers: DecryptedMember[] = []
|
||||
firstName = ''
|
||||
isLoading = true
|
||||
isOrganizer = false
|
||||
members: Member[] = []
|
||||
missingPassword = false
|
||||
missingMyself = false
|
||||
activeDid = ''
|
||||
apiServer = ''
|
||||
contacts: Array<Contact> = []
|
||||
|
||||
async created() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.firstName = settings.firstName || "";
|
||||
await this.fetchMembers();
|
||||
await this.loadContacts();
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
this.activeDid = settings.activeDid || ''
|
||||
this.apiServer = settings.apiServer || ''
|
||||
this.firstName = settings.firstName || ''
|
||||
await this.fetchMembers()
|
||||
await this.loadContacts()
|
||||
}
|
||||
|
||||
async fetchMembers() {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
this.isLoading = true
|
||||
const headers = await getHeaders(this.activeDid)
|
||||
const response = await this.axios.get(
|
||||
`${this.apiServer}/api/partner/groupOnboardMembers`,
|
||||
{ headers },
|
||||
);
|
||||
{ headers }
|
||||
)
|
||||
|
||||
if (response.data && response.data.data) {
|
||||
this.members = response.data.data;
|
||||
await this.decryptMemberContents();
|
||||
this.members = response.data.data
|
||||
await this.decryptMemberContents()
|
||||
}
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
"Error fetching members: " + errorStringForLog(error),
|
||||
true,
|
||||
);
|
||||
'Error fetching members: ' + errorStringForLog(error),
|
||||
true
|
||||
)
|
||||
this.$emit(
|
||||
"error",
|
||||
serverMessageForUser(error) || "Failed to fetch members.",
|
||||
);
|
||||
'error',
|
||||
serverMessageForUser(error) || 'Failed to fetch members.'
|
||||
)
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async decryptMemberContents() {
|
||||
this.decryptedMembers = [];
|
||||
this.decryptedMembers = []
|
||||
|
||||
if (!this.password) {
|
||||
this.missingPassword = true;
|
||||
return;
|
||||
this.missingPassword = true
|
||||
return
|
||||
}
|
||||
|
||||
let isFirstEntry = true,
|
||||
foundMyself = false;
|
||||
foundMyself = false
|
||||
for (const member of this.members) {
|
||||
try {
|
||||
const decryptedContent = await decryptMessage(
|
||||
member.content,
|
||||
this.password,
|
||||
);
|
||||
const content = JSON.parse(decryptedContent);
|
||||
this.password
|
||||
)
|
||||
const content = JSON.parse(decryptedContent)
|
||||
|
||||
this.decryptedMembers.push({
|
||||
member: member,
|
||||
name: content.name,
|
||||
did: content.did,
|
||||
isRegistered: !!content.isRegistered,
|
||||
});
|
||||
isRegistered: !!content.isRegistered
|
||||
})
|
||||
if (isFirstEntry && content.did === this.activeDid) {
|
||||
this.isOrganizer = true;
|
||||
this.isOrganizer = true
|
||||
}
|
||||
if (content.did === this.activeDid) {
|
||||
foundMyself = true;
|
||||
foundMyself = true
|
||||
}
|
||||
} catch (error) {
|
||||
// do nothing, relying on the count of members to determine if there was an error
|
||||
}
|
||||
isFirstEntry = false;
|
||||
isFirstEntry = false
|
||||
}
|
||||
this.missingMyself = !foundMyself;
|
||||
this.missingMyself = !foundMyself
|
||||
}
|
||||
|
||||
decryptionErrorMessage(): string {
|
||||
if (this.isOrganizer) {
|
||||
if (this.decryptedMembers.length < this.members.length) {
|
||||
return "Some members have data that cannot be decrypted with that password.";
|
||||
return 'Some members have data that cannot be decrypted with that password.'
|
||||
} else {
|
||||
// the lists must be equal
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
} else {
|
||||
// non-organizers should only see problems if the first (organizer) member is not decrypted
|
||||
@@ -296,10 +298,10 @@ 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. Reload or have them check their password.'
|
||||
} else {
|
||||
// the first (organizer) member was decrypted OK
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,118 +309,118 @@ export default class MembersList extends Vue {
|
||||
membersToShow(): DecryptedMember[] {
|
||||
if (this.isOrganizer) {
|
||||
if (this.showOrganizerTools) {
|
||||
return this.decryptedMembers;
|
||||
return this.decryptedMembers
|
||||
} else {
|
||||
return this.decryptedMembers.filter(
|
||||
(member: DecryptedMember) => member.member.admitted,
|
||||
);
|
||||
(member: DecryptedMember) => member.member.admitted
|
||||
)
|
||||
}
|
||||
}
|
||||
// non-organizers only get visible members from server
|
||||
return this.decryptedMembers;
|
||||
return this.decryptedMembers
|
||||
}
|
||||
|
||||
informAboutAdmission() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Admission info",
|
||||
text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.",
|
||||
group: 'alert',
|
||||
type: 'info',
|
||||
title: 'Admission info',
|
||||
text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered."
|
||||
},
|
||||
10000,
|
||||
);
|
||||
10000
|
||||
)
|
||||
}
|
||||
|
||||
informAboutAddingContact(contactImportedAlready: boolean) {
|
||||
if (contactImportedAlready) {
|
||||
this.$notify(
|
||||
{
|
||||
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.",
|
||||
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.'
|
||||
},
|
||||
10000,
|
||||
);
|
||||
10000
|
||||
)
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
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.",
|
||||
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.'
|
||||
},
|
||||
10000,
|
||||
);
|
||||
10000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async loadContacts() {
|
||||
this.contacts = await db.contacts.toArray();
|
||||
this.contacts = await db.contacts.toArray()
|
||||
}
|
||||
|
||||
getContactFor(did: string): Contact | undefined {
|
||||
return this.contacts.find((contact) => contact.did === did);
|
||||
return this.contacts.find((contact) => contact.did === did)
|
||||
}
|
||||
|
||||
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
|
||||
const contact = this.getContactFor(decrMember.did);
|
||||
const contact = this.getContactFor(decrMember.did)
|
||||
if (!decrMember.member.admitted && !contact) {
|
||||
// If not a contact, show confirmation dialog
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Add as Contact First?",
|
||||
text: "This person is not in your contacts. Would you like to add them as a contact first?",
|
||||
yesText: "Add as Contact",
|
||||
noText: "Skip Adding Contact",
|
||||
group: 'modal',
|
||||
type: 'confirm',
|
||||
title: 'Add as Contact First?',
|
||||
text: 'This person is not in your contacts. Would you like to add them as a contact first?',
|
||||
yesText: 'Add as Contact',
|
||||
noText: 'Skip Adding Contact',
|
||||
onYes: async () => {
|
||||
await this.addAsContact(decrMember);
|
||||
await this.addAsContact(decrMember)
|
||||
// After adding as contact, proceed with admission
|
||||
await this.toggleAdmission(decrMember);
|
||||
await this.toggleAdmission(decrMember)
|
||||
},
|
||||
onNo: async () => {
|
||||
// If they choose not to add as contact, show second confirmation
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Continue Without Adding?",
|
||||
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.",
|
||||
yesText: "Continue",
|
||||
group: 'modal',
|
||||
type: 'confirm',
|
||||
title: 'Continue Without Adding?',
|
||||
text: 'Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.',
|
||||
yesText: 'Continue',
|
||||
onYes: async () => {
|
||||
await this.toggleAdmission(decrMember);
|
||||
await this.toggleAdmission(decrMember)
|
||||
},
|
||||
onCancel: async () => {
|
||||
// Do nothing, effectively canceling the operation
|
||||
},
|
||||
}
|
||||
},
|
||||
-1,
|
||||
);
|
||||
},
|
||||
-1
|
||||
)
|
||||
}
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
} else {
|
||||
// If already a contact, proceed directly with admission
|
||||
this.toggleAdmission(decrMember);
|
||||
this.toggleAdmission(decrMember)
|
||||
}
|
||||
}
|
||||
|
||||
async toggleAdmission(decrMember: DecryptedMember) {
|
||||
try {
|
||||
const headers = await getHeaders(this.activeDid);
|
||||
const headers = await getHeaders(this.activeDid)
|
||||
await this.axios.put(
|
||||
`${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`,
|
||||
{ admitted: !decrMember.member.admitted },
|
||||
{ headers },
|
||||
);
|
||||
{ headers }
|
||||
)
|
||||
// Update local state
|
||||
decrMember.member.admitted = !decrMember.member.admitted;
|
||||
decrMember.member.admitted = !decrMember.member.admitted
|
||||
|
||||
const oldContact = this.getContactFor(decrMember.did);
|
||||
const oldContact = this.getContactFor(decrMember.did)
|
||||
// if admitted, now register that user if they are not registered
|
||||
if (
|
||||
decrMember.member.admitted &&
|
||||
@@ -427,61 +429,61 @@ export default class MembersList extends Vue {
|
||||
) {
|
||||
const contactOldOrNew: Contact = oldContact || {
|
||||
did: decrMember.did,
|
||||
name: decrMember.name,
|
||||
};
|
||||
name: decrMember.name
|
||||
}
|
||||
try {
|
||||
const result = await register(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
contactOldOrNew,
|
||||
);
|
||||
contactOldOrNew
|
||||
)
|
||||
if (result.success) {
|
||||
decrMember.isRegistered = true;
|
||||
decrMember.isRegistered = true
|
||||
if (oldContact) {
|
||||
await db.contacts.update(decrMember.did, { registered: true });
|
||||
oldContact.registered = true;
|
||||
await db.contacts.update(decrMember.did, { registered: true })
|
||||
oldContact.registered = true
|
||||
}
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Registered",
|
||||
text: "Besides being admitted, they were also registered.",
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Registered',
|
||||
text: 'Besides being admitted, they were also registered.'
|
||||
},
|
||||
3000,
|
||||
);
|
||||
3000
|
||||
)
|
||||
} else {
|
||||
throw result;
|
||||
throw result
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
// registration failure is likely explained by a message from the server
|
||||
const additionalInfo =
|
||||
serverMessageForUser(error) || error?.error || "";
|
||||
serverMessageForUser(error) || error?.error || ''
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Registration failed",
|
||||
group: 'alert',
|
||||
type: 'warning',
|
||||
title: 'Registration failed',
|
||||
text:
|
||||
"They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. " +
|
||||
additionalInfo,
|
||||
'They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. ' +
|
||||
additionalInfo
|
||||
},
|
||||
12000,
|
||||
);
|
||||
12000
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logConsoleAndDb(
|
||||
"Error toggling admission: " + errorStringForLog(error),
|
||||
true,
|
||||
);
|
||||
'Error toggling admission: ' + errorStringForLog(error),
|
||||
true
|
||||
)
|
||||
this.$emit(
|
||||
"error",
|
||||
'error',
|
||||
serverMessageForUser(error) ||
|
||||
"Failed to update member admission status.",
|
||||
);
|
||||
'Failed to update member admission status.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,36 +491,36 @@ export default class MembersList extends Vue {
|
||||
try {
|
||||
const newContact = {
|
||||
did: member.did,
|
||||
name: member.name,
|
||||
};
|
||||
name: member.name
|
||||
}
|
||||
|
||||
await db.contacts.add(newContact);
|
||||
this.contacts.push(newContact);
|
||||
await db.contacts.add(newContact)
|
||||
this.contacts.push(newContact)
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Contact Added",
|
||||
text: "They were added to your contacts.",
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Contact Added',
|
||||
text: 'They were added to your contacts.'
|
||||
},
|
||||
3000,
|
||||
);
|
||||
3000
|
||||
)
|
||||
} catch (err) {
|
||||
logConsoleAndDb("Error adding contact: " + errorStringForLog(err), true);
|
||||
let message = "An error prevented adding this contact.";
|
||||
if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
|
||||
message = "This person is already in your contact list.";
|
||||
logConsoleAndDb('Error adding contact: ' + errorStringForLog(err), true)
|
||||
let message = 'An error prevented adding this contact.'
|
||||
if (err instanceof Error && err.message?.indexOf('already exists') > -1) {
|
||||
message = 'This person is already in your contact list.'
|
||||
}
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Contact Not Added",
|
||||
text: message,
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Contact Not Added',
|
||||
text: message
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+101
-104
@@ -49,8 +49,8 @@
|
||||
projectName,
|
||||
recipientDid,
|
||||
recipientName,
|
||||
unitCode: amountUnitCode,
|
||||
},
|
||||
unitCode: amountUnitCode
|
||||
}
|
||||
}"
|
||||
class="text-blue-500"
|
||||
>
|
||||
@@ -80,117 +80,114 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { Vue, Component, Prop } from 'vue-facing-decorator'
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface } from '../constants/app'
|
||||
import {
|
||||
createAndSubmitOffer,
|
||||
serverMessageForUser,
|
||||
} from "../libs/endorserServer";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { logger } from "../utils/logger";
|
||||
serverMessageForUser
|
||||
} from '../libs/endorserServer'
|
||||
import * as libsUtil from '../libs/util'
|
||||
import { retrieveSettingsForActiveAccount } from '../db/index'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
@Component
|
||||
export default class OfferDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
@Prop projectId?: string;
|
||||
@Prop projectName?: string;
|
||||
@Prop projectId?: string
|
||||
@Prop projectName?: string
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
activeDid = ''
|
||||
apiServer = ''
|
||||
|
||||
amountInput = "0";
|
||||
amountUnitCode = "HUR";
|
||||
description = "";
|
||||
expirationDateInput = "";
|
||||
recipientDid? = "";
|
||||
recipientName? = "";
|
||||
visible = false;
|
||||
amountInput = '0'
|
||||
amountUnitCode = 'HUR'
|
||||
description = ''
|
||||
expirationDateInput = ''
|
||||
recipientDid? = ''
|
||||
recipientName? = ''
|
||||
visible = false
|
||||
|
||||
libsUtil = libsUtil;
|
||||
libsUtil = libsUtil
|
||||
|
||||
async open(recipientDid?: string, recipientName?: string) {
|
||||
try {
|
||||
this.recipientDid = recipientDid;
|
||||
this.recipientName = recipientName;
|
||||
this.recipientDid = recipientDid
|
||||
this.recipientName = recipientName
|
||||
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.activeDid = settings.activeDid || "";
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
this.apiServer = settings.apiServer || ''
|
||||
this.activeDid = settings.activeDid || ''
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
logger.error("Error retrieving settings from database:", err);
|
||||
logger.error('Error retrieving settings from database:', err)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: err.message || "There was an error retrieving your settings.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: err.message || 'There was an error retrieving your settings.'
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
}
|
||||
|
||||
this.visible = true;
|
||||
this.visible = true
|
||||
}
|
||||
|
||||
close() {
|
||||
// close the dialog but don't change values (since it might be submitting info)
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
}
|
||||
|
||||
changeUnitCode() {
|
||||
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
||||
const index = units.indexOf(this.amountUnitCode);
|
||||
this.amountUnitCode = units[(index + 1) % units.length];
|
||||
const units = Object.keys(this.libsUtil.UNIT_SHORT)
|
||||
const index = units.indexOf(this.amountUnitCode)
|
||||
this.amountUnitCode = units[(index + 1) % units.length]
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.amountInput = `${Math.max(
|
||||
0,
|
||||
(parseFloat(this.amountInput) || 1) - 1,
|
||||
)}`;
|
||||
this.amountInput = `${Math.max(0, (parseFloat(this.amountInput) || 1) - 1)}`
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.close();
|
||||
this.eraseValues();
|
||||
this.close()
|
||||
this.eraseValues()
|
||||
}
|
||||
|
||||
eraseValues() {
|
||||
this.description = "";
|
||||
this.amountInput = "0";
|
||||
this.amountUnitCode = "HUR";
|
||||
this.description = ''
|
||||
this.amountInput = '0'
|
||||
this.amountUnitCode = 'HUR'
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
this.close();
|
||||
this.close()
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
text: "Recording the offer...",
|
||||
title: "",
|
||||
group: 'alert',
|
||||
type: 'toast',
|
||||
text: 'Recording the offer...',
|
||||
title: ''
|
||||
},
|
||||
1000,
|
||||
);
|
||||
1000
|
||||
)
|
||||
// this is asynchronous, but we don't need to wait for it to complete
|
||||
this.recordOffer(
|
||||
this.description,
|
||||
parseFloat(this.amountInput),
|
||||
this.amountUnitCode,
|
||||
this.expirationDateInput,
|
||||
this.expirationDateInput
|
||||
).then(() => {
|
||||
this.description = "";
|
||||
this.amountInput = "0";
|
||||
});
|
||||
this.description = ''
|
||||
this.amountInput = '0'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,33 +199,33 @@ export default class OfferDialog extends Vue {
|
||||
public async recordOffer(
|
||||
description: string,
|
||||
amount: number,
|
||||
unitCode: string = "HUR",
|
||||
expirationDateInput?: string,
|
||||
unitCode: string = 'HUR',
|
||||
expirationDateInput?: string
|
||||
) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identity before you can record an offer.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: 'You must select an identity before you can record an offer.'
|
||||
},
|
||||
7000,
|
||||
);
|
||||
return;
|
||||
7000
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!description && !amount) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`,
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
-1
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -239,54 +236,54 @@ export default class OfferDialog extends Vue {
|
||||
description,
|
||||
amount,
|
||||
unitCode,
|
||||
"",
|
||||
'',
|
||||
expirationDateInput,
|
||||
this.recipientDid,
|
||||
this.projectId,
|
||||
);
|
||||
this.projectId
|
||||
)
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
result.type === 'error' ||
|
||||
this.isOfferCreationError(result.response)
|
||||
) {
|
||||
const errorMessage = this.getOfferCreationErrorMessage(result);
|
||||
logger.error("Error with offer creation result:", result);
|
||||
const errorMessage = this.getOfferCreationErrorMessage(result)
|
||||
logger.error('Error with offer creation result:', result)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error creating the offer.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: errorMessage || 'There was an error creating the offer.'
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "That offer was recorded.",
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Success',
|
||||
text: 'That offer was recorded.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
logger.error("Error with offer recordation caught:", error);
|
||||
logger.error('Error with offer recordation caught:', error)
|
||||
const message =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the offer.";
|
||||
'There was an error recording the offer.'
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: message,
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: message
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +295,7 @@ export default class OfferDialog extends Vue {
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isOfferCreationError(result: any) {
|
||||
return result.status !== 201 || result.data?.error;
|
||||
return result.status !== 201 || result.data?.error
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,7 +308,7 @@ export default class OfferDialog extends Vue {
|
||||
serverMessageForUser(result) ||
|
||||
result.error?.userMessage ||
|
||||
result.error?.error
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -198,64 +198,64 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
import { Component, Vue } from 'vue-facing-decorator'
|
||||
import { Router } from 'vue-router'
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { NotificationIface } from '../constants/app'
|
||||
import {
|
||||
db,
|
||||
retrieveSettingsForActiveAccount,
|
||||
updateAccountSettings,
|
||||
} from "../db/index";
|
||||
import { OnboardPage } from "../libs/util";
|
||||
updateAccountSettings
|
||||
} from '../db/index'
|
||||
import { OnboardPage } from '../libs/util'
|
||||
|
||||
@Component({
|
||||
computed: {
|
||||
OnboardPage() {
|
||||
return OnboardPage;
|
||||
},
|
||||
return OnboardPage
|
||||
}
|
||||
},
|
||||
components: { OnboardPage },
|
||||
components: { OnboardPage }
|
||||
})
|
||||
export default class OnboardingDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$router!: Router;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
$router!: Router
|
||||
|
||||
activeDid = "";
|
||||
firstContactName = null;
|
||||
givenName = "";
|
||||
isRegistered = false;
|
||||
numContacts = 0;
|
||||
page = OnboardPage.Home;
|
||||
visible = false;
|
||||
activeDid = ''
|
||||
firstContactName = null
|
||||
givenName = ''
|
||||
isRegistered = false
|
||||
numContacts = 0
|
||||
page = OnboardPage.Home
|
||||
visible = false
|
||||
|
||||
async open(page: OnboardPage) {
|
||||
this.page = page;
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
const contacts = await db.contacts.toArray();
|
||||
this.numContacts = contacts.length;
|
||||
this.page = page
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
this.activeDid = settings.activeDid || ''
|
||||
this.isRegistered = !!settings.isRegistered
|
||||
const contacts = await db.contacts.toArray()
|
||||
this.numContacts = contacts.length
|
||||
if (this.numContacts > 0) {
|
||||
this.firstContactName = contacts[0].name;
|
||||
this.firstContactName = contacts[0].name
|
||||
}
|
||||
this.visible = true;
|
||||
this.visible = true
|
||||
if (this.page === OnboardPage.Create) {
|
||||
// we'll assume that they've been through all the other pages
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
finishedOnboarding: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async onClickClose(done?: boolean, goHome?: boolean) {
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
if (done) {
|
||||
await updateAccountSettings(this.activeDid, {
|
||||
finishedOnboarding: true,
|
||||
});
|
||||
finishedOnboarding: true
|
||||
})
|
||||
if (goHome) {
|
||||
this.$router.push({ name: "home" });
|
||||
this.$router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+148
-439
@@ -1,33 +1,21 @@
|
||||
/** * PhotoDialog.vue - Cross-platform photo capture and selection component * *
|
||||
This component provides a unified interface for taking photos and selecting
|
||||
images * across different platforms (web, mobile) using the PlatformService. It
|
||||
supports: * - Taking photos using device camera * - Selecting images from device
|
||||
gallery * - Image cropping functionality * - Image upload to server * - Error
|
||||
handling and user feedback * * Features: * - Responsive design with mobile-first
|
||||
approach * - Cross-platform compatibility through PlatformService * - Image
|
||||
cropping with aspect ratio control * - Progress feedback during upload * -
|
||||
Comprehensive error handling * * @author Matthew Raymer * @version 1.0.0 * @file
|
||||
PhotoDialog.vue */
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div class="dialog relative">
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div
|
||||
id="ViewHeading"
|
||||
class="text-center font-bold absolute top-0 inset-x-0 px-4 py-2 bg-black/50 text-white leading-none pointer-events-none"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||
>
|
||||
<span v-if="uploading"> Uploading... </span>
|
||||
<span v-else-if="blob"> Look Good? </span>
|
||||
<span v-else-if="showCameraPreview"> Take Photo </span>
|
||||
<span v-else> Say "Cheese"! </span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-lg text-center px-2 py-2 leading-none absolute right-0 top-0 text-white cursor-pointer"
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||
@click="close()"
|
||||
>
|
||||
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
|
||||
<font-awesome icon="xmark" class="w-[1em]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,13 +30,13 @@ PhotoDialog.vue */
|
||||
<VuePictureCropper
|
||||
:box-style="{
|
||||
backgroundColor: '#f8f8f8',
|
||||
margin: 'auto',
|
||||
margin: 'auto'
|
||||
}"
|
||||
:img="createBlobURL(blob)"
|
||||
:options="{
|
||||
viewMode: 1,
|
||||
dragMode: 'crop',
|
||||
aspectRatio: 1 / 1,
|
||||
aspectRatio: 9 / 9
|
||||
}"
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
@@ -61,45 +49,31 @@ PhotoDialog.vue */
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
|
||||
<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 py-2 px-3 rounded-md"
|
||||
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 py-1 px-2 rounded-md"
|
||||
@click="uploadImage"
|
||||
>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showRetry"
|
||||
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
|
||||
>
|
||||
<button
|
||||
v-if="showRetry"
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||||
@click="retryImage"
|
||||
>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="showCameraPreview" class="camera-preview">
|
||||
<div class="camera-container">
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="camera-video"
|
||||
autoplay
|
||||
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>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex flex-col items-center justify-center gap-4 p-4">
|
||||
<button
|
||||
v-if="isRegistered"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
@click="startCameraPreview"
|
||||
@click="takePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
@@ -116,424 +90,199 @@ PhotoDialog.vue */
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
/**
|
||||
* PhotoDialog.vue - Cross-platform photo capture and selection component
|
||||
*
|
||||
* This component provides a unified interface for taking photos and selecting images
|
||||
* across different platforms using the PlatformService.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @file PhotoDialog.vue
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { Component, Vue } from 'vue-facing-decorator'
|
||||
import VuePictureCropper, { cropper } from 'vue-picture-cropper'
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from '../constants/app'
|
||||
import { retrieveSettingsForActiveAccount } from '../db/index'
|
||||
import { accessToken } from '../libs/crypto'
|
||||
import { logger } from '../utils/logger'
|
||||
import { PlatformServiceFactory } from '../services/PlatformServiceFactory'
|
||||
|
||||
@Component({ components: { VuePictureCropper } })
|
||||
export default class PhotoDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
/** Active DID for user authentication */
|
||||
activeDid = "";
|
||||
activeDid = ''
|
||||
blob?: Blob
|
||||
claimType = ''
|
||||
crop = false
|
||||
fileName?: string
|
||||
setImageCallback: (arg: string) => void = () => {}
|
||||
showRetry = true
|
||||
uploading = false
|
||||
visible = false
|
||||
|
||||
/** Current image blob being processed */
|
||||
blob?: Blob;
|
||||
private platformService = PlatformServiceFactory.getInstance()
|
||||
URL = window.URL || window.webkitURL
|
||||
|
||||
/** Type of claim for the image */
|
||||
claimType = "";
|
||||
|
||||
/** Whether to show cropping interface */
|
||||
crop = false;
|
||||
|
||||
/** Name of the selected file */
|
||||
fileName?: string;
|
||||
|
||||
/** Callback function to set image URL after upload */
|
||||
setImageCallback: (arg: string) => void = () => {};
|
||||
|
||||
/** Whether to show retry button */
|
||||
showRetry = true;
|
||||
|
||||
/** Upload progress state */
|
||||
uploading = false;
|
||||
|
||||
/** Dialog visibility state */
|
||||
visible = false;
|
||||
|
||||
/** Whether to show camera preview */
|
||||
showCameraPreview = false;
|
||||
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
isRegistered = false;
|
||||
private platformCapabilities = this.platformService.getCapabilities();
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Initializes component and retrieves user settings
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
logger.log("PhotoDialog mounted");
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
logger.log("isRegistered:", this.isRegistered);
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error retrieving settings from database:", error);
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
this.activeDid = settings.activeDid || ''
|
||||
} catch (err: unknown) {
|
||||
logger.error('Error retrieving settings from database:', err)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "There was an error retrieving your settings.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: err.message || 'There was an error retrieving your settings.'
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Cleans up camera stream when component is destroyed
|
||||
*/
|
||||
beforeDestroy() {
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the photo dialog with specified configuration
|
||||
* @param setImageFn - Callback function to handle image URL after upload
|
||||
* @param claimType - Type of claim for the image
|
||||
* @param crop - Whether to enable cropping
|
||||
* @param blob - Optional existing image blob
|
||||
* @param inputFileName - Optional filename for the image
|
||||
*/
|
||||
async open(
|
||||
open(
|
||||
setImageFn: (arg: string) => void,
|
||||
claimType: string,
|
||||
crop?: boolean,
|
||||
blob?: Blob,
|
||||
inputFileName?: string,
|
||||
inputFileName?: string
|
||||
) {
|
||||
this.visible = true;
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||
this.visible = true
|
||||
this.claimType = claimType
|
||||
this.crop = !!crop
|
||||
const bottomNav = document.querySelector('#QuickNav') as HTMLElement
|
||||
if (bottomNav) {
|
||||
bottomNav.style.display = "none";
|
||||
bottomNav.style.display = 'none'
|
||||
}
|
||||
this.setImageCallback = setImageFn;
|
||||
this.setImageCallback = setImageFn
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = inputFileName;
|
||||
this.showRetry = false;
|
||||
this.blob = blob
|
||||
this.fileName = inputFileName
|
||||
this.showRetry = false
|
||||
} else {
|
||||
this.blob = undefined;
|
||||
this.fileName = undefined;
|
||||
this.showRetry = true;
|
||||
// Start camera preview automatically if no blob is provided
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
this.blob = undefined
|
||||
this.fileName = undefined
|
||||
this.showRetry = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the photo dialog and resets state
|
||||
*/
|
||||
close() {
|
||||
logger.debug(
|
||||
"Dialog closing, current showCameraPreview:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
this.visible = false;
|
||||
this.stopCameraPreview();
|
||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||
this.visible = false
|
||||
const bottomNav = document.querySelector('#QuickNav') as HTMLElement
|
||||
if (bottomNav) {
|
||||
bottomNav.style.display = "";
|
||||
bottomNav.style.display = ''
|
||||
}
|
||||
this.blob = undefined;
|
||||
this.blob = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the camera preview
|
||||
*/
|
||||
async startCameraPreview() {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
|
||||
// If we're on a mobile device or using Capacitor, use the platform service
|
||||
if (this.platformCapabilities.isMobile) {
|
||||
logger.debug("Using platform service for mobile device");
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
} catch (error) {
|
||||
logger.error("Error taking picture:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to take picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For desktop web browsers, use our custom preview
|
||||
logger.debug("Starting camera preview for desktop browser");
|
||||
try {
|
||||
// Set state before requesting camera access
|
||||
this.showCameraPreview = true;
|
||||
logger.debug("showCameraPreview set to:", this.showCameraPreview);
|
||||
|
||||
// Force a re-render
|
||||
await this.$nextTick();
|
||||
logger.debug(
|
||||
"After nextTick, showCameraPreview is:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
|
||||
logger.debug("Requesting camera access...");
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
logger.debug("Camera access granted, setting up video element");
|
||||
this.cameraStream = stream;
|
||||
|
||||
// Force another re-render after getting the stream
|
||||
await this.$nextTick();
|
||||
logger.debug(
|
||||
"After getting stream, showCameraPreview is:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
logger.debug("Video element found, setting srcObject");
|
||||
videoElement.srcObject = stream;
|
||||
// Wait for video to be ready
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
logger.debug("Video metadata loaded");
|
||||
videoElement.play().then(() => {
|
||||
logger.debug("Video playback started");
|
||||
resolve(true);
|
||||
});
|
||||
};
|
||||
});
|
||||
} else {
|
||||
logger.error("Video element not found");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to access camera. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the camera preview and cleans up resources
|
||||
*/
|
||||
stopCameraPreview() {
|
||||
logger.debug(
|
||||
"Stopping camera preview, current showCameraPreview:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach((track) => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
this.showCameraPreview = false;
|
||||
logger.debug(
|
||||
"After stopping, showCameraPreview is:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a photo from the camera preview
|
||||
*/
|
||||
async capturePhoto() {
|
||||
if (!this.cameraStream) return;
|
||||
|
||||
try {
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = videoElement.videoWidth;
|
||||
canvas.height = videoElement.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = `photo_${Date.now()}.jpg`;
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error capturing photo:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to capture photo. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a photo using device camera
|
||||
* @throws {Error} When camera access fails
|
||||
*/
|
||||
async takePhoto() {
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
const result = await this.platformService.takePicture()
|
||||
this.blob = result.blob
|
||||
this.fileName = result.fileName
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error taking picture:", error);
|
||||
logger.error('Error taking picture:', error)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to take picture. Please try again.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: 'Failed to take picture. Please try again.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects an image from device gallery
|
||||
* @throws {Error} When gallery access fails
|
||||
*/
|
||||
async pickPhoto() {
|
||||
try {
|
||||
const result = await this.platformService.pickImage();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
const result = await this.platformService.pickImage()
|
||||
this.blob = result.blob
|
||||
this.fileName = result.fileName
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error picking image:", error);
|
||||
logger.error('Error picking image:', error)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to pick image. Please try again.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: 'Failed to pick image. Please try again.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a blob URL for image preview
|
||||
* @param blob - Image blob to create URL for
|
||||
* @returns {string} Blob URL for the image
|
||||
*/
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the current image selection and restarts camera preview
|
||||
*/
|
||||
async retryImage() {
|
||||
this.blob = undefined;
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
this.blob = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current image to the server
|
||||
* Handles cropping if enabled and manages upload state
|
||||
* @throws {Error} When upload fails or server returns error
|
||||
*/
|
||||
async uploadImage() {
|
||||
this.uploading = true;
|
||||
this.uploading = true
|
||||
|
||||
if (this.crop) {
|
||||
this.blob = (await cropper?.getBlob()) || undefined;
|
||||
this.blob = (await cropper?.getBlob()) || undefined
|
||||
}
|
||||
|
||||
const token = await accessToken(this.activeDid);
|
||||
const token = await accessToken(this.activeDid)
|
||||
const headers = {
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const formData = new FormData();
|
||||
Authorization: 'Bearer ' + token
|
||||
}
|
||||
const formData = new FormData()
|
||||
if (!this.blob) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error finding the picture. Please try again.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: 'There was an error finding the picture. Please try again.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
return;
|
||||
5000
|
||||
)
|
||||
this.uploading = false
|
||||
return
|
||||
}
|
||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||
formData.append("claimType", this.claimType);
|
||||
formData.append('image', this.blob, this.fileName || 'photo.jpg')
|
||||
formData.append('claimType', this.claimType)
|
||||
try {
|
||||
if (
|
||||
window.location.hostname === "localhost" &&
|
||||
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
||||
window.location.hostname === 'localhost' &&
|
||||
!DEFAULT_IMAGE_API_SERVER.includes('localhost')
|
||||
) {
|
||||
logger.log(
|
||||
"Using shared image API server, so only users on that server can play with images.",
|
||||
);
|
||||
'Using shared image API server, so only users on that server can play with images.'
|
||||
)
|
||||
}
|
||||
const response = await axios.post(
|
||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
||||
DEFAULT_IMAGE_API_SERVER + '/image',
|
||||
formData,
|
||||
{ headers },
|
||||
);
|
||||
this.uploading = false;
|
||||
{ headers }
|
||||
)
|
||||
this.uploading = false
|
||||
|
||||
this.close();
|
||||
this.setImageCallback(response.data.url as string);
|
||||
this.close()
|
||||
this.setImageCallback(response.data.url as string)
|
||||
} catch (error: unknown) {
|
||||
// Log the raw error first
|
||||
logger.error("Raw error object:", JSON.stringify(error, null, 2));
|
||||
logger.error('Raw error object:', JSON.stringify(error, null, 2))
|
||||
|
||||
let errorMessage = "There was an error saving the picture.";
|
||||
let errorMessage = 'There was an error saving the picture.'
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const statusText = error.response?.statusText;
|
||||
const data = error.response?.data;
|
||||
const status = error.response?.status
|
||||
const statusText = error.response?.statusText
|
||||
const data = error.response?.data
|
||||
|
||||
// Log detailed error information
|
||||
logger.error("Upload error details:", {
|
||||
logger.error('Upload error details:', {
|
||||
status,
|
||||
statusText,
|
||||
data: JSON.stringify(data, null, 2),
|
||||
@@ -541,56 +290,55 @@ export default class PhotoDialog extends Vue {
|
||||
config: {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
headers: error.config?.headers,
|
||||
},
|
||||
});
|
||||
headers: error.config?.headers
|
||||
}
|
||||
})
|
||||
|
||||
if (status === 401) {
|
||||
errorMessage = "Authentication failed. Please try logging in again.";
|
||||
errorMessage = 'Authentication failed. Please try logging in again.'
|
||||
} else if (status === 413) {
|
||||
errorMessage = "Image file is too large. Please try a smaller image.";
|
||||
errorMessage = 'Image file is too large. Please try a smaller image.'
|
||||
} else if (status === 415) {
|
||||
errorMessage =
|
||||
"Unsupported image format. Please try a different image.";
|
||||
'Unsupported image format. Please try a different image.'
|
||||
} else if (status && status >= 500) {
|
||||
errorMessage = "Server error. Please try again later.";
|
||||
errorMessage = 'Server error. Please try again later.'
|
||||
} else if (data?.message) {
|
||||
errorMessage = data.message;
|
||||
errorMessage = data.message
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
// Log non-Axios error with full details
|
||||
logger.error("Non-Axios error details:", {
|
||||
logger.error('Non-Axios error details:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
|
||||
});
|
||||
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
|
||||
})
|
||||
} else {
|
||||
// Log any other type of error
|
||||
logger.error("Unknown error type:", {
|
||||
logger.error('Unknown error type:', {
|
||||
error: JSON.stringify(error, null, 2),
|
||||
type: typeof error,
|
||||
});
|
||||
type: typeof error
|
||||
})
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: errorMessage
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.blob = undefined;
|
||||
5000
|
||||
)
|
||||
this.uploading = false
|
||||
this.blob = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Dialog overlay styling */
|
||||
.dialog-overlay {
|
||||
z-index: 60;
|
||||
position: fixed;
|
||||
@@ -605,50 +353,11 @@ export default class PhotoDialog extends Vue {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Dialog container styling */
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Camera preview styling */
|
||||
.camera-preview {
|
||||
flex: 1;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.capture-button {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(to bottom, #60a5fa, #2563eb);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
box-shadow: inset 0 -1px 0 0 rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,35 +15,35 @@
|
||||
/>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { toSvg } from "jdenticon";
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { toSvg } from 'jdenticon'
|
||||
import { Vue, Component, Prop } from 'vue-facing-decorator'
|
||||
|
||||
const BLANK_CONFIG = {
|
||||
lightness: {
|
||||
color: [1.0, 1.0],
|
||||
grayscale: [1.0, 1.0],
|
||||
grayscale: [1.0, 1.0]
|
||||
},
|
||||
saturation: {
|
||||
color: 0.0,
|
||||
grayscale: 0.0,
|
||||
grayscale: 0.0
|
||||
},
|
||||
backColor: "#0000",
|
||||
};
|
||||
backColor: '#0000'
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class ProjectIcon extends Vue {
|
||||
@Prop entityId = "";
|
||||
@Prop iconSize = 0;
|
||||
@Prop imageUrl = "";
|
||||
@Prop linkToFull = false;
|
||||
@Prop entityId = ''
|
||||
@Prop iconSize = 0
|
||||
@Prop imageUrl = ''
|
||||
@Prop linkToFull = false
|
||||
|
||||
generateIdenticon() {
|
||||
if (this.imageUrl) {
|
||||
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
|
||||
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`
|
||||
} else {
|
||||
const config = this.entityId ? undefined : BLANK_CONFIG;
|
||||
const svgString = toSvg(this.entityId, this.iconSize, config);
|
||||
return svgString;
|
||||
const config = this.entityId ? undefined : BLANK_CONFIG
|
||||
const svgString = toSvg(this.entityId, this.iconSize, config)
|
||||
return svgString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
>
|
||||
<!-- eslint-enable -->
|
||||
<span class="w-full flex justify-between text-xs text-slate-500">
|
||||
<span></span>
|
||||
<span />
|
||||
<span>(100 characters max)</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -79,8 +79,8 @@
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
|
||||
@click="
|
||||
close();
|
||||
turnOnNotifications();
|
||||
close()
|
||||
turnOnNotifications()
|
||||
"
|
||||
>
|
||||
Turn on Daily Message
|
||||
@@ -100,46 +100,46 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Component, Vue } from 'vue-facing-decorator'
|
||||
|
||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
|
||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from '../constants/app'
|
||||
import {
|
||||
logConsoleAndDb,
|
||||
retrieveSettingsForActiveAccount,
|
||||
secretDB,
|
||||
} from "../db/index";
|
||||
import { MASTER_SECRET_KEY } from "../db/tables/secret";
|
||||
import { urlBase64ToUint8Array } from "../libs/crypto/vc/util";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
secretDB
|
||||
} from '../db/index'
|
||||
import { MASTER_SECRET_KEY } from '../db/tables/secret'
|
||||
import { urlBase64ToUint8Array } from '../libs/crypto/vc/util'
|
||||
import * as libsUtil from '../libs/util'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
// Example interface for error
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
message: string
|
||||
}
|
||||
|
||||
// PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson
|
||||
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
|
||||
message?: string;
|
||||
notifyTime: { utcHour: number; minute: number };
|
||||
notifyType: string;
|
||||
message?: string
|
||||
notifyTime: { utcHour: number; minute: number }
|
||||
notifyType: string
|
||||
}
|
||||
|
||||
interface ServiceWorkerMessage {
|
||||
type: string;
|
||||
data: string;
|
||||
type: string
|
||||
data: string
|
||||
}
|
||||
|
||||
interface ServiceWorkerResponse {
|
||||
// Define the properties and their types
|
||||
success: boolean;
|
||||
message?: string;
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface VapidResponse {
|
||||
data: {
|
||||
vapidKey: string;
|
||||
};
|
||||
vapidKey: string
|
||||
}
|
||||
}
|
||||
|
||||
@Component
|
||||
@@ -147,135 +147,135 @@ export default class PushNotificationPermission extends Vue {
|
||||
// eslint-disable-next-line
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>;
|
||||
|
||||
DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE;
|
||||
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE;
|
||||
DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE
|
||||
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE
|
||||
|
||||
callback: (success: boolean, time: string, message?: string) => void =
|
||||
() => {};
|
||||
hourAm = true;
|
||||
hourInput = "8";
|
||||
isVisible = false;
|
||||
messageInput = "";
|
||||
minuteInput = "00";
|
||||
pushType = "";
|
||||
serviceWorkerReady = false;
|
||||
vapidKey = "";
|
||||
() => {}
|
||||
hourAm = true
|
||||
hourInput = '8'
|
||||
isVisible = false
|
||||
messageInput = ''
|
||||
minuteInput = '00'
|
||||
pushType = ''
|
||||
serviceWorkerReady = false
|
||||
vapidKey = ''
|
||||
|
||||
async open(
|
||||
pushType: string,
|
||||
callback?: (success: boolean, time: string, message?: string) => void,
|
||||
callback?: (success: boolean, time: string, message?: string) => void
|
||||
) {
|
||||
this.callback = callback || this.callback;
|
||||
this.isVisible = true;
|
||||
this.pushType = pushType;
|
||||
this.callback = callback || this.callback
|
||||
this.isVisible = true
|
||||
this.pushType = pushType
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
let pushUrl = DEFAULT_PUSH_SERVER;
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
let pushUrl = DEFAULT_PUSH_SERVER
|
||||
if (settings?.webPushServer) {
|
||||
pushUrl = settings.webPushServer;
|
||||
pushUrl = settings.webPushServer
|
||||
}
|
||||
|
||||
if (pushUrl.startsWith("http://localhost")) {
|
||||
logConsoleAndDb("Not checking for VAPID in this local environment.");
|
||||
if (pushUrl.startsWith('http://localhost')) {
|
||||
logConsoleAndDb('Not checking for VAPID in this local environment.')
|
||||
} else {
|
||||
let responseData = "";
|
||||
let responseData = ''
|
||||
await this.axios
|
||||
.get(pushUrl + "/web-push/vapid")
|
||||
.get(pushUrl + '/web-push/vapid')
|
||||
.then((response: VapidResponse) => {
|
||||
this.vapidKey = response.data?.vapidKey || "";
|
||||
logConsoleAndDb("Got vapid key: " + this.vapidKey);
|
||||
responseData = JSON.stringify(response.data);
|
||||
this.vapidKey = response.data?.vapidKey || ''
|
||||
logConsoleAndDb('Got vapid key: ' + this.vapidKey)
|
||||
responseData = JSON.stringify(response.data)
|
||||
navigator.serviceWorker?.addEventListener(
|
||||
"controllerchange",
|
||||
'controllerchange',
|
||||
() => {
|
||||
logConsoleAndDb(
|
||||
"New service worker is now controlling the page",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
'New service worker is now controlling the page'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
if (!this.vapidKey) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Notifications",
|
||||
text: "Could not set notifications.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error Setting Notifications',
|
||||
text: 'Could not set notifications.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
logConsoleAndDb(
|
||||
"Error Setting Notifications: web push server response didn't have vapidKey: " +
|
||||
responseData,
|
||||
true,
|
||||
);
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.location.host.startsWith("localhost")) {
|
||||
if (window.location.host.startsWith('localhost')) {
|
||||
logConsoleAndDb(
|
||||
"Ignoring the error getting VAPID for local development.",
|
||||
);
|
||||
'Ignoring the error getting VAPID for local development.'
|
||||
)
|
||||
} else {
|
||||
logConsoleAndDb(
|
||||
"Got an error initializing notifications: " + JSON.stringify(error),
|
||||
true,
|
||||
);
|
||||
'Got an error initializing notifications: ' + JSON.stringify(error),
|
||||
true
|
||||
)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Notifications",
|
||||
text: "Got an error setting notifications.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error Setting Notifications',
|
||||
text: 'Got an error setting notifications.'
|
||||
},
|
||||
5000,
|
||||
);
|
||||
5000
|
||||
)
|
||||
}
|
||||
}
|
||||
// there may be a long pause here on first initialization
|
||||
navigator.serviceWorker?.ready.then(() => {
|
||||
this.serviceWorkerReady = true;
|
||||
});
|
||||
this.serviceWorkerReady = true
|
||||
})
|
||||
|
||||
if (this.pushType === this.DIRECT_PUSH_TITLE) {
|
||||
this.messageInput =
|
||||
"Click to share some gratitude with the world -- even if they're unnamed.";
|
||||
"Click to share some gratitude with the world -- even if they're unnamed."
|
||||
// focus on the message input
|
||||
setTimeout(function () {
|
||||
document.getElementById("push-message")?.focus();
|
||||
}, 100);
|
||||
document.getElementById('push-message')?.focus()
|
||||
}, 100)
|
||||
} else {
|
||||
// not critical but doesn't make sense in a daily check
|
||||
this.messageInput = "";
|
||||
this.messageInput = ''
|
||||
}
|
||||
}
|
||||
|
||||
private close() {
|
||||
this.isVisible = false;
|
||||
this.isVisible = false
|
||||
}
|
||||
|
||||
private sendMessageToServiceWorker(
|
||||
message: ServiceWorkerMessage,
|
||||
message: ServiceWorkerMessage
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (navigator.serviceWorker?.controller) {
|
||||
const messageChannel = new MessageChannel();
|
||||
const messageChannel = new MessageChannel()
|
||||
|
||||
messageChannel.port1.onmessage = (event: MessageEvent) => {
|
||||
if (event.data.error) {
|
||||
reject(event.data.error as ErrorResponse);
|
||||
reject(event.data.error as ErrorResponse)
|
||||
} else {
|
||||
resolve(event.data as ServiceWorkerResponse);
|
||||
resolve(event.data as ServiceWorkerResponse)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
navigator.serviceWorker?.controller.postMessage(message, [
|
||||
messageChannel.port2,
|
||||
]);
|
||||
messageChannel.port2
|
||||
])
|
||||
} else {
|
||||
reject("Service worker controller not available");
|
||||
reject('Service worker controller not available')
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
private async askPermission(): Promise<NotificationPermission> {
|
||||
@@ -283,136 +283,136 @@ export default class PushNotificationPermission extends Vue {
|
||||
// "Requesting permission for notifications: " + JSON.stringify(navigator),
|
||||
// );
|
||||
if (
|
||||
!("serviceWorker" in navigator && navigator.serviceWorker?.controller)
|
||||
!('serviceWorker' in navigator && navigator.serviceWorker?.controller)
|
||||
) {
|
||||
return Promise.reject("Service worker not available.");
|
||||
return Promise.reject('Service worker not available.')
|
||||
}
|
||||
|
||||
await secretDB.open();
|
||||
const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret;
|
||||
await secretDB.open()
|
||||
const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret
|
||||
if (!secret) {
|
||||
return Promise.reject("No secret found.");
|
||||
return Promise.reject('No secret found.')
|
||||
}
|
||||
|
||||
return this.sendSecretToServiceWorker(secret)
|
||||
.then(() => this.checkNotificationSupport())
|
||||
.then(() => this.requestNotificationPermission())
|
||||
.catch((error) => Promise.reject(error));
|
||||
.catch((error) => Promise.reject(error))
|
||||
}
|
||||
|
||||
private sendSecretToServiceWorker(secret: string): Promise<void> {
|
||||
const message: ServiceWorkerMessage = {
|
||||
type: "SEND_LOCAL_DATA",
|
||||
data: secret,
|
||||
};
|
||||
type: 'SEND_LOCAL_DATA',
|
||||
data: secret
|
||||
}
|
||||
|
||||
return this.sendMessageToServiceWorker(message).then((response) => {
|
||||
logConsoleAndDb(
|
||||
"Response from service worker: " + JSON.stringify(response),
|
||||
);
|
||||
});
|
||||
'Response from service worker: ' + JSON.stringify(response)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private checkNotificationSupport(): Promise<void> {
|
||||
if (!("Notification" in window)) {
|
||||
if (!('Notification' in window)) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Browser Notifications Are Not Supported",
|
||||
text: "This browser does not support notifications.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Browser Notifications Are Not Supported',
|
||||
text: 'This browser does not support notifications.'
|
||||
},
|
||||
3000,
|
||||
);
|
||||
return Promise.reject("This browser does not support notifications.");
|
||||
3000
|
||||
)
|
||||
return Promise.reject('This browser does not support notifications.')
|
||||
}
|
||||
if (window.Notification.permission === "granted") {
|
||||
return Promise.resolve();
|
||||
if (window.Notification.permission === 'granted') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.resolve();
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
private requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
return window.Notification.requestPermission().then(
|
||||
(permission: string) => {
|
||||
if (permission !== "granted") {
|
||||
if (permission !== 'granted') {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Requesting Notification Permission",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error Requesting Notification Permission',
|
||||
text:
|
||||
"Allow this app permission to make notifications for personal reminders." +
|
||||
" You can adjust them at any time in your settings.",
|
||||
'Allow this app permission to make notifications for personal reminders.' +
|
||||
' You can adjust them at any time in your settings.'
|
||||
},
|
||||
-1,
|
||||
);
|
||||
throw new Error("Permission was not granted to this app.");
|
||||
-1
|
||||
)
|
||||
throw new Error('Permission was not granted to this app.')
|
||||
}
|
||||
return permission;
|
||||
},
|
||||
);
|
||||
return permission
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private checkHourInput() {
|
||||
const hourNum = parseInt(this.hourInput);
|
||||
const hourNum = parseInt(this.hourInput)
|
||||
if (isNaN(hourNum)) {
|
||||
this.hourInput = "12";
|
||||
this.hourInput = '12'
|
||||
} else if (hourNum < 1) {
|
||||
this.hourInput = "12";
|
||||
this.hourAm = !this.hourAm;
|
||||
this.hourInput = '12'
|
||||
this.hourAm = !this.hourAm
|
||||
} else if (hourNum > 12) {
|
||||
this.hourInput = "1";
|
||||
this.hourAm = !this.hourAm;
|
||||
this.hourInput = '1'
|
||||
this.hourAm = !this.hourAm
|
||||
} else {
|
||||
this.hourInput = hourNum.toString();
|
||||
this.hourInput = hourNum.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private checkMinuteInput() {
|
||||
const minuteNum = parseInt(this.minuteInput);
|
||||
const minuteNum = parseInt(this.minuteInput)
|
||||
if (isNaN(minuteNum)) {
|
||||
this.minuteInput = "00";
|
||||
this.minuteInput = '00'
|
||||
} else if (minuteNum < 0) {
|
||||
this.minuteInput = "59";
|
||||
this.minuteInput = '59'
|
||||
} else if (minuteNum < 10) {
|
||||
this.minuteInput = "0" + minuteNum;
|
||||
this.minuteInput = '0' + minuteNum
|
||||
} else if (minuteNum > 59) {
|
||||
this.minuteInput = "00";
|
||||
this.minuteInput = '00'
|
||||
} else {
|
||||
this.minuteInput = minuteNum.toString();
|
||||
this.minuteInput = minuteNum.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private async turnOnNotifications() {
|
||||
let notifyCloser = () => {};
|
||||
let notifyCloser = () => {}
|
||||
return this.askPermission()
|
||||
.then((permission) => {
|
||||
logConsoleAndDb("Permission granted: " + JSON.stringify(permission));
|
||||
logConsoleAndDb('Permission granted: ' + JSON.stringify(permission))
|
||||
|
||||
// Call the function and handle promises
|
||||
return this.subscribeToPush();
|
||||
return this.subscribeToPush()
|
||||
})
|
||||
.then(() => {
|
||||
logConsoleAndDb("Subscribed successfully.");
|
||||
return navigator.serviceWorker?.ready;
|
||||
logConsoleAndDb('Subscribed successfully.')
|
||||
return navigator.serviceWorker?.ready
|
||||
})
|
||||
.then((registration) => {
|
||||
return registration.pushManager.getSubscription();
|
||||
return registration.pushManager.getSubscription()
|
||||
})
|
||||
.then(async (subscription) => {
|
||||
if (subscription) {
|
||||
notifyCloser = await this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Notification Setup Underway",
|
||||
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
|
||||
group: 'alert',
|
||||
type: 'info',
|
||||
title: 'Notification Setup Underway',
|
||||
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page."
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
// we already checked that this is a valid hour number
|
||||
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
|
||||
const rawHourNum = libsUtil.numberOrZero(this.hourInput)
|
||||
const adjHourNum = this.hourAm
|
||||
? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum
|
||||
rawHourNum === 12
|
||||
@@ -421,153 +421,153 @@ export default class PushNotificationPermission extends Vue {
|
||||
: // Otherwise it's PM, so keep a 12 but otherwise add 12
|
||||
rawHourNum === 12
|
||||
? 12
|
||||
: rawHourNum + 12;
|
||||
const hourNum = adjHourNum % 24; // probably unnecessary now
|
||||
: rawHourNum + 12
|
||||
const hourNum = adjHourNum % 24 // probably unnecessary now
|
||||
const utcHour =
|
||||
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
|
||||
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
|
||||
const minuteNum = libsUtil.numberOrZero(this.minuteInput);
|
||||
hourNum + Math.round(new Date().getTimezoneOffset() / 60)
|
||||
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24
|
||||
const minuteNum = libsUtil.numberOrZero(this.minuteInput)
|
||||
const utcMinute =
|
||||
minuteNum + Math.round(new Date().getTimezoneOffset() % 60);
|
||||
const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60;
|
||||
minuteNum + Math.round(new Date().getTimezoneOffset() % 60)
|
||||
const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60
|
||||
|
||||
const subscriptionWithTime: PushSubscriptionWithTime = {
|
||||
notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute },
|
||||
notifyType: this.pushType,
|
||||
message: this.messageInput,
|
||||
...subscription.toJSON(),
|
||||
};
|
||||
await this.sendSubscriptionToServer(subscriptionWithTime);
|
||||
...subscription.toJSON()
|
||||
}
|
||||
await this.sendSubscriptionToServer(subscriptionWithTime)
|
||||
// To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1
|
||||
logConsoleAndDb(
|
||||
"Subscription data sent to server with endpoint: " +
|
||||
subscription.endpoint,
|
||||
);
|
||||
return subscriptionWithTime;
|
||||
'Subscription data sent to server with endpoint: ' +
|
||||
subscription.endpoint
|
||||
)
|
||||
return subscriptionWithTime
|
||||
} else {
|
||||
throw new Error("Subscription object is not available.");
|
||||
throw new Error('Subscription object is not available.')
|
||||
}
|
||||
})
|
||||
.then(async (subscription: PushSubscriptionWithTime) => {
|
||||
logConsoleAndDb(
|
||||
"Subscription data sent to server and all finished successfully.",
|
||||
);
|
||||
await libsUtil.sendTestThroughPushServer(subscription, true);
|
||||
notifyCloser();
|
||||
'Subscription data sent to server and all finished successfully.'
|
||||
)
|
||||
await libsUtil.sendTestThroughPushServer(subscription, true)
|
||||
notifyCloser()
|
||||
setTimeout(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Notification Is On",
|
||||
text: "You should see at least one on your device; if not, check the 'Troubleshoot' link.",
|
||||
group: 'alert',
|
||||
type: 'success',
|
||||
title: 'Notification Is On',
|
||||
text: "You should see at least one on your device; if not, check the 'Troubleshoot' link."
|
||||
},
|
||||
7000,
|
||||
);
|
||||
}, 500);
|
||||
7000
|
||||
)
|
||||
}, 500)
|
||||
const timeText =
|
||||
// eslint-disable-next-line
|
||||
this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM");
|
||||
this.callback(true, timeText, this.messageInput);
|
||||
this.callback(true, timeText, this.messageInput)
|
||||
})
|
||||
.catch((error) => {
|
||||
logConsoleAndDb(
|
||||
"Got an error setting notification permissions: " +
|
||||
" string " +
|
||||
'Got an error setting notification permissions: ' +
|
||||
' string ' +
|
||||
error.toString() +
|
||||
" JSON " +
|
||||
' JSON ' +
|
||||
JSON.stringify(error),
|
||||
true,
|
||||
);
|
||||
true
|
||||
)
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Notification Permissions",
|
||||
text: "Could not set notification permissions.",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error Setting Notification Permissions',
|
||||
text: 'Could not set notification permissions.'
|
||||
},
|
||||
3000,
|
||||
);
|
||||
3000
|
||||
)
|
||||
// if we want to also unsubscribe, be sure to do that only if no other notification is active
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
private subscribeToPush(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!("serviceWorker" in navigator && "PushManager" in window)) {
|
||||
const errorMsg = "Push messaging is not supported";
|
||||
logger.warn(errorMsg);
|
||||
return reject(new Error(errorMsg));
|
||||
if (!('serviceWorker' in navigator && 'PushManager' in window)) {
|
||||
const errorMsg = 'Push messaging is not supported'
|
||||
logger.warn(errorMsg)
|
||||
return reject(new Error(errorMsg))
|
||||
}
|
||||
|
||||
if (window.Notification.permission !== "granted") {
|
||||
const errorMsg = "Notification permission not granted";
|
||||
logger.warn(errorMsg);
|
||||
return reject(new Error(errorMsg));
|
||||
if (window.Notification.permission !== 'granted') {
|
||||
const errorMsg = 'Notification permission not granted'
|
||||
logger.warn(errorMsg)
|
||||
return reject(new Error(errorMsg))
|
||||
}
|
||||
|
||||
const applicationServerKey = urlBase64ToUint8Array(this.vapidKey);
|
||||
const applicationServerKey = urlBase64ToUint8Array(this.vapidKey)
|
||||
const options: PushSubscriptionOptions = {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey,
|
||||
};
|
||||
applicationServerKey: applicationServerKey
|
||||
}
|
||||
|
||||
navigator.serviceWorker?.ready
|
||||
.then((registration) => {
|
||||
return registration.pushManager.subscribe(options);
|
||||
return registration.pushManager.subscribe(options)
|
||||
})
|
||||
.then((subscription) => {
|
||||
logConsoleAndDb(
|
||||
"Push subscription successful: " + JSON.stringify(subscription),
|
||||
);
|
||||
resolve();
|
||||
'Push subscription successful: ' + JSON.stringify(subscription)
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
.catch((error) => {
|
||||
logConsoleAndDb(
|
||||
"Push subscription failed: " +
|
||||
'Push subscription failed: ' +
|
||||
JSON.stringify(error) +
|
||||
" - " +
|
||||
' - ' +
|
||||
JSON.stringify(options),
|
||||
true,
|
||||
);
|
||||
true
|
||||
)
|
||||
|
||||
// Inform the user about the issue
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Push Notifications",
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error Setting Push Notifications',
|
||||
text:
|
||||
"We encountered an issue setting up push notifications. " +
|
||||
"If you wish to revoke notification permissions, please do so in your browser settings.",
|
||||
'We encountered an issue setting up push notifications. ' +
|
||||
'If you wish to revoke notification permissions, please do so in your browser settings.'
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private sendSubscriptionToServer(
|
||||
subscription: PushSubscriptionWithTime,
|
||||
subscription: PushSubscriptionWithTime
|
||||
): Promise<void> {
|
||||
logConsoleAndDb(
|
||||
"About to send subscription... " + JSON.stringify(subscription),
|
||||
);
|
||||
return fetch("/web-push/subscribe", {
|
||||
method: "POST",
|
||||
'About to send subscription... ' + JSON.stringify(subscription)
|
||||
)
|
||||
return fetch('/web-push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(subscription),
|
||||
body: JSON.stringify(subscription)
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
logger.error("Bad response subscribing to web push: ", response);
|
||||
throw new Error("Failed to send push subscription to server");
|
||||
logger.error('Bad response subscribing to web push: ', response)
|
||||
throw new Error('Failed to send push subscription to server')
|
||||
}
|
||||
logConsoleAndDb("Push subscription sent to server successfully.");
|
||||
});
|
||||
logConsoleAndDb('Push subscription sent to server successfully.')
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
BarcodeScanner,
|
||||
BarcodeFormat,
|
||||
LensFacing,
|
||||
ScanResult
|
||||
} from '@capacitor-mlkit/barcode-scanning'
|
||||
import type { QRScannerService, ScanListener } from './types'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
export class CapacitorQRScanner implements QRScannerService {
|
||||
private scanListener: ScanListener | null = null
|
||||
private isScanning = false
|
||||
private listenerHandles: Array<() => Promise<void>> = []
|
||||
|
||||
async checkPermissions() {
|
||||
try {
|
||||
const { camera } = await BarcodeScanner.checkPermissions()
|
||||
return camera === 'granted'
|
||||
} catch (error) {
|
||||
logger.error('Error checking camera permissions:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async requestPermissions() {
|
||||
try {
|
||||
const { camera } = await BarcodeScanner.requestPermissions()
|
||||
return camera === 'granted'
|
||||
} catch (error) {
|
||||
logger.error('Error requesting camera permissions:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async isSupported() {
|
||||
try {
|
||||
const { supported } = await BarcodeScanner.isSupported()
|
||||
return supported
|
||||
} catch (error) {
|
||||
logger.error('Error checking barcode scanner support:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async startScan() {
|
||||
if (this.isScanning) {
|
||||
logger.warn('Scanner is already active')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// First register listeners before starting scan
|
||||
await this.registerListeners()
|
||||
|
||||
this.isScanning = true
|
||||
await BarcodeScanner.startScan({
|
||||
formats: [BarcodeFormat.QrCode],
|
||||
lensFacing: LensFacing.Back
|
||||
})
|
||||
} catch (error) {
|
||||
// Ensure cleanup on error
|
||||
this.isScanning = false
|
||||
await this.removeListeners()
|
||||
logger.error('Error starting barcode scan:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async registerListeners() {
|
||||
try {
|
||||
const handle = await BarcodeScanner.addListener(
|
||||
'barcodesScanned',
|
||||
async (result: ScanResult) => {
|
||||
if (result.barcodes.length > 0 && this.scanListener) {
|
||||
const barcode = result.barcodes[0]
|
||||
this.scanListener.onScan(barcode.rawValue)
|
||||
await this.stopScan()
|
||||
}
|
||||
}
|
||||
)
|
||||
this.listenerHandles.push(() => handle.remove())
|
||||
} catch (error) {
|
||||
logger.error('Error registering barcode listener:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async removeListeners() {
|
||||
for (const remove of this.listenerHandles) {
|
||||
try {
|
||||
await remove()
|
||||
} catch (error) {
|
||||
logger.error('Error removing listener:', error)
|
||||
}
|
||||
}
|
||||
this.listenerHandles = []
|
||||
}
|
||||
|
||||
async stopScan() {
|
||||
if (!this.isScanning) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// First stop the scan
|
||||
await BarcodeScanner.stopScan()
|
||||
} catch (error) {
|
||||
logger.error('Error stopping barcode scan:', error)
|
||||
} finally {
|
||||
// Always cleanup state even if stop fails
|
||||
this.isScanning = false
|
||||
await this.removeListeners()
|
||||
}
|
||||
}
|
||||
|
||||
addListener(listener: ScanListener): void {
|
||||
this.scanListener = listener
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.stopScan()
|
||||
this.scanListener = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div class="dialog relative">
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div
|
||||
id="ViewHeading"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||
>
|
||||
<span v-if="state.isProcessing">{{ state.processingStatus }}</span>
|
||||
<span v-else>Scan QR Code</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white cursor-pointer"
|
||||
@click="close()"
|
||||
>
|
||||
<font-awesome icon="xmark" class="w-[1em]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<!-- Web QR Code Scanner -->
|
||||
<qrcode-stream
|
||||
v-if="useQRReader"
|
||||
class="w-full max-w-lg mx-auto"
|
||||
@detect="onScanDetect"
|
||||
@error="onScanError"
|
||||
/>
|
||||
|
||||
<!-- Mobile Camera Button -->
|
||||
<div v-else class="text-center mt-4">
|
||||
<button
|
||||
v-if="!state.isProcessing"
|
||||
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 px-4 py-2 rounded-md"
|
||||
@click="openMobileCamera"
|
||||
>
|
||||
Open Camera
|
||||
</button>
|
||||
<div v-else class="text-center">
|
||||
<font-awesome icon="spinner" class="fa-spin fa-3x" />
|
||||
<p class="mt-2">
|
||||
{{ state.processingDetails }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="state.error" class="mt-4 text-red-500 text-center">
|
||||
{{ state.error }}
|
||||
</p>
|
||||
|
||||
<p class="mt-4 text-sm text-gray-600 text-center">
|
||||
Position the QR code within the camera view to scan
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from 'vue-facing-decorator'
|
||||
import { QrcodeStream } from 'vue-qrcode-reader'
|
||||
import { reactive } from 'vue'
|
||||
import {
|
||||
BarcodeScanner,
|
||||
type ScanResult
|
||||
} from '@capacitor-mlkit/barcode-scanning'
|
||||
import type { PluginListenerHandle } from '@capacitor/core'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { NotificationIface } from '../../constants/app'
|
||||
|
||||
// Declare global constants
|
||||
declare const __USE_QR_READER__: boolean
|
||||
declare const __IS_MOBILE__: boolean
|
||||
|
||||
interface AppState {
|
||||
isProcessing: boolean
|
||||
processingStatus: string
|
||||
processingDetails: string
|
||||
error: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QrcodeStream
|
||||
}
|
||||
})
|
||||
export default class QRScannerDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
visible = false
|
||||
private scanListener: PluginListenerHandle | null = null
|
||||
private onScanCallback: ((result: string) => void) | null = null
|
||||
|
||||
state = reactive<AppState>({
|
||||
isProcessing: false,
|
||||
processingStatus: '',
|
||||
processingDetails: '',
|
||||
error: ''
|
||||
})
|
||||
|
||||
async open(onScan: (result: string) => void) {
|
||||
this.onScanCallback = onScan
|
||||
this.visible = true
|
||||
this.state.error = ''
|
||||
|
||||
if (!this.useQRReader) {
|
||||
// Check if barcode scanning is supported on mobile
|
||||
try {
|
||||
const { supported } = await BarcodeScanner.isSupported()
|
||||
if (!supported) {
|
||||
this.showError('Barcode scanning is not supported on this device')
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError('Failed to check barcode scanner support')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false
|
||||
this.stopScanning().catch((error) => {
|
||||
logger.error('Error stopping scanner during close:', error)
|
||||
})
|
||||
this.onScanCallback = null
|
||||
}
|
||||
|
||||
async openMobileCamera() {
|
||||
try {
|
||||
this.state.isProcessing = true
|
||||
this.state.processingStatus = 'Starting camera...'
|
||||
logger.log('Opening mobile camera - starting initialization')
|
||||
|
||||
// Check current permission status
|
||||
const status = await BarcodeScanner.checkPermissions()
|
||||
logger.log('Camera permission status:', JSON.stringify(status, null, 2))
|
||||
|
||||
if (status.camera !== 'granted') {
|
||||
// Request permission if not granted
|
||||
logger.log('Requesting camera permissions...')
|
||||
const permissionStatus = await BarcodeScanner.requestPermissions()
|
||||
if (permissionStatus.camera !== 'granted') {
|
||||
throw new Error('Camera permission not granted')
|
||||
}
|
||||
logger.log(
|
||||
'Camera permission granted:',
|
||||
JSON.stringify(permissionStatus, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
// Remove any existing listener first
|
||||
await this.cleanupScanListener()
|
||||
|
||||
// Set up the listener before starting the scan
|
||||
logger.log('Setting up new barcode listener')
|
||||
this.scanListener = await BarcodeScanner.addListener(
|
||||
'barcodesScanned',
|
||||
async (result: ScanResult) => {
|
||||
logger.log(
|
||||
'Barcode scan result received:',
|
||||
JSON.stringify(result, null, 2)
|
||||
)
|
||||
if (result.barcodes && result.barcodes.length > 0) {
|
||||
this.state.processingDetails = `Processing QR code: ${result.barcodes[0].rawValue}`
|
||||
await this.handleScanResult(result.barcodes[0].rawValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
logger.log('Barcode listener setup complete')
|
||||
|
||||
// Start the scanner
|
||||
logger.log('Starting barcode scanner')
|
||||
await BarcodeScanner.startScan()
|
||||
logger.log('Barcode scanner started successfully')
|
||||
|
||||
this.state.isProcessing = false
|
||||
this.state.processingStatus = ''
|
||||
} catch (error) {
|
||||
logger.error('Failed to open camera:', error)
|
||||
this.state.isProcessing = false
|
||||
this.state.processingStatus = ''
|
||||
this.showError(
|
||||
error instanceof Error ? error.message : 'Failed to open camera'
|
||||
)
|
||||
|
||||
// Cleanup on error
|
||||
await this.cleanupScanListener()
|
||||
}
|
||||
}
|
||||
|
||||
private async handleScanResult(rawValue: string) {
|
||||
try {
|
||||
this.state.isProcessing = true
|
||||
this.state.processingStatus = 'Processing QR code...'
|
||||
this.state.processingDetails = `Scanned value: ${rawValue}`
|
||||
|
||||
// Stop scanning before processing
|
||||
await this.stopScanning()
|
||||
|
||||
if (this.onScanCallback) {
|
||||
await this.onScanCallback(rawValue)
|
||||
// Only close after the callback is complete
|
||||
this.close()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling scan result:', error)
|
||||
this.showError('Failed to process scan result')
|
||||
} finally {
|
||||
this.state.isProcessing = false
|
||||
this.state.processingStatus = ''
|
||||
this.state.processingDetails = ''
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupScanListener() {
|
||||
try {
|
||||
if (this.scanListener) {
|
||||
await this.scanListener.remove()
|
||||
this.scanListener = null
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error removing scan listener:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async stopScanning() {
|
||||
try {
|
||||
await this.cleanupScanListener()
|
||||
|
||||
if (!this.useQRReader) {
|
||||
// Stop the native scanner
|
||||
await BarcodeScanner.stopScan()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error stopping scanner:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Web QR reader handlers
|
||||
async onScanDetect(result: { rawValue: string }) {
|
||||
await this.handleScanResult(result.rawValue)
|
||||
}
|
||||
|
||||
onScanError(error: Error) {
|
||||
logger.error('Scan error:', error)
|
||||
this.showError('Failed to scan QR code')
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
this.state.error = message
|
||||
this.$notify(
|
||||
{
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error',
|
||||
text: message
|
||||
},
|
||||
5000
|
||||
)
|
||||
}
|
||||
|
||||
get useQRReader(): boolean {
|
||||
return __USE_QR_READER__
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return __IS_MOBILE__
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dialog-overlay {
|
||||
z-index: 60;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
position: relative;
|
||||
z-index: 61;
|
||||
}
|
||||
|
||||
/* Add styles for the camera preview */
|
||||
.qrcode-stream {
|
||||
position: relative;
|
||||
z-index: 62;
|
||||
}
|
||||
|
||||
.qrcode-stream video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Ensure mobile camera elements are also properly layered */
|
||||
.barcode-scanner-container {
|
||||
position: relative;
|
||||
z-index: 62;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { QRScannerService, ScanListener } from './types'
|
||||
import QRScannerDialog from './QRScannerDialog.vue'
|
||||
import { createApp, type App } from 'vue'
|
||||
import { logger } from '../../utils/logger'
|
||||
|
||||
// Import platform-specific flags from Vite config
|
||||
declare const __USE_QR_READER__: boolean
|
||||
|
||||
export class WebDialogQRScanner implements QRScannerService {
|
||||
private dialogApp: App | null = null
|
||||
private dialogElement: HTMLDivElement | null = null
|
||||
private scanListener: ScanListener | null = null
|
||||
|
||||
async checkPermissions(): Promise<boolean> {
|
||||
return navigator?.mediaDevices !== undefined
|
||||
}
|
||||
|
||||
async requestPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to get camera permissions:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async isSupported(): Promise<boolean> {
|
||||
return Promise.resolve(
|
||||
__USE_QR_READER__ && navigator?.mediaDevices !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
async startScan(): Promise<void> {
|
||||
if (!(await this.isSupported())) {
|
||||
throw new Error('QR scanning is not supported in this environment')
|
||||
}
|
||||
|
||||
this.dialogElement = document.createElement('div')
|
||||
document.body.appendChild(this.dialogElement)
|
||||
|
||||
this.dialogApp = createApp(QRScannerDialog, {
|
||||
onScan: (result: string) => {
|
||||
if (this.scanListener) {
|
||||
this.scanListener.onScan(result)
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
this.stopScan()
|
||||
}
|
||||
})
|
||||
|
||||
this.dialogApp.mount(this.dialogElement)
|
||||
}
|
||||
|
||||
async stopScan(): Promise<void> {
|
||||
if (this.dialogApp && this.dialogElement) {
|
||||
this.dialogApp.unmount()
|
||||
this.dialogElement.remove()
|
||||
this.dialogApp = null
|
||||
this.dialogElement = null
|
||||
}
|
||||
}
|
||||
|
||||
addListener(listener: ScanListener): void {
|
||||
this.scanListener = listener
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.stopScan()
|
||||
this.scanListener = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import type { QRScannerService } from './types'
|
||||
import { logger } from '../../utils/logger'
|
||||
import { WebDialogQRScanner } from './WebDialogScanner'
|
||||
import { CapacitorQRScanner } from './CapacitorScanner'
|
||||
|
||||
// Import platform-specific flags from Vite config
|
||||
declare const __USE_QR_READER__: boolean
|
||||
declare const __IS_MOBILE__: boolean
|
||||
|
||||
export class QRScannerFactory {
|
||||
private static instance: QRScannerService | null = null
|
||||
|
||||
static getInstance(): QRScannerService {
|
||||
if (!this.instance) {
|
||||
// Use platform-specific flags for more accurate detection
|
||||
if (__IS_MOBILE__ || Capacitor.isNativePlatform()) {
|
||||
logger.log('Creating native QR scanner instance')
|
||||
this.instance = new CapacitorQRScanner()
|
||||
} else if (__USE_QR_READER__) {
|
||||
logger.log('Creating web QR scanner instance')
|
||||
this.instance = new WebDialogQRScanner()
|
||||
} else {
|
||||
throw new Error(
|
||||
'No QR scanner implementation available for this platform'
|
||||
)
|
||||
}
|
||||
}
|
||||
return this.instance
|
||||
}
|
||||
|
||||
static async cleanup() {
|
||||
if (this.instance) {
|
||||
await this.instance.cleanup()
|
||||
this.instance = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface ScanListener {
|
||||
onScan: (result: string) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export interface QRScannerService {
|
||||
checkPermissions(): Promise<boolean>
|
||||
requestPermissions(): Promise<boolean>
|
||||
isSupported(): Promise<boolean>
|
||||
startScan(): Promise<void>
|
||||
stopScan(): Promise<void>
|
||||
addListener(listener: ScanListener): void
|
||||
cleanup(): Promise<void>
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav
|
||||
id="QuickNav"
|
||||
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl px-6 py-2 gap-1 max-w-3xl mx-auto">
|
||||
<!-- Home Feed -->
|
||||
<li
|
||||
@@ -11,7 +8,7 @@
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Home',
|
||||
'text-slate-500': selected !== 'Home',
|
||||
'text-slate-500': selected !== 'Home'
|
||||
}"
|
||||
>
|
||||
<router-link :to="{ name: 'home' }" class="block text-center py-2 px-1">
|
||||
@@ -27,7 +24,7 @@
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Discover',
|
||||
'text-slate-500': selected !== 'Discover',
|
||||
'text-slate-500': selected !== 'Discover'
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
@@ -46,7 +43,7 @@
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Projects',
|
||||
'text-slate-500': selected !== 'Projects',
|
||||
'text-slate-500': selected !== 'Projects'
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
@@ -65,7 +62,7 @@
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Contacts',
|
||||
'text-slate-500': selected !== 'Contacts',
|
||||
'text-slate-500': selected !== 'Contacts'
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
@@ -84,7 +81,7 @@
|
||||
'basis-1/5': true,
|
||||
'rounded-md': true,
|
||||
'bg-slate-400 text-white': selected === 'Profile',
|
||||
'text-slate-500': selected !== 'Profile',
|
||||
'text-slate-500': selected !== 'Profile'
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
@@ -109,10 +106,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { Component, Vue, Prop } from 'vue-facing-decorator'
|
||||
|
||||
@Component
|
||||
export default class QuickNav extends Vue {
|
||||
@Prop selected = "";
|
||||
@Prop selected = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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-3">
|
||||
<span class="align-center text-red-500 mr-2">{{ message }}</span>
|
||||
<span class="ml-2">
|
||||
<router-link
|
||||
@@ -13,46 +13,46 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { Component, Vue, Prop } from 'vue-facing-decorator'
|
||||
|
||||
import { AppString, NotificationIface } from "../constants/app";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { AppString, NotificationIface } from '../constants/app'
|
||||
import { retrieveSettingsForActiveAccount } from '../db/index'
|
||||
|
||||
@Component
|
||||
export default class TopMessage extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
@Prop selected = "";
|
||||
@Prop selected = ''
|
||||
|
||||
message = "";
|
||||
message = ''
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
if (
|
||||
settings.warnIfTestServer &&
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message = "You're linked to a non-prod server, user " + didPrefix;
|
||||
const didPrefix = settings.activeDid?.slice(11, 15)
|
||||
this.message = "You're linked to a non-prod server, user " + didPrefix
|
||||
} else if (
|
||||
settings.warnIfProdServer &&
|
||||
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
const didPrefix = settings.activeDid?.slice(11, 15)
|
||||
this.message =
|
||||
"You're linked to the production server, user " + didPrefix;
|
||||
"You're linked to the production server, user " + didPrefix
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Detecting Server",
|
||||
text: JSON.stringify(err),
|
||||
group: 'alert',
|
||||
type: 'danger',
|
||||
title: 'Error Detecting Server',
|
||||
text: JSON.stringify(err)
|
||||
},
|
||||
-1,
|
||||
);
|
||||
-1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,49 +35,49 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { Vue, Component, Prop } from 'vue-facing-decorator'
|
||||
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
|
||||
import { NotificationIface } from '../constants/app'
|
||||
import { db, retrieveSettingsForActiveAccount } from '../db/index'
|
||||
import { MASTER_SETTINGS_KEY } from '../db/tables/settings'
|
||||
|
||||
@Component
|
||||
export default class UserNameDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void
|
||||
|
||||
@Prop({
|
||||
default:
|
||||
"This is not sent to servers. It is only shared with people when you send it to them.",
|
||||
'This is not sent to servers. It is only shared with people when you send it to them.'
|
||||
})
|
||||
sharingExplanation!: string;
|
||||
@Prop({ default: false }) callbackOnCancel!: boolean;
|
||||
sharingExplanation!: string
|
||||
@Prop({ default: false }) callbackOnCancel!: boolean
|
||||
|
||||
callback: (name?: string) => void = () => {};
|
||||
givenName = "";
|
||||
visible = false;
|
||||
callback: (name?: string) => void = () => {}
|
||||
givenName = ''
|
||||
visible = false
|
||||
|
||||
/**
|
||||
* @param aCallback - callback function for name, which may be ""
|
||||
*/
|
||||
async open(aCallback?: (name?: string) => void) {
|
||||
this.callback = aCallback || this.callback;
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.givenName = settings.firstName || "";
|
||||
this.visible = true;
|
||||
this.callback = aCallback || this.callback
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
this.givenName = settings.firstName || ''
|
||||
this.visible = true
|
||||
}
|
||||
|
||||
async onClickSaveChanges() {
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
firstName: this.givenName,
|
||||
});
|
||||
this.visible = false;
|
||||
this.callback(this.givenName);
|
||||
firstName: this.givenName
|
||||
})
|
||||
this.visible = false
|
||||
this.callback(this.givenName)
|
||||
}
|
||||
|
||||
onClickCancel() {
|
||||
this.visible = false;
|
||||
this.visible = false
|
||||
if (this.callbackOnCancel) {
|
||||
this.callback();
|
||||
this.callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
// from https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80
|
||||
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import * as THREE from "three";
|
||||
import * as TWEEN from '@tweenjs/tween.js'
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { createCamera } from "./components/camera.js";
|
||||
import { createLights } from "./components/lights.js";
|
||||
import { createScene } from "./components/scene.js";
|
||||
import { loadLandmarks } from "./components/objects/landmarks.js";
|
||||
import { createTerrain } from "./components/objects/terrain.js";
|
||||
import { Loop } from "./systems/Loop.js";
|
||||
import { Resizer } from "./systems/Resizer.js";
|
||||
import { createControls } from "./systems/controls.js";
|
||||
import { createRenderer } from "./systems/renderer.js";
|
||||
import { createCamera } from './components/camera.js'
|
||||
import { createLights } from './components/lights.js'
|
||||
import { createScene } from './components/scene.js'
|
||||
import { loadLandmarks } from './components/objects/landmarks.js'
|
||||
import { createTerrain } from './components/objects/terrain.js'
|
||||
import { Loop } from './systems/Loop.js'
|
||||
import { Resizer } from './systems/Resizer.js'
|
||||
import { createControls } from './systems/controls.js'
|
||||
import { createRenderer } from './systems/renderer.js'
|
||||
|
||||
const COLOR1 = "#dddddd";
|
||||
const COLOR2 = "#0055aa";
|
||||
const COLOR1 = '#dddddd'
|
||||
const COLOR2 = '#0055aa'
|
||||
|
||||
class World {
|
||||
constructor(container, vue) {
|
||||
this.PLATFORM_BORDER = 5;
|
||||
this.PLATFORM_EDGE_FOR_UNKNOWNS = 10;
|
||||
this.PLATFORM_SIZE = 100; // note that the loadLandmarks calculations may still assume 100
|
||||
this.PLATFORM_BORDER = 5
|
||||
this.PLATFORM_EDGE_FOR_UNKNOWNS = 10
|
||||
this.PLATFORM_SIZE = 100 // note that the loadLandmarks calculations may still assume 100
|
||||
|
||||
this.update = this.update.bind(this);
|
||||
this.update = this.update.bind(this)
|
||||
|
||||
this.vue = vue;
|
||||
this.vue = vue
|
||||
|
||||
// Instances of camera, scene, and renderer
|
||||
this.camera = createCamera();
|
||||
this.scene = createScene(COLOR2);
|
||||
this.renderer = createRenderer();
|
||||
this.camera = createCamera()
|
||||
this.scene = createScene(COLOR2)
|
||||
this.renderer = createRenderer()
|
||||
|
||||
// necessary for models, says https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
|
||||
this.light = null;
|
||||
this.lights = [];
|
||||
this.bushes = [];
|
||||
this.light = null
|
||||
this.lights = []
|
||||
this.bushes = []
|
||||
|
||||
// Initialize Loop
|
||||
this.loop = new Loop(this.camera, this.scene, this.renderer);
|
||||
this.loop = new Loop(this.camera, this.scene, this.renderer)
|
||||
|
||||
container.append(this.renderer.domElement);
|
||||
container.append(this.renderer.domElement)
|
||||
|
||||
// Orbit Controls
|
||||
const controls = createControls(this.camera, this.renderer.domElement);
|
||||
const controls = createControls(this.camera, this.renderer.domElement)
|
||||
|
||||
// Light Instance, with optional light helper
|
||||
const { light } = createLights(COLOR1);
|
||||
const { light } = createLights(COLOR1)
|
||||
|
||||
// Terrain Instance
|
||||
const terrain = createTerrain({
|
||||
@@ -56,55 +56,55 @@ class World {
|
||||
width:
|
||||
this.PLATFORM_SIZE +
|
||||
this.PLATFORM_BORDER * 2 +
|
||||
this.PLATFORM_EDGE_FOR_UNKNOWNS * 2,
|
||||
});
|
||||
this.PLATFORM_EDGE_FOR_UNKNOWNS * 2
|
||||
})
|
||||
|
||||
this.loop.updatables.push(controls);
|
||||
this.loop.updatables.push(light);
|
||||
this.loop.updatables.push(terrain);
|
||||
this.loop.updatables.push(controls)
|
||||
this.loop.updatables.push(light)
|
||||
this.loop.updatables.push(terrain)
|
||||
|
||||
this.scene.add(light, terrain);
|
||||
this.scene.add(light, terrain)
|
||||
|
||||
loadLandmarks(vue, this, this.scene, this.loop);
|
||||
loadLandmarks(vue, this, this.scene, this.loop)
|
||||
|
||||
requestAnimationFrame(this.update);
|
||||
requestAnimationFrame(this.update)
|
||||
|
||||
// Responsive handler
|
||||
const resizer = new Resizer(container, this.camera, this.renderer);
|
||||
const resizer = new Resizer(container, this.camera, this.renderer)
|
||||
resizer.onResize = () => {
|
||||
this.render();
|
||||
};
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
update(time) {
|
||||
TWEEN.update(time);
|
||||
TWEEN.update(time)
|
||||
this.lights.forEach((light) => {
|
||||
light.updateMatrixWorld();
|
||||
light.target.updateMatrixWorld();
|
||||
});
|
||||
light.updateMatrixWorld()
|
||||
light.target.updateMatrixWorld()
|
||||
})
|
||||
this.lights.forEach((bush) => {
|
||||
bush.updateMatrixWorld();
|
||||
});
|
||||
requestAnimationFrame(this.update);
|
||||
bush.updateMatrixWorld()
|
||||
})
|
||||
requestAnimationFrame(this.update)
|
||||
}
|
||||
|
||||
render() {
|
||||
// draw a single frame
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
}
|
||||
|
||||
// Animation handlers
|
||||
start() {
|
||||
this.loop.start();
|
||||
this.loop.start()
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.loop.stop();
|
||||
this.loop.stop()
|
||||
}
|
||||
|
||||
setExposedWorldProperties(key, value) {
|
||||
this.vue.setWorldProperty(key, value);
|
||||
this.vue.setWorldProperty(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export { World };
|
||||
export { World }
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { PerspectiveCamera } from "three";
|
||||
import { PerspectiveCamera } from 'three'
|
||||
|
||||
function createCamera() {
|
||||
const camera = new PerspectiveCamera(
|
||||
35, // fov = Field Of View
|
||||
1, // aspect ratio (dummy value)
|
||||
0.1, // near clipping plane
|
||||
350, // far clipping plane
|
||||
);
|
||||
350 // far clipping plane
|
||||
)
|
||||
|
||||
// move the camera back so we can view the scene
|
||||
camera.position.set(0, 100, 200);
|
||||
camera.position.set(0, 100, 200)
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
camera.tick = () => {};
|
||||
camera.tick = () => {}
|
||||
|
||||
return camera;
|
||||
return camera
|
||||
}
|
||||
|
||||
export { createCamera };
|
||||
export { createCamera }
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { DirectionalLight, DirectionalLightHelper } from "three";
|
||||
import { DirectionalLight, DirectionalLightHelper } from 'three'
|
||||
|
||||
function createLights(color) {
|
||||
const light = new DirectionalLight(color, 4);
|
||||
const lightHelper = new DirectionalLightHelper(light, 0);
|
||||
light.position.set(60, 100, 30);
|
||||
const light = new DirectionalLight(color, 4)
|
||||
const lightHelper = new DirectionalLightHelper(light, 0)
|
||||
light.position.set(60, 100, 30)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
light.tick = () => {};
|
||||
light.tick = () => {}
|
||||
|
||||
return { light, lightHelper };
|
||||
return { light, lightHelper }
|
||||
}
|
||||
|
||||
export { createLights };
|
||||
export { createLights }
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
import axios from "axios";
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
|
||||
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
|
||||
import * as TWEEN from "@tweenjs/tween.js";
|
||||
import { retrieveSettingsForActiveAccount } from "../../../../db";
|
||||
import { getHeaders } from "../../../../libs/endorserServer";
|
||||
import { logger } from "../../../../utils/logger";
|
||||
import axios from 'axios'
|
||||
import * as THREE from 'three'
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader'
|
||||
import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils'
|
||||
import * as TWEEN from '@tweenjs/tween.js'
|
||||
import { retrieveSettingsForActiveAccount } from '../../../../db'
|
||||
import { getHeaders } from '../../../../libs/endorserServer'
|
||||
import { logger } from '../../../../utils/logger'
|
||||
|
||||
const ANIMATION_DURATION_SECS = 10;
|
||||
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
|
||||
const ANIMATION_DURATION_SECS = 10
|
||||
const ENDORSER_ENTITY_PREFIX = 'https://endorser.ch/entity/'
|
||||
|
||||
export async function loadLandmarks(vue, world, scene, loop) {
|
||||
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
|
||||
vue.setWorldProperty('animationDurationSeconds', ANIMATION_DURATION_SECS)
|
||||
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
const activeDid = settings.activeDid || "";
|
||||
const apiServer = settings.apiServer;
|
||||
const headers = await getHeaders(activeDid);
|
||||
const settings = await retrieveSettingsForActiveAccount()
|
||||
const activeDid = settings.activeDid || ''
|
||||
const apiServer = settings.apiServer
|
||||
const headers = await getHeaders(activeDid)
|
||||
|
||||
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
|
||||
const resp = await axios.get(url, { headers: headers });
|
||||
const url = apiServer + '/api/v2/report/claims?claimType=GiveAction'
|
||||
const resp = await axios.get(url, { headers: headers })
|
||||
if (resp.status === 200) {
|
||||
const landmarks = resp.data.data;
|
||||
const landmarks = resp.data.data
|
||||
|
||||
const minDate = landmarks[landmarks.length - 1].issuedAt;
|
||||
const maxDate = landmarks[0].issuedAt;
|
||||
const minDate = landmarks[landmarks.length - 1].issuedAt
|
||||
const maxDate = landmarks[0].issuedAt
|
||||
|
||||
world.setExposedWorldProperties("startTime", minDate.replace("T", " "));
|
||||
world.setExposedWorldProperties("endTime", maxDate.replace("T", " "));
|
||||
world.setExposedWorldProperties('startTime', minDate.replace('T', ' '))
|
||||
world.setExposedWorldProperties('endTime', maxDate.replace('T', ' '))
|
||||
|
||||
const minTimeMillis = new Date(minDate).getTime();
|
||||
const minTimeMillis = new Date(minDate).getTime()
|
||||
const fullTimeMillis =
|
||||
maxDate > minDate ? new Date(maxDate).getTime() - minTimeMillis : 1; // avoid divide by zero
|
||||
maxDate > minDate ? new Date(maxDate).getTime() - minTimeMillis : 1 // avoid divide by zero
|
||||
// ratio of animation time to real time
|
||||
const fakeRealRatio = (ANIMATION_DURATION_SECS * 1000) / fullTimeMillis;
|
||||
const fakeRealRatio = (ANIMATION_DURATION_SECS * 1000) / fullTimeMillis
|
||||
|
||||
// load plant model first because it takes a second
|
||||
const loader = new GLTFLoader();
|
||||
const loader = new GLTFLoader()
|
||||
// choose the right plant
|
||||
const modelLoc = "/models/lupine_plant/scene.gltf", // push with pokies
|
||||
modScale = 0.1;
|
||||
const modelLoc = '/models/lupine_plant/scene.gltf', // push with pokies
|
||||
modScale = 0.1
|
||||
//const modelLoc = "/models/round_bush/scene.gltf", // green & pink
|
||||
// modScale = 1;
|
||||
//const modelLoc = "/models/coreopsis-flower.glb", // 3 flowers
|
||||
@@ -50,59 +50,55 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
|
||||
// calculate positions for each claim, especially because some are random
|
||||
const locations = landmarks.map((claim) =>
|
||||
locForGive(
|
||||
claim,
|
||||
world.PLATFORM_SIZE,
|
||||
world.PLATFORM_EDGE_FOR_UNKNOWNS,
|
||||
),
|
||||
);
|
||||
locForGive(claim, world.PLATFORM_SIZE, world.PLATFORM_EDGE_FOR_UNKNOWNS)
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
loader.load(
|
||||
modelLoc,
|
||||
function (gltf) {
|
||||
gltf.scene.scale.set(0, 0, 0);
|
||||
gltf.scene.scale.set(0, 0, 0)
|
||||
for (let i = 0; i < landmarks.length; i++) {
|
||||
// claim is a GiveServerRecord (see endorserServer.ts)
|
||||
const claim = landmarks[i];
|
||||
const newPlant = SkeletonUtils.clone(gltf.scene);
|
||||
const claim = landmarks[i]
|
||||
const newPlant = SkeletonUtils.clone(gltf.scene)
|
||||
|
||||
const loc = locations[i];
|
||||
newPlant.position.set(loc.x, 0, loc.z);
|
||||
const loc = locations[i]
|
||||
newPlant.position.set(loc.x, 0, loc.z)
|
||||
|
||||
world.scene.add(newPlant);
|
||||
world.scene.add(newPlant)
|
||||
const timeDelayMillis =
|
||||
fakeRealRatio *
|
||||
(new Date(claim.issuedAt).getTime() - minTimeMillis);
|
||||
(new Date(claim.issuedAt).getTime() - minTimeMillis)
|
||||
new TWEEN.Tween(newPlant.scale)
|
||||
.delay(timeDelayMillis)
|
||||
.to({ x: modScale, y: modScale, z: modScale }, 5000)
|
||||
.start();
|
||||
world.bushes = [...world.bushes, newPlant];
|
||||
.start()
|
||||
world.bushes = [...world.bushes, newPlant]
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
function (error) {
|
||||
logger.error(error);
|
||||
},
|
||||
);
|
||||
logger.error(error)
|
||||
}
|
||||
)
|
||||
|
||||
// calculate when lights shine on appearing claim area
|
||||
for (let i = 0; i < landmarks.length; i++) {
|
||||
// claim is a GiveServerRecord (see endorserServer.ts)
|
||||
const claim = landmarks[i];
|
||||
const claim = landmarks[i]
|
||||
|
||||
const loc = locations[i];
|
||||
const light = createLight();
|
||||
light.position.set(loc.x, 20, loc.z);
|
||||
light.target.position.set(loc.x, 0, loc.z);
|
||||
loop.updatables.push(light);
|
||||
scene.add(light);
|
||||
scene.add(light.target);
|
||||
const loc = locations[i]
|
||||
const light = createLight()
|
||||
light.position.set(loc.x, 20, loc.z)
|
||||
light.target.position.set(loc.x, 0, loc.z)
|
||||
loop.updatables.push(light)
|
||||
scene.add(light)
|
||||
scene.add(light.target)
|
||||
|
||||
// now figure out the timing and shine a light
|
||||
const timeDelayMillis =
|
||||
fakeRealRatio * (new Date(claim.issuedAt).getTime() - minTimeMillis);
|
||||
fakeRealRatio * (new Date(claim.issuedAt).getTime() - minTimeMillis)
|
||||
new TWEEN.Tween(light)
|
||||
.delay(timeDelayMillis)
|
||||
.to({ intensity: 100 }, 10)
|
||||
@@ -110,30 +106,30 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
new TWEEN.Tween(light.position)
|
||||
.to({ y: 5 }, 5000)
|
||||
.onComplete(() => {
|
||||
scene.remove(light);
|
||||
light.dispose();
|
||||
}),
|
||||
scene.remove(light)
|
||||
light.dispose()
|
||||
})
|
||||
)
|
||||
.start();
|
||||
world.lights = [...world.lights, light];
|
||||
.start()
|
||||
world.lights = [...world.lights, light]
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
"Got bad server response status & data of",
|
||||
'Got bad server response status & data of',
|
||||
resp.status,
|
||||
resp.data,
|
||||
);
|
||||
resp.data
|
||||
)
|
||||
vue.setAlert(
|
||||
"Error With Server",
|
||||
"There was an error retrieving your claims from the server.",
|
||||
);
|
||||
'Error With Server',
|
||||
'There was an error retrieving your claims from the server.'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Got exception contacting server:", error);
|
||||
logger.error('Got exception contacting server:', error)
|
||||
vue.setAlert(
|
||||
"Error With Server",
|
||||
"There was a problem retrieving your claims from the server.",
|
||||
);
|
||||
'Error With Server',
|
||||
'There was a problem retrieving your claims from the server.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,30 +139,30 @@ export async function loadLandmarks(vue, world, scene, loop) {
|
||||
* @returns {x:float, z:float} where -50 <= x & z < 50
|
||||
*/
|
||||
function locForGive(giveClaim, platformWidth, borderWidth) {
|
||||
let loc;
|
||||
let loc
|
||||
if (giveClaim?.claim?.recipient?.identifier) {
|
||||
// this is directly to a person
|
||||
loc = locForEthrDid(giveClaim.claim.recipient.identifier);
|
||||
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
|
||||
loc = locForEthrDid(giveClaim.claim.recipient.identifier)
|
||||
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 }
|
||||
} else if (giveClaim?.object?.isPartOf?.identifier) {
|
||||
// this is probably to a project
|
||||
const objId = giveClaim.object.isPartOf.identifier;
|
||||
const objId = giveClaim.object.isPartOf.identifier
|
||||
if (objId.startsWith(ENDORSER_ENTITY_PREFIX)) {
|
||||
loc = locForUlid(objId.substring(ENDORSER_ENTITY_PREFIX.length));
|
||||
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
|
||||
loc = locForUlid(objId.substring(ENDORSER_ENTITY_PREFIX.length))
|
||||
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 }
|
||||
}
|
||||
}
|
||||
if (!loc) {
|
||||
// it must be outside our known addresses so let's put it somewhere random on the side
|
||||
const leftSide = Math.random() < 0.5;
|
||||
const leftSide = Math.random() < 0.5
|
||||
loc = {
|
||||
x: leftSide
|
||||
? -platformWidth / 2 - borderWidth / 2
|
||||
: platformWidth / 2 + borderWidth / 2,
|
||||
z: Math.random() * platformWidth - platformWidth / 2,
|
||||
};
|
||||
z: Math.random() * platformWidth - platformWidth / 2
|
||||
}
|
||||
}
|
||||
return loc;
|
||||
return loc
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,20 +191,20 @@ function locForGive(giveClaim, platformWidth, borderWidth) {
|
||||
* @returns {x: float, z: float} where 0 <= x & z < 100
|
||||
*/
|
||||
function locForUlid(ulid) {
|
||||
const xChars = ulid.substring(0, 13).split("").reverse().join("");
|
||||
const zChars = ulid.substring(13, 26).split("").reverse().join("");
|
||||
const xChars = ulid.substring(0, 13).split('').reverse().join('')
|
||||
const zChars = ulid.substring(13, 26).split('').reverse().join('')
|
||||
|
||||
// from https://github.com/ulid/javascript/blob/5e9727b527aec5b841737c395a20085c4361e971/lib/index.ts#L21
|
||||
const BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford's Base32
|
||||
const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' // Crockford's Base32
|
||||
|
||||
// We're currently only using 1024 possible x and z values
|
||||
// because the display is pretty low-fidelity at this point.
|
||||
const rawX = BASE32.indexOf(xChars[1]) * 32 + BASE32.indexOf(xChars[0]);
|
||||
const rawZ = BASE32.indexOf(zChars[1]) * 32 + BASE32.indexOf(zChars[0]);
|
||||
const rawX = BASE32.indexOf(xChars[1]) * 32 + BASE32.indexOf(xChars[0])
|
||||
const rawZ = BASE32.indexOf(zChars[1]) * 32 + BASE32.indexOf(zChars[0])
|
||||
|
||||
const x = (100 * rawX) / 1024;
|
||||
const z = (100 * rawZ) / 1024;
|
||||
return { x, z };
|
||||
const x = (100 * rawX) / 1024
|
||||
const z = (100 * rawZ) / 1024
|
||||
return { x, z }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,24 +215,24 @@ function locForUlid(ulid) {
|
||||
function locForEthrDid(did) {
|
||||
// "did:ethr:0x..."
|
||||
if (did.length < 51) {
|
||||
return { x: 0, z: 0 };
|
||||
return { x: 0, z: 0 }
|
||||
} else {
|
||||
const randomness = did.substring("did:ethr:0x".length);
|
||||
const randomness = did.substring('did:ethr:0x'.length)
|
||||
// We'll use all the randomness for fully unique x & z values.
|
||||
// But we'll only calculate this view with the first byte since our rendering resolution is low.
|
||||
const xOff = parseInt(Number("0x" + randomness.substring(0, 2)), 10);
|
||||
const x = (xOff * 100) / 256;
|
||||
const xOff = parseInt(Number('0x' + randomness.substring(0, 2)), 10)
|
||||
const x = (xOff * 100) / 256
|
||||
// ... and since we're reserving 20 bytes total for x, start z with character 20,
|
||||
// again with one byte.
|
||||
const zOff = parseInt(Number("0x" + randomness.substring(20, 22)), 10);
|
||||
const z = (zOff * 100) / 256;
|
||||
return { x, z };
|
||||
const zOff = parseInt(Number('0x' + randomness.substring(20, 22)), 10)
|
||||
const z = (zOff * 100) / 256
|
||||
return { x, z }
|
||||
}
|
||||
}
|
||||
|
||||
function createLight() {
|
||||
const light = new THREE.SpotLight(0xffffff, 0, 0, Math.PI / 8, 0.5, 0);
|
||||
const light = new THREE.SpotLight(0xffffff, 0, 0, Math.PI / 8, 0.5, 0)
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
light.tick = () => {};
|
||||
return light;
|
||||
light.tick = () => {}
|
||||
return light
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { PlaneGeometry, MeshLambertMaterial, Mesh, TextureLoader } from "three";
|
||||
import { PlaneGeometry, MeshLambertMaterial, Mesh, TextureLoader } from 'three'
|
||||
|
||||
export function createTerrain(props) {
|
||||
const loader = new TextureLoader();
|
||||
const height = loader.load("img/textures/leafy-autumn-forest-floor.jpg");
|
||||
const loader = new TextureLoader()
|
||||
const height = loader.load('img/textures/leafy-autumn-forest-floor.jpg')
|
||||
// w h
|
||||
const geometry = new PlaneGeometry(props.width, props.height, 64, 64);
|
||||
const geometry = new PlaneGeometry(props.width, props.height, 64, 64)
|
||||
|
||||
const material = new MeshLambertMaterial({
|
||||
color: props.color,
|
||||
flatShading: true,
|
||||
map: height,
|
||||
map: height
|
||||
//displacementMap: height,
|
||||
//displacementScale: 5,
|
||||
});
|
||||
})
|
||||
|
||||
const plane = new Mesh(geometry, material);
|
||||
plane.position.set(0, 0, 0);
|
||||
plane.rotation.x -= Math.PI * 0.5;
|
||||
const plane = new Mesh(geometry, material)
|
||||
plane.position.set(0, 0, 0)
|
||||
plane.rotation.x -= Math.PI * 0.5
|
||||
|
||||
//Storing our original vertices position on a new attribute
|
||||
plane.geometry.attributes.position.originalPosition =
|
||||
plane.geometry.attributes.position.array;
|
||||
plane.geometry.attributes.position.array
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
plane.tick = () => {};
|
||||
plane.tick = () => {}
|
||||
|
||||
return plane;
|
||||
return plane
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Color, Scene } from "three";
|
||||
import { Color, Scene } from 'three'
|
||||
|
||||
function createScene(color) {
|
||||
const scene = new Scene();
|
||||
const scene = new Scene()
|
||||
|
||||
scene.background = new Color(color);
|
||||
scene.background = new Color(color)
|
||||
//scene.fog = new Fog(color, 60, 90);
|
||||
return scene;
|
||||
return scene
|
||||
}
|
||||
|
||||
export { createScene };
|
||||
export { createScene }
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { Clock } from "three";
|
||||
import { Clock } from 'three'
|
||||
|
||||
const clock = new Clock();
|
||||
const clock = new Clock()
|
||||
|
||||
class Loop {
|
||||
constructor(camera, scene, renderer) {
|
||||
this.camera = camera;
|
||||
this.scene = scene;
|
||||
this.renderer = renderer;
|
||||
this.updatables = [];
|
||||
this.camera = camera
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
this.updatables = []
|
||||
}
|
||||
|
||||
start() {
|
||||
this.renderer.setAnimationLoop(() => {
|
||||
this.tick();
|
||||
this.tick()
|
||||
// render a frame
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
});
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
})
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.renderer.setAnimationLoop(null);
|
||||
this.renderer.setAnimationLoop(null)
|
||||
}
|
||||
|
||||
tick() {
|
||||
const delta = clock.getDelta();
|
||||
const delta = clock.getDelta()
|
||||
for (const object of this.updatables) {
|
||||
object.tick(delta);
|
||||
object.tick(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { Loop };
|
||||
export { Loop }
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
const setSize = (container, camera, renderer) => {
|
||||
// These are great for full-screen, which adjusts to a window.
|
||||
const height = window.innerHeight;
|
||||
const width = window.innerWidth - 50;
|
||||
const height = window.innerHeight
|
||||
const width = window.innerWidth - 50
|
||||
// These are better for fitting in a container, which stays that size.
|
||||
//const height = container.scrollHeight;
|
||||
//const width = container.scrollWidth;
|
||||
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
};
|
||||
renderer.setSize(width, height)
|
||||
renderer.setPixelRatio(window.devicePixelRatio)
|
||||
}
|
||||
|
||||
class Resizer {
|
||||
constructor(container, camera, renderer) {
|
||||
// set initial size on load
|
||||
setSize(container, camera, renderer);
|
||||
setSize(container, camera, renderer)
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
window.addEventListener('resize', () => {
|
||||
// set the size again if a resize occurs
|
||||
setSize(container, camera, renderer);
|
||||
setSize(container, camera, renderer)
|
||||
// perform any custom actions
|
||||
this.onResize();
|
||||
});
|
||||
this.onResize()
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onResize() {}
|
||||
}
|
||||
|
||||
export { Resizer };
|
||||
export { Resizer }
|
||||
|
||||
+12
-12
@@ -1,12 +1,12 @@
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { MathUtils } from "three";
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
import { MathUtils } from 'three'
|
||||
|
||||
function createControls(camera, canvas) {
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
const controls = new OrbitControls(camera, canvas)
|
||||
|
||||
//enable controls?
|
||||
controls.enabled = true;
|
||||
controls.autoRotate = false;
|
||||
controls.enabled = true
|
||||
controls.autoRotate = false
|
||||
//controls.autoRotateSpeed = 0.2;
|
||||
|
||||
// control limits
|
||||
@@ -14,8 +14,8 @@ function createControls(camera, canvas) {
|
||||
// to prevent the user from clipping with the objects.
|
||||
|
||||
// y axis
|
||||
controls.minPolarAngle = MathUtils.degToRad(40); // default
|
||||
controls.maxPolarAngle = MathUtils.degToRad(75);
|
||||
controls.minPolarAngle = MathUtils.degToRad(40) // default
|
||||
controls.maxPolarAngle = MathUtils.degToRad(75)
|
||||
|
||||
// x axis
|
||||
// controls.minAzimuthAngle = ...
|
||||
@@ -23,16 +23,16 @@ function createControls(camera, canvas) {
|
||||
|
||||
//smooth camera:
|
||||
// remember to add to loop updatables to work
|
||||
controls.enableDamping = true;
|
||||
controls.enableDamping = true
|
||||
|
||||
//controls.enableZoom = false;
|
||||
controls.maxDistance = 250;
|
||||
controls.maxDistance = 250
|
||||
|
||||
//controls.enablePan = false;
|
||||
|
||||
controls.tick = () => controls.update();
|
||||
controls.tick = () => controls.update()
|
||||
|
||||
return controls;
|
||||
return controls
|
||||
}
|
||||
|
||||
export { createControls };
|
||||
export { createControls }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { WebGLRenderer } from "three";
|
||||
import { WebGLRenderer } from 'three'
|
||||
|
||||
function createRenderer() {
|
||||
const renderer = new WebGLRenderer({ antialias: true });
|
||||
const renderer = new WebGLRenderer({ antialias: true })
|
||||
|
||||
// turn on the physically correct lighting model
|
||||
// (The browser complains: "THREE.WebGLRenderer: the property .physicallyCorrectLights has been removed. Set renderer.useLegacyLights instead." However, that changes the lighting in a way that doesn't look better.)
|
||||
renderer.physicallyCorrectLights = true;
|
||||
renderer.physicallyCorrectLights = true
|
||||
|
||||
return renderer;
|
||||
return renderer
|
||||
}
|
||||
|
||||
export { createRenderer };
|
||||
export { createRenderer }
|
||||
|
||||
+31
-32
@@ -6,64 +6,63 @@
|
||||
export enum AppString {
|
||||
// This is used in titles and verbiage inside the app.
|
||||
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
||||
APP_NAME = "Time Safari",
|
||||
APP_NAME = 'Time Safari',
|
||||
|
||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
|
||||
PROD_ENDORSER_API_SERVER = 'https://api.endorser.ch',
|
||||
TEST_ENDORSER_API_SERVER = 'https://test-api.endorser.ch',
|
||||
LOCAL_ENDORSER_API_SERVER = 'http://localhost:3000',
|
||||
|
||||
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
|
||||
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
|
||||
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
|
||||
PROD_IMAGE_API_SERVER = 'https://image-api.timesafari.app',
|
||||
TEST_IMAGE_API_SERVER = 'https://test-image-api.timesafari.app',
|
||||
LOCAL_IMAGE_API_SERVER = 'http://localhost:3001',
|
||||
|
||||
PROD_PARTNER_API_SERVER = "https://partner-api.endorser.ch",
|
||||
TEST_PARTNER_API_SERVER = "https://test-partner-api.endorser.ch",
|
||||
PROD_PARTNER_API_SERVER = 'https://partner-api.endorser.ch',
|
||||
TEST_PARTNER_API_SERVER = 'https://test-partner-api.endorser.ch',
|
||||
LOCAL_PARTNER_API_SERVER = LOCAL_ENDORSER_API_SERVER,
|
||||
|
||||
PROD_PUSH_SERVER = "https://timesafari.app",
|
||||
TEST1_PUSH_SERVER = "https://test.timesafari.app",
|
||||
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
|
||||
PROD_PUSH_SERVER = 'https://timesafari.app',
|
||||
TEST1_PUSH_SERVER = 'https://test.timesafari.app',
|
||||
TEST2_PUSH_SERVER = 'https://timesafari-pwa.anomalistlabs.com',
|
||||
|
||||
NO_CONTACT_NAME = "(no name)",
|
||||
NO_CONTACT_NAME = '(no name)'
|
||||
}
|
||||
|
||||
export const APP_SERVER =
|
||||
import.meta.env.VITE_APP_SERVER || "https://timesafari.app";
|
||||
import.meta.env.VITE_APP_SERVER || 'https://timesafari.app'
|
||||
|
||||
export const DEFAULT_ENDORSER_API_SERVER =
|
||||
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
|
||||
AppString.TEST_ENDORSER_API_SERVER;
|
||||
AppString.TEST_ENDORSER_API_SERVER
|
||||
|
||||
export const DEFAULT_IMAGE_API_SERVER =
|
||||
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
|
||||
AppString.TEST_IMAGE_API_SERVER;
|
||||
AppString.TEST_IMAGE_API_SERVER
|
||||
|
||||
export const DEFAULT_PARTNER_API_SERVER =
|
||||
import.meta.env.VITE_DEFAULT_PARTNER_API_SERVER ||
|
||||
AppString.TEST_PARTNER_API_SERVER;
|
||||
AppString.TEST_PARTNER_API_SERVER
|
||||
|
||||
export const DEFAULT_PUSH_SERVER =
|
||||
window.location.protocol + "//" + window.location.host;
|
||||
window.location.protocol + '//' + window.location.host
|
||||
|
||||
export const IMAGE_TYPE_PROFILE = "profile";
|
||||
export const IMAGE_TYPE_PROFILE = 'profile'
|
||||
|
||||
export const PASSKEYS_ENABLED =
|
||||
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
||||
export const PASSKEYS_ENABLED = !!import.meta.env.VITE_PASSKEYS_ENABLED || false
|
||||
|
||||
/**
|
||||
* The possible values for "group" and "type" are in App.vue.
|
||||
* Some of this comes from the notiwind package, some is custom.
|
||||
*/
|
||||
export interface NotificationIface {
|
||||
group: string; // "alert" | "modal"
|
||||
type: string; // "toast" | "info" | "success" | "warning" | "danger"
|
||||
title: string;
|
||||
text?: string;
|
||||
callback?: (success: boolean) => Promise<void>; // if this triggered an action
|
||||
noText?: string;
|
||||
onCancel?: (stopAsking?: boolean) => Promise<void>;
|
||||
onNo?: (stopAsking?: boolean) => Promise<void>;
|
||||
onYes?: () => Promise<void>;
|
||||
promptToStopAsking?: boolean;
|
||||
yesText?: string;
|
||||
group: string // "alert" | "modal"
|
||||
type: string // "toast" | "info" | "success" | "warning" | "danger"
|
||||
title: string
|
||||
text?: string
|
||||
callback?: (success: boolean) => Promise<void> // if this triggered an action
|
||||
noText?: string
|
||||
onCancel?: (stopAsking?: boolean) => Promise<void>
|
||||
onNo?: (stopAsking?: boolean) => Promise<void>
|
||||
onYes?: () => Promise<void>
|
||||
promptToStopAsking?: boolean
|
||||
yesText?: string
|
||||
}
|
||||
|
||||
+86
-86
@@ -1,44 +1,44 @@
|
||||
import BaseDexie, { Table } from "dexie";
|
||||
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
|
||||
import * as R from "ramda";
|
||||
import BaseDexie, { Table } from 'dexie'
|
||||
import { encrypted, Encryption } from '@pvermeer/dexie-encrypted-addon'
|
||||
import * as R from 'ramda'
|
||||
|
||||
import { Account, AccountsSchema } from "./tables/accounts";
|
||||
import { Contact, ContactSchema } from "./tables/contacts";
|
||||
import { Log, LogSchema } from "./tables/logs";
|
||||
import { MASTER_SECRET_KEY, Secret, SecretSchema } from "./tables/secret";
|
||||
import { Account, AccountsSchema } from './tables/accounts'
|
||||
import { Contact, ContactSchema } from './tables/contacts'
|
||||
import { Log, LogSchema } from './tables/logs'
|
||||
import { MASTER_SECRET_KEY, Secret, SecretSchema } from './tables/secret'
|
||||
import {
|
||||
MASTER_SETTINGS_KEY,
|
||||
Settings,
|
||||
SettingsSchema,
|
||||
} from "./tables/settings";
|
||||
import { Temp, TempSchema } from "./tables/temp";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app";
|
||||
import { logger } from "../utils/logger";
|
||||
SettingsSchema
|
||||
} from './tables/settings'
|
||||
import { Temp, TempSchema } from './tables/temp'
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from '../constants/app'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
// Define types for tables that hold sensitive and non-sensitive data
|
||||
type SecretTable = { secret: Table<Secret> };
|
||||
type SensitiveTables = { accounts: Table<Account> };
|
||||
type SecretTable = { secret: Table<Secret> }
|
||||
type SensitiveTables = { accounts: Table<Account> }
|
||||
type NonsensitiveTables = {
|
||||
contacts: Table<Contact>;
|
||||
logs: Table<Log>;
|
||||
settings: Table<Settings>;
|
||||
temp: Table<Temp>;
|
||||
};
|
||||
contacts: Table<Contact>
|
||||
logs: Table<Log>
|
||||
settings: Table<Settings>
|
||||
temp: Table<Temp>
|
||||
}
|
||||
|
||||
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
||||
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
|
||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
||||
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T
|
||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T
|
||||
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
||||
BaseDexie & T;
|
||||
BaseDexie & T
|
||||
|
||||
//// Initialize the DBs, starting with the sensitive ones.
|
||||
|
||||
// Initialize Dexie database for secret, which is then used to encrypt accountsDB
|
||||
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie;
|
||||
secretDB.version(1).stores(SecretSchema);
|
||||
export const secretDB = new BaseDexie('TimeSafariSecret') as SecretDexie
|
||||
secretDB.version(1).stores(SecretSchema)
|
||||
|
||||
// Initialize Dexie database for accounts
|
||||
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||
const accountsDexie = new BaseDexie('TimeSafariAccounts') as SensitiveDexie
|
||||
|
||||
// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
|
||||
// so that it's clear whether the usage needs the private key inside.
|
||||
@@ -49,13 +49,13 @@ const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||
// to a user action required to unlock the data.
|
||||
export const accountsDBPromise = useSecretAndInitializeAccountsDB(
|
||||
secretDB,
|
||||
accountsDexie,
|
||||
);
|
||||
accountsDexie
|
||||
)
|
||||
|
||||
//// Now initialize the other DB.
|
||||
|
||||
// Initialize Dexie databases for non-sensitive data
|
||||
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
||||
export const db = new BaseDexie('TimeSafari') as NonsensitiveDexie
|
||||
|
||||
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
|
||||
|
||||
@@ -64,31 +64,31 @@ export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
||||
db.version(2).stores({
|
||||
...ContactSchema,
|
||||
...LogSchema,
|
||||
...{ settings: "id" }, // old Settings schema
|
||||
});
|
||||
...{ settings: 'id' } // old Settings schema
|
||||
})
|
||||
// v3 added Temp
|
||||
db.version(3).stores(TempSchema);
|
||||
db.version(3).stores(TempSchema)
|
||||
db.version(4)
|
||||
.stores(SettingsSchema)
|
||||
.upgrade((tx) => {
|
||||
return tx
|
||||
.table("settings")
|
||||
.table('settings')
|
||||
.toCollection()
|
||||
.modify((settings) => {
|
||||
settings.accountDid = ""; // make it non-null for the default master settings, but still indexable
|
||||
});
|
||||
});
|
||||
settings.accountDid = '' // make it non-null for the default master settings, but still indexable
|
||||
})
|
||||
})
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
id: MASTER_SETTINGS_KEY,
|
||||
activeDid: undefined,
|
||||
apiServer: DEFAULT_ENDORSER_API_SERVER,
|
||||
};
|
||||
apiServer: DEFAULT_ENDORSER_API_SERVER
|
||||
}
|
||||
|
||||
// Event handler to initialize the non-sensitive database with default settings
|
||||
db.on("populate", async () => {
|
||||
await db.settings.add(DEFAULT_SETTINGS);
|
||||
});
|
||||
db.on('populate', async () => {
|
||||
await db.settings.add(DEFAULT_SETTINGS)
|
||||
})
|
||||
|
||||
// Manage the encryption key.
|
||||
|
||||
@@ -114,130 +114,130 @@ db.on("populate", async () => {
|
||||
|
||||
async function useSecretAndInitializeAccountsDB(
|
||||
secretDB: SecretDexie,
|
||||
accountsDB: SensitiveDexie,
|
||||
accountsDB: SensitiveDexie
|
||||
): Promise<SensitiveDexie> {
|
||||
return secretDB
|
||||
.open()
|
||||
.then(() => {
|
||||
return secretDB.secret.get(MASTER_SECRET_KEY);
|
||||
return secretDB.secret.get(MASTER_SECRET_KEY)
|
||||
})
|
||||
.then((secretRow?: Secret) => {
|
||||
let secret = secretRow?.secret;
|
||||
let secret = secretRow?.secret
|
||||
if (secret != null) {
|
||||
// they already have it in IndexedDB, so just pass it along
|
||||
return secret;
|
||||
return secret
|
||||
} else {
|
||||
// check localStorage (for users before v 0.3.37)
|
||||
const localSecret = localStorage.getItem("secret");
|
||||
const localSecret = localStorage.getItem('secret')
|
||||
if (localSecret != null) {
|
||||
// they had one, so we want to move it to IndexedDB
|
||||
secret = localSecret;
|
||||
secret = localSecret
|
||||
} else {
|
||||
// they didn't have one, so let's generate one
|
||||
secret = Encryption.createRandomEncryptionKey();
|
||||
secret = Encryption.createRandomEncryptionKey()
|
||||
}
|
||||
// it is not in IndexedDB, so add it now
|
||||
return secretDB.secret
|
||||
.add({ id: MASTER_SECRET_KEY, secret })
|
||||
.then(() => {
|
||||
return secret;
|
||||
});
|
||||
return secret
|
||||
})
|
||||
}
|
||||
})
|
||||
.then((secret?: string) => {
|
||||
if (secret == null) {
|
||||
throw new Error("No secret found or created.");
|
||||
throw new Error('No secret found or created.')
|
||||
} else {
|
||||
// apply encryption to the sensitive database using the secret key
|
||||
encrypted(accountsDB, { secretKey: secret });
|
||||
accountsDB.version(1).stores(AccountsSchema);
|
||||
accountsDB.open();
|
||||
return accountsDB;
|
||||
encrypted(accountsDB, { secretKey: secret })
|
||||
accountsDB.version(1).stores(AccountsSchema)
|
||||
accountsDB.open()
|
||||
return accountsDB
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logConsoleAndDb("Error processing secret & encrypted accountsDB.", error);
|
||||
logConsoleAndDb('Error processing secret & encrypted accountsDB.', error)
|
||||
// alert("There was an error processing encrypted data. See the Help page.");
|
||||
throw error;
|
||||
});
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
// retrieves default settings
|
||||
// calls db.open()
|
||||
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
|
||||
await db.open();
|
||||
return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS;
|
||||
await db.open()
|
||||
return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS
|
||||
}
|
||||
|
||||
export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
|
||||
const defaultSettings = await retrieveSettingsForDefaultAccount();
|
||||
const defaultSettings = await retrieveSettingsForDefaultAccount()
|
||||
if (!defaultSettings.activeDid) {
|
||||
return defaultSettings;
|
||||
return defaultSettings
|
||||
} else {
|
||||
const overrideSettings =
|
||||
(await db.settings
|
||||
.where("accountDid")
|
||||
.where('accountDid')
|
||||
.equals(defaultSettings.activeDid)
|
||||
.first()) || {};
|
||||
return R.mergeDeepRight(defaultSettings, overrideSettings);
|
||||
.first()) || {}
|
||||
return R.mergeDeepRight(defaultSettings, overrideSettings)
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDefaultSettings(
|
||||
settingsChanges: Settings,
|
||||
settingsChanges: Settings
|
||||
): Promise<void> {
|
||||
delete settingsChanges.accountDid; // just in case
|
||||
delete settingsChanges.accountDid // just in case
|
||||
// ensure there is no "id" that would override the key
|
||||
delete settingsChanges.id;
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
|
||||
delete settingsChanges.id
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges)
|
||||
}
|
||||
|
||||
export async function updateAccountSettings(
|
||||
accountDid: string,
|
||||
settingsChanges: Settings,
|
||||
settingsChanges: Settings
|
||||
): Promise<void> {
|
||||
settingsChanges.accountDid = accountDid;
|
||||
delete settingsChanges.id; // key off account, not ID
|
||||
settingsChanges.accountDid = accountDid
|
||||
delete settingsChanges.id // key off account, not ID
|
||||
const result = await db.settings
|
||||
.where("accountDid")
|
||||
.where('accountDid')
|
||||
.equals(settingsChanges.accountDid)
|
||||
.modify(settingsChanges);
|
||||
.modify(settingsChanges)
|
||||
if (result === 0) {
|
||||
if (!settingsChanges.id) {
|
||||
// It is unfortunate that we have to set this explicitly.
|
||||
// We didn't make id a "++id" at the beginning and Dexie won't let us change it,
|
||||
// plus we made our first settings objects MASTER_SETTINGS_KEY = 1 instead of 0
|
||||
settingsChanges.id = (await db.settings.count()) + 1;
|
||||
settingsChanges.id = (await db.settings.count()) + 1
|
||||
}
|
||||
await db.settings.add(settingsChanges);
|
||||
await db.settings.add(settingsChanges)
|
||||
}
|
||||
}
|
||||
|
||||
export async function logToDb(message: string): Promise<void> {
|
||||
await db.open();
|
||||
const todayKey = new Date().toDateString();
|
||||
await db.open()
|
||||
const todayKey = new Date().toDateString()
|
||||
// only keep one day's worth of logs
|
||||
const previous = await db.logs.get(todayKey);
|
||||
const previous = await db.logs.get(todayKey)
|
||||
if (!previous) {
|
||||
// when this is today's first log, clear out everything previous
|
||||
// to avoid the log table getting too large
|
||||
// (let's limit a different way someday)
|
||||
await db.logs.clear();
|
||||
await db.logs.clear()
|
||||
}
|
||||
const prevMessages = (previous && previous.message) || "";
|
||||
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`;
|
||||
await db.logs.update(todayKey, { message: fullMessage });
|
||||
const prevMessages = (previous && previous.message) || ''
|
||||
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`
|
||||
await db.logs.update(todayKey, { message: fullMessage })
|
||||
}
|
||||
|
||||
// similar method is in the sw_scripts/additional-scripts.js file
|
||||
export async function logConsoleAndDb(
|
||||
message: string,
|
||||
isError = false,
|
||||
isError = false
|
||||
): Promise<void> {
|
||||
if (isError) {
|
||||
logger.error(`${new Date().toISOString()} ${message}`);
|
||||
logger.error(`${new Date().toISOString()} ${message}`)
|
||||
} else {
|
||||
logger.log(`${new Date().toISOString()} ${message}`);
|
||||
logger.log(`${new Date().toISOString()} ${message}`)
|
||||
}
|
||||
await logToDb(message);
|
||||
await logToDb(message)
|
||||
}
|
||||
|
||||
+11
-11
@@ -5,45 +5,45 @@ export type Account = {
|
||||
/**
|
||||
* Auto-generated ID by Dexie
|
||||
*/
|
||||
id?: number; // this is only blank on input, when the database assigns it
|
||||
id?: number // this is only blank on input, when the database assigns it
|
||||
|
||||
/**
|
||||
* The date the account was created
|
||||
*/
|
||||
dateCreated: string;
|
||||
dateCreated: string
|
||||
|
||||
/**
|
||||
* The derivation path for the account, if this is from a mnemonic
|
||||
*/
|
||||
derivationPath?: string;
|
||||
derivationPath?: string
|
||||
|
||||
/**
|
||||
* Decentralized Identifier (DID) for the account
|
||||
*/
|
||||
did: string;
|
||||
did: string
|
||||
|
||||
/**
|
||||
* Stringified JSON containing underlying key material, if generated from a mnemonic
|
||||
* Based on the IIdentifier type from Veramo
|
||||
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
|
||||
*/
|
||||
identity?: string;
|
||||
identity?: string
|
||||
|
||||
/**
|
||||
* The mnemonic phrase for the account, if this is from a mnemonic
|
||||
*/
|
||||
mnemonic?: string;
|
||||
mnemonic?: string
|
||||
|
||||
/**
|
||||
* The Webauthn credential ID in hex, if this is from a passkey
|
||||
*/
|
||||
passkeyCredIdHex?: string;
|
||||
passkeyCredIdHex?: string
|
||||
|
||||
/**
|
||||
* The public key in hexadecimal format
|
||||
*/
|
||||
publicKeyHex: string;
|
||||
};
|
||||
publicKeyHex: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for the accounts table in the database.
|
||||
@@ -52,5 +52,5 @@ export type Account = {
|
||||
*/
|
||||
export const AccountsSchema = {
|
||||
accounts:
|
||||
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",
|
||||
};
|
||||
'++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex'
|
||||
}
|
||||
|
||||
+14
-14
@@ -1,24 +1,24 @@
|
||||
export interface ContactMethod {
|
||||
label: string;
|
||||
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
|
||||
value: string;
|
||||
label: string
|
||||
type: string // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
//
|
||||
// When adding a property, consider whether it should be added when exporting & sharing contacts.
|
||||
|
||||
did: string;
|
||||
contactMethods?: Array<ContactMethod>;
|
||||
name?: string;
|
||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||
notes?: string;
|
||||
profileImageUrl?: string;
|
||||
publicKeyBase64?: string;
|
||||
seesMe?: boolean; // cached value of the server setting
|
||||
registered?: boolean; // cached value of the server setting
|
||||
did: string
|
||||
contactMethods?: Array<ContactMethod>
|
||||
name?: string
|
||||
nextPubKeyHashB64?: string // base64-encoded SHA256 hash of next public key
|
||||
notes?: string
|
||||
profileImageUrl?: string
|
||||
publicKeyBase64?: string
|
||||
seesMe?: boolean // cached value of the server setting
|
||||
registered?: boolean // cached value of the server setting
|
||||
}
|
||||
|
||||
export const ContactSchema = {
|
||||
contacts: "&did, name", // no need to key by other things
|
||||
};
|
||||
contacts: '&did, name' // no need to key by other things
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export interface Log {
|
||||
date: string;
|
||||
message: string;
|
||||
date: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const LogSchema = {
|
||||
// Currently keyed by "date" because A) today's log data is what we need so we append, and
|
||||
// B) we don't want it to grow so we remove everything if this is the first entry today.
|
||||
// See safari-notifications.js logMessage for the associated logic.
|
||||
logs: "date", // definitely don't key by the potentially large message field
|
||||
};
|
||||
logs: 'date' // definitely don't key by the potentially large message field
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ export type Secret = {
|
||||
/**
|
||||
* Auto-generated ID by Dexie
|
||||
*/
|
||||
id: number;
|
||||
id: number
|
||||
|
||||
/**
|
||||
* The secret key used to decrypt the identity if they're not using their own password
|
||||
*/
|
||||
secret: string;
|
||||
};
|
||||
secret: string
|
||||
}
|
||||
|
||||
export const SecretSchema = { secret: "++id, secret" };
|
||||
export const SecretSchema = { secret: '++id, secret' }
|
||||
|
||||
export const MASTER_SECRET_KEY = 0;
|
||||
export const MASTER_SECRET_KEY = 0
|
||||
|
||||
+42
-42
@@ -2,82 +2,82 @@
|
||||
* BoundingBox type describes the geographical bounding box coordinates.
|
||||
*/
|
||||
export type BoundingBox = {
|
||||
eastLong: number; // Eastern longitude
|
||||
maxLat: number; // Maximum (Northernmost) latitude
|
||||
minLat: number; // Minimum (Southernmost) latitude
|
||||
westLong: number; // Western longitude
|
||||
};
|
||||
eastLong: number // Eastern longitude
|
||||
maxLat: number // Maximum (Northernmost) latitude
|
||||
minLat: number // Minimum (Southernmost) latitude
|
||||
westLong: number // Western longitude
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings type encompasses user-specific configuration details.
|
||||
*/
|
||||
export type Settings = {
|
||||
// default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID
|
||||
id?: number; // this is erased for all those entries that are keyed with accountDid
|
||||
id?: number // this is erased for all those entries that are keyed with accountDid
|
||||
|
||||
// if supplied, this settings record overrides the master record when the user switches to this account
|
||||
accountDid?: string; // not used in the MASTER_SETTINGS_KEY entry
|
||||
accountDid?: string // not used in the MASTER_SETTINGS_KEY entry
|
||||
// active Decentralized ID
|
||||
activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry
|
||||
activeDid?: string // only used in the MASTER_SETTINGS_KEY entry
|
||||
|
||||
apiServer?: string; // API server URL
|
||||
apiServer?: string // API server URL
|
||||
|
||||
filterFeedByNearby?: boolean; // filter by nearby
|
||||
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
|
||||
finishedOnboarding?: boolean; // the user has completed the onboarding process
|
||||
filterFeedByNearby?: boolean // filter by nearby
|
||||
filterFeedByVisible?: boolean // filter by visible users ie. anyone not hidden
|
||||
finishedOnboarding?: boolean // the user has completed the onboarding process
|
||||
|
||||
firstName?: string; // user's full name, may be null if unwanted for a particular account
|
||||
hideRegisterPromptOnNewContact?: boolean;
|
||||
isRegistered?: boolean;
|
||||
firstName?: string // user's full name, may be null if unwanted for a particular account
|
||||
hideRegisterPromptOnNewContact?: boolean
|
||||
isRegistered?: boolean
|
||||
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
|
||||
lastName?: string; // deprecated - put all names in firstName
|
||||
lastName?: string // deprecated - put all names in firstName
|
||||
|
||||
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
|
||||
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
|
||||
lastAckedOfferToUserJwtId?: string // the last JWT ID for offer-to-user that they've acknowledged seeing
|
||||
lastAckedOfferToUserProjectsJwtId?: string // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
|
||||
|
||||
// The claim list has a most recent one used in notifications that's separate from the last viewed
|
||||
lastNotifiedClaimId?: string;
|
||||
lastViewedClaimId?: string;
|
||||
lastNotifiedClaimId?: string
|
||||
lastViewedClaimId?: string
|
||||
|
||||
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server
|
||||
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
|
||||
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
|
||||
notifyingNewActivityTime?: string // set to their chosen time if they have turned on daily check for new activity via the push server
|
||||
notifyingReminderMessage?: string // set to their chosen message for a daily reminder
|
||||
notifyingReminderTime?: string // set to their chosen time for a daily reminder
|
||||
|
||||
partnerApiServer?: string; // partner server API URL
|
||||
partnerApiServer?: string // partner server API URL
|
||||
|
||||
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
|
||||
passkeyExpirationMinutes?: number // passkey access token time-to-live in minutes
|
||||
|
||||
profileImageUrl?: string; // may be null if unwanted for a particular account
|
||||
profileImageUrl?: string // may be null if unwanted for a particular account
|
||||
|
||||
// Array of named search boxes defined by bounding boxes
|
||||
searchBoxes?: Array<{
|
||||
name: string;
|
||||
bbox: BoundingBox;
|
||||
}>;
|
||||
name: string
|
||||
bbox: BoundingBox
|
||||
}>
|
||||
|
||||
showContactGivesInline?: boolean; // Display contact inline or not
|
||||
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
|
||||
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
|
||||
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
|
||||
warnIfProdServer?: boolean; // Warn if using a production server
|
||||
warnIfTestServer?: boolean; // Warn if using a testing server
|
||||
webPushServer?: string; // Web Push server URL
|
||||
};
|
||||
showContactGivesInline?: boolean // Display contact inline or not
|
||||
showGeneralAdvanced?: boolean // Show advanced features which don't have their own flag
|
||||
showShortcutBvc?: boolean // Show shortcut for Bountiful Voluntaryist Community actions
|
||||
vapid?: string // VAPID (Voluntary Application Server Identification) field for web push
|
||||
warnIfProdServer?: boolean // Warn if using a production server
|
||||
warnIfTestServer?: boolean // Warn if using a testing server
|
||||
webPushServer?: string // Web Push server URL
|
||||
}
|
||||
|
||||
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
|
||||
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
|
||||
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for the Settings table in the database.
|
||||
*/
|
||||
export const SettingsSchema = {
|
||||
settings: "id, &accountDid",
|
||||
};
|
||||
settings: 'id, &accountDid'
|
||||
}
|
||||
|
||||
/**
|
||||
* Constants.
|
||||
*/
|
||||
export const MASTER_SETTINGS_KEY = 1;
|
||||
export const MASTER_SETTINGS_KEY = 1
|
||||
|
||||
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;
|
||||
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
|
||||
|
||||
export type Temp = {
|
||||
id: string;
|
||||
blob?: Blob; // deprecated because webkit (Safari) does not support Blob
|
||||
blobB64?: string; // base64-encoded blob
|
||||
};
|
||||
id: string
|
||||
blob?: Blob // deprecated because webkit (Safari) does not support Blob
|
||||
blobB64?: string // base64-encoded blob
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for the Temp table in the database.
|
||||
*/
|
||||
export const TempSchema = { temp: "id" };
|
||||
export const TempSchema = { temp: 'id' }
|
||||
|
||||
+83
-83
@@ -1,16 +1,16 @@
|
||||
const { app, BrowserWindow } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const logger = require("../utils/logger");
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// Check if running in dev mode
|
||||
const isDev = process.argv.includes("--inspect");
|
||||
const isDev = process.argv.includes('--inspect')
|
||||
|
||||
function createWindow() {
|
||||
// Add before createWindow function
|
||||
const preloadPath = path.join(__dirname, "preload.js");
|
||||
logger.log("Checking preload path:", preloadPath);
|
||||
logger.log("Preload exists:", fs.existsSync(preloadPath));
|
||||
const preloadPath = path.join(__dirname, 'preload.js')
|
||||
logger.log('Checking preload path:', preloadPath)
|
||||
logger.log('Preload exists:', fs.existsSync(preloadPath))
|
||||
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
@@ -21,71 +21,71 @@ function createWindow() {
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
});
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
}
|
||||
})
|
||||
|
||||
// Always open DevTools for now
|
||||
mainWindow.webContents.openDevTools();
|
||||
mainWindow.webContents.openDevTools()
|
||||
|
||||
// Intercept requests to fix asset paths
|
||||
mainWindow.webContents.session.webRequest.onBeforeRequest(
|
||||
{
|
||||
urls: [
|
||||
"file://*/*/assets/*",
|
||||
"file://*/assets/*",
|
||||
"file:///assets/*", // Catch absolute paths
|
||||
"<all_urls>", // Catch all URLs as a fallback
|
||||
],
|
||||
'file://*/*/assets/*',
|
||||
'file://*/assets/*',
|
||||
'file:///assets/*', // Catch absolute paths
|
||||
'<all_urls>' // Catch all URLs as a fallback
|
||||
]
|
||||
},
|
||||
(details, callback) => {
|
||||
let url = details.url;
|
||||
let url = details.url
|
||||
|
||||
// Handle paths that don't start with file://
|
||||
if (!url.startsWith("file://") && url.includes("/assets/")) {
|
||||
url = `file://${path.join(__dirname, "www", url)}`;
|
||||
if (!url.startsWith('file://') && url.includes('/assets/')) {
|
||||
url = `file://${path.join(__dirname, 'www', url)}`
|
||||
}
|
||||
|
||||
// Handle absolute paths starting with /assets/
|
||||
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
|
||||
const baseDir = url.includes("dist-electron")
|
||||
if (url.includes('/assets/') && !url.includes('/www/assets/')) {
|
||||
const baseDir = url.includes('dist-electron')
|
||||
? url.substring(
|
||||
0,
|
||||
url.indexOf("/dist-electron") + "/dist-electron".length,
|
||||
url.indexOf('/dist-electron') + '/dist-electron'.length
|
||||
)
|
||||
: `file://${__dirname}`;
|
||||
const assetPath = url.split("/assets/")[1];
|
||||
const newUrl = `${baseDir}/www/assets/${assetPath}`;
|
||||
callback({ redirectURL: newUrl });
|
||||
return;
|
||||
: `file://${__dirname}`
|
||||
const assetPath = url.split('/assets/')[1]
|
||||
const newUrl = `${baseDir}/www/assets/${assetPath}`
|
||||
callback({ redirectURL: newUrl })
|
||||
return
|
||||
}
|
||||
|
||||
callback({}); // No redirect for other URLs
|
||||
},
|
||||
);
|
||||
callback({}) // No redirect for other URLs
|
||||
}
|
||||
)
|
||||
|
||||
if (isDev) {
|
||||
// Debug info
|
||||
logger.log("Debug Info:");
|
||||
logger.log("Running in dev mode:", isDev);
|
||||
logger.log("App is packaged:", app.isPackaged);
|
||||
logger.log("Process resource path:", process.resourcesPath);
|
||||
logger.log("App path:", app.getAppPath());
|
||||
logger.log("__dirname:", __dirname);
|
||||
logger.log("process.cwd():", process.cwd());
|
||||
logger.log('Debug Info:')
|
||||
logger.log('Running in dev mode:', isDev)
|
||||
logger.log('App is packaged:', app.isPackaged)
|
||||
logger.log('Process resource path:', process.resourcesPath)
|
||||
logger.log('App path:', app.getAppPath())
|
||||
logger.log('__dirname:', __dirname)
|
||||
logger.log('process.cwd():', process.cwd())
|
||||
}
|
||||
|
||||
const indexPath = path.join(__dirname, "www", "index.html");
|
||||
const indexPath = path.join(__dirname, 'www', 'index.html')
|
||||
|
||||
if (isDev) {
|
||||
logger.log("Loading index from:", indexPath);
|
||||
logger.log("www path:", path.join(__dirname, "www"));
|
||||
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
|
||||
logger.log('Loading index from:', indexPath)
|
||||
logger.log('www path:', path.join(__dirname, 'www'))
|
||||
logger.log('www assets path:', path.join(__dirname, 'www', 'assets'))
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
logger.error(`Index file not found at: ${indexPath}`);
|
||||
throw new Error("Index file not found");
|
||||
logger.error(`Index file not found at: ${indexPath}`)
|
||||
throw new Error('Index file not found')
|
||||
}
|
||||
|
||||
// Add CSP headers to allow API connections
|
||||
@@ -94,81 +94,81 @@ function createWindow() {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'self';" +
|
||||
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" +
|
||||
"img-src 'self' data: https: blob:;" +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
|
||||
"style-src 'self' 'unsafe-inline';" +
|
||||
"font-src 'self' data:;",
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
"font-src 'self' data:;"
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Load the index.html
|
||||
mainWindow
|
||||
.loadFile(indexPath)
|
||||
.then(() => {
|
||||
logger.log("Successfully loaded index.html");
|
||||
logger.log('Successfully loaded index.html')
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
logger.log("DevTools opened - running in dev mode");
|
||||
mainWindow.webContents.openDevTools()
|
||||
logger.log('DevTools opened - running in dev mode')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to load index.html:", err);
|
||||
logger.error("Attempted path:", indexPath);
|
||||
});
|
||||
logger.error('Failed to load index.html:', err)
|
||||
logger.error('Attempted path:', indexPath)
|
||||
})
|
||||
|
||||
// Listen for console messages from the renderer
|
||||
mainWindow.webContents.on("console-message", (_event, level, message) => {
|
||||
logger.log("Renderer Console:", message);
|
||||
});
|
||||
mainWindow.webContents.on('console-message', (_event, level, message) => {
|
||||
logger.log('Renderer Console:', message)
|
||||
})
|
||||
|
||||
// Add right after creating the BrowserWindow
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
'did-fail-load',
|
||||
(event, errorCode, errorDescription) => {
|
||||
logger.error("Page failed to load:", errorCode, errorDescription);
|
||||
},
|
||||
);
|
||||
logger.error('Page failed to load:', errorCode, errorDescription)
|
||||
}
|
||||
)
|
||||
|
||||
mainWindow.webContents.on("preload-error", (event, preloadPath, error) => {
|
||||
logger.error("Preload script error:", preloadPath, error);
|
||||
});
|
||||
mainWindow.webContents.on('preload-error', (event, preloadPath, error) => {
|
||||
logger.error('Preload script error:', preloadPath, error)
|
||||
})
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"console-message",
|
||||
'console-message',
|
||||
(event, level, message, line, sourceId) => {
|
||||
logger.log("Renderer Console:", line, sourceId, message);
|
||||
},
|
||||
);
|
||||
logger.log('Renderer Console:', line, sourceId, message)
|
||||
}
|
||||
)
|
||||
|
||||
// Enable remote debugging when in dev mode
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle app ready
|
||||
app.whenReady().then(createWindow);
|
||||
app.whenReady().then(createWindow)
|
||||
|
||||
// Handle all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
app.on("activate", () => {
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
createWindow()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Handle any errors
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("Uncaught Exception:", error);
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught Exception:', error)
|
||||
})
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Simple logger implementation
|
||||
const logger = {
|
||||
// eslint-disable-next-line no-console
|
||||
log: (...args: unknown[]) => console.log(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
error: (...args: unknown[]) => console.error(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
info: (...args: unknown[]) => console.info(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
warn: (...args: unknown[]) => console.warn(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
debug: (...args: unknown[]) => console.debug(...args),
|
||||
};
|
||||
|
||||
// Check if running in dev mode
|
||||
const isDev = process.argv.includes("--inspect");
|
||||
|
||||
function createWindow(): void {
|
||||
// Add before createWindow function
|
||||
const preloadPath = path.join(__dirname, "preload.js");
|
||||
logger.log("Checking preload path:", preloadPath);
|
||||
logger.log("Preload exists:", fs.existsSync(preloadPath));
|
||||
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
// Always open DevTools for now
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
||||
// Intercept requests to fix asset paths
|
||||
mainWindow.webContents.session.webRequest.onBeforeRequest(
|
||||
{
|
||||
urls: [
|
||||
"file://*/*/assets/*",
|
||||
"file://*/assets/*",
|
||||
"file:///assets/*", // Catch absolute paths
|
||||
"<all_urls>", // Catch all URLs as a fallback
|
||||
],
|
||||
},
|
||||
(details, callback) => {
|
||||
let url = details.url;
|
||||
|
||||
// Handle paths that don't start with file://
|
||||
if (!url.startsWith("file://") && url.includes("/assets/")) {
|
||||
url = `file://${path.join(__dirname, "www", url)}`;
|
||||
}
|
||||
|
||||
// Handle absolute paths starting with /assets/
|
||||
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
|
||||
const baseDir = url.includes("dist-electron")
|
||||
? url.substring(
|
||||
0,
|
||||
url.indexOf("/dist-electron") + "/dist-electron".length,
|
||||
)
|
||||
: `file://${__dirname}`;
|
||||
const assetPath = url.split("/assets/")[1];
|
||||
const newUrl = `${baseDir}/www/assets/${assetPath}`;
|
||||
callback({ redirectURL: newUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
callback({}); // No redirect for other URLs
|
||||
},
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
// Debug info
|
||||
logger.log("Debug Info:");
|
||||
logger.log("Running in dev mode:", isDev);
|
||||
logger.log("App is packaged:", app.isPackaged);
|
||||
logger.log("Process resource path:", process.resourcesPath);
|
||||
logger.log("App path:", app.getAppPath());
|
||||
logger.log("__dirname:", __dirname);
|
||||
logger.log("process.cwd():", process.cwd());
|
||||
}
|
||||
|
||||
const indexPath = path.join(__dirname, "www", "index.html");
|
||||
|
||||
if (isDev) {
|
||||
logger.log("Loading index from:", indexPath);
|
||||
logger.log("www path:", path.join(__dirname, "www"));
|
||||
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
logger.error(`Index file not found at: ${indexPath}`);
|
||||
throw new Error("Index file not found");
|
||||
}
|
||||
|
||||
// Add CSP headers to allow API connections
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
||||
(details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self';" +
|
||||
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" +
|
||||
"img-src 'self' data: https: blob:;" +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
|
||||
"style-src 'self' 'unsafe-inline';" +
|
||||
"font-src 'self' data:;",
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Load the index.html
|
||||
mainWindow
|
||||
.loadFile(indexPath)
|
||||
.then(() => {
|
||||
logger.log("Successfully loaded index.html");
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
logger.log("DevTools opened - running in dev mode");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to load index.html:", err);
|
||||
logger.error("Attempted path:", indexPath);
|
||||
});
|
||||
|
||||
// Listen for console messages from the renderer
|
||||
mainWindow.webContents.on("console-message", (_event, _level, message) => {
|
||||
logger.log("Renderer Console:", message);
|
||||
});
|
||||
|
||||
// Add right after creating the BrowserWindow
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription) => {
|
||||
logger.error("Page failed to load:", errorCode, errorDescription);
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
logger.error("Preload script error:", preloadPath, error);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on(
|
||||
"console-message",
|
||||
(_event, _level, message, line, sourceId) => {
|
||||
logger.log("Renderer Console:", line, sourceId, message);
|
||||
},
|
||||
);
|
||||
|
||||
// Enable remote debugging when in dev mode
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle app ready
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
// Handle all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle any errors
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error("Uncaught Exception:", error);
|
||||
});
|
||||
+29
-29
@@ -1,78 +1,78 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
const logger = {
|
||||
log: (message, ...args) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
/* eslint-disable no-console */
|
||||
console.log(message, ...args);
|
||||
console.log(message, ...args)
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
},
|
||||
warn: (message, ...args) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
/* eslint-disable no-console */
|
||||
console.warn(message, ...args);
|
||||
console.warn(message, ...args)
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
},
|
||||
error: (message, ...args) => {
|
||||
/* eslint-disable no-console */
|
||||
console.error(message, ...args); // Errors should always be logged
|
||||
console.error(message, ...args) // Errors should always be logged
|
||||
/* eslint-enable no-console */
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use a more direct path resolution approach
|
||||
const getPath = (pathType) => {
|
||||
switch (pathType) {
|
||||
case "userData":
|
||||
case 'userData':
|
||||
return (
|
||||
process.env.APPDATA ||
|
||||
(process.platform === "darwin"
|
||||
(process.platform === 'darwin'
|
||||
? `${process.env.HOME}/Library/Application Support`
|
||||
: `${process.env.HOME}/.local/share`)
|
||||
);
|
||||
case "home":
|
||||
return process.env.HOME;
|
||||
case "appPath":
|
||||
return process.resourcesPath;
|
||||
)
|
||||
case 'home':
|
||||
return process.env.HOME
|
||||
case 'appPath':
|
||||
return process.resourcesPath
|
||||
default:
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
logger.log("Preload script starting...");
|
||||
logger.log('Preload script starting...')
|
||||
|
||||
try {
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Path utilities
|
||||
getPath,
|
||||
|
||||
// IPC functions
|
||||
send: (channel, data) => {
|
||||
const validChannels = ["toMain"];
|
||||
const validChannels = ['toMain']
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.send(channel, data);
|
||||
ipcRenderer.send(channel, data)
|
||||
}
|
||||
},
|
||||
receive: (channel, func) => {
|
||||
const validChannels = ["fromMain"];
|
||||
const validChannels = ['fromMain']
|
||||
if (validChannels.includes(channel)) {
|
||||
ipcRenderer.on(channel, (event, ...args) => func(...args));
|
||||
ipcRenderer.on(channel, (event, ...args) => func(...args))
|
||||
}
|
||||
},
|
||||
// Environment info
|
||||
env: {
|
||||
isElectron: true,
|
||||
isDev: process.env.NODE_ENV === "development",
|
||||
isDev: process.env.NODE_ENV === 'development'
|
||||
},
|
||||
// Path utilities
|
||||
getBasePath: () => {
|
||||
return process.env.NODE_ENV === "development" ? "/" : "./";
|
||||
},
|
||||
});
|
||||
return process.env.NODE_ENV === 'development' ? '/' : './'
|
||||
}
|
||||
})
|
||||
|
||||
logger.log("Preload script completed successfully");
|
||||
logger.log('Preload script completed successfully')
|
||||
} catch (error) {
|
||||
logger.error("Error in preload script:", error);
|
||||
logger.error('Error in preload script:', error)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user