Compare commits
No commits in common. 'master' and 'vite-version' have entirely different histories.
master
...
vite-versi
@ -1,4 +0,0 @@ |
|||||
> 1% |
|
||||
last 2 versions |
|
||||
not dead |
|
||||
not ie 11 |
|
@ -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,6 +0,0 @@ |
|||||
# Only the variables that start with VITE_ are seen in the application import.meta.env in Vue. |
|
||||
VITE_APP_SERVER=https://timesafari.app |
|
||||
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 |
|
||||
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.timesafari.app |
|
@ -1,34 +0,0 @@ |
|||||
module.exports = { |
|
||||
root: true, |
|
||||
env: { |
|
||||
node: true, |
|
||||
es2022: true, |
|
||||
}, |
|
||||
extends: [ |
|
||||
"plugin:vue/vue3-essential", |
|
||||
"eslint:recommended", |
|
||||
"@vue/typescript/recommended", |
|
||||
"plugin:prettier/recommended", |
|
||||
], |
|
||||
// parserOptions: {
|
|
||||
// ecmaVersion: 2020,
|
|
||||
// },
|
|
||||
rules: { |
|
||||
"max-len": [ |
|
||||
"warn", |
|
||||
{ |
|
||||
code: 120, |
|
||||
ignoreComments: true, // why does this not make it allow comment of any length?
|
|
||||
ignorePattern: '^\\s*class="[^"]*"$', |
|
||||
ignoreStrings: true, |
|
||||
ignoreTemplateLiterals: true, |
|
||||
ignoreTrailingComments: true, |
|
||||
ignoreUrls: true, |
|
||||
}, |
|
||||
], |
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off", |
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", |
|
||||
// "prettier/prettier": ["warn", { printWidth: 120 }], // removes errors but adds thousands of warnings
|
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off", |
|
||||
}, |
|
||||
}; |
|
@ -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 |
|
@ -0,0 +1,3 @@ |
|||||
|
{ |
||||
|
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] |
||||
|
} |
@ -1,384 +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). |
|
||||
|
|
||||
|
|
||||
## [0.3.33] - 2024.11.07 - adb7b16ecf1343c39cba71a7d6bb0e7a973e1102 |
|
||||
### Fixed |
|
||||
- Affirm Delivery button on offer claim page didn't work. |
|
||||
- Plans were not showing by default on project page. |
|
||||
|
|
||||
|
|
||||
## [0.3.32] - 2024.11.06 - 9a3fa38a3fd28f977e06f0265fc39e635c9c5ccd |
|
||||
### Added |
|
||||
- Highlight new offers to user & to user's projects on the front page. |
|
||||
|
|
||||
|
|
||||
## [0.3.31] - 2024.10.25 - 07c02ab98a09d293dd90d9289a7872e7d681d296 |
|
||||
### Changed |
|
||||
- Onboarding messages about offers |
|
||||
|
|
||||
|
|
||||
## [0.3.30] |
|
||||
### Added |
|
||||
- Onboarding messages |
|
||||
|
|
||||
|
|
||||
## [0.3.29] - 2024.10.09 - babd3832bdfe0c40eaa3869de1b41399a51713c1 |
|
||||
### Added |
|
||||
- Invite for a contact to join immediately |
|
||||
### Changed |
|
||||
- Send signed data to nostr endpoints to verify public key ownership. |
|
||||
- Enhanced help & help onboarding. |
|
||||
### Changed in DB or environment |
|
||||
- Uses Endorser.ch version 4.1.1 |
|
||||
|
|
||||
|
|
||||
## [0.3.28] - 2024.09.30 - 84720b94049d29cc0ddd99c50cef2e7176130133 |
|
||||
### Added |
|
||||
- Posting to nostr apps Trustroots & TripHopping |
|
||||
- Display of providers on claim view page |
|
||||
### Changed |
|
||||
- Switched BVC-meeting-ending gift to be a gift from the group. |
|
||||
### Changed in DB or environment |
|
||||
- Requires Endorser.ch version 4.1.0 |
|
||||
|
|
||||
|
|
||||
## [0.3.27] - 2024.09.22 - ee23e6f005e47f5bd6f04d804599f6395371b0e4 |
|
||||
### Fixed |
|
||||
- Error loading BVC claims to confirm |
|
||||
- Really allow visibility of bulk-imported contacts |
|
||||
|
|
||||
|
|
||||
## [0.3.26] - 2024.09.16 - 8263ed2b29947b3ccc6f3133bbc9454c222bce28 |
|
||||
### Added |
|
||||
- Separate 'isRegistered' flag for each account |
|
||||
### Fixed |
|
||||
- Failure to assign offers to their project |
|
||||
- Alert when looking at one's own activity if not in contacts. |
|
||||
|
|
||||
|
|
||||
## [0.3.25] - 2024.08.30 - dcbe02d877aecb4cdef2643d90e6595d246a9f82 |
|
||||
### Added |
|
||||
- "Ideas" now jumps directly to giving prompt or contact list. |
|
||||
### Fixed |
|
||||
- Empty giver name on gifted-details view |
|
||||
- Previously visited project would show up on the giving-details page. |
|
||||
### Removed |
|
||||
- All unnecessary localStorage for project IDs |
|
||||
|
|
||||
|
|
||||
## [0.3.23] - 2024.08.30 |
|
||||
### Added |
|
||||
- Sections in Help for different kinds of users |
|
||||
- Discovery page parameters so that links with search text work |
|
||||
- Message when no projects are found |
|
||||
|
|
||||
|
|
||||
## [0.3.21] - 2024.08.24 - a7b89f4bb6da928d56daeffaae7741fa74cc80bf |
|
||||
### Added |
|
||||
- Send list of contacts to someone, and move individual contact actions to detail page. |
|
||||
- Prompt for name in pop-up, and send to different contact-sharing screens. |
|
||||
### 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. |
|
@ -1,242 +1,18 @@ |
|||||
# TimeSafari.app - Crowd-Funder for Time - PWA |
# Vue 3 + TypeScript + Vite |
||||
|
|
||||
[Time Safari](https://timesafari.org/) allows people to ease into collaboration: start with expressions of gratitude |
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more. |
||||
and expand to crowd-fund with time & money, then record and see the impact of contributions. |
|
||||
|
|
||||
## Roadmap |
## Recommended IDE Setup |
||||
|
|
||||
See [project.task.yaml](project.task.yaml) for current priorities. |
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). |
||||
(Numbers at the beginning of lines are estimated hours. See [taskyaml.org](https://taskyaml.org/) for details.) |
|
||||
|
|
||||
## Setup |
## Type Support For `.vue` Imports in TS |
||||
|
|
||||
We like pkgx: `sh <(curl https://pkgx.sh) +npm sh` |
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. |
||||
|
|
||||
``` |
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: |
||||
npm install |
|
||||
``` |
|
||||
|
|
||||
### Compile and hot-reloads for development |
1. Disable the built-in TypeScript Extension |
||||
``` |
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette |
||||
npm run dev |
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` |
||||
``` |
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. |
||||
|
|
||||
See the test locations for "IMAGE_API_SERVER" or "PARTNER_API_SERVER" below, or use http://localhost:3000 for local endorser.ch |
|
||||
|
|
||||
### Build the test & production app |
|
||||
``` |
|
||||
npm run serve |
|
||||
``` |
|
||||
|
|
||||
### Lint and fix files |
|
||||
``` |
|
||||
npm run lint |
|
||||
``` |
|
||||
|
|
||||
### Run all important tests |
|
||||
|
|
||||
... including automated UI tests (see below for details) |
|
||||
|
|
||||
``` |
|
||||
npm run test-all |
|
||||
``` |
|
||||
|
|
||||
### Compile and minify for test & production |
|
||||
|
|
||||
* If there are DB changes: before updating the test server, open browser(s) with current version to test DB migrations. |
|
||||
|
|
||||
* `npx prettier --write ./sw_scripts/` |
|
||||
|
|
||||
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`. |
|
||||
|
|
||||
* Commit everything (since the commit hash is used the app). |
|
||||
|
|
||||
* Put the commit hash in the changelog (which will help you remember to bump the version later). |
|
||||
|
|
||||
* Record what version is currently on production in docs. |
|
||||
|
|
||||
* 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_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build |
|
||||
``` |
|
||||
|
|
||||
* 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` |
|
||||
|
|
||||
* 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: |
|
||||
``` |
|
||||
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 |
|
||||
``` |
|
||||
|
|
||||
|
|
||||
To run a single test like above with the screenshots, use the following: |
|
||||
``` |
|
||||
npx playwright test -c playwright.config-local.ts --trace on test-playwright/40-add-contact.spec.ts |
|
||||
``` |
|
||||
|
|
||||
|
|
||||
|
|
||||
### 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/) |
|
||||
|
@ -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. |
|
||||
|
|
||||
![](images/01_infura-api-keys.png){ width=550px } |
|
||||
|
|
||||
- Go to the key detail page. Then click "MANAGE API KEY". |
|
||||
|
|
||||
![](images/02-infura-key-detail.png){ 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. |
|
||||
|
|
||||
![](images/03-infura-api-key-id.png){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. |
|
||||
|
|
||||
![](images/04-pwa-chrome-devtools.png){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. |
|
||||
|
|
||||
![](images/05-pwa-account-button.png){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. |
|
||||
|
|
||||
![](images/06-pwa-account-page.png){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. |
|
||||
|
|
||||
![](images/07-pwa-did-copied.png){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. |
|
||||
|
|
||||
![](images/08-endorser-sqlite-row-added.png){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." |
|
||||
|
|
||||
![](images/09-pwa-second-profile-first-open.png){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. |
|
||||
|
|
||||
![](images/10-pwa-second-user-did.png){width=350px} |
|
||||
|
|
||||
6. Copy the DID and put it in the text bar on the "Your Contacts" page for the first account |
|
||||
|
|
||||
![](images/11-pwa-first-user-add-contact.png){width=350px} |
|
||||
|
|
||||
7. Click the "+" plus icon to add the user. |
|
||||
|
|
||||
![](images/12-pwa-first-user-contact-added.png){width=350px} |
|
||||
|
|
||||
8. Then click the register button to register the second user. |
|
||||
|
|
||||
![](images/13-pwa-first-user-register-second-user-btn.png){width=350px} |
|
||||
|
|
||||
9. Click "YES" on the dialog that shows up. |
|
||||
|
|
||||
![](images/14-pwa-first-user-register-yes.png){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. |
|
@ -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 |
|
@ -1,107 +1,20 @@ |
|||||
{ |
{ |
||||
"name": "TimeSafari", |
"name": "timesafari", |
||||
"version": "0.3.34-beta", |
"private": true, |
||||
|
"version": "0.0.0", |
||||
|
"type": "module", |
||||
"scripts": { |
"scripts": { |
||||
"dev": "vite", |
"dev": "vite", |
||||
"serve": "vite preview", |
"build": "vue-tsc && vite build", |
||||
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build", |
"preview": "vite preview" |
||||
"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": { |
||||
"@capacitor/android": "^6.1.2", |
"vue": "^3.3.11" |
||||
"@capacitor/cli": "^6.1.2", |
|
||||
"@capacitor/core": "^6.1.2", |
|
||||
"@capacitor/ios": "^6.1.2", |
|
||||
"@dicebear/collection": "^5.4.1", |
|
||||
"@dicebear/core": "^5.4.1", |
|
||||
"@ethersproject/hdnode": "^5.7.0", |
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.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", |
|
||||
"did-resolver": "^4.1.0", |
|
||||
"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", |
|
||||
"nostr-tools": "^2.7.2", |
|
||||
"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", |
|
||||
"simple-vue-camera": "^1.1.3", |
|
||||
"three": "^0.156.1", |
|
||||
"ua-parser-js": "^1.0.37", |
|
||||
"util": "^0.12.5", |
|
||||
"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", |
"@vitejs/plugin-vue": "^4.5.2", |
||||
"@types/js-yaml": "^4.0.9", |
"typescript": "^5.2.2", |
||||
"@types/leaflet": "^1.9.8", |
"vite": "^5.0.8", |
||||
"@types/luxon": "^3.4.2", |
"vue-tsc": "^1.8.25" |
||||
"@types/node": "^20.14.11", |
|
||||
"@types/ramda": "^0.29.11", |
|
||||
"@types/three": "^0.155.1", |
|
||||
"@types/ua-parser-js": "^0.7.39", |
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0", |
|
||||
"@typescript-eslint/parser": "^6.21.0", |
|
||||
"@vitejs/plugin-vue": "^5.0.4", |
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1", |
|
||||
"@vue/eslint-config-typescript": "^11.0.3", |
|
||||
"autoprefixer": "^10.4.19", |
|
||||
"eslint": "^8.57.0", |
|
||||
"eslint-config-prettier": "^9.1.0", |
|
||||
"eslint-plugin-prettier": "^5.1.3", |
|
||||
"eslint-plugin-vue": "^9.23.0", |
|
||||
"leaflet": "^1.9.4", |
|
||||
"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: 10000,
|
|
||||
|
|
||||
/* 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_APP_SERVER=http://localhost:8080 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_PASSKEYS_ENABLED=true 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,6 +0,0 @@ |
|||||
module.exports = { |
|
||||
plugins: { |
|
||||
tailwindcss: {}, |
|
||||
autoprefixer: {}, |
|
||||
}, |
|
||||
}; |
|
@ -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 |
Before Width: | Height: | Size: 78 KiB |
Before Width: | Height: | Size: 463 KiB |
Before Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 150 KiB |
Before Width: | Height: | Size: 33 KiB |
Before Width: | Height: | Size: 51 KiB |
Before Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 9.7 KiB |
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 7.3 KiB |
Before Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 705 KiB |
@ -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 |
@ -1,2 +0,0 @@ |
|||||
User-agent: * |
|
||||
Disallow: |
|
After Width: | Height: | Size: 1.5 KiB |
@ -1,404 +1,30 @@ |
|||||
<template> |
<script setup lang="ts"> |
||||
<router-view /> |
import HelloWorld from './components/HelloWorld.vue' |
||||
|
</script> |
||||
<!-- 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 for next time they open this modal |
|
||||
" |
|
||||
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-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 Day |
|
||||
</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 2 Days |
|
||||
</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 1 Week |
|
||||
</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 |
<template> |
||||
@click=" |
<div> |
||||
close(notification.id); |
<a href="https://vitejs.dev" target="_blank"> |
||||
turnOffNotifications(notification); |
<img src="/vite.svg" class="logo" alt="Vite logo" /> |
||||
" |
</a> |
||||
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2" |
<a href="https://vuejs.org/" target="_blank"> |
||||
> |
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" /> |
||||
Turn Off Notifications |
</a> |
||||
</button> |
</div> |
||||
<button |
<HelloWorld msg="Vite + Vue" /> |
||||
@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 scoped> |
||||
|
.logo { |
||||
<script lang="ts"> |
height: 6em; |
||||
import { Vue, Component } from "vue-facing-decorator"; |
padding: 1.5em; |
||||
|
will-change: filter; |
||||
import { db, logConsoleAndDb } from "@/db/index"; |
transition: filter 300ms; |
||||
import { NotificationIface } from "./constants/app"; |
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
|
||||
|
|
||||
@Component |
|
||||
export default class App extends Vue { |
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|
||||
|
|
||||
stopAsking = false; |
|
||||
|
|
||||
async turnOffNotifications(notification: NotificationIface) { |
|
||||
let subscription; |
|
||||
const pushProviderSuccess: boolean = await navigator.serviceWorker?.ready |
|
||||
.then((registration) => { |
|
||||
return registration.pushManager.getSubscription(); |
|
||||
}) |
|
||||
.then((subscript: PushSubscription | null) => { |
|
||||
subscription = subscript; |
|
||||
if (subscript) { |
|
||||
return subscript.unsubscribe(); |
|
||||
} else { |
|
||||
logConsoleAndDb("Subscription object is not available."); |
|
||||
return false; |
|
||||
} |
|
||||
}) |
|
||||
.catch((error) => { |
|
||||
logConsoleAndDb( |
|
||||
"Push provider server communication failed: " + JSON.stringify(error), |
|
||||
true, |
|
||||
); |
|
||||
return false; |
|
||||
}); |
|
||||
|
|
||||
const pushServerSuccess: boolean = await fetch("/web-push/unsubscribe", { |
|
||||
method: "POST", |
|
||||
headers: { |
|
||||
"Content-Type": "application/json", |
|
||||
}, |
|
||||
body: JSON.stringify(subscription), |
|
||||
}) |
|
||||
.then((response) => { |
|
||||
return response.ok; |
|
||||
}) |
|
||||
.catch((error) => { |
|
||||
logConsoleAndDb( |
|
||||
"Push server communication failed: " + JSON.stringify(error), |
|
||||
true, |
|
||||
); |
|
||||
return false; |
|
||||
}); |
|
||||
|
|
||||
let message; |
|
||||
if (pushProviderSuccess === pushServerSuccess) { |
|
||||
message = "Both local and server notifications "; |
|
||||
if (pushProviderSuccess) { |
|
||||
message += "are off."; |
|
||||
} else { |
|
||||
message += "failed to turn off."; |
|
||||
} |
|
||||
} else { |
|
||||
message = |
|
||||
"Local unsubscribe " + |
|
||||
(pushProviderSuccess ? "succeeded" : "failed") + |
|
||||
" but server unsubscribe " + |
|
||||
(pushServerSuccess ? "succeeded" : "failed") + |
|
||||
"."; |
|
||||
} |
|
||||
this.$notify({ |
|
||||
group: "alert", |
|
||||
type: "info", |
|
||||
title: "Finished", |
|
||||
text: message, |
|
||||
}); |
|
||||
|
|
||||
await db.open(); |
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, { |
|
||||
notifyingNewActivity: false, |
|
||||
}); |
|
||||
if (notification.callback) { |
|
||||
notification.callback(pushProviderSuccess && pushServerSuccess); |
|
||||
} |
|
||||
} |
|
||||
} |
} |
||||
</script> |
.logo:hover { |
||||
|
filter: drop-shadow(0 0 2em #646cffaa); |
||||
|
} |
||||
|
.logo.vue:hover { |
||||
|
filter: drop-shadow(0 0 2em #42b883aa); |
||||
|
} |
||||
|
</style> |
||||
|
Before Width: | Height: | Size: 145 B |
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 365 B |
Before Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 2.5 KiB |
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 |
@ -1,17 +0,0 @@ |
|||||
@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 components; |
|
||||
@tailwind utilities; |
|
||||
|
|
||||
@layer base { |
|
||||
html { |
|
||||
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@layer components { |
|
||||
input:checked ~ .dot { |
|
||||
transform: translateX(100%); |
|
||||
background-color: #FFF !important; |
|
||||
} |
|
||||
} |
|
After Width: | Height: | Size: 496 B |
@ -1,99 +0,0 @@ |
|||||
<!-- similar to UserNameDialog --> |
|
||||
<template> |
|
||||
<div v-if="visible" class="dialog-overlay"> |
|
||||
<div class="dialog"> |
|
||||
<h1 class="text-xl font-bold text-center mb-4">{{ title }}</h1> |
|
||||
{{ message }} |
|
||||
Note that their name is only stored on this device. |
|
||||
<input |
|
||||
type="text" |
|
||||
placeholder="Name" |
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" |
|
||||
v-model="newText" |
|
||||
/> |
|
||||
|
|
||||
<div class="mt-8"> |
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
|
||||
<button |
|
||||
type="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 mb-2" |
|
||||
@click="onClickSaveChanges()" |
|
||||
> |
|
||||
Save |
|
||||
</button> |
|
||||
<button |
|
||||
type="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-2 py-3 rounded-md mb-2" |
|
||||
@click="onClickCancel()" |
|
||||
> |
|
||||
Cancel |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</template> |
|
||||
|
|
||||
<script lang="ts"> |
|
||||
import { Vue, Component } from "vue-facing-decorator"; |
|
||||
|
|
||||
@Component |
|
||||
export default class ContactNameDialog extends Vue { |
|
||||
cancelCallback: () => void = () => {}; |
|
||||
saveCallback: (name?: string) => void = () => {}; |
|
||||
message = ""; |
|
||||
newText = ""; |
|
||||
title = "Contact Name"; |
|
||||
visible = false; |
|
||||
|
|
||||
async open( |
|
||||
title?: string, |
|
||||
message?: string, |
|
||||
saveCallback?: (name?: string) => void, |
|
||||
cancelCallback?: () => void, |
|
||||
) { |
|
||||
this.cancelCallback = cancelCallback || this.cancelCallback; |
|
||||
this.saveCallback = saveCallback || this.saveCallback; |
|
||||
this.message = message ?? this.message; |
|
||||
this.title = title ?? this.title; |
|
||||
this.visible = true; |
|
||||
} |
|
||||
|
|
||||
async onClickSaveChanges() { |
|
||||
this.visible = false; |
|
||||
if (this.saveCallback) { |
|
||||
this.saveCallback(this.newText); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
onClickCancel() { |
|
||||
this.visible = false; |
|
||||
if (this.cancelCallback) { |
|
||||
this.cancelCallback(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
</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,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,218 +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, retrieveSettingsForActiveAccount } 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; |
|
||||
|
|
||||
const settings = await retrieveSettingsForActiveAccount(); |
|
||||
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,409 +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="prompt || '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, |
|
||||
fulfillsProjectId: 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 } from "@/libs/endorserServer"; |
|
||||
import * as libsUtil from "@/libs/util"; |
|
||||
import { accountsDB, db, retrieveSettingsForActiveAccount } from "@/db/index"; |
|
||||
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?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent |
|
||||
isTrade = false; |
|
||||
offerId = ""; |
|
||||
prompt = ""; |
|
||||
receiver?: libsUtil.GiverReceiverInputInfo; |
|
||||
unitCode = "HUR"; |
|
||||
visible = false; |
|
||||
|
|
||||
libsUtil = libsUtil; |
|
||||
|
|
||||
async open( |
|
||||
giver?: libsUtil.GiverReceiverInputInfo, |
|
||||
receiver?: libsUtil.GiverReceiverInputInfo, |
|
||||
offerId?: string, |
|
||||
customTitle?: string, |
|
||||
prompt?: string, |
|
||||
callbackOnSuccess?: (amount: number) => void, |
|
||||
) { |
|
||||
this.customTitle = customTitle; |
|
||||
this.giver = giver; |
|
||||
this.prompt = prompt || ""; |
|
||||
this.receiver = receiver; |
|
||||
// if we show "given to user" selection, default checkbox to true |
|
||||
this.amountInput = "0"; |
|
||||
this.callbackOnSuccess = callbackOnSuccess; |
|
||||
this.offerId = offerId || ""; |
|
||||
|
|
||||
try { |
|
||||
const settings = await retrieveSettingsForActiveAccount(); |
|
||||
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.prompt = ""; |
|
||||
this.unitCode = "HUR"; |
|
||||
} |
|
||||
|
|
||||
async confirm() { |
|
||||
if (!this.activeDid) { |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "danger", |
|
||||
title: "Error", |
|
||||
text: "You must select an identifier before you can record a give.", |
|
||||
}, |
|
||||
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,260 +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="currentCategory === CATEGORY_IDEAS"> |
|
||||
<p class="text-center text-lg font-bold"> |
|
||||
{{ IDEAS[currentIdeaIndex] }} |
|
||||
</p> |
|
||||
</span> |
|
||||
<div v-if="currentCategory === CATEGORY_CONTACTS"> |
|
||||
<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="proceed" |
|
||||
> |
|
||||
That's it! |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</template> |
|
||||
|
|
||||
<script lang="ts"> |
|
||||
import { Vue, Component } from "vue-facing-decorator"; |
|
||||
import { Router } from "vue-router"; |
|
||||
|
|
||||
import { AppString, NotificationIface } from "@/constants/app"; |
|
||||
import { db } from "@/db/index"; |
|
||||
import { Contact } from "@/db/tables/contacts"; |
|
||||
import { GiverReceiverInputInfo } from "@/libs/util"; |
|
||||
|
|
||||
@Component |
|
||||
export default class GivenPrompts extends Vue { |
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|
||||
|
|
||||
CATEGORY_CONTACTS = 1; |
|
||||
CATEGORY_IDEAS = 0; |
|
||||
IDEAS = [ |
|
||||
"What food did someone fix for you?", |
|
||||
"What did a family member do for you?", |
|
||||
"What compliment did someone give you?", |
|
||||
"Who is someone you can always rely on, and how did they demonstrate that?", |
|
||||
"What did you see someone give to someone else?", |
|
||||
"What is a way that someone helped you even though you have never met?", |
|
||||
"How did a musician or author or artist inspire you?", |
|
||||
"What inspiration did you get from someone who handled tragedy well?", |
|
||||
"What is something worth respect that an organization gave you?", |
|
||||
"Who last gave you a good laugh?", |
|
||||
"What do you recall someone giving you while you were young?", |
|
||||
"Who forgave you or overlooked a mistake?", |
|
||||
"What is a way an ancestor contributed to your life?", |
|
||||
"What kind of help did someone at work give you?", |
|
||||
"How did a teacher or mentor or great example help you?", |
|
||||
]; |
|
||||
|
|
||||
callbackOnFullGiftInfo?: ( |
|
||||
contactInfo?: GiverReceiverInputInfo, |
|
||||
description?: string, |
|
||||
) => void; |
|
||||
currentCategory = this.CATEGORY_IDEAS; // 0 = IDEAS, 1 = CONTACTS |
|
||||
currentContact: Contact | undefined = undefined; |
|
||||
currentIdeaIndex = 0; |
|
||||
numContacts = 0; |
|
||||
shownContactDbIndices: Array<boolean> = []; |
|
||||
visible = false; |
|
||||
|
|
||||
AppString = AppString; |
|
||||
|
|
||||
async open( |
|
||||
callbackOnFullGiftInfo: ( |
|
||||
contactInfo: GiverReceiverInputInfo, |
|
||||
description: string, |
|
||||
) => void, |
|
||||
) { |
|
||||
this.visible = true; |
|
||||
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo; |
|
||||
|
|
||||
await db.open(); |
|
||||
this.numContacts = await db.contacts.count(); |
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start |
|
||||
} |
|
||||
|
|
||||
cancel() { |
|
||||
this.currentCategory = this.CATEGORY_IDEAS; |
|
||||
this.currentContact = undefined; |
|
||||
this.currentIdeaIndex = 0; |
|
||||
this.numContacts = 0; |
|
||||
this.shownContactDbIndices = []; |
|
||||
|
|
||||
this.visible = false; |
|
||||
} |
|
||||
|
|
||||
proceed() { |
|
||||
// proceed with logic but don't change values (just in case some actions are added later) |
|
||||
this.visible = false; |
|
||||
if (this.currentCategory === this.CATEGORY_IDEAS) { |
|
||||
(this.$router as Router).push({ |
|
||||
name: "contact-gift", |
|
||||
query: { |
|
||||
prompt: this.IDEAS[this.currentIdeaIndex], |
|
||||
}, |
|
||||
}); |
|
||||
} else { |
|
||||
// must be this.CATEGORY_CONTACTS |
|
||||
this.callbackOnFullGiftInfo?.( |
|
||||
this.currentContact as GiverReceiverInputInfo, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Get the next idea. |
|
||||
* If it is a contact prompt, loop through. |
|
||||
*/ |
|
||||
async nextIdea() { |
|
||||
// check if the next one is an idea or a contact |
|
||||
if (this.currentCategory === this.CATEGORY_IDEAS) { |
|
||||
this.currentIdeaIndex++; |
|
||||
if (this.currentIdeaIndex === this.IDEAS.length) { |
|
||||
// must have just finished ideas so move to contacts |
|
||||
this.findNextUnshownContact(); |
|
||||
} |
|
||||
} else { |
|
||||
// must be this.CATEGORY_CONTACTS |
|
||||
this.findNextUnshownContact(); |
|
||||
// when that's finished, it'll reset to ideas |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/** |
|
||||
* Get the previous idea. |
|
||||
* If it is a contact prompt, loop through. |
|
||||
*/ |
|
||||
async prevIdea() { |
|
||||
// check if the next one is an idea or a contact |
|
||||
if (this.currentCategory === this.CATEGORY_IDEAS) { |
|
||||
this.currentIdeaIndex--; |
|
||||
if (this.currentIdeaIndex < 0) { |
|
||||
// must have just finished ideas so move to contacts |
|
||||
this.findNextUnshownContact(); |
|
||||
} |
|
||||
} else { |
|
||||
// must be this.CATEGORY_CONTACTS |
|
||||
this.findNextUnshownContact(); |
|
||||
// when that's finished, it'll reset to ideas |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
nextIdeaPastContacts() { |
|
||||
this.currentContact = undefined; |
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts); |
|
||||
|
|
||||
this.currentCategory = this.CATEGORY_IDEAS; |
|
||||
// look at the previous idea and switch to the other side of the list |
|
||||
this.currentIdeaIndex = |
|
||||
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1; |
|
||||
} |
|
||||
|
|
||||
async findNextUnshownContact() { |
|
||||
if (this.currentCategory === this.CATEGORY_IDEAS) { |
|
||||
// we're not in the contact prompts, so reset index array |
|
||||
this.shownContactDbIndices = new Array<boolean>(this.numContacts); |
|
||||
} |
|
||||
this.currentCategory = this.CATEGORY_CONTACTS; |
|
||||
|
|
||||
let someContactDbIndex = Math.floor(Math.random() * this.numContacts); |
|
||||
let count = 0; |
|
||||
// as long as the index has an entry, loop |
|
||||
while ( |
|
||||
this.shownContactDbIndices[someContactDbIndex] != null && |
|
||||
count++ < this.numContacts |
|
||||
) { |
|
||||
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts; |
|
||||
} |
|
||||
if (count >= this.numContacts) { |
|
||||
// all contacts have been shown |
|
||||
this.nextIdeaPastContacts(); |
|
||||
} else { |
|
||||
// get the contact at that offset |
|
||||
await db.open(); |
|
||||
this.currentContact = await db.contacts |
|
||||
.offset(someContactDbIndex) |
|
||||
.first(); |
|
||||
this.shownContactDbIndices[someContactDbIndex] = true; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
</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> |
|
@ -0,0 +1,38 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { ref } from 'vue' |
||||
|
|
||||
|
defineProps<{ msg: string }>() |
||||
|
|
||||
|
const count = ref(0) |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<h1>{{ msg }}</h1> |
||||
|
|
||||
|
<div class="card"> |
||||
|
<button type="button" @click="count++">count is {{ count }}</button> |
||||
|
<p> |
||||
|
Edit |
||||
|
<code>components/HelloWorld.vue</code> to test HMR |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<p> |
||||
|
Check out |
||||
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank" |
||||
|
>create-vue</a |
||||
|
>, the official Vue + Vite starter |
||||
|
</p> |
||||
|
<p> |
||||
|
Install |
||||
|
<a href="https://github.com/vuejs/language-tools" target="_blank">Volar</a> |
||||
|
in your IDE for a better DX |
||||
|
</p> |
||||
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.read-the-docs { |
||||
|
color: #888; |
||||
|
} |
||||
|
</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" |
|
||||
> |
|
||||
Add Photo |
|
||||
</div> |
|
||||
<div |
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" |
|
||||
@click="close()" |
|
||||
> |
|
||||
<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,118 +0,0 @@ |
|||||
<template> |
|
||||
<div v-if="visible" class="dialog-overlay"> |
|
||||
<div class="dialog"> |
|
||||
<h1 class="text-xl font-bold text-center mb-4">Invitation & Notes</h1> |
|
||||
|
|
||||
These are optional notes for your use; they are comments to help you |
|
||||
recall who it is when they accept it. These notes are sent to the server. |
|
||||
If you want to store your own way, the invitation ID is: |
|
||||
{{ inviteIdentifier }} |
|
||||
<input |
|
||||
type="text" |
|
||||
placeholder="Notes" |
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" |
|
||||
v-model="text" |
|
||||
/> |
|
||||
|
|
||||
<!-- Add date selection element --> |
|
||||
Expiration |
|
||||
<input |
|
||||
type="date" |
|
||||
class="block rounded border border-slate-400 mb-4 px-3 py-2" |
|
||||
v-model="expiresAt" |
|
||||
/> |
|
||||
|
|
||||
<div class="mt-8"> |
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
|
||||
<button |
|
||||
type="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 mb-2" |
|
||||
@click="onClickSaveChanges()" |
|
||||
> |
|
||||
Save |
|
||||
</button> |
|
||||
<!-- SHOW ME instead while processing saving changes --> |
|
||||
<button |
|
||||
type="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-2 py-3 rounded-md mb-2" |
|
||||
@click="onClickCancel()" |
|
||||
> |
|
||||
Cancel |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</template> |
|
||||
|
|
||||
<script lang="ts"> |
|
||||
import { Vue, Component } from "vue-facing-decorator"; |
|
||||
|
|
||||
import { NotificationIface } from "@/constants/app"; |
|
||||
|
|
||||
@Component |
|
||||
export default class InviteDialog extends Vue { |
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|
||||
|
|
||||
callback: (text: string, expiresAt: string) => void = () => {}; |
|
||||
inviteIdentifier = ""; |
|
||||
text = ""; |
|
||||
visible = false; |
|
||||
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) |
|
||||
.toISOString() |
|
||||
.substring(0, 10); |
|
||||
|
|
||||
async open( |
|
||||
inviteIdentifier: string, |
|
||||
aCallback: (text: string, expiresAt: string) => void, |
|
||||
) { |
|
||||
this.callback = aCallback; |
|
||||
this.inviteIdentifier = inviteIdentifier; |
|
||||
this.visible = true; |
|
||||
} |
|
||||
|
|
||||
async onClickSaveChanges() { |
|
||||
if (!this.expiresAt) { |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "warning", |
|
||||
title: "Needs Expiration", |
|
||||
text: "You must select an expiration date.", |
|
||||
}, |
|
||||
5000, |
|
||||
); |
|
||||
} else { |
|
||||
this.callback(this.text, this.expiresAt); |
|
||||
this.visible = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
onClickCancel() { |
|
||||
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: 500px; |
|
||||
} |
|
||||
</style> |
|
@ -1,336 +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 of what is offered" |
|
||||
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 { retrieveSettingsForActiveAccount } from "@/db/index"; |
|
||||
|
|
||||
@Component |
|
||||
export default class OfferDialog extends Vue { |
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|
||||
|
|
||||
@Prop projectId?: string; |
|
||||
@Prop projectName?: string; |
|
||||
|
|
||||
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; |
|
||||
|
|
||||
const settings = await retrieveSettingsForActiveAccount(); |
|
||||
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 identity before you can record an offer.", |
|
||||
}, |
|
||||
7000, |
|
||||
); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
if (!description && !amount) { |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "danger", |
|
||||
title: "Error", |
|
||||
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`, |
|
||||
}, |
|
||||
-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.", |
|
||||
}, |
|
||||
5000, |
|
||||
); |
|
||||
} |
|
||||
// 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,281 +0,0 @@ |
|||||
<!-- similar to ContactNameDialog --> |
|
||||
<template> |
|
||||
<div v-if="visible" class="dialog-overlay"> |
|
||||
<div v-if="page === OnboardPage.Home" class="dialog"> |
|
||||
<h1 class="text-xl font-bold text-center mb-4 relative"> |
|
||||
Welcome to Time Safari |
|
||||
<br /> |
|
||||
- Showcasing Gratitude & Magnifing Time |
|
||||
<div |
|
||||
class="text-lg text-center leading-none absolute right-0 -top-1" |
|
||||
@click="onClickClose(true)" |
|
||||
> |
|
||||
<fa icon="xmark" class="w-[1em]"></fa> |
|
||||
</div> |
|
||||
</h1> |
|
||||
|
|
||||
<p v-if="isRegistered" class="mt-4"> |
|
||||
You can now log things that you've received or witnessed: |
|
||||
<span v-if="numContacts > 0"> |
|
||||
click on {{ firstContactName }}'s name or |
|
||||
</span> |
|
||||
click on "Unnamed" to express your appreciation for... whatever -- like |
|
||||
thanks for showing you all these fascinating stories of |
|
||||
<em>gratitude</em>. |
|
||||
</p> |
|
||||
<p v-else class="mt-4"> |
|
||||
The feed underneath this pop-up shows the latest gifts recognized by |
|
||||
others. Once someone registers you, you'll be able to log your |
|
||||
appreciation, too. |
|
||||
</p> |
|
||||
|
|
||||
<p class="mt-4"> |
|
||||
The more you illuminate cool things people are doing, the more you |
|
||||
attract people to work together with you. |
|
||||
</p> |
|
||||
|
|
||||
<p class="mt-4 flex items-center"> |
|
||||
The |
|
||||
<fa |
|
||||
icon="house-chimney" |
|
||||
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded" |
|
||||
/> |
|
||||
button below brings you back to this feed screen. |
|
||||
</p> |
|
||||
|
|
||||
<div class="mt-8"> |
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
|
||||
<button |
|
||||
type="button" |
|
||||
data-testId="closeOnboardingAndFinish" |
|
||||
class="block w-full text-center text-md 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-2 py-3 rounded-md mb-2" |
|
||||
@click="onClickClose(true)" |
|
||||
> |
|
||||
That's enough help, thanks. |
|
||||
</button> |
|
||||
<button |
|
||||
type="button" |
|
||||
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" |
|
||||
@click="$router.push({ name: 'discover' })" |
|
||||
> |
|
||||
Show me more! |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<p class="mt-4 flex items-center"> |
|
||||
To see these instructions and more, click above on |
|
||||
<span |
|
||||
class="ml-1 mr-1 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" |
|
||||
> |
|
||||
Help |
|
||||
</span> |
|
||||
</p> |
|
||||
</div> |
|
||||
|
|
||||
<div v-if="page === OnboardPage.Discover" class="dialog"> |
|
||||
<h1 class="text-xl font-bold text-center mb-4 relative"> |
|
||||
Offer to Interesting Events & People |
|
||||
<div |
|
||||
class="text-lg text-center leading-none absolute right-0 -top-1" |
|
||||
@click="onClickClose(true)" |
|
||||
> |
|
||||
<fa icon="xmark" class="w-[1em]"></fa> |
|
||||
</div> |
|
||||
</h1> |
|
||||
|
|
||||
<p> |
|
||||
Once you've seen things that others have given or done, you may find |
|
||||
ways you want to contribute, too. It turns out others have proposed |
|
||||
activities together, and this page is where you find projects. |
|
||||
</p> |
|
||||
|
|
||||
<p class="mt-4"> |
|
||||
Search for a topic, or search around your neighborhod under "Nearby". |
|
||||
</p> |
|
||||
|
|
||||
<p class="mt-4"> |
|
||||
When you find some that seem interesting, you can offer your help. You |
|
||||
are welcome to make your offer conditional, for example if they get 2 |
|
||||
other people, too. |
|
||||
</p> |
|
||||
|
|
||||
<p class="mt-4 flex items-center"> |
|
||||
The |
|
||||
<fa |
|
||||
icon="magnifying-glass" |
|
||||
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded" |
|
||||
/> |
|
||||
button below brings you to this discovery screen. |
|
||||
</p> |
|
||||
|
|
||||
<div class="mt-8"> |
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
|
||||
<button |
|
||||
type="button" |
|
||||
data-testId="closeOnboardingAndFinish" |
|
||||
class="block w-full text-center text-md 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-2 py-3 rounded-md mb-2" |
|
||||
@click="onClickClose(true)" |
|
||||
> |
|
||||
No more help, thanks. |
|
||||
</button> |
|
||||
<button |
|
||||
type="button" |
|
||||
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" |
|
||||
@click="$router.push({ name: 'projects' })" |
|
||||
> |
|
||||
Show me even more. |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div v-if="page === OnboardPage.Create" class="dialog"> |
|
||||
<h1 class="text-xl font-bold text-center mb-4 relative"> |
|
||||
Fish for Others with Your Projects |
|
||||
<div |
|
||||
class="text-lg text-center leading-none absolute right-0 -top-1" |
|
||||
@click="onClickClose(true)" |
|
||||
> |
|
||||
<fa icon="xmark" class="w-[1em]"></fa> |
|
||||
</div> |
|
||||
</h1> |
|
||||
|
|
||||
<p class="relative"> |
|
||||
Now you can take a turn: click on the |
|
||||
<span class="bg-green-600 text-white rounded-full"> |
|
||||
<fa icon="plus" class="fa-fw"></fa> |
|
||||
</span> |
|
||||
button to throw out projects of your own... anything you'd like to see |
|
||||
happen. If your first idea doesn't catch anyone, try, try again... and |
|
||||
let others know that this is a good place to find help. |
|
||||
</p> |
|
||||
|
|
||||
<p class="mt-4 flex items-center"> |
|
||||
The |
|
||||
<fa |
|
||||
icon="hand" |
|
||||
class="ml-1 mr-1 text-lg text-white bg-slate-400 px-2 py-2 rounded" |
|
||||
/> |
|
||||
button below brings you here to see your ideas. |
|
||||
</p> |
|
||||
|
|
||||
<p class="mt-4"> |
|
||||
By the way, one good way to get to know your neighbors and their |
|
||||
interests is to offer time directly to them. You can do this on the |
|
||||
contacts screen |
|
||||
<fa icon="users" class="text-slate-500" /> |
|
||||
which is a great way to get to know a neighbor's interests. |
|
||||
</p> |
|
||||
|
|
||||
<div class="mt-8"> |
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
|
||||
<button |
|
||||
type="button" |
|
||||
data-testId="closeOnboardingAndFinish" |
|
||||
class="block w-full text-center text-md 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-2 py-3 rounded-md mb-2" |
|
||||
@click="onClickClose(true, true)" |
|
||||
> |
|
||||
Let's go! |
|
||||
<br /> |
|
||||
See & record gratitude. |
|
||||
</button> |
|
||||
<button |
|
||||
type="button" |
|
||||
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md mb-2" |
|
||||
@click="$router.push({ name: 'help' })" |
|
||||
> |
|
||||
I want to read more Help. |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</template> |
|
||||
|
|
||||
<script lang="ts"> |
|
||||
import { Component, Vue } from "vue-facing-decorator"; |
|
||||
import { Router } from "vue-router"; |
|
||||
|
|
||||
import { NotificationIface } from "@/constants/app"; |
|
||||
import { |
|
||||
db, |
|
||||
retrieveSettingsForActiveAccount, |
|
||||
updateAccountSettings, |
|
||||
} from "@/db/index"; |
|
||||
import { OnboardPage } from "@/libs/util"; |
|
||||
|
|
||||
@Component({ |
|
||||
computed: { |
|
||||
OnboardPage() { |
|
||||
return OnboardPage; |
|
||||
}, |
|
||||
}, |
|
||||
components: { OnboardPage }, |
|
||||
}) |
|
||||
export default class OnboardingDialog extends Vue { |
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|
||||
|
|
||||
activeDid = ""; |
|
||||
firstContactName = null; |
|
||||
givenName = ""; |
|
||||
isRegistered = false; |
|
||||
numContacts = 0; |
|
||||
page = OnboardPage.Home; |
|
||||
visible = false; |
|
||||
|
|
||||
async open(page: OnboardPage) { |
|
||||
this.page = page; |
|
||||
const settings = await retrieveSettingsForActiveAccount(); |
|
||||
this.activeDid = settings.activeDid || ""; |
|
||||
this.isRegistered = !!settings.isRegistered; |
|
||||
const contacts = await db.contacts.toArray(); |
|
||||
this.numContacts = contacts.length; |
|
||||
if (this.numContacts > 0) { |
|
||||
this.firstContactName = contacts[0].name; |
|
||||
} |
|
||||
this.visible = true; |
|
||||
if (this.page === OnboardPage.Create) { |
|
||||
// we'll assume that they've been through all the other pages |
|
||||
await updateAccountSettings(this.activeDid, { |
|
||||
finishedOnboarding: true, |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async onClickClose(done?: boolean, goHome?: boolean) { |
|
||||
this.visible = false; |
|
||||
if (done) { |
|
||||
await updateAccountSettings(this.activeDid, { |
|
||||
finishedOnboarding: true, |
|
||||
}); |
|
||||
if (goHome) { |
|
||||
(this.$router as Router).push({ name: "home" }); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
</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,439 +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 { retrieveSettingsForActiveAccount } from "@/db/index"; |
|
||||
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 { |
|
||||
const settings = await retrieveSettingsForActiveAccount(); |
|
||||
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,530 +0,0 @@ |
|||||
<template> |
|
||||
<transition |
|
||||
enter-active-class="transform ease-out duration-300 transition" |
|
||||
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4" |
|
||||
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0" |
|
||||
leave-active-class="transition ease-in duration-500" |
|
||||
leave-from-class="opacity-100" |
|
||||
leave-to-class="opacity-0" |
|
||||
> |
|
||||
<div |
|
||||
v-if="isVisible" |
|
||||
class="fixed z-[100] top-0 inset-x-0 w-full 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 && vapidKey" 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 5 seconds... |
|
||||
<fa icon="spinner" spin /> |
|
||||
</p> |
|
||||
|
|
||||
<div v-if="serviceWorkerReady && vapidKey"> |
|
||||
<span class="flex flex-row justify-center"> |
|
||||
<span class="mt-2">Yes, tell me at: </span> |
|
||||
<input |
|
||||
type="number" |
|
||||
@change="checkHourInput" |
|
||||
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" |
|
||||
/> |
|
||||
<input |
|
||||
type="number" |
|
||||
@change="checkMinuteInput" |
|
||||
class="border border-slate-400 mt-2 px-2 py-2 text-center w-20" |
|
||||
v-model="minuteInput" |
|
||||
/> |
|
||||
<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=" |
|
||||
close(); |
|
||||
turnOnNotifications(); |
|
||||
" |
|
||||
> |
|
||||
Turn on Daily Message |
|
||||
</button> |
|
||||
</div> |
|
||||
|
|
||||
<button |
|
||||
@click="close()" |
|
||||
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> |
|
||||
</transition> |
|
||||
</template> |
|
||||
|
|
||||
<script lang="ts"> |
|
||||
import { Component, Vue } from "vue-facing-decorator"; |
|
||||
|
|
||||
import { DEFAULT_PUSH_SERVER, NotificationIface } from "@/constants/app"; |
|
||||
import { |
|
||||
db, |
|
||||
logConsoleAndDb, |
|
||||
retrieveSettingsForActiveAccount, |
|
||||
} from "@/db/index"; |
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
|
||||
import { urlBase64ToUint8Array } from "@/libs/crypto/vc/util"; |
|
||||
import * as libsUtil from "@/libs/util"; |
|
||||
|
|
||||
// Example interface for error |
|
||||
interface ErrorResponse { |
|
||||
message: string; |
|
||||
} |
|
||||
|
|
||||
// PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson |
|
||||
interface PushSubscriptionWithTime extends PushSubscriptionJSON { |
|
||||
notifyTime: { utcHour: number; minute: number }; |
|
||||
notifyType: string; |
|
||||
} |
|
||||
|
|
||||
interface ServiceWorkerMessage { |
|
||||
type: string; |
|
||||
data: string; |
|
||||
} |
|
||||
|
|
||||
interface ServiceWorkerResponse { |
|
||||
// Define the properties and their types |
|
||||
success: boolean; |
|
||||
message?: string; |
|
||||
} |
|
||||
|
|
||||
interface VapidResponse { |
|
||||
data: { |
|
||||
vapidKey: string; |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
@Component |
|
||||
export default class PushNotificationPermission extends Vue { |
|
||||
// eslint-disable-next-line |
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>; |
|
||||
|
|
||||
callback: (success: boolean, time: string) => void = () => {}; |
|
||||
hourAm = true; |
|
||||
hourInput = "8"; |
|
||||
isVisible = false; |
|
||||
minuteInput = "00"; |
|
||||
serviceWorkerReady = false; |
|
||||
vapidKey = ""; |
|
||||
|
|
||||
async open(callback?: (success: boolean, time: string) => void) { |
|
||||
this.isVisible = true; |
|
||||
this.callback = callback || this.callback; |
|
||||
try { |
|
||||
const settings = await retrieveSettingsForActiveAccount(); |
|
||||
let pushUrl = DEFAULT_PUSH_SERVER; |
|
||||
if (settings?.webPushServer) { |
|
||||
pushUrl = settings.webPushServer; |
|
||||
} |
|
||||
|
|
||||
if (pushUrl.startsWith("http://localhost")) { |
|
||||
logConsoleAndDb("Not checking for VAPID in this local environment."); |
|
||||
} else { |
|
||||
let responseData = ""; |
|
||||
await this.axios |
|
||||
.get(pushUrl + "/web-push/vapid") |
|
||||
.then((response: VapidResponse) => { |
|
||||
this.vapidKey = response.data?.vapidKey || ""; |
|
||||
logConsoleAndDb("Got vapid key: " + this.vapidKey); |
|
||||
responseData = JSON.stringify(response.data); |
|
||||
navigator.serviceWorker?.addEventListener( |
|
||||
"controllerchange", |
|
||||
() => { |
|
||||
logConsoleAndDb( |
|
||||
"New service worker is now controlling the page", |
|
||||
); |
|
||||
}, |
|
||||
); |
|
||||
}); |
|
||||
if (!this.vapidKey) { |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "danger", |
|
||||
title: "Error Setting Notifications", |
|
||||
text: "Could not set notifications.", |
|
||||
}, |
|
||||
5000, |
|
||||
); |
|
||||
logConsoleAndDb( |
|
||||
"Error Setting Notifications: web push server response didn't have vapidKey: " + |
|
||||
responseData, |
|
||||
true, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
} catch (error) { |
|
||||
if (window.location.host.startsWith("localhost")) { |
|
||||
logConsoleAndDb( |
|
||||
"Ignoring the error getting VAPID for local development.", |
|
||||
); |
|
||||
} else { |
|
||||
logConsoleAndDb( |
|
||||
"Got an error initializing notifications: " + JSON.stringify(error), |
|
||||
true, |
|
||||
); |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "danger", |
|
||||
title: "Error Setting Notifications", |
|
||||
text: "Got an error setting notifications.", |
|
||||
}, |
|
||||
5000, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
// there may be a long pause here on first initialization |
|
||||
navigator.serviceWorker?.ready.then(() => { |
|
||||
this.serviceWorkerReady = true; |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
private close() { |
|
||||
this.isVisible = false; |
|
||||
} |
|
||||
|
|
||||
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> { |
|
||||
logConsoleAndDb( |
|
||||
"Requesting permission for notifications: " + JSON.stringify(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) => { |
|
||||
logConsoleAndDb( |
|
||||
"Response from service worker: " + JSON.stringify(response), |
|
||||
); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
private checkNotificationSupport(): Promise<void> { |
|
||||
if (!("Notification" in window)) { |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "danger", |
|
||||
title: "Browser Notifications Not Supported", |
|
||||
text: "This browser does not support notifications.", |
|
||||
}, |
|
||||
3000, |
|
||||
); |
|
||||
return Promise.reject("This browser does not support notifications."); |
|
||||
} |
|
||||
if (window.Notification.permission === "granted") { |
|
||||
return Promise.resolve(); |
|
||||
} |
|
||||
return Promise.resolve(); |
|
||||
} |
|
||||
|
|
||||
private requestNotificationPermission(): Promise<NotificationPermission> { |
|
||||
return window.Notification.requestPermission().then( |
|
||||
(permission: string) => { |
|
||||
if (permission !== "granted") { |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "danger", |
|
||||
title: "Error Requesting Notification Permission", |
|
||||
text: |
|
||||
"Allow this app permission to make notifications for personal reminders." + |
|
||||
" You can adjust them at any time in your settings.", |
|
||||
}, |
|
||||
-1, |
|
||||
); |
|
||||
throw new Error("We weren't granted permission."); |
|
||||
} |
|
||||
return permission; |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
private checkHourInput() { |
|
||||
const hourNum = parseInt(this.hourInput); |
|
||||
if (isNaN(hourNum)) { |
|
||||
this.hourInput = "12"; |
|
||||
} else if (hourNum < 1) { |
|
||||
this.hourInput = "12"; |
|
||||
this.hourAm = !this.hourAm; |
|
||||
} else if (hourNum > 12) { |
|
||||
this.hourInput = "1"; |
|
||||
this.hourAm = !this.hourAm; |
|
||||
} else { |
|
||||
this.hourInput = hourNum.toString(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private checkMinuteInput() { |
|
||||
const minuteNum = parseInt(this.minuteInput); |
|
||||
if (isNaN(minuteNum)) { |
|
||||
this.minuteInput = "00"; |
|
||||
} else if (minuteNum < 0) { |
|
||||
this.minuteInput = "59"; |
|
||||
} else if (minuteNum < 10) { |
|
||||
this.minuteInput = "0" + minuteNum; |
|
||||
} else if (minuteNum > 59) { |
|
||||
this.minuteInput = "00"; |
|
||||
} else { |
|
||||
this.minuteInput = minuteNum.toString(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
public async turnOnNotifications() { |
|
||||
let notifyCloser = () => {}; |
|
||||
return this.askPermission() |
|
||||
.then((permission) => { |
|
||||
logConsoleAndDb("Permission granted: " + JSON.stringify(permission)); |
|
||||
|
|
||||
// Call the function and handle promises |
|
||||
return this.subscribeToPush(); |
|
||||
}) |
|
||||
.then(() => { |
|
||||
logConsoleAndDb("Subscribed successfully."); |
|
||||
return navigator.serviceWorker?.ready; |
|
||||
}) |
|
||||
.then((registration) => { |
|
||||
return registration.pushManager.getSubscription(); |
|
||||
}) |
|
||||
.then(async (subscription) => { |
|
||||
if (subscription) { |
|
||||
notifyCloser = await this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "info", |
|
||||
title: "Notification Setup Underway", |
|
||||
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.", |
|
||||
}, |
|
||||
-1, |
|
||||
); |
|
||||
// we already checked that this is a valid hour number |
|
||||
const rawHourNum = libsUtil.numberOrZero(this.hourInput); |
|
||||
const adjHourNum = this.hourAm |
|
||||
? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum |
|
||||
rawHourNum === 12 |
|
||||
? 0 |
|
||||
: rawHourNum |
|
||||
: // Otherwise it's PM, so keep a 12 but otherwise add 12 |
|
||||
rawHourNum === 12 |
|
||||
? 12 |
|
||||
: rawHourNum + 12; |
|
||||
const hourNum = adjHourNum % 24; // probably unnecessary now |
|
||||
const utcHour = |
|
||||
hourNum + Math.round(new Date().getTimezoneOffset() / 60); |
|
||||
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24; |
|
||||
const minuteNum = libsUtil.numberOrZero(this.minuteInput); |
|
||||
const utcMinute = |
|
||||
minuteNum + Math.round(new Date().getTimezoneOffset() % 60); |
|
||||
const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60; |
|
||||
|
|
||||
const subscriptionWithTime: PushSubscriptionWithTime = { |
|
||||
notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute }, |
|
||||
notifyType: "DAILY_CHECK", |
|
||||
...subscription.toJSON(), |
|
||||
}; |
|
||||
await this.sendSubscriptionToServer(subscriptionWithTime); |
|
||||
// To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1 |
|
||||
logConsoleAndDb( |
|
||||
"Subscription data sent to server with endpoint: " + |
|
||||
subscription.endpoint, |
|
||||
); |
|
||||
return subscriptionWithTime; |
|
||||
} else { |
|
||||
throw new Error("Subscription object is not available."); |
|
||||
} |
|
||||
}) |
|
||||
.then(async (subscription: PushSubscriptionWithTime) => { |
|
||||
logConsoleAndDb( |
|
||||
"Subscription data sent to server and all finished successfully.", |
|
||||
); |
|
||||
await libsUtil.sendTestThroughPushServer(subscription, true); |
|
||||
notifyCloser(); |
|
||||
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, |
|
||||
); |
|
||||
const timeText = |
|
||||
// eslint-disable-next-line |
|
||||
this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM"); |
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, { |
|
||||
notifyingNewActivity: true, |
|
||||
notifyingNewActivityTime: timeText, |
|
||||
}); |
|
||||
this.callback(true, timeText); |
|
||||
}) |
|
||||
.catch((error) => { |
|
||||
logConsoleAndDb( |
|
||||
"Got an error setting notification permissions: " + |
|
||||
" string " + |
|
||||
error.toString() + |
|
||||
" JSON " + |
|
||||
JSON.stringify(error), |
|
||||
true, |
|
||||
); |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "danger", |
|
||||
title: "Error Setting Notification Permissions", |
|
||||
text: "Could not set notification permissions.", |
|
||||
}, |
|
||||
3000, |
|
||||
); |
|
||||
// unsubscribe just in case we failed after getting a subscription |
|
||||
navigator.serviceWorker?.ready |
|
||||
.then((registration) => registration.pushManager.getSubscription()) |
|
||||
.then((subscription) => { |
|
||||
if (subscription) { |
|
||||
subscription.unsubscribe(); |
|
||||
} |
|
||||
}); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
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 (window.Notification.permission !== "granted") { |
|
||||
const errorMsg = "Notification permission not granted"; |
|
||||
console.warn(errorMsg); |
|
||||
return reject(new Error(errorMsg)); |
|
||||
} |
|
||||
|
|
||||
const applicationServerKey = urlBase64ToUint8Array(this.vapidKey); |
|
||||
const options: PushSubscriptionOptions = { |
|
||||
userVisibleOnly: true, |
|
||||
applicationServerKey: applicationServerKey, |
|
||||
}; |
|
||||
|
|
||||
navigator.serviceWorker?.ready |
|
||||
.then((registration) => { |
|
||||
return registration.pushManager.subscribe(options); |
|
||||
}) |
|
||||
.then((subscription) => { |
|
||||
logConsoleAndDb( |
|
||||
"Push subscription successful: " + JSON.stringify(subscription), |
|
||||
); |
|
||||
resolve(); |
|
||||
}) |
|
||||
.catch((error) => { |
|
||||
logConsoleAndDb( |
|
||||
"Push subscription failed: " + |
|
||||
JSON.stringify(error) + |
|
||||
" - " + |
|
||||
JSON.stringify(options), |
|
||||
true, |
|
||||
); |
|
||||
|
|
||||
// Inform the user about the issue |
|
||||
this.$notify( |
|
||||
{ |
|
||||
group: "alert", |
|
||||
type: "danger", |
|
||||
title: "Error Setting Push Notifications", |
|
||||
text: |
|
||||
"We encountered an issue setting up push notifications. " + |
|
||||
"If you wish to revoke notification permissions, please do so in your browser settings.", |
|
||||
}, |
|
||||
-1, |
|
||||
); |
|
||||
|
|
||||
reject(error); |
|
||||
}); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
private sendSubscriptionToServer( |
|
||||
subscription: PushSubscriptionWithTime, |
|
||||
): Promise<void> { |
|
||||
logConsoleAndDb( |
|
||||
"About to send subscription... " + JSON.stringify(subscription), |
|
||||
); |
|
||||
return fetch("/web-push/subscribe", { |
|
||||
method: "POST", |
|
||||
headers: { |
|
||||
"Content-Type": "application/json", |
|
||||
}, |
|
||||
body: JSON.stringify(subscription), |
|
||||
}).then((response) => { |
|
||||
if (!response.ok) { |
|
||||
console.error("Bad response subscribing to web push: ", response); |
|
||||
throw new Error("Failed to send push subscription to server"); |
|
||||
} |
|
||||
logConsoleAndDb("Push subscription sent to server successfully."); |
|
||||
}); |
|
||||
} |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<style scoped> |
|
||||
/* Add any specific styles for this component here */ |
|
||||
</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,59 +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 { retrieveSettingsForActiveAccount } from "@/db/index"; |
|
||||
|
|
||||
@Component |
|
||||
export default class TopMessage extends Vue { |
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|
||||
|
|
||||
@Prop selected = ""; |
|
||||
|
|
||||
message = ""; |
|
||||
|
|
||||
async mounted() { |
|
||||
try { |
|
||||
const settings = await retrieveSettingsForActiveAccount(); |
|
||||
if ( |
|
||||
settings.warnIfTestServer && |
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER |
|
||||
) { |
|
||||
const didPrefix = settings.activeDid?.slice(11, 15); |
|
||||
this.message = "You're linked to a non-prod server, user " + didPrefix; |
|
||||
} 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,98 +0,0 @@ |
|||||
<!-- similar to ContactNameDialog --> |
|
||||
<template> |
|
||||
<div v-if="visible" class="dialog-overlay"> |
|
||||
<div class="dialog"> |
|
||||
<h1 class="text-xl font-bold text-center mb-4">Set Your Name</h1> |
|
||||
|
|
||||
This is not sent to servers. It is only shared with people when you send |
|
||||
it to them. |
|
||||
<input |
|
||||
type="text" |
|
||||
placeholder="Name" |
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2" |
|
||||
v-model="givenName" |
|
||||
/> |
|
||||
|
|
||||
<div class="mt-8"> |
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> |
|
||||
<button |
|
||||
type="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 mb-2" |
|
||||
@click="onClickSaveChanges()" |
|
||||
> |
|
||||
Save |
|
||||
</button> |
|
||||
<button |
|
||||
type="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-2 py-3 rounded-md mb-2" |
|
||||
@click="onClickCancel()" |
|
||||
> |
|
||||
Cancel |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</template> |
|
||||
|
|
||||
<script lang="ts"> |
|
||||
import { Vue, Component } from "vue-facing-decorator"; |
|
||||
|
|
||||
import { NotificationIface } from "@/constants/app"; |
|
||||
import { db, retrieveSettingsForActiveAccount } from "@/db/index"; |
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; |
|
||||
|
|
||||
@Component |
|
||||
export default class UserNameDialog extends Vue { |
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|
||||
|
|
||||
callback: (name: string) => void = () => {}; |
|
||||
givenName = ""; |
|
||||
visible = false; |
|
||||
|
|
||||
/** |
|
||||
* @param aCallback - callback function for name, which may be "" |
|
||||
*/ |
|
||||
async open(aCallback?: (name: string) => void) { |
|
||||
this.callback = aCallback || this.callback; |
|
||||
const settings = await retrieveSettingsForActiveAccount(); |
|
||||
this.givenName = settings.firstName || ""; |
|
||||
this.visible = true; |
|
||||
} |
|
||||
|
|
||||
async onClickSaveChanges() { |
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, { |
|
||||
firstName: this.givenName, |
|
||||
}); |
|
||||
this.visible = false; |
|
||||
this.callback(this.givenName); |
|
||||
} |
|
||||
|
|
||||
onClickCancel() { |
|
||||
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: 500px; |
|
||||
} |
|
||||
</style> |
|
@ -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,241 +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 { retrieveSettingsForActiveAccount } from "@/db"; |
|
||||
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 { |
|
||||
const settings = await retrieveSettingsForActiveAccount(); |
|
||||
const activeDid = settings.activeDid || ""; |
|
||||
const apiServer = settings.apiServer; |
|
||||
const headers = await getHeaders(activeDid); |
|
||||
|
|
||||
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction"; |
|
||||
const resp = await axios.get(url, { headers: headers }); |
|
||||
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 }; |
|
@ -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 }; |
|