Compare commits

..

5 Commits

Author SHA1 Message Date
Matthew Raymer
3c515719e0 chore: update packages after merging electron fixes 2025-03-26 11:45:54 +00:00
Matthew Raymer
5bc7e93b4b Merge branch 'electron_fix_20250317' into fix-service-worker 2025-03-26 11:00:22 +00:00
Matthew Raymer
ee6124021d fix: improve service worker registration and debugging
- Add detailed logging for service worker registration process
- Fix PWA enabling logic in vite config to properly handle web builds
- Update serve script to explicitly set production mode
- Add better error handling and registration status reporting
2025-03-26 10:21:36 +00:00
Matthew Raymer
ae25a066f2 Merge branch 'master' into electron_fix_20250317 2025-03-24 12:19:33 +00:00
Matthew Raymer
4230deab1d fix: Improve Electron application stability and asset handling
- Completely rewrite main.js for reliable asset loading
- Update preload.js with proper security context isolation
- Fix file:// protocol handling for application resources
- Add proper error logging and reporting in Electron context
- Disable service workers in Electron environment
- Fix path resolution for assets in packaged application
- Add prerequisite checking for Electron builds
- Update electron-builder configuration

The changes resolve persistent issues with:
1. Missing assets in packaged application
2. Incorrect path resolution in production builds
3. Service worker conflicts in desktop environment
4. Security context handling in preload script
5. Electron build process reliability
2025-03-17 07:18:06 +00:00
239 changed files with 13053 additions and 17763 deletions

View File

@@ -1,8 +0,0 @@
---
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.

View File

@@ -1,7 +0,0 @@
---
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.

View File

@@ -1,9 +0,0 @@
---
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

View File

@@ -1,6 +0,0 @@
---
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.

View File

@@ -1,270 +0,0 @@
---
description:
globs:
alwaysApply: true
---
# 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

View File

@@ -1,16 +0,0 @@
---
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

31
.eslintrc.js Normal file
View File

@@ -0,0 +1,31 @@
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"
},
};

View File

@@ -1,57 +0,0 @@
{
"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"
}
]
}

View File

@@ -1,55 +0,0 @@
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']
}
}
}
};

18
.gitignore vendored
View File

@@ -38,20 +38,30 @@ pnpm-debug.log*
/dist-capacitor/ /dist-capacitor/
/test-playwright-results/ /test-playwright-results/
playwright-tests playwright-tests
test-playwright
dist-electron-packages dist-electron-packages
ios
.ruby-version .ruby-version
+.env +.env
# Generated test files # Generated test files
.generated/ .generated/
# Fastlane
ios/fastlane/report.xml
ios/fastlane/Preview.html
ios/fastlane/screenshots
ios/fastlane/test_output
android/fastlane/report.xml
android/fastlane/Preview.html
android/fastlane/screenshots
android/fastlane/test_output
.env.default .env.default
vendor/ vendor/
# Build logs # Build logs
build_logs/ build_logs/
android/app/src/main/assets/public # Android generated assets
android/app/src/main/res android/app/src/main/assets/public/assets/
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
android/.gradle/file-system.probe

View File

@@ -9,19 +9,8 @@ For a quick dev environment setup, use [pkgx](https://pkgx.dev).
- Node.js (LTS version recommended) - Node.js (LTS version recommended)
- npm (comes with Node.js) - npm (comes with Node.js)
- Git - Git
- For iOS builds: macOS with Xcode installed
- For Android builds: Android Studio with SDK installed - For Android builds: Android Studio with SDK installed
- For iOS builds: macOS with Xcode and ruby gems & bundle
- pkgx +rubygems.org sh
- ... and you may have to fix these, especially with pkgx
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
- For desktop builds: Additional build tools based on your OS - For desktop builds: Additional build tools based on your OS
## Forks ## Forks
@@ -33,23 +22,26 @@ If you have forked this to make your own app, you'll want to customize the iOS &
npx cap add ios npx cap add ios
``` ```
You'll also want to edit the deep link configuration (see below). You'll also want to edit the deep link configuration.
## Initial Setup ## Initial Setup
Install dependencies: 1. Clone the repository:
```bash
git clone [repository-url]
cd TimeSafari
```
2. Install dependencies:
```bash ```bash
npm install npm install
``` ```
## Web Dev Locally ## Web Build
```bash To build for web deployment:
npm run dev
```
## Web Build for Server
1. Run the production build: 1. Run the production build:
@@ -57,66 +49,17 @@ Install dependencies:
npm run build npm run build
``` ```
The built files will be in the `dist` directory. 2. The built files will be in the `dist` directory.
2. To test the production build locally: 3. To test the production build locally:
You'll likely want to use test locations for the Endorser & image & partner servers; see "DEFAULT_ENDORSER_API_SERVER" & "DEFAULT_IMAGE_API_SERVER" & "DEFAULT_PARTNER_API_SERVER" below.
```bash ```bash
npm run serve npm run serve
``` ```
### Compile and minify for test & production
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
* `npx prettier --write ./sw_scripts/`
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Commit everything (since the commit hash is used the app).
* Put the commit hash in the changelog (which will help you remember to bump the version later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
* For test, build the app (because test server is not yet set up to build):
```bash
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
```
... and transfer to the test server:
```bash
rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari
```
(Let's replace that with a .env.development or .env.staging file.)
(Note: The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.)
* For prod, get on the server and run the correct build:
... and log onto the server:
* `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.55 && npm install && npm run build && cd -`
(The plain `npm run build` uses the .env.production file.)
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
* 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.
## Desktop Build (Electron) ## Desktop Build (Electron)
### Linux Build ### Building for Linux
1. Build the electron app in production mode: 1. Build the electron app in production mode:
@@ -199,12 +142,6 @@ Prerequisites: macOS with Xcode installed
4. Use Xcode to build and run on simulator or device. 4. Use Xcode to build and run on simulator or device.
#### First-time iOS Configuration
- Generate certificates inside XCode.
- Right-click on App and under Signing & Capabilities set the Team.
### Android Build ### Android Build
Prerequisites: Android Studio with SDK installed Prerequisites: Android Studio with SDK installed
@@ -215,9 +152,6 @@ Prerequisites: Android Studio with SDK installed
rm -rf dist rm -rf dist
npm run build:web npm run build:web
npm run build:capacitor npm run build:capacitor
cd android
./gradlew clean
./gradlew assembleDebug
``` ```
2. Update Android project with latest build: 2. Update Android project with latest build:
@@ -240,7 +174,7 @@ Prerequisites: Android Studio with SDK installed
5. 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 ## Building Android from the console
```bash ```bash
cd android cd android
@@ -253,18 +187,11 @@ Prerequisites: Android Studio with SDK installed
... or, to create the `aab` file, `bundle` instead of `build`: ... or, to create the `aab` file, `bundle` instead of `build`:
```bash ```bash
./gradlew bundleDebug -Dlint.baselines.continue=true ./gradlew bundle -Dlint.baselines.continue=true
```
... 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
``` ```
## Configuring Android for deep links
## First-time Android Configuration for deep links
You must add the following intent filter to the `android/app/src/main/AndroidManifest.xml` file: You must add the following intent filter to the `android/app/src/main/AndroidManifest.xml` file:

View File

@@ -1,322 +0,0 @@
# 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

View File

@@ -19,6 +19,59 @@ npm run dev
See [BUILDING.md](BUILDING.md) for more details. See [BUILDING.md](BUILDING.md) for more details.
See the test locations for "IMAGE_API_SERVER" or "PARTNER_API_SERVER" below, or use http://localhost:3000 for local endorser.ch
### Run all UI tests
Look at [BUILDING.md](BUILDING.md) for the "test-all" instructions and [TESTING.md](test-playwright/TESTING.md) for more details.
### Compile and minify for test & production
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations.
* `npx prettier --write ./sw_scripts/`
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Commit everything (since the commit hash is used the app).
* Put the commit hash in the changelog (which will help you remember to bump the version later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
* For test, build the app (because test server is not yet set up to build):
```bash
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
```
... and transfer to the test server:
```bash
rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari
```
(Let's replace that with a .env.development or .env.staging file.)
(Note: The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.)
* For prod, get on the server and run the correct build:
... and log onto the server:
* `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.55 && npm install && npm run build && cd -`
(The plain `npm run build` uses the .env.production file.)
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
* 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.

3
android/.gitignore vendored
View File

@@ -1,8 +1,5 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore # Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
app/gradle.properties.secrets
app/time-safari-upload-key-pkcs12.jks
# Built application files # Built application files
*.apk *.apk
*.aar *.aar

View File

@@ -1,2 +1,2 @@
#Wed Apr 09 09:01:13 UTC 2025 #Fri Mar 21 07:27:50 UTC 2025
gradle.version=8.11.1 gradle.version=8.2.1

Binary file not shown.

View File

@@ -1,38 +1,14 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
// These are sample values to set in gradle.properties.secrets
// MY_KEYSTORE_FILE=time-safari-upload-key-pkcs12.jks
// MY_KEYSTORE_PASSWORD=...
// MY_KEY_ALIAS=time-safari-key-alias
// MY_KEY_PASSWORD=...
// Try to load from environment variables first
project.ext.MY_KEYSTORE_FILE = System.getenv('ANDROID_KEYSTORE_FILE') ?: ""
project.ext.MY_KEYSTORE_PASSWORD = System.getenv('ANDROID_KEYSTORE_PASSWORD') ?: ""
project.ext.MY_KEY_ALIAS = System.getenv('ANDROID_KEY_ALIAS') ?: ""
project.ext.MY_KEY_PASSWORD = System.getenv('ANDROID_KEY_PASSWORD') ?: ""
// If no environment variables, try to load from secrets file
if (!project.ext.MY_KEYSTORE_FILE) {
def secretsPropertiesFile = rootProject.file("app/gradle.properties.secrets")
if (secretsPropertiesFile.exists()) {
Properties secretsProperties = new Properties()
secretsProperties.load(new FileInputStream(secretsPropertiesFile))
secretsProperties.each { name, value ->
project.ext[name] = value
}
}
}
android { android {
namespace 'app.timesafari' namespace 'app.timesafari'
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
defaultConfig { defaultConfig {
applicationId "app.timesafari.app" applicationId "app.timesafari"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10 versionCode 1
versionName "0.4.4" versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -40,41 +16,10 @@ android {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
} }
} }
signingConfigs {
release {
if (project.ext.MY_KEYSTORE_FILE &&
project.ext.MY_KEYSTORE_PASSWORD &&
project.ext.MY_KEY_ALIAS &&
project.ext.MY_KEY_PASSWORD) {
storeFile file(project.ext.MY_KEYSTORE_FILE)
storePassword project.ext.MY_KEYSTORE_PASSWORD
keyAlias project.ext.MY_KEY_ALIAS
keyPassword project.ext.MY_KEY_PASSWORD
}
}
}
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Only sign if we have the signing config
if (signingConfigs.release.storeFile != null) {
signingConfig signingConfigs.release
}
}
}
// Enable bundle builds (without which it doesn't work right for bundleDebug vs bundleRelease)
bundle {
language {
enableSplit = true
}
density {
enableSplit = true
}
abi {
enableSplit = true
} }
} }
} }

View File

@@ -10,12 +10,6 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') 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')
} }

View File

@@ -21,6 +21,6 @@ public class ExampleInstrumentedTest {
// Context of the app under test. // Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("app.timesafari.app", appContext.getPackageName()); assertEquals("app.timesafari", appContext.getPackageName());
} }
} }

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -7,6 +8,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
@@ -14,6 +16,7 @@
android:label="@string/title_activity_main" android:label="@string/title_activity_main"
android:launchMode="singleTask" android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch"> android:theme="@style/AppTheme.NoActionBarLaunch">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -25,6 +28,7 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" /> <data android:scheme="timesafari" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider <provider
@@ -32,14 +36,13 @@
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> <meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider> </provider>
</application> </application>
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<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" />
</manifest> </manifest>

View File

@@ -16,14 +16,6 @@
} }
] ]
} }
},
"MLKitBarcodeScanner": {
"formats": [
"QR_CODE"
],
"detectorSize": 1,
"lensFacing": "back",
"googleBarcodeScannerModuleInstallState": true
} }
} }
} }

View File

@@ -2,29 +2,5 @@
{ {
"pkg": "@capacitor/app", "pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin" "classpath": "com.capacitorjs.plugins.app.AppPlugin"
},
{
"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"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
},
{
"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"
} }
] ]

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<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">
<link rel="icon" href="/favicon.ico">
<title>TimeSafari</title>
<script type="module" crossorigin src="/assets/index-CZMUlUNO.js"></script>
</head>
<body>
<noscript>
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -2,5 +2,4 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." /> <external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." /> <cache-path name="my_cache_images" path="." />
<files-path name="my_files" path="." />
</paths> </paths>

View File

@@ -7,7 +7,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.9.1' classpath 'com.android.tools.build:gradle:8.2.1'
classpath 'com.google.gms:google-services:4.4.0' classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong

View File

@@ -4,21 +4,3 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') 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')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
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')

View File

@@ -9,7 +9,7 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
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 org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
@@ -20,8 +20,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Djdk.io.File.enableADS=true
# Android operating system, and which are packaged with your app's APK # Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn # https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX android.suppressUnsupportedCompileSdk=34
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

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

BIN
assets/icon-only.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View File

@@ -18,12 +18,6 @@ const config: CapacitorConfig = {
} }
] ]
} }
},
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
} }
} }
}; };

36
electron-builder.json Normal file
View File

@@ -0,0 +1,36 @@
{
"appId": "app.timesafari.app",
"productName": "TimeSafari",
"directories": {
"output": "dist-electron-packages",
"buildResources": "build"
},
"files": [
"dist-electron/**/*",
"node_modules/**/*",
"package.json",
"src/electron/electron-logger.js"
],
"extraResources": [
{
"from": "src/utils",
"to": "utils",
"filter": ["**/*"]
}
],
"extraMetadata": {
"main": "src/electron/main.js"
},
"linux": {
"target": ["AppImage"],
"category": "Utility",
"maintainer": "TimeSafari Team"
},
"mac": {
"target": ["dmg"],
"category": "public.app-category.productivity"
},
"win": {
"target": ["nsis"]
}
}

23
ios/.gitignore vendored
View File

@@ -1,23 +0,0 @@
App/build
App/Pods
App/output
App/App/public
DerivedData
xcuserdata
*.xcuserstate
# Cordova plugins for Capacitor
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

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Time Safari.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -1,8 +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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -1,49 +0,0 @@
import UIKit
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
}
func applicationWillTerminate(_ application: UIApplication) {
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -1,14 +0,0 @@
{
"images": [
{
"idiom": "universal",
"size": "1024x1024",
"filename": "AppIcon-512@2x.png",
"platform": "ios"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,6 +0,0 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17132" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17105"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<imageView key="view" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Splash" id="snD-IY-ifK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</imageView>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="Splash" width="1366" height="1366"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14111" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
</dependencies>
<scenes>
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@@ -1,59 +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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>TimeSafari</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</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>

View File

@@ -1,30 +0,0 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
# Product -> Clean Build Folder after new Cordova plugins installed
# Requires CocoaPods 1.6 or newer
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 '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
capacitor_pods
# Add your Pods here
end
post_install do |installer|
assertDeploymentTarget(installer)
end

View File

@@ -1,28 +0,0 @@
PODS:
- Capacitor (6.2.0):
- CapacitorCordova
- CapacitorApp (6.0.2):
- Capacitor
- CapacitorCordova (6.2.0)
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
EXTERNAL SOURCES:
Capacitor:
:path: "../../node_modules/@capacitor/ios"
CapacitorApp:
:path: "../../node_modules/@capacitor/app"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
SPEC CHECKSUMS:
Capacitor: 05d35014f4425b0740fc8776481f6a369ad071bf
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9
PODFILE CHECKSUM: 4233f5c5f414604460ff96d372542c311b0fb7a8
COCOAPODS: 1.16.2

View File

@@ -1,414 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 48;
objects = {
/* Begin PBXBuildFile section */
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
/* End PBXBuildFile section */
/* 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 /* 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>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
isa = PBXGroup;
children = (
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
504EC2FB1FED79650016851F = {
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
);
sourceTree = "<group>";
};
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* Time Safari.app */,
);
name = Products;
sourceTree = "<group>";
};
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
504EC30E1FED79650016851F /* Assets.xcassets */,
504EC3101FED79650016851F /* LaunchScreen.storyboard */,
504EC3131FED79650016851F /* Info.plist */,
2FAD9762203C412B000D30F8 /* config.xml */,
50B271D01FEDC1A000F3C39B /* public */,
);
path = App;
sourceTree = "<group>";
};
7F8756D8B27F46E3366F6CEA /* Pods */ = {
isa = PBXGroup;
children = (
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
);
name = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* Time Safari */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */;
buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "Time Safari";
productName = App;
productReference = 504EC3041FED79650016851F /* Time Safari.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* Time Safari */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
504EC3021FED79650016851F /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */,
50B271D11FEDC1A000F3C39B /* public in Resources */,
504EC30F1FED79650016851F /* Assets.xcassets in Resources */,
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */,
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
504EC3001FED79650016851F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC30C1FED79650016851F /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
504EC3101FED79650016851F /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
504EC3111FED79650016851F /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
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 = 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";
};
name = Debug;
};
504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
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 = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
504EC3151FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,
504EC3181FED79650016851F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;
}

View File

@@ -1,22 +0,0 @@
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

2174
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,16 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "0.4.4", "version": "0.4.4",
"description": "Time Safari Application", "description": "TimeSafari Desktop Application",
"type": "module",
"author": { "author": {
"name": "TimeSafari Team" "name": "TimeSafari Team"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --config vite.config.dev.mts",
"dev:web": "vite --config vite.config.web.mts", "serve": "NODE_ENV=production vite preview --mode production --host",
"serve": "vite preview", "build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
"build": "vite build", "lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"build:mobile": "VITE_PLATFORM=capacitor vite build", "lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"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", "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:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
"test:prerequisites": "node scripts/check-prerequisites.js", "test:prerequisites": "node scripts/check-prerequisites.js",
@@ -27,16 +22,14 @@
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)", "check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"clean:electron": "rimraf dist-electron", "clean:electron": "rimraf dist-electron",
"build:pywebview": "vite build --config vite.config.pywebview.mts", "build:pywebview": "vite build --config vite.config.pywebview.mts",
"build:electron": "npm run clean:electron && vite build --config vite.config.electron.mts && node scripts/build-electron.js", "build:electron": "npm run check: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:capacitor": "vite build --config vite.config.capacitor.mts",
"build:web": "vite build --config vite.config.web.mts", "build:web": "vite build --config vite.config.web.mts",
"electron:dev": "npm run build && electron dist-electron", "electron:dev": "concurrently \"vite --config vite.config.electron.mts\" \"electron .\"",
"electron:start": "electron dist-electron", "electron:start": "electron dist-electron",
"clean:android": "adb uninstall app.timesafari.app || true", "electron:build-linux": "npm run check:electron && npm run build:electron && electron-builder --linux AppImage",
"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-deb": "npm run check:electron && npm run build:electron && electron-builder --linux deb",
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage", "electron:build-linux-prod": "NODE_ENV=production npm run check:electron &&npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
"build:electron-prod": "NODE_ENV=production npm run build:electron", "build:electron-prod": "NODE_ENV=production npm run build:electron",
"pywebview:dev": "vite build --config vite.config.pywebview.mts && .venv/bin/python src/pywebview/main.py", "pywebview:dev": "vite build --config vite.config.pywebview.mts && .venv/bin/python src/pywebview/main.py",
"pywebview:build": "vite build --config vite.config.pywebview.mts && .venv/bin/python src/pywebview/main.py", "pywebview:build": "vite build --config vite.config.pywebview.mts && .venv/bin/python src/pywebview/main.py",
@@ -46,19 +39,17 @@
"fastlane:ios:beta": "cd ios && fastlane beta", "fastlane:ios:beta": "cd ios && fastlane beta",
"fastlane:ios:release": "cd ios && fastlane release", "fastlane:ios:release": "cd ios && fastlane release",
"fastlane:android:beta": "cd android && fastlane beta", "fastlane:android:beta": "cd android && fastlane beta",
"fastlane:android:release": "cd android && fastlane release" "fastlane:android:release": "cd android && fastlane release",
"check:electron": "node scripts/check-electron-prerequisites.js",
"electron:build": "npm run check:electron && vite build --config vite.config.electron.mts && node scripts/fix-electron-paths.js && electron-builder",
"postinstall": "electron-builder install-app-deps"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^6.2.0", "@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
"@capacitor/camera": "^6.0.0",
"@capacitor/cli": "^6.2.0", "@capacitor/cli": "^6.2.0",
"@capacitor/clipboard": "^6.0.2", "@capacitor/core": "^6.2.0",
"@capacitor/core": "^6.2.1",
"@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",
@@ -86,10 +77,8 @@
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2", "asn1-ber": "^1.2.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"buffer": "^6.0.3",
"cbor-x": "^1.5.9", "cbor-x": "^1.5.9",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"crypto-browserify": "^3.12.1",
"dexie": "^3.2.7", "dexie": "^3.2.7",
"dexie-export-import": "^4.1.4", "dexie-export-import": "^4.1.4",
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
@@ -100,13 +89,12 @@
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0", "localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"merkletreejs": "^0.3.11", "merkletreejs": "^0.3.11",
"nostr-tools": "^2.12.0", "nostr-tools": "^2.10.4",
"notiwind": "^2.0.2", "notiwind": "^2.0.2",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pina": "^0.20.2204228", "pina": "^0.20.2204228",
@@ -132,7 +120,6 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@capacitor-mlkit/barcode-scanning": "^6.2.0",
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@playwright/test": "^1.45.2", "@playwright/test": "^1.45.2",
"@types/dom-webcodecs": "^0.1.7", "@types/dom-webcodecs": "^0.1.7",
@@ -148,23 +135,21 @@
"@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^12.0.0", "@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"electron": "^33.2.1", "electron": "^33.2.1",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"eslint": "^8.54.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-node": "^0.3.9", "eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-prettier": "^5.2.6", "eslint-plugin-vue": "^9.32.0",
"eslint-plugin-vue": "^9.33.0",
"espree": "^10.3.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"markdownlint": "^0.37.4", "markdownlint": "^0.37.4",
"markdownlint-cli": "^0.44.0", "markdownlint-cli": "^0.44.0",
"npm-check-updates": "^17.1.13", "npm-check-updates": "^17.1.13",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.5.3", "prettier": "^3.2.5",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",
@@ -175,28 +160,31 @@
"build": { "build": {
"appId": "app.timesafari", "appId": "app.timesafari",
"productName": "TimeSafari", "productName": "TimeSafari",
"directories": {
"output": "dist-electron-packages"
},
"files": [ "files": [
"dist-electron/**/*", "dist-electron/**/*",
"src/electron/**/*", "!dist-electron/node_modules/**/*"
"main.js"
], ],
"extraResources": [ "directories": {
{ "output": "dist-electron-packages",
"from": "dist-electron", "buildResources": "build-resources"
"to": "." },
} "extraResources": [],
"asar": true,
"asarUnpack": [
"dist-electron/www/assets/**/*"
], ],
"linux": { "linux": {
"target": [ "target": ["AppImage"],
"AppImage", "category": "Utility",
"deb" "executableName": "TimeSafari"
],
"category": "Office",
"icon": "build/icon.png"
}, },
"asar": true "mac": {
"category": "public.app-category.productivity"
},
"win": {
"target": ["nsis"]
},
"artifactName": "TimeSafari-${version}-${arch}.${ext}",
"publish": null
} }
} }

View File

@@ -46,21 +46,21 @@ export default defineConfig({
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
// { {
// name: 'chromium-serial', name: 'chromium-serial',
// testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/, testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
// use: { use: {
// ...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
// permissions: ["clipboard-read"], permissions: ["clipboard-read"],
// }, },
// workers: 1, // Force serial execution for problematic tests workers: 1, // Force serial execution for problematic tests
// }, },
// { {
// name: 'firefox-serial', name: 'firefox-serial',
// testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/, testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
// use: { ...devices['Desktop Firefox'] }, use: { ...devices['Desktop Firefox'] },
// workers: 1, workers: 1,
// }, },
{ {
name: 'chromium', name: 'chromium',
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/, testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
@@ -76,26 +76,32 @@ export default defineConfig({
}, },
/* Test against mobile viewports. */ /* Test against mobile viewports. */
// {
// name: "Mobile Chrome", {
// use: { ...devices["Pixel 5"] }, name: "Mobile Chrome",
// }, use: { ...devices["Pixel 5"] },
// { },
// name: "Mobile Safari", {
// use: { ...devices["iPhone 12"] }, name: "Mobile Safari",
// }, use: { ...devices["iPhone 12"] },
},
/* Test against branded browsers. */ /* Test against branded browsers. */
// { // {
// name: 'Microsoft Edge', // name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' }, // use: { ...devices['Desktop Edge'], channel: 'msedge' },
// }, // },
{
name: "Google Chrome",
use: { ...devices["Desktop Chrome"], channel: "chrome" },
},
], ],
/* Configure global timeout; default is 30000 milliseconds */ /* Configure global timeout; default is 30000 milliseconds */
// the image upload will often not succeed in 5 seconds // the image upload will often not succeed in 5 seconds
// 33-record-gift-x10.spec.ts:90:5 > Record 9 new gifts will often not succeed in 30 seconds // 33-record-gift-x10.spec.ts:90:5 > Record 9 new gifts will often not succeed in 30 seconds
timeout: 45000, // various tests fail at various times with 25000 timeout: 35000, // various tests fail at various times with 25000
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
/** /**

View File

@@ -88,6 +88,12 @@ async function main() {
throw new Error('package.json not found in build directory'); throw new Error('package.json not found in build directory');
} }
// Copy the electron-logger.js file
const loggerSrc = path.join(__dirname, '../src/electron/electron-logger.js');
const loggerDest = path.join(distElectronDir, 'electron-logger.js');
fs.copyFileSync(loggerSrc, loggerDest);
console.log(`Copying src/electron/electron-logger.js to ${loggerDest}`);
console.log('Build completed successfully!'); console.log('Build completed successfully!');
} catch (error) { } catch (error) {
console.error('Build failed:', error); console.error('Build failed:', error);

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env node
/**
* @file check-electron-prerequisites.js
* @description Verifies and installs required dependencies for Electron builds
*
* This script checks if Python's distutils module is available, which is required
* by node-gyp when compiling native Node.js modules during Electron packaging.
* Without distutils, builds will fail with "ModuleNotFoundError: No module named 'distutils'".
*
* The script performs the following actions:
* 1. Checks if Python's distutils module is available
* 2. If missing, offers to install setuptools package which provides distutils
* 3. Attempts installation through pip or pip3
* 4. Provides manual installation instructions if automated installation fails
*
* Usage:
* - Direct execution: node scripts/check-electron-prerequisites.js
* - As npm script: npm run check:electron
* - Before builds: npm run check:electron && electron-builder
*
* Exit codes:
* - 0: All prerequisites are met or were successfully installed
* - 1: Prerequisites are missing and weren't installed
*
* @author [YOUR_NAME]
* @version 1.0.0
* @license MIT
*/
const { execSync } = require('child_process');
const readline = require('readline');
const chalk = require('chalk'); // You might need to add this to your dependencies
console.log(chalk.blue('🔍 Checking Electron build prerequisites...'));
/**
* Checks if Python's distutils module is available
*
* This function attempts to import the distutils module in Python.
* If successful, it means node-gyp will be able to compile native modules.
* If unsuccessful, the Electron build will likely fail when compiling native dependencies.
*
* @returns {boolean} True if distutils is available, false otherwise
*
* @example
* if (checkDistutils()) {
* console.log('Ready to build Electron app');
* }
*/
function checkDistutils() {
try {
// Attempt to import distutils using Python
// We use stdio: 'ignore' to suppress any Python output
execSync('python -c "import distutils"', { stdio: 'ignore' });
console.log(chalk.green('✅ Python distutils is available'));
return true;
} catch (e) {
// This error occurs if either Python is not found or if distutils is missing
console.log(chalk.red('❌ Python distutils is missing'));
return false;
}
}
/**
* Installs the setuptools package which provides distutils
*
* This function attempts to install setuptools using pip or pip3.
* Setuptools is a package that provides the distutils module needed by node-gyp.
* In Python 3.12+, distutils was moved out of the standard library into setuptools.
*
* The function tries multiple installation methods:
* 1. First attempts with pip
* 2. If that fails, tries with pip3
* 3. If both fail, provides instructions for manual installation
*
* @returns {Promise<boolean>} True if installation succeeded, false otherwise
*
* @example
* const success = await installSetuptools();
* if (success) {
* console.log('Ready to proceed with build');
* } else {
* console.log('Please fix prerequisites manually');
* }
*/
async function installSetuptools() {
console.log(chalk.yellow('📦 Attempting to install setuptools...'));
try {
// First try with pip, commonly used on all platforms
execSync('pip install setuptools', { stdio: 'inherit' });
console.log(chalk.green('✅ Successfully installed setuptools'));
return true;
} catch (pipError) {
try {
// If pip fails, try with pip3 (common on Linux distributions)
console.log(chalk.yellow('⚠️ Trying with pip3...'));
execSync('pip3 install setuptools', { stdio: 'inherit' });
console.log(chalk.green('✅ Successfully installed setuptools using pip3'));
return true;
} catch (pip3Error) {
// If both methods fail, provide manual installation guidance
console.log(chalk.red('❌ Failed to install setuptools automatically'));
console.log(chalk.yellow('📝 Please install it manually with:'));
console.log(' pip install setuptools');
console.log(' or');
console.log(' sudo apt install python3-setuptools (on Debian/Ubuntu)');
console.log(' sudo pacman -S python-setuptools (on Arch Linux)');
console.log(' sudo dnf install python3-setuptools (on Fedora)');
console.log(' brew install python-setuptools (on macOS with Homebrew)');
return false;
}
}
}
/**
* Main execution function
*
* This function orchestrates the checking and installation process:
* 1. Checks if distutils is already available
* 2. If not, informs the user and prompts for installation
* 3. Based on user input, attempts to install or exits
*
* The function handles interactive user prompts and orchestrates
* the overall flow of the script.
*
* @returns {Promise<void>}
* @throws Will exit process with code 1 if prerequisites aren't met
*/
async function main() {
// First check if distutils is already available
if (checkDistutils()) {
// All prerequisites are met, exit successfully
process.exit(0);
}
// Inform the user about the missing prerequisite
console.log(chalk.yellow('⚠️ Python distutils is required for Electron builds'));
console.log(chalk.yellow('⚠️ This is needed to compile native modules during the build process'));
// Set up readline interface for user interaction
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// Prompt the user for installation permission
const answer = await new Promise(resolve => {
rl.question(chalk.blue('Would you like to install setuptools now? (y/n) '), resolve);
});
// Clean up readline interface
rl.close();
if (answer.toLowerCase() === 'y') {
// User agreed to installation
const success = await installSetuptools();
if (success) {
// Installation succeeded, exit successfully
process.exit(0);
} else {
// Installation failed, exit with error
process.exit(1);
}
} else {
// User declined installation
console.log(chalk.yellow('⚠️ Build may fail without distutils'));
process.exit(1);
}
}
// Execute the main function and handle any uncaught errors
main().catch(error => {
console.error(chalk.red('Error during prerequisites check:'), error);
process.exit(1);
});

View File

@@ -1,22 +0,0 @@
#!/bin/bash
# Clean the public directory
rm -rf android/app/src/main/assets/public/*
# Copy web assets
cp -r dist/* android/app/src/main/assets/public/
# Ensure the directory structure exists
mkdir -p android/app/src/main/assets/public/assets
# Copy the main index file
cp dist/index.html android/app/src/main/assets/public/
# Copy all assets
cp -r dist/assets/* android/app/src/main/assets/public/assets/
# Copy other necessary files
cp dist/favicon.ico android/app/src/main/assets/public/
cp dist/robots.txt android/app/src/main/assets/public/
echo "Web assets copied successfully!"

View File

@@ -0,0 +1,61 @@
/**
* Fix path resolution issues in the Electron build
*/
const fs = require('fs');
const path = require('path');
const glob = require('glob');
// Fix asset paths in HTML file
function fixHtmlPaths() {
const htmlFile = path.join(__dirname, '../dist-electron/index.html');
if (fs.existsSync(htmlFile)) {
let html = fs.readFileSync(htmlFile, 'utf8');
// Convert absolute paths to relative
html = html.replace(/src="\//g, 'src="./');
html = html.replace(/href="\//g, 'href="./');
fs.writeFileSync(htmlFile, html);
console.log('✅ Fixed paths in index.html');
}
}
// Fix asset imports in JS files
function fixJsPaths() {
const jsFiles = glob.sync('dist-electron/assets/*.js');
jsFiles.forEach(file => {
let content = fs.readFileSync(file, 'utf8');
// Replace absolute imports with relative ones
const originalContent = content;
content = content.replace(/["']\/assets\//g, '"./assets/');
if (content !== originalContent) {
fs.writeFileSync(file, content);
console.log(`✅ Fixed paths in ${path.basename(file)}`);
}
});
}
// Add base href to HTML
function addBaseHref() {
const htmlFile = path.join(__dirname, '../dist-electron/index.html');
if (fs.existsSync(htmlFile)) {
let html = fs.readFileSync(htmlFile, 'utf8');
// Add base href if not present
if (!html.includes('<base href=')) {
html = html.replace('</head>', '<base href="./">\n</head>');
fs.writeFileSync(htmlFile, html);
console.log('✅ Added base href to index.html');
}
}
}
// Run all fixes
fixHtmlPaths();
fixJsPaths();
addBaseHref();
console.log('🎉 Electron path fixes completed');

View File

@@ -1,22 +0,0 @@
#!/bin/bash
# Create directories if they don't exist
mkdir -p android/app/src/main/res/mipmap-mdpi
mkdir -p android/app/src/main/res/mipmap-hdpi
mkdir -p android/app/src/main/res/mipmap-xhdpi
mkdir -p android/app/src/main/res/mipmap-xxhdpi
mkdir -p android/app/src/main/res/mipmap-xxxhdpi
# Generate placeholder icons using ImageMagick
convert -size 48x48 xc:blue -gravity center -pointsize 20 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-mdpi/ic_launcher.png
convert -size 72x72 xc:blue -gravity center -pointsize 30 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-hdpi/ic_launcher.png
convert -size 96x96 xc:blue -gravity center -pointsize 40 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
convert -size 144x144 xc:blue -gravity center -pointsize 60 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
convert -size 192x192 xc:blue -gravity center -pointsize 80 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
# Copy to round versions
cp android/app/src/main/res/mipmap-mdpi/ic_launcher.png android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-hdpi/ic_launcher.png android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xhdpi/ic_launcher.png android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png

14
scripts/notarize.js Normal file
View File

@@ -0,0 +1,14 @@
// This is a placeholder notarize script that does nothing for non-macOS platforms
// Only necessary for macOS app store submissions
exports.default = async function notarizing(context) {
// Only notarize macOS builds
if (context.electronPlatformName !== 'darwin') {
console.log('Skipping notarization for non-macOS platform');
return;
}
// For macOS, we would implement actual notarization here
console.log('This is where macOS notarization would happen');
// We're just returning with no action for non-macOS builds
};

View File

@@ -29,9 +29,7 @@
> >
<div class="w-full px-4 py-3"> <div class="w-full px-4 py-3">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm"> <p class="text-sm">{{ notification.text }}</p>
{{ notification.text }}
</p>
</div> </div>
</div> </div>
@@ -42,20 +40,21 @@
<div <div
class="flex items-center justify-center w-12 bg-slate-600 text-slate-100" 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
icon="circle-info"
class="fa-fw fa-xl"
></font-awesome>
</div> </div>
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900"> <div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm"> <p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
{{ truncateLongWords(notification.text) }}
</p>
<button <button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600"
@click="close(notification.id)" @click="close(notification.id)"
> >
<font-awesome icon="xmark" class="fa-fw" /> <font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button> </button>
</div> </div>
</div> </div>
@@ -67,20 +66,21 @@
<div <div
class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100" 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
icon="circle-info"
class="fa-fw fa-xl"
></font-awesome>
</div> </div>
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900"> <div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm"> <p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
{{ truncateLongWords(notification.text) }}
</p>
<button <button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600"
@click="close(notification.id)" @click="close(notification.id)"
> >
<font-awesome icon="xmark" class="fa-fw" /> <font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button> </button>
</div> </div>
</div> </div>
@@ -92,20 +92,21 @@
<div <div
class="flex items-center justify-center w-12 bg-amber-600 text-amber-100" 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
icon="triangle-exclamation"
class="fa-fw fa-xl"
></font-awesome>
</div> </div>
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900"> <div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm"> <p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
{{ truncateLongWords(notification.text) }}
</p>
<button <button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600"
@click="close(notification.id)" @click="close(notification.id)"
> >
<font-awesome icon="xmark" class="fa-fw" /> <font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button> </button>
</div> </div>
</div> </div>
@@ -117,20 +118,21 @@
<div <div
class="flex items-center justify-center w-12 bg-rose-600 text-rose-100" 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
icon="triangle-exclamation"
class="fa-fw fa-xl"
></font-awesome>
</div> </div>
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900"> <div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm"> <p class="text-sm">{{ truncateLongWords(notification.text) }}</p>
{{ truncateLongWords(notification.text) }}
</p>
<button <button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600"
@click="close(notification.id)" @click="close(notification.id)"
> >
<font-awesome icon="xmark" class="fa-fw" /> <font-awesome icon="xmark" class="fa-fw"></font-awesome>
</button> </button>
</div> </div>
</div> </div>
@@ -181,33 +183,31 @@
<span class="font-semibold text-lg"> <span class="font-semibold text-lg">
{{ notification.title }} {{ notification.title }}
</span> </span>
<p class="text-sm mb-2"> <p class="text-sm mb-2">{{ notification.text }}</p>
{{ notification.text }}
</p>
<button <button
v-if="notification.onYes" 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" 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(); notification.onYes();
close(notification.id); close(notification.id);
}" "
> >
Yes{{ Yes{{
notification.yesText ? ', ' + notification.yesText : '' notification.yesText ? ", " + notification.yesText : ""
}} }}
</button> </button>
<button <button
v-if="notification.onNo" 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" 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); notification.onNo(stopAsking);
close(notification.id); close(notification.id);
stopAsking = false; // reset value stopAsking = false; // reset value
}" "
> >
No{{ notification.noText ? ', ' + notification.noText : '' }} No{{ notification.noText ? ", " + notification.noText : "" }}
</button> </button>
<label <label
@@ -228,25 +228,25 @@
class="sr-only" class="sr-only"
/> />
<!-- line --> <!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full" /> <div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot --> <!-- dot -->
<div <div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
/> ></div>
</div> </div>
</label> </label>
<button <button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" 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
? notification.onCancel(stopAsking) ? notification.onCancel(stopAsking)
: null; : null;
close(notification.id); close(notification.id);
stopAsking = false; // reset value for next time they open this modal stopAsking = false; // reset value for next time they open this modal
}" "
> >
{{ notification.onYes ? 'Cancel' : 'Close' }} {{ notification.onYes ? "Cancel" : "Close" }}
</button> </button>
</div> </div>
</div> </div>
@@ -306,10 +306,10 @@
<button <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" 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); close(notification.id);
turnOffNotifications(notification); turnOffNotifications(notification);
}" "
> >
Turn Off Notification Turn Off Notification
</button> </button>
@@ -329,21 +329,21 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from 'vue-facing-decorator' import { Vue, Component } from "vue-facing-decorator";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from './db/index' import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index";
import { NotificationIface } from './constants/app' import { NotificationIface } from "./constants/app";
import { logger } from './utils/logger' import { logger } from "./utils/logger";
interface Settings { interface Settings {
notifyingNewActivityTime?: string notifyingNewActivityTime?: string;
notifyingReminderTime?: string notifyingReminderTime?: string;
} }
@Component @Component
export default class App extends Vue { export default class App extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
stopAsking = false stopAsking = false;
// created() { // created() {
// logger.log( // logger.log(
@@ -382,158 +382,158 @@ export default class App extends Vue {
truncateLongWords(sentence: string) { truncateLongWords(sentence: string) {
return sentence return sentence
.split(' ') .split(" ")
.map((word) => (word.length > 30 ? word.slice(0, 30) + '...' : word)) .map((word) => (word.length > 30 ? word.slice(0, 30) + "..." : word))
.join(' ') .join(" ");
} }
async turnOffNotifications( async turnOffNotifications(
notification: NotificationIface notification: NotificationIface,
): Promise<boolean> { ): Promise<boolean> {
logger.log('Starting turnOffNotifications...') logger.log("Starting turnOffNotifications...");
let subscription: PushSubscriptionJSON | null = null let subscription: PushSubscriptionJSON | null = null;
let allGoingOff = false let allGoingOff = false;
try { try {
logger.log('Retrieving settings for the active account...') logger.log("Retrieving settings for the active account...");
const settings: Settings = await retrieveSettingsForActiveAccount() const settings: Settings = await retrieveSettingsForActiveAccount();
logger.log('Retrieved settings:', settings) logger.log("Retrieved settings:", settings);
const notifyingNewActivity = !!settings?.notifyingNewActivityTime const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
const notifyingReminder = !!settings?.notifyingReminderTime const notifyingReminder = !!settings?.notifyingReminderTime;
if (!notifyingNewActivity || !notifyingReminder) { if (!notifyingNewActivity || !notifyingReminder) {
allGoingOff = true allGoingOff = true;
logger.log('Both notifications are being turned off.') logger.log("Both notifications are being turned off.");
} }
logger.log('Checking service worker readiness...') logger.log("Checking service worker readiness...");
await navigator.serviceWorker?.ready await navigator.serviceWorker?.ready
.then((registration) => { .then((registration) => {
logger.log('Service worker is ready. Fetching subscription...') logger.log("Service worker is ready. Fetching subscription...");
return registration.pushManager.getSubscription() return registration.pushManager.getSubscription();
}) })
.then(async (subscript: PushSubscription | null) => { .then(async (subscript: PushSubscription | null) => {
if (subscript) { if (subscript) {
subscription = subscript.toJSON() subscription = subscript.toJSON();
logger.log('PushSubscription retrieved:', subscription) logger.log("PushSubscription retrieved:", subscription);
if (allGoingOff) { if (allGoingOff) {
logger.log('Unsubscribing from push notifications...') logger.log("Unsubscribing from push notifications...");
await subscript.unsubscribe() await subscript.unsubscribe();
logger.log('Successfully unsubscribed.') logger.log("Successfully unsubscribed.");
} }
} else { } else {
logConsoleAndDb('Subscription object is not available.') logConsoleAndDb("Subscription object is not available.");
logger.log('No subscription found.') logger.log("No subscription found.");
} }
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb( logConsoleAndDb(
'Push provider server communication failed: ' + "Push provider server communication failed: " +
JSON.stringify(error), JSON.stringify(error),
true true,
) );
logger.error('Error during subscription fetch:', error) logger.error("Error during subscription fetch:", error);
}) });
if (!subscription) { if (!subscription) {
logger.log('No subscription available. Notifying user...') logger.log("No subscription available. Notifying user...");
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'info', type: "info",
title: 'Finished', title: "Finished",
text: 'Notifications are off.' text: "Notifications are off.",
}, },
5000 5000,
) );
logger.log('Exiting as there is no subscription to process.') logger.log("Exiting as there is no subscription to process.");
return true return true;
} }
const serverSubscription = { const serverSubscription = {
...subscription ...subscription,
} };
if (!allGoingOff) { if (!allGoingOff) {
serverSubscription['notifyType'] = notification.title serverSubscription["notifyType"] = notification.title;
logger.log( logger.log(
`Server subscription updated with notifyType: ${notification.title}` `Server subscription updated with notifyType: ${notification.title}`,
) );
} }
logger.log('Sending unsubscribe request to the server...') logger.log("Sending unsubscribe request to the server...");
const pushServerSuccess = await fetch('/web-push/unsubscribe', { const pushServerSuccess = await fetch("/web-push/unsubscribe", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify(serverSubscription) body: JSON.stringify(serverSubscription),
}) })
.then(async (response) => { .then(async (response) => {
if (!response.ok) { if (!response.ok) {
const errorBody = await response.text() const errorBody = await response.text();
logConsoleAndDb( logConsoleAndDb(
`Push server failed: ${response.status} ${errorBody}`, `Push server failed: ${response.status} ${errorBody}`,
true true,
) );
logger.error('Push server error response:', errorBody) logger.error("Push server error response:", errorBody);
} }
logger.log(`Server response status: ${response.status}`) logger.log(`Server response status: ${response.status}`);
return response.ok return response.ok;
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb( logConsoleAndDb(
'Push server communication failed: ' + JSON.stringify(error), "Push server communication failed: " + JSON.stringify(error),
true true,
) );
logger.error('Error during server communication:', error) logger.error("Error during server communication:", error);
return false return false;
}) });
const message = pushServerSuccess const message = pushServerSuccess
? 'Notification is off.' ? "Notification is off."
: 'Notification is still on. Try to turn it off again.' : "Notification is still on. Try to turn it off again.";
logger.log('Server response processed. Message:', message) logger.log("Server response processed. Message:", message);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'info', type: "info",
title: 'Finished', title: "Finished",
text: message text: message,
}, },
5000 5000,
) );
if (notification.callback) { if (notification.callback) {
logger.log('Executing notification callback...') logger.log("Executing notification callback...");
notification.callback(pushServerSuccess) notification.callback(pushServerSuccess);
} }
logger.log( logger.log(
'Completed turnOffNotifications with success:', "Completed turnOffNotifications with success:",
pushServerSuccess pushServerSuccess,
) );
return pushServerSuccess return pushServerSuccess;
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
'Error turning off notifications: ' + JSON.stringify(error), "Error turning off notifications: " + JSON.stringify(error),
true true,
) );
logger.error('Critical error in turnOffNotifications:', error) logger.error("Critical error in turnOffNotifications:", error);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'error', type: "error",
title: 'Error', title: "Error",
text: 'Failed to turn off notifications. Please try again.' text: "Failed to turn off notifications. Please try again.",
}, },
5000 5000,
) );
return false return false;
} }
} }
} }

View File

@@ -10,10 +10,8 @@
</span> </span>
</div> </div>
<div <div class="bg-slate-100 rounded-t-md border border-slate-300 p-3 sm:p-4">
class="flex items-center justify-between gap-2 text-lg bg-slate-200 border border-slate-300 border-b-0 rounded-t-md px-3 sm:px-4 py-1 sm:py-2" <div class="flex items-center gap-2 mb-6">
>
<div class="flex items-center gap-2">
<div v-if="record.issuerDid"> <div v-if="record.issuerDid">
<EntityIcon <EntityIcon
:entity-id="record.issuerDid" :entity-id="record.issuerDid"
@@ -29,7 +27,7 @@
<div> <div>
<h3 class="font-semibold"> <h3 class="font-semibold">
{{ record.issuer.known ? record.issuer.displayName : '' }} {{ record.issuer.known ? record.issuer.displayName : "" }}
</h3> </h3>
<p class="ms-auto text-xs text-slate-500 italic"> <p class="ms-auto text-xs text-slate-500 italic">
{{ friendlyDate }} {{ friendlyDate }}
@@ -37,16 +35,10 @@
</div> </div>
</div> </div>
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
<font-awesome icon="circle-info" class="fa-fw text-slate-500" />
</a>
</div>
<div class="bg-slate-100 rounded-b-md border border-slate-300 p-3 sm:p-4">
<!-- Record Image --> <!-- Record Image -->
<div <div
v-if="record.image" v-if="record.image"
class="bg-cover mb-6 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4" class="bg-cover mb-6 -mx-3 sm:-mx-4"
:style="`background-image: url(${record.image});`" :style="`background-image: url(${record.image});`"
> >
<a <a
@@ -62,12 +54,10 @@
</a> </a>
</div> </div>
<div <div class="relative flex justify-between gap-4 max-w-lg mx-auto mb-5">
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-5"
>
<!-- Source --> <!-- Source -->
<div <div
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3" class="w-28 sm:w-40 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
> >
<div class="relative w-fit mx-auto"> <div class="relative w-fit mx-auto">
<div> <div>
@@ -96,10 +86,8 @@
</div> </div>
</div> </div>
</div> </div>
<div <div class="text-xs mt-2 line-clamp-3 sm:line-clamp-2">
v-if="record.providerPlanName || record.giver.known" <div v-if="record.providerPlanName || record.giver.known">
class="text-xs mt-2 truncate"
>
<font-awesome <font-awesome
:icon="record.providerPlanName ? 'users' : 'user'" :icon="record.providerPlanName ? 'users' : 'user'"
class="fa-fw text-slate-400" class="fa-fw text-slate-400"
@@ -107,12 +95,13 @@
{{ record.providerPlanName || record.giver.displayName }} {{ record.providerPlanName || record.giver.displayName }}
</div> </div>
</div> </div>
</div>
<!-- Arrow --> <!-- Arrow -->
<div <div
class="absolute inset-x-[8rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2" class="absolute inset-x-28 sm:inset-x-40 mx-2 top-1/2 -translate-y-1/2"
> >
<div class="text-sm text-center leading-none font-semibold pe-[15px]"> <div class="text-sm text-center leading-none font-semibold">
{{ fetchAmount }} {{ fetchAmount }}
</div> </div>
@@ -123,13 +112,13 @@
<div <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" 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>
</div> </div>
<!-- Destination --> <!-- Destination -->
<div <div
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3" class="w-28 sm:w-40 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
> >
<div class="relative w-fit mx-auto"> <div class="relative w-fit mx-auto">
<div> <div>
@@ -158,10 +147,8 @@
</div> </div>
</div> </div>
</div> </div>
<div <div class="text-xs mt-2 line-clamp-3 sm:line-clamp-2">
v-if="record.recipientProjectName || record.receiver.known" <div v-if="record.recipientProjectName || record.receiver.known">
class="text-xs mt-2 truncate"
>
<font-awesome <font-awesome
:icon="record.recipientProjectName ? 'users' : 'user'" :icon="record.recipientProjectName ? 'users' : 'user'"
class="fa-fw text-slate-400" class="fa-fw text-slate-400"
@@ -170,6 +157,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Description --> <!-- Description -->
<p class="font-medium"> <p class="font-medium">
@@ -178,63 +166,75 @@
</a> </a>
</p> </p>
</div> </div>
<div
class="flex items-center gap-2 text-lg bg-slate-300 rounded-b-md px-3 sm:px-4 py-1 sm:py-2"
>
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
<font-awesome icon="circle-info" class="fa-fw text-slate-500" />
</a>
</div>
</li> </li>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-facing-decorator' import { Component, Prop, Vue } from "vue-facing-decorator";
import { GiveRecordWithContactInfo } from '../types' import { GiveRecordWithContactInfo } from "../types";
import EntityIcon from './EntityIcon.vue' import EntityIcon from "./EntityIcon.vue";
import { isGiveClaimType, notifyWhyCannotConfirm } from '../libs/util' import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
import { containsHiddenDid } from '../libs/endorserServer' import { containsHiddenDid } from "../libs/endorserServer";
import ProjectIcon from './ProjectIcon.vue' import ProjectIcon from "./ProjectIcon.vue";
@Component({ @Component({
components: { components: {
EntityIcon, EntityIcon,
ProjectIcon ProjectIcon,
} },
}) })
export default class ActivityListItem extends Vue { export default class ActivityListItem extends Vue {
@Prop() record!: GiveRecordWithContactInfo @Prop() record!: GiveRecordWithContactInfo;
@Prop() lastViewedClaimId?: string @Prop() lastViewedClaimId?: string;
@Prop() isRegistered!: boolean @Prop() isRegistered!: boolean;
@Prop() activeDid!: string @Prop() activeDid!: string;
@Prop() confirmerIdList?: string[] @Prop() confirmerIdList?: string[];
get fetchAmount(): string { get fetchAmount(): string {
const claim = 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 const amount = claim.object?.amountOfThisGood
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood) ? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
: '' : "";
return amount return amount;
} }
get description(): string { get description(): string {
const claim = const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim (this.record.fullClaim as unknown).claim || this.record.fullClaim;
return `${claim.description}` if (!claim.description) {
return "something not described";
}
return `${claim.description}`;
} }
private displayAmount(code: string, amt: number) { 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) { private currencyShortWordForCode(unitCode: string, single: boolean) {
return unitCode === 'HUR' ? (single ? 'hour' : 'hours') : unitCode return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
} }
get canConfirm(): boolean { get canConfirm(): boolean {
if (!this.isRegistered) return false if (!this.isRegistered) return false;
if (!isGiveClaimType(this.record.fullClaim?.['@type'])) return false if (!isGiveClaimType(this.record.fullClaim?.["@type"])) return false;
if (this.confirmerIdList?.includes(this.activeDid)) return false if (this.confirmerIdList?.includes(this.activeDid)) return false;
if (this.record.issuerDid === this.activeDid) return false if (this.record.issuerDid === this.activeDid) return false;
if (containsHiddenDid(this.record.fullClaim)) return false if (containsHiddenDid(this.record.fullClaim)) return false;
return true return true;
} }
handleConfirmClick() { handleConfirmClick() {
@@ -242,24 +242,24 @@ export default class ActivityListItem extends Vue {
notifyWhyCannotConfirm( notifyWhyCannotConfirm(
this.$notify, this.$notify,
this.isRegistered, this.isRegistered,
this.record.fullClaim?.['@type'], this.record.fullClaim?.["@type"],
this.record, this.record,
this.activeDid, this.activeDid,
this.confirmerIdList this.confirmerIdList,
) );
return return;
} }
this.$emit('confirmClaim', this.record) this.$emit("confirmClaim", this.record);
} }
get friendlyDate(): string { get friendlyDate(): string {
const date = new Date(this.record.issuedAt) const date = new Date(this.record.issuedAt);
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(undefined, {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric' day: "numeric",
}) });
} }
} }
</script> </script>

View File

@@ -26,9 +26,7 @@
> >
<div class="w-full px-6 py-6 text-slate-900 text-center"> <div class="w-full px-6 py-6 text-slate-900 text-center">
<span class="font-semibold text-lg">{{ title }}</span> <span class="font-semibold text-lg">{{ title }}</span>
<p class="text-sm mb-2"> <p class="text-sm mb-2">{{ text }}</p>
{{ text }}
</p>
<button <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" class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2"
@@ -67,48 +65,48 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-facing-decorator' import { Component, Vue } from "vue-facing-decorator";
import { NotificationIface } from '../constants/app' import { NotificationIface } from "../constants/app";
@Component @Component
export default class PromptDialog extends Vue { export default class PromptDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
title = '' title = "";
text = '' text = "";
option1Text = '' option1Text = "";
option2Text = '' option2Text = "";
option3Text = '' option3Text = "";
onOption1?: () => void onOption1?: () => void;
onOption2?: () => void onOption2?: () => void;
onOption3?: () => void onOption3?: () => void;
onCancel?: () => Promise<void> onCancel?: () => Promise<void>;
open(options: { open(options: {
title: string title: string;
text: string text: string;
option1Text?: string option1Text?: string;
option2Text?: string option2Text?: string;
option3Text?: string option3Text?: string;
onOption1?: () => void onOption1?: () => void;
onOption2?: () => void onOption2?: () => void;
onOption3?: () => void onOption3?: () => void;
onCancel?: () => Promise<void> onCancel?: () => Promise<void>;
}) { }) {
this.title = options.title this.title = options.title;
this.text = options.text this.text = options.text;
this.option1Text = options.option1Text || '' this.option1Text = options.option1Text || "";
this.option2Text = options.option2Text || '' this.option2Text = options.option2Text || "";
this.option3Text = options.option3Text || '' this.option3Text = options.option3Text || "";
this.onOption1 = options.onOption1 this.onOption1 = options.onOption1;
this.onOption2 = options.onOption2 this.onOption2 = options.onOption2;
this.onOption3 = options.onOption3 this.onOption3 = options.onOption3;
this.onCancel = options.onCancel this.onCancel = options.onCancel;
this.$notify( this.$notify(
{ {
group: 'customModal', group: "customModal",
type: 'confirm', type: "confirm",
title: this.title, title: this.title,
text: this.text, text: this.text,
option1Text: this.option1Text, option1Text: this.option1Text,
@@ -117,38 +115,38 @@ export default class PromptDialog extends Vue {
onOption1: this.onOption1, onOption1: this.onOption1,
onOption2: this.onOption2, onOption2: this.onOption2,
onOption3: this.onOption3, onOption3: this.onOption3,
onCancel: this.onCancel onCancel: this.onCancel,
} as NotificationIface, } as NotificationIface,
-1 -1,
) );
} }
handleOption1(close: (id: string) => void) { handleOption1(close: (id: string) => void) {
if (this.onOption1) { if (this.onOption1) {
this.onOption1() this.onOption1();
} }
close('string that does not matter') close("string that does not matter");
} }
handleOption2(close: (id: string) => void) { handleOption2(close: (id: string) => void) {
if (this.onOption2) { if (this.onOption2) {
this.onOption2() this.onOption2();
} }
close('string that does not matter') close("string that does not matter");
} }
handleOption3(close: (id: string) => void) { handleOption3(close: (id: string) => void) {
if (this.onOption3) { if (this.onOption3) {
this.onOption3() this.onOption3();
} }
close('string that does not matter') close("string that does not matter");
} }
handleCancel(close: (id: string) => void) { handleCancel(close: (id: string) => void) {
if (this.onCancel) { if (this.onCancel) {
this.onCancel() this.onCancel();
} }
close('string that does not matter') close("string that does not matter");
} }
} }
</script> </script>

View File

@@ -2,9 +2,7 @@
<template> <template>
<div v-if="visible" class="dialog-overlay"> <div v-if="visible" class="dialog-overlay">
<div class="dialog"> <div class="dialog">
<h1 class="text-xl font-bold text-center mb-4"> <h1 class="text-xl font-bold text-center mb-4">{{ title }}</h1>
{{ title }}
</h1>
{{ message }} {{ message }}
Note that their name is only stored on this device. Note that their name is only stored on this device.
<input <input
@@ -37,43 +35,43 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from 'vue-facing-decorator' import { Vue, Component } from "vue-facing-decorator";
@Component @Component
export default class ContactNameDialog extends Vue { export default class ContactNameDialog extends Vue {
cancelCallback: () => void = () => {} cancelCallback: () => void = () => {};
saveCallback: (name?: string) => void = () => {} saveCallback: (name?: string) => void = () => {};
message = '' message = "";
newText = '' newText = "";
title = 'Contact Name' title = "Contact Name";
visible = false visible = false;
async open( async open(
title?: string, title?: string,
message?: string, message?: string,
saveCallback?: (name?: string) => void, saveCallback?: (name?: string) => void,
cancelCallback?: () => void, cancelCallback?: () => void,
defaultName?: string defaultName?: string,
) { ) {
this.cancelCallback = cancelCallback || this.cancelCallback this.cancelCallback = cancelCallback || this.cancelCallback;
this.saveCallback = saveCallback || this.saveCallback this.saveCallback = saveCallback || this.saveCallback;
this.message = message ?? this.message this.message = message ?? this.message;
this.newText = defaultName ?? '' this.newText = defaultName ?? "";
this.title = title ?? this.title this.title = title ?? this.title;
this.visible = true this.visible = true;
} }
async onClickSaveChanges() { async onClickSaveChanges() {
this.visible = false this.visible = false;
if (this.saveCallback) { if (this.saveCallback) {
this.saveCallback(this.newText) this.saveCallback(this.newText);
} }
} }
onClickCancel() { onClickCancel() {
this.visible = false this.visible = false;
if (this.cancelCallback) { if (this.cancelCallback) {
this.cancelCallback() this.cancelCallback();
} }
} }
} }

View File

@@ -1,196 +0,0 @@
/** * Data Export Section Component * * Provides UI and functionality for
exporting user data and backing up identifier seeds. * Includes buttons for seed
backup and database export, with platform-specific download instructions. * *
@component * @displayName DataExportSection * @example * ```vue *
<DataExportSection :active-did="currentDid" />
* ``` */
<template>
<div
id="sectionDataExport"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<div class="mb-2 font-bold">Data Export</div>
<router-link
v-if="activeDid"
:to="{ name: 'seed-backup' }"
class="block w-full text-center text-md 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-1.5 py-2 rounded-md mb-2 mt-2"
>
Backup Identifier Seed
</router-link>
<button
:class="computedStartDownloadLinkClassNames()"
class="block w-full text-center text-md 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-1.5 py-2 rounded-md"
@click="exportDatabase()"
>
Download Settings & Contacts
<br />
(excluding Identifier Data)
</button>
<a
ref="downloadLink"
:class="computedDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
>
If no download happened yet, click again here to download now.
</a>
<div v-if="platformCapabilities.needsFileHandlingInstructions" class="mt-4">
<p>
After the download, you can save the file in your preferred storage
location.
</p>
<ul>
<li
v-if="platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On iOS: You will be prompted to choose a location to save your backup
file.
</li>
<li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On Android: You will be prompted to choose a location to save your
backup file.
</li>
</ul>
</div>
</div>
</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 {
PlatformService,
PlatformCapabilities
} from '../services/PlatformService'
/**
* @vue-component
* Data Export Section Component
* Handles database export and seed backup functionality with platform-specific behavior
*/
@Component
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
/**
* Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option
* @required
*/
@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 = ''
/**
* Platform service instance for platform-specific operations
*/
private platformService: PlatformService =
PlatformServiceFactory.getInstance()
/**
* Platform capabilities for the current platform
*/
private get platformCapabilities(): PlatformCapabilities {
return this.platformService.getCapabilities()
}
/**
* Lifecycle hook to clean up resources
* Revokes object URL when component is unmounted (web platform only)
*/
beforeUnmount() {
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) {
URL.revokeObjectURL(this.downloadUrl)
}
}
/**
* Exports the database to a JSON file
* Uses platform-specific methods for saving the exported data
* Shows success/error notifications to user
*
* @throws {Error} If export fails
* @emits {Notification} Success or error notification
*/
public async exportDatabase() {
try {
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)
} else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to app directory
const content = await blob.text()
await this.platformService.writeAndShareFile(fileName, content)
}
this.$notify(
{
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.'
},
-1
)
} catch (error) {
logger.error('Export Error:', error)
this.$notify(
{
group: 'alert',
type: 'danger',
title: 'Export Error',
text: 'There was an error exporting the data.'
},
3000
)
}
}
/**
* Computes class names for the initial download button
* @returns Object with 'hidden' class when download is in progress (web platform only)
*/
public computedStartDownloadLinkClassNames() {
return {
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload
}
}
/**
* Computes class names for the secondary download link
* @returns Object with 'hidden' class when no download is available or not on web platform
*/
public computedDownloadLinkClassNames() {
return {
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload
}
}
}
</script>

View File

@@ -1,40 +1,40 @@
<template> <template>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div class="w-fit" v-html="generateIcon()" /> <div class="w-fit" v-html="generateIcon()"></div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { createAvatar, StyleOptions } from '@dicebear/core' import { createAvatar, StyleOptions } from "@dicebear/core";
import { avataaars } from '@dicebear/collection' import { avataaars } from "@dicebear/collection";
import { Vue, Component, Prop } from 'vue-facing-decorator' import { Vue, Component, Prop } from "vue-facing-decorator";
import { Contact } from '../db/tables/contacts' import { Contact } from "../db/tables/contacts";
@Component @Component
export default class EntityIcon extends Vue { export default class EntityIcon extends Vue {
@Prop contact: Contact @Prop contact: Contact;
@Prop entityId = '' // overridden by contact.did or profileImageUrl @Prop entityId = ""; // overridden by contact.did or profileImageUrl
@Prop iconSize = 0 @Prop iconSize = 0;
@Prop profileImageUrl = '' // overridden by contact.profileImageUrl @Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
generateIcon() { generateIcon() {
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
if (imageUrl) { 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 { } else {
const identifier = this.contact?.did || this.entityId const identifier = this.contact?.did || this.entityId;
if (!identifier) { 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= // https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library. // ... 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 // "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. // ... which looks similar to '' at the dicebear site but which is different.
const options: StyleOptions<object> = { const options: StyleOptions<object> = {
seed: (identifier as string) || '', seed: (identifier as string) || "",
size: this.iconSize size: this.iconSize,
} };
const avatar = createAvatar(avataaars, options) const avatar = createAvatar(avataaars, options);
const svgString = avatar.toString() const svgString = avatar.toString();
return svgString return svgString;
} }
} }
} }

View File

@@ -22,11 +22,11 @@
class="sr-only" class="sr-only"
/> />
<!-- line --> <!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full" /> <div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot --> <!-- dot -->
<div <div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
/> ></div>
</div> </div>
</div> </div>
@@ -52,11 +52,11 @@
class="sr-only" class="sr-only"
/> />
<!-- line --> <!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full" /> <div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<!-- dot --> <!-- dot -->
<div <div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
/> ></div>
</div> </div>
<div v-else class="relative ml-2"> <div v-else class="relative ml-2">
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"> <button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500">
@@ -91,96 +91,101 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from 'vue-facing-decorator' import { Vue, Component } from "vue-facing-decorator";
import { LMap, LMarker, LRectangle, LTileLayer } from '@vue-leaflet/vue-leaflet' import {
import { Router } from 'vue-router' LMap,
import { MASTER_SETTINGS_KEY } from '../db/tables/settings' LMarker,
import { db, retrieveSettingsForActiveAccount } from '../db/index' 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({ @Component({
components: { components: {
LRectangle, LRectangle,
LMap, LMap,
LMarker, LMarker,
LTileLayer LTileLayer,
} },
}) })
export default class FeedFilters extends Vue { export default class FeedFilters extends Vue {
$router!: Router $router!: Router;
onCloseIfChanged = () => {} onCloseIfChanged = () => {};
hasSearchBox = false hasSearchBox = false;
hasVisibleDid = false hasVisibleDid = false;
isNearby = false isNearby = false;
settingChanged = false settingChanged = false;
visible = false visible = false;
async open(onCloseIfChanged: () => void) { async open(onCloseIfChanged: () => void) {
this.onCloseIfChanged = onCloseIfChanged this.onCloseIfChanged = onCloseIfChanged;
const settings = await retrieveSettingsForActiveAccount() const settings = await retrieveSettingsForActiveAccount();
this.hasVisibleDid = !!settings.filterFeedByVisible this.hasVisibleDid = !!settings.filterFeedByVisible;
this.isNearby = !!settings.filterFeedByNearby this.isNearby = !!settings.filterFeedByNearby;
if (settings.searchBoxes && settings.searchBoxes.length > 0) { if (settings.searchBoxes && settings.searchBoxes.length > 0) {
this.hasSearchBox = true this.hasSearchBox = true;
} }
this.settingChanged = false this.settingChanged = false;
this.visible = true this.visible = true;
} }
async toggleHasVisibleDid() { async toggleHasVisibleDid() {
this.settingChanged = true this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid this.hasVisibleDid = !this.hasVisibleDid;
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid filterFeedByVisible: this.hasVisibleDid,
}) });
} }
async toggleNearby() { async toggleNearby() {
this.settingChanged = true this.settingChanged = true;
this.isNearby = !this.isNearby this.isNearby = !this.isNearby;
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby filterFeedByNearby: this.isNearby,
}) });
} }
async clearAll() { async clearAll() {
if (this.hasVisibleDid || this.isNearby) { if (this.hasVisibleDid || this.isNearby) {
this.settingChanged = true this.settingChanged = true;
} }
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false, filterFeedByNearby: false,
filterFeedByVisible: false filterFeedByVisible: false,
}) });
this.hasVisibleDid = false this.hasVisibleDid = false;
this.isNearby = false this.isNearby = false;
} }
async setAll() { async setAll() {
if (!this.hasVisibleDid || !this.isNearby) { if (!this.hasVisibleDid || !this.isNearby) {
this.settingChanged = true this.settingChanged = true;
} }
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true, filterFeedByNearby: true,
filterFeedByVisible: true filterFeedByVisible: true,
}) });
this.hasVisibleDid = true this.hasVisibleDid = true;
this.isNearby = true this.isNearby = true;
} }
close() { close() {
if (this.settingChanged) { if (this.settingChanged) {
this.onCloseIfChanged() this.onCloseIfChanged();
} }
this.visible = false this.visible = false;
} }
done() { done() {
this.close() this.close();
} }
} }
</script> </script>

View File

@@ -51,8 +51,8 @@
providerProjectId: fromProjectId, providerProjectId: fromProjectId,
recipientDid: receiver?.did, recipientDid: receiver?.did,
recipientName: receiver?.name, recipientName: receiver?.name,
unitCode unitCode,
} },
}" }"
class="text-blue-500" class="text-blue-500"
> >
@@ -87,44 +87,44 @@
</template> </template>
<script lang="ts"> <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 { import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
serverMessageForUser serverMessageForUser,
} from '../libs/endorserServer' } from "../libs/endorserServer";
import * as libsUtil from '../libs/util' import * as libsUtil from "../libs/util";
import { db, retrieveSettingsForActiveAccount } from '../db/index' import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from '../db/tables/contacts' import { Contact } from "../db/tables/contacts";
import { retrieveAccountDids } from '../libs/util' import { retrieveAccountDids } from "../libs/util";
@Component @Component
export default class GiftedDialog extends Vue { export default class GiftedDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop() fromProjectId = '' @Prop() fromProjectId = "";
@Prop() toProjectId = '' @Prop() toProjectId = "";
activeDid = '' activeDid = "";
allContacts: Array<Contact> = [] allContacts: Array<Contact> = [];
allMyDids: Array<string> = [] allMyDids: Array<string> = [];
apiServer = '' apiServer = "";
amountInput = '0' amountInput = "0";
callbackOnSuccess?: (amount: number) => void = () => {} callbackOnSuccess?: (amount: number) => void = () => {};
customTitle?: string customTitle?: string;
description = '' description = "";
giver?: libsUtil.GiverReceiverInputInfo // undefined means no identified giver agent giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
isTrade = false isTrade = false;
offerId = '' offerId = "";
prompt = '' prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo receiver?: libsUtil.GiverReceiverInputInfo;
unitCode = 'HUR' unitCode = "HUR";
visible = false visible = false;
libsUtil = libsUtil libsUtil = libsUtil;
async open( async open(
giver?: libsUtil.GiverReceiverInputInfo, giver?: libsUtil.GiverReceiverInputInfo,
@@ -132,143 +132,146 @@ export default class GiftedDialog extends Vue {
offerId?: string, offerId?: string,
customTitle?: string, customTitle?: string,
prompt?: string, prompt?: string,
callbackOnSuccess: (amount: number) => void = () => {} callbackOnSuccess: (amount: number) => void = () => {},
) { ) {
this.customTitle = customTitle this.customTitle = customTitle;
this.giver = giver this.giver = giver;
this.prompt = prompt || '' this.prompt = prompt || "";
this.receiver = receiver this.receiver = receiver;
// if we show "given to user" selection, default checkbox to true // if we show "given to user" selection, default checkbox to true
this.amountInput = '0' this.amountInput = "0";
this.callbackOnSuccess = callbackOnSuccess this.callbackOnSuccess = callbackOnSuccess;
this.offerId = offerId || '' this.offerId = offerId || "";
try { try {
const settings = await retrieveSettingsForActiveAccount() const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || '' this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || '' 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) { if (this.giver && !this.giver.name) {
this.giver.name = didInfo( this.giver.name = didInfo(
this.giver.did, this.giver.did,
this.activeDid, this.activeDid,
this.allMyDids, this.allMyDids,
this.allContacts this.allContacts,
) );
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
logger.error('Error retrieving settings from database:', err) logger.error("Error retrieving settings from database:", err);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: err.message || 'There was an error retrieving your settings.' text: err.message || "There was an error retrieving your settings.",
}, },
-1 -1,
) );
} }
this.visible = true this.visible = true;
} }
close() { close() {
// close the dialog but don't change values (since it might be submitting info) // close the dialog but don't change values (since it might be submitting info)
this.visible = false this.visible = false;
} }
changeUnitCode() { changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT) const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.unitCode) const index = units.indexOf(this.unitCode);
this.unitCode = units[(index + 1) % units.length] this.unitCode = units[(index + 1) % units.length];
} }
increment() { increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}` this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
} }
decrement() { decrement() {
this.amountInput = `${Math.max(0, (parseFloat(this.amountInput) || 1) - 1)}` this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
} }
cancel() { cancel() {
this.close() this.close();
this.eraseValues() this.eraseValues();
} }
eraseValues() { eraseValues() {
this.description = '' this.description = "";
this.giver = undefined this.giver = undefined;
this.amountInput = '0' this.amountInput = "0";
this.prompt = '' this.prompt = "";
this.unitCode = 'HUR' this.unitCode = "HUR";
} }
async confirm() { async confirm() {
if (!this.activeDid) { if (!this.activeDid) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: 'You must select an identifier before you can record a give.' text: "You must select an identifier before you can record a give.",
}, },
3000 3000,
) );
return return;
} }
if (parseFloat(this.amountInput) < 0) { if (parseFloat(this.amountInput) < 0) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
text: 'You may not send a negative number.', text: "You may not send a negative number.",
title: '' title: "",
}, },
2000 2000,
) );
return return;
} }
if (!this.description && !parseFloat(this.amountInput)) { if (!this.description && !parseFloat(this.amountInput)) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: `You must enter a description or some number of ${ text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode] this.libsUtil.UNIT_LONG[this.unitCode]
}.` }.`,
}, },
2000 2000,
) );
return return;
} }
this.close() this.close();
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'toast', type: "toast",
text: 'Recording the give...', text: "Recording the give...",
title: '' title: "",
}, },
1000 1000,
) );
// this is asynchronous, but we don't need to wait for it to complete // this is asynchronous, but we don't need to wait for it to complete
await this.recordGive( await this.recordGive(
(this.giver?.did as string) || null, (this.giver?.did as string) || null,
(this.receiver?.did as string) || null, (this.receiver?.did as string) || null,
this.description, this.description,
parseFloat(this.amountInput), parseFloat(this.amountInput),
this.unitCode this.unitCode,
).then(() => { ).then(() => {
this.eraseValues() this.eraseValues();
}) });
} }
/** /**
@@ -284,7 +287,7 @@ export default class GiftedDialog extends Vue {
recipientDid: string | null, recipientDid: string | null,
description: string, description: string,
amount: number, amount: number,
unitCode: string = 'HUR' unitCode: string = "HUR",
) { ) {
try { try {
const result = await createAndSubmitGive( const result = await createAndSubmitGive(
@@ -300,54 +303,54 @@ export default class GiftedDialog extends Vue {
this.offerId, this.offerId,
this.isTrade, this.isTrade,
undefined, undefined,
this.fromProjectId this.fromProjectId,
) );
if ( if (
result.type === 'error' || result.type === "error" ||
this.isGiveCreationError(result.response) this.isGiveCreationError(result.response)
) { ) {
const errorMessage = this.getGiveCreationErrorMessage(result) const errorMessage = this.getGiveCreationErrorMessage(result);
logger.error('Error with give creation result:', result) logger.error("Error with give creation result:", result);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: errorMessage || 'There was an error creating the give.' text: errorMessage || "There was an error creating the give.",
}, },
-1 -1,
) );
} else { } else {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'success', type: "success",
title: 'Success', title: "Success",
text: `That ${this.isTrade ? 'trade' : 'gift'} was recorded.` text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
}, },
7000 7000,
) );
if (this.callbackOnSuccess) { if (this.callbackOnSuccess) {
this.callbackOnSuccess(amount) this.callbackOnSuccess(amount);
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
logger.error('Error with give recordation caught:', error) logger.error("Error with give recordation caught:", error);
const errorMessage = const errorMessage =
error.userMessage || error.userMessage ||
serverMessageForUser(error) || serverMessageForUser(error) ||
'There was an error recording the give.' "There was an error recording the give.";
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: errorMessage text: errorMessage,
}, },
-1 -1,
) );
} }
} }
@@ -359,7 +362,7 @@ export default class GiftedDialog extends Vue {
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) { isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error return result.status !== 201 || result.data?.error;
} }
/** /**
@@ -372,19 +375,19 @@ export default class GiftedDialog extends Vue {
result.error?.userMessage || result.error?.userMessage ||
result.error?.error || result.error?.error ||
result.response?.data?.error?.message result.response?.data?.error?.message
) );
} }
explainData() { explainData() {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'success', type: "success",
title: 'Data Sharing', title: "Data Sharing",
text: libsUtil.PRIVACY_MESSAGE text: libsUtil.PRIVACY_MESSAGE,
}, },
-1 -1,
) );
} }
} }
</script> </script>

View File

@@ -7,7 +7,7 @@
class="text-lg text-center p-2 leading-none absolute right-0 -top-1" class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
@click="cancel" @click="cancel"
> >
<font-awesome icon="xmark" class="w-[1em]" /> <font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div> </div>
</h1> </h1>
<span class="mt-2 flex justify-between"> <span class="mt-2 flex justify-between">
@@ -71,92 +71,92 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from 'vue-facing-decorator' import { Vue, Component } from "vue-facing-decorator";
import { Router } from 'vue-router' import { Router } from "vue-router";
import { AppString, NotificationIface } from '../constants/app' import { AppString, NotificationIface } from "../constants/app";
import { db } from '../db/index' import { db } from "../db/index";
import { Contact } from '../db/tables/contacts' import { Contact } from "../db/tables/contacts";
import { GiverReceiverInputInfo } from '../libs/util' import { GiverReceiverInputInfo } from "../libs/util";
@Component @Component
export default class GivenPrompts extends Vue { export default class GivenPrompts extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router $router!: Router;
CATEGORY_CONTACTS = 1 CATEGORY_CONTACTS = 1;
CATEGORY_IDEAS = 0 CATEGORY_IDEAS = 0;
IDEAS = [ IDEAS = [
'What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)', "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 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?)', "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?)', "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 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?)', "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?)', "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 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?)', "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?)', "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?)', "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?)', "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 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?)', "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?)', "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 is a surprise gift you received? (What extra possibilities did it give you?)",
] ];
callbackOnFullGiftInfo?: ( callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo, contactInfo?: GiverReceiverInputInfo,
description?: string description?: string,
) => void ) => void;
currentCategory = this.CATEGORY_IDEAS // 0 = IDEAS, 1 = CONTACTS currentCategory = this.CATEGORY_IDEAS; // 0 = IDEAS, 1 = CONTACTS
currentContact: Contact | undefined = undefined currentContact: Contact | undefined = undefined;
currentIdeaIndex = 0 currentIdeaIndex = 0;
numContacts = 0 numContacts = 0;
shownContactDbIndices: Array<boolean> = [] shownContactDbIndices: Array<boolean> = [];
visible = false visible = false;
AppString = AppString AppString = AppString;
async open( async open(
callbackOnFullGiftInfo?: ( callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo, contactInfo?: GiverReceiverInputInfo,
description?: string description?: string,
) => void ) => void,
) { ) {
this.visible = true this.visible = true;
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo this.callbackOnFullGiftInfo = callbackOnFullGiftInfo;
await db.open() await db.open();
this.numContacts = await db.contacts.count() this.numContacts = await db.contacts.count();
this.shownContactDbIndices = new Array<boolean>(this.numContacts) // all undefined to start this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start
} }
cancel() { cancel() {
this.currentCategory = this.CATEGORY_IDEAS this.currentCategory = this.CATEGORY_IDEAS;
this.currentContact = undefined this.currentContact = undefined;
this.currentIdeaIndex = 0 this.currentIdeaIndex = 0;
this.numContacts = 0 this.numContacts = 0;
this.shownContactDbIndices = [] this.shownContactDbIndices = [];
this.visible = false this.visible = false;
} }
proceed() { proceed() {
// proceed with logic but don't change values (just in case some actions are added later) // 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) { if (this.currentCategory === this.CATEGORY_IDEAS) {
this.$router.push({ this.$router.push({
name: 'contact-gift', name: "contact-gift",
query: { query: {
prompt: this.IDEAS[this.currentIdeaIndex] prompt: this.IDEAS[this.currentIdeaIndex],
} },
}) });
} else { } else {
// must be this.CATEGORY_CONTACTS // must be this.CATEGORY_CONTACTS
this.callbackOnFullGiftInfo?.( this.callbackOnFullGiftInfo?.(
this.currentContact as GiverReceiverInputInfo this.currentContact as GiverReceiverInputInfo,
) );
} }
} }
@@ -167,14 +167,14 @@ export default class GivenPrompts extends Vue {
async nextIdea() { async nextIdea() {
// check if the next one is an idea or a contact // check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) { if (this.currentCategory === this.CATEGORY_IDEAS) {
this.currentIdeaIndex++ this.currentIdeaIndex++;
if (this.currentIdeaIndex === this.IDEAS.length) { if (this.currentIdeaIndex === this.IDEAS.length) {
// must have just finished ideas so move to contacts // must have just finished ideas so move to contacts
this.findNextUnshownContact() this.findNextUnshownContact();
} }
} else { } else {
// must be this.CATEGORY_CONTACTS // must be this.CATEGORY_CONTACTS
this.findNextUnshownContact() this.findNextUnshownContact();
// when that's finished, it'll reset to ideas // when that's finished, it'll reset to ideas
} }
} }
@@ -186,52 +186,54 @@ export default class GivenPrompts extends Vue {
async prevIdea() { async prevIdea() {
// check if the next one is an idea or a contact // check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) { if (this.currentCategory === this.CATEGORY_IDEAS) {
this.currentIdeaIndex-- this.currentIdeaIndex--;
if (this.currentIdeaIndex < 0) { if (this.currentIdeaIndex < 0) {
// must have just finished ideas so move to contacts // must have just finished ideas so move to contacts
this.findNextUnshownContact() this.findNextUnshownContact();
} }
} else { } else {
// must be this.CATEGORY_CONTACTS // must be this.CATEGORY_CONTACTS
this.findNextUnshownContact() this.findNextUnshownContact();
// when that's finished, it'll reset to ideas // when that's finished, it'll reset to ideas
} }
} }
nextIdeaPastContacts() { nextIdeaPastContacts() {
this.currentContact = undefined this.currentContact = undefined;
this.shownContactDbIndices = new Array<boolean>(this.numContacts) 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 // look at the previous idea and switch to the other side of the list
this.currentIdeaIndex = this.currentIdeaIndex =
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1 this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1;
} }
async findNextUnshownContact() { async findNextUnshownContact() {
if (this.currentCategory === this.CATEGORY_IDEAS) { if (this.currentCategory === this.CATEGORY_IDEAS) {
// we're not in the contact prompts, so reset index array // 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 someContactDbIndex = Math.floor(Math.random() * this.numContacts);
let count = 0 let count = 0;
// as long as the index has an entry, loop // as long as the index has an entry, loop
while ( while (
this.shownContactDbIndices[someContactDbIndex] != null && this.shownContactDbIndices[someContactDbIndex] != null &&
count++ < this.numContacts count++ < this.numContacts
) { ) {
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts someContactDbIndex = (someContactDbIndex + 1) % this.numContacts;
} }
if (count >= this.numContacts) { if (count >= this.numContacts) {
// all contacts have been shown // all contacts have been shown
this.nextIdeaPastContacts() this.nextIdeaPastContacts();
} else { } else {
// get the contact at that offset // get the contact at that offset
await db.open() await db.open();
this.currentContact = await db.contacts.offset(someContactDbIndex).first() this.currentContact = await db.contacts
this.shownContactDbIndices[someContactDbIndex] = true .offset(someContactDbIndex)
.first();
this.shownContactDbIndices[someContactDbIndex] = true;
} }
} }
} }

View File

@@ -100,33 +100,33 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-facing-decorator' import { Component, Vue } from "vue-facing-decorator";
import * as R from 'ramda' import * as R from "ramda";
import { useClipboard } from '@vueuse/core' import { useClipboard } from "@vueuse/core";
import { Contact } from '../db/tables/contacts' import { Contact } from "../db/tables/contacts";
import * as serverUtil from '../libs/endorserServer' import * as serverUtil from "../libs/endorserServer";
import { NotificationIface } from '../constants/app' import { NotificationIface } from "../constants/app";
@Component @Component
export default class HiddenDidDialog extends Vue { export default class HiddenDidDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
isOpen = false isOpen = false;
roleName = '' roleName = "";
visibleToDids: string[] = [] visibleToDids: string[] = [];
allContacts: Array<Contact> = [] allContacts: Array<Contact> = [];
activeDid = '' activeDid = "";
allMyDids: Array<string> = [] allMyDids: Array<string> = [];
canShare = false canShare = false;
windowLocation = window.location.href windowLocation = window.location.href;
R = R R = R;
serverUtil = serverUtil serverUtil = serverUtil;
created() { created() {
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare // 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() // then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share this.canShare = !!navigator.share;
} }
open( open(
@@ -134,18 +134,18 @@ export default class HiddenDidDialog extends Vue {
visibleToDids: string[], visibleToDids: string[],
allContacts: Array<Contact>, allContacts: Array<Contact>,
activeDid: string, activeDid: string,
allMyDids: Array<string> allMyDids: Array<string>,
) { ) {
this.roleName = roleName this.roleName = roleName;
this.visibleToDids = visibleToDids this.visibleToDids = visibleToDids;
this.allContacts = allContacts this.allContacts = allContacts;
this.activeDid = activeDid this.activeDid = activeDid;
this.allMyDids = allMyDids this.allMyDids = allMyDids;
this.isOpen = true this.isOpen = true;
} }
close() { close() {
this.isOpen = false this.isOpen = false;
} }
didInfo(did: string) { didInfo(did: string) {
@@ -153,8 +153,8 @@ export default class HiddenDidDialog extends Vue {
did, did,
this.activeDid, this.activeDid,
this.allMyDids, this.allMyDids,
this.allContacts this.allContacts,
) );
} }
copyToClipboard(name: string, text: string) { copyToClipboard(name: string, text: string) {
@@ -163,23 +163,23 @@ export default class HiddenDidDialog extends Vue {
.then(() => { .then(() => {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'toast', type: "toast",
title: 'Copied', title: "Copied",
text: (name || 'That') + ' was copied to the clipboard.' text: (name || "That") + " was copied to the clipboard.",
}, },
2000 2000,
) );
}) });
} }
onClickShareClaim() { onClickShareClaim() {
this.copyToClipboard('A link to this page', this.windowLocation) this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({ 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?", text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation url: this.windowLocation,
}) });
} }
} }
</script> </script>

View File

@@ -12,7 +12,7 @@
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()" @click="close()"
> >
<font-awesome icon="xmark" class="w-[1em]" /> <font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div> </div>
</div> </div>
@@ -56,102 +56,103 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import axios from 'axios' import axios from "axios";
import { ref } from 'vue' import { ref } from "vue";
import { Component, Vue } from 'vue-facing-decorator' import { Component, Vue } from "vue-facing-decorator";
import PhotoDialog from '../components/PhotoDialog.vue' import PhotoDialog from "../components/PhotoDialog.vue";
import { NotificationIface } from '../constants/app' import { NotificationIface } from "../constants/app";
const inputImageFileNameRef = ref<Blob>() const inputImageFileNameRef = ref<Blob>();
@Component({ @Component({
components: { PhotoDialog } components: { PhotoDialog },
}) })
export default class ImageMethodDialog extends Vue { export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
claimType: string claimType: string;
crop: boolean = false crop: boolean = false;
imageCallback: (imageUrl?: string) => void = () => {} imageCallback: (imageUrl?: string) => void = () => {};
imageUrl?: string imageUrl?: string;
visible = false visible = false;
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) { open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
this.claimType = claimType this.claimType = claimType;
this.crop = !!crop this.crop = !!crop;
this.imageCallback = setImageFn this.imageCallback = setImageFn;
this.visible = true this.visible = true;
} }
openPhotoDialog(blob?: Blob, fileName?: string) { openPhotoDialog(blob?: Blob, fileName?: string) {
this.visible = false this.visible = false;
;(this.$refs.photoDialog as PhotoDialog).open(
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback, this.imageCallback,
this.claimType, this.claimType,
this.crop, this.crop,
blob, blob,
fileName fileName,
) );
} }
async uploadImageFile(event: Event) { async uploadImageFile(event: Event) {
this.visible = false this.visible = false;
inputImageFileNameRef.value = event.target.files[0] inputImageFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File // https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing // ... plus it has a `type` property from my testing
const file = inputImageFileNameRef.value const file = inputImageFileNameRef.value;
if (file != null) { if (file != null) {
const reader = new FileReader() const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer const data = e.target?.result as ArrayBuffer;
if (data) { if (data) {
const blob = new Blob([new Uint8Array(data)], { const blob = new Blob([new Uint8Array(data)], {
type: file.type type: file.type,
}) });
this.openPhotoDialog(blob, file.name as string) this.openPhotoDialog(blob, file.name as string);
} }
} };
reader.readAsArrayBuffer(file as Blob) reader.readAsArrayBuffer(file as Blob);
} }
} }
async acceptUrl() { async acceptUrl() {
this.visible = false this.visible = false;
if (this.crop) { if (this.crop) {
try { try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, { const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
responseType: 'blob' // This ensures the data is returned as a Blob responseType: "blob", // This ensures the data is returned as a Blob
}) });
const fullUrl = new URL(this.imageUrl as string) const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split('/').pop() as string const fileName = fullUrl.pathname.split("/").pop() as string;
;(this.$refs.photoDialog as PhotoDialog).open( (this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback, this.imageCallback,
this.claimType, this.claimType,
this.crop, this.crop,
urlBlobResponse.data as Blob, urlBlobResponse.data as Blob,
fileName fileName,
) );
} catch (error) { } catch (error) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: 'There was an error retrieving that image.' text: "There was an error retrieving that image.",
}, },
5000 5000,
) );
} }
} else { } else {
this.imageCallback(this.imageUrl) this.imageCallback(this.imageUrl);
} }
} }
close() { close() {
this.visible = false this.visible = false;
} }
} }
</script> </script>

View File

@@ -38,44 +38,44 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from 'vue-facing-decorator' import { Component, Vue, Prop } from "vue-facing-decorator";
import { UAParser } from 'ua-parser-js' import { UAParser } from "ua-parser-js";
@Component({ emits: ['update:isOpen'] }) @Component({ emits: ["update:isOpen"] })
export default class ImageViewer extends Vue { export default class ImageViewer extends Vue {
@Prop() imageUrl!: string @Prop() imageUrl!: string;
@Prop() imageData!: Blob | null @Prop() imageData!: Blob | null;
@Prop() isOpen!: boolean @Prop() isOpen!: boolean;
userAgent = new UAParser() userAgent = new UAParser();
get isMobile() { get isMobile() {
const os = this.userAgent.getOS().name const os = this.userAgent.getOS().name;
return os === 'iOS' || os === 'Android' return os === "iOS" || os === "Android";
} }
close() { close() {
this.$emit('update:isOpen', false) this.$emit("update:isOpen", false);
} }
async handleShare() { async handleShare() {
const os = this.userAgent.getOS().name const os = this.userAgent.getOS().name;
try { try {
if (os === 'iOS' || os === 'Android') { if (os === "iOS" || os === "Android") {
if (navigator.share) { if (navigator.share) {
// Always share the URL since it's more reliable across platforms // Always share the URL since it's more reliable across platforms
await navigator.share({ await navigator.share({
url: this.imageUrl url: this.imageUrl,
}) });
} else { } else {
// Fallback for browsers without share API // Fallback for browsers without share API
window.open(this.imageUrl, '_blank') window.open(this.imageUrl, "_blank");
} }
} }
} catch (error) { } catch (error) {
logger.warn('Share failed, opening in new tab:', error) logger.warn("Share failed, opening in new tab:", error);
window.open(this.imageUrl, '_blank') window.open(this.imageUrl, "_blank");
} }
} }
} }

View File

@@ -1,122 +1,49 @@
/** * @file InfiniteScroll.vue * @description A Vue component that implements
infinite scrolling functionality using the Intersection Observer API. * This
component emits a 'reached-bottom' event when the user scrolls near the bottom
of the content. * It includes debouncing to prevent multiple rapid triggers and
loading state management. * * @author Matthew Raymer * @version 1.0.0 */
<template> <template>
<div ref="scrollContainer"> <div ref="scrollContainer">
<slot /> <slot />
<div ref="sentinel" style="height: 1px" /> <div ref="sentinel" style="height: 1px"></div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Prop, Vue } from 'vue-facing-decorator' import { Component, Emit, Prop, Vue } from "vue-facing-decorator";
/**
* InfiniteScroll Component
*
* This component implements infinite scrolling functionality by observing when a user
* scrolls near the bottom of the content. It uses the Intersection Observer API for
* efficient scroll detection and includes debouncing to prevent multiple rapid triggers.
*
* Usage in template:
* ```vue
* <InfiniteScroll @reached-bottom="loadMore">
* <div>Content goes here</div>
* </InfiniteScroll>
* ```
*
* Props:
* - distance: number (default: 200) - Distance in pixels from the bottom at which to trigger the event
*
* Events:
* - reached-bottom: Emitted when the user scrolls near the bottom of the content
*/
@Component @Component
export default class InfiniteScroll extends Vue { export default class InfiniteScroll extends Vue {
/** Distance in pixels from the bottom at which to trigger the reached-bottom event */
@Prop({ default: 200 }) @Prop({ default: 200 })
readonly distance!: number readonly distance!: number;
private observer!: IntersectionObserver;
private isInitialRender = true;
/** Intersection Observer instance for detecting scroll position */
private observer!: IntersectionObserver
/** Flag to track initial render state */
private isInitialRender = true
/** Flag to prevent multiple simultaneous loading states */
private isLoading = false
/** Timeout ID for debouncing scroll events */
private debounceTimeout: number | null = null
/**
* Vue lifecycle hook that runs after component updates.
* Initializes the Intersection Observer if not already set up.
*
* @internal
* Used internally by Vue's lifecycle system
*/
updated() { updated() {
if (!this.observer) { if (!this.observer) {
const options = { const options = {
root: null, root: null,
rootMargin: `0px 0px ${this.distance}px 0px`, rootMargin: `0px 0px ${this.distance}px 0px`,
threshold: 1.0 threshold: 1.0,
} };
this.observer = new IntersectionObserver(this.handleIntersection, options) this.observer = new IntersectionObserver(
this.observer.observe(this.$refs.sentinel as HTMLElement) this.handleIntersection,
options,
);
this.observer.observe(this.$refs.sentinel as HTMLElement);
} }
} }
/** // 'beforeUnmount' hook runs before unmounting the component
* Vue lifecycle hook that runs before component unmounting.
* Cleans up the Intersection Observer and any pending timeouts.
*
* @internal
* Used internally by Vue's lifecycle system
*/
beforeUnmount() { beforeUnmount() {
if (this.observer) { if (this.observer) {
this.observer.disconnect() this.observer.disconnect();
}
if (this.debounceTimeout) {
window.clearTimeout(this.debounceTimeout)
} }
} }
/** @Emit("reached-bottom")
* Handles intersection observer callbacks when the sentinel element becomes visible.
* Implements debouncing to prevent multiple rapid triggers and manages loading state.
*
* @param entries - Array of IntersectionObserverEntry objects
* @returns false (required by @Emit decorator)
*
* @internal
* Used internally by the Intersection Observer
* @emits reached-bottom - Emitted when the user scrolls near the bottom
*/
@Emit('reached-bottom')
handleIntersection(entries: IntersectionObserverEntry[]) { handleIntersection(entries: IntersectionObserverEntry[]) {
const entry = entries[0] const entry = entries[0];
if (entry.isIntersecting && !this.isLoading) { if (entry.isIntersecting) {
// Debounce the intersection event return true;
if (this.debounceTimeout) {
window.clearTimeout(this.debounceTimeout)
} }
return false;
this.debounceTimeout = window.setTimeout(() => {
this.isLoading = true
this.$emit('reached-bottom', true)
// Reset loading state after a short delay
setTimeout(() => {
this.isLoading = false
}, 1000)
}, 300)
}
return false
} }
} }
</script> </script>

View File

@@ -46,50 +46,50 @@
</template> </template>
<script lang="ts"> <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 @Component
export default class InviteDialog extends Vue { export default class InviteDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
callback: (text: string, expiresAt: string) => void = () => {} callback: (text: string, expiresAt: string) => void = () => {};
inviteIdentifier = '' inviteIdentifier = "";
text = '' text = "";
visible = false visible = false;
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
.toISOString() .toISOString()
.substring(0, 10) .substring(0, 10);
async open( async open(
inviteIdentifier: string, inviteIdentifier: string,
aCallback: (text: string, expiresAt: string) => void aCallback: (text: string, expiresAt: string) => void,
) { ) {
this.callback = aCallback this.callback = aCallback;
this.inviteIdentifier = inviteIdentifier this.inviteIdentifier = inviteIdentifier;
this.visible = true this.visible = true;
} }
async onClickSaveChanges() { async onClickSaveChanges() {
if (!this.expiresAt) { if (!this.expiresAt) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'warning', type: "warning",
title: 'Needs Expiration', title: "Needs Expiration",
text: 'You must select an expiration date.' text: "You must select an expiration date.",
}, },
5000 5000,
) );
} else { } else {
this.callback(this.text, this.expiresAt) this.callback(this.text, this.expiresAt);
this.visible = false this.visible = false;
} }
} }
onClickCancel() { onClickCancel() {
this.visible = false this.visible = false;
} }
} }
</script> </script>

View File

@@ -80,9 +80,7 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<h3 class="text-lg font-medium"> <h3 class="text-lg font-medium">{{ member.name }}</h3>
{{ member.name }}
</h3>
<div <div
v-if="!getContactFor(member.did) && member.did !== activeDid" v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex justify-end" class="flex justify-end"
@@ -101,7 +99,7 @@
title="Contact info" title="Contact info"
@click=" @click="
informAboutAddingContact( informAboutAddingContact(
getContactFor(member.did) !== undefined getContactFor(member.did) !== undefined,
) )
" "
> >
@@ -159,138 +157,138 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from 'vue-facing-decorator' import { Component, Vue, Prop } from "vue-facing-decorator";
import { import {
logConsoleAndDb, logConsoleAndDb,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
db db,
} from '../db/index' } from "../db/index";
import { import {
errorStringForLog, errorStringForLog,
getHeaders, getHeaders,
register, register,
serverMessageForUser serverMessageForUser,
} from '../libs/endorserServer' } from "../libs/endorserServer";
import { decryptMessage } from '../libs/crypto' import { decryptMessage } from "../libs/crypto";
import { Contact } from '../db/tables/contacts' import { Contact } from "../db/tables/contacts";
import * as libsUtil from '../libs/util' import * as libsUtil from "../libs/util";
import { NotificationIface } from '../constants/app' import { NotificationIface } from "../constants/app";
interface Member { interface Member {
admitted: boolean admitted: boolean;
content: string content: string;
memberId: number memberId: number;
} }
interface DecryptedMember { interface DecryptedMember {
member: Member member: Member;
name: string name: string;
did: string did: string;
isRegistered: boolean isRegistered: boolean;
} }
@Component @Component
export default class MembersList extends Vue { 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({ required: true }) password!: string;
@Prop({ default: false }) showOrganizerTools!: boolean @Prop({ default: false }) showOrganizerTools!: boolean;
decryptedMembers: DecryptedMember[] = [] decryptedMembers: DecryptedMember[] = [];
firstName = '' firstName = "";
isLoading = true isLoading = true;
isOrganizer = false isOrganizer = false;
members: Member[] = [] members: Member[] = [];
missingPassword = false missingPassword = false;
missingMyself = false missingMyself = false;
activeDid = '' activeDid = "";
apiServer = '' apiServer = "";
contacts: Array<Contact> = [] contacts: Array<Contact> = [];
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount() const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || '' this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || '' this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || '' this.firstName = settings.firstName || "";
await this.fetchMembers() await this.fetchMembers();
await this.loadContacts() await this.loadContacts();
} }
async fetchMembers() { async fetchMembers() {
try { try {
this.isLoading = true this.isLoading = true;
const headers = await getHeaders(this.activeDid) const headers = await getHeaders(this.activeDid);
const response = await this.axios.get( const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMembers`, `${this.apiServer}/api/partner/groupOnboardMembers`,
{ headers } { headers },
) );
if (response.data && response.data.data) { if (response.data && response.data.data) {
this.members = response.data.data this.members = response.data.data;
await this.decryptMemberContents() await this.decryptMemberContents();
} }
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
'Error fetching members: ' + errorStringForLog(error), "Error fetching members: " + errorStringForLog(error),
true true,
) );
this.$emit( this.$emit(
'error', "error",
serverMessageForUser(error) || 'Failed to fetch members.' serverMessageForUser(error) || "Failed to fetch members.",
) );
} finally { } finally {
this.isLoading = false this.isLoading = false;
} }
} }
async decryptMemberContents() { async decryptMemberContents() {
this.decryptedMembers = [] this.decryptedMembers = [];
if (!this.password) { if (!this.password) {
this.missingPassword = true this.missingPassword = true;
return return;
} }
let isFirstEntry = true, let isFirstEntry = true,
foundMyself = false foundMyself = false;
for (const member of this.members) { for (const member of this.members) {
try { try {
const decryptedContent = await decryptMessage( const decryptedContent = await decryptMessage(
member.content, member.content,
this.password this.password,
) );
const content = JSON.parse(decryptedContent) const content = JSON.parse(decryptedContent);
this.decryptedMembers.push({ this.decryptedMembers.push({
member: member, member: member,
name: content.name, name: content.name,
did: content.did, did: content.did,
isRegistered: !!content.isRegistered isRegistered: !!content.isRegistered,
}) });
if (isFirstEntry && content.did === this.activeDid) { if (isFirstEntry && content.did === this.activeDid) {
this.isOrganizer = true this.isOrganizer = true;
} }
if (content.did === this.activeDid) { if (content.did === this.activeDid) {
foundMyself = true foundMyself = true;
} }
} catch (error) { } catch (error) {
// do nothing, relying on the count of members to determine if there was an 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 { decryptionErrorMessage(): string {
if (this.isOrganizer) { if (this.isOrganizer) {
if (this.decryptedMembers.length < this.members.length) { 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 { } else {
// the lists must be equal // the lists must be equal
return '' return "";
} }
} else { } else {
// non-organizers should only see problems if the first (organizer) member is not decrypted // non-organizers should only see problems if the first (organizer) member is not decrypted
@@ -298,10 +296,10 @@ export default class MembersList extends Vue {
this.decryptedMembers.length === 0 || this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId 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 { } else {
// the first (organizer) member was decrypted OK // the first (organizer) member was decrypted OK
return '' return "";
} }
} }
} }
@@ -309,118 +307,118 @@ export default class MembersList extends Vue {
membersToShow(): DecryptedMember[] { membersToShow(): DecryptedMember[] {
if (this.isOrganizer) { if (this.isOrganizer) {
if (this.showOrganizerTools) { if (this.showOrganizerTools) {
return this.decryptedMembers return this.decryptedMembers;
} else { } else {
return this.decryptedMembers.filter( return this.decryptedMembers.filter(
(member: DecryptedMember) => member.member.admitted (member: DecryptedMember) => member.member.admitted,
) );
} }
} }
// non-organizers only get visible members from server // non-organizers only get visible members from server
return this.decryptedMembers return this.decryptedMembers;
} }
informAboutAdmission() { informAboutAdmission() {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'info', type: "info",
title: 'Admission 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." 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) { informAboutAddingContact(contactImportedAlready: boolean) {
if (contactImportedAlready) { if (contactImportedAlready) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'info', type: "info",
title: 'Contact Exists', title: "Contact Exists",
text: 'They are in your contacts. If you want to remove them, you must do that from the contacts screen.' text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
}, },
10000 10000,
) );
} else { } else {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'info', type: "info",
title: 'Contact Available', title: "Contact Available",
text: 'This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.' text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
}, },
10000 10000,
) );
} }
} }
async loadContacts() { async loadContacts() {
this.contacts = await db.contacts.toArray() this.contacts = await db.contacts.toArray();
} }
getContactFor(did: string): Contact | undefined { getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did) return this.contacts.find((contact) => contact.did === did);
} }
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) { checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did) const contact = this.getContactFor(decrMember.did);
if (!decrMember.member.admitted && !contact) { if (!decrMember.member.admitted && !contact) {
// If not a contact, show confirmation dialog // If not a contact, show confirmation dialog
this.$notify( this.$notify(
{ {
group: 'modal', group: "modal",
type: 'confirm', type: "confirm",
title: 'Add as Contact First?', title: "Add as Contact First?",
text: 'This person is not in your contacts. Would you like to add them as a contact first?', text: "This person is not in your contacts. Would you like to add them as a contact first?",
yesText: 'Add as Contact', yesText: "Add as Contact",
noText: 'Skip Adding Contact', noText: "Skip Adding Contact",
onYes: async () => { onYes: async () => {
await this.addAsContact(decrMember) await this.addAsContact(decrMember);
// After adding as contact, proceed with admission // After adding as contact, proceed with admission
await this.toggleAdmission(decrMember) await this.toggleAdmission(decrMember);
}, },
onNo: async () => { onNo: async () => {
// If they choose not to add as contact, show second confirmation // If they choose not to add as contact, show second confirmation
this.$notify( this.$notify(
{ {
group: 'modal', group: "modal",
type: 'confirm', type: "confirm",
title: 'Continue Without Adding?', 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.', 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', yesText: "Continue",
onYes: async () => { onYes: async () => {
await this.toggleAdmission(decrMember) await this.toggleAdmission(decrMember);
}, },
onCancel: async () => { onCancel: async () => {
// Do nothing, effectively canceling the operation // Do nothing, effectively canceling the operation
}
}, },
-1
)
}
}, },
-1 -1,
) );
},
},
-1,
);
} else { } else {
// If already a contact, proceed directly with admission // If already a contact, proceed directly with admission
this.toggleAdmission(decrMember) this.toggleAdmission(decrMember);
} }
} }
async toggleAdmission(decrMember: DecryptedMember) { async toggleAdmission(decrMember: DecryptedMember) {
try { try {
const headers = await getHeaders(this.activeDid) const headers = await getHeaders(this.activeDid);
await this.axios.put( await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`, `${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`,
{ admitted: !decrMember.member.admitted }, { admitted: !decrMember.member.admitted },
{ headers } { headers },
) );
// Update local state // 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 admitted, now register that user if they are not registered
if ( if (
decrMember.member.admitted && decrMember.member.admitted &&
@@ -429,61 +427,61 @@ export default class MembersList extends Vue {
) { ) {
const contactOldOrNew: Contact = oldContact || { const contactOldOrNew: Contact = oldContact || {
did: decrMember.did, did: decrMember.did,
name: decrMember.name name: decrMember.name,
} };
try { try {
const result = await register( const result = await register(
this.activeDid, this.activeDid,
this.apiServer, this.apiServer,
this.axios, this.axios,
contactOldOrNew contactOldOrNew,
) );
if (result.success) { if (result.success) {
decrMember.isRegistered = true decrMember.isRegistered = true;
if (oldContact) { if (oldContact) {
await db.contacts.update(decrMember.did, { registered: true }) await db.contacts.update(decrMember.did, { registered: true });
oldContact.registered = true oldContact.registered = true;
} }
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'success', type: "success",
title: 'Registered', title: "Registered",
text: 'Besides being admitted, they were also registered.' text: "Besides being admitted, they were also registered.",
}, },
3000 3000,
) );
} else { } else {
throw result throw result;
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
// registration failure is likely explained by a message from the server // registration failure is likely explained by a message from the server
const additionalInfo = const additionalInfo =
serverMessageForUser(error) || error?.error || '' serverMessageForUser(error) || error?.error || "";
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'warning', type: "warning",
title: 'Registration failed', title: "Registration failed",
text: text:
'They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. ' + "They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. " +
additionalInfo additionalInfo,
}, },
12000 12000,
) );
} }
} }
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
'Error toggling admission: ' + errorStringForLog(error), "Error toggling admission: " + errorStringForLog(error),
true true,
) );
this.$emit( this.$emit(
'error', "error",
serverMessageForUser(error) || serverMessageForUser(error) ||
'Failed to update member admission status.' "Failed to update member admission status.",
) );
} }
} }
@@ -491,36 +489,36 @@ export default class MembersList extends Vue {
try { try {
const newContact = { const newContact = {
did: member.did, did: member.did,
name: member.name name: member.name,
} };
await db.contacts.add(newContact) await db.contacts.add(newContact);
this.contacts.push(newContact) this.contacts.push(newContact);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'success', type: "success",
title: 'Contact Added', title: "Contact Added",
text: 'They were added to your contacts.' text: "They were added to your contacts.",
}, },
3000 3000,
) );
} catch (err) { } catch (err) {
logConsoleAndDb('Error adding contact: ' + errorStringForLog(err), true) logConsoleAndDb("Error adding contact: " + errorStringForLog(err), true);
let message = 'An error prevented adding this contact.' let message = "An error prevented adding this contact.";
if (err instanceof Error && err.message?.indexOf('already exists') > -1) { if (err instanceof Error && err.message?.indexOf("already exists") > -1) {
message = 'This person is already in your contact list.' message = "This person is already in your contact list.";
} }
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Contact Not Added', title: "Contact Not Added",
text: message text: message,
}, },
5000 5000,
) );
} }
} }
} }

View File

@@ -49,8 +49,8 @@
projectName, projectName,
recipientDid, recipientDid,
recipientName, recipientName,
unitCode: amountUnitCode unitCode: amountUnitCode,
} },
}" }"
class="text-blue-500" class="text-blue-500"
> >
@@ -80,114 +80,117 @@
</template> </template>
<script lang="ts"> <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 { import {
createAndSubmitOffer, createAndSubmitOffer,
serverMessageForUser serverMessageForUser,
} from '../libs/endorserServer' } from "../libs/endorserServer";
import * as libsUtil from '../libs/util' import * as libsUtil from "../libs/util";
import { retrieveSettingsForActiveAccount } from '../db/index' import { retrieveSettingsForActiveAccount } from "../db/index";
import { logger } from '../utils/logger' import { logger } from "../utils/logger";
@Component @Component
export default class OfferDialog extends Vue { export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop projectId?: string @Prop projectId?: string;
@Prop projectName?: string @Prop projectName?: string;
activeDid = '' activeDid = "";
apiServer = '' apiServer = "";
amountInput = '0' amountInput = "0";
amountUnitCode = 'HUR' amountUnitCode = "HUR";
description = '' description = "";
expirationDateInput = '' expirationDateInput = "";
recipientDid? = '' recipientDid? = "";
recipientName? = '' recipientName? = "";
visible = false visible = false;
libsUtil = libsUtil libsUtil = libsUtil;
async open(recipientDid?: string, recipientName?: string) { async open(recipientDid?: string, recipientName?: string) {
try { try {
this.recipientDid = recipientDid this.recipientDid = recipientDid;
this.recipientName = recipientName this.recipientName = recipientName;
const settings = await retrieveSettingsForActiveAccount() const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || '' this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || '' this.activeDid = settings.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
logger.error('Error retrieving settings from database:', err) logger.error("Error retrieving settings from database:", err);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: err.message || 'There was an error retrieving your settings.' text: err.message || "There was an error retrieving your settings.",
}, },
-1 -1,
) );
} }
this.visible = true this.visible = true;
} }
close() { close() {
// close the dialog but don't change values (since it might be submitting info) // close the dialog but don't change values (since it might be submitting info)
this.visible = false this.visible = false;
} }
changeUnitCode() { changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT) const units = Object.keys(this.libsUtil.UNIT_SHORT);
const index = units.indexOf(this.amountUnitCode) const index = units.indexOf(this.amountUnitCode);
this.amountUnitCode = units[(index + 1) % units.length] this.amountUnitCode = units[(index + 1) % units.length];
} }
increment() { increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}` this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
} }
decrement() { decrement() {
this.amountInput = `${Math.max(0, (parseFloat(this.amountInput) || 1) - 1)}` this.amountInput = `${Math.max(
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
} }
cancel() { cancel() {
this.close() this.close();
this.eraseValues() this.eraseValues();
} }
eraseValues() { eraseValues() {
this.description = '' this.description = "";
this.amountInput = '0' this.amountInput = "0";
this.amountUnitCode = 'HUR' this.amountUnitCode = "HUR";
} }
async confirm() { async confirm() {
this.close() this.close();
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'toast', type: "toast",
text: 'Recording the offer...', text: "Recording the offer...",
title: '' title: "",
}, },
1000 1000,
) );
// this is asynchronous, but we don't need to wait for it to complete // this is asynchronous, but we don't need to wait for it to complete
this.recordOffer( this.recordOffer(
this.description, this.description,
parseFloat(this.amountInput), parseFloat(this.amountInput),
this.amountUnitCode, this.amountUnitCode,
this.expirationDateInput this.expirationDateInput,
).then(() => { ).then(() => {
this.description = '' this.description = "";
this.amountInput = '0' this.amountInput = "0";
}) });
} }
/** /**
@@ -199,33 +202,33 @@ export default class OfferDialog extends Vue {
public async recordOffer( public async recordOffer(
description: string, description: string,
amount: number, amount: number,
unitCode: string = 'HUR', unitCode: string = "HUR",
expirationDateInput?: string expirationDateInput?: string,
) { ) {
if (!this.activeDid) { if (!this.activeDid) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: 'You must select an identity before you can record an offer.' text: "You must select an identity before you can record an offer.",
}, },
7000 7000,
) );
return return;
} }
if (!description && !amount) { if (!description && !amount) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.` text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`,
}, },
-1 -1,
) );
return return;
} }
try { try {
@@ -236,54 +239,54 @@ export default class OfferDialog extends Vue {
description, description,
amount, amount,
unitCode, unitCode,
'', "",
expirationDateInput, expirationDateInput,
this.recipientDid, this.recipientDid,
this.projectId this.projectId,
) );
if ( if (
result.type === 'error' || result.type === "error" ||
this.isOfferCreationError(result.response) this.isOfferCreationError(result.response)
) { ) {
const errorMessage = this.getOfferCreationErrorMessage(result) const errorMessage = this.getOfferCreationErrorMessage(result);
logger.error('Error with offer creation result:', result) logger.error("Error with offer creation result:", result);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: errorMessage || 'There was an error creating the offer.' text: errorMessage || "There was an error creating the offer.",
}, },
-1 -1,
) );
} else { } else {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'success', type: "success",
title: 'Success', title: "Success",
text: 'That offer was recorded.' text: "That offer was recorded.",
}, },
5000 5000,
) );
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
logger.error('Error with offer recordation caught:', error) logger.error("Error with offer recordation caught:", error);
const message = const message =
error.userMessage || error.userMessage ||
error.response?.data?.error?.message || error.response?.data?.error?.message ||
'There was an error recording the offer.' "There was an error recording the offer.";
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: message text: message,
}, },
-1 -1,
) );
} }
} }
@@ -295,7 +298,7 @@ export default class OfferDialog extends Vue {
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
isOfferCreationError(result: any) { isOfferCreationError(result: any) {
return result.status !== 201 || result.data?.error return result.status !== 201 || result.data?.error;
} }
/** /**
@@ -308,7 +311,7 @@ export default class OfferDialog extends Vue {
serverMessageForUser(result) || serverMessageForUser(result) ||
result.error?.userMessage || result.error?.userMessage ||
result.error?.error result.error?.error
) );
} }
} }
</script> </script>

View File

@@ -95,7 +95,7 @@
</p> </p>
<p class="mt-4"> <p class="mt-4">
Search for a topic, or search around your neighborhood under "Nearby". Search for a topic, or search around your neighborhod under "Nearby".
</p> </p>
<p class="mt-4"> <p class="mt-4">
@@ -198,64 +198,64 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from 'vue-facing-decorator' import { Component, Vue } from "vue-facing-decorator";
import { Router } from 'vue-router' import { Router } from "vue-router";
import { NotificationIface } from '../constants/app' import { NotificationIface } from "../constants/app";
import { import {
db, db,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
updateAccountSettings updateAccountSettings,
} from '../db/index' } from "../db/index";
import { OnboardPage } from '../libs/util' import { OnboardPage } from "../libs/util";
@Component({ @Component({
computed: { computed: {
OnboardPage() { OnboardPage() {
return OnboardPage return OnboardPage;
}
}, },
components: { OnboardPage } },
components: { OnboardPage },
}) })
export default class OnboardingDialog extends Vue { export default class OnboardingDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router $router!: Router;
activeDid = '' activeDid = "";
firstContactName = null firstContactName = null;
givenName = '' givenName = "";
isRegistered = false isRegistered = false;
numContacts = 0 numContacts = 0;
page = OnboardPage.Home page = OnboardPage.Home;
visible = false visible = false;
async open(page: OnboardPage) { async open(page: OnboardPage) {
this.page = page this.page = page;
const settings = await retrieveSettingsForActiveAccount() const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || '' this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered this.isRegistered = !!settings.isRegistered;
const contacts = await db.contacts.toArray() const contacts = await db.contacts.toArray();
this.numContacts = contacts.length this.numContacts = contacts.length;
if (this.numContacts > 0) { 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) { if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages // we'll assume that they've been through all the other pages
await updateAccountSettings(this.activeDid, { await updateAccountSettings(this.activeDid, {
finishedOnboarding: true finishedOnboarding: true,
}) });
} }
} }
async onClickClose(done?: boolean, goHome?: boolean) { async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false this.visible = false;
if (done) { if (done) {
await updateAccountSettings(this.activeDid, { await updateAccountSettings(this.activeDid, {
finishedOnboarding: true finishedOnboarding: true,
}) });
if (goHome) { if (goHome) {
this.$router.push({ name: 'home' }) this.$router.push({ name: "home" });
} }
} }
} }

View File

@@ -15,7 +15,7 @@
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()" @click="close()"
> >
<font-awesome icon="xmark" class="w-[1em]" /> <font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div> </div>
</div> </div>
@@ -30,16 +30,21 @@
<VuePictureCropper <VuePictureCropper
:box-style="{ :box-style="{
backgroundColor: '#f8f8f8', backgroundColor: '#f8f8f8',
margin: 'auto' margin: 'auto',
}" }"
:img="createBlobURL(blob)" :img="createBlobURL(blob)"
:options="{ :options="{
viewMode: 1, viewMode: 1,
dragMode: 'crop', dragMode: 'crop',
aspectRatio: 9 / 9 aspectRatio: 9 / 9,
}" }"
class="max-h-[90vh] max-w-[90vw] object-contain" class="max-h-[90vh] max-w-[90vw] object-contain"
/> />
<!-- This gives a round cropper.
:presetMode="{
mode: 'round',
}"
-->
</div> </div>
<div v-else> <div v-else>
<div class="flex justify-center"> <div class="flex justify-center">
@@ -69,78 +74,98 @@
</button> </button>
</div> </div>
</div> </div>
<div v-else> <div v-else ref="cameraContainer">
<div class="flex flex-col items-center justify-center gap-4 p-4"> <!--
Camera "resolution" doesn't change how it shows on screen but rather stretches the result,
eg. the following which just stretches it vertically:
:resolution="{ width: 375, height: 812 }"
-->
<camera
ref="camera"
facing-mode="environment"
autoplay
@started="cameraStarted()"
>
<div
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center"
>
<button <button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="takePhoto" @click="takeImage()"
> >
<font-awesome icon="camera" class="w-[1em]" /> <font-awesome icon="camera" class="w-[1em]"></font-awesome>
</button>
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="pickPhoto"
>
<font-awesome icon="image" class="w-[1em]" />
</button> </button>
</div> </div>
<div
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center"
>
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="swapMirrorClass()"
>
<font-awesome icon="left-right" class="w-[1em]"></font-awesome>
</button>
</div>
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
<button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="switchCamera()"
>
<font-awesome icon="rotate" class="w-[1em]"></font-awesome>
</button>
</div>
</camera>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
/** import axios from "axios";
* PhotoDialog.vue - Cross-platform photo capture and selection component import Camera from "simple-vue-camera";
* import { Component, Vue } from "vue-facing-decorator";
* This component provides a unified interface for taking photos and selecting images import VuePictureCropper, { cropper } from "vue-picture-cropper";
* across different platforms using the PlatformService.
*
* @author Matthew Raymer
* @file PhotoDialog.vue
*/
import axios from 'axios' import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { Component, Vue } from 'vue-facing-decorator' import { retrieveSettingsForActiveAccount } from "../db/index";
import VuePictureCropper, { cropper } from 'vue-picture-cropper' import { accessToken } from "../libs/crypto";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from '../constants/app' import { logger } from "../utils/logger";
import { retrieveSettingsForActiveAccount } from '../db/index'
import { accessToken } from '../libs/crypto'
import { logger } from '../utils/logger'
import { PlatformServiceFactory } from '../services/PlatformServiceFactory'
@Component({ components: { VuePictureCropper } }) @Component({ components: { Camera, VuePictureCropper } })
export default class PhotoDialog extends Vue { export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = '' activeDeviceNumber = 0;
blob?: Blob activeDid = "";
claimType = '' blob?: Blob;
crop = false claimType = "";
fileName?: string crop = false;
setImageCallback: (arg: string) => void = () => {} fileName?: string;
showRetry = true mirror = false;
uploading = false numDevices = 0;
visible = false setImageCallback: (arg: string) => void = () => {};
showRetry = true;
uploading = false;
visible = false;
private platformService = PlatformServiceFactory.getInstance() URL = window.URL || window.webkitURL;
URL = window.URL || window.webkitURL
async mounted() { async mounted() {
try { try {
const settings = await retrieveSettingsForActiveAccount() const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || '' this.activeDid = settings.activeDid || "";
} catch (err: unknown) { // eslint-disable-next-line @typescript-eslint/no-explicit-any
logger.error('Error retrieving settings from database:', err) } catch (err: any) {
logger.error("Error retrieving settings from database:", err);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: err.message || 'There was an error retrieving your settings.' text: err.message || "There was an error retrieving your settings.",
}, },
-1 -1,
) );
} }
} }
@@ -148,191 +173,244 @@ export default class PhotoDialog extends Vue {
setImageFn: (arg: string) => void, setImageFn: (arg: string) => void,
claimType: string, claimType: string,
crop?: boolean, crop?: boolean,
blob?: Blob, blob?: Blob, // for image upload, just to use the cropping function
inputFileName?: string inputFileName?: string,
) { ) {
this.visible = true this.visible = true;
this.claimType = claimType this.claimType = claimType;
this.crop = !!crop this.crop = !!crop;
const bottomNav = document.querySelector('#QuickNav') as HTMLElement const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) { if (bottomNav) {
bottomNav.style.display = 'none' bottomNav.style.display = "none";
} }
this.setImageCallback = setImageFn this.setImageCallback = setImageFn;
if (blob) { if (blob) {
this.blob = blob this.blob = blob;
this.fileName = inputFileName this.fileName = inputFileName;
this.showRetry = false // doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
this.showRetry = false;
} else { } else {
this.blob = undefined this.blob = undefined;
this.fileName = undefined this.fileName = undefined;
this.showRetry = true this.showRetry = true;
} }
} }
close() { close() {
this.visible = false this.visible = false;
const bottomNav = document.querySelector('#QuickNav') as HTMLElement const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) { if (bottomNav) {
bottomNav.style.display = '' bottomNav.style.display = "";
} }
this.blob = undefined this.blob = undefined;
} }
async takePhoto() { async cameraStarted() {
try { const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
const result = await this.platformService.takePicture() if (cameraComponent) {
this.blob = result.blob this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
this.fileName = result.fileName this.mirror = cameraComponent.facingMode === "user";
} catch (error: unknown) { // figure out which device is active
logger.error('Error taking picture:', error) const currentDeviceId = cameraComponent.currentDeviceID();
this.$notify( const devices = await cameraComponent.devices(["videoinput"]);
{ this.activeDeviceNumber = devices.findIndex(
group: 'alert', (device) => device.deviceId === currentDeviceId,
type: 'danger', );
title: 'Error',
text: 'Failed to take picture. Please try again.'
},
5000
)
} }
} }
async pickPhoto() { async switchCamera() {
try { const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
const result = await this.platformService.pickImage() this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
this.blob = result.blob const devices = await cameraComponent?.devices(["videoinput"]);
this.fileName = result.fileName await cameraComponent?.changeCamera(
} catch (error: unknown) { devices[this.activeDeviceNumber].deviceId,
logger.error('Error picking image:', error) );
}
async takeImage(/* payload: MouseEvent */) {
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
/**
* This logic to set the image height & width correctly.
* Without it, the portrait orientation ends up with an image that is stretched horizontally.
* Note that it's the same with raw browser Javascript; see the "drawImage" example below.
* Now that I've done it, I can't explain why it works.
*/
let imageHeight = cameraComponent?.resolution?.height;
let imageWidth = cameraComponent?.resolution?.width;
const initialImageRatio = imageWidth / imageHeight;
const windowRatio = window.innerWidth / window.innerHeight;
if (initialImageRatio > 1 && windowRatio < 1) {
// the image is wider than it is tall, and the window is taller than it is wide
// For some reason, mobile in portrait orientation renders a horizontally-stretched image.
// We're gonna force it opposite.
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
} else if (initialImageRatio < 1 && windowRatio > 1) {
// the image is taller than it is wide, and the window is wider than it is tall
// Haven't seen this happen, but we'll do it just in case.
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
}
const newImageRatio = imageWidth / imageHeight;
if (newImageRatio < windowRatio) {
// the image is a taller ratio than the window, so fit the height first
imageHeight = window.innerHeight / 2;
imageWidth = imageHeight * newImageRatio;
} else {
// the image is a wider ratio than the window, so fit the width first
imageWidth = window.innerWidth / 2;
imageHeight = imageWidth / newImageRatio;
}
// The resolution is only necessary because of that mobile portrait-orientation case.
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
this.blob =
(await cameraComponent?.snapshot({
height: imageHeight,
width: imageWidth,
})) || undefined;
// png is default
this.fileName = "snapshot.png";
if (!this.blob) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: 'Failed to pick image. Please try again.' text: "There was an error taking the picture. Please try again.",
}, },
5000 5000,
) );
return;
} }
} }
private createBlobURL(blob: Blob): string { private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob) return URL.createObjectURL(blob);
} }
async retryImage() { async retryImage() {
this.blob = undefined this.blob = undefined;
} }
/****
Here's an approach to photo capture without a library. It has similar quirks.
Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday.
<button id="start-camera" @click="cameraClicked">Start Camera</button>
<video id="video" width="320" height="240" autoplay></video>
<button id="snap-photo" @click="photoSnapped">Snap Photo</button>
<canvas id="canvas" width="320" height="240"></canvas>
async cameraClicked() {
const video = document.querySelector("#video");
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
if (video instanceof HTMLVideoElement) {
video.srcObject = stream;
}
}
photoSnapped() {
const video = document.querySelector("#video");
const canvas = document.querySelector("#canvas");
if (
canvas instanceof HTMLCanvasElement &&
video instanceof HTMLVideoElement
) {
canvas
?.getContext("2d")
?.drawImage(video, 0, 0, canvas.width, canvas.height);
// ... or set the blob:
// canvas?.toBlob(
// (blob) => {
// this.blob = blob;
// },
// "image/jpeg",
// 1,
// );
// data url of the image
const image_data_url = canvas?.toDataURL("image/jpeg");
}
}
****/
async uploadImage() { async uploadImage() {
this.uploading = true this.uploading = true;
if (this.crop) { 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 = { const headers = {
Authorization: 'Bearer ' + token Authorization: "Bearer " + token,
} // axios fills in Content-Type of multipart/form-data
const formData = new FormData() };
const formData = new FormData();
if (!this.blob) { if (!this.blob) {
// yeah, this should never happen, but it helps with subsequent type checking
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: 'There was an error finding the picture. Please try again.' text: "There was an error finding the picture. Please try again.",
}, },
5000 5000,
) );
this.uploading = false this.uploading = false;
return return;
} }
formData.append('image', this.blob, this.fileName || 'photo.jpg') formData.append("image", this.blob, this.fileName || "snapshot.png");
formData.append('claimType', this.claimType) formData.append("claimType", this.claimType);
try { try {
if ( if (
window.location.hostname === 'localhost' && window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes('localhost') !DEFAULT_IMAGE_API_SERVER.includes("localhost")
) { ) {
logger.log( 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( const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + '/image', DEFAULT_IMAGE_API_SERVER + "/image",
formData, formData,
{ headers } { headers },
) );
this.uploading = false this.uploading = false;
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))
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
// Log detailed error information
logger.error('Upload error details:', {
status,
statusText,
data: JSON.stringify(data, null, 2),
message: error.message,
config: {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers
}
})
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
}
} else if (error instanceof Error) {
// Log non-Axios error with full details
logger.error('Non-Axios error details:', {
name: error.name,
message: error.message,
stack: error.stack,
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
})
} else {
// Log any other type of error
logger.error('Unknown error type:', {
error: JSON.stringify(error, null, 2),
type: typeof error
})
}
this.close();
this.setImageCallback(response.data.url as string);
} catch (error) {
logger.error("Error uploading the image", error);
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error', title: "Error",
text: errorMessage text: "There was an error saving the picture.",
}, },
5000 5000,
) );
this.uploading = false this.uploading = false;
this.blob = undefined this.blob = undefined;
}
}
swapMirrorClass() {
this.mirror = !this.mirror;
if (this.mirror) {
(this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video");
} else {
(this.$refs.cameraContainer as HTMLElement).classList.remove(
"mirror-video",
);
} }
} }
} }
@@ -360,4 +438,12 @@ export default class PhotoDialog extends Vue {
width: 100%; width: 100%;
max-width: 700px; max-width: 700px;
} }
.mirror-video {
transform: scaleX(-1);
-webkit-transform: scaleX(-1); /* For Safari */
-moz-transform: scaleX(-1); /* For Firefox */
-ms-transform: scaleX(-1); /* For IE */
-o-transform: scaleX(-1); /* For Opera */
}
</style> </style>

View File

@@ -15,35 +15,35 @@
/> />
</template> </template>
<script lang="ts"> <script lang="ts">
import { toSvg } from 'jdenticon' import { toSvg } from "jdenticon";
import { Vue, Component, Prop } from 'vue-facing-decorator' import { Vue, Component, Prop } from "vue-facing-decorator";
const BLANK_CONFIG = { const BLANK_CONFIG = {
lightness: { lightness: {
color: [1.0, 1.0], color: [1.0, 1.0],
grayscale: [1.0, 1.0] grayscale: [1.0, 1.0],
}, },
saturation: { saturation: {
color: 0.0, color: 0.0,
grayscale: 0.0 grayscale: 0.0,
}, },
backColor: '#0000' backColor: "#0000",
} };
@Component @Component
export default class ProjectIcon extends Vue { export default class ProjectIcon extends Vue {
@Prop entityId = '' @Prop entityId = "";
@Prop iconSize = 0 @Prop iconSize = 0;
@Prop imageUrl = '' @Prop imageUrl = "";
@Prop linkToFull = false @Prop linkToFull = false;
generateIdenticon() { generateIdenticon() {
if (this.imageUrl) { 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 { } else {
const config = this.entityId ? undefined : BLANK_CONFIG const config = this.entityId ? undefined : BLANK_CONFIG;
const svgString = toSvg(this.entityId, this.iconSize, config) const svgString = toSvg(this.entityId, this.iconSize, config);
return svgString return svgString;
} }
} }
} }

View File

@@ -45,7 +45,7 @@
> >
<!-- eslint-enable --> <!-- eslint-enable -->
<span class="w-full flex justify-between text-xs text-slate-500"> <span class="w-full flex justify-between text-xs text-slate-500">
<span /> <span></span>
<span>(100 characters max)</span> <span>(100 characters max)</span>
</span> </span>
</div> </div>
@@ -79,8 +79,8 @@
<button <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" 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=" @click="
close() close();
turnOnNotifications() turnOnNotifications();
" "
> >
Turn on Daily Message Turn on Daily Message
@@ -100,46 +100,46 @@
</template> </template>
<script lang="ts"> <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 { import {
logConsoleAndDb, logConsoleAndDb,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
secretDB secretDB,
} from '../db/index' } from "../db/index";
import { MASTER_SECRET_KEY } from '../db/tables/secret' import { MASTER_SECRET_KEY } from "../db/tables/secret";
import { urlBase64ToUint8Array } from '../libs/crypto/vc/util' import { urlBase64ToUint8Array } from "../libs/crypto/vc/util";
import * as libsUtil from '../libs/util' import * as libsUtil from "../libs/util";
import { logger } from '../utils/logger' import { logger } from "../utils/logger";
// Example interface for error // Example interface for error
interface ErrorResponse { interface ErrorResponse {
message: string message: string;
} }
// PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson // PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson
interface PushSubscriptionWithTime extends PushSubscriptionJSON { interface PushSubscriptionWithTime extends PushSubscriptionJSON {
message?: string message?: string;
notifyTime: { utcHour: number; minute: number } notifyTime: { utcHour: number; minute: number };
notifyType: string notifyType: string;
} }
interface ServiceWorkerMessage { interface ServiceWorkerMessage {
type: string type: string;
data: string data: string;
} }
interface ServiceWorkerResponse { interface ServiceWorkerResponse {
// Define the properties and their types // Define the properties and their types
success: boolean success: boolean;
message?: string message?: string;
} }
interface VapidResponse { interface VapidResponse {
data: { data: {
vapidKey: string vapidKey: string;
} };
} }
@Component @Component
@@ -147,135 +147,135 @@ export default class PushNotificationPermission extends Vue {
// eslint-disable-next-line // eslint-disable-next-line
$notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>; $notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>;
DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE;
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE;
callback: (success: boolean, time: string, message?: string) => void = callback: (success: boolean, time: string, message?: string) => void =
() => {} () => {};
hourAm = true hourAm = true;
hourInput = '8' hourInput = "8";
isVisible = false isVisible = false;
messageInput = '' messageInput = "";
minuteInput = '00' minuteInput = "00";
pushType = '' pushType = "";
serviceWorkerReady = false serviceWorkerReady = false;
vapidKey = '' vapidKey = "";
async open( async open(
pushType: string, pushType: string,
callback?: (success: boolean, time: string, message?: string) => void callback?: (success: boolean, time: string, message?: string) => void,
) { ) {
this.callback = callback || this.callback this.callback = callback || this.callback;
this.isVisible = true this.isVisible = true;
this.pushType = pushType this.pushType = pushType;
try { try {
const settings = await retrieveSettingsForActiveAccount() const settings = await retrieveSettingsForActiveAccount();
let pushUrl = DEFAULT_PUSH_SERVER let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) { if (settings?.webPushServer) {
pushUrl = settings.webPushServer pushUrl = settings.webPushServer;
} }
if (pushUrl.startsWith('http://localhost')) { if (pushUrl.startsWith("http://localhost")) {
logConsoleAndDb('Not checking for VAPID in this local environment.') logConsoleAndDb("Not checking for VAPID in this local environment.");
} else { } else {
let responseData = '' let responseData = "";
await this.axios await this.axios
.get(pushUrl + '/web-push/vapid') .get(pushUrl + "/web-push/vapid")
.then((response: VapidResponse) => { .then((response: VapidResponse) => {
this.vapidKey = response.data?.vapidKey || '' this.vapidKey = response.data?.vapidKey || "";
logConsoleAndDb('Got vapid key: ' + this.vapidKey) logConsoleAndDb("Got vapid key: " + this.vapidKey);
responseData = JSON.stringify(response.data) responseData = JSON.stringify(response.data);
navigator.serviceWorker?.addEventListener( navigator.serviceWorker?.addEventListener(
'controllerchange', "controllerchange",
() => { () => {
logConsoleAndDb( logConsoleAndDb(
'New service worker is now controlling the page' "New service worker is now controlling the page",
) );
} },
) );
}) });
if (!this.vapidKey) { if (!this.vapidKey) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error Setting Notifications', title: "Error Setting Notifications",
text: 'Could not set notifications.' text: "Could not set notifications.",
}, },
5000 5000,
) );
logConsoleAndDb( logConsoleAndDb(
"Error Setting Notifications: web push server response didn't have vapidKey: " + "Error Setting Notifications: web push server response didn't have vapidKey: " +
responseData, responseData,
true true,
) );
} }
} }
} catch (error) { } catch (error) {
if (window.location.host.startsWith('localhost')) { if (window.location.host.startsWith("localhost")) {
logConsoleAndDb( logConsoleAndDb(
'Ignoring the error getting VAPID for local development.' "Ignoring the error getting VAPID for local development.",
) );
} else { } else {
logConsoleAndDb( logConsoleAndDb(
'Got an error initializing notifications: ' + JSON.stringify(error), "Got an error initializing notifications: " + JSON.stringify(error),
true true,
) );
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error Setting Notifications', title: "Error Setting Notifications",
text: 'Got an error setting notifications.' text: "Got an error setting notifications.",
}, },
5000 5000,
) );
} }
} }
// there may be a long pause here on first initialization // there may be a long pause here on first initialization
navigator.serviceWorker?.ready.then(() => { navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true this.serviceWorkerReady = true;
}) });
if (this.pushType === this.DIRECT_PUSH_TITLE) { if (this.pushType === this.DIRECT_PUSH_TITLE) {
this.messageInput = 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 // focus on the message input
setTimeout(function () { setTimeout(function () {
document.getElementById('push-message')?.focus() document.getElementById("push-message")?.focus();
}, 100) }, 100);
} else { } else {
// not critical but doesn't make sense in a daily check // not critical but doesn't make sense in a daily check
this.messageInput = '' this.messageInput = "";
} }
} }
private close() { private close() {
this.isVisible = false this.isVisible = false;
} }
private sendMessageToServiceWorker( private sendMessageToServiceWorker(
message: ServiceWorkerMessage message: ServiceWorkerMessage,
): Promise<unknown> { ): Promise<unknown> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (navigator.serviceWorker?.controller) { if (navigator.serviceWorker?.controller) {
const messageChannel = new MessageChannel() const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event: MessageEvent) => { messageChannel.port1.onmessage = (event: MessageEvent) => {
if (event.data.error) { if (event.data.error) {
reject(event.data.error as ErrorResponse) reject(event.data.error as ErrorResponse);
} else { } else {
resolve(event.data as ServiceWorkerResponse) resolve(event.data as ServiceWorkerResponse);
}
} }
};
navigator.serviceWorker?.controller.postMessage(message, [ navigator.serviceWorker?.controller.postMessage(message, [
messageChannel.port2 messageChannel.port2,
]) ]);
} else { } else {
reject('Service worker controller not available') reject("Service worker controller not available");
} }
}) });
} }
private async askPermission(): Promise<NotificationPermission> { private async askPermission(): Promise<NotificationPermission> {
@@ -283,136 +283,136 @@ export default class PushNotificationPermission extends Vue {
// "Requesting permission for notifications: " + JSON.stringify(navigator), // "Requesting permission for notifications: " + JSON.stringify(navigator),
// ); // );
if ( 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() await secretDB.open();
const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret;
if (!secret) { if (!secret) {
return Promise.reject('No secret found.') return Promise.reject("No secret found.");
} }
return this.sendSecretToServiceWorker(secret) return this.sendSecretToServiceWorker(secret)
.then(() => this.checkNotificationSupport()) .then(() => this.checkNotificationSupport())
.then(() => this.requestNotificationPermission()) .then(() => this.requestNotificationPermission())
.catch((error) => Promise.reject(error)) .catch((error) => Promise.reject(error));
} }
private sendSecretToServiceWorker(secret: string): Promise<void> { private sendSecretToServiceWorker(secret: string): Promise<void> {
const message: ServiceWorkerMessage = { const message: ServiceWorkerMessage = {
type: 'SEND_LOCAL_DATA', type: "SEND_LOCAL_DATA",
data: secret data: secret,
} };
return this.sendMessageToServiceWorker(message).then((response) => { return this.sendMessageToServiceWorker(message).then((response) => {
logConsoleAndDb( logConsoleAndDb(
'Response from service worker: ' + JSON.stringify(response) "Response from service worker: " + JSON.stringify(response),
) );
}) });
} }
private checkNotificationSupport(): Promise<void> { private checkNotificationSupport(): Promise<void> {
if (!('Notification' in window)) { if (!("Notification" in window)) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Browser Notifications Are Not Supported', title: "Browser Notifications Are Not Supported",
text: 'This browser does not support notifications.' text: "This browser does not support notifications.",
}, },
3000 3000,
) );
return Promise.reject('This browser does not support notifications.') return Promise.reject("This browser does not support notifications.");
} }
if (window.Notification.permission === 'granted') { if (window.Notification.permission === "granted") {
return Promise.resolve() return Promise.resolve();
} }
return Promise.resolve() return Promise.resolve();
} }
private requestNotificationPermission(): Promise<NotificationPermission> { private requestNotificationPermission(): Promise<NotificationPermission> {
return window.Notification.requestPermission().then( return window.Notification.requestPermission().then(
(permission: string) => { (permission: string) => {
if (permission !== 'granted') { if (permission !== "granted") {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error Requesting Notification Permission', title: "Error Requesting Notification Permission",
text: text:
'Allow this app permission to make notifications for personal reminders.' + "Allow this app permission to make notifications for personal reminders." +
' You can adjust them at any time in your settings.' " You can adjust them at any time in your settings.",
}, },
-1 -1,
) );
throw new Error('Permission was not granted to this app.') throw new Error("Permission was not granted to this app.");
} }
return permission return permission;
} },
) );
} }
private checkHourInput() { private checkHourInput() {
const hourNum = parseInt(this.hourInput) const hourNum = parseInt(this.hourInput);
if (isNaN(hourNum)) { if (isNaN(hourNum)) {
this.hourInput = '12' this.hourInput = "12";
} else if (hourNum < 1) { } else if (hourNum < 1) {
this.hourInput = '12' this.hourInput = "12";
this.hourAm = !this.hourAm this.hourAm = !this.hourAm;
} else if (hourNum > 12) { } else if (hourNum > 12) {
this.hourInput = '1' this.hourInput = "1";
this.hourAm = !this.hourAm this.hourAm = !this.hourAm;
} else { } else {
this.hourInput = hourNum.toString() this.hourInput = hourNum.toString();
} }
} }
private checkMinuteInput() { private checkMinuteInput() {
const minuteNum = parseInt(this.minuteInput) const minuteNum = parseInt(this.minuteInput);
if (isNaN(minuteNum)) { if (isNaN(minuteNum)) {
this.minuteInput = '00' this.minuteInput = "00";
} else if (minuteNum < 0) { } else if (minuteNum < 0) {
this.minuteInput = '59' this.minuteInput = "59";
} else if (minuteNum < 10) { } else if (minuteNum < 10) {
this.minuteInput = '0' + minuteNum this.minuteInput = "0" + minuteNum;
} else if (minuteNum > 59) { } else if (minuteNum > 59) {
this.minuteInput = '00' this.minuteInput = "00";
} else { } else {
this.minuteInput = minuteNum.toString() this.minuteInput = minuteNum.toString();
} }
} }
private async turnOnNotifications() { private async turnOnNotifications() {
let notifyCloser = () => {} let notifyCloser = () => {};
return this.askPermission() return this.askPermission()
.then((permission) => { .then((permission) => {
logConsoleAndDb('Permission granted: ' + JSON.stringify(permission)) logConsoleAndDb("Permission granted: " + JSON.stringify(permission));
// Call the function and handle promises // Call the function and handle promises
return this.subscribeToPush() return this.subscribeToPush();
}) })
.then(() => { .then(() => {
logConsoleAndDb('Subscribed successfully.') logConsoleAndDb("Subscribed successfully.");
return navigator.serviceWorker?.ready return navigator.serviceWorker?.ready;
}) })
.then((registration) => { .then((registration) => {
return registration.pushManager.getSubscription() return registration.pushManager.getSubscription();
}) })
.then(async (subscription) => { .then(async (subscription) => {
if (subscription) { if (subscription) {
notifyCloser = await this.$notify( notifyCloser = await this.$notify(
{ {
group: 'alert', group: "alert",
type: 'info', type: "info",
title: 'Notification Setup Underway', 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." 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 // 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 const adjHourNum = this.hourAm
? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum ? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum
rawHourNum === 12 rawHourNum === 12
@@ -421,153 +421,153 @@ export default class PushNotificationPermission extends Vue {
: // Otherwise it's PM, so keep a 12 but otherwise add 12 : // Otherwise it's PM, so keep a 12 but otherwise add 12
rawHourNum === 12 rawHourNum === 12
? 12 ? 12
: rawHourNum + 12 : rawHourNum + 12;
const hourNum = adjHourNum % 24 // probably unnecessary now const hourNum = adjHourNum % 24; // probably unnecessary now
const utcHour = const utcHour =
hourNum + Math.round(new Date().getTimezoneOffset() / 60) hourNum + Math.round(new Date().getTimezoneOffset() / 60);
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24 const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
const minuteNum = libsUtil.numberOrZero(this.minuteInput) const minuteNum = libsUtil.numberOrZero(this.minuteInput);
const utcMinute = const utcMinute =
minuteNum + Math.round(new Date().getTimezoneOffset() % 60) minuteNum + Math.round(new Date().getTimezoneOffset() % 60);
const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60 const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60;
const subscriptionWithTime: PushSubscriptionWithTime = { const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute }, notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute },
notifyType: this.pushType, notifyType: this.pushType,
message: this.messageInput, message: this.messageInput,
...subscription.toJSON() ...subscription.toJSON(),
} };
await this.sendSubscriptionToServer(subscriptionWithTime) await this.sendSubscriptionToServer(subscriptionWithTime);
// To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1 // To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1
logConsoleAndDb( logConsoleAndDb(
'Subscription data sent to server with endpoint: ' + "Subscription data sent to server with endpoint: " +
subscription.endpoint subscription.endpoint,
) );
return subscriptionWithTime return subscriptionWithTime;
} else { } else {
throw new Error('Subscription object is not available.') throw new Error("Subscription object is not available.");
} }
}) })
.then(async (subscription: PushSubscriptionWithTime) => { .then(async (subscription: PushSubscriptionWithTime) => {
logConsoleAndDb( logConsoleAndDb(
'Subscription data sent to server and all finished successfully.' "Subscription data sent to server and all finished successfully.",
) );
await libsUtil.sendTestThroughPushServer(subscription, true) await libsUtil.sendTestThroughPushServer(subscription, true);
notifyCloser() notifyCloser();
setTimeout(() => { setTimeout(() => {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'success', type: "success",
title: 'Notification Is On', title: "Notification Is On",
text: "You should see at least one on your device; if not, check the 'Troubleshoot' link." text: "You should see at least one on your device; if not, check the 'Troubleshoot' link.",
}, },
7000 7000,
) );
}, 500) }, 500);
const timeText = const timeText =
// eslint-disable-next-line // eslint-disable-next-line
this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM"); this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM");
this.callback(true, timeText, this.messageInput) this.callback(true, timeText, this.messageInput);
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb( logConsoleAndDb(
'Got an error setting notification permissions: ' + "Got an error setting notification permissions: " +
' string ' + " string " +
error.toString() + error.toString() +
' JSON ' + " JSON " +
JSON.stringify(error), JSON.stringify(error),
true true,
) );
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error Setting Notification Permissions', title: "Error Setting Notification Permissions",
text: 'Could not set 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 // if we want to also unsubscribe, be sure to do that only if no other notification is active
}) });
} }
private subscribeToPush(): Promise<void> { private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (!('serviceWorker' in navigator && 'PushManager' in window)) { if (!("serviceWorker" in navigator && "PushManager" in window)) {
const errorMsg = 'Push messaging is not supported' const errorMsg = "Push messaging is not supported";
logger.warn(errorMsg) logger.warn(errorMsg);
return reject(new Error(errorMsg)) return reject(new Error(errorMsg));
} }
if (window.Notification.permission !== 'granted') { if (window.Notification.permission !== "granted") {
const errorMsg = 'Notification permission not granted' const errorMsg = "Notification permission not granted";
logger.warn(errorMsg) logger.warn(errorMsg);
return reject(new Error(errorMsg)) return reject(new Error(errorMsg));
} }
const applicationServerKey = urlBase64ToUint8Array(this.vapidKey) const applicationServerKey = urlBase64ToUint8Array(this.vapidKey);
const options: PushSubscriptionOptions = { const options: PushSubscriptionOptions = {
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: applicationServerKey applicationServerKey: applicationServerKey,
} };
navigator.serviceWorker?.ready navigator.serviceWorker?.ready
.then((registration) => { .then((registration) => {
return registration.pushManager.subscribe(options) return registration.pushManager.subscribe(options);
}) })
.then((subscription) => { .then((subscription) => {
logConsoleAndDb( logConsoleAndDb(
'Push subscription successful: ' + JSON.stringify(subscription) "Push subscription successful: " + JSON.stringify(subscription),
) );
resolve() resolve();
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb( logConsoleAndDb(
'Push subscription failed: ' + "Push subscription failed: " +
JSON.stringify(error) + JSON.stringify(error) +
' - ' + " - " +
JSON.stringify(options), JSON.stringify(options),
true true,
) );
// Inform the user about the issue // Inform the user about the issue
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error Setting Push Notifications', title: "Error Setting Push Notifications",
text: text:
'We encountered an issue setting up push notifications. ' + "We encountered an issue setting up push notifications. " +
'If you wish to revoke notification permissions, please do so in your browser settings.' "If you wish to revoke notification permissions, please do so in your browser settings.",
}, },
-1 -1,
) );
reject(error) reject(error);
}) });
}) });
} }
private sendSubscriptionToServer( private sendSubscriptionToServer(
subscription: PushSubscriptionWithTime subscription: PushSubscriptionWithTime,
): Promise<void> { ): Promise<void> {
logConsoleAndDb( logConsoleAndDb(
'About to send subscription... ' + JSON.stringify(subscription) "About to send subscription... " + JSON.stringify(subscription),
) );
return fetch('/web-push/subscribe', { return fetch("/web-push/subscribe", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json' "Content-Type": "application/json",
}, },
body: JSON.stringify(subscription) body: JSON.stringify(subscription),
}).then((response) => { }).then((response) => {
if (!response.ok) { if (!response.ok) {
logger.error('Bad response subscribing to web push: ', response) logger.error("Bad response subscribing to web push: ", response);
throw new Error('Failed to send push subscription to server') 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> </script>

View File

@@ -1,124 +0,0 @@
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
}
}

View File

@@ -1,319 +0,0 @@
<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>

View File

@@ -1,74 +0,0 @@
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
}
}

View File

@@ -1,38 +0,0 @@
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
}
}
}

View File

@@ -1,14 +0,0 @@
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>
}

View File

@@ -1,14 +1,14 @@
<template> <template>
<!-- QUICK NAV --> <!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50"> <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"> <ul class="flex text-2xl p-2 gap-2 max-w-3xl mx-auto">
<!-- Home Feed --> <!-- Home Feed -->
<li <li
:class="{ :class="{
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Home', '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"> <router-link :to="{ name: 'home' }" class="block text-center py-2 px-1">
@@ -24,7 +24,7 @@
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Discover', 'bg-slate-400 text-white': selected === 'Discover',
'text-slate-500': selected !== 'Discover' 'text-slate-500': selected !== 'Discover',
}" }"
> >
<router-link <router-link
@@ -43,7 +43,7 @@
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Projects', 'bg-slate-400 text-white': selected === 'Projects',
'text-slate-500': selected !== 'Projects' 'text-slate-500': selected !== 'Projects',
}" }"
> >
<router-link <router-link
@@ -52,7 +52,7 @@
> >
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<font-awesome icon="hand" class="fa-fw" /> <font-awesome icon="hand" class="fa-fw" />
<span class="text-xs mt-1">yours</span> <span class="text-xs mt-1">your work</span>
</div> </div>
</router-link> </router-link>
</li> </li>
@@ -62,7 +62,7 @@
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Contacts', 'bg-slate-400 text-white': selected === 'Contacts',
'text-slate-500': selected !== 'Contacts' 'text-slate-500': selected !== 'Contacts',
}" }"
> >
<router-link <router-link
@@ -81,7 +81,7 @@
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Profile', 'bg-slate-400 text-white': selected === 'Profile',
'text-slate-500': selected !== 'Profile' 'text-slate-500': selected !== 'Profile',
}" }"
> >
<router-link <router-link
@@ -106,10 +106,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from 'vue-facing-decorator' import { Component, Vue, Prop } from "vue-facing-decorator";
@Component @Component
export default class QuickNav extends Vue { export default class QuickNav extends Vue {
@Prop selected = '' @Prop selected = "";
} }
</script> </script>

View File

@@ -13,46 +13,46 @@
</template> </template>
<script lang="ts"> <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 { AppString, NotificationIface } from "../constants/app";
import { retrieveSettingsForActiveAccount } from '../db/index' import { retrieveSettingsForActiveAccount } from "../db/index";
@Component @Component
export default class TopMessage extends Vue { 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() { async mounted() {
try { try {
const settings = await retrieveSettingsForActiveAccount() const settings = await retrieveSettingsForActiveAccount();
if ( if (
settings.warnIfTestServer && settings.warnIfTestServer &&
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER 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 a non-prod server, user " + didPrefix this.message = "You're linked to a non-prod server, user " + didPrefix;
} else if ( } else if (
settings.warnIfProdServer && settings.warnIfProdServer &&
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) { ) {
const didPrefix = settings.activeDid?.slice(11, 15) const didPrefix = settings.activeDid?.slice(11, 15);
this.message = this.message =
"You're linked to the production server, user " + didPrefix "You're linked to the production server, user " + didPrefix;
} }
} catch (err: unknown) { } catch (err: unknown) {
this.$notify( this.$notify(
{ {
group: 'alert', group: "alert",
type: 'danger', type: "danger",
title: 'Error Detecting Server', title: "Error Detecting Server",
text: JSON.stringify(err) text: JSON.stringify(err),
}, },
-1 -1,
) );
} }
} }
} }

View File

@@ -35,49 +35,49 @@
</template> </template>
<script lang="ts"> <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 { db, retrieveSettingsForActiveAccount } from '../db/index' import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { MASTER_SETTINGS_KEY } from '../db/tables/settings' import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
@Component @Component
export default class UserNameDialog extends Vue { export default class UserNameDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void $notify!: (notification: NotificationIface, timeout?: number) => void;
@Prop({ @Prop({
default: 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 sharingExplanation!: string;
@Prop({ default: false }) callbackOnCancel!: boolean @Prop({ default: false }) callbackOnCancel!: boolean;
callback: (name?: string) => void = () => {} callback: (name?: string) => void = () => {};
givenName = '' givenName = "";
visible = false visible = false;
/** /**
* @param aCallback - callback function for name, which may be "" * @param aCallback - callback function for name, which may be ""
*/ */
async open(aCallback?: (name?: string) => void) { async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback this.callback = aCallback || this.callback;
const settings = await retrieveSettingsForActiveAccount() const settings = await retrieveSettingsForActiveAccount();
this.givenName = settings.firstName || '' this.givenName = settings.firstName || "";
this.visible = true this.visible = true;
} }
async onClickSaveChanges() { async onClickSaveChanges() {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName firstName: this.givenName,
}) });
this.visible = false this.visible = false;
this.callback(this.givenName) this.callback(this.givenName);
} }
onClickCancel() { onClickCancel() {
this.visible = false this.visible = false;
if (this.callbackOnCancel) { if (this.callbackOnCancel) {
this.callback() this.callback();
} }
} }
} }

View File

@@ -1,53 +1,53 @@
// from https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80 // 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 TWEEN from "@tweenjs/tween.js";
import * as THREE from 'three' import * as THREE from "three";
import { createCamera } from './components/camera.js' import { createCamera } from "./components/camera.js";
import { createLights } from './components/lights.js' import { createLights } from "./components/lights.js";
import { createScene } from './components/scene.js' import { createScene } from "./components/scene.js";
import { loadLandmarks } from './components/objects/landmarks.js' import { loadLandmarks } from "./components/objects/landmarks.js";
import { createTerrain } from './components/objects/terrain.js' import { createTerrain } from "./components/objects/terrain.js";
import { Loop } from './systems/Loop.js' import { Loop } from "./systems/Loop.js";
import { Resizer } from './systems/Resizer.js' import { Resizer } from "./systems/Resizer.js";
import { createControls } from './systems/controls.js' import { createControls } from "./systems/controls.js";
import { createRenderer } from './systems/renderer.js' import { createRenderer } from "./systems/renderer.js";
const COLOR1 = '#dddddd' const COLOR1 = "#dddddd";
const COLOR2 = '#0055aa' const COLOR2 = "#0055aa";
class World { class World {
constructor(container, vue) { constructor(container, vue) {
this.PLATFORM_BORDER = 5 this.PLATFORM_BORDER = 5;
this.PLATFORM_EDGE_FOR_UNKNOWNS = 10 this.PLATFORM_EDGE_FOR_UNKNOWNS = 10;
this.PLATFORM_SIZE = 100 // note that the loadLandmarks calculations may still assume 100 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 // Instances of camera, scene, and renderer
this.camera = createCamera() this.camera = createCamera();
this.scene = createScene(COLOR2) this.scene = createScene(COLOR2);
this.renderer = createRenderer() this.renderer = createRenderer();
// necessary for models, says https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader // 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.light = null;
this.lights = [] this.lights = [];
this.bushes = [] this.bushes = [];
// Initialize Loop // 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 // Orbit Controls
const controls = createControls(this.camera, this.renderer.domElement) const controls = createControls(this.camera, this.renderer.domElement);
// Light Instance, with optional light helper // Light Instance, with optional light helper
const { light } = createLights(COLOR1) const { light } = createLights(COLOR1);
// Terrain Instance // Terrain Instance
const terrain = createTerrain({ const terrain = createTerrain({
@@ -56,55 +56,55 @@ class World {
width: width:
this.PLATFORM_SIZE + this.PLATFORM_SIZE +
this.PLATFORM_BORDER * 2 + 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(controls);
this.loop.updatables.push(light) this.loop.updatables.push(light);
this.loop.updatables.push(terrain) 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 // Responsive handler
const resizer = new Resizer(container, this.camera, this.renderer) const resizer = new Resizer(container, this.camera, this.renderer);
resizer.onResize = () => { resizer.onResize = () => {
this.render() this.render();
} };
} }
update(time) { update(time) {
TWEEN.update(time) TWEEN.update(time);
this.lights.forEach((light) => { this.lights.forEach((light) => {
light.updateMatrixWorld() light.updateMatrixWorld();
light.target.updateMatrixWorld() light.target.updateMatrixWorld();
}) });
this.lights.forEach((bush) => { this.lights.forEach((bush) => {
bush.updateMatrixWorld() bush.updateMatrixWorld();
}) });
requestAnimationFrame(this.update) requestAnimationFrame(this.update);
} }
render() { render() {
// draw a single frame // draw a single frame
this.renderer.render(this.scene, this.camera) this.renderer.render(this.scene, this.camera);
} }
// Animation handlers // Animation handlers
start() { start() {
this.loop.start() this.loop.start();
} }
stop() { stop() {
this.loop.stop() this.loop.stop();
} }
setExposedWorldProperties(key, value) { setExposedWorldProperties(key, value) {
this.vue.setWorldProperty(key, value) this.vue.setWorldProperty(key, value);
} }
} }
export { World } export { World };

Some files were not shown because too many files have changed in this diff Show More