Compare commits
9 Commits
playwright
...
tmp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf4375164f | ||
|
|
bdb9da2b87 | ||
|
|
6fc070b45d | ||
|
|
55ba4f0154 | ||
|
|
38d04566a4 | ||
|
|
4b8466fb04 | ||
|
|
f45a528f43 | ||
|
|
d25f8f45bd | ||
|
|
fee0c08f76 |
@@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
# I tried and failed to set things here with vue-cli-service but
|
|
||||||
# things may be more reliable with vite so let's try again.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Only the variables that start with VITE_ are seen in the application import.meta.env in Vue.
|
|
||||||
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H
|
|
||||||
VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
|
|
||||||
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
|
|
||||||
@@ -2,7 +2,6 @@ module.exports = {
|
|||||||
root: true,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
es2022: true,
|
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
"plugin:vue/vue3-essential",
|
"plugin:vue/vue3-essential",
|
||||||
@@ -10,12 +9,11 @@ module.exports = {
|
|||||||
"@vue/typescript/recommended",
|
"@vue/typescript/recommended",
|
||||||
"plugin:prettier/recommended",
|
"plugin:prettier/recommended",
|
||||||
],
|
],
|
||||||
// parserOptions: {
|
parserOptions: {
|
||||||
// ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
// },
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
27
.github/workflows/playwright.yml
vendored
@@ -1,27 +0,0 @@
|
|||||||
name: Playwright Tests
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main, master ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main, master ]
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
timeout-minutes: 60
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
- name: Install Playwright Browsers
|
|
||||||
run: npx playwright install --with-deps
|
|
||||||
- name: Run Playwright tests
|
|
||||||
run: npx playwright test
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: playwright-report
|
|
||||||
path: playwright-report/
|
|
||||||
retention-days: 30
|
|
||||||
12
.gitignore
vendored
@@ -1,19 +1,13 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
/dist
|
/dist
|
||||||
signature.bin
|
|
||||||
# generated during `npm run build`
|
|
||||||
sw_scripts-combined.js
|
|
||||||
*.pem
|
|
||||||
verified.txt
|
|
||||||
myenv
|
|
||||||
|
|
||||||
*~
|
*~
|
||||||
# local env files
|
# local env files
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# Log filesopenssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
|
# Log files
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
@@ -27,7 +21,3 @@ pnpm-debug.log*
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
/test-results/
|
|
||||||
/playwright-report/
|
|
||||||
/blob-report/
|
|
||||||
/playwright/.cache/
|
|
||||||
|
|||||||
311
CHANGELOG.md
@@ -1,311 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
|
|
||||||
## ?
|
|
||||||
### Added
|
|
||||||
- Send list of contacts to someone
|
|
||||||
### Changed
|
|
||||||
- Moved contact actions from list onto detail page
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.20] - 2024.08.18 - 4064eb75a9743ca268bf00016fa0a5fc5dec4e30
|
|
||||||
### Fixed
|
|
||||||
- Bad "give" verbiage on offer page
|
|
||||||
- Failing offer test
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.19] - 2024.08.18 - ee9c14942ceba993bf21a11249601f205158ec71
|
|
||||||
### Added
|
|
||||||
- Update of an offer
|
|
||||||
- Recipient description in offer list
|
|
||||||
### Fixed
|
|
||||||
- List of offers wasn't showing.
|
|
||||||
- Destination page after sharing photo was wrong.
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.17] - 2024.07.11 - cefa384ff1a2d922848c370640c096c529920fab
|
|
||||||
### Added
|
|
||||||
- Photos on more screens
|
|
||||||
### Fixed
|
|
||||||
- Share of a photo, including sharing a photo from webkit/Safari which never worked
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing (though there's a new temp field in IndexedDB)
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.15] - 2024.08.04 - c8f0f2c2b16b9f0b4b47d40f7bf29058c7baa68e
|
|
||||||
### Added
|
|
||||||
- Edit gives
|
|
||||||
- Page to edit claim JSON before submitting
|
|
||||||
- Update of imported contacts
|
|
||||||
- Improve messaging on give dialog
|
|
||||||
- Section for gives provided by plan
|
|
||||||
- Deletion of an identity
|
|
||||||
- UI for choosing a passkey creation (not enabled on prod)
|
|
||||||
- Cache signatures for reports for passkey-signed requests
|
|
||||||
- Refactor: consolidate alternative signing, eg. for passkeys & did:peer
|
|
||||||
- Playwright tests
|
|
||||||
### Changed
|
|
||||||
- Linked projects display below description (instead of at bottom)
|
|
||||||
### Fixed
|
|
||||||
- Visibility toggle appearance
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.14] - 2024.06.22 - 1611d22892f683f43856d2503eee7f391b6bbce8
|
|
||||||
### Added
|
|
||||||
- Clearer give-confirmation screen
|
|
||||||
- BX currency https://thebx.medium.com/
|
|
||||||
- Deselection of project on gifted details page
|
|
||||||
### Fixed
|
|
||||||
- Don't show registration pop-up for a new contact that is registered
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.13] - 2024.05.24 - 08b67984e443c58d9178ad3776013b0bce7afddc
|
|
||||||
### Added
|
|
||||||
- Photos on projects
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.12] - 2024.05.19 - 141fb39ad19c44d82fe1a33bf85115beacf50870
|
|
||||||
### Fixed
|
|
||||||
- Photo share (share_target) failed because requests were sent to server
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.11] - 2024.05.19 - 567bcad88dfb7e9ac8fea72530d1163985e4a7cc
|
|
||||||
### Added
|
|
||||||
- Choose a file for gifts, and a URL for gifts & profiles
|
|
||||||
### Fixed
|
|
||||||
- Multiple button pushes were required to switch camera
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.10] - 2024.05.11 - 03ac31d98110f7828cf9acb366db8d01b185f64c
|
|
||||||
### Added
|
|
||||||
- Share an image
|
|
||||||
- Choose a file on the device for a profile image
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.9] - 2024.04.28 - 874e717e698b93a1ace9f588e675b8a3dccd7617
|
|
||||||
### Added
|
|
||||||
- Offers on contacts page
|
|
||||||
- Checks on front page until they show as registered
|
|
||||||
### Changed
|
|
||||||
- Scanned contacts now add immediately and prompt for registration.
|
|
||||||
- Better UI for gives on contact page
|
|
||||||
- Better UI for all confirmation messages
|
|
||||||
### Fixed
|
|
||||||
- Repeated elements at top of main feed
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.8] - 2024.04.20 - 15c026c80ce03a26cae3ff80b0888934c101c7e2
|
|
||||||
### Added
|
|
||||||
- Profile image for user
|
|
||||||
### Fixed
|
|
||||||
- Slow loading of home page feed
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.7] - 2024.04.10 - cf18f1543a700d62a5f9e764905a4aafe1fb229b
|
|
||||||
### Added
|
|
||||||
- Filter on home page feed
|
|
||||||
- Ability to set time of daily notification
|
|
||||||
- Jump to app on click of notification
|
|
||||||
### Changed
|
|
||||||
- Built with vite
|
|
||||||
- Descriptions on home page to include projects
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.6] - 2024.03.24 - 3a07e31d6313ab95711265562d9023c42916e141
|
|
||||||
### Added
|
|
||||||
- Button to mirror photo during video
|
|
||||||
- More detailed onboarding help screen
|
|
||||||
- Public-data blurb
|
|
||||||
### Changed in DB or environment
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.3.5] - 2024.03.23 - 28754bdfb1e11aa221dd49a5dce4219b69cf6a9d
|
|
||||||
### Added
|
|
||||||
- Photo on gift records
|
|
||||||
### Fixed
|
|
||||||
- Environment variable for BVC meetings project
|
|
||||||
- Environment variables and build enhancements for test vs prod
|
|
||||||
### Changed in DB or environment
|
|
||||||
- New environment variable for image API server
|
|
||||||
- Test that a new browser session will get the right default APIs.
|
|
||||||
- Test that a new browser session will send the right BVC meetings project.
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.17] - 2024.03.01 - 3612ea42240c5e1b7d7eff29a39ff18f1b869b36
|
|
||||||
### Added
|
|
||||||
- Shortcut page for Bountiful Voluntaryist Community
|
|
||||||
### Changed
|
|
||||||
- More readable, targeted summaries in home-page feed items
|
|
||||||
### Changed in DB
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.14] - 2024.02.14 - 5f9edea1167dbfb64e16648764eed8c09b24eaeb
|
|
||||||
### Changed
|
|
||||||
- Combine all service worker scripts into a single file.
|
|
||||||
### Changed in DB
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.13] - 2024.02.07
|
|
||||||
### Added
|
|
||||||
- Display of user's offers
|
|
||||||
- Check for valid DIDs
|
|
||||||
### Fixed
|
|
||||||
- Name display on give prompt
|
|
||||||
- Non-numbers on number input & autocapitalize on URL input
|
|
||||||
### Changed in DB
|
|
||||||
- Nothing
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.12] - 2024.02.01
|
|
||||||
### Added
|
|
||||||
- Prompts for gratitude
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.11] - 2024.01.28
|
|
||||||
### Added
|
|
||||||
- Actions to share claim data with contacts
|
|
||||||
- Bulk CSV import from Endorser Mobile export
|
|
||||||
- Dates on give summaries
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.10] - 2024.01.18 - 667e1e8890b42de59cd939caca1a01c7a7a702be
|
|
||||||
### Added
|
|
||||||
- Person identicons for contacts
|
|
||||||
- Confirmation & delivery directly from project page
|
|
||||||
- Offer dialog now allows units
|
|
||||||
- Links from claim detail page to the fulfilled project or offer
|
|
||||||
- Link to project from home feed
|
|
||||||
- Copy to clipboard in more places
|
|
||||||
### Fixed
|
|
||||||
- "More Contacts" for give on project page now links correctly.
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.9] - 2024.01.15 - e5e702f8a5a53a6efbed48d35f0bc3cee63024a0
|
|
||||||
### Fixed
|
|
||||||
- Set visibility for new contact.
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.8] - 2024.01.14
|
|
||||||
### Added
|
|
||||||
- Automatic ID creation from home page
|
|
||||||
- Agent who can also edit a project
|
|
||||||
### Fixed
|
|
||||||
- Cannot declare anonymous gift
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.7] - 2024.01.12
|
|
||||||
### Added
|
|
||||||
- Give to fulfill a particular offer
|
|
||||||
- Give as part of a trade as opposed to a donation
|
|
||||||
- Error notifications on import
|
|
||||||
### Changed
|
|
||||||
- Library security updates
|
|
||||||
- Visibility of actions & confirmations on claim page
|
|
||||||
### Fixed
|
|
||||||
- Name of offerer
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.2] - 2024.01.05
|
|
||||||
### Added
|
|
||||||
- Check for notification capability on front screen
|
|
||||||
- Contact next-public-key-hash in manual textual input
|
|
||||||
- Confirmation for contact visibility change
|
|
||||||
- YAML rendering of full claim details
|
|
||||||
- Hints for onboarding on the contact screen
|
|
||||||
|
|
||||||
|
|
||||||
## [0.2.0] - 2024.01.04
|
|
||||||
### Added
|
|
||||||
- Contact next-public-key-hash
|
|
||||||
- Icon for Android
|
|
||||||
- More thorough messaging and testing for notifications
|
|
||||||
|
|
||||||
|
|
||||||
## [0.1.9] - 2024.01.01
|
|
||||||
### Added
|
|
||||||
- Import for contacts and settings
|
|
||||||
- Second download button for DuckDuckGo
|
|
||||||
### Changed
|
|
||||||
- Removed some keys from Dexie's IndexedDB declarations
|
|
||||||
|
|
||||||
|
|
||||||
## [0.1.8] - 2023.12.27- d26d1d360152a7d0e559b68486e85b72b88bd9ff
|
|
||||||
### Added
|
|
||||||
- DB logging for service-worker events
|
|
||||||
- Help page for notifications
|
|
||||||
- Test notification & web-push triggers inside app
|
|
||||||
- Check that the app is installed
|
|
||||||
### Fixed
|
|
||||||
- Project issuer display name
|
|
||||||
|
|
||||||
|
|
||||||
## [0.1.7] - 2023.12.19 - 91c6c7c11c71f96006cc876fc946f1f98a274ba2
|
|
||||||
### Changed
|
|
||||||
- Icons
|
|
||||||
### Fixed
|
|
||||||
- Notification switch now shows message
|
|
||||||
- Prod/test server warning message at top of page
|
|
||||||
|
|
||||||
|
|
||||||
## [0.1.6] - 2023.12.17 - b445b1234fbfcf6b37d695373f259aab0eda1118
|
|
||||||
### Added
|
|
||||||
- Infinite scroll on home page
|
|
||||||
### Changed
|
|
||||||
- UI improvements
|
|
||||||
- Show web-push subscription info
|
|
||||||
- Icon
|
|
||||||
|
|
||||||
|
|
||||||
## [0.1.5] - 2023.12.09 - 9c36bb509a9bae9bb3306d3bd9eeb144b67aa8ad
|
|
||||||
### Added
|
|
||||||
- Web push notifications (though not finalized)
|
|
||||||
- Credentials details page
|
|
||||||
- See more data without an ID
|
|
||||||
- Change units of a give
|
|
||||||
|
|
||||||
|
|
||||||
## [0.1.4] - 2023.11.20 - 7311d36726f3667ec4c68f241f91d404273ad4db
|
|
||||||
### Added
|
|
||||||
- Offer on a project
|
|
||||||
### Changed
|
|
||||||
- Automatically set as visible when importing a contact
|
|
||||||
|
|
||||||
|
|
||||||
## [0.1.3] - 2023.11.08 - 910f57ec7d2e50803ae3d04f4b927e0f5219fbde
|
|
||||||
### Added
|
|
||||||
- Contact name editing
|
|
||||||
### Changed
|
|
||||||
- Don't show actions on front page if not registered.
|
|
||||||
### Removed
|
|
||||||
- Home page Notiwind test buttons
|
|
||||||
|
|
||||||
|
|
||||||
## [0.1.2] - 2023.11.01 - 7f6c93802911a030a89fe3706e18b5c17151e5bb
|
|
||||||
### Added
|
|
||||||
- Basics: create ID, record a give, declare a project, search, and get notifications.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Contributing
|
|
||||||
|
|
||||||
Welcome! We are happy to have your help with this project.
|
|
||||||
|
|
||||||
We expect contributions to include automated tests and pass linting. Run the `test-all` task.
|
|
||||||
Note that some previous features don't have tests and adding more will make you friends quick.
|
|
||||||
|
|
||||||
Note that all contributions will be under our [license, modeled after SQLite](https://github.com/trentlarson/endorser-ch/blob/master/LICENSE).
|
|
||||||
|
|
||||||
If you want to see a code of conduct, we're probably not the people you want to hang with.
|
|
||||||
Basically, we'll work together as long as we both enjoy it, and we'll stop when that stops.
|
|
||||||
221
README.md
@@ -1,223 +1,24 @@
|
|||||||
# TimeSafari.app - Crowd-Funder for Time - PWA
|
# kickstart-for-time-pwa
|
||||||
|
|
||||||
[Time Safari](https://timesafari.org/) allows people to ease into collaboration: start with expressions of gratitude
|
|
||||||
and expand to crowd-fund with time & money, then record and see the impact of contributions.
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
See [project.task.yaml](project.task.yaml) for current priorities.
|
|
||||||
(Numbers at the beginning of lines are estimated hours. See [taskyaml.org](https://taskyaml.org/) for details.)
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh`
|
|
||||||
|
|
||||||
|
## Project setup
|
||||||
```
|
```
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compile and hot-reloads for development
|
### Compiles and hot-reloads for development
|
||||||
```
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build the test & production app
|
|
||||||
```
|
```
|
||||||
npm run serve
|
npm run serve
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lint and fix files
|
### Compiles and minifies for production
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lints and fixes files
|
||||||
```
|
```
|
||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compile and minify for test & production
|
### Customize configuration
|
||||||
|
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||||
* 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).
|
|
||||||
|
|
||||||
* Record what version is currently on production.
|
|
||||||
|
|
||||||
* Run the correct build:
|
|
||||||
|
|
||||||
* Staging
|
|
||||||
```
|
|
||||||
# (Let's replace this with a .env.development or .env.staging file.)
|
|
||||||
# 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.
|
|
||||||
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HNTZYJJXTGT0EZS3VEJGX7AK VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_PASSKEYS_ENABLED=yep npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
* Production
|
|
||||||
```
|
|
||||||
# This picks up values from .env.production
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
* Get on the server and back up the time-safari/dist folder.
|
|
||||||
|
|
||||||
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
|
|
||||||
|
|
||||||
* Commit changes. 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.
|
|
||||||
|
|
||||||
* [Tag with the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
### Automated
|
|
||||||
|
|
||||||
Use the locally running Endorser server:
|
|
||||||
|
|
||||||
* Clone and set up https://github.com/trentlarson/endorser-ch and run the following in that directory:
|
|
||||||
```
|
|
||||||
test/test.sh
|
|
||||||
NODE_ENV=test-local npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
* Now run the local tests:
|
|
||||||
```
|
|
||||||
npm run test-all
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that a test will sometimes fail and rerunning may succeed (and repeat if a different test fails).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
It's possible to use the global test Endorser (ledger) server (but currently the tests don't all succeed):
|
|
||||||
`npx playwright test`
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
It's possible to run with a minimal set of data: the following starts with the bare minimum of test data (but currently the tests don't all succeed):
|
|
||||||
```
|
|
||||||
rm ../endorser-ch-test-local.sqlite3
|
|
||||||
NODE_ENV=test-local npm run flyway migrate
|
|
||||||
NODE_ENV=test-local npm run test test/controller0
|
|
||||||
NODE_ENV=test-local npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### Register new user on test server
|
|
||||||
|
|
||||||
On the test server, User #0 has rights to register others, so you can start
|
|
||||||
playing by importing that user and registering others. Import the keys for the test User
|
|
||||||
`did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F` by importing this seed phrase:
|
|
||||||
`rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
|
|
||||||
(Other test users are found [here](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js).)
|
|
||||||
|
|
||||||
### Create multiple identifiers
|
|
||||||
|
|
||||||
Under the "Your Identity" screen, click "Advanced", click "Switch Identity / No Identity", then "Add Another Identity...".
|
|
||||||
|
|
||||||
### Create keys with alternate tools
|
|
||||||
|
|
||||||
[This page](openssl_signing_console.rst) is a tool to create a JWT from a locally-generated keypair.
|
|
||||||
|
|
||||||
### Web-push
|
|
||||||
|
|
||||||
For your own web-push tests, change the push server URL in Advanced settings on the account page, and install Time Safari & push server on the same domain.
|
|
||||||
|
|
||||||
### Icons
|
|
||||||
|
|
||||||
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name.
|
|
||||||
|
|
||||||
### Manual walk-through test
|
|
||||||
|
|
||||||
- Backup seed & data & get a CSV dump from Endorser Mobile.
|
|
||||||
- If there were any DB changes, check that you're on the old version and reload the page and ensure you can still act and haven't lost data (ie. contacts, identities).
|
|
||||||
- Use a mobile user as well as a desktop user.
|
|
||||||
- Check that the version is updated.
|
|
||||||
- Clear the browser data & add identity & import Time Safari contacts and then CSV contacts.
|
|
||||||
- Make sure that it's using the test API (under Identity in 'Advanced').
|
|
||||||
- Clear the browser data again. (See "Reset" below.)
|
|
||||||
- Go to the account page before visiting the home page to see that there is no ID.
|
|
||||||
- On the home page:
|
|
||||||
- Check that it generated an ID.
|
|
||||||
- Check the feed without names.
|
|
||||||
- Copy the contact URL.
|
|
||||||
- On each page, verify the messaging, and that they cannot take action.
|
|
||||||
- On the discovery page, check that they can see projects, and set a search area to see projects nearby.
|
|
||||||
- On the contacts page, check that they can add a contact even without their own ID.
|
|
||||||
- Install the PWA.
|
|
||||||
- As User 0 in another browser on the test API, add a give & a project.
|
|
||||||
- Note that some combinations of desktop with mobile emulation stretch the image.
|
|
||||||
- Import User 0 with seed: `rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage`
|
|
||||||
- Add new user as a contact (which allows them to see User 0).
|
|
||||||
- With the new user on the home page, see the feed that shows User 0 in network but without the name.
|
|
||||||
- As the new user, import contacts & identifiers.
|
|
||||||
- As the new user on the contacts page, add User 0 as a contact.
|
|
||||||
- On the home page, see the feed that shows User 0 with a name.
|
|
||||||
- Switch back to the generated identifier.
|
|
||||||
- On the account page, check that they see messages on limits.
|
|
||||||
- As User 0, register the ID.
|
|
||||||
- As the new user on the home page, check that they can now record a gift, and record an offer & delivery.
|
|
||||||
- On the contacts page, check that they cannot register someone else yet.
|
|
||||||
- Walk through the functions on each page.
|
|
||||||
- Set and run notifications.
|
|
||||||
- Export & import, both seed and contacts & settings.
|
|
||||||
- Choose location on the search map.
|
|
||||||
- Offer, deliver a give, and confirm. Create a third user and test connections.
|
|
||||||
- On mobile, share an image with the app.
|
|
||||||
- Switch to "no identifier" to see that things look OK without any ID.
|
|
||||||
|
|
||||||
### Clear/Reset data & restart
|
|
||||||
|
|
||||||
* Clear cache for site. (In Chrome, go to `chrome://settings/cookies` and "all site data and permissions"; in Firefox, go to `about:preferences` and search for "cache" then "Manage Data", and also manually remove the IndexedDB data if the DBs still show.)
|
|
||||||
* Clear notification permission. (In Chrome, go to `chrome://settings/content/notifications`; in Firefox, go to `about:preferences` and search for "notifications".)
|
|
||||||
* Unregister service worker. (In Chrome, go to `chrome://serviceworker-internals`; in Firefox, go to `about:serviceworkers`.)
|
|
||||||
* Clear Cache Storage manually, possibly deleting the DB. (In Chrome, in dev tools under Application; in Firefox, in dev tools under Storage.)
|
|
||||||
|
|
||||||
(If you find more, add them to the HelpNotificationsView.vue file.)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
* A problem with `GET http://localhost:8080/web-push/vapid` means the py-push-server is not running
|
|
||||||
(and notifications won't work for a local app without special routing from the browser's web push service provider, anyway).
|
|
||||||
|
|
||||||
* Red errors everywhere with a console message like this:
|
|
||||||
`Error: An ID is chosen but there are no keys for it so it cannot be used to talk with the service`
|
|
||||||
... has happened on account switching when the current account was erased (or maybe replaced -- once I had a duplicate and I don't know how).
|
|
||||||
|
|
||||||
* The error `DEXIE ENCRYPT ADDON: Could not decrypt message!` or
|
|
||||||
`Encryption key has changed` means that the encryption key is wrong,
|
|
||||||
sometimes seen after clearing storage for testing; you can make it happen by clearing localStorage.
|
|
||||||
Maybe only part of the storage was cleared out. Unless you got a copy of that password, you'll
|
|
||||||
have to erase storage and reload the identifier.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Other
|
|
||||||
|
|
||||||
### Reference Material
|
|
||||||
|
|
||||||
* Notifications can be type of `toast` (self-dismiss), `info`, `success`, `warning`, and `danger`.
|
|
||||||
They are done via [notiwind](https://www.npmjs.com/package/notiwind) and set up in App.vue.
|
|
||||||
|
|
||||||
* [Customize Vue configuration](https://cli.vuejs.org/config/).
|
|
||||||
|
|
||||||
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
|
||||||
|
|
||||||
|
|
||||||
### Kudos
|
|
||||||
|
|
||||||
Gifts make the world go 'round!
|
|
||||||
|
|
||||||
* [WebStorm by JetBrains](https://www.jetbrains.com/webstorm/) for the free open-source license
|
|
||||||
* [Máximo Fernández](https://medium.com/@maxfarenas) for the 3D [code](https://github.com/maxfer03/vue-three-ns) and [explanatory post](https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80)
|
|
||||||
* [Many tools & libraries](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/src/branch/master/package.json#L10) such as Nodejs.org, IntelliJ Idea, Veramo.io, Vuejs.org, threejs.org
|
|
||||||
* [Bush 3D model](https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439)
|
|
||||||
* [Forest floor image](https://www.goodfreephotos.com/albums/textures/leafy-autumn-forest-floor.jpg)
|
|
||||||
* Time Safari logo assisted by [DALL-E in ChatGPT](https://chat.openai.com/g/g-2fkFE8rbu-dall-e)
|
|
||||||
* [DiceBear](https://www.dicebear.com/licenses/) and [Avataaars](https://www.dicebear.com/styles/avataaars/#details) for human-looking identicons
|
|
||||||
* Some gratitude prompts thanks to [Develop Good Habits](https://www.developgoodhabits.com/gratitude-journal-prompts/)
|
|
||||||
|
|||||||
3
babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
presets: ["@vue/cli-plugin-babel/preset"],
|
||||||
|
};
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# TimeSafari Docs
|
|
||||||
|
|
||||||
## Generating PDF from Markdown on OSx
|
|
||||||
|
|
||||||
This uses Pandoc and BasicTex (LaTeX) Installed through Homebrew.
|
|
||||||
|
|
||||||
### Set Up
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install pandoc
|
|
||||||
|
|
||||||
brew install basictex
|
|
||||||
|
|
||||||
# Setting up LaTex packages
|
|
||||||
|
|
||||||
# First update tlmgr
|
|
||||||
sudo tlmgr update --self
|
|
||||||
|
|
||||||
# Then install LaTex packages
|
|
||||||
sudo tlmgr install bbding
|
|
||||||
sudo tlmgr install enumitem
|
|
||||||
sudo tlmgr install environ
|
|
||||||
sudo tlmgr install fancyhdr
|
|
||||||
sudo tlmgr install framed
|
|
||||||
sudo tlmgr install import
|
|
||||||
sudo tlmgr install lastpage # Enables Page X of Y
|
|
||||||
sudo tlmgr install mdframed
|
|
||||||
sudo tlmgr install multirow
|
|
||||||
sudo tlmgr install needspace
|
|
||||||
sudo tlmgr install ntheorem
|
|
||||||
sudo tlmgr install tabu
|
|
||||||
sudo tlmgr install tcolorbox
|
|
||||||
sudo tlmgr install textpos
|
|
||||||
sudo tlmgr install titlesec
|
|
||||||
sudo tlmgr install titling # Required for the fancy headers used
|
|
||||||
sudo tlmgr install threeparttable
|
|
||||||
sudo tlmgr install trimspaces
|
|
||||||
sudo tlmgr install tocloft # Required for \tableofcontents generation
|
|
||||||
sudo tlmgr install varwidth
|
|
||||||
sudo tlmgr install wrapfig
|
|
||||||
|
|
||||||
# Install fonts
|
|
||||||
sudo tlmgr install cmbright
|
|
||||||
sudo tlmgr install collection-fontsrecommended # And set up fonts
|
|
||||||
sudo tlmgr install fira
|
|
||||||
sudo tlmgr install fontaxes
|
|
||||||
sudo tlmgr install libertine # The main font the doc uses
|
|
||||||
sudo tlmgr install opensans
|
|
||||||
sudo tlmgr install sourceserifpro
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#### References
|
|
||||||
|
|
||||||
The following guide was adapted to this project except that we install with Brew and have a few more packages.
|
|
||||||
|
|
||||||
Guide: https://daniel.feldroy.com/posts/setting-up-latex-on-mac-os-x
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
Use the `pandoc` command to generate a PDF.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pandoc usage-guide.md -o usage-guide.pdf
|
|
||||||
```
|
|
||||||
|
|
||||||
And you can open the PDF with the `open` command.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
open usage-guide.pdf
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use this one-liner
|
|
||||||
```bash
|
|
||||||
pandoc usage-guide.md -o usage-guide.pdf && open usage-guide.pdf
|
|
||||||
```
|
|
||||||
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 463 KiB |
@@ -1,316 +0,0 @@
|
|||||||
---
|
|
||||||
geometry: margin=1in
|
|
||||||
header-includes:
|
|
||||||
- \usepackage{graphicx}
|
|
||||||
- \usepackage{titling}
|
|
||||||
- \usepackage{fancyhdr}
|
|
||||||
- \usepackage{lastpage}
|
|
||||||
- \pagestyle{fancy}
|
|
||||||
- \fancyhead[L]{Time Safari Usage Guide}
|
|
||||||
- \fancyhead[C]{Page \thepage\ of \pageref{LastPage}}
|
|
||||||
- \fancyhead[R]{}
|
|
||||||
- \fancyfoot[L]{}
|
|
||||||
- \fancyfoot[C]{}
|
|
||||||
- \fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}
|
|
||||||
- \usepackage{tocloft}
|
|
||||||
- \usepackage{libertine}
|
|
||||||
- \renewcommand{\familydefault}{\sfdefault}
|
|
||||||
- \fancypagestyle{tocstyle}{
|
|
||||||
\fancyhead[L]{Time Safari Usage Guide}
|
|
||||||
\fancyhead[C]{Page \thepage\ of \pageref{LastPage}}
|
|
||||||
\fancyhead[R]{}
|
|
||||||
\fancyfoot[L]{}
|
|
||||||
\fancyfoot[C]{}
|
|
||||||
\fancyfoot[R]{\includegraphics[width=1cm]{images/timesafari-logo-binoculars.png}}}
|
|
||||||
---
|
|
||||||
|
|
||||||
\begin{titlepage}
|
|
||||||
\centering
|
|
||||||
\vspace*{\fill}
|
|
||||||
{\huge\textbf{TimeSafari Usage guide}}
|
|
||||||
|
|
||||||
\vspace{1cm}
|
|
||||||
{\Large Signing up users, adding contacts, and adding gifts.}
|
|
||||||
|
|
||||||
\vspace{1cm}
|
|
||||||
\includegraphics[width=0.5\textwidth]{images/timesafari-logo.png}
|
|
||||||
\vspace*{\fill}
|
|
||||||
|
|
||||||
\vspace{1cm}
|
|
||||||
{\Large Trent Larson, Kent Bull}
|
|
||||||
|
|
||||||
\vspace{0.5cm}
|
|
||||||
{\large 2024-06-25}
|
|
||||||
|
|
||||||
\end{titlepage}
|
|
||||||
|
|
||||||
\clearpage
|
|
||||||
|
|
||||||
\begin{center}
|
|
||||||
\includegraphics[width=2cm]{images/timesafari-logo-binoculars.png}
|
|
||||||
\end{center}
|
|
||||||
\tableofcontents
|
|
||||||
|
|
||||||
\clearpage
|
|
||||||
|
|
||||||
|
|
||||||
# Purpose of Document
|
|
||||||
|
|
||||||
Both end-users and development team members need to know how to use TimeSafari.
|
|
||||||
This document serves to show how to use every feature of the TimeSafari platform.
|
|
||||||
|
|
||||||
Sections of this document are geared specifically for software developers and quality assurance
|
|
||||||
team members.
|
|
||||||
|
|
||||||
Companion videos will also describe end-to-end workflows for the end-user.
|
|
||||||
|
|
||||||
# TimeSafari
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
\pagebreak
|
|
||||||
|
|
||||||
# 1 - End Users
|
|
||||||
|
|
||||||
This section covers application usage for people who will use TimeSafari as intended. It is a
|
|
||||||
simplified guide illustrating how to gain value from using TimeSafari.
|
|
||||||
|
|
||||||
\pagebreak
|
|
||||||
|
|
||||||
# 2 - Software Developers
|
|
||||||
|
|
||||||
This section is tailored for software developers seeking to use the application during development,
|
|
||||||
quality assurance, and testing.
|
|
||||||
|
|
||||||
# Bootstrapping a local development environment
|
|
||||||
|
|
||||||
The first concern a software developer has when working on TimeSafari is to set up a local
|
|
||||||
development environment. This section will guide you through the process.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. Have the following installed on your local machine:
|
|
||||||
- Node.js and NPM
|
|
||||||
- A web browser. For this guide, we will use Google Chrome.
|
|
||||||
- Git
|
|
||||||
- A code editor
|
|
||||||
|
|
||||||
2. Create an API key on Infura. This is necessary for the Endorser API to connect to the Ethereum
|
|
||||||
blockchain.
|
|
||||||
- You can create an account on Infura [here](https://infura.io/).\
|
|
||||||
Click "CREATE NEW API KEY" and label the key. Then click "API Keys" in the top menu bar to
|
|
||||||
be taken back to the list of keys.
|
|
||||||
|
|
||||||
Click "VIEW STATS" on the key you want to use.
|
|
||||||
|
|
||||||
{ width=550px }
|
|
||||||
|
|
||||||
- Go to the key detail page. Then click "MANAGE API KEY".
|
|
||||||
|
|
||||||
{ width=550px }
|
|
||||||
|
|
||||||
- Click the copy and paste button next to the string of alphanumeric characters.\
|
|
||||||
This is your API, also known as your project ID.
|
|
||||||
|
|
||||||
{width=550px }
|
|
||||||
|
|
||||||
- Save this for later during the Endorser API setup. This will go in your `INFURA_PROJECT_ID`
|
|
||||||
environment variable.
|
|
||||||
|
|
||||||
|
|
||||||
## Setup steps
|
|
||||||
|
|
||||||
### 1. Clone the following repositories from their respective Git hosts:
|
|
||||||
- [TimeSafari Frontend](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa)\
|
|
||||||
This is a Progressive Web App (PWA) built with VueJS and TypeScript.
|
|
||||||
Note that the clone command here is different from the one you would use for GitHub.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone git clone \
|
|
||||||
ssh://git@gitea.anomalistdesign.com:222/trent_larson/crowd-funder-for-time-pwa.git
|
|
||||||
```
|
|
||||||
|
|
||||||
- [TimeSafari Backend - Endorser API](https://github.com/trentlarson/endorser-ch)\
|
|
||||||
This is a NodeJS service providing the backend for TimeSafari.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone git@github.com:trentlarson/endorser-ch.git
|
|
||||||
```
|
|
||||||
|
|
||||||
\pagebreak
|
|
||||||
|
|
||||||
### 2. Database creation
|
|
||||||
|
|
||||||
#### Alternative 1 - use test data
|
|
||||||
|
|
||||||
To generate a development database and perform user setup you can run a local test with instructions
|
|
||||||
below to generate sample data. Then copy the test database, rename it to `-dev` as below:\
|
|
||||||
`cp ../endorser-ch-test-local.sqlite3 ../endorser-ch-dev.sqlite3` \
|
|
||||||
and rerun `npm run dev` to give yourself user #0 and others from the ETHR_CRED_DATA in [the endorser.ch test util file](https://github.com/trentlarson/endorser-ch/blob/master/test/util.js#L90)
|
|
||||||
|
|
||||||
#### Alternative 2 - boostrap single seed user
|
|
||||||
|
|
||||||
In this method you will end up with two accounts in the database, one for the first boostrap user,
|
|
||||||
and the second as the primary user you will use during testing. The first user will invite the
|
|
||||||
second user to the app.
|
|
||||||
|
|
||||||
1. Install dependencies and environment variables.\
|
|
||||||
In endorser-ch install dependencies and set up environment variables to allow starting it up in
|
|
||||||
development mode.
|
|
||||||
```bash
|
|
||||||
cd endorser-ch
|
|
||||||
npm clean install # or npm ci
|
|
||||||
cp .env.local .env
|
|
||||||
```
|
|
||||||
Edit the .env file's INFURA_PROJECT_ID with the value you saved earlier in the
|
|
||||||
prerequisites.\
|
|
||||||
Then create the SQLite database by running `npm run flyway migrate` with environment variables
|
|
||||||
set correctly to select the default SQLite development user as follows.
|
|
||||||
```bash
|
|
||||||
export NODE_ENV=dev
|
|
||||||
export DBUSER=sa
|
|
||||||
export DBPASS=sasa
|
|
||||||
npm run flyway migrate
|
|
||||||
```
|
|
||||||
The first run of flyway migrate may take some time to complete because the entire Flyway
|
|
||||||
distribution must be downloaded prior to executing migrations.
|
|
||||||
|
|
||||||
Successful output looks similar to the following:
|
|
||||||
|
|
||||||
```
|
|
||||||
Database: jdbc:sqlite:../endorser-ch-dev.sqlite3 (SQLite 3.41)
|
|
||||||
Schema history table "main"."flyway_schema_history" does not exist yet
|
|
||||||
Successfully validated 10 migrations (execution time 00:00.034s)
|
|
||||||
Creating Schema History table "main"."flyway_schema_history" ...
|
|
||||||
Current version of schema "main": << Empty Schema >>
|
|
||||||
Migrating schema "main" to version "1 - initial-anew"
|
|
||||||
Migrating schema "main" to version "2 - registration"
|
|
||||||
Migrating schema "main" to version "3 - plan project"
|
|
||||||
Migrating schema "main" to version "4 - offer gave"
|
|
||||||
Migrating schema "main" to version "5 - more confirmations"
|
|
||||||
Migrating schema "main" to version "6 - providers urls"
|
|
||||||
Migrating schema "main" to version "7 - hash nonce"
|
|
||||||
Migrating schema "main" to version "8 - project location"
|
|
||||||
Migrating schema "main" to version "9 - plan links"
|
|
||||||
Migrating schema "main" to version "10 - gift or trade"
|
|
||||||
Successfully applied 10 migrations to schema "main", now at version v10 (execution time 00:00.043s)
|
|
||||||
A Flyway report has been generated here: /Users/kbull/code/timesafari/endorser-ch/report.html
|
|
||||||
```
|
|
||||||
|
|
||||||
\pagebreak
|
|
||||||
|
|
||||||
2. Generate the first user in TimeSafari PWA and bootstrap that user in Endorser's database.\
|
|
||||||
As TimeSafari is an invite-only platform the first user must be manually bootstrapped since
|
|
||||||
no other users exist to be able to invite the first user. This first user must be added manually
|
|
||||||
to the SQLite database used by Endorser. In this setup you generate the first user from the PWA.
|
|
||||||
|
|
||||||
This user is automatically generated on first usage of the TimeSafari PWA. Bootstrapping that
|
|
||||||
user is required so that this first user can register other users.
|
|
||||||
- Change directories into `crowd-funder-for-time-pwa`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ..
|
|
||||||
cd crowd-funder-for-time-pwa
|
|
||||||
```
|
|
||||||
|
|
||||||
- Ensure the `.env.development` file exists and has the following values:
|
|
||||||
|
|
||||||
```env
|
|
||||||
VITE_DEFAULT_ENDORSER_API_SERVER=http://127.0.0.1:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
- Install dependencies and run in dev mode. For now don't worry about configuring the app. All we
|
|
||||||
need is to generate the first root user and this happens automatically on app startup.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm clean install # or npm ci
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- Open the app in a browser and go to the developer tools. It is recommended to use a completely
|
|
||||||
separate browser profile so you do not clear out your existing user account. We will be
|
|
||||||
completely resetting the PWA app state prior to generating the first user.
|
|
||||||
|
|
||||||
In the Developer Tools go to the Application tab.
|
|
||||||
|
|
||||||
{width=350px}
|
|
||||||
|
|
||||||
Click the "Clear site data" button and then refresh the page.
|
|
||||||
|
|
||||||
- Click the account button in the bottom right corner of the page.
|
|
||||||
|
|
||||||
{width=150px}
|
|
||||||
|
|
||||||
- This will take you to the account page titled "Your Identity" on which you can see your DID,
|
|
||||||
a `did:ethr` DID in this case.
|
|
||||||
|
|
||||||
{width=350px}
|
|
||||||
|
|
||||||
- Copy the DID by selecting it and copying it to the clipboard or by clicking the copy and paste
|
|
||||||
button as shown in the image.
|
|
||||||
|
|
||||||
{width=200px}
|
|
||||||
|
|
||||||
In our case this DID is:\
|
|
||||||
`did:ethr:0xe4B783c74c8B0e229524e44d0cD898D272E02CD6`
|
|
||||||
|
|
||||||
- Add that DID to the following echoed SQL statement where it says `YOUR_DID`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "INSERT INTO registration (did, maxClaims, maxRegs, epoch)
|
|
||||||
VALUES ('YOUR_DID', 100, 10000, 1719348718092);"
|
|
||||||
| sqlite3 ./endorser-ch-dev.sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
and run this command in the parent directory just above the `endorser-ch` directory.
|
|
||||||
|
|
||||||
It needs to be the parent directory of your `endorser-ch` repository because when
|
|
||||||
`endorser-ch` creates the SQLite database it depends on it creates it in the parent directory
|
|
||||||
of `endorser-ch`.
|
|
||||||
|
|
||||||
- You can verify with an SQL browser tool that your record has been added to the `registration`
|
|
||||||
table.
|
|
||||||
|
|
||||||
{width=350px}
|
|
||||||
|
|
||||||
3. Then start the Endorser service in development mode with the following commands.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ./endorser-ch
|
|
||||||
export NODE_ENV=dev
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts the Endorser service on port 3000.
|
|
||||||
4. Create the second user by opening up a separate browser profile or incognito session, opening the
|
|
||||||
TimeSafari PWA at `http://localhost:8080`. You will see the yellow banner stating "Someone must
|
|
||||||
register you before you can give or offer."
|
|
||||||
|
|
||||||
{width=350px}
|
|
||||||
|
|
||||||
- If you want to ensure you have a fresh user account then open the developer tools, clear the
|
|
||||||
Application data as before, and then refresh the page. This will generate a new user in the
|
|
||||||
browser's IndexedDB database.
|
|
||||||
5. Go to the second users' account page to copy the DID.
|
|
||||||
|
|
||||||
{width=350px}
|
|
||||||
|
|
||||||
6. Copy the DID and put it in the text bar on the "Your Contacts" page for the first account
|
|
||||||
|
|
||||||
{width=350px}
|
|
||||||
|
|
||||||
7. Click the "+" plus icon to add the user.
|
|
||||||
|
|
||||||
{width=350px}
|
|
||||||
|
|
||||||
8. Then click the register button to register the second user.
|
|
||||||
|
|
||||||
{width=350px}
|
|
||||||
|
|
||||||
9. Click "YES" on the dialog that shows up.
|
|
||||||
|
|
||||||
{width=350px}
|
|
||||||
|
|
||||||
After this a notification will pop up indicating whether registration was successful or not.
|
|
||||||
|
|
||||||
10. You have finished the initial set up of users.
|
|
||||||
17
index.html
@@ -1,17 +0,0 @@
|
|||||||
<!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>
|
|
||||||
</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>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
JWT Creation & Verification
|
|
||||||
|
|
||||||
To run this in a script, see ./openssl_signing_console.sh
|
|
||||||
|
|
||||||
Prerequisites: openssl, jq
|
|
||||||
|
|
||||||
You can create a JWT using a library or by encoding the header and payload base64Url and signing it with a secret using
|
|
||||||
a ES256K algorithm. Here is an example of how you can create a JWT using the jq and openssl command line utilities:
|
|
||||||
|
|
||||||
Here is an example of how you can use openssl to sign a JWT with the ES256K algorithm:
|
|
||||||
|
|
||||||
Generate an ECDSA key pair using the secp256k1 curve:
|
|
||||||
|
|
||||||
openssl ecparam -name secp256k1 -genkey -noout -out private.pem
|
|
||||||
openssl ec -in private.pem -pubout -out public.pem
|
|
||||||
|
|
||||||
First, create a header object as a JSON object containing the alg (algorithm) and typ (type) fields. For example:
|
|
||||||
|
|
||||||
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
|
|
||||||
|
|
||||||
Next, create a payload object as a JSON object containing the claims you want to include in the JWT.
|
|
||||||
For example schema.org :
|
|
||||||
|
|
||||||
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
|
|
||||||
|
|
||||||
Encode the header and payload objects as base64Url strings. You can use the jq command line utility to do this:
|
|
||||||
|
|
||||||
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
|
||||||
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
|
||||||
|
|
||||||
Concatenate the encoded header, payload, and a secret to create the signing input:
|
|
||||||
|
|
||||||
signing_input="$header_b64.$payload_b64"
|
|
||||||
|
|
||||||
Create the signature by signing the signing input with a ES256K algorithm and your secret.
|
|
||||||
You can use the openssl command line utility to do this:
|
|
||||||
|
|
||||||
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem)
|
|
||||||
|
|
||||||
Finally, encode the signature as a base64Url string and concatenate it with the signing input to create the JWT:
|
|
||||||
|
|
||||||
signature_b64=$(echo -n "$signature" | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
|
||||||
jwt="$signing_input.$signature_b64"
|
|
||||||
|
|
||||||
This JWT can then be passed in the Authorization header of a HTTP request as a bearer token, for example:
|
|
||||||
|
|
||||||
Authorization: Bearer $jwt
|
|
||||||
|
|
||||||
To verify the JWT, you can use the openssl utility with the public key:
|
|
||||||
|
|
||||||
echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature")
|
|
||||||
|
|
||||||
This will verify the signature and output "Verified OK" if the signature is valid.
|
|
||||||
If the signature is not valid, it will give an error response and output "Verification failure".
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Generate a JWT, with signature verified using OpenSSL
|
|
||||||
#
|
|
||||||
# Prerequisites: openssl, jq
|
|
||||||
#
|
|
||||||
# Usage: source ./openssl_signing_console.sh
|
|
||||||
#
|
|
||||||
# For a more complete explanation, see ./openssl_signing_console.rst
|
|
||||||
|
|
||||||
|
|
||||||
# Generate a key and extract the public part
|
|
||||||
openssl ecparam -name secp256k1 -genkey -noout -out private.pem
|
|
||||||
openssl ec -in private.pem -pubout -out public.pem
|
|
||||||
|
|
||||||
# Use test data
|
|
||||||
header='{"alg":"ES256K", "issuer": "", "typ":"JWT"}'
|
|
||||||
payload='{"@context": "http://schema.org", "@type": "PlanAction", "identifier": "did:ethr:0xb86913f83A867b5Ef04902419614A6FF67466c12", "name": "Test", "description": "Me"}'
|
|
||||||
|
|
||||||
header_b64=$(echo -n "$header" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
|
||||||
payload_b64=$(echo -n "$payload" | jq -c -M . | tr -d '\n' | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
|
||||||
|
|
||||||
signing_input="$header_b64.$payload_b64"
|
|
||||||
|
|
||||||
signature=$(echo -n "$signing_input" | openssl dgst -sha256 -sign private.pem | openssl base64 -e)
|
|
||||||
|
|
||||||
echo -n "$signing_input" | openssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature" | openssl base64 -d)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Read binary signature and encode it to Base64 URL-Safe format
|
|
||||||
signature_b64=$(echo -n "$signature" | base64 | tr -d '=' | tr '+' '-' | tr '/' '_')
|
|
||||||
|
|
||||||
# Construct the JWT
|
|
||||||
jwt="$signing_input.$signature_b64"
|
|
||||||
|
|
||||||
echo Resulting JWT: $jwt
|
|
||||||
25226
package-lock.json
generated
125
package.json
@@ -1,101 +1,42 @@
|
|||||||
{
|
{
|
||||||
"name": "TimeSafari",
|
"name": "kickstart-for-time-pwa",
|
||||||
"version": "0.3.21-beta",
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"serve": "vue-cli-service serve",
|
||||||
"serve": "vite preview",
|
"build": "vue-cli-service build",
|
||||||
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build",
|
"lint": "vue-cli-service lint"
|
||||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
|
||||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
|
||||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
|
|
||||||
"test-local": "npx playwright test -c playwright.config-local.ts --trace on",
|
|
||||||
"test-all": "npm run build && npx playwright test -c playwright.config-local.ts --trace on"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dicebear/collection": "^5.4.1",
|
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||||
"@dicebear/core": "^5.4.1",
|
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||||
"@ethersproject/hdnode": "^5.7.0",
|
"@fortawesome/vue-fontawesome": "^3.0.2",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
"core-js": "^3.26.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
|
||||||
"@peculiar/asn1-ecc": "^2.3.8",
|
|
||||||
"@peculiar/asn1-schema": "^2.3.8",
|
|
||||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
|
||||||
"@simplewebauthn/browser": "^10.0.0",
|
|
||||||
"@simplewebauthn/server": "^10.0.0",
|
|
||||||
"@tweenjs/tween.js": "^21.1.1",
|
|
||||||
"@veramo/core": "^5.6.0",
|
|
||||||
"@veramo/credential-w3c": "^5.6.0",
|
|
||||||
"@veramo/data-store": "^5.6.0",
|
|
||||||
"@veramo/did-manager": "^5.6.0",
|
|
||||||
"@veramo/did-provider-ethr": "^5.6.0",
|
|
||||||
"@veramo/did-provider-peer": "^6.0.0",
|
|
||||||
"@veramo/did-resolver": "^5.6.0",
|
|
||||||
"@veramo/key-manager": "^5.6.0",
|
|
||||||
"@vueuse/core": "^10.9.0",
|
|
||||||
"@zxing/text-encoding": "^0.9.0",
|
|
||||||
"asn1-ber": "^1.2.2",
|
|
||||||
"axios": "^1.6.8",
|
|
||||||
"cbor-x": "^1.5.9",
|
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"dexie": "^3.2.7",
|
|
||||||
"dexie-export-import": "^4.1.1",
|
|
||||||
"did-jwt": "^7.4.7",
|
|
||||||
"ethereum-cryptography": "^2.1.3",
|
|
||||||
"ethereumjs-util": "^7.1.5",
|
|
||||||
"jdenticon": "^3.2.0",
|
|
||||||
"js-generate-password": "^0.1.9",
|
|
||||||
"js-yaml": "^4.1.0",
|
|
||||||
"localstorage-slim": "^2.7.0",
|
|
||||||
"lru-cache": "^10.2.0",
|
|
||||||
"luxon": "^3.4.4",
|
|
||||||
"merkletreejs": "^0.3.11",
|
|
||||||
"notiwind": "^2.0.2",
|
|
||||||
"papaparse": "^5.4.1",
|
|
||||||
"pina": "^0.20.2204228",
|
|
||||||
"pinia-plugin-persistedstate": "^3.2.1",
|
|
||||||
"qr-code-generator-vue3": "^1.4.21",
|
|
||||||
"ramda": "^0.29.1",
|
|
||||||
"readable-stream": "^4.5.2",
|
|
||||||
"reflect-metadata": "^0.1.14",
|
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"simple-vue-camera": "^1.1.3",
|
"vue": "^3.2.45",
|
||||||
"three": "^0.156.1",
|
"vue-class-component": "^8.0.0-0",
|
||||||
"ua-parser-js": "^1.0.37",
|
"vue-router": "^4.1.6",
|
||||||
"util": "^0.12.5",
|
"vuex": "^4.1.0"
|
||||||
"vue": "^3.4.21",
|
|
||||||
"vue-axios": "^3.5.2",
|
|
||||||
"vue-facing-decorator": "^3.0.4",
|
|
||||||
"vue-picture-cropper": "^0.7.0",
|
|
||||||
"vue-qrcode-reader": "^5.5.3",
|
|
||||||
"vue-router": "^4.3.0",
|
|
||||||
"web-did-resolver": "^2.0.27"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.45.2",
|
"@typescript-eslint/eslint-plugin": "^5.44.0",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@typescript-eslint/parser": "^5.44.0",
|
||||||
"@types/leaflet": "^1.9.8",
|
"@vue/cli-plugin-babel": "~5.0.8",
|
||||||
"@types/luxon": "^3.4.2",
|
"@vue/cli-plugin-eslint": "~5.0.8",
|
||||||
"@types/node": "^20.14.11",
|
"@vue/cli-plugin-pwa": "~5.0.8",
|
||||||
"@types/ramda": "^0.29.11",
|
"@vue/cli-plugin-router": "~5.0.8",
|
||||||
"@types/three": "^0.155.1",
|
"@vue/cli-plugin-typescript": "~5.0.8",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@vue/cli-plugin-vuex": "~5.0.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@vue/cli-service": "~5.0.8",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
"@vue/eslint-config-typescript": "^11.0.2",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"autoprefixer": "^10.4.13",
|
||||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
"eslint": "^8.28.0",
|
||||||
"@vue/eslint-config-typescript": "^11.0.3",
|
"eslint-config-prettier": "^8.5.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint": "^8.57.0",
|
"eslint-plugin-vue": "^9.8.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"postcss": "^8.4.19",
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
"prettier": "^2.8.0",
|
||||||
"eslint-plugin-vue": "^9.23.0",
|
"tailwindcss": "^3.2.4",
|
||||||
"leaflet": "^1.9.4",
|
"typescript": "~4.9.3"
|
||||||
"postcss": "^8.4.38",
|
|
||||||
"prettier": "^3.2.5",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"typescript": "~5.2.2",
|
|
||||||
"vite": "^5.2.0",
|
|
||||||
"vite-plugin-pwa": "^0.19.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read environment variables from file.
|
|
||||||
* https://github.com/motdotla/dotenv
|
|
||||||
*/
|
|
||||||
// import dotenv from 'dotenv';
|
|
||||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './test-playwright',
|
|
||||||
/* Run tests in files in parallel */
|
|
||||||
fullyParallel: true,
|
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
/* Retry on CI only */
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
/* Opt out of parallel tests on CI. */
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
|
||||||
reporter: 'html',
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
|
||||||
use: {
|
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
|
||||||
baseURL: 'http://localhost:8080',
|
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: {
|
|
||||||
...devices['Desktop Chrome'],
|
|
||||||
permissions: ["clipboard-read"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'firefox',
|
|
||||||
use: { ...devices['Desktop Firefox'] },
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'webkit',
|
|
||||||
use: { ...devices['Desktop Safari'] },
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
|
||||||
{
|
|
||||||
name: 'Mobile Chrome',
|
|
||||||
use: { ...devices['Pixel 5'] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mobile Safari',
|
|
||||||
use: { ...devices['iPhone 12'] },
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Test against branded browsers. */
|
|
||||||
// {
|
|
||||||
// name: 'Microsoft Edge',
|
|
||||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
name: 'Google Chrome',
|
|
||||||
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
/* Configure global timeout; default is 30000 milliseconds */
|
|
||||||
// the image upload will often not succeed at 5 seconds
|
|
||||||
timeout: 20000,
|
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
|
||||||
/**
|
|
||||||
* This could be an array of servers, meaning we could start the Endorser server as well:
|
|
||||||
* {
|
|
||||||
* command: "cd ../endorser-ch; NODE_ENV=test-local npm run dev",
|
|
||||||
* url: 'http://localhost:3000',
|
|
||||||
* reuseExistingServer: !process.env.CI,
|
|
||||||
* },
|
|
||||||
*
|
|
||||||
* But if we do then the testInfo.config.webServer is null and the API-setting test 00 fails.
|
|
||||||
* It is worth considering a change such that Time Safari's default Endorser API server is NOT set
|
|
||||||
* in the user's settings so that it can be blanked out and the default is used.
|
|
||||||
*/
|
|
||||||
webServer: {
|
|
||||||
command:
|
|
||||||
"VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
|
|
||||||
url: "http://localhost:8080",
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read environment variables from file.
|
|
||||||
* https://github.com/motdotla/dotenv
|
|
||||||
*/
|
|
||||||
// import dotenv from 'dotenv';
|
|
||||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: './test-playwright',
|
|
||||||
/* Run tests in files in parallel */
|
|
||||||
fullyParallel: true,
|
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
/* Retry on CI only */
|
|
||||||
retries: process.env.CI ? 2 : 0,
|
|
||||||
/* Opt out of parallel tests on CI. */
|
|
||||||
workers: process.env.CI ? 1 : undefined,
|
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
|
||||||
reporter: 'html',
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
|
||||||
use: {
|
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
|
||||||
baseURL: 'https://test.timesafari.app',
|
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
|
||||||
trace: 'on-first-retry',
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: 'chromium',
|
|
||||||
use: {
|
|
||||||
...devices['Desktop Chrome'],
|
|
||||||
permissions: ["clipboard-read"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'firefox',
|
|
||||||
use: { ...devices['Desktop Firefox'] },
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name: 'webkit',
|
|
||||||
use: { ...devices['Desktop Safari'] },
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Test against mobile viewports. */
|
|
||||||
{
|
|
||||||
name: 'Mobile Chrome',
|
|
||||||
use: { ...devices['Pixel 5'] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mobile Safari',
|
|
||||||
use: { ...devices['iPhone 12'] },
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Test against branded browsers. */
|
|
||||||
// {
|
|
||||||
// name: 'Microsoft Edge',
|
|
||||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: 'Google Chrome',
|
|
||||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
|
||||||
// },
|
|
||||||
],
|
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
|
||||||
// webServer: {
|
|
||||||
// command:
|
|
||||||
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
|
|
||||||
// url: "http://localhost:8080",
|
|
||||||
// reuseExistingServer: !process.env.CI,
|
|
||||||
// },
|
|
||||||
});
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
|
|
||||||
tasks :
|
|
||||||
|
|
||||||
- This list has moved - see https://sharing.clickup.com/9014278710/l/h/6-901402735154-1/685f1be3f9b150d
|
|
||||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 799 B |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 215 B |
|
Before Width: | Height: | Size: 705 KiB |
17
public/index.html
Normal 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="<%= BASE_URL %>favicon.ico">
|
||||||
|
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
|
</noscript>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- built files will be auto injected -->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
Model Information:
|
|
||||||
* title: Lupine Plant
|
|
||||||
* source: https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439
|
|
||||||
* author: rufusrockwell (https://sketchfab.com/rufusrockwell)
|
|
||||||
|
|
||||||
Model License:
|
|
||||||
* license type: CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)
|
|
||||||
* requirements: Author must be credited. Commercial use is allowed.
|
|
||||||
|
|
||||||
If you use this 3D model in your project be sure to copy paste this credit wherever you share it:
|
|
||||||
This work is based on "Lupine Plant" (https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439) by rufusrockwell (https://sketchfab.com/rufusrockwell) licensed under CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
{
|
|
||||||
"accessors": [
|
|
||||||
{
|
|
||||||
"bufferView": 2,
|
|
||||||
"componentType": 5126,
|
|
||||||
"count": 2759,
|
|
||||||
"max": [
|
|
||||||
41.3074951171875,
|
|
||||||
40.37548828125,
|
|
||||||
87.85917663574219
|
|
||||||
],
|
|
||||||
"min": [
|
|
||||||
-35.245540618896484,
|
|
||||||
-36.895416259765625,
|
|
||||||
-0.9094290137290955
|
|
||||||
],
|
|
||||||
"type": "VEC3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bufferView": 2,
|
|
||||||
"byteOffset": 33108,
|
|
||||||
"componentType": 5126,
|
|
||||||
"count": 2759,
|
|
||||||
"max": [
|
|
||||||
0.9999382495880127,
|
|
||||||
0.9986748695373535,
|
|
||||||
0.9985831379890442
|
|
||||||
],
|
|
||||||
"min": [
|
|
||||||
-0.9998949766159058,
|
|
||||||
-0.9975876212120056,
|
|
||||||
-0.411094069480896
|
|
||||||
],
|
|
||||||
"type": "VEC3"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bufferView": 3,
|
|
||||||
"componentType": 5126,
|
|
||||||
"count": 2759,
|
|
||||||
"max": [
|
|
||||||
0.9987699389457703,
|
|
||||||
0.9998998045921326,
|
|
||||||
0.9577858448028564,
|
|
||||||
1.0
|
|
||||||
],
|
|
||||||
"min": [
|
|
||||||
-0.9987726807594299,
|
|
||||||
-0.9990445971488953,
|
|
||||||
-0.999801516532898,
|
|
||||||
1.0
|
|
||||||
],
|
|
||||||
"type": "VEC4"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bufferView": 1,
|
|
||||||
"componentType": 5126,
|
|
||||||
"count": 2759,
|
|
||||||
"max": [
|
|
||||||
1.0061479806900024,
|
|
||||||
0.9993550181388855
|
|
||||||
],
|
|
||||||
"min": [
|
|
||||||
0.00279300007969141,
|
|
||||||
0.0011620000004768372
|
|
||||||
],
|
|
||||||
"type": "VEC2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"bufferView": 0,
|
|
||||||
"componentType": 5125,
|
|
||||||
"count": 6378,
|
|
||||||
"type": "SCALAR"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"asset": {
|
|
||||||
"extras": {
|
|
||||||
"author": "rufusrockwell (https://sketchfab.com/rufusrockwell)",
|
|
||||||
"license": "CC-BY-4.0 (http://creativecommons.org/licenses/by/4.0/)",
|
|
||||||
"source": "https://sketchfab.com/3d-models/lupine-plant-bf30f1110c174d4baedda0ed63778439",
|
|
||||||
"title": "Lupine Plant"
|
|
||||||
},
|
|
||||||
"generator": "Sketchfab-12.68.0",
|
|
||||||
"version": "2.0"
|
|
||||||
},
|
|
||||||
"bufferViews": [
|
|
||||||
{
|
|
||||||
"buffer": 0,
|
|
||||||
"byteLength": 25512,
|
|
||||||
"name": "floatBufferViews",
|
|
||||||
"target": 34963
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"buffer": 0,
|
|
||||||
"byteLength": 22072,
|
|
||||||
"byteOffset": 25512,
|
|
||||||
"byteStride": 8,
|
|
||||||
"name": "floatBufferViews",
|
|
||||||
"target": 34962
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"buffer": 0,
|
|
||||||
"byteLength": 66216,
|
|
||||||
"byteOffset": 47584,
|
|
||||||
"byteStride": 12,
|
|
||||||
"name": "floatBufferViews",
|
|
||||||
"target": 34962
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"buffer": 0,
|
|
||||||
"byteLength": 44144,
|
|
||||||
"byteOffset": 113800,
|
|
||||||
"byteStride": 16,
|
|
||||||
"name": "floatBufferViews",
|
|
||||||
"target": 34962
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"buffers": [
|
|
||||||
{
|
|
||||||
"byteLength": 157944,
|
|
||||||
"uri": "scene.bin"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"images": [
|
|
||||||
{
|
|
||||||
"uri": "textures/lambert2SG_baseColor.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "textures/lambert2SG_normal.png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"materials": [
|
|
||||||
{
|
|
||||||
"alphaCutoff": 0.2,
|
|
||||||
"alphaMode": "MASK",
|
|
||||||
"doubleSided": true,
|
|
||||||
"name": "lambert2SG",
|
|
||||||
"normalTexture": {
|
|
||||||
"index": 1
|
|
||||||
},
|
|
||||||
"pbrMetallicRoughness": {
|
|
||||||
"baseColorTexture": {
|
|
||||||
"index": 0
|
|
||||||
},
|
|
||||||
"metallicFactor": 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"meshes": [
|
|
||||||
{
|
|
||||||
"name": "Object_0",
|
|
||||||
"primitives": [
|
|
||||||
{
|
|
||||||
"attributes": {
|
|
||||||
"NORMAL": 1,
|
|
||||||
"POSITION": 0,
|
|
||||||
"TANGENT": 2,
|
|
||||||
"TEXCOORD_0": 3
|
|
||||||
},
|
|
||||||
"indices": 4,
|
|
||||||
"material": 0,
|
|
||||||
"mode": 4
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"children": [
|
|
||||||
1
|
|
||||||
],
|
|
||||||
"matrix": [
|
|
||||||
1.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
2.220446049250313e-16,
|
|
||||||
-1.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
1.0,
|
|
||||||
2.220446049250313e-16,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
0.0,
|
|
||||||
1.0
|
|
||||||
],
|
|
||||||
"name": "Sketchfab_model"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"children": [
|
|
||||||
2
|
|
||||||
],
|
|
||||||
"name": "LupineSF.obj.cleaner.materialmerger.gles"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mesh": 0,
|
|
||||||
"name": "Object_2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"samplers": [
|
|
||||||
{
|
|
||||||
"magFilter": 9729,
|
|
||||||
"minFilter": 9987,
|
|
||||||
"wrapS": 10497,
|
|
||||||
"wrapT": 10497
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"scene": 0,
|
|
||||||
"scenes": [
|
|
||||||
{
|
|
||||||
"name": "Sketchfab_Scene",
|
|
||||||
"nodes": [
|
|
||||||
0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"textures": [
|
|
||||||
{
|
|
||||||
"sampler": 0,
|
|
||||||
"source": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sampler": 0,
|
|
||||||
"source": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 3.6 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
786
src/App.vue
@@ -1,791 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
|
|
||||||
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
|
|
||||||
<NotificationGroup group="alert">
|
|
||||||
<div
|
|
||||||
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
|
|
||||||
>
|
|
||||||
<Notification
|
|
||||||
v-slot="{ notifications, close }"
|
|
||||||
enter="transform ease-out duration-300 transition"
|
|
||||||
enter-from="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-4"
|
|
||||||
enter-to="translate-y-0 opacity-100 sm:translate-x-0"
|
|
||||||
leave="transition ease-in duration-500"
|
|
||||||
leave-from="opacity-100"
|
|
||||||
leave-to="opacity-0"
|
|
||||||
move="transition duration-500"
|
|
||||||
move-delay="delay-300"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="notification in notifications"
|
|
||||||
:key="notification.id"
|
|
||||||
class="w-full"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="notification.type === 'toast'"
|
|
||||||
class="w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-900/90 text-white rounded-lg shadow-md"
|
|
||||||
>
|
|
||||||
<div class="w-full px-4 py-3">
|
|
||||||
<span class="font-semibold">{{ notification.title }}</span>
|
|
||||||
<p class="text-sm">{{ notification.text }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="notification.type === 'info'"
|
|
||||||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-100 rounded-lg shadow-md"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-center w-12 bg-slate-600 text-slate-100"
|
|
||||||
>
|
|
||||||
<fa icon="circle-info" class="fa-fw fa-xl"></fa>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
|
|
||||||
<span class="font-semibold">{{ notification.title }}</span>
|
|
||||||
<p class="text-sm">{{ notification.text }}</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="close(notification.id)"
|
|
||||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600"
|
|
||||||
>
|
|
||||||
<fa icon="xmark" class="fa-fw"></fa>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="notification.type === 'success'"
|
|
||||||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-emerald-100 rounded-lg shadow-md"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100"
|
|
||||||
>
|
|
||||||
<fa icon="circle-info" class="fa-fw fa-xl"></fa>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
|
|
||||||
<span class="font-semibold">{{ notification.title }}</span>
|
|
||||||
<p class="text-sm">{{ notification.text }}</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="close(notification.id)"
|
|
||||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600"
|
|
||||||
>
|
|
||||||
<fa icon="xmark" class="fa-fw"></fa>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="notification.type === 'warning'"
|
|
||||||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-amber-100 rounded-lg shadow-md"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-center w-12 bg-amber-600 text-amber-100"
|
|
||||||
>
|
|
||||||
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
|
|
||||||
<span class="font-semibold">{{ notification.title }}</span>
|
|
||||||
<p class="text-sm">{{ notification.text }}</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="close(notification.id)"
|
|
||||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600"
|
|
||||||
>
|
|
||||||
<fa icon="xmark" class="fa-fw"></fa>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="notification.type === 'danger'"
|
|
||||||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-rose-100 rounded-lg shadow-md"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-center w-12 bg-rose-600 text-rose-100"
|
|
||||||
>
|
|
||||||
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
|
|
||||||
<span class="font-semibold">{{ notification.title }}</span>
|
|
||||||
<p class="text-sm">{{ notification.text }}</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="close(notification.id)"
|
|
||||||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600"
|
|
||||||
>
|
|
||||||
<fa icon="xmark" class="fa-fw"></fa>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Notification>
|
|
||||||
</div>
|
|
||||||
</NotificationGroup>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
This "group" of "modal" is the prompt for an answer.
|
|
||||||
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off"
|
|
||||||
-->
|
|
||||||
<NotificationGroup group="modal">
|
|
||||||
<div class="fixed z-[100] top-0 inset-x-0 w-full">
|
|
||||||
<Notification
|
|
||||||
v-slot="{ notifications, close }"
|
|
||||||
enter="transform ease-out duration-300 transition"
|
|
||||||
enter-from="translate-y-2 opacity-0 sm:translate-y-4"
|
|
||||||
enter-to="translate-y-0 opacity-100 sm:translate-y-0"
|
|
||||||
leave="transition ease-in duration-500"
|
|
||||||
leave-from="opacity-100"
|
|
||||||
leave-to="opacity-0"
|
|
||||||
move="transition duration-500"
|
|
||||||
move-delay="delay-300"
|
|
||||||
>
|
|
||||||
<!-- see NotificationIface in constants/app.ts -->
|
|
||||||
<div
|
|
||||||
v-for="notification in notifications"
|
|
||||||
:key="notification.id"
|
|
||||||
class="w-full"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<!--
|
|
||||||
Type of "confirm" will post a message.
|
|
||||||
With onYes function, show a "Yes" button to call that function.
|
|
||||||
With onNo function, show a "No" button to call that function,
|
|
||||||
and pass it state of "askAgain" field shown if you set promptToStopAsking.
|
|
||||||
-->
|
|
||||||
<div
|
|
||||||
v-if="notification.type === 'confirm'"
|
|
||||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
|
||||||
>
|
|
||||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
|
||||||
<span class="font-semibold text-lg">
|
|
||||||
{{ notification.title }}
|
|
||||||
</span>
|
|
||||||
<p class="text-sm mb-2">{{ notification.text }}</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="notification.onYes"
|
|
||||||
@click="
|
|
||||||
notification.onYes();
|
|
||||||
close(notification.id);
|
|
||||||
"
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
|
||||||
>
|
|
||||||
Yes
|
|
||||||
{{ notification.yesText ? ", " + notification.yesText : "" }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="notification.onNo"
|
|
||||||
@click="
|
|
||||||
notification.onNo(stopAsking);
|
|
||||||
close(notification.id);
|
|
||||||
stopAsking = false; // reset value
|
|
||||||
"
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
|
|
||||||
>
|
|
||||||
No {{ notification.noText ? ", " + notification.noText : "" }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<label
|
|
||||||
v-if="notification.promptToStopAsking && notification.onNo"
|
|
||||||
for="toggleStopAsking"
|
|
||||||
class="flex items-center justify-between cursor-pointer my-4"
|
|
||||||
@click="stopAsking = !stopAsking"
|
|
||||||
>
|
|
||||||
<!-- label -->
|
|
||||||
<span class="ml-2">... and do not ask again.</span>
|
|
||||||
<!-- toggle -->
|
|
||||||
<div class="relative ml-2">
|
|
||||||
<!-- input -->
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
v-model="stopAsking"
|
|
||||||
name="stopAsking"
|
|
||||||
class="sr-only"
|
|
||||||
/>
|
|
||||||
<!-- line -->
|
|
||||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
||||||
<!-- dot -->
|
|
||||||
<div
|
|
||||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
notification.onCancel
|
|
||||||
? notification.onCancel(stopAsking)
|
|
||||||
: null;
|
|
||||||
close(notification.id);
|
|
||||||
stopAsking = false; // reset value
|
|
||||||
"
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
{{ notification.onYes ? "Cancel" : "Close" }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="notification.type === 'notification-permission'"
|
|
||||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
|
||||||
>
|
|
||||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
|
||||||
<p v-if="serviceWorkerReady" class="text-lg mb-4">
|
|
||||||
Would you like to be notified of new activity once a day?
|
|
||||||
</p>
|
|
||||||
<p v-else class="text-lg mb-4">
|
|
||||||
Waiting for system initialization, which may take up to 10
|
|
||||||
seconds...
|
|
||||||
<fa icon="spinner" spin />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-if="serviceWorkerReady">
|
|
||||||
<span class="flex flex-row justify-center">
|
|
||||||
<span class="mt-2">Yes, tell me at: </span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
class="rounded-l border border-r-0 border-slate-400 ml-2 mt-2 px-2 py-2 text-center w-20"
|
|
||||||
v-model="hourInput"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="rounded-r border border-slate-400 bg-slate-200 text-center text-blue-500 mt-2 px-2 py-2 w-20"
|
|
||||||
@click="hourAm = !hourAm"
|
|
||||||
>
|
|
||||||
<span v-if="hourAm"> AM <fa icon="chevron-down" /> </span>
|
|
||||||
<span v-else> PM <fa icon="chevron-up" /> </span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
if (checkHour()) {
|
|
||||||
close(notification.id);
|
|
||||||
turnOnNotifications();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Turn on Daily Message
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="close(notification.id)"
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white mt-4 px-2 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
No, Not Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="notification.type === 'notification-mute'"
|
|
||||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
|
||||||
>
|
|
||||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
|
||||||
<p class="text-lg mb-4">Mute app notifications:</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
|
||||||
>
|
|
||||||
For 1 Hour
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
|
||||||
>
|
|
||||||
For 8 Hours
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
|
||||||
>
|
|
||||||
For 24 Hours
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
|
||||||
>
|
|
||||||
Until I turn it back on
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="close(notification.id)"
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="notification.type === 'notification-off'"
|
|
||||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
|
||||||
>
|
|
||||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
|
||||||
<p class="text-lg mb-4">
|
|
||||||
Would you like to <b>turn off</b> notifications for this app?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
@click="
|
|
||||||
close(notification.id);
|
|
||||||
turnOffNotifications();
|
|
||||||
"
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
|
|
||||||
>
|
|
||||||
Turn Off Notifications
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="close(notification.id)"
|
|
||||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Leave it On
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Notification>
|
|
||||||
</div>
|
|
||||||
</NotificationGroup>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style></style>
|
<style></style>
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import axios from "axios";
|
|
||||||
import { Vue, Component } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
import * as libsUtil from "@/libs/util";
|
|
||||||
|
|
||||||
interface ServiceWorkerMessage {
|
|
||||||
type: string;
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceWorkerResponse {
|
|
||||||
// Define the properties and their types
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example interface for error
|
|
||||||
interface ErrorResponse {
|
|
||||||
message: string;
|
|
||||||
// Other properties as needed
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VapidResponse {
|
|
||||||
data: {
|
|
||||||
vapidKey: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PushSubscriptionWithTime extends PushSubscriptionJSON {
|
|
||||||
notifyTime: { utcHour: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app";
|
|
||||||
import { db } from "@/db/index";
|
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|
||||||
import { sendTestThroughPushServer } from "@/libs/util";
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class App extends Vue {
|
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
||||||
|
|
||||||
stopAsking = false;
|
|
||||||
b64 = "";
|
|
||||||
hourAm = true;
|
|
||||||
hourInput = "8";
|
|
||||||
serviceWorkerReady = true;
|
|
||||||
|
|
||||||
async mounted() {
|
|
||||||
try {
|
|
||||||
await db.open();
|
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
||||||
let pushUrl = DEFAULT_PUSH_SERVER;
|
|
||||||
if (settings?.webPushServer) {
|
|
||||||
pushUrl = settings.webPushServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pushUrl.startsWith("http://localhost")) {
|
|
||||||
console.log("Not checking for VAPID in this local environment.");
|
|
||||||
} else {
|
|
||||||
await axios
|
|
||||||
.get(pushUrl + "/web-push/vapid")
|
|
||||||
.then((response: VapidResponse) => {
|
|
||||||
this.b64 = response.data?.vapidKey || "";
|
|
||||||
console.log("Got vapid key:", this.b64);
|
|
||||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
|
||||||
console.log("New service worker is now controlling the page");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (!this.b64) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error Setting Notifications",
|
|
||||||
text: "Could not set notifications.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (window.location.host.startsWith("localhost")) {
|
|
||||||
console.log("Ignoring the error getting VAPID for local development.");
|
|
||||||
} else {
|
|
||||||
console.error("Got an error initializing notifications:", error);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error Setting Notifications",
|
|
||||||
text: "Got an error setting notifications.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// there may be a long pause here on first initialization
|
|
||||||
navigator.serviceWorker?.ready.then(() => {
|
|
||||||
this.serviceWorkerReady = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendMessageToServiceWorker(
|
|
||||||
message: ServiceWorkerMessage,
|
|
||||||
): Promise<unknown> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (navigator.serviceWorker.controller) {
|
|
||||||
const messageChannel = new MessageChannel();
|
|
||||||
|
|
||||||
messageChannel.port1.onmessage = (event: MessageEvent) => {
|
|
||||||
if (event.data.error) {
|
|
||||||
reject(event.data.error as ErrorResponse);
|
|
||||||
} else {
|
|
||||||
resolve(event.data as ServiceWorkerResponse);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
navigator.serviceWorker.controller.postMessage(message, [
|
|
||||||
messageChannel.port2,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
reject("Service worker controller not available");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private askPermission(): Promise<NotificationPermission> {
|
|
||||||
console.log("Requesting permission for notifications:", navigator);
|
|
||||||
if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) {
|
|
||||||
return Promise.reject("Service worker not available.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = localStorage.getItem("secret");
|
|
||||||
if (!secret) {
|
|
||||||
return Promise.reject("No secret found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.sendSecretToServiceWorker(secret)
|
|
||||||
.then(() => this.checkNotificationSupport())
|
|
||||||
.then(() => this.requestNotificationPermission())
|
|
||||||
.catch((error) => Promise.reject(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendSecretToServiceWorker(secret: string): Promise<void> {
|
|
||||||
const message: ServiceWorkerMessage = {
|
|
||||||
type: "SEND_LOCAL_DATA",
|
|
||||||
data: secret,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.sendMessageToServiceWorker(message).then((response) => {
|
|
||||||
console.log("Response from service worker:", response);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkNotificationSupport(): Promise<void> {
|
|
||||||
if (!("Notification" in window)) {
|
|
||||||
alert("This browser does not support notifications.");
|
|
||||||
return Promise.reject("This browser does not support notifications.");
|
|
||||||
}
|
|
||||||
if (Notification.permission === "granted") {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
private requestNotificationPermission(): Promise<NotificationPermission> {
|
|
||||||
return Notification.requestPermission().then((permission) => {
|
|
||||||
if (permission !== "granted") {
|
|
||||||
alert(
|
|
||||||
"Allow this app permission to make notifications for personal reminders." +
|
|
||||||
" You can adjust them at any time in your settings.",
|
|
||||||
);
|
|
||||||
throw new Error("We weren't granted permission.");
|
|
||||||
}
|
|
||||||
return permission;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// this allows us to show an error without closing the dialog
|
|
||||||
checkHour() {
|
|
||||||
if (!libsUtil.isNumeric(this.hourInput)) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Not a Number",
|
|
||||||
text: "The time must be an hour number.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const hourNum = libsUtil.numberOrZero(this.hourInput);
|
|
||||||
if (!Number.isInteger(hourNum)) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Not a Whole Number",
|
|
||||||
text: "The time must be a whole hour number.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (hourNum < 1 || 12 < hourNum) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Not a Whole Number",
|
|
||||||
text: "The time must be an hour between 1 and 12.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async turnOnNotifications() {
|
|
||||||
return this.askPermission()
|
|
||||||
.then((permission) => {
|
|
||||||
console.log("Permission granted:", permission);
|
|
||||||
|
|
||||||
// Call the function and handle promises
|
|
||||||
this.subscribeToPush()
|
|
||||||
.then(() => {
|
|
||||||
console.log("Subscribed successfully.");
|
|
||||||
return navigator.serviceWorker?.ready;
|
|
||||||
})
|
|
||||||
.then((registration) => {
|
|
||||||
return registration.pushManager.getSubscription();
|
|
||||||
})
|
|
||||||
.then(async (subscription) => {
|
|
||||||
if (subscription) {
|
|
||||||
await this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "info",
|
|
||||||
title: "Notification Setup Underway",
|
|
||||||
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
// we already checked that this is a valid hour number
|
|
||||||
const rawHourNum = libsUtil.numberOrZero(this.hourInput);
|
|
||||||
const adjHourNum = rawHourNum + (this.hourAm ? 0 : 12);
|
|
||||||
const hourNum = adjHourNum % 24;
|
|
||||||
const utcHour =
|
|
||||||
hourNum + Math.round(new Date().getTimezoneOffset() / 60);
|
|
||||||
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24;
|
|
||||||
|
|
||||||
const subscriptionWithTime: PushSubscriptionWithTime = {
|
|
||||||
notifyTime: { utcHour: finalUtcHour },
|
|
||||||
...subscription.toJSON(),
|
|
||||||
};
|
|
||||||
await this.sendSubscriptionToServer(subscriptionWithTime);
|
|
||||||
return subscriptionWithTime;
|
|
||||||
} else {
|
|
||||||
throw new Error("Subscription object is not available.");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(async (subscription: PushSubscriptionWithTime) => {
|
|
||||||
console.log(
|
|
||||||
"Subscription data sent to server and all finished successfully.",
|
|
||||||
);
|
|
||||||
await sendTestThroughPushServer(subscription, true);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "success",
|
|
||||||
title: "Notifications Turned On",
|
|
||||||
text: "Notifications are on. You should see at least one on your device; if not, check the 'Troubleshoot' page.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(
|
|
||||||
"Subscription or server communication failed:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
alert(
|
|
||||||
"Subscription or server communication failed. Try again in a while.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(
|
|
||||||
"An error occurred setting notification permissions:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
alert("Some error occurred setting notification permissions.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|
||||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
|
||||||
const base64 = (base64String + padding)
|
|
||||||
.replace(/-/g, "+")
|
|
||||||
.replace(/_/g, "/");
|
|
||||||
const rawData = window.atob(base64);
|
|
||||||
const outputArray = new Uint8Array(rawData.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < rawData.length; ++i) {
|
|
||||||
outputArray[i] = rawData.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return outputArray;
|
|
||||||
}
|
|
||||||
|
|
||||||
private subscribeToPush(): Promise<void> {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
if (!("serviceWorker" in navigator && "PushManager" in window)) {
|
|
||||||
const errorMsg = "Push messaging is not supported";
|
|
||||||
console.warn(errorMsg);
|
|
||||||
return reject(new Error(errorMsg));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Notification.permission !== "granted") {
|
|
||||||
const errorMsg = "Notification permission not granted";
|
|
||||||
console.warn(errorMsg);
|
|
||||||
return reject(new Error(errorMsg));
|
|
||||||
}
|
|
||||||
|
|
||||||
const applicationServerKey = this.urlBase64ToUint8Array(this.b64);
|
|
||||||
const options: PushSubscriptionOptions = {
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey: applicationServerKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
navigator.serviceWorker.ready
|
|
||||||
.then((registration) => {
|
|
||||||
return registration.pushManager.subscribe(options);
|
|
||||||
})
|
|
||||||
.then((subscription) => {
|
|
||||||
console.log("Push subscription successful:", subscription);
|
|
||||||
resolve();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Push subscription failed:", error, options);
|
|
||||||
|
|
||||||
// Inform the user about the issue
|
|
||||||
alert(
|
|
||||||
"We encountered an issue setting up push notifications. " +
|
|
||||||
"If you wish to revoke notification permissions, please do so in your browser settings.",
|
|
||||||
);
|
|
||||||
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private sendSubscriptionToServer(
|
|
||||||
subscription: PushSubscriptionWithTime,
|
|
||||||
): Promise<void> {
|
|
||||||
console.log("About to send subscription...", subscription);
|
|
||||||
return fetch("/web-push/subscribe", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(subscription),
|
|
||||||
}).then((response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to send subscription to server");
|
|
||||||
}
|
|
||||||
console.log("Subscription sent to server successfully.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async turnOffNotifications() {
|
|
||||||
let subscription;
|
|
||||||
const pushProviderSuccess = await navigator.serviceWorker?.ready
|
|
||||||
.then((registration) => {
|
|
||||||
return registration.pushManager.getSubscription();
|
|
||||||
})
|
|
||||||
.then((subscript) => {
|
|
||||||
subscription = subscript;
|
|
||||||
if (subscription) {
|
|
||||||
return subscription.unsubscribe();
|
|
||||||
} else {
|
|
||||||
console.log("Subscription object is not available.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Push provider server communication failed:", error);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(subscription),
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
return response.ok;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Push server communication failed:", error);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
alert(
|
|
||||||
"Notifications are off. Push provider unsubscribe " +
|
|
||||||
(pushProviderSuccess ? "succeeded" : "failed") +
|
|
||||||
(pushProviderSuccess === pushServerSuccess ? " and" : " but") +
|
|
||||||
" push server unsubscribe " +
|
|
||||||
(pushServerSuccess ? "succeeded" : "failed") +
|
|
||||||
".",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
|
||||||
<rect width="64" height="64" fill="#ffffff"></rect>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 145 B |
|
Before Width: | Height: | Size: 5.2 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" enable-background="new 0 0 50 50">
|
|
||||||
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z"/><path d="M24 7h2v21h-2z"/><path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 5.1 KiB |
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
|
||||||
<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
|
|
||||||
<g>
|
|
||||||
<circle fill="#FFFFFF" cx="37.785" cy="28.501" r="28.836"/>
|
|
||||||
<path d="M37.441-3.5c8.951,0,16.572,3.125,22.857,9.372c3.008,3.009,5.295,6.448,6.857,10.314
|
|
||||||
c1.561,3.867,2.344,7.971,2.344,12.314c0,4.381-0.773,8.486-2.314,12.313c-1.543,3.828-3.82,7.21-6.828,10.143
|
|
||||||
c-3.123,3.085-6.666,5.448-10.629,7.086c-3.961,1.638-8.057,2.457-12.285,2.457s-8.276-0.808-12.143-2.429
|
|
||||||
c-3.866-1.618-7.333-3.961-10.4-7.027c-3.067-3.066-5.4-6.524-7-10.372S5.5,32.767,5.5,28.5c0-4.229,0.809-8.295,2.428-12.2
|
|
||||||
c1.619-3.905,3.972-7.4,7.057-10.486C21.08-0.394,28.565-3.5,37.441-3.5z M37.557,2.272c-7.314,0-13.467,2.553-18.458,7.657
|
|
||||||
c-2.515,2.553-4.448,5.419-5.8,8.6c-1.354,3.181-2.029,6.505-2.029,9.972c0,3.429,0.675,6.734,2.029,9.913
|
|
||||||
c1.353,3.183,3.285,6.021,5.8,8.516c2.514,2.496,5.351,4.399,8.515,5.715c3.161,1.314,6.476,1.971,9.943,1.971
|
|
||||||
c3.428,0,6.75-0.665,9.973-1.999c3.219-1.335,6.121-3.257,8.713-5.771c4.99-4.876,7.484-10.99,7.484-18.344
|
|
||||||
c0-3.543-0.648-6.895-1.943-10.057c-1.293-3.162-3.18-5.98-5.654-8.458C50.984,4.844,44.795,2.272,37.557,2.272z M37.156,23.187
|
|
||||||
l-4.287,2.229c-0.458-0.951-1.019-1.619-1.685-2c-0.667-0.38-1.286-0.571-1.858-0.571c-2.856,0-4.286,1.885-4.286,5.657
|
|
||||||
c0,1.714,0.362,3.084,1.085,4.113c0.724,1.029,1.791,1.544,3.201,1.544c1.867,0,3.181-0.915,3.944-2.743l3.942,2
|
|
||||||
c-0.838,1.563-2,2.791-3.486,3.686c-1.484,0.896-3.123,1.343-4.914,1.343c-2.857,0-5.163-0.875-6.915-2.629
|
|
||||||
c-1.752-1.752-2.628-4.19-2.628-7.313c0-3.048,0.886-5.466,2.657-7.257c1.771-1.79,4.009-2.686,6.715-2.686
|
|
||||||
C32.604,18.558,35.441,20.101,37.156,23.187z M55.613,23.187l-4.229,2.229c-0.457-0.951-1.02-1.619-1.686-2
|
|
||||||
c-0.668-0.38-1.307-0.571-1.914-0.571c-2.857,0-4.287,1.885-4.287,5.657c0,1.714,0.363,3.084,1.086,4.113
|
|
||||||
c0.723,1.029,1.789,1.544,3.201,1.544c1.865,0,3.18-0.915,3.941-2.743l4,2c-0.875,1.563-2.057,2.791-3.541,3.686
|
|
||||||
c-1.486,0.896-3.105,1.343-4.857,1.343c-2.896,0-5.209-0.875-6.941-2.629c-1.736-1.752-2.602-4.19-2.602-7.313
|
|
||||||
c0-3.048,0.885-5.466,2.658-7.257c1.77-1.79,4.008-2.686,6.713-2.686C51.117,18.558,53.938,20.101,55.613,23.187z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
width="64px" height="64px" viewBox="-0.5 0.5 64 64" enable-background="new -0.5 0.5 64 64" xml:space="preserve">
|
|
||||||
<g>
|
|
||||||
<circle fill="#FFFFFF" cx="31.325" cy="32.873" r="30.096"/>
|
|
||||||
<path id="text2809_1_" d="M31.5,14.08c-10.565,0-13.222,9.969-13.222,18.42c0,8.452,2.656,18.42,13.222,18.42
|
|
||||||
c10.564,0,13.221-9.968,13.221-18.42C44.721,24.049,42.064,14.08,31.5,14.08z M31.5,21.026c0.429,0,0.82,0.066,1.188,0.157
|
|
||||||
c0.761,0.656,1.133,1.561,0.403,2.823l-7.036,12.93c-0.216-1.636-0.247-3.24-0.247-4.437C25.808,28.777,26.066,21.026,31.5,21.026z
|
|
||||||
M36.766,26.987c0.373,1.984,0.426,4.056,0.426,5.513c0,3.723-0.258,11.475-5.69,11.475c-0.428,0-0.822-0.045-1.188-0.136
|
|
||||||
c-0.07-0.021-0.134-0.043-0.202-0.067c-0.112-0.032-0.23-0.068-0.336-0.11c-1.21-0.515-1.972-1.446-0.874-3.093L36.766,26.987z"/>
|
|
||||||
<path id="path2815_1_" d="M31.433,0.5c-8.877,0-16.359,3.09-22.454,9.3c-3.087,3.087-5.443,6.607-7.082,10.532
|
|
||||||
C0.297,24.219-0.5,28.271-0.5,32.5c0,4.268,0.797,8.32,2.397,12.168c1.6,3.85,3.921,7.312,6.969,10.396
|
|
||||||
c3.085,3.049,6.549,5.399,10.398,7.037c3.886,1.602,7.939,2.398,12.169,2.398c4.229,0,8.34-0.826,12.303-2.465
|
|
||||||
c3.962-1.639,7.496-3.994,10.621-7.081c3.011-2.933,5.289-6.297,6.812-10.106C62.73,41,63.5,36.883,63.5,32.5
|
|
||||||
c0-4.343-0.77-8.454-2.33-12.303c-1.562-3.885-3.848-7.32-6.857-10.33C48.025,3.619,40.385,0.5,31.433,0.5z M31.567,6.259
|
|
||||||
c7.238,0,13.412,2.566,18.554,7.709c2.477,2.477,4.375,5.31,5.67,8.471c1.296,3.162,1.949,6.518,1.949,10.061
|
|
||||||
c0,7.354-2.516,13.454-7.506,18.33c-2.592,2.516-5.502,4.447-8.74,5.781c-3.2,1.334-6.498,1.994-9.927,1.994
|
|
||||||
c-3.468,0-6.788-0.653-9.949-1.948c-3.163-1.334-6.001-3.238-8.516-5.716c-2.515-2.514-4.455-5.353-5.826-8.516
|
|
||||||
c-1.333-3.199-2.017-6.498-2.017-9.927c0-3.467,0.684-6.787,2.017-9.949c1.371-3.2,3.312-6.074,5.826-8.628
|
|
||||||
C18.092,8.818,24.252,6.259,31.567,6.259z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 6.7 KiB |
@@ -1,17 +1,11 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
|
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
|
||||||
input:checked ~ .dot {
|
|
||||||
transform: translateX(100%);
|
|
||||||
background-color: #FFF !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-html="generateIcon()" class="w-fit"></div>
|
|
||||||
</template>
|
|
||||||
<script lang="ts">
|
|
||||||
import { createAvatar, StyleOptions } from "@dicebear/core";
|
|
||||||
import { avataaars } from "@dicebear/collection";
|
|
||||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
|
||||||
import { Contact } from "@/db/tables/contacts";
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class EntityIcon extends Vue {
|
|
||||||
@Prop contact: Contact;
|
|
||||||
@Prop entityId = ""; // overridden by contact.did or profileImageUrl
|
|
||||||
@Prop iconSize = 0;
|
|
||||||
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
|
|
||||||
|
|
||||||
generateIcon() {
|
|
||||||
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
|
|
||||||
if (imageUrl) {
|
|
||||||
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
|
||||||
} else {
|
|
||||||
const identifier = this.contact?.did || this.entityId;
|
|
||||||
if (!identifier) {
|
|
||||||
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
|
||||||
}
|
|
||||||
// https://api.dicebear.com/8.x/avataaars/svg?seed=
|
|
||||||
// ... does not render things with the same seed as this library.
|
|
||||||
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
|
|
||||||
// ... which looks similar to '' at the dicebear site but which is different.
|
|
||||||
const options: StyleOptions<object> = {
|
|
||||||
seed: (identifier as string) || "",
|
|
||||||
size: this.iconSize,
|
|
||||||
};
|
|
||||||
const avatar = createAvatar(avataaars, options);
|
|
||||||
const svgString = avatar.toString();
|
|
||||||
return svgString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="visible" id="dialogFeedFilters" class="dialog-overlay">
|
|
||||||
<div class="dialog">
|
|
||||||
<h1 class="text-xl font-bold text-center mb-4">Feed Filters</h1>
|
|
||||||
|
|
||||||
<p class="mb-4 font-bold">Show only activities that…</p>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-2">
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between cursor-pointer"
|
|
||||||
@click="toggleHasVisibleDid()"
|
|
||||||
>
|
|
||||||
<!-- label -->
|
|
||||||
<div>Include someone visible to me</div>
|
|
||||||
<!-- toggle -->
|
|
||||||
<div class="relative ml-2">
|
|
||||||
<!-- input -->
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
v-model="hasVisibleDid"
|
|
||||||
name="toggleFilterFromMyContacts"
|
|
||||||
class="sr-only"
|
|
||||||
/>
|
|
||||||
<!-- line -->
|
|
||||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
||||||
<!-- dot -->
|
|
||||||
<div
|
|
||||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<em>or</em>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between cursor-pointer"
|
|
||||||
@click="
|
|
||||||
hasSearchBox
|
|
||||||
? toggleNearby()
|
|
||||||
: $router.push({ name: 'search-area' })
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<!-- label -->
|
|
||||||
<div>Are nearby</div>
|
|
||||||
<!-- toggle -->
|
|
||||||
<div v-if="hasSearchBox" class="relative ml-2">
|
|
||||||
<!-- input -->
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
v-model="isNearby"
|
|
||||||
name="toggleFilterNearby"
|
|
||||||
class="sr-only"
|
|
||||||
/>
|
|
||||||
<!-- line -->
|
|
||||||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
|
|
||||||
<!-- dot -->
|
|
||||||
<div
|
|
||||||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="relative ml-2">
|
|
||||||
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500">
|
|
||||||
Select Location
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
|
||||||
@click="setAll()"
|
|
||||||
>
|
|
||||||
Set All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
|
||||||
@click="clearAll()"
|
|
||||||
>
|
|
||||||
Clear All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
|
||||||
@click="done()"
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Vue, Component } from "vue-facing-decorator";
|
|
||||||
import {
|
|
||||||
LMap,
|
|
||||||
LMarker,
|
|
||||||
LRectangle,
|
|
||||||
LTileLayer,
|
|
||||||
} from "@vue-leaflet/vue-leaflet";
|
|
||||||
|
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|
||||||
import { db } from "@/db/index";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
components: {
|
|
||||||
LRectangle,
|
|
||||||
LMap,
|
|
||||||
LMarker,
|
|
||||||
LTileLayer,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class FeedFilters extends Vue {
|
|
||||||
onCloseIfChanged = () => {};
|
|
||||||
hasSearchBox = false;
|
|
||||||
hasVisibleDid = false;
|
|
||||||
isNearby = false;
|
|
||||||
settingChanged = false;
|
|
||||||
visible = false;
|
|
||||||
|
|
||||||
async open(onCloseIfChanged: () => void) {
|
|
||||||
this.onCloseIfChanged = onCloseIfChanged;
|
|
||||||
|
|
||||||
await db.open();
|
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
||||||
this.hasVisibleDid = !!settings?.filterFeedByVisible;
|
|
||||||
this.isNearby = !!settings?.filterFeedByNearby;
|
|
||||||
if (settings?.searchBoxes && settings.searchBoxes.length > 0) {
|
|
||||||
this.hasSearchBox = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.settingChanged = false;
|
|
||||||
this.visible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleHasVisibleDid() {
|
|
||||||
this.settingChanged = true;
|
|
||||||
this.hasVisibleDid = !this.hasVisibleDid;
|
|
||||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
||||||
filterFeedByVisible: this.hasVisibleDid,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleNearby() {
|
|
||||||
this.settingChanged = true;
|
|
||||||
this.isNearby = !this.isNearby;
|
|
||||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
||||||
filterFeedByNearby: this.isNearby,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearAll() {
|
|
||||||
if (this.hasVisibleDid || this.isNearby) {
|
|
||||||
this.settingChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
||||||
filterFeedByNearby: false,
|
|
||||||
filterFeedByVisible: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.hasVisibleDid = false;
|
|
||||||
this.isNearby = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setAll() {
|
|
||||||
if (!this.hasVisibleDid || !this.isNearby) {
|
|
||||||
this.settingChanged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
|
||||||
filterFeedByNearby: true,
|
|
||||||
filterFeedByVisible: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.hasVisibleDid = true;
|
|
||||||
this.isNearby = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
if (this.settingChanged) {
|
|
||||||
this.onCloseIfChanged();
|
|
||||||
}
|
|
||||||
this.visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
done() {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dialog-overlay {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#dialogFeedFilters.dialog-overlay {
|
|
||||||
z-index: 99999;
|
|
||||||
overflow: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog {
|
|
||||||
background-color: white;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="visible" class="dialog-overlay">
|
|
||||||
<div class="dialog">
|
|
||||||
<h1 class="text-xl font-bold text-center mb-4">
|
|
||||||
{{ customTitle }}
|
|
||||||
</h1>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
|
||||||
placeholder="What was given"
|
|
||||||
v-model="description"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-row justify-center">
|
|
||||||
<span
|
|
||||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center text-blue-500 px-2 py-2 w-20"
|
|
||||||
@click="changeUnitCode()"
|
|
||||||
>
|
|
||||||
{{ libsUtil.UNIT_SHORT[unitCode] || unitCode }}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
|
||||||
@click="amountInput === '0' ? null : decrement()"
|
|
||||||
>
|
|
||||||
<fa icon="chevron-left" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="inputGivenAmount"
|
|
||||||
type="number"
|
|
||||||
class="border border-r-0 border-slate-400 px-2 py-2 text-center w-20"
|
|
||||||
v-model="amountInput"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
|
||||||
@click="increment()"
|
|
||||||
>
|
|
||||||
<fa icon="chevron-right" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex justify-center">
|
|
||||||
<span>
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'gifted-details',
|
|
||||||
query: {
|
|
||||||
amountInput,
|
|
||||||
description,
|
|
||||||
giverDid: giver?.did,
|
|
||||||
giverName: giver?.name,
|
|
||||||
offerId,
|
|
||||||
projectId,
|
|
||||||
recipientDid: receiver?.did,
|
|
||||||
recipientName: receiver?.name,
|
|
||||||
unitCode,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
class="text-blue-500"
|
|
||||||
>
|
|
||||||
Photo & more options ...
|
|
||||||
</router-link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-center mb-2 mt-6 italic">
|
|
||||||
Sign & Send to publish to the world
|
|
||||||
<fa
|
|
||||||
icon="circle-info"
|
|
||||||
class="pl-2 text-blue-500 cursor-pointer"
|
|
||||||
@click="explainData()"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
|
||||||
@click="confirm"
|
|
||||||
>
|
|
||||||
Sign & Send
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
|
||||||
@click="cancel"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
import { NotificationIface } from "@/constants/app";
|
|
||||||
import {
|
|
||||||
createAndSubmitGive,
|
|
||||||
didInfo,
|
|
||||||
GiverReceiverInputInfo,
|
|
||||||
} from "@/libs/endorserServer";
|
|
||||||
import * as libsUtil from "@/libs/util";
|
|
||||||
import { accountsDB, db } from "@/db/index";
|
|
||||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
|
||||||
import { Contact } from "@/db/tables/contacts";
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class GiftedDialog extends Vue {
|
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
||||||
|
|
||||||
@Prop projectId = "";
|
|
||||||
|
|
||||||
activeDid = "";
|
|
||||||
allContacts: Array<Contact> = [];
|
|
||||||
allMyDids: Array<string> = [];
|
|
||||||
apiServer = "";
|
|
||||||
|
|
||||||
amountInput = "0";
|
|
||||||
callbackOnSuccess?: (amount: number) => void = () => {};
|
|
||||||
customTitle?: string;
|
|
||||||
description = "";
|
|
||||||
giver?: GiverReceiverInputInfo; // undefined means no identified giver agent
|
|
||||||
isTrade = false;
|
|
||||||
offerId = "";
|
|
||||||
receiver?: GiverReceiverInputInfo;
|
|
||||||
unitCode = "HUR";
|
|
||||||
visible = false;
|
|
||||||
|
|
||||||
libsUtil = libsUtil;
|
|
||||||
|
|
||||||
async open(
|
|
||||||
giver?: GiverReceiverInputInfo,
|
|
||||||
receiver?: GiverReceiverInputInfo,
|
|
||||||
offerId?: string,
|
|
||||||
customTitle?: string,
|
|
||||||
callbackOnSuccess?: (amount: number) => void,
|
|
||||||
) {
|
|
||||||
this.customTitle = customTitle;
|
|
||||||
this.description = "";
|
|
||||||
this.giver = giver;
|
|
||||||
this.receiver = receiver;
|
|
||||||
// if we show "given to user" selection, default checkbox to true
|
|
||||||
this.amountInput = "0";
|
|
||||||
this.callbackOnSuccess = callbackOnSuccess;
|
|
||||||
this.offerId = offerId || "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.open();
|
|
||||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
|
||||||
this.apiServer = settings?.apiServer || "";
|
|
||||||
this.activeDid = settings?.activeDid || "";
|
|
||||||
|
|
||||||
this.allContacts = await db.contacts.toArray();
|
|
||||||
|
|
||||||
await accountsDB.open();
|
|
||||||
const allAccounts = await accountsDB.accounts.toArray();
|
|
||||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
|
||||||
|
|
||||||
if (this.giver && !this.giver.name) {
|
|
||||||
this.giver.name = didInfo(
|
|
||||||
this.giver.did,
|
|
||||||
this.activeDid,
|
|
||||||
this.allMyDids,
|
|
||||||
this.allContacts,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Error retrieving settings from database:", err);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: err.message || "There was an error retrieving your settings.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.visible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
// close the dialog but don't change values (since it might be submitting info)
|
|
||||||
this.visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
changeUnitCode() {
|
|
||||||
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
|
||||||
const index = units.indexOf(this.unitCode);
|
|
||||||
this.unitCode = units[(index + 1) % units.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
increment() {
|
|
||||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
decrement() {
|
|
||||||
this.amountInput = `${Math.max(
|
|
||||||
0,
|
|
||||||
(parseFloat(this.amountInput) || 1) - 1,
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
this.close();
|
|
||||||
this.eraseValues();
|
|
||||||
}
|
|
||||||
|
|
||||||
eraseValues() {
|
|
||||||
this.description = "";
|
|
||||||
this.giver = undefined;
|
|
||||||
this.amountInput = "0";
|
|
||||||
this.unitCode = "HUR";
|
|
||||||
}
|
|
||||||
|
|
||||||
async confirm() {
|
|
||||||
if (!this.activeDid) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You must select an identifier before you can record a give.",
|
|
||||||
},
|
|
||||||
3000,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (parseFloat(this.amountInput) < 0) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
text: "You may not send a negative number.",
|
|
||||||
title: "",
|
|
||||||
},
|
|
||||||
2000,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.description && !parseFloat(this.amountInput)) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: `You must enter a description or some number of ${
|
|
||||||
this.libsUtil.UNIT_LONG[this.unitCode]
|
|
||||||
}.`,
|
|
||||||
},
|
|
||||||
2000,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.close();
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "toast",
|
|
||||||
text: "Recording the give...",
|
|
||||||
title: "",
|
|
||||||
},
|
|
||||||
1000,
|
|
||||||
);
|
|
||||||
// this is asynchronous, but we don't need to wait for it to complete
|
|
||||||
await this.recordGive(
|
|
||||||
(this.giver?.did as string) || null,
|
|
||||||
(this.receiver?.did as string) || null,
|
|
||||||
this.description,
|
|
||||||
parseFloat(this.amountInput),
|
|
||||||
this.unitCode,
|
|
||||||
).then(() => {
|
|
||||||
this.eraseValues();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param giverDid may be null
|
|
||||||
* @param recipientDid may be null
|
|
||||||
* @param description may be an empty string
|
|
||||||
* @param amount may be 0
|
|
||||||
* @param unitCode may be omitted, defaults to "HUR"
|
|
||||||
*/
|
|
||||||
async recordGive(
|
|
||||||
giverDid: string | null,
|
|
||||||
recipientDid: string | null,
|
|
||||||
description: string,
|
|
||||||
amount: number,
|
|
||||||
unitCode: string = "HUR",
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const result = await createAndSubmitGive(
|
|
||||||
this.axios,
|
|
||||||
this.apiServer,
|
|
||||||
this.activeDid,
|
|
||||||
giverDid as string,
|
|
||||||
recipientDid as string,
|
|
||||||
description,
|
|
||||||
amount,
|
|
||||||
unitCode,
|
|
||||||
this.projectId,
|
|
||||||
this.offerId,
|
|
||||||
this.isTrade,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
result.type === "error" ||
|
|
||||||
this.isGiveCreationError(result.response)
|
|
||||||
) {
|
|
||||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
|
||||||
console.error("Error with give creation result:", result);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: errorMessage || "There was an error creating the give.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "success",
|
|
||||||
title: "Success",
|
|
||||||
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
|
|
||||||
},
|
|
||||||
7000,
|
|
||||||
);
|
|
||||||
if (this.callbackOnSuccess) {
|
|
||||||
this.callbackOnSuccess(amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error with give recordation caught:", error);
|
|
||||||
const errorMessage =
|
|
||||||
error.userMessage ||
|
|
||||||
error.response?.data?.error?.message ||
|
|
||||||
"There was an error recording the give.";
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: errorMessage,
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for readability
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param result response "data" from the server
|
|
||||||
* @returns true if the result indicates an error
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
isGiveCreationError(result: any) {
|
|
||||||
return result.status !== 201 || result.data?.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
|
||||||
* @returns best guess at an error message
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
getGiveCreationErrorMessage(result: any) {
|
|
||||||
return (
|
|
||||||
result.error?.userMessage ||
|
|
||||||
result.error?.error ||
|
|
||||||
result.response?.data?.error?.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
explainData() {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "success",
|
|
||||||
title: "Data Sharing",
|
|
||||||
text: libsUtil.PRIVACY_MESSAGE,
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dialog-overlay {
|
|
||||||
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: 500px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="visible" class="dialog-overlay">
|
|
||||||
<div class="dialog">
|
|
||||||
<h1 class="text-xl font-bold text-center mb-4 relative">
|
|
||||||
Here's one:
|
|
||||||
<div
|
|
||||||
class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
|
|
||||||
@click="cancel"
|
|
||||||
>
|
|
||||||
<fa icon="xmark" class="w-[1em]"></fa>
|
|
||||||
</div>
|
|
||||||
</h1>
|
|
||||||
<span class="flex justify-between">
|
|
||||||
<span
|
|
||||||
class="rounded-l border border-slate-400 bg-slate-200 px-4 py-2 flex"
|
|
||||||
@click="prevIdea()"
|
|
||||||
>
|
|
||||||
<fa icon="chevron-left" class="m-auto" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="m-2">
|
|
||||||
<span v-if="currentIdeaIndex < IDEAS.length">
|
|
||||||
<p class="text-center text-lg font-bold">
|
|
||||||
{{ IDEAS[currentIdeaIndex] }}
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
<div v-if="currentIdeaIndex == IDEAS.length + 0">
|
|
||||||
<p class="text-center">
|
|
||||||
<span
|
|
||||||
v-if="currentContact == null"
|
|
||||||
class="text-orange-500 text-lg font-bold"
|
|
||||||
>
|
|
||||||
That's all your contacts.
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
<span class="text-lg font-bold">
|
|
||||||
Did {{ currentContact.name || AppString.NO_CONTACT_NAME }}
|
|
||||||
<br />
|
|
||||||
or someone near them do anything – maybe a while ago?
|
|
||||||
</span>
|
|
||||||
<span class="flex justify-between">
|
|
||||||
<span />
|
|
||||||
<button
|
|
||||||
class="text-center bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4"
|
|
||||||
@click="nextIdeaPastContacts()"
|
|
||||||
>
|
|
||||||
Skip Contacts <fa icon="forward" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2 flex"
|
|
||||||
@click="nextIdea()"
|
|
||||||
>
|
|
||||||
<fa icon="chevron-right" class="m-auto" />
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mt-4"
|
|
||||||
@click="cancel"
|
|
||||||
>
|
|
||||||
That's it!
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Vue, Component } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
import { AppString, NotificationIface } from "@/constants/app";
|
|
||||||
import { db } from "@/db/index";
|
|
||||||
import { Contact } from "@/db/tables/contacts";
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class GivenPrompts extends Vue {
|
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
||||||
|
|
||||||
IDEAS = [
|
|
||||||
"Did anyone fix food for you?",
|
|
||||||
"Did a family member do something for you?",
|
|
||||||
"Did anyone give you a compliment?",
|
|
||||||
"Who is someone you can always rely on, and how did they demonstrate that?",
|
|
||||||
"Did you see anyone give to someone else?",
|
|
||||||
"Is there someone who you have never met who has helped you somehow?",
|
|
||||||
"How did an artist or musician or author inspire you?",
|
|
||||||
"What inspiration did you get from someone who handled tragedy well?",
|
|
||||||
"Did some organization give something worth respect?",
|
|
||||||
"Who last gave you a good laugh?",
|
|
||||||
"Do you recall anything that was given to you while you were young?",
|
|
||||||
"Did someone forgive you or overlook a mistake?",
|
|
||||||
"Do you know of a way an ancestor contributed to your life?",
|
|
||||||
"Did anyone give you help at work?",
|
|
||||||
"How did a teacher or mentor or great example help you?",
|
|
||||||
];
|
|
||||||
OTHER_PROMPTS = 1;
|
|
||||||
CONTACT_PROMPT_INDEX = this.IDEAS.length; // expected after other prompts
|
|
||||||
|
|
||||||
currentContact: Contact | undefined = undefined;
|
|
||||||
currentIdeaIndex = 0;
|
|
||||||
numContacts = 0;
|
|
||||||
shownContactDbIndices: number[] = [];
|
|
||||||
visible = false;
|
|
||||||
|
|
||||||
AppString = AppString;
|
|
||||||
|
|
||||||
async open() {
|
|
||||||
this.visible = true;
|
|
||||||
|
|
||||||
await db.open();
|
|
||||||
this.numContacts = await db.contacts.count();
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
// close the dialog but don't change values (just in case some actions are added later)
|
|
||||||
this.visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the next idea.
|
|
||||||
* If it is a contact prompt, loop through.
|
|
||||||
*/
|
|
||||||
async nextIdea() {
|
|
||||||
// if we're incrementing to the contact prompt
|
|
||||||
// or if we're at the contact prompt and there was a previous contact...
|
|
||||||
if (
|
|
||||||
this.currentIdeaIndex == this.CONTACT_PROMPT_INDEX - 1 ||
|
|
||||||
(this.currentIdeaIndex == this.CONTACT_PROMPT_INDEX &&
|
|
||||||
this.shownContactDbIndices.length < this.numContacts)
|
|
||||||
) {
|
|
||||||
this.currentIdeaIndex = this.CONTACT_PROMPT_INDEX;
|
|
||||||
this.findNextUnshownContact();
|
|
||||||
} else {
|
|
||||||
// we're not at the contact prompt (or we ran out), so increment the idea index
|
|
||||||
this.currentIdeaIndex =
|
|
||||||
(this.currentIdeaIndex + 1) % (this.IDEAS.length + this.OTHER_PROMPTS);
|
|
||||||
// ... and clear out any other prompt info
|
|
||||||
this.currentContact = undefined;
|
|
||||||
this.shownContactDbIndices = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prevIdea() {
|
|
||||||
if (
|
|
||||||
this.currentIdeaIndex ==
|
|
||||||
(this.CONTACT_PROMPT_INDEX + 1) %
|
|
||||||
(this.IDEAS.length + this.OTHER_PROMPTS) ||
|
|
||||||
(this.currentIdeaIndex == this.CONTACT_PROMPT_INDEX &&
|
|
||||||
this.shownContactDbIndices.length < this.numContacts)
|
|
||||||
) {
|
|
||||||
this.currentIdeaIndex = this.CONTACT_PROMPT_INDEX;
|
|
||||||
this.findNextUnshownContact();
|
|
||||||
} else {
|
|
||||||
// we're not at the contact prompt (or we ran out), so increment the idea index
|
|
||||||
this.currentIdeaIndex--;
|
|
||||||
if (this.currentIdeaIndex < 0) {
|
|
||||||
this.currentIdeaIndex = this.IDEAS.length - 1 + this.OTHER_PROMPTS;
|
|
||||||
}
|
|
||||||
// ... and clear out any other prompt info
|
|
||||||
this.currentContact = undefined;
|
|
||||||
this.shownContactDbIndices = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nextIdeaPastContacts() {
|
|
||||||
this.currentIdeaIndex = 0;
|
|
||||||
this.currentContact = undefined;
|
|
||||||
this.shownContactDbIndices = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async findNextUnshownContact() {
|
|
||||||
// get a random contact
|
|
||||||
if (this.shownContactDbIndices.length === this.numContacts) {
|
|
||||||
// no more contacts to show
|
|
||||||
this.currentContact = undefined;
|
|
||||||
} else {
|
|
||||||
// get a random contact that hasn't been shown yet
|
|
||||||
let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
|
|
||||||
// and guarantee that one is found by walking past shown contacts
|
|
||||||
let shownContactIndex =
|
|
||||||
this.shownContactDbIndices.indexOf(someContactDbIndex);
|
|
||||||
while (shownContactIndex !== -1) {
|
|
||||||
// increment both indices until we find a spot where "shown" skips a spot
|
|
||||||
shownContactIndex = (shownContactIndex + 1) % this.numContacts;
|
|
||||||
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts;
|
|
||||||
if (
|
|
||||||
this.shownContactDbIndices[shownContactIndex] !== someContactDbIndex
|
|
||||||
) {
|
|
||||||
// we found a contact that hasn't been shown yet
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// continue
|
|
||||||
// ... and there must be at least one because shownContactDbIndices length < numContacts
|
|
||||||
}
|
|
||||||
this.shownContactDbIndices.push(someContactDbIndex);
|
|
||||||
this.shownContactDbIndices.sort();
|
|
||||||
|
|
||||||
// get the contact at that offset
|
|
||||||
await db.open();
|
|
||||||
this.currentContact = await db.contacts
|
|
||||||
.offset(someContactDbIndex)
|
|
||||||
.first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
this.currentContact = undefined;
|
|
||||||
this.currentIdeaIndex = 0;
|
|
||||||
this.numContacts = 0;
|
|
||||||
this.shownContactDbIndices = [];
|
|
||||||
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dialog-overlay {
|
|
||||||
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: 500px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
150
src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hello">
|
||||||
|
<h1>{{ msg }}</h1>
|
||||||
|
<p>
|
||||||
|
For a guide and recipes on how to configure / customize this project,<br />
|
||||||
|
check out the
|
||||||
|
<a href="https://cli.vuejs.org" target="_blank" rel="noopener"
|
||||||
|
>vue-cli documentation</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<h3>Installed CLI Plugins</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>babel</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>pwa</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>router</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>vuex</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>eslint</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>typescript</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Essential Links</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://forum.vuejs.org" target="_blank" rel="noopener"
|
||||||
|
>Forum</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://chat.vuejs.org" target="_blank" rel="noopener"
|
||||||
|
>Community Chat</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
|
||||||
|
>Twitter</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Ecosystem</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://router.vuejs.org" target="_blank" rel="noopener"
|
||||||
|
>vue-router</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/vuejs/vue-devtools#vue-devtools"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>vue-devtools</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
|
||||||
|
>vue-loader</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/vuejs/awesome-vue"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>awesome-vue</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Options, Vue } from "vue-class-component";
|
||||||
|
|
||||||
|
@Options({
|
||||||
|
props: {
|
||||||
|
msg: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class HelloWorld extends Vue {
|
||||||
|
msg!: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
h3 {
|
||||||
|
margin: 40px 0 0;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #42b983;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,177 +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"
|
|
||||||
>
|
|
||||||
Camera or Other?
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
|
||||||
@click="close()"
|
|
||||||
>
|
|
||||||
<fa icon="xmark" class="w-[1em]"></fa>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="text-center mt-8">
|
|
||||||
<div class>
|
|
||||||
<fa
|
|
||||||
icon="camera"
|
|
||||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
|
||||||
@click="openPhotoDialog()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<input type="file" @change="uploadImageFile" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<span class="mt-2">
|
|
||||||
... or paste a URL:
|
|
||||||
<input type="text" v-model="imageUrl" class="border-2" />
|
|
||||||
</span>
|
|
||||||
<span class="ml-2">
|
|
||||||
<fa
|
|
||||||
v-if="imageUrl"
|
|
||||||
icon="check"
|
|
||||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer"
|
|
||||||
@click="acceptUrl"
|
|
||||||
/>
|
|
||||||
<!-- so that there's no shifting when it becomes visible -->
|
|
||||||
<fa v-else icon="check" class="text-white bg-white px-2 py-2" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<PhotoDialog ref="photoDialog" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import axios from "axios";
|
|
||||||
import { ref } from "vue";
|
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
import PhotoDialog from "@/components/PhotoDialog.vue";
|
|
||||||
import { NotificationIface } from "@/constants/app";
|
|
||||||
|
|
||||||
const inputImageFileNameRef = ref<Blob>();
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
components: { PhotoDialog },
|
|
||||||
})
|
|
||||||
export default class ImageMethodDialog extends Vue {
|
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
||||||
|
|
||||||
claimType: string;
|
|
||||||
crop: boolean = false;
|
|
||||||
imageCallback: (imageUrl?: string) => void = () => {};
|
|
||||||
imageUrl?: string;
|
|
||||||
visible = false;
|
|
||||||
|
|
||||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
|
||||||
this.claimType = claimType;
|
|
||||||
this.crop = !!crop;
|
|
||||||
this.imageCallback = setImageFn;
|
|
||||||
|
|
||||||
this.visible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
openPhotoDialog(blob?: Blob, fileName?: string) {
|
|
||||||
this.visible = false;
|
|
||||||
|
|
||||||
(this.$refs.photoDialog as PhotoDialog).open(
|
|
||||||
this.imageCallback,
|
|
||||||
this.claimType,
|
|
||||||
this.crop,
|
|
||||||
blob,
|
|
||||||
fileName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadImageFile(event: Event) {
|
|
||||||
this.visible = false;
|
|
||||||
|
|
||||||
inputImageFileNameRef.value = event.target.files[0];
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/File
|
|
||||||
// ... plus it has a `type` property from my testing
|
|
||||||
const file = inputImageFileNameRef.value;
|
|
||||||
if (file != null) {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (e) => {
|
|
||||||
const data = e.target?.result as ArrayBuffer;
|
|
||||||
if (data) {
|
|
||||||
const blob = new Blob([new Uint8Array(data)], {
|
|
||||||
type: file.type,
|
|
||||||
});
|
|
||||||
this.openPhotoDialog(blob, file.name as string);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsArrayBuffer(file as Blob);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async acceptUrl() {
|
|
||||||
this.visible = false;
|
|
||||||
if (this.crop) {
|
|
||||||
try {
|
|
||||||
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
|
|
||||||
responseType: "blob", // This ensures the data is returned as a Blob
|
|
||||||
});
|
|
||||||
const fullUrl = new URL(this.imageUrl as string);
|
|
||||||
const fileName = fullUrl.pathname.split("/").pop() as string;
|
|
||||||
(this.$refs.photoDialog as PhotoDialog).open(
|
|
||||||
this.imageCallback,
|
|
||||||
this.claimType,
|
|
||||||
this.crop,
|
|
||||||
urlBlobResponse.data as Blob,
|
|
||||||
fileName,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "There was an error retrieving that image.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.imageCallback(this.imageUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.visible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dialog-overlay {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="scrollContainer">
|
|
||||||
<slot />
|
|
||||||
<div ref="sentinel" style="height: 1px"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Emit, Prop, Vue } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class InfiniteScroll extends Vue {
|
|
||||||
@Prop({ default: 200 })
|
|
||||||
readonly distance!: number;
|
|
||||||
private observer!: IntersectionObserver;
|
|
||||||
private isInitialRender = true;
|
|
||||||
|
|
||||||
updated() {
|
|
||||||
if (!this.observer) {
|
|
||||||
const options = {
|
|
||||||
root: null,
|
|
||||||
rootMargin: `0px 0px ${this.distance}px 0px`,
|
|
||||||
threshold: 1.0,
|
|
||||||
};
|
|
||||||
this.observer = new IntersectionObserver(
|
|
||||||
this.handleIntersection,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
this.observer.observe(this.$refs.sentinel as HTMLElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'beforeUnmount' hook runs before unmounting the component
|
|
||||||
beforeUnmount() {
|
|
||||||
if (this.observer) {
|
|
||||||
this.observer.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Emit("reached-bottom")
|
|
||||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
|
||||||
const entry = entries[0];
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="visible" class="dialog-overlay">
|
|
||||||
<div class="dialog">
|
|
||||||
<h1 class="text-xl font-bold text-center mb-4">Offer Help</h1>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
data-testId="inputDescription"
|
|
||||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
|
||||||
placeholder="Description, prerequisites, terms, etc."
|
|
||||||
v-model="description"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-row mt-2">
|
|
||||||
<span
|
|
||||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center text-blue-500 px-2 py-2"
|
|
||||||
@click="changeUnitCode()"
|
|
||||||
>
|
|
||||||
{{ libsUtil.UNIT_SHORT[amountUnitCode] }}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
|
||||||
@click="decrement()"
|
|
||||||
v-if="amountInput !== '0'"
|
|
||||||
>
|
|
||||||
<fa icon="chevron-left" />
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
data-testId="inputOfferAmount"
|
|
||||||
type="number"
|
|
||||||
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
|
||||||
v-model="amountInput"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="rounded-r border border-slate-400 bg-slate-200 px-4 py-2"
|
|
||||||
@click="increment()"
|
|
||||||
>
|
|
||||||
<fa icon="chevron-right" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex justify-center">
|
|
||||||
<span>
|
|
||||||
<router-link
|
|
||||||
:to="{
|
|
||||||
name: 'offer-details',
|
|
||||||
query: {
|
|
||||||
amountInput,
|
|
||||||
description,
|
|
||||||
offererDid: activeDid,
|
|
||||||
projectId,
|
|
||||||
projectName,
|
|
||||||
recipientDid,
|
|
||||||
recipientName,
|
|
||||||
unitCode: amountUnitCode,
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
class="text-blue-500"
|
|
||||||
>
|
|
||||||
Conditions & more options...
|
|
||||||
</router-link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-center mt-6 mb-2 italic">
|
|
||||||
Sign & Send to publish to the world
|
|
||||||
</p>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md"
|
|
||||||
@click="confirm"
|
|
||||||
>
|
|
||||||
Sign & Send
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
|
|
||||||
@click="cancel"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
import { NotificationIface } from "@/constants/app";
|
|
||||||
import { createAndSubmitOffer } from "@/libs/endorserServer";
|
|
||||||
import * as libsUtil from "@/libs/util";
|
|
||||||
import { db } from "@/db/index";
|
|
||||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class OfferDialog extends Vue {
|
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
||||||
|
|
||||||
@Prop projectId?;
|
|
||||||
@Prop projectName?;
|
|
||||||
|
|
||||||
activeDid = "";
|
|
||||||
apiServer = "";
|
|
||||||
|
|
||||||
amountInput = "0";
|
|
||||||
amountUnitCode = "HUR";
|
|
||||||
description = "";
|
|
||||||
expirationDateInput = "";
|
|
||||||
recipientDid? = "";
|
|
||||||
recipientName? = "";
|
|
||||||
visible = false;
|
|
||||||
|
|
||||||
libsUtil = libsUtil;
|
|
||||||
|
|
||||||
async open(recipientDid?: string, recipientName?: string) {
|
|
||||||
try {
|
|
||||||
this.recipientDid = recipientDid;
|
|
||||||
this.recipientName = recipientName;
|
|
||||||
|
|
||||||
await db.open();
|
|
||||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
|
||||||
this.apiServer = settings?.apiServer || "";
|
|
||||||
this.activeDid = settings?.activeDid || "";
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Error retrieving settings from database:", err);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: err.message || "There was an error retrieving your settings.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.visible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
// close the dialog but don't change values (since it might be submitting info)
|
|
||||||
this.visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
changeUnitCode() {
|
|
||||||
const units = Object.keys(this.libsUtil.UNIT_SHORT);
|
|
||||||
const index = units.indexOf(this.amountUnitCode);
|
|
||||||
this.amountUnitCode = units[(index + 1) % units.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
increment() {
|
|
||||||
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
decrement() {
|
|
||||||
this.amountInput = `${Math.max(
|
|
||||||
0,
|
|
||||||
(parseFloat(this.amountInput) || 1) - 1,
|
|
||||||
)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
this.close();
|
|
||||||
this.eraseValues();
|
|
||||||
}
|
|
||||||
|
|
||||||
eraseValues() {
|
|
||||||
this.description = "";
|
|
||||||
this.amountInput = "0";
|
|
||||||
this.amountUnitCode = "HUR";
|
|
||||||
}
|
|
||||||
|
|
||||||
async confirm() {
|
|
||||||
this.close();
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "toast",
|
|
||||||
text: "Recording the offer...",
|
|
||||||
title: "",
|
|
||||||
},
|
|
||||||
1000,
|
|
||||||
);
|
|
||||||
// this is asynchronous, but we don't need to wait for it to complete
|
|
||||||
this.recordOffer(
|
|
||||||
this.description,
|
|
||||||
parseFloat(this.amountInput),
|
|
||||||
this.amountUnitCode,
|
|
||||||
this.expirationDateInput,
|
|
||||||
).then(() => {
|
|
||||||
this.description = "";
|
|
||||||
this.amountInput = "0";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param description may be an empty string
|
|
||||||
* @param hours may be 0
|
|
||||||
* @param unitCode may be omitted, defaults to "HUR"
|
|
||||||
*/
|
|
||||||
public async recordOffer(
|
|
||||||
description: string,
|
|
||||||
amount: number,
|
|
||||||
unitCode: string = "HUR",
|
|
||||||
expirationDateInput?: string,
|
|
||||||
) {
|
|
||||||
if (!this.activeDid) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "You must select an identifier before you can record an offer.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!description && !amount) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`,
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await createAndSubmitOffer(
|
|
||||||
this.axios,
|
|
||||||
this.apiServer,
|
|
||||||
this.activeDid,
|
|
||||||
description,
|
|
||||||
amount,
|
|
||||||
unitCode,
|
|
||||||
expirationDateInput,
|
|
||||||
this.recipientDid,
|
|
||||||
this.projectId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
result.type === "error" ||
|
|
||||||
this.isOfferCreationError(result.response)
|
|
||||||
) {
|
|
||||||
const errorMessage = this.getOfferCreationErrorMessage(result);
|
|
||||||
console.error("Error with offer creation result:", result);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: errorMessage || "There was an error creating the offer.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "success",
|
|
||||||
title: "Success",
|
|
||||||
text: "That offer was recorded.",
|
|
||||||
},
|
|
||||||
10000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Error with offer recordation caught:", error);
|
|
||||||
const message =
|
|
||||||
error.userMessage ||
|
|
||||||
error.response?.data?.error?.message ||
|
|
||||||
"There was an error recording the offer.";
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: message,
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for readability
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param result response "data" from the server
|
|
||||||
* @returns true if the result indicates an error
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
isOfferCreationError(result: any) {
|
|
||||||
return result.status !== 201 || result.data?.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
|
|
||||||
* @returns best guess at an error message
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
getOfferCreationErrorMessage(result: any) {
|
|
||||||
return (
|
|
||||||
result.error?.userMessage ||
|
|
||||||
result.error?.error ||
|
|
||||||
result.response?.data?.error?.message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dialog-overlay {
|
|
||||||
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: 500px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,440 +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="uploading"> Uploading... </span>
|
|
||||||
<span v-else-if="blob"> Look Good? </span>
|
|
||||||
<span v-else> Say "Cheese"! </span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
|
||||||
@click="close()"
|
|
||||||
>
|
|
||||||
<fa icon="xmark" class="w-[1em]"></fa>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="uploading" class="flex justify-center">
|
|
||||||
<fa
|
|
||||||
icon="spinner"
|
|
||||||
class="fa-spin fa-3x text-center block px-12 py-12"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="blob">
|
|
||||||
<div v-if="crop">
|
|
||||||
<VuePictureCropper
|
|
||||||
:boxStyle="{
|
|
||||||
backgroundColor: '#f8f8f8',
|
|
||||||
margin: 'auto',
|
|
||||||
}"
|
|
||||||
:img="createBlobURL(blob)"
|
|
||||||
:options="{
|
|
||||||
viewMode: 1,
|
|
||||||
dragMode: 'crop',
|
|
||||||
aspectRatio: 9 / 9,
|
|
||||||
}"
|
|
||||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
|
||||||
/>
|
|
||||||
<!-- This gives a round cropper.
|
|
||||||
:presetMode="{
|
|
||||||
mode: 'round',
|
|
||||||
}"
|
|
||||||
-->
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<img
|
|
||||||
:src="createBlobURL(blob)"
|
|
||||||
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
|
|
||||||
<button
|
|
||||||
@click="uploadImage"
|
|
||||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
|
||||||
>
|
|
||||||
<span>Upload</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="showRetry"
|
|
||||||
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
@click="retryImage"
|
|
||||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
|
||||||
>
|
|
||||||
<span>Retry</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else ref="cameraContainer">
|
|
||||||
<!--
|
|
||||||
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
|
|
||||||
facingMode="environment"
|
|
||||||
autoplay
|
|
||||||
ref="camera"
|
|
||||||
@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
|
|
||||||
@click="takeImage()"
|
|
||||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
|
||||||
>
|
|
||||||
<fa icon="camera" class="w-[1em]"></fa>
|
|
||||||
</button>
|
|
||||||
</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
|
|
||||||
@click="swapMirrorClass()"
|
|
||||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
|
||||||
>
|
|
||||||
<fa icon="left-right" class="w-[1em]"></fa>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
|
|
||||||
<button
|
|
||||||
@click="switchCamera()"
|
|
||||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
|
||||||
>
|
|
||||||
<fa icon="rotate" class="w-[1em]"></fa>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</camera>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import axios from "axios";
|
|
||||||
import Camera from "simple-vue-camera";
|
|
||||||
import { Component, Vue } from "vue-facing-decorator";
|
|
||||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
|
||||||
|
|
||||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app";
|
|
||||||
import { db } from "@/db/index";
|
|
||||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
|
||||||
import { accessToken } from "@/libs/crypto";
|
|
||||||
|
|
||||||
@Component({ components: { Camera, VuePictureCropper } })
|
|
||||||
export default class PhotoDialog extends Vue {
|
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
||||||
|
|
||||||
activeDeviceNumber = 0;
|
|
||||||
activeDid = "";
|
|
||||||
blob?: Blob;
|
|
||||||
claimType = "";
|
|
||||||
crop = false;
|
|
||||||
fileName?: string;
|
|
||||||
mirror = false;
|
|
||||||
numDevices = 0;
|
|
||||||
setImageCallback: (arg: string) => void = () => {};
|
|
||||||
showRetry = true;
|
|
||||||
uploading = false;
|
|
||||||
visible = false;
|
|
||||||
|
|
||||||
URL = window.URL || window.webkitURL;
|
|
||||||
|
|
||||||
async mounted() {
|
|
||||||
try {
|
|
||||||
await db.open();
|
|
||||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
|
||||||
this.activeDid = settings?.activeDid || "";
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("Error retrieving settings from database:", err);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: err.message || "There was an error retrieving your settings.",
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open(
|
|
||||||
setImageFn: (arg: string) => void,
|
|
||||||
claimType: string,
|
|
||||||
crop?: boolean,
|
|
||||||
blob?: Blob, // for image upload, just to use the cropping function
|
|
||||||
inputFileName?: string,
|
|
||||||
) {
|
|
||||||
this.visible = true;
|
|
||||||
this.claimType = claimType;
|
|
||||||
this.crop = !!crop;
|
|
||||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
|
||||||
if (bottomNav) {
|
|
||||||
bottomNav.style.display = "none";
|
|
||||||
}
|
|
||||||
this.setImageCallback = setImageFn;
|
|
||||||
if (blob) {
|
|
||||||
this.blob = blob;
|
|
||||||
this.fileName = inputFileName;
|
|
||||||
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
|
|
||||||
this.showRetry = false;
|
|
||||||
} else {
|
|
||||||
this.blob = undefined;
|
|
||||||
this.fileName = undefined;
|
|
||||||
this.showRetry = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.visible = false;
|
|
||||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
|
||||||
if (bottomNav) {
|
|
||||||
bottomNav.style.display = "";
|
|
||||||
}
|
|
||||||
this.blob = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cameraStarted() {
|
|
||||||
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
|
||||||
if (cameraComponent) {
|
|
||||||
this.numDevices = (await cameraComponent.devices(["videoinput"])).length;
|
|
||||||
this.mirror = cameraComponent.facingMode === "user";
|
|
||||||
// figure out which device is active
|
|
||||||
const currentDeviceId = cameraComponent.currentDeviceID();
|
|
||||||
const devices = await cameraComponent.devices(["videoinput"]);
|
|
||||||
this.activeDeviceNumber = devices.findIndex(
|
|
||||||
(device) => device.deviceId === currentDeviceId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async switchCamera() {
|
|
||||||
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>;
|
|
||||||
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices;
|
|
||||||
const devices = await cameraComponent?.devices(["videoinput"]);
|
|
||||||
await cameraComponent?.changeCamera(
|
|
||||||
devices[this.activeDeviceNumber].deviceId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "There was an error taking the picture. Please try again.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private createBlobURL(blob: Blob): string {
|
|
||||||
return URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
|
|
||||||
async retryImage() {
|
|
||||||
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() {
|
|
||||||
this.uploading = true;
|
|
||||||
|
|
||||||
if (this.crop) {
|
|
||||||
this.blob = (await cropper?.getBlob()) || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await accessToken(this.activeDid);
|
|
||||||
const headers = {
|
|
||||||
Authorization: "Bearer " + token,
|
|
||||||
// axios fills in Content-Type of multipart/form-data
|
|
||||||
};
|
|
||||||
const formData = new FormData();
|
|
||||||
if (!this.blob) {
|
|
||||||
// yeah, this should never happen, but it helps with subsequent type checking
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "There was an error finding the picture. Please try again.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
this.uploading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
formData.append("image", this.blob, this.fileName || "snapshot.png");
|
|
||||||
formData.append("claimType", this.claimType);
|
|
||||||
try {
|
|
||||||
const response = await axios.post(
|
|
||||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
|
||||||
formData,
|
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
this.uploading = false;
|
|
||||||
|
|
||||||
this.close();
|
|
||||||
this.setImageCallback(response.data.url as string);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error uploading the image", error);
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error",
|
|
||||||
text: "There was an error saving the picture.",
|
|
||||||
},
|
|
||||||
5000,
|
|
||||||
);
|
|
||||||
this.uploading = false;
|
|
||||||
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.dialog-overlay {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a
|
|
||||||
v-if="linkToFull && imageUrl"
|
|
||||||
:href="imageUrl"
|
|
||||||
target="_blank"
|
|
||||||
class="h-full w-full object-contain"
|
|
||||||
>
|
|
||||||
<div v-html="generateIdenticon()" class="h-full w-full object-contain" />
|
|
||||||
</a>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
v-html="generateIdenticon()"
|
|
||||||
class="h-full w-full object-contain"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script lang="ts">
|
|
||||||
import { toSvg } from "jdenticon";
|
|
||||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
const BLANK_CONFIG = {
|
|
||||||
lightness: {
|
|
||||||
color: [1.0, 1.0],
|
|
||||||
grayscale: [1.0, 1.0],
|
|
||||||
},
|
|
||||||
saturation: {
|
|
||||||
color: 0.0,
|
|
||||||
grayscale: 0.0,
|
|
||||||
},
|
|
||||||
backColor: "#0000",
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class ProjectIcon extends Vue {
|
|
||||||
@Prop entityId = "";
|
|
||||||
@Prop iconSize = 0;
|
|
||||||
@Prop imageUrl = "";
|
|
||||||
@Prop linkToFull = false;
|
|
||||||
|
|
||||||
generateIdenticon() {
|
|
||||||
if (this.imageUrl) {
|
|
||||||
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
|
|
||||||
} else {
|
|
||||||
const config = this.entityId ? undefined : BLANK_CONFIG;
|
|
||||||
const svgString = toSvg(this.entityId, this.iconSize, config);
|
|
||||||
return svgString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- QUICK NAV -->
|
|
||||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
|
||||||
<ul class="flex text-2xl p-2 gap-2 max-w-3xl mx-auto">
|
|
||||||
<!-- Home Feed -->
|
|
||||||
<li
|
|
||||||
:class="{
|
|
||||||
'basis-1/5': true,
|
|
||||||
'rounded-md': true,
|
|
||||||
'bg-slate-400 text-white': selected === 'Home',
|
|
||||||
'text-slate-500': selected !== 'Home',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<router-link :to="{ name: 'home' }" class="block text-center py-3 px-1">
|
|
||||||
<fa icon="house-chimney" class="fa-fw" />
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
<!-- Search -->
|
|
||||||
<li
|
|
||||||
:class="{
|
|
||||||
'basis-1/5': true,
|
|
||||||
'rounded-md': true,
|
|
||||||
'bg-slate-400 text-white': selected === 'Discover',
|
|
||||||
'text-slate-500': selected !== 'Discover',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
:to="{ name: 'discover' }"
|
|
||||||
class="block text-center py-3 px-1"
|
|
||||||
>
|
|
||||||
<fa icon="magnifying-glass" class="fa-fw" />
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
<!-- Projects -->
|
|
||||||
<li
|
|
||||||
:class="{
|
|
||||||
'basis-1/5': true,
|
|
||||||
'rounded-md': true,
|
|
||||||
'bg-slate-400 text-white': selected === 'Projects',
|
|
||||||
'text-slate-500': selected !== 'Projects',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
:to="{ name: 'projects' }"
|
|
||||||
class="block text-center py-3 px-1"
|
|
||||||
>
|
|
||||||
<fa icon="hand" class="fa-fw" />
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
<!-- Contacts -->
|
|
||||||
<li
|
|
||||||
:class="{
|
|
||||||
'basis-1/5': true,
|
|
||||||
'rounded-md': true,
|
|
||||||
'bg-slate-400 text-white': selected === 'Contacts',
|
|
||||||
'text-slate-500': selected !== 'Contacts',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
:to="{ name: 'contacts' }"
|
|
||||||
class="block text-center py-3 px-1"
|
|
||||||
>
|
|
||||||
<fa icon="users" class="fa-fw" />
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
<!-- Profile -->
|
|
||||||
<li
|
|
||||||
:class="{
|
|
||||||
'basis-1/5': true,
|
|
||||||
'rounded-md': true,
|
|
||||||
'bg-slate-400 text-white': selected === 'Profile',
|
|
||||||
'text-slate-500': selected !== 'Profile',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<router-link
|
|
||||||
:to="{ name: 'account' }"
|
|
||||||
class="block text-center py-3 px-1"
|
|
||||||
>
|
|
||||||
<fa icon="circle-user" class="fa-fw" />
|
|
||||||
</router-link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class QuickNav extends Vue {
|
|
||||||
@Prop selected = "";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="absolute right-5 top-3">
|
|
||||||
<span class="align-center text-red-500 mr-2">{{ message }}</span>
|
|
||||||
<span class="ml-2">
|
|
||||||
<router-link
|
|
||||||
:to="{ name: 'help' }"
|
|
||||||
class="text-xs uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md ml-1"
|
|
||||||
>
|
|
||||||
Help
|
|
||||||
</router-link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
|
||||||
|
|
||||||
import { AppString, NotificationIface } from "@/constants/app";
|
|
||||||
import { db } from "@/db/index";
|
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class TopMessage extends Vue {
|
|
||||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
|
||||||
|
|
||||||
@Prop selected = "";
|
|
||||||
|
|
||||||
message = "";
|
|
||||||
|
|
||||||
async mounted() {
|
|
||||||
try {
|
|
||||||
await db.open();
|
|
||||||
|
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
||||||
if (
|
|
||||||
settings?.warnIfTestServer &&
|
|
||||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
|
||||||
) {
|
|
||||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
|
||||||
this.message = "You're linked to a non-prod server, user " + didPrefix;
|
|
||||||
} else if (
|
|
||||||
settings?.warnIfProdServer &&
|
|
||||||
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
|
|
||||||
) {
|
|
||||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
|
||||||
this.message =
|
|
||||||
"You're linked to the production server, user " + didPrefix;
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
this.$notify(
|
|
||||||
{
|
|
||||||
group: "alert",
|
|
||||||
type: "danger",
|
|
||||||
title: "Error Detecting Server",
|
|
||||||
text: JSON.stringify(err),
|
|
||||||
},
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// from https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80
|
|
||||||
|
|
||||||
import * as TWEEN from "@tweenjs/tween.js";
|
|
||||||
import * as THREE from "three";
|
|
||||||
|
|
||||||
import { createCamera } from "./components/camera.js";
|
|
||||||
import { createLights } from "./components/lights.js";
|
|
||||||
import { createScene } from "./components/scene.js";
|
|
||||||
import { loadLandmarks } from "./components/objects/landmarks.js";
|
|
||||||
import { createTerrain } from "./components/objects/terrain.js";
|
|
||||||
import { Loop } from "./systems/Loop.js";
|
|
||||||
import { Resizer } from "./systems/Resizer.js";
|
|
||||||
import { createControls } from "./systems/controls.js";
|
|
||||||
import { createRenderer } from "./systems/renderer.js";
|
|
||||||
|
|
||||||
const COLOR1 = "#dddddd";
|
|
||||||
const COLOR2 = "#0055aa";
|
|
||||||
|
|
||||||
class World {
|
|
||||||
constructor(container, vue) {
|
|
||||||
this.PLATFORM_BORDER = 5;
|
|
||||||
this.PLATFORM_EDGE_FOR_UNKNOWNS = 10;
|
|
||||||
this.PLATFORM_SIZE = 100; // note that the loadLandmarks calculations may still assume 100
|
|
||||||
|
|
||||||
this.update = this.update.bind(this);
|
|
||||||
|
|
||||||
this.vue = vue;
|
|
||||||
|
|
||||||
// Instances of camera, scene, and renderer
|
|
||||||
this.camera = createCamera();
|
|
||||||
this.scene = createScene(COLOR2);
|
|
||||||
this.renderer = createRenderer();
|
|
||||||
|
|
||||||
// necessary for models, says https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader
|
|
||||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
||||||
|
|
||||||
this.light = null;
|
|
||||||
this.lights = [];
|
|
||||||
this.bushes = [];
|
|
||||||
|
|
||||||
// Initialize Loop
|
|
||||||
this.loop = new Loop(this.camera, this.scene, this.renderer);
|
|
||||||
|
|
||||||
container.append(this.renderer.domElement);
|
|
||||||
|
|
||||||
// Orbit Controls
|
|
||||||
const controls = createControls(this.camera, this.renderer.domElement);
|
|
||||||
|
|
||||||
// Light Instance, with optional light helper
|
|
||||||
const { light } = createLights(COLOR1);
|
|
||||||
|
|
||||||
// Terrain Instance
|
|
||||||
const terrain = createTerrain({
|
|
||||||
color: COLOR1,
|
|
||||||
height: this.PLATFORM_SIZE + this.PLATFORM_BORDER * 2,
|
|
||||||
width:
|
|
||||||
this.PLATFORM_SIZE +
|
|
||||||
this.PLATFORM_BORDER * 2 +
|
|
||||||
this.PLATFORM_EDGE_FOR_UNKNOWNS * 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.loop.updatables.push(controls);
|
|
||||||
this.loop.updatables.push(light);
|
|
||||||
this.loop.updatables.push(terrain);
|
|
||||||
|
|
||||||
this.scene.add(light, terrain);
|
|
||||||
|
|
||||||
loadLandmarks(vue, this, this.scene, this.loop);
|
|
||||||
|
|
||||||
requestAnimationFrame(this.update);
|
|
||||||
|
|
||||||
// Responsive handler
|
|
||||||
const resizer = new Resizer(container, this.camera, this.renderer);
|
|
||||||
resizer.onResize = () => {
|
|
||||||
this.render();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
update(time) {
|
|
||||||
TWEEN.update(time);
|
|
||||||
this.lights.forEach((light) => {
|
|
||||||
light.updateMatrixWorld();
|
|
||||||
light.target.updateMatrixWorld();
|
|
||||||
});
|
|
||||||
this.lights.forEach((bush) => {
|
|
||||||
bush.updateMatrixWorld();
|
|
||||||
});
|
|
||||||
requestAnimationFrame(this.update);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
// draw a single frame
|
|
||||||
this.renderer.render(this.scene, this.camera);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animation handlers
|
|
||||||
start() {
|
|
||||||
this.loop.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.loop.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
setExposedWorldProperties(key, value) {
|
|
||||||
this.vue.setWorldProperty(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { World };
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { PerspectiveCamera } from "three";
|
|
||||||
|
|
||||||
function createCamera() {
|
|
||||||
const camera = new PerspectiveCamera(
|
|
||||||
35, // fov = Field Of View
|
|
||||||
1, // aspect ratio (dummy value)
|
|
||||||
0.1, // near clipping plane
|
|
||||||
350, // far clipping plane
|
|
||||||
);
|
|
||||||
|
|
||||||
// move the camera back so we can view the scene
|
|
||||||
camera.position.set(0, 100, 200);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
camera.tick = () => {};
|
|
||||||
|
|
||||||
return camera;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createCamera };
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { DirectionalLight, DirectionalLightHelper } from "three";
|
|
||||||
|
|
||||||
function createLights(color) {
|
|
||||||
const light = new DirectionalLight(color, 4);
|
|
||||||
const lightHelper = new DirectionalLightHelper(light, 0);
|
|
||||||
light.position.set(60, 100, 30);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
light.tick = () => {};
|
|
||||||
|
|
||||||
return { light, lightHelper };
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createLights };
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
|
|
||||||
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
|
|
||||||
import * as TWEEN from "@tweenjs/tween.js";
|
|
||||||
import { db } from "@/db";
|
|
||||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
|
||||||
import { getHeaders } from "@/libs/endorserServer";
|
|
||||||
|
|
||||||
const ANIMATION_DURATION_SECS = 10;
|
|
||||||
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
|
|
||||||
|
|
||||||
export async function loadLandmarks(vue, world, scene, loop) {
|
|
||||||
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.open();
|
|
||||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
|
||||||
const activeDid = settings?.activeDid || "";
|
|
||||||
const apiServer = settings?.apiServer;
|
|
||||||
const headers = await getHeaders(activeDid);
|
|
||||||
|
|
||||||
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction";
|
|
||||||
const resp = await axios.get(url, { headers: headers });
|
|
||||||
if (resp.status === 200) {
|
|
||||||
const landmarks = resp.data.data;
|
|
||||||
|
|
||||||
const minDate = landmarks[landmarks.length - 1].issuedAt;
|
|
||||||
const maxDate = landmarks[0].issuedAt;
|
|
||||||
|
|
||||||
world.setExposedWorldProperties("startTime", minDate.replace("T", " "));
|
|
||||||
world.setExposedWorldProperties("endTime", maxDate.replace("T", " "));
|
|
||||||
|
|
||||||
const minTimeMillis = new Date(minDate).getTime();
|
|
||||||
const fullTimeMillis =
|
|
||||||
maxDate > minDate ? new Date(maxDate).getTime() - minTimeMillis : 1; // avoid divide by zero
|
|
||||||
// ratio of animation time to real time
|
|
||||||
const fakeRealRatio = (ANIMATION_DURATION_SECS * 1000) / fullTimeMillis;
|
|
||||||
|
|
||||||
// load plant model first because it takes a second
|
|
||||||
const loader = new GLTFLoader();
|
|
||||||
// choose the right plant
|
|
||||||
const modelLoc = "/models/lupine_plant/scene.gltf", // push with pokies
|
|
||||||
modScale = 0.1;
|
|
||||||
//const modelLoc = "/models/round_bush/scene.gltf", // green & pink
|
|
||||||
// modScale = 1;
|
|
||||||
//const modelLoc = "/models/coreopsis-flower.glb", // 3 flowers
|
|
||||||
// modScale = 2;
|
|
||||||
//const modelLoc = "/models/a_bush/scene.gltf", // purple leaves
|
|
||||||
// modScale = 15;
|
|
||||||
|
|
||||||
// calculate positions for each claim, especially because some are random
|
|
||||||
const locations = landmarks.map((claim) =>
|
|
||||||
locForGive(
|
|
||||||
claim,
|
|
||||||
world.PLATFORM_SIZE,
|
|
||||||
world.PLATFORM_EDGE_FOR_UNKNOWNS,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
||||||
loader.load(
|
|
||||||
modelLoc,
|
|
||||||
function (gltf) {
|
|
||||||
gltf.scene.scale.set(0, 0, 0);
|
|
||||||
for (let i = 0; i < landmarks.length; i++) {
|
|
||||||
// claim is a GiveServerRecord (see endorserServer.ts)
|
|
||||||
const claim = landmarks[i];
|
|
||||||
const newPlant = SkeletonUtils.clone(gltf.scene);
|
|
||||||
|
|
||||||
const loc = locations[i];
|
|
||||||
newPlant.position.set(loc.x, 0, loc.z);
|
|
||||||
|
|
||||||
world.scene.add(newPlant);
|
|
||||||
const timeDelayMillis =
|
|
||||||
fakeRealRatio *
|
|
||||||
(new Date(claim.issuedAt).getTime() - minTimeMillis);
|
|
||||||
new TWEEN.Tween(newPlant.scale)
|
|
||||||
.delay(timeDelayMillis)
|
|
||||||
.to({ x: modScale, y: modScale, z: modScale }, 5000)
|
|
||||||
.start();
|
|
||||||
world.bushes = [...world.bushes, newPlant];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
function (error) {
|
|
||||||
console.error(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// calculate when lights shine on appearing claim area
|
|
||||||
for (let i = 0; i < landmarks.length; i++) {
|
|
||||||
// claim is a GiveServerRecord (see endorserServer.ts)
|
|
||||||
const claim = landmarks[i];
|
|
||||||
|
|
||||||
const loc = locations[i];
|
|
||||||
const light = createLight();
|
|
||||||
light.position.set(loc.x, 20, loc.z);
|
|
||||||
light.target.position.set(loc.x, 0, loc.z);
|
|
||||||
loop.updatables.push(light);
|
|
||||||
scene.add(light);
|
|
||||||
scene.add(light.target);
|
|
||||||
|
|
||||||
// now figure out the timing and shine a light
|
|
||||||
const timeDelayMillis =
|
|
||||||
fakeRealRatio * (new Date(claim.issuedAt).getTime() - minTimeMillis);
|
|
||||||
new TWEEN.Tween(light)
|
|
||||||
.delay(timeDelayMillis)
|
|
||||||
.to({ intensity: 100 }, 10)
|
|
||||||
.chain(
|
|
||||||
new TWEEN.Tween(light.position)
|
|
||||||
.to({ y: 5 }, 5000)
|
|
||||||
.onComplete(() => {
|
|
||||||
scene.remove(light);
|
|
||||||
light.dispose();
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.start();
|
|
||||||
world.lights = [...world.lights, light];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
"Got bad server response status & data of",
|
|
||||||
resp.status,
|
|
||||||
resp.data,
|
|
||||||
);
|
|
||||||
vue.setAlert(
|
|
||||||
"Error With Server",
|
|
||||||
"There was an error retrieving your claims from the server.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Got exception contacting server:", error);
|
|
||||||
vue.setAlert(
|
|
||||||
"Error With Server",
|
|
||||||
"There was a problem retrieving your claims from the server.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param giveClaim
|
|
||||||
* @returns {x:float, z:float} where -50 <= x & z < 50
|
|
||||||
*/
|
|
||||||
function locForGive(giveClaim, platformWidth, borderWidth) {
|
|
||||||
let loc;
|
|
||||||
if (giveClaim?.claim?.recipient?.identifier) {
|
|
||||||
// this is directly to a person
|
|
||||||
loc = locForEthrDid(giveClaim.claim.recipient.identifier);
|
|
||||||
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
|
|
||||||
} else if (giveClaim?.object?.isPartOf?.identifier) {
|
|
||||||
// this is probably to a project
|
|
||||||
const objId = giveClaim.object.isPartOf.identifier;
|
|
||||||
if (objId.startsWith(ENDORSER_ENTITY_PREFIX)) {
|
|
||||||
loc = locForUlid(objId.substring(ENDORSER_ENTITY_PREFIX.length));
|
|
||||||
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!loc) {
|
|
||||||
// it must be outside our known addresses so let's put it somewhere random on the side
|
|
||||||
const leftSide = Math.random() < 0.5;
|
|
||||||
loc = {
|
|
||||||
x: leftSide
|
|
||||||
? -platformWidth / 2 - borderWidth / 2
|
|
||||||
: platformWidth / 2 + borderWidth / 2,
|
|
||||||
z: Math.random() * platformWidth - platformWidth / 2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return loc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a deterministic x & z location based on the randomness of an ID.
|
|
||||||
*
|
|
||||||
* We'd like the location to fully map back to the original ID.
|
|
||||||
* This typically means we use half the ID for the x and half for the z.
|
|
||||||
*
|
|
||||||
* ... in this case: a ULID.
|
|
||||||
* We'll use the first half (13 characters) for the x coordinate and next 13 for the z.
|
|
||||||
* We recognize that this is only 3 characters = 15 bits = 32768 unique values
|
|
||||||
* for the random part for the first half. We also recognize that those random
|
|
||||||
* bits may be shared with previous ULIDs if they were generated in the same
|
|
||||||
* millisecond, and therefore much of the evenness of the distribution depends
|
|
||||||
* on the other dimension.
|
|
||||||
*
|
|
||||||
* Also: since the first 10 characters are time-based, we're going to reverse
|
|
||||||
* the order of the characters to make the randomness more evenly distributed.
|
|
||||||
* This is reversing the order of the 5-bit characters, not each of the bits.
|
|
||||||
* Also wik: the first characters of the second half might be the same as
|
|
||||||
* previous ULIDs if they were generated in the same millisecond. So it's
|
|
||||||
* best to have that last character be the most significant bit so that there
|
|
||||||
* is a more even distribution in that dimension.
|
|
||||||
*
|
|
||||||
* @param ulid
|
|
||||||
* @returns {x: float, z: float} where 0 <= x & z < 100
|
|
||||||
*/
|
|
||||||
function locForUlid(ulid) {
|
|
||||||
const xChars = ulid.substring(0, 13).split("").reverse().join("");
|
|
||||||
const zChars = ulid.substring(13, 26).split("").reverse().join("");
|
|
||||||
|
|
||||||
// from https://github.com/ulid/javascript/blob/5e9727b527aec5b841737c395a20085c4361e971/lib/index.ts#L21
|
|
||||||
const BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford's Base32
|
|
||||||
|
|
||||||
// We're currently only using 1024 possible x and z values
|
|
||||||
// because the display is pretty low-fidelity at this point.
|
|
||||||
const rawX = BASE32.indexOf(xChars[1]) * 32 + BASE32.indexOf(xChars[0]);
|
|
||||||
const rawZ = BASE32.indexOf(zChars[1]) * 32 + BASE32.indexOf(zChars[0]);
|
|
||||||
|
|
||||||
const x = (100 * rawX) / 1024;
|
|
||||||
const z = (100 * rawZ) / 1024;
|
|
||||||
return { x, z };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See locForUlid. Similar, but for ethr DIDs.
|
|
||||||
* @param did
|
|
||||||
* @returns {x: float, z: float} where 0 <= x & z < 100
|
|
||||||
*/
|
|
||||||
function locForEthrDid(did) {
|
|
||||||
// "did:ethr:0x..."
|
|
||||||
if (did.length < 51) {
|
|
||||||
return { x: 0, z: 0 };
|
|
||||||
} else {
|
|
||||||
const randomness = did.substring("did:ethr:0x".length);
|
|
||||||
// We'll use all the randomness for fully unique x & z values.
|
|
||||||
// But we'll only calculate this view with the first byte since our rendering resolution is low.
|
|
||||||
const xOff = parseInt(Number("0x" + randomness.substring(0, 2)), 10);
|
|
||||||
const x = (xOff * 100) / 256;
|
|
||||||
// ... and since we're reserving 20 bytes total for x, start z with character 20,
|
|
||||||
// again with one byte.
|
|
||||||
const zOff = parseInt(Number("0x" + randomness.substring(20, 22)), 10);
|
|
||||||
const z = (zOff * 100) / 256;
|
|
||||||
return { x, z };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLight() {
|
|
||||||
const light = new THREE.SpotLight(0xffffff, 0, 0, Math.PI / 8, 0.5, 0);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
light.tick = () => {};
|
|
||||||
return light;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { PlaneGeometry, MeshLambertMaterial, Mesh, TextureLoader } from "three";
|
|
||||||
|
|
||||||
export function createTerrain(props) {
|
|
||||||
const loader = new TextureLoader();
|
|
||||||
const height = loader.load("img/textures/leafy-autumn-forest-floor.jpg");
|
|
||||||
// w h
|
|
||||||
const geometry = new PlaneGeometry(props.width, props.height, 64, 64);
|
|
||||||
|
|
||||||
const material = new MeshLambertMaterial({
|
|
||||||
color: props.color,
|
|
||||||
flatShading: true,
|
|
||||||
map: height,
|
|
||||||
//displacementMap: height,
|
|
||||||
//displacementScale: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
const plane = new Mesh(geometry, material);
|
|
||||||
plane.position.set(0, 0, 0);
|
|
||||||
plane.rotation.x -= Math.PI * 0.5;
|
|
||||||
|
|
||||||
//Storing our original vertices position on a new attribute
|
|
||||||
plane.geometry.attributes.position.originalPosition =
|
|
||||||
plane.geometry.attributes.position.array;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
plane.tick = () => {};
|
|
||||||
|
|
||||||
return plane;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Color, Scene } from "three";
|
|
||||||
|
|
||||||
function createScene(color) {
|
|
||||||
const scene = new Scene();
|
|
||||||
|
|
||||||
scene.background = new Color(color);
|
|
||||||
//scene.fog = new Fog(color, 60, 90);
|
|
||||||
return scene;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createScene };
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Clock } from "three";
|
|
||||||
|
|
||||||
const clock = new Clock();
|
|
||||||
|
|
||||||
class Loop {
|
|
||||||
constructor(camera, scene, renderer) {
|
|
||||||
this.camera = camera;
|
|
||||||
this.scene = scene;
|
|
||||||
this.renderer = renderer;
|
|
||||||
this.updatables = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
this.renderer.setAnimationLoop(() => {
|
|
||||||
this.tick();
|
|
||||||
// render a frame
|
|
||||||
this.renderer.render(this.scene, this.camera);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.renderer.setAnimationLoop(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
tick() {
|
|
||||||
const delta = clock.getDelta();
|
|
||||||
for (const object of this.updatables) {
|
|
||||||
object.tick(delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Loop };
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
const setSize = (container, camera, renderer) => {
|
|
||||||
// These are great for full-screen, which adjusts to a window.
|
|
||||||
const height = window.innerHeight;
|
|
||||||
const width = window.innerWidth - 50;
|
|
||||||
// These are better for fitting in a container, which stays that size.
|
|
||||||
//const height = container.scrollHeight;
|
|
||||||
//const width = container.scrollWidth;
|
|
||||||
|
|
||||||
camera.aspect = width / height;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
|
|
||||||
renderer.setSize(width, height);
|
|
||||||
renderer.setPixelRatio(window.devicePixelRatio);
|
|
||||||
};
|
|
||||||
|
|
||||||
class Resizer {
|
|
||||||
constructor(container, camera, renderer) {
|
|
||||||
// set initial size on load
|
|
||||||
setSize(container, camera, renderer);
|
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
// set the size again if a resize occurs
|
|
||||||
setSize(container, camera, renderer);
|
|
||||||
// perform any custom actions
|
|
||||||
this.onResize();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
||||||
onResize() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Resizer };
|
|
||||||
38
src/components/World/systems/controls.js
vendored
@@ -1,38 +0,0 @@
|
|||||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
||||||
import { MathUtils } from "three";
|
|
||||||
|
|
||||||
function createControls(camera, canvas) {
|
|
||||||
const controls = new OrbitControls(camera, canvas);
|
|
||||||
|
|
||||||
//enable controls?
|
|
||||||
controls.enabled = true;
|
|
||||||
controls.autoRotate = false;
|
|
||||||
//controls.autoRotateSpeed = 0.2;
|
|
||||||
|
|
||||||
// control limits
|
|
||||||
// It's recommended to set some control boundaries,
|
|
||||||
// to prevent the user from clipping with the objects.
|
|
||||||
|
|
||||||
// y axis
|
|
||||||
controls.minPolarAngle = MathUtils.degToRad(40); // default
|
|
||||||
controls.maxPolarAngle = MathUtils.degToRad(75);
|
|
||||||
|
|
||||||
// x axis
|
|
||||||
// controls.minAzimuthAngle = ...
|
|
||||||
// controls.maxAzimuthAngle = ...
|
|
||||||
|
|
||||||
//smooth camera:
|
|
||||||
// remember to add to loop updatables to work
|
|
||||||
controls.enableDamping = true;
|
|
||||||
|
|
||||||
//controls.enableZoom = false;
|
|
||||||
controls.maxDistance = 250;
|
|
||||||
|
|
||||||
//controls.enablePan = false;
|
|
||||||
|
|
||||||
controls.tick = () => controls.update();
|
|
||||||
|
|
||||||
return controls;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createControls };
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { WebGLRenderer } from "three";
|
|
||||||
|
|
||||||
function createRenderer() {
|
|
||||||
const renderer = new WebGLRenderer({ antialias: true });
|
|
||||||
|
|
||||||
// turn on the physically correct lighting model
|
|
||||||
// (The browser complains: "THREE.WebGLRenderer: the property .physicallyCorrectLights has been removed. Set renderer.useLegacyLights instead." However, that changes the lighting in a way that doesn't look better.)
|
|
||||||
renderer.physicallyCorrectLights = true;
|
|
||||||
|
|
||||||
return renderer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createRenderer };
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generic strings that could be used throughout the app.
|
|
||||||
*
|
|
||||||
* See also ../libs/veramo/setup.ts
|
|
||||||
*/
|
|
||||||
export enum AppString {
|
|
||||||
// This is used in titles and verbiage inside the app.
|
|
||||||
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
|
||||||
APP_NAME = "Time Safari",
|
|
||||||
|
|
||||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
|
||||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
|
||||||
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
|
|
||||||
|
|
||||||
PROD_PUSH_SERVER = "https://timesafari.app",
|
|
||||||
TEST1_PUSH_SERVER = "https://test.timesafari.app",
|
|
||||||
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
|
|
||||||
|
|
||||||
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app",
|
|
||||||
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app",
|
|
||||||
LOCAL_IMAGE_API_SERVER = "http://localhost:3001",
|
|
||||||
|
|
||||||
NO_CONTACT_NAME = "(no name)",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_ENDORSER_API_SERVER =
|
|
||||||
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
|
|
||||||
AppString.TEST_ENDORSER_API_SERVER;
|
|
||||||
|
|
||||||
export const DEFAULT_IMAGE_API_SERVER =
|
|
||||||
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
|
|
||||||
AppString.TEST_IMAGE_API_SERVER;
|
|
||||||
|
|
||||||
export const DEFAULT_PUSH_SERVER =
|
|
||||||
window.location.protocol + "//" + window.location.host;
|
|
||||||
|
|
||||||
export const IMAGE_TYPE_PROFILE = "profile";
|
|
||||||
|
|
||||||
export const PASSKEYS_ENABLED =
|
|
||||||
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The possible values for "group" and "type" are in App.vue.
|
|
||||||
* From the notiwind package
|
|
||||||
*/
|
|
||||||
export interface NotificationIface {
|
|
||||||
group: string; // "alert" | "modal"
|
|
||||||
type: string; // "toast" | "info" | "success" | "warning" | "danger"
|
|
||||||
title: string;
|
|
||||||
text?: string;
|
|
||||||
noText?: string;
|
|
||||||
onCancel?: (stopAsking: boolean) => Promise<void>;
|
|
||||||
onNo?: (stopAsking: boolean) => Promise<void>;
|
|
||||||
onYes?: () => Promise<void>;
|
|
||||||
promptToStopAsking?: boolean;
|
|
||||||
yesText?: string;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import BaseDexie, { Table } from "dexie";
|
|
||||||
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
|
|
||||||
import { Account, AccountsSchema } from "./tables/accounts";
|
|
||||||
import { Contact, ContactSchema } from "./tables/contacts";
|
|
||||||
import { Log, LogSchema } from "./tables/logs";
|
|
||||||
import {
|
|
||||||
MASTER_SETTINGS_KEY,
|
|
||||||
Settings,
|
|
||||||
SettingsSchema,
|
|
||||||
} from "./tables/settings";
|
|
||||||
import { Temp, TempSchema } from "./tables/temp";
|
|
||||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
|
||||||
|
|
||||||
// Define types for tables that hold sensitive and non-sensitive data
|
|
||||||
type SensitiveTables = { accounts: Table<Account> };
|
|
||||||
type NonsensitiveTables = {
|
|
||||||
contacts: Table<Contact>;
|
|
||||||
logs: Table<Log>;
|
|
||||||
settings: Table<Settings>;
|
|
||||||
temp: Table<Temp>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
|
||||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
|
||||||
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
|
||||||
BaseDexie & T;
|
|
||||||
|
|
||||||
// Initialize Dexie databases for sensitive and non-sensitive data
|
|
||||||
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
|
||||||
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
|
||||||
|
|
||||||
// Manage the encryption key. If not present in localStorage, create and store it.
|
|
||||||
const secret =
|
|
||||||
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
|
|
||||||
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
|
|
||||||
|
|
||||||
// Apply encryption to the sensitive database using the secret key
|
|
||||||
encrypted(accountsDB, { secretKey: secret });
|
|
||||||
|
|
||||||
// Define the schemas for our databases
|
|
||||||
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
|
|
||||||
accountsDB.version(1).stores(AccountsSchema);
|
|
||||||
// v1 also had contacts & settings
|
|
||||||
// v2 added Log
|
|
||||||
db.version(2).stores({
|
|
||||||
...ContactSchema,
|
|
||||||
...LogSchema,
|
|
||||||
...SettingsSchema,
|
|
||||||
});
|
|
||||||
// v3 added Temp
|
|
||||||
db.version(3).stores(TempSchema);
|
|
||||||
|
|
||||||
// Event handler to initialize the non-sensitive database with default settings
|
|
||||||
db.on("populate", async () => {
|
|
||||||
await db.settings.add({
|
|
||||||
id: MASTER_SETTINGS_KEY,
|
|
||||||
apiServer: DEFAULT_ENDORSER_API_SERVER,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* Represents an account stored in the database.
|
|
||||||
*/
|
|
||||||
export type Account = {
|
|
||||||
/**
|
|
||||||
* Auto-generated ID by Dexie
|
|
||||||
*/
|
|
||||||
id?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The date the account was created
|
|
||||||
*/
|
|
||||||
dateCreated: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The derivation path for the account, if this is from a mnemonic
|
|
||||||
*/
|
|
||||||
derivationPath?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decentralized Identifier (DID) for the account
|
|
||||||
*/
|
|
||||||
did: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stringified JSON containing underlying key material, if generated from a mnemonic
|
|
||||||
* Based on the IIdentifier type from Veramo
|
|
||||||
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
|
|
||||||
*/
|
|
||||||
identity?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The mnemonic phrase for the account, if this is from a mnemonic
|
|
||||||
*/
|
|
||||||
mnemonic?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Webauthn credential ID in hex, if this is from a passkey
|
|
||||||
*/
|
|
||||||
passkeyCredIdHex?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The public key in hexadecimal format
|
|
||||||
*/
|
|
||||||
publicKeyHex: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schema for the accounts table in the database.
|
|
||||||
* Fields starting with a $ character are encrypted.
|
|
||||||
* @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon}
|
|
||||||
*/
|
|
||||||
export const AccountsSchema = {
|
|
||||||
accounts:
|
|
||||||
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export interface Contact {
|
|
||||||
did: string;
|
|
||||||
name?: string;
|
|
||||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
|
||||||
profileImageUrl?: string;
|
|
||||||
publicKeyBase64?: string;
|
|
||||||
seesMe?: boolean; // cached value of the server setting
|
|
||||||
registered?: boolean; // cached value of the server setting
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ContactSchema = {
|
|
||||||
contacts: "&did, name", // no need to key by other things
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export interface Log {
|
|
||||||
date: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LogSchema = {
|
|
||||||
// Currently keyed by "date" because A) today's log data is what we need so we append, and
|
|
||||||
// B) we don't want it to grow so we remove everything if this is the first entry today.
|
|
||||||
// See safari-notifications.js logMessage for the associated logic.
|
|
||||||
logs: "date", // definitely don't key by the potentially large message field
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* BoundingBox type describes the geographical bounding box coordinates.
|
|
||||||
*/
|
|
||||||
export type BoundingBox = {
|
|
||||||
eastLong: number; // Eastern longitude
|
|
||||||
maxLat: number; // Maximum (Northernmost) latitude
|
|
||||||
minLat: number; // Minimum (Southernmost) latitude
|
|
||||||
westLong: number; // Western longitude
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settings type encompasses user-specific configuration details.
|
|
||||||
*/
|
|
||||||
export type Settings = {
|
|
||||||
id: number; // Only one entry, keyed with MASTER_SETTINGS_KEY
|
|
||||||
|
|
||||||
activeDid?: string; // Active Decentralized ID
|
|
||||||
apiServer?: string; // API server URL
|
|
||||||
|
|
||||||
filterFeedByNearby?: boolean; // filter by nearby
|
|
||||||
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden
|
|
||||||
|
|
||||||
firstName?: string; // user's full name
|
|
||||||
hideRegisterPromptOnNewContact?: boolean;
|
|
||||||
isRegistered?: boolean;
|
|
||||||
lastName?: string; // deprecated - put all names in firstName
|
|
||||||
lastNotifiedClaimId?: string;
|
|
||||||
lastViewedClaimId?: string;
|
|
||||||
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
|
|
||||||
profileImageUrl?: string;
|
|
||||||
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
|
|
||||||
reminderOn?: boolean; // Toggle to enable or disable reminders
|
|
||||||
|
|
||||||
// Array of named search boxes defined by bounding boxes
|
|
||||||
searchBoxes?: Array<{
|
|
||||||
name: string;
|
|
||||||
bbox: BoundingBox;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
showContactGivesInline?: boolean; // Display contact inline or not
|
|
||||||
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
|
|
||||||
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
|
|
||||||
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
|
|
||||||
warnIfProdServer?: boolean; // Warn if using a production server
|
|
||||||
warnIfTestServer?: boolean; // Warn if using a testing server
|
|
||||||
webPushServer?: string; // Web Push server URL
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isAnyFeedFilterOn(settings: Settings): boolean {
|
|
||||||
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schema for the Settings table in the database.
|
|
||||||
*/
|
|
||||||
export const SettingsSchema = {
|
|
||||||
settings: "id",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constants.
|
|
||||||
*/
|
|
||||||
export const MASTER_SETTINGS_KEY = 1;
|
|
||||||
|
|
||||||
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
// for ephemeral uses, eg. passing a blob from the service worker to the main thread
|
|
||||||
|
|
||||||
export type Temp = {
|
|
||||||
id: string;
|
|
||||||
blob?: Blob; // deprecated because webkit (Safari) does not support Blob
|
|
||||||
blobB64?: string; // base64-encoded blob
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schema for the Temp table in the database.
|
|
||||||
*/
|
|
||||||
export const TempSchema = {
|
|
||||||
temp: "id",
|
|
||||||
};
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { IIdentifier } from "@veramo/core";
|
|
||||||
import { getRandomBytesSync } from "ethereum-cryptography/random";
|
|
||||||
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
|
|
||||||
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
|
|
||||||
import { HDNode } from "@ethersproject/hdnode";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createEndorserJwtForDid,
|
|
||||||
ENDORSER_JWT_URL_LOCATION,
|
|
||||||
} from "@/libs/endorserServer";
|
|
||||||
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
|
|
||||||
import { decodeEndorserJwt } from "@/libs/crypto/vc";
|
|
||||||
|
|
||||||
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
|
|
||||||
|
|
||||||
export const LOCAL_KMS_NAME = "local";
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* @param {string} address
|
|
||||||
* @param {string} publicHex
|
|
||||||
* @param {string} privateHex
|
|
||||||
* @param {string} derivationPath
|
|
||||||
* @return {*} {Omit<IIdentifier, 'provider'>}
|
|
||||||
*/
|
|
||||||
export const newIdentifier = (
|
|
||||||
address: string,
|
|
||||||
publicHex: string,
|
|
||||||
privateHex: string,
|
|
||||||
derivationPath: string,
|
|
||||||
): Omit<IIdentifier, keyof "provider"> => {
|
|
||||||
return {
|
|
||||||
did: DEFAULT_DID_PROVIDER_NAME + ":" + address,
|
|
||||||
keys: [
|
|
||||||
{
|
|
||||||
kid: publicHex,
|
|
||||||
kms: LOCAL_KMS_NAME,
|
|
||||||
meta: { derivationPath: derivationPath },
|
|
||||||
privateKeyHex: privateHex,
|
|
||||||
publicKeyHex: publicHex,
|
|
||||||
type: "Secp256k1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
provider: DEFAULT_DID_PROVIDER_NAME,
|
|
||||||
services: [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* @param {string} mnemonic
|
|
||||||
* @return {*} {[string, string, string, string]}
|
|
||||||
*/
|
|
||||||
export const deriveAddress = (
|
|
||||||
mnemonic: string,
|
|
||||||
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
|
|
||||||
): [string, string, string, string] => {
|
|
||||||
mnemonic = mnemonic.trim().toLowerCase();
|
|
||||||
|
|
||||||
const hdnode: HDNode = HDNode.fromMnemonic(mnemonic);
|
|
||||||
const rootNode: HDNode = hdnode.derivePath(derivationPath);
|
|
||||||
const privateHex = rootNode.privateKey.substring(2); // original starts with '0x'
|
|
||||||
const publicHex = rootNode.publicKey.substring(2); // original starts with '0x'
|
|
||||||
const address = rootNode.address;
|
|
||||||
|
|
||||||
return [address, privateHex, publicHex, derivationPath];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateRandomBytes = (numBytes: number): Uint8Array => {
|
|
||||||
return getRandomBytesSync(numBytes);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* @return {*} {string}
|
|
||||||
*/
|
|
||||||
export const generateSeed = (): string => {
|
|
||||||
const entropy: Uint8Array = getRandomBytesSync(32);
|
|
||||||
const mnemonic = entropyToMnemonic(entropy, wordlist);
|
|
||||||
|
|
||||||
return mnemonic;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve an access token, or "" if no DID is provided.
|
|
||||||
*
|
|
||||||
* @return {*}
|
|
||||||
*/
|
|
||||||
export const accessToken = async (did?: string) => {
|
|
||||||
if (did) {
|
|
||||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
||||||
const endEpoch = nowEpoch + 60; // add one minute
|
|
||||||
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
|
||||||
return createEndorserJwtForDid(did, tokenPayload);
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
@return results of uportJwtPayload:
|
|
||||||
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
|
|
||||||
|
|
||||||
Note that similar code is also contained in time-safari
|
|
||||||
*/
|
|
||||||
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
|
|
||||||
let jwtText = jwtUrlText;
|
|
||||||
const endorserContextLoc = jwtText.indexOf(ENDORSER_JWT_URL_LOCATION);
|
|
||||||
if (endorserContextLoc > -1) {
|
|
||||||
jwtText = jwtText.substring(
|
|
||||||
endorserContextLoc + ENDORSER_JWT_URL_LOCATION.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT format: { header, payload, signature, data }
|
|
||||||
const jwt = decodeEndorserJwt(jwtText);
|
|
||||||
|
|
||||||
return jwt.payload;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const nextDerivationPath = (origDerivPath: string) => {
|
|
||||||
let lastStr = origDerivPath.split("/").slice(-1)[0];
|
|
||||||
if (lastStr.endsWith("'")) {
|
|
||||||
lastStr = lastStr.slice(0, -1);
|
|
||||||
}
|
|
||||||
const lastNum = parseInt(lastStr, 10);
|
|
||||||
const newLastNum = lastNum + 1;
|
|
||||||
const newLastStr = newLastNum.toString() + (lastStr.endsWith("'") ? "'" : "");
|
|
||||||
const newDerivPath = origDerivPath
|
|
||||||
.split("/")
|
|
||||||
.slice(0, -1)
|
|
||||||
.concat([newLastStr])
|
|
||||||
.join("/");
|
|
||||||
return newDerivPath;
|
|
||||||
};
|
|
||||||