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,396 +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.36] - 2024.11.24 |
|||
### Changed |
|||
- More friendly default reminder message |
|||
- Blue borders around people to indicate clickability |
|||
|
|||
|
|||
## [0.3.35] - 2024.11.24 - bff7d0a6320b70349185e26bfac72e3bb17f76df |
|||
### Added |
|||
- Daily reliable, hard-coded notification message |
|||
- Setting to change the partner API server |
|||
|
|||
|
|||
## [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 |
|||
and expand to crowd-fund with time & money, then record and see the impact of contributions. |
|||
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. |
|||
|
|||
## Roadmap |
|||
## Recommended IDE Setup |
|||
|
|||
See [project.task.yaml](project.task.yaml) for current priorities. |
|||
(Numbers at the beginning of lines are estimated hours. See [taskyaml.org](https://taskyaml.org/) for details.) |
|||
- [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). |
|||
|
|||
## 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. |
|||
|
|||
``` |
|||
npm install |
|||
``` |
|||
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: |
|||
|
|||
### Compile and hot-reloads for development |
|||
``` |
|||
npm run dev |
|||
``` |
|||
|
|||
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, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.36` && `git push origin 0.3.36`. |
|||
|
|||
|
|||
|
|||
|
|||
## 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. Disable the built-in TypeScript Extension |
|||
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette |
|||
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. |
|||
|
@ -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", |
|||
"version": "0.3.36", |
|||
"name": "timesafari", |
|||
"private": true, |
|||
"version": "0.0.0", |
|||
"type": "module", |
|||
"scripts": { |
|||
"dev": "vite", |
|||
"serve": "vite preview", |
|||
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build", |
|||
"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" |
|||
"build": "vue-tsc && vite build", |
|||
"preview": "vite preview" |
|||
}, |
|||
"dependencies": { |
|||
"@capacitor/android": "^6.1.2", |
|||
"@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" |
|||
"vue": "^3.3.11" |
|||
}, |
|||
"devDependencies": { |
|||
"@playwright/test": "^1.45.2", |
|||
"@types/js-yaml": "^4.0.9", |
|||
"@types/leaflet": "^1.9.8", |
|||
"@types/luxon": "^3.4.2", |
|||
"@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" |
|||
"@vitejs/plugin-vue": "^4.5.2", |
|||
"typescript": "^5.2.2", |
|||
"vite": "^5.0.8", |
|||
"vue-tsc": "^1.8.25" |
|||
} |
|||
} |
|||
|
@ -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: 5.6 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,425 +1,30 @@ |
|||
<template> |
|||
<router-view /> |
|||
|
|||
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind --> |
|||
<NotificationGroup group="alert"> |
|||
<div |
|||
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end" |
|||
> |
|||
<Notification |
|||
v-slot="{ notifications, close }" |
|||
enter="transform ease-out duration-300 transition" |
|||
enter-from="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-4" |
|||
enter-to="translate-y-0 opacity-100 sm:translate-x-0" |
|||
leave="transition ease-in duration-500" |
|||
leave-from="opacity-100" |
|||
leave-to="opacity-0" |
|||
move="transition duration-500" |
|||
move-delay="delay-300" |
|||
> |
|||
<div |
|||
v-for="notification in notifications" |
|||
:key="notification.id" |
|||
class="w-full" |
|||
role="alert" |
|||
> |
|||
<div |
|||
v-if="notification.type === 'toast'" |
|||
class="w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-900/90 text-white rounded-lg shadow-md" |
|||
> |
|||
<div class="w-full px-4 py-3"> |
|||
<span class="font-semibold">{{ notification.title }}</span> |
|||
<p class="text-sm">{{ notification.text }}</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
v-if="notification.type === 'info'" |
|||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-slate-100 rounded-lg shadow-md" |
|||
> |
|||
<div |
|||
class="flex items-center justify-center w-12 bg-slate-600 text-slate-100" |
|||
> |
|||
<fa icon="circle-info" class="fa-fw fa-xl"></fa> |
|||
</div> |
|||
|
|||
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900"> |
|||
<span class="font-semibold">{{ notification.title }}</span> |
|||
<p class="text-sm">{{ notification.text }}</p> |
|||
|
|||
<button |
|||
@click="close(notification.id)" |
|||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600" |
|||
> |
|||
<fa icon="xmark" class="fa-fw"></fa> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
v-if="notification.type === 'success'" |
|||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-emerald-100 rounded-lg shadow-md" |
|||
> |
|||
<div |
|||
class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100" |
|||
> |
|||
<fa icon="circle-info" class="fa-fw fa-xl"></fa> |
|||
</div> |
|||
|
|||
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900"> |
|||
<span class="font-semibold">{{ notification.title }}</span> |
|||
<p class="text-sm">{{ notification.text }}</p> |
|||
|
|||
<button |
|||
@click="close(notification.id)" |
|||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600" |
|||
> |
|||
<fa icon="xmark" class="fa-fw"></fa> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
v-if="notification.type === 'warning'" |
|||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-amber-100 rounded-lg shadow-md" |
|||
> |
|||
<div |
|||
class="flex items-center justify-center w-12 bg-amber-600 text-amber-100" |
|||
> |
|||
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa> |
|||
</div> |
|||
|
|||
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900"> |
|||
<span class="font-semibold">{{ notification.title }}</span> |
|||
<p class="text-sm">{{ notification.text }}</p> |
|||
|
|||
<button |
|||
@click="close(notification.id)" |
|||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600" |
|||
> |
|||
<fa icon="xmark" class="fa-fw"></fa> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div |
|||
v-if="notification.type === 'danger'" |
|||
class="flex w-full max-w-sm mx-auto mb-3 overflow-hidden bg-rose-100 rounded-lg shadow-md" |
|||
> |
|||
<div |
|||
class="flex items-center justify-center w-12 bg-rose-600 text-rose-100" |
|||
> |
|||
<fa icon="triangle-exclamation" class="fa-fw fa-xl"></fa> |
|||
</div> |
|||
|
|||
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900"> |
|||
<span class="font-semibold">{{ notification.title }}</span> |
|||
<p class="text-sm">{{ notification.text }}</p> |
|||
|
|||
<button |
|||
@click="close(notification.id)" |
|||
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600" |
|||
> |
|||
<fa icon="xmark" class="fa-fw"></fa> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</Notification> |
|||
</div> |
|||
</NotificationGroup> |
|||
|
|||
<!-- |
|||
This "group" of "modal" is the prompt for an answer. |
|||
Set "type" as follows: "confirm" for yes/no, and "notification" ones: "-permission", "-mute", "-off" |
|||
--> |
|||
<NotificationGroup group="modal"> |
|||
<div class="fixed z-[100] top-0 inset-x-0 w-full"> |
|||
<Notification |
|||
v-slot="{ notifications, close }" |
|||
enter="transform ease-out duration-300 transition" |
|||
enter-from="translate-y-2 opacity-0 sm:translate-y-4" |
|||
enter-to="translate-y-0 opacity-100 sm:translate-y-0" |
|||
leave="transition ease-in duration-500" |
|||
leave-from="opacity-100" |
|||
leave-to="opacity-0" |
|||
move="transition duration-500" |
|||
move-delay="delay-300" |
|||
> |
|||
<!-- see NotificationIface in constants/app.ts --> |
|||
<div |
|||
v-for="notification in notifications" |
|||
:key="notification.id" |
|||
class="w-full" |
|||
role="alert" |
|||
> |
|||
<!-- |
|||
Type of "confirm" will post a message. |
|||
With onYes function, show a "Yes" button to call that function. |
|||
With onNo function, show a "No" button to call that function, |
|||
and pass it state of "askAgain" field shown if you set promptToStopAsking. |
|||
--> |
|||
<div |
|||
v-if="notification.type === 'confirm'" |
|||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50" |
|||
> |
|||
<div |
|||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg" |
|||
> |
|||
<div class="w-full px-6 py-6 text-slate-900 text-center"> |
|||
<span class="font-semibold text-lg"> |
|||
{{ notification.title }} |
|||
</span> |
|||
<p class="text-sm mb-2">{{ notification.text }}</p> |
|||
|
|||
<button |
|||
v-if="notification.onYes" |
|||
@click=" |
|||
notification.onYes(); |
|||
close(notification.id); |
|||
" |
|||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2" |
|||
> |
|||
Yes{{ |
|||
notification.yesText ? ", " + notification.yesText : "" |
|||
}} |
|||
</button> |
|||
|
|||
<button |
|||
v-if="notification.onNo" |
|||
@click=" |
|||
notification.onNo(stopAsking); |
|||
close(notification.id); |
|||
stopAsking = false; // reset value |
|||
" |
|||
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2" |
|||
> |
|||
No{{ notification.noText ? ", " + notification.noText : "" }} |
|||
</button> |
|||
|
|||
<label |
|||
v-if="notification.promptToStopAsking && notification.onNo" |
|||
for="toggleStopAsking" |
|||
class="flex items-center justify-between cursor-pointer my-4" |
|||
@click="stopAsking = !stopAsking" |
|||
> |
|||
<!-- label --> |
|||
<span class="ml-2">... and do not ask again.</span> |
|||
<!-- toggle --> |
|||
<div class="relative ml-2"> |
|||
<!-- input --> |
|||
<input |
|||
type="checkbox" |
|||
v-model="stopAsking" |
|||
name="stopAsking" |
|||
class="sr-only" |
|||
/> |
|||
<!-- line --> |
|||
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div> |
|||
<!-- dot --> |
|||
<div |
|||
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" |
|||
></div> |
|||
</div> |
|||
</label> |
|||
|
|||
<button |
|||
@click=" |
|||
notification.onCancel |
|||
? notification.onCancel(stopAsking) |
|||
: null; |
|||
close(notification.id); |
|||
stopAsking = false; // reset value 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> this notification? |
|||
</p> |
|||
<script setup lang="ts"> |
|||
import HelloWorld from './components/HelloWorld.vue' |
|||
</script> |
|||
|
|||
<button |
|||
@click=" |
|||
close(notification.id); |
|||
turnOffNotifications(notification); |
|||
" |
|||
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2" |
|||
> |
|||
Turn Off Notification |
|||
</button> |
|||
<button |
|||
@click="close(notification.id)" |
|||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" |
|||
> |
|||
Leave it On |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</Notification> |
|||
</div> |
|||
</NotificationGroup> |
|||
<template> |
|||
<div> |
|||
<a href="https://vitejs.dev" target="_blank"> |
|||
<img src="/vite.svg" class="logo" alt="Vite logo" /> |
|||
</a> |
|||
<a href="https://vuejs.org/" target="_blank"> |
|||
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" /> |
|||
</a> |
|||
</div> |
|||
<HelloWorld msg="Vite + Vue" /> |
|||
</template> |
|||
|
|||
<style></style> |
|||
|
|||
<script lang="ts"> |
|||
import { Vue, Component } from "vue-facing-decorator"; |
|||
|
|||
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index"; |
|||
import { NotificationIface } from "./constants/app"; |
|||
|
|||
@Component |
|||
export default class App extends Vue { |
|||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|||
|
|||
stopAsking = false; |
|||
|
|||
async turnOffNotifications(notification: NotificationIface) { |
|||
let subscription: object | null = null; |
|||
|
|||
let allGoingOff = false; |
|||
const settings = await retrieveSettingsForActiveAccount(); |
|||
const notifyingNewActivity = !!settings?.notifyingNewActivityTime; |
|||
const notifyingReminder = !!settings?.notifyingReminderTime; |
|||
if (!notifyingNewActivity || !notifyingReminder) { |
|||
// the other notification is already off, so fully unsubscribe now |
|||
allGoingOff = true; |
|||
} |
|||
|
|||
await navigator.serviceWorker?.ready |
|||
.then((registration) => { |
|||
return registration.pushManager.getSubscription(); |
|||
}) |
|||
.then(async (subscript: PushSubscription | null) => { |
|||
if (subscript) { |
|||
subscription = subscript.toJSON(); |
|||
if (allGoingOff) { |
|||
await subscript.unsubscribe(); |
|||
} |
|||
} else { |
|||
logConsoleAndDb("Subscription object is not available."); |
|||
} |
|||
}) |
|||
.catch((error) => { |
|||
logConsoleAndDb( |
|||
"Push provider server communication failed: " + JSON.stringify(error), |
|||
true, |
|||
); |
|||
}); |
|||
|
|||
if (!subscription) { |
|||
// there is no endpoint or auth for the server to compare, so we're done |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "info", |
|||
title: "Finished", |
|||
text: "Notifications are off.", // a different message so I know there are none stored |
|||
}, |
|||
5000, |
|||
); |
|||
return true; |
|||
} |
|||
|
|||
const serverSubscription = { |
|||
...subscription, |
|||
}; |
|||
if (!allGoingOff) { |
|||
serverSubscription["notifyType"] = notification.title; |
|||
} |
|||
const pushServerSuccess = await fetch("/web-push/unsubscribe", { |
|||
method: "POST", |
|||
headers: { |
|||
"Content-Type": "application/json", |
|||
}, |
|||
body: JSON.stringify(serverSubscription), |
|||
}) |
|||
.then((response) => { |
|||
return response.ok; |
|||
}) |
|||
.catch((error) => { |
|||
logConsoleAndDb( |
|||
"Push server communication failed: " + JSON.stringify(error), |
|||
true, |
|||
); |
|||
return false; |
|||
}); |
|||
|
|||
let message; |
|||
if (pushServerSuccess) { |
|||
message = "Notification is off."; |
|||
} else { |
|||
message = "Notification is still on. Try to turn it off again."; |
|||
} |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "info", |
|||
title: "Finished", |
|||
text: message, |
|||
}, |
|||
5000, |
|||
); |
|||
|
|||
if (notification.callback) { |
|||
// it's OK if the local notifications are still on (especially if the other notification is on) |
|||
notification.callback(pushServerSuccess); |
|||
} |
|||
} |
|||
<style scoped> |
|||
.logo { |
|||
height: 6em; |
|||
padding: 1.5em; |
|||
will-change: filter; |
|||
transition: filter 300ms; |
|||
} |
|||
</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,568 +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"> |
|||
<span v-if="pushType === DAILY_CHECK_TITLE"> |
|||
Would you like to be notified of new activity, up to once a day? |
|||
</span> |
|||
<span v-else> |
|||
Would you like to get a reminder message once a day? |
|||
</span> |
|||
</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"> |
|||
<div v-if="pushType === DAILY_CHECK_TITLE"> |
|||
<span>Yes, send me a message when there is new data for me</span> |
|||
</div> |
|||
<div v-else> |
|||
<span>Yes, send me this message:</span> |
|||
<!-- eslint-disable --> |
|||
<textarea |
|||
type="text" |
|||
id="push-message" |
|||
v-model="messageInput" |
|||
class="rounded border border-slate-400 mt-2 px-2 py-2 w-full" |
|||
maxlength="100" |
|||
></textarea |
|||
> |
|||
<!-- eslint-enable --> |
|||
<span class="w-full flex justify-between text-xs text-slate-500"> |
|||
<span></span> |
|||
<span>(100 characters max)</span> |
|||
</span> |
|||
</div> |
|||
|
|||
<div> |
|||
<span class="flex flex-row justify-center"> |
|||
<span class="mt-2">... 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> |
|||
</div> |
|||
<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 { logConsoleAndDb, retrieveSettingsForActiveAccount } from "@/db/index"; |
|||
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 { |
|||
message?: string; |
|||
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>; |
|||
|
|||
DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE; |
|||
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE; |
|||
|
|||
callback: (success: boolean, time: string, message?: string) => void = |
|||
() => {}; |
|||
hourAm = true; |
|||
hourInput = "8"; |
|||
isVisible = false; |
|||
messageInput = ""; |
|||
minuteInput = "00"; |
|||
pushType = ""; |
|||
serviceWorkerReady = false; |
|||
vapidKey = ""; |
|||
|
|||
async open( |
|||
pushType: string, |
|||
callback?: (success: boolean, time: string, message?: string) => void, |
|||
) { |
|||
this.callback = callback || this.callback; |
|||
this.isVisible = true; |
|||
this.pushType = pushType; |
|||
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; |
|||
}); |
|||
|
|||
if (this.pushType === this.DIRECT_PUSH_TITLE) { |
|||
this.messageInput = |
|||
"Click to share some gratitude with the world -- even if they're unnamed."; |
|||
// focus on the message input |
|||
setTimeout(function () { |
|||
document.getElementById("push-message")?.focus(); |
|||
}, 100); |
|||
} else { |
|||
// not critical but doesn't make sense in a daily check |
|||
this.messageInput = ""; |
|||
} |
|||
} |
|||
|
|||
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 Are 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(); |
|||
} |
|||
} |
|||
|
|||
private 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: this.pushType, |
|||
message: this.messageInput, |
|||
...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(); |
|||
setTimeout(() => { |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "success", |
|||
title: "Notification Is On", |
|||
text: "You should see at least one on your device; if not, check the 'Troubleshoot' link.", |
|||
}, |
|||
7000, |
|||
); |
|||
}, 500); |
|||
const timeText = |
|||
// eslint-disable-next-line |
|||
this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM"); |
|||
this.callback(true, timeText, this.messageInput); |
|||
}) |
|||
.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, |
|||
); |
|||
// if we want to also unsubscribe, be sure to do that only if no other notification is active |
|||
}); |
|||
} |
|||
|
|||
private subscribeToPush(): Promise<void> { |
|||
return new Promise<void>((resolve, reject) => { |
|||
if (!("serviceWorker" in navigator && "PushManager" in window)) { |
|||
const errorMsg = "Push messaging is not supported"; |
|||
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 }; |