Compare commits
724 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60fec5763d | |||
| aeb1d6a6a5 | |||
| ec6175a550 | |||
| c1361e088f | |||
| a2c986951e | |||
| dce7b8e3d9 | |||
| 211e0487fe | |||
| cc931dcb04 | |||
| bfe14cc9c2 | |||
| 275dba4468 | |||
| 1f05e81b05 | |||
| e9ad68f2a5 | |||
| 934664b9c9 | |||
| 780be59c76 | |||
| 4a0bedb628 | |||
| 5689f95230 | |||
| 3083bb084a | |||
| 821d27a58a | |||
|
|
998a1d312f | ||
| 1f13bf772c | |||
| def744b3df | |||
| 0fb37acb24 | |||
| 20bb723f0b | |||
| d821a7bd59 | |||
| 9f3b7314e8 | |||
| 4df7bb58a4 | |||
| 15ccd2394f | |||
| 920c7bb612 | |||
| 6eb26ea90c | |||
| 28b6d9bbf9 | |||
| 7a099183ae | |||
| 11070755d6 | |||
| c79dfac1fe | |||
| 2b06c64664 | |||
| 769a928b3d | |||
| d26d1d3601 | |||
| 1e6159869f | |||
| 75d15ddeb9 | |||
| 051a0a97d8 | |||
| f8d3fe2ee1 | |||
| 4f0a046723 | |||
| c4a0458c08 | |||
| 25b1598fcb | |||
| ddbb700c34 | |||
| fd8877900b | |||
| 05c6ddda02 | |||
| 853eb3c623 | |||
| 44cfe0d88e | |||
| 7fe256dc9e | |||
| e739d0be7c | |||
| 8d873b51bd | |||
| d7f4acb702 | |||
| f8002c4550 | |||
| d6b1386741 | |||
| 50fdd95c60 | |||
| 91c6c7c11c | |||
| 4e28dc8de6 | |||
| fb425f0d51 | |||
| a19aebcb37 | |||
| d0697c1ef4 | |||
| 1dd2333624 | |||
|
|
b4b78f6a2c | ||
|
|
3c0f6ce0de | ||
| 5534f8fa50 | |||
| a5004d475e | |||
| b445b1234f | |||
| 17c96dd01a | |||
| 6ad17101b2 | |||
| b4085ffaa7 | |||
| 4f2cb55753 | |||
| ebf9164ecc | |||
| 540cc21839 | |||
| c182068901 | |||
| aaa1f31945 | |||
| 17c632eb16 | |||
| 41c4cbe61a | |||
| c8402797ad | |||
| 4a09b9b9b1 | |||
| 5db3423301 | |||
| 2b00b243e8 | |||
| f2e5d8168d | |||
| 1d262b8da9 | |||
| 8ed74b71f2 | |||
| 8fb21c3d89 | |||
| 8dbfcd38d3 | |||
| 04df0d4eff | |||
|
|
ab523639a5 | ||
|
|
0484dfb253 | ||
|
|
c2839e8a99 | ||
|
|
e533cd3d34 | ||
|
|
18e00b95c7 | ||
|
|
e97cd1b1fa | ||
| ccca93b9f1 | |||
| 1be6c04699 | |||
| 2c33febb0e | |||
| e6f73dc81c | |||
| 0d55a722c5 | |||
| 97ef78f5dd | |||
| 672abac9a9 | |||
| 0607fad3e5 | |||
| 6aa89a1d1d | |||
| 2556d5feb9 | |||
| 3c1654764c | |||
| 4c1e229d62 | |||
| 17444d75de | |||
| f2fb432d2e | |||
| e45689daed | |||
| 041308ebc9 | |||
| 9c36bb509a | |||
| 2c300614ef | |||
| 8849e8806a | |||
| f75094283a | |||
| 0fabccd410 | |||
|
|
8ddf7d9532 | ||
|
|
4078853558 | ||
|
|
f4df5ffa9a | ||
| fa856f7594 | |||
|
|
a60beb483c | ||
| a0db6433a6 | |||
| 59d0772881 | |||
| b18e554886 | |||
| 098ef3c644 | |||
| 6045975b79 | |||
| a6bb036ceb | |||
| 1e2ad85547 | |||
|
|
3e2723b744 | ||
| 4daffe8f40 | |||
| efb1922826 | |||
| c6e10bfdad | |||
| bb122be319 | |||
| 3f436476a2 | |||
| a77d20b572 | |||
| 393d1583ae | |||
| 69a25ddd6c | |||
| a12d7fcc1b | |||
| 69c60e5426 | |||
| 4806acc30e | |||
| 1127d7079b | |||
| 0bbadfec6d | |||
| 276d8b2f19 | |||
| a7fbbbd4cd | |||
| a8d362c14d | |||
| ce5933f645 | |||
| 5cbf917ada | |||
| 7335412145 | |||
| feea1a1d3b | |||
| 7f4d31a79c | |||
| 4041a7d08e | |||
|
|
9846cf3e4c | ||
| 681d949098 | |||
| 3bf8fd0c22 | |||
| fa41fb3415 | |||
| 6dbfc5f77d | |||
| 1b9ae96006 | |||
|
|
4dd5664462 | ||
|
|
7d6a45061d | ||
|
|
3b32c2b156 | ||
|
|
1ee6203f4c | ||
|
|
d93299c352 | ||
|
|
9aea7a576d | ||
| 714bb169fa | |||
| 606d9ec734 | |||
| 7a3bd069b8 | |||
| b1ac9e71cb | |||
| c1176fa24d | |||
| 1cf6660e6c | |||
| 6957678474 | |||
| 889b6d5737 | |||
| 1be10b1511 | |||
| 85405317ee | |||
| 072497a553 | |||
| 8a33ccfdcf | |||
| 7311d36726 | |||
| 7e819ea4de | |||
| 5670f23bf3 | |||
| 08d9ca3a25 | |||
| 607666a2f9 | |||
| 0a618cc4ff | |||
| e387794db3 | |||
| ab1a725c1b | |||
| 46d76013e8 | |||
| faf8f4f6a9 | |||
| 154fcd98a5 | |||
| c391385500 | |||
| b64f35869e | |||
| 45fbf7ade5 | |||
| 92fcffdfc5 | |||
| 5f5562f5e3 | |||
| 74ed025377 | |||
| f36ecfd8db | |||
| ee6a344daf | |||
| 65a5edf26b | |||
| fc70a11bd8 | |||
| 73f890beac | |||
| 67dce9e678 | |||
| 2b66ddfb83 | |||
| 56fc2893a2 | |||
|
|
552ad5a267 | ||
|
|
910f57ec7d | ||
|
|
e813315dad | ||
| aea9626c06 | |||
|
|
7f0f1b7fc8 | ||
|
|
cfc4d0a947 | ||
|
|
8684488def | ||
|
|
a820a7b131 | ||
| 30d45c0acf | |||
| 221bb2a27c | |||
| 2961e29831 | |||
| 5ae5e110c2 | |||
| 20c2954be1 | |||
| a848e1fa81 | |||
| 85bd807bcc | |||
| eeece8a1b4 | |||
| bbfc1e1007 | |||
| 433d0c023e | |||
| ac6376243b | |||
| a12f033b72 | |||
| 42cd7d00de | |||
| c388cc8cfe | |||
| 6d4d4e40c3 | |||
| 3b39faf173 | |||
| f43ecc98aa | |||
| 5b7ccf9ef0 | |||
| 9bacd4da87 | |||
|
|
ee28b18b14 | ||
| 7450d8d1c3 | |||
| 7490cfc557 | |||
| 95287e4dd0 | |||
| 679d1a70e8 | |||
| 047fb263dd | |||
| b76cf28bc2 | |||
| 58c091cdaa | |||
| 0df5a975f3 | |||
| 94051e6ba9 | |||
| 8e60f53f0b | |||
| afc48a5434 | |||
| 6eb3381a98 | |||
| 2bec218cc5 | |||
| 327c655fb3 | |||
| 866aad069f | |||
| 7f6c938029 | |||
| 6d2df4a50c | |||
| 7305606546 | |||
| 2a9ff8aa77 | |||
| 829994491c | |||
| ce06e8f0fa | |||
| 1ee751eea8 | |||
|
|
2d38183dce | ||
|
|
082a6eae1f | ||
|
|
d07fb47721 | ||
|
|
ccb6160bca | ||
| 116b239616 | |||
|
|
2eaa4203aa | ||
|
|
f27a18c712 | ||
| f47346cc35 | |||
|
|
2c4a920c3c | ||
| 0e02268950 | |||
| 94d9c425ad | |||
|
|
ed91cadd9d | ||
|
|
a6de282aec | ||
| 2db662c125 | |||
| b7892f4dfa | |||
|
|
3bbb138299 | ||
|
|
5b5c631001 | ||
|
|
e60b56a0b0 | ||
|
|
d3e025c293 | ||
|
|
6f4027f614 | ||
| 249811efe3 | |||
| bd2455458f | |||
| a053c48819 | |||
| 9486142b2a | |||
|
|
2fba7f2a55 | ||
|
|
31d13b9143 | ||
|
|
852bd93f3f | ||
| b707bfce40 | |||
| bdb8e2e32a | |||
| 06b173e861 | |||
| 6a8b9d36a7 | |||
| 52a6451a2d | |||
| 4b9cbd0e9f | |||
| a5e0c847b1 | |||
|
|
fd43da93a5 | ||
| b59bcf249a | |||
| b05b602acd | |||
| b8aaffbf8d | |||
|
|
5501ac1a2f | ||
|
|
b514d64068 | ||
|
|
c4537420b4 | ||
|
|
5f50338dd0 | ||
| 308386d829 | |||
| 999d7abc04 | |||
| f7f947bfdd | |||
| 26d9b134c7 | |||
| 43f942c905 | |||
| 8ee610c1bc | |||
| 8d15b7bfb8 | |||
| 5c57ee3e72 | |||
|
|
3f7bcbfd76 | ||
| ef0988c9ec | |||
| 22de6113e9 | |||
| 87139f203c | |||
| c8de13d376 | |||
| 2ccfb283b4 | |||
| 552fce3281 | |||
| 12de3dec4f | |||
| b171e1ae13 | |||
| dc54006fca | |||
| 9b4db018f5 | |||
| 519f320a2e | |||
|
|
f1b3094026 | ||
|
|
e5ad87f4d5 | ||
|
|
7de6171911 | ||
|
|
bb6bacac97 | ||
|
|
40fc6a29a4 | ||
|
|
9ec19fa4ee | ||
| 28b20f86ea | |||
| 502109de4b | |||
| 97274a701d | |||
| 81a6d73f2f | |||
| 5804f692b7 | |||
| 257aa8d49e | |||
| 34806b514b | |||
| 0024238ca8 | |||
| 0af05b4b0d | |||
| b9d59eb642 | |||
| 0c05505c46 | |||
| 98c093f655 | |||
| 88112e0629 | |||
|
|
6ab92a83bd | ||
|
|
bfc52151c0 | ||
|
|
868b5413de | ||
|
|
50005a0dc3 | ||
|
|
9247b6ed1f | ||
|
|
75f26ccf2d | ||
|
|
bfd2498906 | ||
|
|
4933017e9c | ||
|
|
18c23451bb | ||
|
|
304985f88d | ||
|
|
9a41aff8f0 | ||
|
|
e19cd980b4 | ||
| 6d1756b4a5 | |||
| ac4c92d8e8 | |||
| 937a3cb6c6 | |||
| 194f741984 | |||
| b31c0d975c | |||
| f09684d7cd | |||
| 1767a48a7f | |||
| d2e2fc707e | |||
| bf6830a1a8 | |||
|
|
fe09f5180d | ||
|
|
5addc3c206 | ||
|
|
69f2f3cfd2 | ||
|
|
4de66b1609 | ||
|
|
4b87692231 | ||
|
|
503bb1bd93 | ||
|
|
9fa3b8be0b | ||
| 3b1a9b9c5b | |||
|
|
f55e50067f | ||
| 7f48149d6f | |||
| c5b4921583 | |||
| b28689ad06 | |||
| 0444b5be32 | |||
| 4866416aae | |||
| e48a4ed05b | |||
| 87cfead094 | |||
| 179a5cd9f8 | |||
|
|
eff67c2a4a | ||
|
|
db22d559b7 | ||
|
|
c4443f2ed1 | ||
| be348461f1 | |||
| 6e2c596030 | |||
|
|
05a7758c65 | ||
|
|
c502869c5f | ||
|
|
b7aacd63e6 | ||
|
|
5bc0e27b30 | ||
|
|
a4fe94f081 | ||
|
|
8de95566df | ||
|
|
97569697f6 | ||
|
|
b9ed9d748b | ||
|
|
790d44db81 | ||
| e2bf469dc1 | |||
| d0ec7930e1 | |||
| 3e2cd1291c | |||
| 8d42fe905d | |||
| 592ffacebc | |||
| b706e65598 | |||
|
|
6e3066ae92 | ||
| a7e98c8f1a | |||
| f07c804b24 | |||
|
|
e8eae544f3 | ||
|
|
7c77578f79 | ||
|
|
34636d6047 | ||
|
|
5134e2f562 | ||
| 91b46eaaee | |||
| 31d1a449ae | |||
| 1248132076 | |||
| 015704c94e | |||
| 540ef916c2 | |||
| bee7c87a8f | |||
| 6bbc88f86c | |||
| 624abbb179 | |||
| 110ed009b2 | |||
| a5892238d5 | |||
| 8eb80a9ede | |||
|
|
32125133f0 | ||
|
|
47ade49e31 | ||
|
|
47ce91cca1 | ||
|
|
3e52b504b0 | ||
|
|
4ecea1ab0e | ||
|
|
b9fdc920ea | ||
|
|
0907d59a6a | ||
| 59ce15c744 | |||
|
|
9960a96a20 | ||
|
|
098c6c0fa0 | ||
|
|
ead37ede74 | ||
|
|
f428199228 | ||
|
|
1405b88323 | ||
|
|
44fc2850dd | ||
|
|
52d411470e | ||
|
|
ab678a900a | ||
|
|
efa59e170f | ||
|
|
7a4ceaa455 | ||
| d7a9fb6d54 | |||
| 78b98bab5e | |||
| 2493f2ad39 | |||
| cf2b80b1f5 | |||
| 00954693b5 | |||
| 2dd77f898f | |||
| c1f218c2f3 | |||
| b5e78e5dc8 | |||
| b86323ec83 | |||
| 8add6448fb | |||
| 47442655cb | |||
|
|
1d362c314b | ||
| 3eda246e85 | |||
|
|
3f13d3ea33 | ||
|
|
cef346e487 | ||
|
|
fed23a61ee | ||
|
|
b6b7c56157 | ||
| 3f8be3b4de | |||
| 21af37c2c2 | |||
| 0b7a35c9b8 | |||
| 0257901c5b | |||
| d9d6096275 | |||
| ed7d37c649 | |||
| 81dd6eb595 | |||
|
|
c61bb88788 | ||
|
|
3bd55f3ad2 | ||
|
|
3471afdf25 | ||
|
|
e25a83ff1b | ||
|
|
0fbdb45d3e | ||
|
|
dc23ba1375 | ||
|
|
08137eb000 | ||
|
|
5d49965166 | ||
| 8e8aa4356d | |||
| 59a354027e | |||
|
|
5dc80ce12a | ||
|
|
754bced2a9 | ||
|
|
e3f58bd593 | ||
|
|
3b41014083 | ||
| f568149745 | |||
| a27d035e9b | |||
| 16d0be681c | |||
|
|
5be67fd4c9 | ||
|
|
dda3ad057d | ||
|
|
cf54096326 | ||
| 49c3971cf2 | |||
|
|
80a1185faa | ||
| cd8bc73bac | |||
| e42b3ff11d | |||
| d98e95915b | |||
|
|
4758a740de | ||
|
|
0a020a4069 | ||
|
|
c859778832 | ||
|
|
c24022c41c | ||
|
|
0fd4b86a84 | ||
|
|
c31445865e | ||
|
|
0af03227a6 | ||
|
|
3c977a1f28 | ||
|
|
8d8635a3e6 | ||
|
|
bcc6de6fc0 | ||
|
|
99ea161da0 | ||
|
|
3f6dbdebef | ||
|
|
b139957e3e | ||
| 6e4f6d090a | |||
| 48227e8cf2 | |||
| 09f02ca4b2 | |||
| 9b3823ef0e | |||
| cdeece1795 | |||
| c2ebaa0a76 | |||
| 3f60051599 | |||
| a8f1e25986 | |||
| 964248e895 | |||
| a2b3cebdb3 | |||
| bc6e52774c | |||
| 643f777d10 | |||
| ec1d8404ca | |||
|
|
1d6241abbb | ||
| c40b690878 | |||
| c9c81f1e5c | |||
| a94069e70a | |||
|
|
53f42e1ad3 | ||
|
|
5f0bbccbe6 | ||
|
|
3ec9056901 | ||
|
|
6d3ab7c313 | ||
| d9e9a7b740 | |||
| ea95382fdf | |||
| 072b663ec9 | |||
|
|
6393a20e7e | ||
|
|
19d934eb28 | ||
|
|
49ce7d43b0 | ||
|
|
6233189a49 | ||
|
|
ffb0f2d37f | ||
| 502352ad36 | |||
| db7b3fff06 | |||
| bd75802a0c | |||
| 1a86730354 | |||
| a96728bec5 | |||
| 944b0ad759 | |||
| 42bf34f549 | |||
|
|
071c41b70c | ||
| 5747404fd6 | |||
| 639f630436 | |||
| a8794be2ea | |||
|
|
0726a8d3ba | ||
| aa2f484a9f | |||
| 07e7a70d56 | |||
| 6daa515d19 | |||
| d5336dbf1b | |||
| b0fc8818ee | |||
| 9f49234179 | |||
| 32351b07b7 | |||
| 0ce06bd9ac | |||
| c3c16fd15b | |||
| a16c34d4ee | |||
| 40f9de0609 | |||
| d1194297ac | |||
| 6d67a3e8e5 | |||
| b0ccd84b62 | |||
| b94e36ef3b | |||
| 0fed104be1 | |||
| aa34a2362f | |||
| 55b53955fc | |||
| fc7c1187e8 | |||
| 2feea0d645 | |||
| 8f3a11bb98 | |||
| beb7821f58 | |||
| 712b25bc71 | |||
| f039f98b61 | |||
| f7a149444a | |||
| 58e962a3bd | |||
| 7160aa3cc5 | |||
| 786f0bd94a | |||
| b5db2b4140 | |||
| faa7959929 | |||
| 3dd1b6f6f0 | |||
| 30b8b941ae | |||
| 9b1c51ba15 | |||
| 8c6c32ed20 | |||
| 0eaf72b83b | |||
| 41d3ad56f5 | |||
| 0227d32f15 | |||
| b5ab485354 | |||
| 02ae78de7b | |||
| 64f3dbd138 | |||
| f603882d42 | |||
| a9844e6e78 | |||
| e4f3f9b2e0 | |||
| d7d53a5b8c | |||
| 44ed39b5c1 | |||
|
|
0dbc018c8d | ||
| fb7d51ac4c | |||
| 85031f84c0 | |||
|
|
7208a0fad1 | ||
|
|
48ac2685b7 | ||
|
|
504da70fec | ||
|
|
67a1a07cab | ||
|
|
1974570c01 | ||
| 3b35fe7ff3 | |||
|
|
59e1311d23 | ||
| 1d47a90836 | |||
| 76e2249b5e | |||
| 00182443fd | |||
| fed1ec6397 | |||
| a20e63a57e | |||
| a3b577e2c2 | |||
| 1279ff050c | |||
| 6c05d3105f | |||
| 2e530518b1 | |||
| eadcc22e9a | |||
| 25b9dce669 | |||
| f281e41181 | |||
| 9317b59231 | |||
| c6bb7b9d42 | |||
| 27a5a3a8dd | |||
| 3177d0f4b3 | |||
| cdef139468 | |||
| a7363eadcf | |||
| f7e3a036e0 | |||
| e17140206c | |||
| 7a7c5b6ba1 | |||
| 55c0eb6114 | |||
| 0ea123e028 | |||
| fdac4f2665 | |||
| 5c75ad80af | |||
| d293d0c3e2 | |||
| c47d6d8ae4 | |||
| 07bba55a30 | |||
| 4cec3859ea | |||
| c7fa6823bc | |||
| 34a50d75b3 | |||
| 45b54db01e | |||
| fb44c8aa48 | |||
| ee32c1aef4 | |||
| ae96d88680 | |||
| 75eb712c62 | |||
| b3cdcb010a | |||
| 59d621efc1 | |||
| afc175e3e7 | |||
| 315cdc0cf1 | |||
| 5f3861049e | |||
| cfeabf05a4 | |||
| f6a7677bdc | |||
| 9cb10b8561 | |||
| d6a5bd02f3 | |||
| d5abfb0265 | |||
| 392728fd4a | |||
| 740f2f0932 | |||
| 7214882523 | |||
| 53204179a2 | |||
| 4fdfe2f824 | |||
| 682942268d | |||
| 701f71e942 | |||
| 8b0b65c55b | |||
|
|
1378106be7 | ||
|
|
2d78a46ef2 | ||
|
|
a2d1569d93 | ||
|
|
da6833a0eb | ||
| f3f55e1636 | |||
| 6c38e69f9e | |||
| f4dcfb8dad | |||
|
|
1f114bfc52 | ||
| 1ed22c9848 | |||
|
|
99c38079b3 | ||
|
|
54d556ac4b | ||
| c8feb0c35b | |||
| 2c74f358c7 | |||
| 3f1a0185a4 | |||
| f886be7844 | |||
| 83a9dc332c | |||
| 5638798ca8 | |||
| 1bedbe17c0 | |||
| d6253ca737 | |||
| 4664d697fd | |||
| fa01125c84 | |||
|
|
68eb04c137 | ||
|
|
51600b65d7 | ||
|
|
997093c695 | ||
|
|
cc57d59717 | ||
|
|
01eecfd8d9 | ||
|
|
64bd9a103d | ||
|
|
c84e597047 | ||
|
|
c61be23fee | ||
|
|
1c0881fe14 | ||
| 2a7c858662 | |||
| 8540a2de77 | |||
|
|
41d8df2238 | ||
|
|
71546ea605 | ||
|
|
487997b87c | ||
| 693df1bda1 | |||
| d3e590822e | |||
| 4e1263d041 | |||
|
|
ba85663048 | ||
| 9d566fa977 | |||
|
|
3440f28121 | ||
|
|
150b35c4c7 | ||
|
|
39c931cde9 | ||
|
|
f858a4d29a | ||
|
|
f021fcdb1c | ||
|
|
6325bcbe35 | ||
|
|
0ee35e4946 | ||
|
|
f5a2d71ed3 | ||
| c43fdcbb7f | |||
| 3034d66a5d | |||
| ba14fd4242 | |||
| 236c1c2836 | |||
| d2cea34242 | |||
|
|
3687e5e282 | ||
|
|
65381e103c | ||
|
|
07f763e167 | ||
|
|
c9919987ca | ||
|
|
9f94aad88a | ||
|
|
b4557c3596 | ||
|
|
05e969fbc4 | ||
|
|
4a407b43ae | ||
|
|
c6d0473fab | ||
|
|
607230b51c | ||
|
|
c9d5ab82fd | ||
|
|
3ac8f911ac | ||
|
|
9232afb5af | ||
|
|
2c57bbf4ee | ||
|
|
c239906a96 | ||
|
|
0fa0936c59 | ||
|
|
aad6b8273b | ||
|
|
571fd241aa | ||
|
|
e0a3f92211 | ||
|
|
b58c0ce820 | ||
|
|
cbad2e7308 | ||
|
|
84af5287de | ||
|
|
39f2d73007 | ||
|
|
ed23317b0f | ||
|
|
3c843b2f16 | ||
|
|
617de58a92 | ||
|
|
290a13fbf2 | ||
|
|
5c14275a75 | ||
|
|
3c388da22d | ||
|
|
ba143dfccd | ||
|
|
037fb09d82 | ||
|
|
3357ee08eb | ||
|
|
65d4efb936 | ||
|
|
f28e2123b1 | ||
|
|
301b96ef3a | ||
|
|
7cb2821e76 | ||
|
|
7d707e47f4 |
@@ -15,5 +15,6 @@ module.exports = {
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
||||
},
|
||||
};
|
||||
|
||||
6
.gitignore
vendored
@@ -1,13 +1,17 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
signature.bin
|
||||
*.pem
|
||||
verified.txt
|
||||
myenv
|
||||
|
||||
*~
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
# Log filesopenssl dgst -sha256 -verify public.pem -signature <(echo -n "$signature") "$signing_input"
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
87
CHANGELOG.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 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).
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
## [0.2.1] - 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
|
||||
|
||||
|
||||
## [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.
|
||||
135
README.md
@@ -1,6 +1,9 @@
|
||||
# kickstart-for-time-pwa
|
||||
# TimeSafari.app - Crowd-Funder for Time - PWA
|
||||
|
||||
## Project setup
|
||||
|
||||
We have pkgx.dev set up in package.json, so you can use `dev` to set up the dev environment.
|
||||
|
||||
```
|
||||
npm install
|
||||
```
|
||||
@@ -10,15 +13,131 @@ npm install
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
### Compiles and minifies for production
|
||||
|
||||
If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
||||
|
||||
* Update the project.task.yaml & CHANGELOG.md & the version in package.json, run `npm install`, and commit.
|
||||
|
||||
* [Tag wth the new version.](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases)
|
||||
|
||||
* If production, change src/constants/app.ts DEFAULT_*_SERVER to be "PROD" and package.json to remove "_Test". Also record what version is on production.
|
||||
|
||||
* `npm run build`
|
||||
|
||||
* `npx prettier --write ./sw_scripts/`
|
||||
|
||||
...to make sure the service worker scripts are in proper form. (It's only important if you changed something in that directory.)
|
||||
|
||||
* `cp sw_scripts/[ns]* dist/`
|
||||
|
||||
... to copy the contents of the `sw_scripts` folder to the `dist` folder - except additional_scripts.js.
|
||||
|
||||
* `rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safari`
|
||||
|
||||
* Revert src/constants/app.ts and package.json (if that was prod), edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
|
||||
|
||||
|
||||
|
||||
## Tests
|
||||
|
||||
### Register new user on test server
|
||||
|
||||
On the test server, User #0 has rights to register others, so you can start
|
||||
playing one of two ways:
|
||||
|
||||
- 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).)
|
||||
|
||||
- Alternatively, register someone else under User #0 automatically:
|
||||
|
||||
* In the `src/views/AccountViewView.vue` file, uncomment the lines referring to "testServerRegisterUser".
|
||||
|
||||
* Visit the `/account` page.
|
||||
|
||||
### 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
|
||||
|
||||
- Clear the browser cache for localhost for a new user.
|
||||
- See that it's using the test API.
|
||||
- On each page, verify the messaging.
|
||||
- On the home page, see the feed without names, and see a message prompting to generate an ID.
|
||||
- On the discovery page, check that they can see projects, and set a search area to see projects nearby.
|
||||
- As User #0 in another browser on the test API, add a give & a project. (See User #0 details above.)
|
||||
- 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 on the contacts page, add User #0 as a contact.
|
||||
- On the home page, see the feed that shows User #0 with a name.
|
||||
- Generate an ID.
|
||||
- On the home page, check that it now prompts them to get registered.
|
||||
- On the account page, check that they see messages on limits.
|
||||
- Register the ID from User #0.
|
||||
- As the new user on the home page, check that they can now record a gift.
|
||||
- On the contacts page, check that they cannot register someone else yet.
|
||||
- Walk through the functions on each page.
|
||||
|
||||
|
||||
|
||||
## Scenarios
|
||||
|
||||
- Create a new identity as prompted. Go to "Your Identity" screen and copy the ID to the clipboard.
|
||||
|
||||
- Go back to /start and import test User `did:ethr:0x000Ee5654b9742f6Fe18ea970e32b97ee2247B51` with this 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).)
|
||||
|
||||
- Go to "Your Contacts" screen and add the ID you copied to the clipboard, and hit "+" to add them.
|
||||
|
||||
- Click on the "Registration Unknown" button and register that person to be able to make claims as them.
|
||||
|
||||
### 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.)
|
||||
|
||||
|
||||
|
||||
|
||||
## 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/).
|
||||
|
||||
|
||||
### Kudos
|
||||
|
||||
Gifts make the world go 'round!
|
||||
|
||||
* [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]() 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)
|
||||
|
||||
54
openssl_signing_console.rst
Normal file
@@ -0,0 +1,54 @@
|
||||
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".
|
||||
39
openssl_signing_console.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/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
|
||||
23459
package-lock.json
generated
90
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kickstart-for-time-pwa",
|
||||
"version": "0.1.0",
|
||||
"name": "TimeSafari_Test",
|
||||
"version": "0.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
@@ -8,16 +8,67 @@
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.26.1",
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
|
||||
"@tweenjs/tween.js": "^21.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@veramo/core": "^5.4.1",
|
||||
"@veramo/credential-w3c": "^5.4.1",
|
||||
"@veramo/data-store": "^5.4.1",
|
||||
"@veramo/did-manager": "^5.4.1",
|
||||
"@veramo/did-provider-ethr": "^5.4.1",
|
||||
"@veramo/did-resolver": "^5.4.1",
|
||||
"@veramo/key-manager": "^5.4.1",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"@zxing/text-encoding": "^0.9.0",
|
||||
"axios": "^1.5.0",
|
||||
"buffer": "^6.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"core-js": "^3.32.1",
|
||||
"dexie": "^3.2.4",
|
||||
"dexie-export-import": "^4.0.7",
|
||||
"did-jwt": "^7.2.7",
|
||||
"ethereum-cryptography": "^2.1.2",
|
||||
"ethereumjs-util": "^7.1.5",
|
||||
"ethr-did-resolver": "^8.1.2",
|
||||
"git-describe": "^4.1.1",
|
||||
"jdenticon": "^3.2.0",
|
||||
"js-generate-password": "^0.1.9",
|
||||
"js-yaml": "^4.1.0",
|
||||
"localstorage-slim": "^2.5.0",
|
||||
"luxon": "^3.4.3",
|
||||
"merkletreejs": "^0.3.10",
|
||||
"moment": "^2.29.4",
|
||||
"notiwind": "^2.0.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"pina": "^0.20.2204228",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"qr-code-generator-vue3": "^1.4.21",
|
||||
"ramda": "^0.29.0",
|
||||
"readable-stream": "^4.4.2",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"vue": "^3.2.45",
|
||||
"vue-class-component": "^8.0.0-0",
|
||||
"vue-router": "^4.1.6",
|
||||
"vuex": "^4.1.0"
|
||||
"three": "^0.156.1",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"util": "^0.12.5",
|
||||
"vue": "^3.3.4",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-facing-decorator": "^3.0.2",
|
||||
"vue-qrcode-reader": "^5.4.1",
|
||||
"vue-router": "^4.2.4",
|
||||
"web-did-resolver": "^2.0.27"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.44.0",
|
||||
"@typescript-eslint/parser": "^5.44.0",
|
||||
"@types/leaflet": "^1.9.4",
|
||||
"@types/ramda": "^0.29.3",
|
||||
"@types/three": "^0.155.1",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@typescript-eslint/eslint-plugin": "^6.6.0",
|
||||
"@typescript-eslint/parser": "^6.6.0",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"@vue/cli-plugin-babel": "~5.0.8",
|
||||
"@vue/cli-plugin-eslint": "~5.0.8",
|
||||
"@vue/cli-plugin-pwa": "~5.0.8",
|
||||
@@ -25,15 +76,16 @@
|
||||
"@vue/cli-plugin-typescript": "~5.0.8",
|
||||
"@vue/cli-plugin-vuex": "~5.0.8",
|
||||
"@vue/cli-service": "~5.0.8",
|
||||
"@vue/eslint-config-typescript": "^11.0.2",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-vue": "^9.8.0",
|
||||
"postcss": "^8.4.19",
|
||||
"prettier": "^2.8.0",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "~4.9.3"
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"postcss": "^8.4.29",
|
||||
"prettier": "^3.1.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "~5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
111
project.task.yaml
Normal file
@@ -0,0 +1,111 @@
|
||||
|
||||
tasks:
|
||||
|
||||
- show VC details... somehow:
|
||||
- 01 show my VCs - most interesting, or via search
|
||||
- 04 allow user to download chains of VCs, mine + ones I can see about me from others
|
||||
- add VC confirmation
|
||||
|
||||
- look for "if is a new user" -- blank name
|
||||
- copy button for seed
|
||||
|
||||
- .5 If notifications are not enabled, add message to front page with link/button to enable
|
||||
- record donations vs gives
|
||||
- make server endpoint for full English description of limits
|
||||
- make identicons for contacts into more-memorable faces (and maybe change project identicons, too)
|
||||
|
||||
- 01 server - show all claim details when issued by the issuer
|
||||
- bug - got error adding on Firefox user #0 as contact for themselves
|
||||
- bug (that is hard to reproduce) - back-and-forth on discovery & project pages led to "You need an identity to load your projects." error on product page when I had an identity
|
||||
- bug (that is hard to reproduce) - in Chrome, install app then delete app and try from Chrome browser and see log errors "Uncaught TypeError: self.appendDailyLog is not a function"
|
||||
- bug (that is hard to reproduce) - on the second 'give' recorded on prod it showed me as the agent
|
||||
- 01 send visibility signal as a VC and store it
|
||||
- 04 remove 'rowid' references (that are sqlite-specific); may involve server
|
||||
- 04 look at other examples for better UI friend.tech
|
||||
- 01 make the prod build copy the sw_scripts
|
||||
- .5 Add start date to project
|
||||
- .3 check that Android shows "back" buttons on screens without bottom tray
|
||||
- .1 Make give description text box into something that expands as they type?
|
||||
- .5 customize favicon assignee-group:ui
|
||||
- .2 Show a warning if both giver and recipient are the same (but still allow?)
|
||||
- 01 Would it look better to shrink the buttons on many pages so they don't expand to the width of the screen? assignee-group:ui
|
||||
- .5 Display a more appealing confirmation on the map when erasing the marker
|
||||
- .5 remove references to localStorage for projectId (now that it's pulling from the path)
|
||||
- switch some checks for activeDid to check for isRegistered
|
||||
- .2 in SeedBackupView, don't load the mnemonic and keep it in memory; only load it when they click "show"
|
||||
- .5 fix cert generation on server (since it didn't happen automatically for Nov 30)
|
||||
- warn if they're using the web (android only?)
|
||||
https://developer.mozilla.org/en-US/docs/Web/API/Navigator/getInstalledRelatedApps
|
||||
https://web.dev/articles/get-installed-related-apps
|
||||
|
||||
- 04 fix lack of initial notification in Firefox (on MacOS, maybe others)
|
||||
|
||||
- contacts v+ :
|
||||
- 01 Import all the non-sensitive data (ie. contacts & settings).
|
||||
- .2 show error to user when adding a duplicate contact
|
||||
- 01 parse input more robustly (with CSV lib and not commas)
|
||||
|
||||
- stats v1 :
|
||||
- 01 show numeric stats
|
||||
- 04 show different graphic for projects vs people (gnome?) on world
|
||||
- 01 link to world for specific stats
|
||||
- .5 don't load another instance of a bush if it already exists
|
||||
- maybe - allow type annotations in World.js & landmarks.js (since we get this error - "Types are not supported by current JavaScript version")
|
||||
- 08 convert to cleaner implementation (maybe Drie -- https://github.com/janvorisek/drie)
|
||||
|
||||
- .5 show seed phrase in a QR code for transfer to another device
|
||||
- .5 on DiscoverView, switch to a filter UI (eg. just from friend
|
||||
- .5 don't show "Offer" on project screen if they aren't registered
|
||||
|
||||
- 24 Move to Vite
|
||||
- 32 accept images for projects
|
||||
- 32 accept images for contacts
|
||||
- import project interactions from GitHub/GitLab and manage signing
|
||||
|
||||
- show total time offered to & fulfilled to a project
|
||||
- show total time offered by & fulfilled by a contact
|
||||
|
||||
- linking between projects or plans :
|
||||
- show total time given to & from a project
|
||||
- terminology:
|
||||
- for subtasks: fulfills (is it really the same?), feeds, contributes to, supplies, boosts, advances
|
||||
- for blocking: blocks, precedes, comes before, is sought by -- vs follows, seeks, builds on ("contributes to" isn't specific enough, "succeeds" has different, possibly confusing meaning)
|
||||
|
||||
- .5 add "back" button to all screens that aren't part of the bottom tray
|
||||
- .5 fit as many icons as possible on home & project view screens but only going halfway down the page assignee-group:ui
|
||||
- .5 Replace Gifted/Give in ContactsView with GiftedDialog
|
||||
|
||||
- Stats :
|
||||
- 01 point out user's location on the world
|
||||
- 01 present a credential selected from the stats
|
||||
- 04 show gives spreading to other places
|
||||
- badge for most gives/receives/confirms per day/week/month
|
||||
- badge for amount given/offered to your project
|
||||
- set a goal of given/offers
|
||||
|
||||
- automated tests, eg. cypress
|
||||
|
||||
- Notifications (wake on the phone, push notifications)
|
||||
- 02 change push server so that the web-push/subscribe call sets up a thread for the 10-seconds-later push notification, but returns immediately to the callee
|
||||
- pull instead of push, maybe via scheduled runs
|
||||
- have a notification pop-up on Mac screen
|
||||
|
||||
- Connect with phone contacts
|
||||
|
||||
- Multiple identities
|
||||
|
||||
- Support KERI AIDs
|
||||
- Support Peer DIDs
|
||||
- Support messaging through DIDComm
|
||||
- Write to or read from a different ledger (eg. private ACDC, EAS & attest.sh)
|
||||
|
||||
- Do we want split first name & last name?
|
||||
|
||||
- 01 On nearby search, if user starts changing their box but cancels and goes back to the map it is zoomed far out. Fix to fit the box better.
|
||||
- 16 From the home screen, make the quick action even easier.
|
||||
|
||||
- allow some gives even if they aren't registered - maybe someday as a gift to the world, but we really want this to be built via personal connections
|
||||
|
||||
log:
|
||||
- videos for multiple identities https://youtu.be/p8L87AeD76w and for adding time to contacts https://youtu.be/7Yylczevp10 done:2023-03-29
|
||||
- project lists, contact totals & actions, multiple identifiers, stats-world, activity feed, rename of this project file (use "--follow --") milestone:2 done:2023-06-27
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 799 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 215 B After Width: | Height: | Size: 37 KiB |
BIN
public/img/textures/leafy-autumn-forest-floor.jpg
Normal file
|
After Width: | Height: | Size: 705 KiB |
11
public/models/lupine_plant/license.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
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/)
|
||||
BIN
public/models/lupine_plant/scene.bin
Normal file
229
public/models/lupine_plant/scene.gltf
Normal file
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
public/models/lupine_plant/textures/lambert2SG_baseColor.png
Normal file
|
After Width: | Height: | Size: 3.6 MiB |
BIN
public/models/lupine_plant/textures/lambert2SG_normal.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
629
src/App.vue
@@ -1,30 +1,621 @@
|
||||
<template>
|
||||
<nav>
|
||||
<router-link to="/">Home</router-link> |
|
||||
<router-link to="/about">About</router-link>
|
||||
</nav>
|
||||
<router-view />
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="w-full"
|
||||
role="alert"
|
||||
>
|
||||
<div
|
||||
v-if="notification.type === 'notification-permission'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<p v-if="serviceWorkerReady" class="text-lg mb-4">
|
||||
Would you like to <b>turn on</b> notifications for this app?
|
||||
</p>
|
||||
<p v-else class="text-lg mb-4">
|
||||
Waiting for system initialization, which may take up to 10
|
||||
seconds...
|
||||
<fa icon="spinner" spin />
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="serviceWorkerReady"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
@click="
|
||||
close(notification.id);
|
||||
turnOnNotifications();
|
||||
"
|
||||
>
|
||||
Turn on Notifications
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type === 'notification-mute'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<p class="text-lg mb-4">Mute app notifications:</p>
|
||||
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
For 1 Hour
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
For 8 Hours
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
For 24 Hours
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
Until I turn it back on
|
||||
</button>
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notification.type === 'notification-off'"
|
||||
class="absolute inset-0 h-screen flex flex-col items-center justify-center bg-slate-900/50"
|
||||
>
|
||||
<div
|
||||
class="flex w-11/12 max-w-sm mx-auto mb-3 overflow-hidden bg-white rounded-lg shadow-lg"
|
||||
>
|
||||
<div class="w-full px-6 py-6 text-slate-900 text-center">
|
||||
<p class="text-lg mb-4">
|
||||
Would you like to <b>turn off</b> notifications for this app?
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="
|
||||
close(notification.id);
|
||||
turnOffNotifications();
|
||||
"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
|
||||
>
|
||||
Turn Off Notifications
|
||||
</button>
|
||||
<button
|
||||
@click="close(notification.id)"
|
||||
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
|
||||
>
|
||||
Leave it On
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Notification>
|
||||
</div>
|
||||
</NotificationGroup>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
<style></style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
import axios from "axios";
|
||||
interface ServiceWorkerMessage {
|
||||
type: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding: 30px;
|
||||
interface ServiceWorkerResponse {
|
||||
// Define the properties and their types
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
nav a {
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
// Example interface for error
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
// Other properties as needed
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: #42b983;
|
||||
interface VapidResponse {
|
||||
data: {
|
||||
vapidKey: string;
|
||||
};
|
||||
}
|
||||
</style>
|
||||
|
||||
import { DEFAULT_PUSH_SERVER } from "@/constants/app";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { sendTestThroughPushServer } from "@/libs/util";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class App extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
b64 = "";
|
||||
serviceWorkerReady = false;
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
let pushUrl = DEFAULT_PUSH_SERVER;
|
||||
if (settings?.webPushServer) {
|
||||
pushUrl = settings.webPushServer;
|
||||
}
|
||||
|
||||
await axios
|
||||
.get(pushUrl + "/web-push/vapid")
|
||||
.then((response: VapidResponse) => {
|
||||
this.b64 = response.data?.vapidKey || "";
|
||||
console.log("Got vapid key:", this.b64);
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
console.log("New service worker is now controlling the page");
|
||||
});
|
||||
});
|
||||
if (!this.b64) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Notifications",
|
||||
text: "Could not set notifications.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.location.host.startsWith("localhost")) {
|
||||
console.log("Ignoring the error getting VAPID for local development.");
|
||||
} else {
|
||||
console.error("Got an error initializing notifications:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Setting Notifications",
|
||||
text: "Got an error setting notifications.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
// there may be a long pause here on first initialization
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
this.serviceWorkerReady = true;
|
||||
});
|
||||
}
|
||||
|
||||
private sendMessageToServiceWorker(
|
||||
message: ServiceWorkerMessage,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
const messageChannel = new MessageChannel();
|
||||
|
||||
messageChannel.port1.onmessage = (event: MessageEvent) => {
|
||||
if (event.data.error) {
|
||||
reject(event.data.error as ErrorResponse);
|
||||
} else {
|
||||
resolve(event.data as ServiceWorkerResponse);
|
||||
}
|
||||
};
|
||||
|
||||
navigator.serviceWorker.controller.postMessage(message, [
|
||||
messageChannel.port2,
|
||||
]);
|
||||
} else {
|
||||
reject("Service worker controller not available");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private askPermission(): Promise<NotificationPermission> {
|
||||
console.log("Requesting permission for notifications:", navigator);
|
||||
if (!("serviceWorker" in navigator && navigator.serviceWorker.controller)) {
|
||||
return Promise.reject("Service worker not available.");
|
||||
}
|
||||
|
||||
const secret = localStorage.getItem("secret");
|
||||
if (!secret) {
|
||||
return Promise.reject("No secret found.");
|
||||
}
|
||||
|
||||
return this.sendSecretToServiceWorker(secret)
|
||||
.then(() => this.checkNotificationSupport())
|
||||
.then(() => this.requestNotificationPermission())
|
||||
.catch((error) => Promise.reject(error));
|
||||
}
|
||||
|
||||
private sendSecretToServiceWorker(secret: string): Promise<void> {
|
||||
const message: ServiceWorkerMessage = {
|
||||
type: "SEND_LOCAL_DATA",
|
||||
data: secret,
|
||||
};
|
||||
|
||||
return this.sendMessageToServiceWorker(message).then((response) => {
|
||||
console.log("Response from service worker:", response);
|
||||
});
|
||||
}
|
||||
|
||||
private checkNotificationSupport(): Promise<void> {
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support notifications.");
|
||||
return Promise.reject("This browser does not support notifications.");
|
||||
}
|
||||
if (Notification.permission === "granted") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
return Notification.requestPermission().then((permission) => {
|
||||
if (permission !== "granted") {
|
||||
alert(
|
||||
"Allow this app permission to make notifications for personal reminders." +
|
||||
" You can adjust them at any time in your settings.",
|
||||
);
|
||||
throw new Error("We weren't granted permission.");
|
||||
}
|
||||
return permission;
|
||||
});
|
||||
}
|
||||
|
||||
public async turnOnNotifications() {
|
||||
return this.askPermission()
|
||||
.then((permission) => {
|
||||
console.log("Permission granted:", permission);
|
||||
|
||||
// Call the function and handle promises
|
||||
this.subscribeToPush()
|
||||
.then(() => {
|
||||
console.log("Subscribed successfully.");
|
||||
return navigator.serviceWorker.ready;
|
||||
})
|
||||
.then((registration) => {
|
||||
return registration.pushManager.getSubscription();
|
||||
})
|
||||
.then(async (subscription) => {
|
||||
if (subscription) {
|
||||
await this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Notification Setup Underway",
|
||||
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.sendSubscriptionToServer(subscription);
|
||||
return subscription;
|
||||
} else {
|
||||
throw new Error("Subscription object is not available.");
|
||||
}
|
||||
})
|
||||
.then(async (subscription) => {
|
||||
console.log(
|
||||
"Subscription data sent to server and all finished successfully.",
|
||||
);
|
||||
await sendTestThroughPushServer(subscription, true);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Notifications Turned On",
|
||||
text: "Notifications are on. You should see at least one on your device; if not, check the 'Troubleshoot' page.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Subscription or server communication failed:",
|
||||
error,
|
||||
);
|
||||
alert(
|
||||
"Subscription or server communication failed. Try again in a while.",
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"An error occurred setting notification permissions:",
|
||||
error,
|
||||
);
|
||||
alert("Some error occurred setting notification permissions.");
|
||||
});
|
||||
}
|
||||
|
||||
private urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
private subscribeToPush(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (!("serviceWorker" in navigator && "PushManager" in window)) {
|
||||
const errorMsg = "Push messaging is not supported";
|
||||
console.warn(errorMsg);
|
||||
return reject(new Error(errorMsg));
|
||||
}
|
||||
|
||||
if (Notification.permission !== "granted") {
|
||||
const errorMsg = "Notification permission not granted";
|
||||
console.warn(errorMsg);
|
||||
return reject(new Error(errorMsg));
|
||||
}
|
||||
|
||||
const applicationServerKey = this.urlBase64ToUint8Array(this.b64);
|
||||
const options: PushSubscriptionOptions = {
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey,
|
||||
};
|
||||
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
return registration.pushManager.subscribe(options);
|
||||
})
|
||||
.then((subscription) => {
|
||||
console.log("Push subscription successful:", subscription);
|
||||
resolve();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Push subscription failed:", error, options);
|
||||
|
||||
// Inform the user about the issue
|
||||
alert(
|
||||
"We encountered an issue setting up push notifications. " +
|
||||
"If you wish to revoke notification permissions, please do so in your browser settings.",
|
||||
);
|
||||
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private sendSubscriptionToServer(
|
||||
subscription: PushSubscription,
|
||||
): Promise<void> {
|
||||
console.log("About to send subscription...", subscription);
|
||||
return fetch("/web-push/subscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(subscription),
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to send subscription to server");
|
||||
}
|
||||
console.log("Subscription sent to server successfully.");
|
||||
});
|
||||
}
|
||||
|
||||
async turnOffNotifications() {
|
||||
let subscription;
|
||||
const pushProviderSuccess = await navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
return registration.pushManager.getSubscription();
|
||||
})
|
||||
.then((subscript) => {
|
||||
subscription = subscript;
|
||||
if (subscription) {
|
||||
return subscription.unsubscribe();
|
||||
} else {
|
||||
console.log("Subscription object is not available.");
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("Push provider server communication failed:", error);
|
||||
return false;
|
||||
});
|
||||
|
||||
const pushServerSuccess = await fetch("/web-push/unsubscribe", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(subscription),
|
||||
})
|
||||
.then((response) => {
|
||||
return response.ok;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log("Push server communication failed:", error);
|
||||
return false;
|
||||
});
|
||||
|
||||
alert(
|
||||
"Notifications are off. Push provider unsubscribe " +
|
||||
(pushProviderSuccess ? "succeeded" : "failed") +
|
||||
(pushProviderSuccess === pushServerSuccess ? " and" : " but") +
|
||||
" push server unsubscribe " +
|
||||
(pushServerSuccess ? "succeeded" : "failed") +
|
||||
".",
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
BIN
src/assets/help/apple-icon.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
3
src/assets/help/apple-share-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" enable-background="new 0 0 50 50">
|
||||
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z"/><path d="M24 7h2v21h-2z"/><path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 365 B |
BIN
src/assets/help/chrome-install-pwa.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
27
src/assets/help/creative-commons-circle.svg
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
|
||||
<g>
|
||||
<circle fill="#FFFFFF" cx="37.785" cy="28.501" r="28.836"/>
|
||||
<path d="M37.441-3.5c8.951,0,16.572,3.125,22.857,9.372c3.008,3.009,5.295,6.448,6.857,10.314
|
||||
c1.561,3.867,2.344,7.971,2.344,12.314c0,4.381-0.773,8.486-2.314,12.313c-1.543,3.828-3.82,7.21-6.828,10.143
|
||||
c-3.123,3.085-6.666,5.448-10.629,7.086c-3.961,1.638-8.057,2.457-12.285,2.457s-8.276-0.808-12.143-2.429
|
||||
c-3.866-1.618-7.333-3.961-10.4-7.027c-3.067-3.066-5.4-6.524-7-10.372S5.5,32.767,5.5,28.5c0-4.229,0.809-8.295,2.428-12.2
|
||||
c1.619-3.905,3.972-7.4,7.057-10.486C21.08-0.394,28.565-3.5,37.441-3.5z M37.557,2.272c-7.314,0-13.467,2.553-18.458,7.657
|
||||
c-2.515,2.553-4.448,5.419-5.8,8.6c-1.354,3.181-2.029,6.505-2.029,9.972c0,3.429,0.675,6.734,2.029,9.913
|
||||
c1.353,3.183,3.285,6.021,5.8,8.516c2.514,2.496,5.351,4.399,8.515,5.715c3.161,1.314,6.476,1.971,9.943,1.971
|
||||
c3.428,0,6.75-0.665,9.973-1.999c3.219-1.335,6.121-3.257,8.713-5.771c4.99-4.876,7.484-10.99,7.484-18.344
|
||||
c0-3.543-0.648-6.895-1.943-10.057c-1.293-3.162-3.18-5.98-5.654-8.458C50.984,4.844,44.795,2.272,37.557,2.272z M37.156,23.187
|
||||
l-4.287,2.229c-0.458-0.951-1.019-1.619-1.685-2c-0.667-0.38-1.286-0.571-1.858-0.571c-2.856,0-4.286,1.885-4.286,5.657
|
||||
c0,1.714,0.362,3.084,1.085,4.113c0.724,1.029,1.791,1.544,3.201,1.544c1.867,0,3.181-0.915,3.944-2.743l3.942,2
|
||||
c-0.838,1.563-2,2.791-3.486,3.686c-1.484,0.896-3.123,1.343-4.914,1.343c-2.857,0-5.163-0.875-6.915-2.629
|
||||
c-1.752-1.752-2.628-4.19-2.628-7.313c0-3.048,0.886-5.466,2.657-7.257c1.771-1.79,4.009-2.686,6.715-2.686
|
||||
C32.604,18.558,35.441,20.101,37.156,23.187z M55.613,23.187l-4.229,2.229c-0.457-0.951-1.02-1.619-1.686-2
|
||||
c-0.668-0.38-1.307-0.571-1.914-0.571c-2.857,0-4.287,1.885-4.287,5.657c0,1.714,0.363,3.084,1.086,4.113
|
||||
c0.723,1.029,1.789,1.544,3.201,1.544c1.865,0,3.18-0.915,3.941-2.743l4,2c-0.875,1.563-2.057,2.791-3.541,3.686
|
||||
c-1.486,0.896-3.105,1.343-4.857,1.343c-2.896,0-5.209-0.875-6.941-2.629c-1.736-1.752-2.602-4.19-2.602-7.313
|
||||
c0-3.048,0.885-5.466,2.658-7.257c1.77-1.79,4.008-2.686,6.713-2.686C51.117,18.558,53.938,20.101,55.613,23.187z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
24
src/assets/help/creative-commons-zero.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="64px" height="64px" viewBox="-0.5 0.5 64 64" enable-background="new -0.5 0.5 64 64" xml:space="preserve">
|
||||
<g>
|
||||
<circle fill="#FFFFFF" cx="31.325" cy="32.873" r="30.096"/>
|
||||
<path id="text2809_1_" d="M31.5,14.08c-10.565,0-13.222,9.969-13.222,18.42c0,8.452,2.656,18.42,13.222,18.42
|
||||
c10.564,0,13.221-9.968,13.221-18.42C44.721,24.049,42.064,14.08,31.5,14.08z M31.5,21.026c0.429,0,0.82,0.066,1.188,0.157
|
||||
c0.761,0.656,1.133,1.561,0.403,2.823l-7.036,12.93c-0.216-1.636-0.247-3.24-0.247-4.437C25.808,28.777,26.066,21.026,31.5,21.026z
|
||||
M36.766,26.987c0.373,1.984,0.426,4.056,0.426,5.513c0,3.723-0.258,11.475-5.69,11.475c-0.428,0-0.822-0.045-1.188-0.136
|
||||
c-0.07-0.021-0.134-0.043-0.202-0.067c-0.112-0.032-0.23-0.068-0.336-0.11c-1.21-0.515-1.972-1.446-0.874-3.093L36.766,26.987z"/>
|
||||
<path id="path2815_1_" d="M31.433,0.5c-8.877,0-16.359,3.09-22.454,9.3c-3.087,3.087-5.443,6.607-7.082,10.532
|
||||
C0.297,24.219-0.5,28.271-0.5,32.5c0,4.268,0.797,8.32,2.397,12.168c1.6,3.85,3.921,7.312,6.969,10.396
|
||||
c3.085,3.049,6.549,5.399,10.398,7.037c3.886,1.602,7.939,2.398,12.169,2.398c4.229,0,8.34-0.826,12.303-2.465
|
||||
c3.962-1.639,7.496-3.994,10.621-7.081c3.011-2.933,5.289-6.297,6.812-10.106C62.73,41,63.5,36.883,63.5,32.5
|
||||
c0-4.343-0.77-8.454-2.33-12.303c-1.562-3.885-3.848-7.32-6.857-10.33C48.025,3.619,40.385,0.5,31.433,0.5z M31.567,6.259
|
||||
c7.238,0,13.412,2.566,18.554,7.709c2.477,2.477,4.375,5.31,5.67,8.471c1.296,3.162,1.949,6.518,1.949,10.061
|
||||
c0,7.354-2.516,13.454-7.506,18.33c-2.592,2.516-5.502,4.447-8.74,5.781c-3.2,1.334-6.498,1.994-9.927,1.994
|
||||
c-3.468,0-6.788-0.653-9.949-1.948c-3.163-1.334-6.001-3.238-8.516-5.716c-2.515-2.514-4.455-5.353-5.826-8.516
|
||||
c-1.333-3.199-2.017-6.498-2.017-9.927c0-3.467,0.684-6.787,2.017-9.949c1.371-3.2,3.312-6.074,5.826-8.628
|
||||
C18.092,8.818,24.252,6.259,31.567,6.259z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src/assets/help/install-android-chrome.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src/assets/help/mac-installed-app-settings.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
src/assets/help/windows-system-enable-notifications.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 85 KiB |
@@ -9,3 +9,10 @@
|
||||
font-family: 'Work Sans', ui-sans-serif, system-ui, sans-serif !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
input:checked ~ .dot {
|
||||
transform: translateX(100%);
|
||||
background-color: #FFF !important;
|
||||
}
|
||||
}
|
||||
32
src/components/EntityIcon.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div v-html="generateIdenticon()" class="w-fit"></div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { toSvg } from "jdenticon";
|
||||
|
||||
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 EntityIcon extends Vue {
|
||||
@Prop entityId = "";
|
||||
@Prop iconSize = 0;
|
||||
|
||||
generateIdenticon() {
|
||||
const config = this.entityId ? undefined : BLANK_CONFIG;
|
||||
const svgString = toSvg(this.entityId, this.iconSize, config);
|
||||
return svgString;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped></style>
|
||||
361
src/components/GiftedDialog.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<h1 class="text-xl font-bold text-center mb-4">
|
||||
{{ message }} {{ giver?.name || "somebody not named" }}
|
||||
</h1>
|
||||
<input
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="What was received"
|
||||
v-model="description"
|
||||
/>
|
||||
<div class="flex flex-row">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 w-1/3 text-center px-2 py-2"
|
||||
@click="changeUnitCode()"
|
||||
>
|
||||
{{ UNIT_SHORT[unitCode] }}
|
||||
</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
|
||||
type="text"
|
||||
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 v-if="showGivenToUser" class="mt-2 text-right">
|
||||
<input type="checkbox" class="mr-2" v-model="givenToUser" />
|
||||
<label class="text-sm">Given to you</label>
|
||||
</div>
|
||||
<p class="text-center mb-2 mt-6 italic">
|
||||
Sign & Send to publish to the world
|
||||
</p>
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { createAndSubmitGive, GiverInputInfo } from "@/libs/endorserServer";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class GiftedDialog extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
@Prop message = "";
|
||||
@Prop projectId = "";
|
||||
@Prop showGivenToUser = false;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
|
||||
amountInput = "0";
|
||||
giver?: GiverInputInfo; // undefined means no identified giver agent
|
||||
description = "";
|
||||
givenToUser = false;
|
||||
unitCode = "HUR";
|
||||
visible = false;
|
||||
|
||||
/* eslint-disable prettier/prettier */
|
||||
UNIT_SHORT: Record<string, string> = {
|
||||
"BTC": "BTC",
|
||||
"ETH": "ETH",
|
||||
"HUR": "Hours",
|
||||
"USD": "US $",
|
||||
};
|
||||
/* eslint-enable prettier/prettier */
|
||||
|
||||
/* eslint-disable prettier/prettier */
|
||||
UNIT_LONG: Record<string, string> = {
|
||||
"BTC": "BTC",
|
||||
"ETH": "ETH",
|
||||
"HUR": "hours",
|
||||
"USD": "dollars",
|
||||
};
|
||||
/* eslint-enable prettier/prettier */
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.log("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(giver: GiverInputInfo) {
|
||||
this.description = "";
|
||||
this.giver = giver;
|
||||
// if we show "given to user" selection, default checkbox to true
|
||||
this.givenToUser = this.showGivenToUser;
|
||||
this.amountInput = "0";
|
||||
|
||||
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.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.givenToUser = this.showGivenToUser;
|
||||
this.amountInput = "0";
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
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 | undefined,
|
||||
this.description,
|
||||
parseFloat(this.amountInput),
|
||||
this.unitCode,
|
||||
).then(() => {
|
||||
this.eraseValues();
|
||||
});
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records for DID ${activeDid} but no identity was found",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param giverDid may be null
|
||||
* @param description may be an empty string
|
||||
* @param amountInput may be 0
|
||||
*/
|
||||
public async recordGive(
|
||||
giverDid?: string,
|
||||
description?: string,
|
||||
amountInput?: number,
|
||||
unitCode?: string,
|
||||
) {
|
||||
if (!this.activeDid) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must select an identity before you can record a give.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description && !amountInput) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `You must enter a description or some number of ${
|
||||
this.UNIT_LONG[this.unitCode]
|
||||
}.`,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitGive(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
giverDid,
|
||||
this.givenToUser ? this.activeDid : undefined,
|
||||
description,
|
||||
amountInput,
|
||||
unitCode,
|
||||
this.projectId,
|
||||
);
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isGiveCreationError(result.response)
|
||||
) {
|
||||
const errorMessage = this.getGiveCreationErrorMessage(result);
|
||||
console.log("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 gift was recorded.",
|
||||
},
|
||||
7000,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("Error with give recordation caught:", error);
|
||||
const message =
|
||||
error.userMessage ||
|
||||
error.response?.data?.error?.message ||
|
||||
"There was an error recording the give.";
|
||||
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
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
</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,150 +0,0 @@
|
||||
<template>
|
||||
<div class="hello">
|
||||
<h1>{{ msg }}</h1>
|
||||
<p>
|
||||
For a guide and recipes on how to configure / customize this project,<br />
|
||||
check out the
|
||||
<a href="https://cli.vuejs.org" target="_blank" rel="noopener"
|
||||
>vue-cli documentation</a
|
||||
>.
|
||||
</p>
|
||||
<h3>Installed CLI Plugins</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>babel</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>pwa</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>router</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vuex</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>eslint</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>typescript</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Essential Links</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://forum.vuejs.org" target="_blank" rel="noopener"
|
||||
>Forum</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener"
|
||||
>Community Chat</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
|
||||
>Twitter</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Ecosystem</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://router.vuejs.org" target="_blank" rel="noopener"
|
||||
>vue-router</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-devtools#vue-devtools"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vue-devtools</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
|
||||
>vue-loader</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/awesome-vue"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>awesome-vue</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
@Options({
|
||||
props: {
|
||||
msg: String,
|
||||
},
|
||||
})
|
||||
export default class HelloWorld extends Vue {
|
||||
msg!: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
h3 {
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
||||
52
src/components/InfiniteScroll.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<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>
|
||||
315
src/components/OfferDialog.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<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"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
placeholder="Description, prerequisites, terms, etc."
|
||||
v-model="description"
|
||||
/>
|
||||
<div class="flex flex-row mb-6">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||
>
|
||||
Hours
|
||||
</span>
|
||||
<div
|
||||
class="border border-r-0 border-slate-400 bg-slate-200 px-4 py-2"
|
||||
@click="decrement()"
|
||||
>
|
||||
<fa icon="chevron-left" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full border border-r-0 border-slate-400 px-2 py-2 text-center"
|
||||
v-model="hours"
|
||||
/>
|
||||
<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="flex flex-row mb-6">
|
||||
<span
|
||||
class="rounded-l border border-r-0 border-slate-400 bg-slate-200 text-center px-2 py-2"
|
||||
>
|
||||
Expiration
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full border border-slate-400 px-2 py-2 rounded-r"
|
||||
:placeholder="'Date, eg. ' + new Date().toISOString().slice(0, 10)"
|
||||
v-model="expirationDateInput"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-center mb-2 italic">Sign & Send to publish to the world</p>
|
||||
<button
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="confirm"
|
||||
>
|
||||
Sign & Send
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { createAndSubmitOffer } from "@/libs/endorserServer";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class OfferDialog extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
@Prop message = "";
|
||||
@Prop projectId = "";
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
|
||||
description = "";
|
||||
expirationDateInput = "";
|
||||
hours = "0";
|
||||
visible = false;
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.log("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() {
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.hours = `${(parseFloat(this.hours) || 0) + 1}`;
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.hours = `${Math.max(0, (parseFloat(this.hours) || 1) - 1)}`;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.close();
|
||||
this.description = "";
|
||||
this.hours = "0";
|
||||
}
|
||||
|
||||
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.hours),
|
||||
this.expirationDateInput,
|
||||
).then(() => {
|
||||
this.description = "";
|
||||
this.hours = "0";
|
||||
});
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Offer records for DID ${activeDid} but no identity was found",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param description may be an empty string
|
||||
* @param hours may be 0
|
||||
*/
|
||||
public async recordOffer(
|
||||
description?: string,
|
||||
hours?: number,
|
||||
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.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description && !hours) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You must enter a description or some number of hours.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const result = await createAndSubmitOffer(
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
identity,
|
||||
description,
|
||||
hours,
|
||||
expirationDateInput,
|
||||
this.projectId,
|
||||
);
|
||||
|
||||
if (
|
||||
result.type === "error" ||
|
||||
this.isOfferCreationError(result.response)
|
||||
) {
|
||||
const errorMessage = this.getOfferCreationErrorMessage(result);
|
||||
console.log("Error with offer creation result:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage || "There was an error creating the offer.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "That offer was recorded.",
|
||||
},
|
||||
10000,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("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>
|
||||
93
src/components/QuickNav.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<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"></fa>
|
||||
</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"></fa>
|
||||
</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="folder-open" class="fa-fw"></fa>
|
||||
</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"></fa>
|
||||
</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"></fa>
|
||||
</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>
|
||||
58
src/components/TopMessage.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="text-center text-red-500">{{ message }}</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { AppString } from "@/constants/app";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export default class TopMessage extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
@Prop selected = "";
|
||||
|
||||
message = "";
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
await db.open();
|
||||
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
if (
|
||||
settings?.warnIfTestServer &&
|
||||
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message = "You're linked to a non-prod server, user " + didPrefix;
|
||||
} else if (
|
||||
settings?.warnIfProdServer &&
|
||||
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
|
||||
) {
|
||||
const didPrefix = settings.activeDid?.slice(11, 15);
|
||||
this.message =
|
||||
"You're linked to the production server, user " + didPrefix;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Detecting Server",
|
||||
text: JSON.stringify(err),
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
110
src/components/World/World.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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 };
|
||||
19
src/components/World/components/camera.js
Normal file
@@ -0,0 +1,19 @@
|
||||
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 };
|
||||
14
src/components/World/components/lights.js
Normal file
@@ -0,0 +1,14 @@
|
||||
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 };
|
||||
254
src/components/World/components/objects/landmarks.js
Normal file
@@ -0,0 +1,254 @@
|
||||
import axios from "axios";
|
||||
import * as R from "ramda";
|
||||
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 { accountsDB, db } from "@/db";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
|
||||
const ANIMATION_DURATION_SECS = 10;
|
||||
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/";
|
||||
|
||||
export async function loadLandmarks(vue, world, scene, loop) {
|
||||
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
|
||||
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
const activeDid = settings?.activeDid || "";
|
||||
const apiServer = settings?.apiServer;
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === activeDid, accounts);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
if (identity) {
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
29
src/components/World/components/objects/terrain.js
Normal file
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
}
|
||||
11
src/components/World/components/scene.js
Normal file
@@ -0,0 +1,11 @@
|
||||
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 };
|
||||
33
src/components/World/systems/Loop.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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 };
|
||||
33
src/components/World/systems/Resizer.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const setSize = (container, camera, renderer) => {
|
||||
// These are great for full-screen, which adjusts to a window.
|
||||
const height = window.innerHeight;
|
||||
const width = window.innerWidth - 50;
|
||||
// These are better for fitting in a container, which stays that size.
|
||||
//const height = container.scrollHeight;
|
||||
//const width = container.scrollWidth;
|
||||
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
};
|
||||
|
||||
class Resizer {
|
||||
constructor(container, camera, renderer) {
|
||||
// set initial size on load
|
||||
setSize(container, camera, renderer);
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
// set the size again if a resize occurs
|
||||
setSize(container, camera, renderer);
|
||||
// perform any custom actions
|
||||
this.onResize();
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onResize() {}
|
||||
}
|
||||
|
||||
export { Resizer };
|
||||
38
src/components/World/systems/controls.js
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { MathUtils } from "three";
|
||||
|
||||
function createControls(camera, canvas) {
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
|
||||
//enable controls?
|
||||
controls.enabled = true;
|
||||
controls.autoRotate = false;
|
||||
//controls.autoRotateSpeed = 0.2;
|
||||
|
||||
// control limits
|
||||
// It's recommended to set some control boundaries,
|
||||
// to prevent the user from clipping with the objects.
|
||||
|
||||
// y axis
|
||||
controls.minPolarAngle = MathUtils.degToRad(40); // default
|
||||
controls.maxPolarAngle = MathUtils.degToRad(75);
|
||||
|
||||
// x axis
|
||||
// controls.minAzimuthAngle = ...
|
||||
// controls.maxAzimuthAngle = ...
|
||||
|
||||
//smooth camera:
|
||||
// remember to add to loop updatables to work
|
||||
controls.enableDamping = true;
|
||||
|
||||
//controls.enableZoom = false;
|
||||
controls.maxDistance = 250;
|
||||
|
||||
//controls.enablePan = false;
|
||||
|
||||
controls.tick = () => controls.update();
|
||||
|
||||
return controls;
|
||||
}
|
||||
|
||||
export { createControls };
|
||||
13
src/components/World/systems/renderer.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { WebGLRenderer } from "three";
|
||||
|
||||
function createRenderer() {
|
||||
const renderer = new WebGLRenderer({ antialias: true });
|
||||
|
||||
// turn on the physically correct lighting model
|
||||
// (The browser complains: "THREE.WebGLRenderer: the property .physicallyCorrectLights has been removed. Set renderer.useLegacyLights instead." However, that changes the lighting in a way that doesn't look better.)
|
||||
renderer.physicallyCorrectLights = true;
|
||||
|
||||
return renderer;
|
||||
}
|
||||
|
||||
export { createRenderer };
|
||||
30
src/constants/app.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Generic strings that could be used throughout the app.
|
||||
*
|
||||
* See also ../libs/veramo/setup.ts
|
||||
*/
|
||||
export enum AppString {
|
||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000",
|
||||
|
||||
PROD_PUSH_SERVER = "https://timesafari.app",
|
||||
TEST1_PUSH_SERVER = "https://test.timesafari.app",
|
||||
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com",
|
||||
}
|
||||
|
||||
export const DEFAULT_ENDORSER_API_SERVER = AppString.TEST_ENDORSER_API_SERVER;
|
||||
|
||||
export const DEFAULT_PUSH_SERVER =
|
||||
window.location.protocol + "//" + window.location.host;
|
||||
|
||||
/**
|
||||
* The possible values for "group" and "type" are in App.vue.
|
||||
* From the notiwind package
|
||||
*/
|
||||
export interface NotificationIface {
|
||||
group: string; // "alert" | "modal"
|
||||
type: string; // "toast" | "info" | "success" | "warning" | "danger"
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
57
src/db/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import BaseDexie, { Table } from "dexie";
|
||||
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
|
||||
import { Account, AccountsSchema } from "./tables/accounts";
|
||||
import { Contact, ContactSchema } from "./tables/contacts";
|
||||
import { Log, LogSchema } from "./tables/logs";
|
||||
import {
|
||||
MASTER_SETTINGS_KEY,
|
||||
Settings,
|
||||
SettingsSchema,
|
||||
} from "./tables/settings";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
|
||||
// Define types for tables that hold sensitive and non-sensitive data
|
||||
type SensitiveTables = { accounts: Table<Account> };
|
||||
type NonsensitiveTables = {
|
||||
contacts: Table<Contact>;
|
||||
logs: Table<Log>;
|
||||
settings: Table<Settings>;
|
||||
};
|
||||
|
||||
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
||||
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
||||
BaseDexie & T;
|
||||
|
||||
// Initialize Dexie databases for sensitive and non-sensitive data
|
||||
export const accountsDB = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||
const SensitiveSchemas = { ...AccountsSchema };
|
||||
|
||||
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
||||
const NonsensitiveSchemas = {
|
||||
...ContactSchema,
|
||||
...LogSchema,
|
||||
...SettingsSchema,
|
||||
};
|
||||
|
||||
// Manage the encryption key. If not present in localStorage, create and store it.
|
||||
const secret =
|
||||
localStorage.getItem("secret") || Encryption.createRandomEncryptionKey();
|
||||
if (!localStorage.getItem("secret")) localStorage.setItem("secret", secret);
|
||||
|
||||
// Apply encryption to the sensitive database using the secret key
|
||||
encrypted(accountsDB, { secretKey: secret });
|
||||
|
||||
// Define the schema for our databases
|
||||
accountsDB.version(1).stores(SensitiveSchemas);
|
||||
// v1 was contacts & settings
|
||||
// v2 added logs
|
||||
db.version(2).stores(NonsensitiveSchemas);
|
||||
|
||||
// Event handler to initialize the non-sensitive database with default settings
|
||||
db.on("populate", () => {
|
||||
db.settings.add({
|
||||
id: MASTER_SETTINGS_KEY,
|
||||
apiServer: DEFAULT_ENDORSER_API_SERVER,
|
||||
});
|
||||
});
|
||||
51
src/db/tables/accounts.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Represents an account stored in the database.
|
||||
*/
|
||||
export type Account = {
|
||||
/**
|
||||
* Auto-generated ID by Dexie.
|
||||
*/
|
||||
id?: number;
|
||||
|
||||
/**
|
||||
* The date the account was created.
|
||||
*/
|
||||
dateCreated: string;
|
||||
|
||||
/**
|
||||
* The derivation path for the account.
|
||||
*/
|
||||
derivationPath: string;
|
||||
|
||||
/**
|
||||
* Decentralized Identifier (DID) for the account.
|
||||
*/
|
||||
did: string;
|
||||
|
||||
/**
|
||||
* Stringified JSON containing underlying key material.
|
||||
* Based on the IIdentifier type from Veramo.
|
||||
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
|
||||
*/
|
||||
identity: string;
|
||||
|
||||
/**
|
||||
* The public key in hexadecimal format.
|
||||
*/
|
||||
publicKeyHex: string;
|
||||
|
||||
/**
|
||||
* The mnemonic passphrase for the account.
|
||||
*/
|
||||
mnemonic: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Schema for the accounts table in the database.
|
||||
* Fields starting with a $ character are encrypted.
|
||||
* @see {@link https://github.com/PVermeer/dexie-addon-suite-monorepo/tree/master/packages/dexie-encrypted-addon}
|
||||
*/
|
||||
export const AccountsSchema = {
|
||||
accounts:
|
||||
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex",
|
||||
};
|
||||
12
src/db/tables/contacts.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface Contact {
|
||||
did: string;
|
||||
name?: string;
|
||||
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
|
||||
publicKeyBase64?: string;
|
||||
seesMe?: boolean;
|
||||
registered?: boolean;
|
||||
}
|
||||
|
||||
export const ContactSchema = {
|
||||
contacts: "&did, name", // no need to key by other things
|
||||
};
|
||||
11
src/db/tables/logs.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface Log {
|
||||
date: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const LogSchema = {
|
||||
// Currently keyed by "date" because A) today's log data is what we need so we append, and
|
||||
// B) we don't want it to grow so we remove everything if this is the first entry today.
|
||||
// See safari-notifications.js logMessage for the associated logic.
|
||||
logs: "date", // definitely don't key by the potentially large message field
|
||||
};
|
||||
50
src/db/tables/settings.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* BoundingBox type describes the geographical bounding box coordinates.
|
||||
*/
|
||||
export type BoundingBox = {
|
||||
eastLong: number; // Eastern longitude
|
||||
maxLat: number; // Maximum (Northernmost) latitude
|
||||
minLat: number; // Minimum (Southernmost) latitude
|
||||
westLong: number; // Western longitude
|
||||
};
|
||||
|
||||
/**
|
||||
* Settings type encompasses user-specific configuration details.
|
||||
*/
|
||||
export type Settings = {
|
||||
id: number; // Only one entry, keyed with MASTER_SETTINGS_KEY
|
||||
|
||||
activeDid?: string; // Active Decentralized ID
|
||||
apiServer?: string; // API server URL
|
||||
firstName?: string; // User's first name
|
||||
isRegistered?: boolean;
|
||||
lastName?: string; // deprecated - put all names in firstName
|
||||
lastNotifiedClaimId?: string; // Last notified claim ID
|
||||
lastViewedClaimId?: string; // Last viewed claim ID
|
||||
reminderTime?: number; // Time in milliseconds since UNIX epoch for reminders
|
||||
reminderOn?: boolean; // Toggle to enable or disable reminders
|
||||
|
||||
// Array of named search boxes defined by bounding boxes
|
||||
searchBoxes?: Array<{
|
||||
name: string;
|
||||
bbox: BoundingBox;
|
||||
}>;
|
||||
|
||||
showContactGivesInline?: boolean; // Display contact inline or not
|
||||
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
|
||||
warnIfProdServer?: boolean; // Warn if using a production server
|
||||
warnIfTestServer?: boolean; // Warn if using a testing server
|
||||
webPushServer?: string; // Web Push server URL
|
||||
};
|
||||
|
||||
/**
|
||||
* Schema for the Settings table in the database.
|
||||
*/
|
||||
export const SettingsSchema = {
|
||||
settings: "id",
|
||||
};
|
||||
|
||||
/**
|
||||
* Constants.
|
||||
*/
|
||||
export const MASTER_SETTINGS_KEY = 1;
|
||||
191
src/libs/crypto/index.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { getRandomBytesSync } from "ethereum-cryptography/random";
|
||||
import { entropyToMnemonic } from "ethereum-cryptography/bip39";
|
||||
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
|
||||
import { HDNode } from "@ethersproject/hdnode";
|
||||
import * as didJwt from "did-jwt";
|
||||
import * as u8a from "uint8arrays";
|
||||
|
||||
import { ENDORSER_JWT_URL_LOCATION } from "@/libs/endorserServer";
|
||||
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
|
||||
|
||||
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'";
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {string} address
|
||||
* @param {string} publicHex
|
||||
* @param {string} privateHex
|
||||
* @param {string} derivationPath
|
||||
* @return {*} {Omit<IIdentifier, 'provider'>}
|
||||
*/
|
||||
export const newIdentifier = (
|
||||
address: string,
|
||||
publicHex: string,
|
||||
privateHex: string,
|
||||
derivationPath: string,
|
||||
): Omit<IIdentifier, keyof "provider"> => {
|
||||
return {
|
||||
did: DEFAULT_DID_PROVIDER_NAME + ":" + address,
|
||||
keys: [
|
||||
{
|
||||
kid: publicHex,
|
||||
kms: "local",
|
||||
meta: { derivationPath: derivationPath },
|
||||
privateKeyHex: privateHex,
|
||||
publicKeyHex: publicHex,
|
||||
type: "Secp256k1",
|
||||
},
|
||||
],
|
||||
provider: DEFAULT_DID_PROVIDER_NAME,
|
||||
services: [],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {string} mnemonic
|
||||
* @return {*} {[string, string, string, string]}
|
||||
*/
|
||||
export const deriveAddress = (
|
||||
mnemonic: string,
|
||||
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
|
||||
): [string, string, string, string] => {
|
||||
mnemonic = mnemonic.trim().toLowerCase();
|
||||
|
||||
const hdnode: HDNode = HDNode.fromMnemonic(mnemonic);
|
||||
const rootNode: HDNode = hdnode.derivePath(derivationPath);
|
||||
const privateHex = rootNode.privateKey.substring(2); // original starts with '0x'
|
||||
const publicHex = rootNode.publicKey.substring(2); // original starts with '0x'
|
||||
const address = rootNode.address;
|
||||
|
||||
return [address, privateHex, publicHex, derivationPath];
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @return {*} {string}
|
||||
*/
|
||||
export const generateSeed = (): string => {
|
||||
const entropy: Uint8Array = getRandomBytesSync(32);
|
||||
const mnemonic = entropyToMnemonic(entropy, wordlist);
|
||||
|
||||
return mnemonic;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retreive an access token
|
||||
*
|
||||
* @param {IIdentifier} identifier
|
||||
* @return {*}
|
||||
*/
|
||||
export const accessToken = async (identifier: IIdentifier) => {
|
||||
const did: string = identifier.did;
|
||||
const privateKeyHex: string = identifier.keys[0].privateKeyHex as string;
|
||||
|
||||
const signer = SimpleSigner(privateKeyHex);
|
||||
|
||||
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||
const endEpoch = nowEpoch + 60; // add one minute
|
||||
|
||||
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
||||
const alg = undefined; // defaults to 'ES256K', more standardized but harder to verify vs ES256K-R
|
||||
const jwt: string = await didJwt.createJWT(tokenPayload, {
|
||||
alg,
|
||||
issuer: did,
|
||||
signer,
|
||||
});
|
||||
return jwt;
|
||||
};
|
||||
|
||||
export const sign = async (privateKeyHex: string) => {
|
||||
const signer = SimpleSigner(privateKeyHex);
|
||||
|
||||
return signer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Copied out of did-jwt since it's deprecated in that library.
|
||||
*
|
||||
* The SimpleSigner returns a configured function for signing data.
|
||||
*
|
||||
* @example
|
||||
* const signer = SimpleSigner(process.env.PRIVATE_KEY)
|
||||
* signer(data, (err, signature) => {
|
||||
* ...
|
||||
* })
|
||||
*
|
||||
* @param {String} hexPrivateKey a hex encoded private key
|
||||
* @return {Function} a configured signer function
|
||||
*/
|
||||
export function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
|
||||
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
|
||||
return async (data) => {
|
||||
const signature = (await signer(data)) as string;
|
||||
return fromJose(signature);
|
||||
};
|
||||
}
|
||||
|
||||
// from did-jwt/util; see SimpleSigner above
|
||||
export function fromJose(signature: string): {
|
||||
r: string;
|
||||
s: string;
|
||||
recoveryParam?: number;
|
||||
} {
|
||||
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
|
||||
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
|
||||
throw new TypeError(
|
||||
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
|
||||
);
|
||||
}
|
||||
const r = bytesToHex(signatureBytes.slice(0, 32));
|
||||
const s = bytesToHex(signatureBytes.slice(32, 64));
|
||||
const recoveryParam =
|
||||
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
|
||||
return { r, s, recoveryParam };
|
||||
}
|
||||
|
||||
// from did-jwt/util; see SimpleSigner above
|
||||
export function bytesToHex(b: Uint8Array): string {
|
||||
return u8a.toString(b, "base16");
|
||||
}
|
||||
|
||||
/**
|
||||
@return results of uportJwtPayload:
|
||||
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
|
||||
|
||||
Note that similar code is also contained in time-safari
|
||||
*/
|
||||
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
|
||||
let jwtText = jwtUrlText;
|
||||
const endorserContextLoc = jwtText.indexOf(ENDORSER_JWT_URL_LOCATION);
|
||||
if (endorserContextLoc > -1) {
|
||||
jwtText = jwtText.substring(
|
||||
endorserContextLoc + ENDORSER_JWT_URL_LOCATION.length,
|
||||
);
|
||||
}
|
||||
|
||||
// JWT format: { header, payload, signature, data }
|
||||
const jwt = didJwt.decodeJWT(jwtText);
|
||||
|
||||
return jwt.payload;
|
||||
};
|
||||
|
||||
export const nextDerivationPath = (origDerivPath: string) => {
|
||||
let lastStr = origDerivPath.split("/").slice(-1)[0];
|
||||
if (lastStr.endsWith("'")) {
|
||||
lastStr = lastStr.slice(0, -1);
|
||||
}
|
||||
const lastNum = parseInt(lastStr, 10);
|
||||
const newLastNum = lastNum + 1;
|
||||
const newLastStr = newLastNum.toString() + (lastStr.endsWith("'") ? "'" : "");
|
||||
const newDerivPath = origDerivPath
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.concat([newLastStr])
|
||||
.join("/");
|
||||
return newDerivPath;
|
||||
};
|
||||
510
src/libs/endorserServer.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
import * as R from "ramda";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { Axios, AxiosResponse } from "axios";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
|
||||
export const SCHEMA_ORG_CONTEXT = "https://schema.org";
|
||||
// the object in RegisterAction claims
|
||||
export const SERVICE_ID = "endorser.ch";
|
||||
// the prefix for the contact URL
|
||||
export const CONTACT_URL_PREFIX = "https://endorser.ch";
|
||||
// the suffix for the contact URL
|
||||
export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt=";
|
||||
// the prefix for handle IDs, the permanent ID for claims on Endorser
|
||||
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";
|
||||
|
||||
export interface AgreeVerifiableCredential {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
// "any" because arbitrary objects can be subject of agreement
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
object: Record<any, any>;
|
||||
}
|
||||
|
||||
export interface GiverInputInfo {
|
||||
did?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface GiverOutputInfo {
|
||||
action: string;
|
||||
giver?: GiverInputInfo;
|
||||
description?: string;
|
||||
hours?: number;
|
||||
}
|
||||
|
||||
export interface ClaimResult {
|
||||
success: { claimId: string; handleId: string };
|
||||
error: { code: string; message: string };
|
||||
}
|
||||
|
||||
export interface GenericVerifiableCredential {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
}
|
||||
|
||||
export interface GenericServerRecord extends GenericVerifiableCredential {
|
||||
handleId?: string;
|
||||
id?: string;
|
||||
issuedAt?: string;
|
||||
issuer?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
claim: Record<any, any>;
|
||||
}
|
||||
export const BLANK_GENERIC_SERVER_RECORD: GenericServerRecord = {
|
||||
"@context": SCHEMA_ORG_CONTEXT,
|
||||
"@type": "",
|
||||
claim: {},
|
||||
};
|
||||
|
||||
export interface GiveServerRecord {
|
||||
agentDid: string;
|
||||
amount: number;
|
||||
amountConfirmed: number;
|
||||
description: string;
|
||||
fullClaim: GiveVerifiableCredential;
|
||||
handleId: string;
|
||||
issuedAt: string;
|
||||
recipientDid: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface OfferServerRecord {
|
||||
amount: number;
|
||||
amountGiven: number;
|
||||
offeredByDid: string;
|
||||
recipientDid: string;
|
||||
requirementsMet: boolean;
|
||||
unit: string;
|
||||
validThrough: string;
|
||||
}
|
||||
|
||||
// Note that previous VCs may have additional fields.
|
||||
// https://endorser.ch/doc/html/transactions.html#id4
|
||||
export interface GiveVerifiableCredential {
|
||||
"@context"?: string; // optional when embedded, eg. in an Agree
|
||||
"@type": "GiveAction";
|
||||
agent?: { identifier: string };
|
||||
description?: string;
|
||||
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
|
||||
identifier?: string;
|
||||
object?: { amountOfThisGood: number; unitCode: string };
|
||||
recipient?: { identifier: string };
|
||||
}
|
||||
|
||||
// Note that previous VCs may have additional fields.
|
||||
// https://endorser.ch/doc/html/transactions.html#id8
|
||||
export interface OfferVerifiableCredential {
|
||||
"@context"?: string; // optional when embedded, eg. in an Agree
|
||||
"@type": "Offer";
|
||||
description?: string;
|
||||
includesObject?: { amountOfThisGood: number; unitCode: string };
|
||||
itemOffered?: {
|
||||
description?: string;
|
||||
isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
|
||||
};
|
||||
offeredBy?: { identifier: string };
|
||||
validThrough?: string;
|
||||
}
|
||||
|
||||
// Note that previous VCs may have additional fields.
|
||||
// https://endorser.ch/doc/html/transactions.html#id7
|
||||
export interface PlanVerifiableCredential {
|
||||
"@context": "https://schema.org";
|
||||
"@type": "PlanAction";
|
||||
name: string;
|
||||
description: string;
|
||||
identifier?: string;
|
||||
location?: {
|
||||
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanServerRecord {
|
||||
agentDid?: string; // optional, if the issuer wants someone else to manage as well
|
||||
description: string;
|
||||
endTime?: string;
|
||||
issuerDid: string;
|
||||
handleId: string;
|
||||
locLat?: number;
|
||||
locLon?: number;
|
||||
startTime?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RegisterVerifiableCredential {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
agent: { identifier: string };
|
||||
object: string;
|
||||
participant: { identifier: string };
|
||||
}
|
||||
|
||||
export interface InternalError {
|
||||
error: string; // for system logging
|
||||
userMessage?: string; // for user display
|
||||
}
|
||||
|
||||
// This is used to check for hidden info.
|
||||
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
|
||||
const HIDDEN_DID = "did:none:HIDDEN";
|
||||
|
||||
export function isHiddenDid(did: string) {
|
||||
return did === HIDDEN_DID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true for any nested string where func(input) === true
|
||||
*
|
||||
* Similar logic is found in endorser-mobile.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function testRecursivelyOnString(func: (arg0: any) => boolean, input: any) {
|
||||
if (Object.prototype.toString.call(input) === "[object String]") {
|
||||
return func(input);
|
||||
} else if (input instanceof Object) {
|
||||
if (!Array.isArray(input)) {
|
||||
// it's an object
|
||||
for (const key in input) {
|
||||
if (testRecursivelyOnString(func, input[key])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// it's an array
|
||||
for (const value of input) {
|
||||
if (testRecursivelyOnString(func, value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function containsHiddenDid(obj: any) {
|
||||
return testRecursivelyOnString(isHiddenDid, obj);
|
||||
}
|
||||
|
||||
export function stripEndorserPrefix(claimId: string) {
|
||||
if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) {
|
||||
return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length);
|
||||
} else {
|
||||
return claimId;
|
||||
}
|
||||
}
|
||||
|
||||
// similar logic is found in endorser-mobile
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function removeSchemaContext(obj: any) {
|
||||
return obj["@context"] === SCHEMA_ORG_CONTEXT
|
||||
? R.omit(["@context"], obj)
|
||||
: obj;
|
||||
}
|
||||
|
||||
// similar logic is found in endorser-mobile
|
||||
export function addLastClaimOrHandleAsIdIfMissing(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
obj: any,
|
||||
lastClaimId?: string,
|
||||
handleId?: string,
|
||||
) {
|
||||
if (!obj.identifier && lastClaimId) {
|
||||
const result = R.clone(obj);
|
||||
result.lastClaimId = lastClaimId;
|
||||
return result;
|
||||
} else if (!obj.identifier && handleId) {
|
||||
const result = R.clone(obj);
|
||||
result.identifier = handleId;
|
||||
return result;
|
||||
} else {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
// return clone of object without any nested *VisibleToDids keys
|
||||
// similar logic is found in endorser-mobile
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function removeVisibleToDids(input: any): any {
|
||||
if (input instanceof Object) {
|
||||
if (!Array.isArray(input)) {
|
||||
// it's an object
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: Record<string, any> = {};
|
||||
for (const key in input) {
|
||||
if (!key.endsWith("VisibleToDids")) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
result[key] = removeVisibleToDids(R.clone(input[key]));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
// it's an array
|
||||
return R.map(removeVisibleToDids, input);
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
always returns text, maybe UNNAMED_VISIBLE or UNKNOWN_ENTITY
|
||||
|
||||
Similar logic is found in endorser-mobile.
|
||||
**/
|
||||
export function didInfo(
|
||||
did: string,
|
||||
activeDid: string,
|
||||
allMyDids: string[],
|
||||
contacts: Contact[],
|
||||
): string {
|
||||
if (!did) return "Someone Anonymous";
|
||||
|
||||
const myId = R.find(R.equals(did), allMyDids);
|
||||
if (myId) return `You${myId !== activeDid ? " (Alt ID)" : ""}`;
|
||||
|
||||
const contact = R.find((c) => c.did === did, contacts);
|
||||
return contact
|
||||
? contact.name || "Contact With No Name"
|
||||
: isHiddenDid(did)
|
||||
? "Someone Not In Network"
|
||||
: "Someone Not In Contacts";
|
||||
}
|
||||
|
||||
export interface ResultWithType {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SuccessResult extends ResultWithType {
|
||||
type: "success";
|
||||
response: AxiosResponse<ClaimResult>;
|
||||
}
|
||||
|
||||
export interface ErrorResult {
|
||||
type: "error";
|
||||
error: InternalError;
|
||||
}
|
||||
|
||||
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
|
||||
|
||||
/**
|
||||
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||
*
|
||||
* @param identity
|
||||
* @param fromDid may be null
|
||||
* @param toDid
|
||||
* @param description may be null; should have this or hours
|
||||
* @param hours may be null; should have this or description
|
||||
*/
|
||||
export async function createAndSubmitGive(
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
identity: IIdentifier,
|
||||
fromDid?: string,
|
||||
toDid?: string,
|
||||
description?: string,
|
||||
hours?: number,
|
||||
unitCode?: string,
|
||||
fulfillsProjectHandleId?: string,
|
||||
): Promise<CreateAndSubmitClaimResult> {
|
||||
const vcClaim: GiveVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "GiveAction",
|
||||
recipient: toDid ? { identifier: toDid } : undefined,
|
||||
agent: fromDid ? { identifier: fromDid } : undefined,
|
||||
description: description || undefined,
|
||||
object: hours
|
||||
? { amountOfThisGood: hours, unitCode: unitCode || "HUR" }
|
||||
: undefined,
|
||||
fulfills: fulfillsProjectHandleId
|
||||
? { "@type": "PlanAction", identifier: fulfillsProjectHandleId }
|
||||
: undefined,
|
||||
};
|
||||
return createAndSubmitClaim(
|
||||
vcClaim as GenericServerRecord,
|
||||
identity,
|
||||
apiServer,
|
||||
axios,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
|
||||
*
|
||||
* @param identity
|
||||
* @param description may be null; should have this or hours
|
||||
* @param hours may be null; should have this or description
|
||||
* @param expirationDate ISO 8601 date string YYYY-MM-DD (may be null)
|
||||
* @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
|
||||
*/
|
||||
export async function createAndSubmitOffer(
|
||||
axios: Axios,
|
||||
apiServer: string,
|
||||
identity: IIdentifier,
|
||||
description?: string,
|
||||
hours?: number,
|
||||
expirationDate?: string,
|
||||
fulfillsProjectHandleId?: string,
|
||||
): Promise<CreateAndSubmitClaimResult> {
|
||||
const vcClaim: OfferVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Offer",
|
||||
offeredBy: { identifier: identity.did },
|
||||
validThrough: expirationDate || undefined,
|
||||
};
|
||||
if (hours) {
|
||||
vcClaim.includesObject = {
|
||||
amountOfThisGood: hours,
|
||||
unitCode: "HUR",
|
||||
};
|
||||
}
|
||||
if (description) {
|
||||
vcClaim.itemOffered = { description };
|
||||
}
|
||||
if (fulfillsProjectHandleId) {
|
||||
vcClaim.itemOffered = vcClaim.itemOffered || {};
|
||||
vcClaim.itemOffered.isPartOf = {
|
||||
"@type": "PlanAction",
|
||||
identifier: fulfillsProjectHandleId,
|
||||
};
|
||||
}
|
||||
return createAndSubmitClaim(
|
||||
vcClaim as GenericServerRecord,
|
||||
identity,
|
||||
apiServer,
|
||||
axios,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAndSubmitClaim(
|
||||
vcClaim: GenericVerifiableCredential,
|
||||
identity: IIdentifier,
|
||||
apiServer: string,
|
||||
axios: Axios,
|
||||
): Promise<CreateAndSubmitClaimResult> {
|
||||
try {
|
||||
const vcPayload = {
|
||||
vc: {
|
||||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||
type: ["VerifiableCredential"],
|
||||
credentialSubject: vcClaim,
|
||||
},
|
||||
};
|
||||
|
||||
// Create a signature using private key of identity
|
||||
const firstKey = identity.keys[0];
|
||||
const privateKeyHex = firstKey?.privateKeyHex;
|
||||
|
||||
if (!privateKeyHex) {
|
||||
throw {
|
||||
error: "No private key",
|
||||
message: `Your identifier ${identity.did} is not configured correctly. Use a different identifier.`,
|
||||
};
|
||||
}
|
||||
|
||||
const signer = await SimpleSigner(privateKeyHex);
|
||||
|
||||
// Create a JWT for the request
|
||||
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
||||
issuer: identity.did,
|
||||
signer,
|
||||
});
|
||||
|
||||
// Make the xhr request payload
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = `${apiServer}/api/v2/claim`;
|
||||
const token = await accessToken(identity);
|
||||
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return { type: "success", response };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.log("Error creating claim:", error);
|
||||
const errorMessage: string =
|
||||
error.response?.data?.error?.message || error.message || "Unknown error";
|
||||
|
||||
return {
|
||||
type: "error",
|
||||
error: {
|
||||
error: errorMessage,
|
||||
userMessage: "Failed to create and submit the claim.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// from https://stackoverflow.com/a/175787/845494
|
||||
//
|
||||
export function isNumeric(str: string): boolean {
|
||||
return !isNaN(+str);
|
||||
}
|
||||
|
||||
export function numberOrZero(str: string): number {
|
||||
return isNumeric(str) ? +str : 0;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RateLimits {
|
||||
doneClaimsThisWeek: string;
|
||||
doneRegistrationsThisMonth: string;
|
||||
maxClaimsPerWeek: string;
|
||||
maxRegistrationsPerMonth: string;
|
||||
nextMonthBeginDateTime: string;
|
||||
nextWeekBeginDateTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents data about a project
|
||||
**/
|
||||
export interface ProjectData {
|
||||
/**
|
||||
* Name of the project
|
||||
**/
|
||||
name: string;
|
||||
/**
|
||||
* Description of the project
|
||||
**/
|
||||
description: string;
|
||||
/**
|
||||
* URL referencing information about the project
|
||||
**/
|
||||
handleId: string;
|
||||
/**
|
||||
* The DID of the issuer
|
||||
*/
|
||||
issuerDid: string;
|
||||
/**
|
||||
* The Identier of the project
|
||||
**/
|
||||
rowid: string;
|
||||
}
|
||||
|
||||
export interface VerifiableCredential {
|
||||
"@context": string;
|
||||
"@type": string;
|
||||
name: string;
|
||||
description: string;
|
||||
identifier?: string;
|
||||
}
|
||||
|
||||
export interface WorldProperties {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
}
|
||||
69
src/libs/util.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// many of these are also found in endorser-mobile utility.ts
|
||||
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
|
||||
import { DEFAULT_PUSH_SERVER } from "@/constants/app";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
|
||||
export const isGlobalUri = (uri: string) => {
|
||||
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
|
||||
};
|
||||
|
||||
export const sendTestThroughPushServer = async (
|
||||
subscription: PushSubscription,
|
||||
skipFilter: boolean,
|
||||
): Promise<AxiosResponse> => {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
let pushUrl: string = DEFAULT_PUSH_SERVER as string;
|
||||
if (settings?.webPushServer) {
|
||||
pushUrl = settings.webPushServer;
|
||||
}
|
||||
|
||||
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
|
||||
// This is shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
|
||||
// Use something other than "Daily Update" https://gitea.anomalistdesign.com/trent_larson/py-push-server/src/commit/3c0e196c11bc98060ec5934e99e7dbd591b5da4d/app.py#L213
|
||||
const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
|
||||
|
||||
const auth = Buffer.from(subscription.getKey("auth"));
|
||||
const authB64 = auth
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
const p256dh = Buffer.from(subscription.getKey("p256dh"));
|
||||
const p256dhB64 = p256dh
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
const newPayload = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
auth: authB64,
|
||||
p256dh: p256dhB64,
|
||||
},
|
||||
message: `Test, where you will see this message ${
|
||||
skipFilter ? "un" : ""
|
||||
}filtered.`,
|
||||
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
|
||||
};
|
||||
console.log("Sending a test web push message:", newPayload);
|
||||
const payloadStr = JSON.stringify(newPayload);
|
||||
const response = await axios.post(
|
||||
pushUrl + "/web-push/send-test",
|
||||
payloadStr,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
console.log("Got response from web push server:", response);
|
||||
return response;
|
||||
};
|
||||
7
src/libs/veramo/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// see also ../constants/app.ts and
|
||||
|
||||
function didProviderName(netName: string) {
|
||||
return "did:ethr" + (netName === "mainnet" ? "" : ":" + netName);
|
||||
}
|
||||
|
||||
export const DEFAULT_DID_PROVIDER_NAME = didProviderName("mainnet");
|
||||
124
src/main.ts
@@ -1,9 +1,129 @@
|
||||
import { createPinia } from "pinia";
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./registerServiceWorker";
|
||||
import router from "./router";
|
||||
import store from "./store";
|
||||
import axios from "axios";
|
||||
import VueAxios from "vue-axios";
|
||||
import Notifications from "notiwind";
|
||||
|
||||
import "./assets/styles/tailwind.css";
|
||||
|
||||
createApp(App).use(store).use(router).mount("#app");
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowUpRightFromSquare,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsisVertical,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileLines,
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHand,
|
||||
faHouseChimney,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQuestion,
|
||||
faQrcode,
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowUpRightFromSquare,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsisVertical,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileLines,
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHand,
|
||||
faHouseChimney,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQrcode,
|
||||
faQuestion,
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark,
|
||||
);
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
|
||||
createApp(App)
|
||||
.component("fa", FontAwesomeIcon)
|
||||
.use(createPinia())
|
||||
.use(VueAxios, axios)
|
||||
.use(router)
|
||||
.use(Notifications)
|
||||
.mount("#app");
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { register } from "register-service-worker";
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||
register("/additional-scripts.js", {
|
||||
ready() {
|
||||
console.log(
|
||||
"App is being served from cache by a service worker.\n" +
|
||||
"For more details, visit https://goo.gl/AFskqB"
|
||||
"For more details, visit https://goo.gl/AFskqB",
|
||||
);
|
||||
},
|
||||
registered() {
|
||||
@@ -24,7 +24,7 @@ if (process.env.NODE_ENV === "production") {
|
||||
},
|
||||
offline() {
|
||||
console.log(
|
||||
"No internet connection found. App is running in offline mode."
|
||||
"No internet connection found. App is running in offline mode.",
|
||||
);
|
||||
},
|
||||
error(error) {
|
||||
|
||||
@@ -1,20 +1,201 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
NavigationGuardNext,
|
||||
RouteLocationNormalized,
|
||||
RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import { accountsDB } from "@/db/index";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param to :RouteLocationNormalized
|
||||
* @param from :RouteLocationNormalized
|
||||
* @param next :NavigationGuardNext
|
||||
*/
|
||||
const enterOrStart = async (
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext,
|
||||
) => {
|
||||
await accountsDB.open();
|
||||
const num_accounts = await accountsDB.accounts.count();
|
||||
if (num_accounts > 0) {
|
||||
next();
|
||||
} else {
|
||||
next({ name: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "home" */ "../views/HomeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
name: "about",
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (about.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
path: "/account",
|
||||
name: "account",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
|
||||
import(/* webpackChunkName: "account" */ "../views/AccountViewView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/claim/:id?",
|
||||
name: "claim",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "claim" */ "../views/ClaimView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/confirm-contact",
|
||||
name: "confirm-contact",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "confirm-contact" */ "../views/ConfirmContactView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/contact-amounts",
|
||||
name: "contact-amounts",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "contact-amounts" */ "../views/ContactAmountsView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/contact-gives",
|
||||
name: "contact-gives",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "contact-gives" */ "../views/ContactGiftingView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/contact-qr",
|
||||
name: "contact-qr",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "contact-qr" */ "../views/ContactQRScanShowView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/contacts",
|
||||
name: "contacts",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "contacts" */ "../views/ContactsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/discover",
|
||||
name: "discover",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "discover" */ "../views/DiscoverView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/help",
|
||||
name: "help",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "help" */ "../views/HelpView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/help-notifications",
|
||||
name: "help-notifications",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "help-notifications" */ "../views/HelpNotificationsView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/identity-switcher",
|
||||
name: "identity-switcher",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "identity-switcher" */ "../views/IdentitySwitcherView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/import-account",
|
||||
name: "import-account",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "import-account" */ "../views/ImportAccountView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/import-derive",
|
||||
name: "import-derive",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "import-derive" */ "../views/ImportDerivedAccountView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/new-edit-account",
|
||||
name: "new-edit-account",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "new-edit-account" */ "../views/NewEditAccountView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/new-edit-commitment",
|
||||
name: "new-edit-commitment",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "new-edit-commitment" */ "../views/NewEditCommitmentView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/new-edit-project",
|
||||
name: "new-edit-project",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "new-edit-project" */ "../views/NewEditProjectView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/new-identifier",
|
||||
name: "new-identifier",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "new-identifier" */ "../views/NewIdentifierView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/project/:id?",
|
||||
name: "project",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "project" */ "../views/ProjectViewView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/projects",
|
||||
name: "projects",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "projects" */ "../views/ProjectsView.vue"),
|
||||
beforeEnter: enterOrStart,
|
||||
},
|
||||
{
|
||||
path: "/scan-contact",
|
||||
name: "scan-contact",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "scan-contact" */ "../views/ContactScanView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/search-area",
|
||||
name: "search-area",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "search-area" */ "../views/SearchAreaView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/seed-backup",
|
||||
name: "seed-backup",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "seed-backup" */ "../views/SeedBackupView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/start",
|
||||
@@ -22,11 +203,40 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "start" */ "../views/StartView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/statistics",
|
||||
name: "statistics",
|
||||
component: () =>
|
||||
import(
|
||||
/* webpackChunkName: "statistics" */ "../views/StatisticsView.vue"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/test",
|
||||
name: "test",
|
||||
component: () =>
|
||||
import(/* webpackChunkName: "test" */ "../views/TestView.vue"),
|
||||
},
|
||||
];
|
||||
|
||||
/** @type {*} */
|
||||
const router = createRouter({
|
||||
history: createWebHistory(process.env.BASE_URL),
|
||||
routes,
|
||||
});
|
||||
|
||||
const errorHandler = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any,
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
) => {
|
||||
// Handle the error here
|
||||
console.error("Caught in top level error handler:", error, to, from);
|
||||
|
||||
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
|
||||
};
|
||||
|
||||
router.onError(errorHandler); // Assign the error handler to the router instance
|
||||
|
||||
export default router;
|
||||
|
||||
20
src/store/app.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// @ts-check
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useAppStore = defineStore({
|
||||
id: "app",
|
||||
state: () => ({
|
||||
_projectId:
|
||||
typeof localStorage.getItem("projectId") === "undefined"
|
||||
? ""
|
||||
: localStorage.getItem("projectId"),
|
||||
}),
|
||||
getters: {
|
||||
projectId: (state): string => state._projectId as string,
|
||||
},
|
||||
actions: {
|
||||
async setProjectId(newProjectId: string) {
|
||||
localStorage.setItem("projectId", newProjectId);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createStore } from "vuex";
|
||||
|
||||
export default createStore({
|
||||
state: {},
|
||||
getters: {},
|
||||
mutations: {},
|
||||
actions: {},
|
||||
modules: {},
|
||||
});
|
||||
61
src/test/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import axios from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { AppString } from "@/constants/app";
|
||||
import { db } from "../db";
|
||||
import { SERVICE_ID } from "../libs/endorserServer";
|
||||
import { deriveAddress, newIdentifier } from "../libs/crypto";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
export async function testServerRegisterUser() {
|
||||
const testUser0Mnem =
|
||||
"seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control";
|
||||
|
||||
const [addr, privateHex, publicHex, deriPath] = deriveAddress(testUser0Mnem);
|
||||
|
||||
const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath);
|
||||
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
|
||||
// Make a claim
|
||||
const vcClaim = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "RegisterAction",
|
||||
agent: { did: identity0.did },
|
||||
object: SERVICE_ID,
|
||||
participant: { did: settings?.activeDid },
|
||||
};
|
||||
// Make a payload for the claim
|
||||
const vcPayload = {
|
||||
sub: "RegisterAction",
|
||||
vc: {
|
||||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||
type: ["VerifiableCredential"],
|
||||
credentialSubject: vcClaim,
|
||||
},
|
||||
};
|
||||
// create a signature using private key of identity
|
||||
// eslint-disable-next-line
|
||||
const privateKeyHex: string = identity0.keys[0].privateKeyHex!;
|
||||
const signer = await didJwt.SimpleSigner(privateKeyHex);
|
||||
const alg = undefined;
|
||||
// create a JWT for the request
|
||||
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
||||
alg: alg,
|
||||
issuer: identity0.did,
|
||||
signer: signer,
|
||||
});
|
||||
|
||||
// Make the xhr request payload
|
||||
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const endorserApiServer =
|
||||
settings?.apiServer || AppString.TEST_ENDORSER_API_SERVER;
|
||||
const url = endorserApiServer + "/api/claim";
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const resp = await axios.post(url, payload, { headers });
|
||||
console.log("User registration result:", resp);
|
||||
}
|
||||
2184
src/util.d.ts
vendored
Normal file
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
|
||||
@Options({
|
||||
components: {},
|
||||
})
|
||||
export default class AboutView extends Vue {}
|
||||
</script>
|
||||
483
src/views/ClaimView.vue
Normal file
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Back -->
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</button>
|
||||
Verifiable Claim Details
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<div>
|
||||
<div class="block flex gap-4 overflow-hidden">
|
||||
<div class="overflow-hidden">
|
||||
<h2 class="text-md font-bold">{{ veriClaim.id }}</h2>
|
||||
<div class="text-sm">
|
||||
<div>
|
||||
{{ veriClaim.claimType }}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="message" class="fa-fw text-slate-400"></fa>
|
||||
{{ veriClaim.claim?.description }}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ veriClaim.issuer }}
|
||||
</div>
|
||||
<div>
|
||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||
{{ veriClaim.issuedAt?.replace(/T/, " ").replace(/Z/, " UTC") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Confirmations</h2>
|
||||
|
||||
<span v-if="totalConfirmers() === 0">Nobody has confirmed this.</span>
|
||||
<span v-else-if="totalConfirmers() === 1">
|
||||
One person has confirmed this.
|
||||
</span>
|
||||
<span v-else> {{ totalConfirmers() }} people have confirmed this. </span>
|
||||
|
||||
<div v-if="totalConfirmers() > 0">
|
||||
<div
|
||||
v-if="
|
||||
confirmerIdList.length === 0 && confsVisibleToIdList.length === 0
|
||||
"
|
||||
>
|
||||
Nobody that you know confirmed this claim, nor do they have any
|
||||
confirmers in their network.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="confirmerIdList.length === 0 && confsVisibleToIdList.length > 0"
|
||||
>
|
||||
<!-- Only show if this person has links to confirmers (below). -->
|
||||
Nobody that you know has issued or confirmed this claim.
|
||||
</div>
|
||||
<div v-if="confirmerIdList.length > 0">
|
||||
The following people have issued or confirmed this claim.
|
||||
<ul>
|
||||
<li
|
||||
v-for="confirmerId in confirmerIdList"
|
||||
:key="confirmerId"
|
||||
class="list-disc"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="grow overflow-hidden">
|
||||
<div class="text-sm">
|
||||
{{ confirmerId }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Never need to show the following message.
|
||||
If there is nobody in the confirmerIdList then we'll show the combined "nobody" message.
|
||||
If there is somebody in the confirmerIdList then that's all they need to show.
|
||||
-->
|
||||
<!-- Nobody that you know can see someone who has confirmed this claim. -->
|
||||
|
||||
<div v-if="confsVisibleToIdList.length > 0">
|
||||
The following people can connect you with people who have issued or
|
||||
confirmed this claim.
|
||||
<ul>
|
||||
<li
|
||||
v-for="confsVisibleTo in confsVisibleToIdList"
|
||||
:key="confsVisibleTo"
|
||||
class="list-disc"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div class="grow overflow-hidden">
|
||||
<div class="text-sm">
|
||||
{{ confsVisibleTo }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div v-if="confirmerIdList.includes(activeDid)">
|
||||
You have confirmed this claim.
|
||||
</div>
|
||||
<div v-else-if="containsHiddenDid(veriClaim.claim)">
|
||||
You cannot confirm this claim because it contains data that is hidden
|
||||
from you.
|
||||
</div>
|
||||
<div v-else>
|
||||
<button
|
||||
class="bg-blue-600 text-white mt-4 px-4 py-2 rounded-md mb-4"
|
||||
@click="confirmClaim(veriClaim.id)"
|
||||
>
|
||||
Confirm Claim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Claim</h2>
|
||||
<pre
|
||||
class="text-sm overflow-x-scroll px-4 py-3 bg-slate-100 rounded-md"
|
||||
>{{ veriClaimDump }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<h2 class="font-bold uppercase text-xl mt-8 mb-2">Full Claim</h2>
|
||||
<p class="mb-4">
|
||||
The full claim includes the claim as it was originally issued, including
|
||||
the signature (ie. the proof of issuance by that person).
|
||||
</p>
|
||||
<div v-if="!fullClaim">
|
||||
<p v-if="fullClaimMessage" class="mb-4">
|
||||
{{ fullClaimMessage }}
|
||||
</p>
|
||||
<button
|
||||
v-else
|
||||
class="block w-full text-center text-md uppercase bg-blue-600 text-white px-1.5 py-2 rounded-md mb-2"
|
||||
@click="showFullClaim(veriClaim.id)"
|
||||
>
|
||||
Load Full Claim Details
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<pre>{{ fullClaimDump }}</pre>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:href="apiServer + '/api/claim/' + veriClaim.id"
|
||||
target="_blank"
|
||||
class="block w-full text-center text-md uppercase bg-blue-600 text-white px-1.5 py-2 rounded-md mb-2"
|
||||
>
|
||||
View on the Public Server
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
||||
import * as yaml from "js-yaml";
|
||||
import * as R from "ramda";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import * as util from "util";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import OfferDialog from "@/components/OfferDialog.vue";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import * as serverUtil from "@/libs/endorserServer";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav },
|
||||
})
|
||||
export default class ClaimView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
confirmerIdList = []; // list of DIDs that have confirmed this claim excluding the issuer
|
||||
confsVisibleErrorMessage = "";
|
||||
confsVisibleToIdList = []; // list of DIDs that can see any confirmer
|
||||
fullClaim = null;
|
||||
fullClaimDump = "";
|
||||
fullClaimMessage = "";
|
||||
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
|
||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||
veriClaimDump = "";
|
||||
|
||||
util = util;
|
||||
yaml = yaml;
|
||||
containsHiddenDid = serverUtil.containsHiddenDid;
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = accountsDB.accounts;
|
||||
const accountsArr = await accounts?.toArray();
|
||||
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
const pathParam = window.location.pathname.substring("/claim/".length);
|
||||
let claimId;
|
||||
if (pathParam) {
|
||||
claimId = decodeURIComponent(pathParam);
|
||||
this.loadClaim(claimId, identity);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "No claim ID was provided.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
totalConfirmers() {
|
||||
return (
|
||||
this.numConfsNotVisible +
|
||||
this.confirmerIdList.length +
|
||||
this.confsVisibleToIdList.length
|
||||
);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string): Promise<IIdentifier> {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const headers: RawAxiosRequestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (identity) {
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
didInfo(
|
||||
did: string,
|
||||
activeDid: string,
|
||||
dids: Array<string>,
|
||||
contacts: Array<Contact>,
|
||||
) {
|
||||
return serverUtil.didInfo(did, activeDid, dids, contacts);
|
||||
}
|
||||
|
||||
async loadClaim(claimId: string, identity: IIdentifier) {
|
||||
const url =
|
||||
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(claimId);
|
||||
const headers = await this.getHeaders(identity);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
this.veriClaim = resp.data;
|
||||
this.veriClaimDump = yaml.dump(this.veriClaim);
|
||||
} else {
|
||||
// actually, axios typically throws an error so we never get here
|
||||
console.log("Error getting claim:", resp);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem getting that claim. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
console.error("Error retrieving claim:", serverError);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving that claim. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmUrl =
|
||||
this.apiServer +
|
||||
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
|
||||
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
|
||||
const confirmHeaders = await this.getHeaders(identity);
|
||||
try {
|
||||
const response = await this.axios.get(confirmUrl, {
|
||||
headers: confirmHeaders,
|
||||
});
|
||||
if (response.status === 200) {
|
||||
const resultList1 = response.data.result || [];
|
||||
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
|
||||
const resultList3 = R.reject(
|
||||
(did: string) => did === this.veriClaim.issuer,
|
||||
resultList2,
|
||||
);
|
||||
this.confirmerIdList = resultList3;
|
||||
this.numConfsNotVisible = resultList1.length - resultList2.length;
|
||||
if (resultList3.length === resultList2.length) {
|
||||
// the issuer was not in the "visible" list so they must be hidden
|
||||
// so subtract them from the non-visible confirmers count
|
||||
this.numConfsNotVisible = this.numConfsNotVisible - 1;
|
||||
}
|
||||
this.confsVisibleToIdList =
|
||||
response.data.result.resultVisibleToDids || [];
|
||||
} else {
|
||||
this.confsVisibleErrorMessage =
|
||||
"Had problems retrieving confirmations.";
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
console.error("Error retrieving confirmations:", serverError);
|
||||
this.confsVisibleErrorMessage =
|
||||
"Had problems retrieving confirmations. See logs for more info.";
|
||||
}
|
||||
}
|
||||
|
||||
async showFullClaim(claimId: string) {
|
||||
await accountsDB.open();
|
||||
const accounts = accountsDB.accounts;
|
||||
const accountsArr = await accounts?.toArray();
|
||||
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
const url =
|
||||
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
|
||||
const headers = await this.getHeaders(identity);
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
this.fullClaim = resp.data;
|
||||
this.fullClaimDump = yaml.dump(this.fullClaim);
|
||||
} else {
|
||||
// actually, axios typically throws an error so we never get here
|
||||
console.log("Error getting full claim:", resp);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem getting that claim. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Error retrieving full claim:", error);
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError.response?.status === 403) {
|
||||
this.fullClaimMessage =
|
||||
"You are not authorized to view the full contents of this claim." +
|
||||
" To see all the details, ask the issuer to allow you to see their claims." +
|
||||
" If you cannot see the issuer's DID, ask someone in the Confirmations section above." +
|
||||
" If there are no connections, you will have to ask people in your" +
|
||||
" network for their help, some other way; send them to this page and" +
|
||||
" see if they can make a connection for you.";
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving that claim. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async confirmClaim() {
|
||||
if (confirm("Do you personally confirm that this is true?")) {
|
||||
// similar logic is found in endorser-mobile
|
||||
const goodClaim = serverUtil.removeSchemaContext(
|
||||
serverUtil.removeVisibleToDids(
|
||||
serverUtil.addLastClaimOrHandleAsIdIfMissing(
|
||||
this.veriClaim.claim,
|
||||
this.veriClaim.id,
|
||||
this.veriClaim.handleId,
|
||||
),
|
||||
),
|
||||
);
|
||||
const confirmationClaim: serverUtil.GenericVerifiableCredential & {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
object: any;
|
||||
} = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "AgreeAction",
|
||||
object: goodClaim,
|
||||
};
|
||||
const result = await serverUtil.createAndSubmitClaim(
|
||||
confirmationClaim,
|
||||
await this.getIdentity(this.activeDid),
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
);
|
||||
if (result.type === "success") {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: "Confirmation submitted.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
} else {
|
||||
console.log("Got error submitting the confirmation:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem submitting the confirmation. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,56 +1,54 @@
|
||||
<template>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<a
|
||||
href="account-view.html"
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><i class="fa-solid fa-chevron-left fa-fw"></i
|
||||
></a>
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
|
||||
Confirm Contact
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Would you like to add <i>Firstname</i> to your network?
|
||||
</p>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Would you like to add <i>Firstname</i> to your network?
|
||||
</p>
|
||||
|
||||
<!-- Account Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<h2 class="text-xl font-semibold mb-2">Firstname Lastname</h2>
|
||||
<!-- Account Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<h2 class="text-xl font-semibold mb-2">Firstname Lastname</h2>
|
||||
|
||||
<div class="text-slate-500 text-sm font-bold">ID</div>
|
||||
<div class="text-sm text-slate-500 mb-1">
|
||||
<span><code>did:peer:kl45kj41lk451kl3</code></span>
|
||||
</div>
|
||||
<div class="text-slate-500 text-sm font-bold">ID</div>
|
||||
<div class="text-sm text-slate-500 mb-1">
|
||||
<span><code>did:peer:kl45kj41lk451kl3</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Add Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Add Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ConfirmContactView extends Vue {}
|
||||
|
||||
405
src/views/ContactAmountsView.vue
Normal file
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<QuickNav selected="Contacts"></QuickNav>
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<h1
|
||||
id="ViewBreadcrumb"
|
||||
class="text-lg text-center font-light relative px-7"
|
||||
>
|
||||
<!-- Back -->
|
||||
<router-link
|
||||
:to="{ name: 'contacts' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
</h1>
|
||||
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
|
||||
Given with {{ contact?.name }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-around">
|
||||
<span />
|
||||
<span class="justify-around">(Only 50 most recent)</span>
|
||||
<span />
|
||||
</div>
|
||||
<div class="flex justify-around">
|
||||
<span />
|
||||
<span class="justify-around">
|
||||
(This does not include claims by them if they're not visible to you.)
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<table
|
||||
class="table-auto w-full border-t border-slate-300 text-sm sm:text-base text-center"
|
||||
>
|
||||
<thead class="bg-slate-100">
|
||||
<tr class="border-b border-slate-300">
|
||||
<th></th>
|
||||
<th class="px-1 py-2">From Them</th>
|
||||
<th></th>
|
||||
<th class="px-1 py-2">To Them</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="record in giveRecords"
|
||||
:key="record.id"
|
||||
class="border-b border-slate-300"
|
||||
>
|
||||
<td class="p-1 text-xs sm:text-sm text-left text-slate-500">
|
||||
{{ new Date(record.issuedAt).toLocaleString() }}
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||
</span>
|
||||
<button v-else @click="confirm(record)" title="Unconfirmed">
|
||||
<fa icon="circle" class="text-blue-600 fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="italic text-xs sm:text-sm text-slate-500">
|
||||
{{ record.description }}
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid == contact.did">
|
||||
<fa icon="arrow-left" class="text-slate-400 fa-fw" />
|
||||
</span>
|
||||
<span v-else>
|
||||
<fa icon="arrow-right" class="text-slate-400 fa-fw" />
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<span v-if="record.agentDid != contact.did">
|
||||
<div class="font-bold">
|
||||
{{ record.amount }} {{ record.unit }}
|
||||
<span v-if="record.amountConfirmed" title="Confirmed">
|
||||
<fa icon="circle-check" class="text-green-600 fa-fw" />
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
@click="cannotConfirmMessage()"
|
||||
title="Unconfirmed"
|
||||
>
|
||||
<fa icon="circle" class="text-slate-600 fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="italic text-xs sm:text-sm text-slate-500">
|
||||
{{ record.description }}
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as R from "ramda";
|
||||
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||
import {
|
||||
AgreeVerifiableCredential,
|
||||
GiveServerRecord,
|
||||
GiveVerifiableCredential,
|
||||
SCHEMA_ORG_CONTEXT,
|
||||
} from "@/libs/endorserServer";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { AxiosError } from "axios";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class ContactAmountssView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
contact: Contact | null = null;
|
||||
giveRecords: Array<GiveServerRecord> = [];
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const contactDid = this.$route.query.contactDid as string;
|
||||
this.contact = (await db.contacts.get(contactDid)) || null;
|
||||
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
|
||||
if (this.activeDid && this.contact) {
|
||||
this.loadGives(this.activeDid, this.contact);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.log("Error retrieving settings or gives.", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.userMessage ||
|
||||
"There was an error retrieving your settings and/or contacts and/or gives.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async loadGives(activeDid: string, contact: Contact) {
|
||||
try {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
let result: Array<GiveServerRecord> = [];
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/v2/report/gives?agentDid=" +
|
||||
encodeURIComponent(identity.did) +
|
||||
"&recipientDid=" +
|
||||
encodeURIComponent(contact.did);
|
||||
const headers = await this.getHeaders(identity);
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
result = resp.data.data;
|
||||
} else {
|
||||
console.error(
|
||||
"Got bad response status & data of",
|
||||
resp.status,
|
||||
resp.data,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: "Got an error retrieving your given time from the server.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
const url2 =
|
||||
this.apiServer +
|
||||
"/api/v2/report/gives?agentDid=" +
|
||||
encodeURIComponent(contact.did) +
|
||||
"&recipientDid=" +
|
||||
encodeURIComponent(identity.did);
|
||||
const headers2 = await this.getHeaders(identity);
|
||||
const resp2 = await this.axios.get(url2, { headers: headers2 });
|
||||
if (resp2.status === 200) {
|
||||
result = R.concat(result, resp2.data.data);
|
||||
} else {
|
||||
console.error(
|
||||
"Got bad response status & data of",
|
||||
resp2.status,
|
||||
resp2.data,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: "Got an error retrieving your given time from the server.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
const sortedResult: Array<GiveServerRecord> = R.sort(
|
||||
(a, b) =>
|
||||
new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime(),
|
||||
result,
|
||||
);
|
||||
this.giveRecords = sortedResult;
|
||||
} catch (error) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: error as string,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async confirm(record: GiveServerRecord) {
|
||||
// Make claim
|
||||
// I use clone here because otherwise it gets a Proxy object.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const origClaim: GiveVerifiableCredential = R.clone(record.fullClaim);
|
||||
if (record.fullClaim["@context"] == SCHEMA_ORG_CONTEXT) {
|
||||
delete origClaim["@context"];
|
||||
}
|
||||
origClaim["identifier"] = record.handleId;
|
||||
const vcClaim: AgreeVerifiableCredential = {
|
||||
"@context": SCHEMA_ORG_CONTEXT,
|
||||
"@type": "AgreeAction",
|
||||
object: origClaim,
|
||||
};
|
||||
|
||||
// Make a payload for the claim
|
||||
const vcPayload = {
|
||||
vc: {
|
||||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||
type: ["VerifiableCredential"],
|
||||
credentialSubject: vcClaim,
|
||||
},
|
||||
};
|
||||
|
||||
// Create a signature using private key of identity
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
if (identity.keys[0].privateKeyHex !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const privateKeyHex: string = identity.keys[0].privateKeyHex!;
|
||||
const signer = await SimpleSigner(privateKeyHex);
|
||||
const alg = undefined;
|
||||
// Create a JWT for the request
|
||||
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
||||
alg: alg,
|
||||
issuer: identity.did,
|
||||
signer: signer,
|
||||
});
|
||||
|
||||
// Make the xhr request payload
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = this.apiServer + "/api/v2/claim";
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
if (resp.data?.success) {
|
||||
record.amountConfirmed = origClaim.object?.amountOfThisGood || 1;
|
||||
}
|
||||
} catch (error) {
|
||||
let userMessage = "There was an error. See logs for more info.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
if (serverError.message) {
|
||||
userMessage = serverError.message; // Info for the user
|
||||
} else {
|
||||
userMessage = JSON.stringify(serverError.toJSON());
|
||||
}
|
||||
} else {
|
||||
userMessage = error as string;
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error With Server",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cannotConfirmMessage() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Not Allowed",
|
||||
text: "Only the recipient can confirm final receipt.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/*
|
||||
Tooltip, generated on "title" attributes on "fa" icons
|
||||
Kudos to https://www.w3schools.com/css/css_tooltip.asp
|
||||
*/
|
||||
/* Tooltip container */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
|
||||
}
|
||||
|
||||
/* Tooltip text */
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 200px;
|
||||
background-color: black;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 5px 0;
|
||||
border-radius: 6px;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Show the tooltip text when you mouse over the tooltip container */
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
}
|
||||
.tooltip:hover .tooltiptext-left {
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
167
src/views/ContactGiftingView.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<QuickNav selected="Home"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Back -->
|
||||
<router-link
|
||||
:to="{ name: 'home' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
|
||||
Give to Contacts
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<ul class="border-t border-slate-300">
|
||||
<li class="border-b border-slate-300 py-3">
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow italic text-slate-500"
|
||||
><EntityIcon
|
||||
:entityId="null"
|
||||
:iconSize="32"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
></EntityIcon>
|
||||
Anonymous
|
||||
</span>
|
||||
<span class="text-right">
|
||||
<button
|
||||
type="button"
|
||||
@click="openDialog()"
|
||||
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md"
|
||||
>
|
||||
<fa icon="gift" class="fa-fw"></fa>
|
||||
</button>
|
||||
</span>
|
||||
</h2>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
class="border-b border-slate-300 py-3"
|
||||
>
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow font-semibold"
|
||||
><EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="32"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
></EntityIcon>
|
||||
{{ contact.name || "(no name)" }}
|
||||
</span>
|
||||
<span class="text-right">
|
||||
<button
|
||||
type="button"
|
||||
@click="openDialog(contact)"
|
||||
class="block w-full text-center text-sm uppercase bg-blue-600 text-white px-3 py-1.5 rounded-md"
|
||||
>
|
||||
<fa icon="gift" class="fa-fw"></fa>
|
||||
</button>
|
||||
</span>
|
||||
</h2>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<GiftedDialog
|
||||
ref="customDialog"
|
||||
message="Received from"
|
||||
showGivenToUser="true"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { Account, AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { GiverInputInfo } from "@/libs/endorserServer";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { GiftedDialog, QuickNav, EntityIcon },
|
||||
})
|
||||
export default class ContactGiftingView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
accounts: typeof AccountsSchema;
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load Give records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.log("Error retrieving settings & contacts:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.message ||
|
||||
"There was an error retrieving your settings and/or contacts.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
openDialog(giver: GiverInputInfo) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
224
src/views/ContactQRScanShowView.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
|
||||
Your Contact Info
|
||||
</h1>
|
||||
<p v-if="!givenName" class="text-center mt-2">
|
||||
<span class="text-red">Beware!</span>
|
||||
You aren't sharing your name, so hurry and
|
||||
<router-link
|
||||
:to="{ name: 'new-edit-account' }"
|
||||
class="bg-blue-500 text-white px-1.5 py-1 rounded-md"
|
||||
>
|
||||
go here to set it for them.
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div @click="onCopyToClipboard()" v-if="activeDid">
|
||||
<!--
|
||||
Play with display options: https://qr-code-styling.com/
|
||||
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
|
||||
-->
|
||||
<QRCodeVue3
|
||||
:value="this.qrValue"
|
||||
:cornersSquareOptions="{ type: 'extra-rounded' }"
|
||||
:dotsOptions="{ type: 'square' }"
|
||||
class="flex justify-center"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center" v-else>
|
||||
You have no identitifiers yet, so
|
||||
<router-link :to="{ name: 'start' }" class="text-blue-500">
|
||||
create your identifier.
|
||||
</router-link>
|
||||
<br />
|
||||
We recommend you do that first; otherwise, these contacts won't see your
|
||||
activity.
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl text-center font-light pt-4">Scan Contact Info</h1>
|
||||
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as didJwt from "did-jwt";
|
||||
import { sha256 } from "ethereum-cryptography/sha256.js";
|
||||
import QRCodeVue3 from "qr-code-generator-vue3";
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { QrcodeStream } from "vue-qrcode-reader";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { deriveAddress, nextDerivationPath, SimpleSigner } from "@/libs/crypto";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import {
|
||||
CONTACT_URL_PREFIX,
|
||||
ENDORSER_JWT_URL_LOCATION,
|
||||
} from "@/libs/endorserServer";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Buffer = require("buffer/").Buffer;
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QrcodeStream,
|
||||
QRCodeVue3,
|
||||
QuickNav,
|
||||
},
|
||||
})
|
||||
export default class ContactQRScanShow extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
givenName = "";
|
||||
qrValue = "";
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account: Account | undefined = R.find(
|
||||
(acc) => acc.did === activeDid,
|
||||
accounts,
|
||||
);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to show contact info with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.givenName = settings?.firstName || "";
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const account = R.find((acc) => acc.did === this.activeDid, accounts);
|
||||
if (account) {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
const publicKeyHex = identity.keys[0].publicKeyHex;
|
||||
const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");
|
||||
|
||||
const newDerivPath = nextDerivationPath(account.derivationPath);
|
||||
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
|
||||
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
|
||||
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
|
||||
const nextPublicEncKeyHashBase64 =
|
||||
Buffer.from(nextPublicEncKeyHash).toString("base64");
|
||||
|
||||
const contactInfo = {
|
||||
iat: Date.now(),
|
||||
iss: this.activeDid,
|
||||
own: {
|
||||
name:
|
||||
(settings?.firstName || "") +
|
||||
(settings?.lastName ? ` ${settings.lastName}` : ""), // deprecated, pre v 0.1.3
|
||||
publicEncKey,
|
||||
nextPublicEncKeyHash: nextPublicEncKeyHashBase64,
|
||||
},
|
||||
};
|
||||
|
||||
const alg = undefined;
|
||||
const privateKeyHex: string = identity.keys[0].privateKeyHex;
|
||||
const signer = await SimpleSigner(privateKeyHex);
|
||||
// create a JWT for the request
|
||||
const vcJwt: string = await didJwt.createJWT(contactInfo, {
|
||||
alg: alg,
|
||||
issuer: identity.did,
|
||||
signer: signer,
|
||||
});
|
||||
const viewPrefix = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
|
||||
this.qrValue = viewPrefix + vcJwt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param content is the result of a QR scan, an array with one item with a rawValue property
|
||||
*/
|
||||
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onScanDetect(content: any) {
|
||||
if (content[0]?.rawValue) {
|
||||
//console.log("onDetect", content[0].rawValue);
|
||||
localStorage.setItem("contactEndorserUrl", content[0].rawValue);
|
||||
this.$router.push({ name: "contacts" });
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Invalid Contact QR Code",
|
||||
text: "No QR code detected with contact information.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onScanError(error: any) {
|
||||
console.log("Scan was invalid:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "Invalid Scan",
|
||||
text: "The scan was invalid.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
onCopyToClipboard() {
|
||||
useClipboard()
|
||||
.copy(this.qrValue)
|
||||
.then(() => {
|
||||
console.log("Contact URL:", this.qrValue);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "toast",
|
||||
title: "Copied",
|
||||
text: "Contact URL was copied to clipboard.",
|
||||
},
|
||||
2000,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,95 +1,89 @@
|
||||
<template>
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<a
|
||||
href="account-view.html"
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><i class="fa-solid fa-chevron-left fa-fw"></i
|
||||
></a>
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
|
||||
Scan Contact
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<h3 class="text-sm uppercase font-semibold mb-2">Scan a QR Code…</h3>
|
||||
<div class="bg-black rounded overflow-hidden relative mb-4">
|
||||
<img src="https://picsum.photos/400/400?random=1" class="w-full" />
|
||||
<h3 class="text-sm uppercase font-semibold mb-2">Scan a QR Code…</h3>
|
||||
<div class="bg-black rounded overflow-hidden relative mb-4">
|
||||
<img src="https://picsum.photos/400/400?random=1" class="w-full" />
|
||||
|
||||
<!-- Darken overlay -->
|
||||
<!-- Top -->
|
||||
<div class="absolute top-0 left-0 right-0 bg-black/50 h-1/4"></div>
|
||||
<!-- Reft -->
|
||||
<div class="absolute top-1/4 bottom-1/4 left-0 bg-black/50 w-1/4"></div>
|
||||
<!-- Right -->
|
||||
<div
|
||||
class="absolute top-1/4 bottom-1/4 right-0 bg-black/50 w-1/4"
|
||||
></div>
|
||||
<!-- Bottom -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black/50 h-1/4"></div>
|
||||
<!-- Darken overlay -->
|
||||
<!-- Top -->
|
||||
<div class="absolute top-0 left-0 right-0 bg-black/50 h-1/4"></div>
|
||||
<!-- Reft -->
|
||||
<div class="absolute top-1/4 bottom-1/4 left-0 bg-black/50 w-1/4"></div>
|
||||
<!-- Right -->
|
||||
<div class="absolute top-1/4 bottom-1/4 right-0 bg-black/50 w-1/4"></div>
|
||||
<!-- Bottom -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black/50 h-1/4"></div>
|
||||
|
||||
<!-- Reticle overlay -->
|
||||
<!-- Top-left -->
|
||||
<div
|
||||
class="absolute top-1/4 left-1/4 h-6 w-6 border-white border-t-4 border-l-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Top-right -->
|
||||
<div
|
||||
class="absolute top-1/4 right-1/4 h-6 w-6 border-white border-t-4 border-r-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Bottom-left -->
|
||||
<div
|
||||
class="absolute bottom-1/4 left-1/4 h-6 w-6 border-white border-b-4 border-l-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Bottom-right -->
|
||||
<div
|
||||
class="absolute bottom-1/4 right-1/4 h-6 w-6 border-white border-b-4 border-r-4 drop-shadow"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Reticle overlay -->
|
||||
<!-- Top-left -->
|
||||
<div
|
||||
class="absolute top-1/4 left-1/4 h-6 w-6 border-white border-t-4 border-l-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Top-right -->
|
||||
<div
|
||||
class="absolute top-1/4 right-1/4 h-6 w-6 border-white border-t-4 border-r-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Bottom-left -->
|
||||
<div
|
||||
class="absolute bottom-1/4 left-1/4 h-6 w-6 border-white border-b-4 border-l-4 drop-shadow"
|
||||
></div>
|
||||
<!-- Bottom-right -->
|
||||
<div
|
||||
class="absolute bottom-1/4 right-1/4 h-6 w-6 border-white border-b-4 border-r-4 drop-shadow"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm uppercase font-semibold mb-2">
|
||||
…or Enter Contact Data
|
||||
</h3>
|
||||
<h3 class="text-sm uppercase font-semibold mb-2">…or Enter Contact Data</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name (optional)"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID"
|
||||
class="block w-full rounded border border-slate-400 mb-2 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Public Key (optional)"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name (optional)"
|
||||
class="block w-full rounded border-slate-400 mb-2"
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Look Up Contact"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID"
|
||||
class="block w-full rounded border-slate-400 mb-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Public Key (optional)"
|
||||
class="block w-full rounded border-slate-400 mb-4"
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Look Up Contact"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ContactScanView extends Vue {}
|
||||
|
||||
1202
src/views/ContactsView.vue
Normal file
@@ -1,59 +1,29 @@
|
||||
<template>
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<a href="" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-house-chimney fa-fw"></i
|
||||
></a>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<a href="search.html" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-magnifying-glass fa-fw"></i
|
||||
></a>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<a href="" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-folder-open fa-fw"></i
|
||||
></a>
|
||||
</li>
|
||||
<!-- Commitments -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<a href="" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-hand fa-fw rotate-45"></i
|
||||
></a>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<a href="account-view.html" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-circle-user fa-fw"></i
|
||||
></a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<QuickNav selected="Discover"></QuickNav>
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Discover
|
||||
</h1>
|
||||
|
||||
<!-- Quick Search -->
|
||||
<form id="QuickSearch" class="mb-4 flex">
|
||||
<div id="QuickSearch" class="mb-4 flex" v-on:keyup.enter="searchSelected()">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchTerms"
|
||||
placeholder="Search…"
|
||||
class="block w-full rounded-l border-r-0 border-slate-400"
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<button
|
||||
@click="searchSelected()"
|
||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass fa-fw"></i>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Result Tabs -->
|
||||
<div class="text-center text-slate-500 border-b border-slate-300">
|
||||
@@ -61,95 +31,425 @@
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="inline-block py-3 rounded-t-lg border-b-2 active text-blue-600 border-blue-600 font-semibold"
|
||||
@click="
|
||||
projects = [];
|
||||
isLocalActive = true;
|
||||
isRemoteActive = false;
|
||||
searchLocal();
|
||||
"
|
||||
v-bind:class="computedLocalTabClassNames()"
|
||||
>
|
||||
Nearby
|
||||
<span
|
||||
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
||||
>20+</span
|
||||
v-if="isLocalActive"
|
||||
>
|
||||
{{ localCount > -1 ? localCount : "?" }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="inline-block py-3 rounded-t-lg border-b-2 border-transparent hover:text-slate-600 hover:border-slate-300"
|
||||
v-bind:class="computedRemoteTabClassNames()"
|
||||
@click="
|
||||
projects = [];
|
||||
isRemoteActive = true;
|
||||
isLocalActive = false;
|
||||
searchAll();
|
||||
"
|
||||
>
|
||||
Remote
|
||||
Anywhere
|
||||
<span
|
||||
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
|
||||
>13</span
|
||||
v-if="isRemoteActive"
|
||||
>
|
||||
{{ remoteCount > -1 ? remoteCount : "?" }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="isLocalActive">
|
||||
<div>
|
||||
<button
|
||||
class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="$router.push({ name: 'search-area' })"
|
||||
>
|
||||
Select a {{ searchBox ? "Different" : "" }} Location for Nearby Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
<div
|
||||
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
|
||||
v-if="isLoading"
|
||||
>
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<ul class="">
|
||||
<li class="border-b border-slate-300">
|
||||
<a href="project-view.html" class="block py-4 flex gap-4">
|
||||
<div class="w-12">
|
||||
<img
|
||||
src="https://picsum.photos/200/200?random=1"
|
||||
class="w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">Canyon cleanup</h2>
|
||||
<div class="text-sm">
|
||||
<i class="fa-solid fa-user fa-fw text-slate-400"></i> Rotary
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<ul>
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
v-for="project in projects"
|
||||
:key="project.handleId"
|
||||
>
|
||||
<a
|
||||
@click="onClickLoadProject(project.handleId)"
|
||||
class="block py-4 flex gap-4"
|
||||
>
|
||||
<div class="w-12">
|
||||
<EntityIcon
|
||||
:entityId="project.handleId"
|
||||
:iconSize="48"
|
||||
class="block border border-slate-300 rounded-md"
|
||||
></EntityIcon>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="border-b border-slate-300">
|
||||
<a href="project-view.html" class="block py-4 flex gap-4">
|
||||
<div class="w-12">
|
||||
<img
|
||||
src="https://picsum.photos/200/200?random=2"
|
||||
class="w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">Potluck with neighbors</h2>
|
||||
<div class="text-sm">
|
||||
<i class="fa-solid fa-user fa-fw text-slate-400"></i> Andrew A.
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">{{ project.name }}</h2>
|
||||
<div class="text-sm">
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{
|
||||
didInfo(project.issuerDid, activeDid, allMyDids, allContacts)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="border-b border-slate-300">
|
||||
<a href="project-view.html" class="block py-4 flex gap-4">
|
||||
<div class="w-12">
|
||||
<img
|
||||
src="https://picsum.photos/200/200?random=3"
|
||||
class="w-full rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<h2 class="text-base font-semibold">Historical site</h2>
|
||||
<div class="text-sm">
|
||||
<i class="fa-solid fa-user fa-fw text-slate-400"></i>
|
||||
<em>Unknown</em>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Options({
|
||||
components: {},
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { didInfo, ProjectData } from "@/libs/endorserServer";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QuickNav,
|
||||
InfiniteScroll,
|
||||
EntityIcon,
|
||||
TopMessage,
|
||||
},
|
||||
})
|
||||
export default class DiscoverView extends Vue {}
|
||||
export default class DiscoverView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
searchTerms = "";
|
||||
projects: ProjectData[] = [];
|
||||
isLoading = false;
|
||||
isLocalActive = true;
|
||||
isRemoteActive = false;
|
||||
localCount = -1;
|
||||
remoteCount = -1;
|
||||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||
|
||||
// make this function available to the Vue template
|
||||
didInfo = didInfo;
|
||||
|
||||
async mounted() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.searchBox = settings?.searchBoxes?.[0] || null;
|
||||
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
|
||||
if (this.searchBox) {
|
||||
await this.searchLocal();
|
||||
} else {
|
||||
this.isLocalActive = false;
|
||||
this.isRemoteActive = true;
|
||||
await this.searchAll();
|
||||
}
|
||||
}
|
||||
|
||||
public resetCounts() {
|
||||
this.localCount = -1;
|
||||
this.remoteCount = -1;
|
||||
}
|
||||
|
||||
public async searchSelected() {
|
||||
if (this.isLocalActive) {
|
||||
await this.searchLocal();
|
||||
} else {
|
||||
await this.searchAll();
|
||||
}
|
||||
}
|
||||
|
||||
public async buildHeaders(): Promise<HeadersInit> {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
const account = allAccounts.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
|
||||
);
|
||||
}
|
||||
|
||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||
} else {
|
||||
// it's OK without auth... we just won't get any identifiers
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
public async searchAll(beforeId?: string) {
|
||||
this.resetCounts();
|
||||
|
||||
if (!beforeId) {
|
||||
// this was an initial search so clear any previous results
|
||||
this.projects = [];
|
||||
}
|
||||
|
||||
let queryParams = "claimContents=" + encodeURIComponent(this.searchTerms);
|
||||
|
||||
if (beforeId) {
|
||||
queryParams = queryParams + `&beforeId=${beforeId}`;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const response = await fetch(
|
||||
this.apiServer + "/api/v2/report/plans?" + queryParams,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await this.buildHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const details = await response.text();
|
||||
console.log("Problem with full search:", details);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: `There was a problem accessing the server. Try again later.`,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
|
||||
throw details;
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
|
||||
const plans: ProjectData[] = results.data;
|
||||
if (plans) {
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId, issuerDid, rowid } = plan;
|
||||
this.projects.push({ name, description, handleId, issuerDid, rowid });
|
||||
}
|
||||
this.remoteCount = this.projects.length;
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
console.log("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: e.userMessage || "There was a problem retrieving projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async searchLocal(beforeId?: string) {
|
||||
this.resetCounts();
|
||||
|
||||
if (!this.searchBox) {
|
||||
this.projects = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!beforeId) {
|
||||
// this was an initial search so clear any previous results
|
||||
this.projects = [];
|
||||
}
|
||||
|
||||
const claimContents =
|
||||
"claimContents=" + encodeURIComponent(this.searchTerms);
|
||||
|
||||
let queryParams = [
|
||||
claimContents,
|
||||
"minLocLat=" + this.searchBox.bbox.minLat,
|
||||
"maxLocLat=" + this.searchBox.bbox.maxLat,
|
||||
"westLocLon=" + this.searchBox.bbox.westLong,
|
||||
"eastLocLon=" + this.searchBox.bbox.eastLong,
|
||||
].join("&");
|
||||
|
||||
if (beforeId) {
|
||||
queryParams = queryParams + `&beforeId=${beforeId}`;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const response = await fetch(
|
||||
this.apiServer + "/api/v2/report/plansByLocation?" + queryParams,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await this.buildHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const details = await response.text();
|
||||
console.log("Problem with nearby search:", details);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem accessing the server. Try again later.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
throw await response.text();
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
|
||||
if (results.data) {
|
||||
if (beforeId) {
|
||||
const plans: ProjectData[] = results.data;
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId, issuerDid, rowid } = plan;
|
||||
this.projects.push({
|
||||
name,
|
||||
description,
|
||||
handleId,
|
||||
issuerDid,
|
||||
rowid,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.projects = results.data;
|
||||
}
|
||||
this.localCount = this.projects.length;
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
console.log("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: e.userMessage || "There was a problem retrieving projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data loader used by infinite scroller
|
||||
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||
**/
|
||||
async loadMoreData(payload: boolean) {
|
||||
if (this.projects.length > 0 && payload) {
|
||||
const latestProject = this.projects[this.projects.length - 1];
|
||||
if (this.isLocalActive) {
|
||||
this.searchLocal(latestProject["rowid"]);
|
||||
} else if (this.isRemoteActive) {
|
||||
this.searchAll(latestProject["rowid"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clicking on a project entry found in the list
|
||||
* @param id of the project
|
||||
**/
|
||||
onClickLoadProject(id: string) {
|
||||
localStorage.setItem("projectId", id);
|
||||
const route = {
|
||||
path: "/project/" + encodeURIComponent(id),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
public computedLocalTabClassNames() {
|
||||
return {
|
||||
"inline-block": true,
|
||||
"py-3": true,
|
||||
"rounded-t-lg": true,
|
||||
"border-b-2": true,
|
||||
active: this.isLocalActive,
|
||||
"text-blue-600": this.isLocalActive,
|
||||
"border-blue-600": this.isLocalActive,
|
||||
"font-semibold": this.isLocalActive,
|
||||
"border-transparent": !this.isLocalActive,
|
||||
"hover:text-slate-600": !this.isLocalActive,
|
||||
"hover:border-slate-300": !this.isLocalActive,
|
||||
};
|
||||
}
|
||||
|
||||
public computedRemoteTabClassNames() {
|
||||
return {
|
||||
"inline-block": true,
|
||||
"py-3": true,
|
||||
"rounded-t-lg": true,
|
||||
"border-b-2": true,
|
||||
active: this.isRemoteActive,
|
||||
"text-blue-600": this.isRemoteActive,
|
||||
"border-blue-600": this.isRemoteActive,
|
||||
"font-semibold": this.isRemoteActive,
|
||||
"border-transparent": !this.isRemoteActive,
|
||||
"hover:text-slate-600": !this.isRemoteActive,
|
||||
"hover:border-slate-300": !this.isRemoteActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
417
src/views/HelpNotificationsView.vue
Normal file
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Notification Help
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- eslint-disable prettier/prettier -->
|
||||
<div>
|
||||
<p>Here are ways to test notifications and get them working.</p>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">Full Test</h2>
|
||||
<div>
|
||||
<p>
|
||||
If this works then you're all set.
|
||||
<button
|
||||
@click="sendTestWebPushMessage(true)"
|
||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mb-2"
|
||||
>
|
||||
Send Yourself a Test Web Push Message (Through Push Server but
|
||||
Skipping Client Filter)
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">
|
||||
If this app doesn't support notifications...
|
||||
<!-- Note that that exact verbiage shows in a message elsewhere. -->
|
||||
</h2>
|
||||
<div>
|
||||
<p>
|
||||
To be notified of interesting updates, install this app on your device
|
||||
(as opposed to using it inside the browser app). In Chrome, it may prompt
|
||||
you, and you can also look for the "Install" command in the browser
|
||||
settings; on the the desktop, look for this icon in the address bar:
|
||||
<img
|
||||
src="../assets/help/chrome-install-pwa.png"
|
||||
alt="Chrome 'install' icon"
|
||||
class="ml-4"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">
|
||||
If you must enable notifications...
|
||||
<!-- Note that that exact verbiage shows in a message elsewhere. -->
|
||||
</h2>
|
||||
<div>
|
||||
<p>
|
||||
<button class="text-blue-500" @click="showNotificationChoice()">
|
||||
Click here.
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">
|
||||
If you're waiting for system initialization...
|
||||
<!-- Note that that exact verbiage shows in a message elsewhere. -->
|
||||
</h2>
|
||||
<div>
|
||||
<p>
|
||||
... and it never stops, then there is a problem with the underlying
|
||||
service worker or push server mechanism in your browser. Your best bet
|
||||
is to follow the "Reinstall" steps below or use a different browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">Check App Permissions</h2>
|
||||
<div>
|
||||
<p>
|
||||
In Apple iOS, check "Settings" -> "Notifications", look for the Time
|
||||
Safari app (or the browser you're using), and make sure notifications
|
||||
are enabled.
|
||||
</p>
|
||||
<p>
|
||||
In Android, hold on to the app icon, then select "App Info", then
|
||||
"Notifications" and make sure they're enabled. If it's still a problem
|
||||
then go further:
|
||||
</p>
|
||||
<p>
|
||||
If you installed the app with Chrome, make sure there are no other
|
||||
tabs with it open. Here are some ways to clear caches that can mess
|
||||
things up (and note that this clears out data from the installed app
|
||||
-- which is good to do while the app is installed):
|
||||
</p>
|
||||
<ul>
|
||||
<li class="list-disc ml-4">
|
||||
Go to Chrome "App Info", then "Storage & Cache" and "Clear Storage".
|
||||
</li>
|
||||
<li class="list-disc ml-4">
|
||||
Go to Chrome "Settings", then "Privacy and Security" and "Clear
|
||||
"Clear browsing data", then "Cookies and site data". Make sure the
|
||||
"Time Range" at the top shows "All time".
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
On a Mac, go to "Settings" and check "Notifications".
|
||||
<img
|
||||
src="../assets/help/mac-installed-app-settings.png"
|
||||
alt="Mac app settings"
|
||||
class="ml-4"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">Check Browser Permissions</h2>
|
||||
<div>
|
||||
<p>In Apple iOS, check Settings -> Notifications.</p>
|
||||
<p>In Android, check Settings -> Notifications.</p>
|
||||
|
||||
You can find more details about compatibility
|
||||
<a
|
||||
href="https://developer.mozilla.org/en-US/docs/Web/API/Push_API#browser_compatibility"
|
||||
class="text-blue-500"
|
||||
target="_blank"
|
||||
>
|
||||
here <fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">
|
||||
Check Operating System (OS) Permissions
|
||||
</h2>
|
||||
<div class="px-2">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Mobile Phone - Apple iOS</h3>
|
||||
<div>
|
||||
Notifications require iOS 16.4 or higher. To check your iOS version,
|
||||
go to Settings > General > About > Software Version.
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold">Mobile Phone - Google Android</h3>
|
||||
<div>
|
||||
We recommend Chrome. It must be version 42 or higher. Check your
|
||||
version under Settings -> About Chrome.
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold">Desktop - Mac</h3>
|
||||
<div>
|
||||
<span>
|
||||
See "System Settings" -> "Notifications" and make sure it is
|
||||
enabled for the browser you're using. Note that these
|
||||
notifications require Mac OS 13; see your macOS version under
|
||||
Apple -> "About This Mac".
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold">Desktop - Windows</h3>
|
||||
In Windows, check "Settings" -> "Notifications".
|
||||
<img
|
||||
src="../assets/help/windows-system-enable-notifications.png"
|
||||
alt="Windows system settings"
|
||||
class="ml-4"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
You can find more details about compatibility
|
||||
<a
|
||||
href="https://developer.mozilla.org/en-US/docs/Web/API/Push_API#browser_compatibility"
|
||||
class="text-blue-500"
|
||||
target="_blank"
|
||||
>
|
||||
here <fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">Reinstall</h2>
|
||||
<div>
|
||||
<p>
|
||||
If all else fails, uninstall the app, ensure all the browser tabs with
|
||||
it are closed, and clear out caches and storage.
|
||||
</p>
|
||||
<p>
|
||||
Of course, you'll want to back up all your data first -- all seeds as
|
||||
well as the contacts & settings -- on the Account
|
||||
<fa icon="circle-user" /> page.
|
||||
</p>
|
||||
<ul class="ml-4 list-disc">
|
||||
<li>
|
||||
Clear cache.
|
||||
<ul>
|
||||
<li>
|
||||
In mobile, look for the browser app settings. This is true even
|
||||
for an installed app: go to the browser which you used to
|
||||
initially visit timesafari.app, because those settings affect
|
||||
the app. Look for "Delete browsing data" in the "Settings",
|
||||
under "Privacy and Security".
|
||||
</li>
|
||||
<li>
|
||||
In Chrome, go to `chrome://settings/cookies` and "all site data
|
||||
and permissions" for timesafari.app; in Firefox, go to
|
||||
`about:preferences` and search for "cache" then "Manage Data"
|
||||
for timesafari.app. Also manually remove the IndexedDB data if
|
||||
the DBs still show.)
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Clear notification permission. (In Chrome, go to
|
||||
`chrome://settings/content/notifications`; in Firefox, go to
|
||||
`about:preferences` and search for "notifications".)
|
||||
</li>
|
||||
<li>
|
||||
Unregister service worker. (In Chrome, go to
|
||||
`chrome://serviceworker-internals/`; in Firefox, go to
|
||||
`about:serviceworkers`.)
|
||||
</li>
|
||||
<li>
|
||||
Clear "Cache Storage". (In Chrome, in dev tools under "Application";
|
||||
in Firefox, in dev tools under "Storage".)
|
||||
</li>
|
||||
</ul>
|
||||
<p>Then reinstall the app.</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">Tests</h2>
|
||||
<button
|
||||
@click="showTestNotification()"
|
||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
||||
>
|
||||
Send Test Notification Directly to Device (Not Through Push Server)
|
||||
</button>
|
||||
<p>
|
||||
If that didn't show a notification on your device, the problem is that
|
||||
your browser or your operating system are not allowing notifications
|
||||
through. See "Check App Permissions" and "Check Browser Permissions" and
|
||||
"Check Operating System (OS) Permissions" above.
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="alertWebPushSubscription()"
|
||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
||||
>
|
||||
Show Web Push Subscription Info
|
||||
</button>
|
||||
<p>
|
||||
If that showed "null" then the notification is not active.
|
||||
<button class="text-blue-500" @click="showNotificationChoice()">
|
||||
Click here.
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="sendTestWebPushMessage(true)"
|
||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
||||
>
|
||||
Send Yourself a Test Web Push Message (Through Push Server but Skipping
|
||||
Client Filter)
|
||||
</button>
|
||||
<p>
|
||||
If that didn't show a notification on your device, there is a problem
|
||||
getting to the push server. Disable notifications and then enable them
|
||||
again.
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="sendTestWebPushMessage()"
|
||||
class="block w-full text-center text-md bg-slate-500 text-white px-1.5 py-2 rounded-md mt-4 mb-2"
|
||||
>
|
||||
Send Yourself a Test Web Push Message (Through Push Server and Client
|
||||
Filter)
|
||||
</button>
|
||||
<p>
|
||||
If you don't see a message, it could be that there is nothing new for
|
||||
you to see. If the previous test worked, then things should work fine.
|
||||
If you notice a full 24 hours where you get no notification and you know
|
||||
that there are new items that should show, gather as many details as
|
||||
possible and go to the bottom of
|
||||
<router-link to="help" class="text-blue-500"> this page </router-link>
|
||||
for ways to contact us.
|
||||
</p>
|
||||
</div>
|
||||
<!-- eslint-enable -->
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { sendTestThroughPushServer } from "@/libs/util";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class HelpNotificationsView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
subscription: PushSubscription | null = null;
|
||||
|
||||
async mounted() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
this.subscription = await registration.pushManager.getSubscription();
|
||||
} catch (error) {
|
||||
console.error("Mount error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
alertWebPushSubscription() {
|
||||
console.log(
|
||||
"Web push subscription:",
|
||||
JSON.parse(JSON.stringify(this.subscription)), // gives more info than plain console logging
|
||||
);
|
||||
alert(JSON.stringify(this.subscription));
|
||||
}
|
||||
|
||||
async sendTestWebPushMessage(skipFilter: boolean = false) {
|
||||
if (!this.subscription) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Not Subscribed",
|
||||
// Note that this exact verbiage shows in help text.
|
||||
text: "You must enable notifications before testing the web push.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendTestThroughPushServer(this.subscription, skipFilter);
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Test Web Push Sent",
|
||||
text:
|
||||
"Check your device for the test web push message" +
|
||||
(skipFilter ? "." : " if there are new items in your feed."),
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Got an error sending test notification:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Sending Test",
|
||||
text: "Got an error sending the test web push notification.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
showTestNotification() {
|
||||
const TEST_NOTIFICATION_TITLE = "It Worked";
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
return registration.showNotification(TEST_NOTIFICATION_TITLE, {
|
||||
body: "This is your test notification.",
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Sent",
|
||||
text: `A notification was triggered, so one should show on your device entitled '${TEST_NOTIFICATION_TITLE}'.`,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Got a notification error:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Failed",
|
||||
text: "Got an error sending a notification.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
showNotificationChoice() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "notification-permission",
|
||||
title: "", // unused, only here to satisfy type check
|
||||
text: "", // unused, only here to satisfy type check
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
335
src/views/HelpView.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Help
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- eslint-disable prettier/prettier -->
|
||||
<div>
|
||||
<p>
|
||||
This app is a window into data that you and your friends own, focused on
|
||||
gifts and collaboration.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What is the idea here?</h2>
|
||||
<p>
|
||||
We are building networks of people who want to grow a giving society.
|
||||
First of all, you can see what people have given, and also recognize
|
||||
gifts you've seen, in a way that leaves a permanent record -- one that
|
||||
came from you, and the recipient can prove it was for them. This is
|
||||
personally gratifying, but it extends to broader work: volunteers get
|
||||
confirmation of activity, and selectively show off their contributions
|
||||
and network.
|
||||
</p>
|
||||
<p>
|
||||
You can show giving and also offer help to ideas, based on others'
|
||||
willingness to help out, too. You can record your own ideas and invite
|
||||
others to collaborate.
|
||||
</p>
|
||||
<p>
|
||||
This app uses the power of cryptography to build a reputation, recording
|
||||
activity that you can share at your discretion. You put some activity
|
||||
public, but your sensitive information is not shared with anyone,
|
||||
including our services. This is in contrast to Meta and Google, who hold
|
||||
your data and allow you use it. Those services are useful, but they have
|
||||
the control; this app gives you the control.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I get started?</h2>
|
||||
<p>
|
||||
You need someone to register you -- usually the person who told you
|
||||
about this app, on the Contacts
|
||||
<fa icon="users" class="fa-fw" /> page. After they register you, you can
|
||||
select any contact on the home page (or "anonymous") and record your
|
||||
appreciation for... whatever. The main goal is to record what people
|
||||
have given you, to grow giving economies. Each claim is recorded on a
|
||||
custom ledger. The day after being registered, you'll be able to able to
|
||||
register others; later, you can create projects, too.
|
||||
</p>
|
||||
<p>
|
||||
Note that there are limits to how many others each person can register,
|
||||
so you may have to wait.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I add someone else?</h2>
|
||||
<p>
|
||||
<button class="text-blue-500" @click="showOnboardInfo">
|
||||
Click here to show an alert with the steps.
|
||||
</button>
|
||||
To start scanning, go
|
||||
<router-link class="text-blue-500" to="/contact-qr">here.</router-link>
|
||||
</p>
|
||||
<p>
|
||||
If they are not nearby to scan QR codes, tell them to copy their ID from
|
||||
their Identity <fa icon="circle-user" class="fa-fw" /> page, which
|
||||
typically starts with "did:ethr:...", and send it to you. Go to the
|
||||
Contacts <fa icon="users" class="fa-fw" /> page and enter that into the
|
||||
top form. To add a name, put a comma and then their name; to add their
|
||||
public key, put another comma followed by the key.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I backup all my data?</h2>
|
||||
<p>
|
||||
There are two sets of data to backup: the identifier secrets and the
|
||||
other data that isn't quite a secret such as settings, contacts, etc.
|
||||
</p>
|
||||
|
||||
<div class="px-4">
|
||||
<h2 class="text-xl font-semibold">
|
||||
How do I backup my identifier (secret) data?
|
||||
</h2>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
|
||||
</li>
|
||||
<li>
|
||||
Click on "Backup Identifier Seed" and follow the instructions.
|
||||
</li>
|
||||
<li>
|
||||
If you have other identifiers, switch to each one and repeat those
|
||||
steps.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
How do I backup my other (non-identifier-secret) data?
|
||||
</h2>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page.
|
||||
</li>
|
||||
<li>
|
||||
Click on "Download Settings...". That will save a file to your
|
||||
downloads folder. That is your backup, so put it someplace where you
|
||||
won't lose it.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I restore my data?</h2>
|
||||
<p>
|
||||
There are two parts to restore your data: the identity secrets and the
|
||||
other data such as settings, contacts, etc.
|
||||
</p>
|
||||
|
||||
<div class="px-4">
|
||||
<h2 class="text-xl font-semibold">
|
||||
How do I restore my identifier (secret) data?
|
||||
</h2>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
<router-link class="text-blue-500" to="/import-account">
|
||||
Go to the import page
|
||||
</router-link>
|
||||
and enter the seed phrase you backed up.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
How do I restore my other (non-identifier-secret) data?
|
||||
</h2>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
Go to Your Identity <fa icon="circle-user" class="fa-fw" /> page,
|
||||
click Advanced, and follow the instructions for Data Import.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I create another identity?</h2>
|
||||
<p>
|
||||
Before doing this, note that it is an advanced feature that affects
|
||||
functionality (eg. the words "Alt ID" next to results, backup features)
|
||||
so beware. You can
|
||||
<router-link to="start" class="text-blue-500">
|
||||
create another identity here.
|
||||
</router-link>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">How do I erase my data?</h2>
|
||||
<p>
|
||||
Before doing this, note the two kinds of data to backup: identity data,
|
||||
and other data for contacts and settings (see instructions above).
|
||||
</p>
|
||||
<ul>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Mobile
|
||||
<ul>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Chrome: Settings -> Privacy and Security -> Clear Browsing Data
|
||||
</li>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
DuckDuckGo: long hold -> Clear Data (takes effect immediately)
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Desktop
|
||||
<ul>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Chrome:
|
||||
<a href="chrome://settings/content/all" class="text-blue-500"
|
||||
>clear here</a
|
||||
>
|
||||
also clear under dev tools Application
|
||||
</li>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Firefox: <a href="about:preferences">go here</a>, Manage Data,
|
||||
find timesafari.app and select, hit Remove Selected, then Save
|
||||
Changes
|
||||
</li>
|
||||
<li class="list-disc list-outside ml-4">
|
||||
Safari: Settings -> Privacy -> Manage Website Data, search for
|
||||
timesafari.app and select, hit Remove Selected, then Done.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<p>To erase your data from our servers, contact us (below).</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
I know there is a record from someone, so why can't I see that info?
|
||||
</h2>
|
||||
<p>
|
||||
If you don't see anything associated with a person, this is typically
|
||||
because they have not given you permission to see their information. Ask
|
||||
them to add you to their contact list and make sure the eye next to your
|
||||
name is open like this
|
||||
<fa icon="eye" class="fa-fw" /> and not closed like this
|
||||
<fa icon="eye-slash" class="fa-fw" />.
|
||||
</p>
|
||||
<p>
|
||||
Sometimes the reason you don't see something is because the search
|
||||
results are limited. Go to the bottom and make sure to load all the data
|
||||
on a list. If you still don't see it, try a search or view on a
|
||||
different page.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
Where do I get help with notifications?
|
||||
</h2>
|
||||
<p>
|
||||
<router-link class="text-blue-500" to="/help-notifications"
|
||||
>Here.</router-link
|
||||
>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
How do I access even more functionality?
|
||||
</h2>
|
||||
<p>
|
||||
There is an "Advanced" section at the bottom of the Account
|
||||
<fa icon="circle-user" /> page.
|
||||
</p>
|
||||
<p>
|
||||
There is a even more functionality in a mobile app (and more
|
||||
documentation) at
|
||||
<a href="https://endorser.ch" class="text-blue-500">
|
||||
EndorserSearch.com
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
|
||||
<p style="display:inline; align-items: center">
|
||||
This work is marked with
|
||||
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
|
||||
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
||||
<img
|
||||
src="../assets/help/creative-commons-circle.svg"
|
||||
alt="CC circle"
|
||||
width="20"
|
||||
class="display: inline"
|
||||
/>
|
||||
<img
|
||||
src="../assets/help/creative-commons-zero.svg"
|
||||
alt="CC zero"
|
||||
width="20"
|
||||
style="display: inline"
|
||||
/>
|
||||
</a>
|
||||
<br />
|
||||
For notifications, this service stores push token data; that can be revoked at any time
|
||||
by disabling notifications on the Account <fa icon="circle-user" class="fa-fw" /> page.
|
||||
<br />
|
||||
For all other claim data,
|
||||
<a href="https://endorser.ch/privacy-policy" class="text-blue-500">
|
||||
the Endorser Service has this Privacy Policy.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">Where can I read more?</h2>
|
||||
<p>
|
||||
This is part of the
|
||||
<a href="https://livesofgiving.org" class="text-blue-500">
|
||||
Lives of Giving
|
||||
</a>
|
||||
initiative.
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What app version is this?</h2>
|
||||
<p>{{ package.version }} ({{ commitHash }})</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">
|
||||
For any other questions, including removing your data:
|
||||
</h2>
|
||||
<p>
|
||||
Contact us at
|
||||
<a href="mailto:info@TimeSafari.app" class="text-blue-500"
|
||||
>info@TimeSafari.app</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<!-- eslint enable -->
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Package from "../../package.json";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class Help extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
package = Package;
|
||||
commitHash = process.env.VUE_APP_GIT_HASH;
|
||||
|
||||
showOnboardInfo() {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Onboard Someone",
|
||||
// If you edit this, check that the numbers still line up on the side in the alert (on mobile, preferably).
|
||||
text: "1) Check that they've entered their name. 2) Go to the scanning page via the Identity page and then the through the QR icon at the top, and then scan and register them. 3) Have them go to that page and scan you.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,18 +1,468 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<img alt="Vue logo" src="../assets/logo.png" />
|
||||
<HelloWorld msg="Welcome to Your Vue.js + TypeScript App" />
|
||||
</div>
|
||||
<QuickNav selected="Home"></QuickNav>
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Time Safari
|
||||
</h1>
|
||||
|
||||
<!-- prompt to install notifications -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
v-if="!notificationsSupported()"
|
||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||
>
|
||||
<p style="display: inline; align-items: center">
|
||||
This app doesn't support notifications, so let's fix that. <br />
|
||||
<!-- Note that that exact verbiage shows in the help. -->
|
||||
|
||||
<span v-if="userAgentInfo.getOS().name === 'iOS'">
|
||||
Tap on "Share"<img
|
||||
src="../assets/help/apple-share-icon.svg"
|
||||
alt="Apple 'share' icon"
|
||||
width="30"
|
||||
style="display: inline; margin: 0 5px; vertical-align: middle"
|
||||
/>and then "Add to Home Screen"
|
||||
<fa icon="square-plus" title="Apple 'Add' icon" />
|
||||
and go click on that new app.
|
||||
</span>
|
||||
<span
|
||||
v-else-if="userAgentInfo.getBrowser().name.startsWith('Chrome')"
|
||||
>
|
||||
You should see a prompt to install, or you can click on the
|
||||
top-right dots
|
||||
<fa
|
||||
icon="ellipsis-vertical"
|
||||
title="vertical ellipsis"
|
||||
class="fa-fw"
|
||||
/>
|
||||
and then "Install"<img
|
||||
src="../assets/help/install-android-chrome.png"
|
||||
alt="Android 'install' icon"
|
||||
width="30"
|
||||
style="display: inline; margin: 0 5px; vertical-align: middle"
|
||||
/>
|
||||
and go use that app.
|
||||
</span>
|
||||
<span v-else>
|
||||
Try
|
||||
<a href="https://www.google.com/chrome/" class="text-blue-500"
|
||||
>Google Chrome</a
|
||||
>
|
||||
or look for a way to install as an app.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- show the actions for recognizing a give -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
v-if="!activeDid"
|
||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||
>
|
||||
<p class="text-lg mb-3">
|
||||
You need an <b>identifier</b> before you can record anyone's gives.
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'start' }"
|
||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
Create Your Identifier</router-link
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isRegistered"
|
||||
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
|
||||
>
|
||||
Someone must register your account before you can record anyone's gives.
|
||||
To do this:
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
1. Show Them Your Identity Info</router-link
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="block text-center text-md font-bold uppercase bg-blue-500 text-white mt-2 px-2 py-3 rounded-md"
|
||||
>
|
||||
2. Check Your Limits</router-link
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- activeDid && isRegistered -->
|
||||
<h2 class="text-xl font-bold mb-4">Record Something Given</h2>
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openDialog()">
|
||||
<EntityIcon
|
||||
:entityId="null"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
Anonymous/Unnamed
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
@click="openDialog(contact)"
|
||||
>
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ contact.name || contact.did }}
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
||||
<router-link
|
||||
v-if="allContacts.length >= 7"
|
||||
:to="{ name: 'contact-gives' }"
|
||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Show More Contacts…
|
||||
</router-link>
|
||||
|
||||
<!-- If there are no contacts, show this instead: -->
|
||||
<div
|
||||
class="rounded border border-dashed border-slate-300 bg-slate-100 px-4 py-3 text-center italic text-slate-500"
|
||||
v-if="allContacts.length === 0"
|
||||
>
|
||||
(No contacts to show.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GiftedDialog
|
||||
ref="customDialog"
|
||||
message="Received from"
|
||||
showGivenToUser="true"
|
||||
/>
|
||||
|
||||
<!-- Results List -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<h2 class="text-xl font-bold mb-4">Latest Activity</h2>
|
||||
<InfiniteScroll @reached-bottom="loadMoreGives">
|
||||
<ul class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300 py-2"
|
||||
v-for="record in feedData"
|
||||
:key="record.jwtId"
|
||||
>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-400 text-orange-400 pb-2 mb-2 font-bold uppercase text-sm"
|
||||
v-if="record.jwtId == feedLastViewedClaimId"
|
||||
>
|
||||
You've seen all the following
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<fa icon="gift" class="pt-1 pr-2 text-slate-500"></fa>
|
||||
<span class="">{{ this.giveDescription(record) }}</span>
|
||||
<a @click="onClickLoadClaim(record.jwtId)">
|
||||
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
<div :class="{ hidden: isHiddenSpinner }">
|
||||
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa> Loading…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import HelloWorld from "@/components/HelloWorld.vue"; // @ is an alias to /src
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Options({
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import {
|
||||
didInfo,
|
||||
GiverInputInfo,
|
||||
GiveServerRecord,
|
||||
} from "@/libs/endorserServer";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
HelloWorld,
|
||||
GiftedDialog,
|
||||
QuickNav,
|
||||
EntityIcon,
|
||||
InfiniteScroll,
|
||||
TopMessage,
|
||||
},
|
||||
})
|
||||
export default class HomeView extends Vue {}
|
||||
export default class HomeView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allContacts: Array<Contact> = [];
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
feedData = [];
|
||||
feedPreviousOldestId?: string;
|
||||
feedLastViewedClaimId?: string;
|
||||
isHiddenSpinner = true;
|
||||
isRegistered = false;
|
||||
numAccounts = 0;
|
||||
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
return identity; // may be null
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
this.allMyDids = allAccounts.map((acc) => acc.did);
|
||||
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
this.feedLastViewedClaimId = settings?.lastViewedClaimId;
|
||||
this.isRegistered = !!settings?.isRegistered;
|
||||
|
||||
// this returns a Promise but we don't need to wait for it
|
||||
this.updateAllFeed();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (err: any) {
|
||||
console.log("Error retrieving settings and/or feed.", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.userMessage ||
|
||||
"There was an error retrieving your settings and/or the latest activity.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
notificationsSupported() {
|
||||
return "Notification" in window;
|
||||
}
|
||||
|
||||
public async buildHeaders() {
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (this.activeDid) {
|
||||
await accountsDB.open();
|
||||
const allAccounts = await accountsDB.accounts.toArray();
|
||||
const account = allAccounts.find(
|
||||
(acc) => acc.did === this.activeDid,
|
||||
) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
|
||||
);
|
||||
}
|
||||
|
||||
headers["Authorization"] = "Bearer " + (await accessToken(identity));
|
||||
} else {
|
||||
// it's OK without auth... we just won't get any identifiers
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data loader used by infinite scroller
|
||||
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||
**/
|
||||
public async loadMoreGives(payload: boolean) {
|
||||
if (payload) {
|
||||
this.updateAllFeed();
|
||||
}
|
||||
}
|
||||
|
||||
public async updateAllFeed() {
|
||||
this.isHiddenSpinner = false;
|
||||
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
|
||||
.then(async (results) => {
|
||||
if (results.data.length > 0) {
|
||||
this.feedData = this.feedData.concat(results.data);
|
||||
this.feedPreviousOldestId =
|
||||
results.data[results.data.length - 1].jwtId;
|
||||
// The following update is only done on the first load.
|
||||
if (
|
||||
this.feedLastViewedClaimId == null ||
|
||||
this.feedLastViewedClaimId < results.data[0].jwtId
|
||||
) {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
lastViewedClaimId: results.data[0].jwtId,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Feed Error",
|
||||
text: e.userMessage || "There was an error retrieving feed data.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
});
|
||||
this.isHiddenSpinner = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve claims in reverse chronological order
|
||||
*
|
||||
* @param beforeId the earliest ID (of previous searches) to search earlier
|
||||
* @return claims in reverse chronological order
|
||||
*/
|
||||
public async retrieveGives(endorserApiServer: string, beforeId?: string) {
|
||||
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
|
||||
const response = await fetch(
|
||||
endorserApiServer + "/api/v2/report/gives?" + beforeQuery,
|
||||
{
|
||||
method: "GET",
|
||||
headers: await this.buildHeaders(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw await response.text();
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
|
||||
if (results.data) {
|
||||
return results;
|
||||
} else {
|
||||
throw JSON.stringify(results);
|
||||
}
|
||||
}
|
||||
|
||||
giveDescription(giveRecord: GiveServerRecord) {
|
||||
// similar code is in endorser-mobile utility.ts
|
||||
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const claim = (giveRecord.fullClaim as any).claim || giveRecord.fullClaim;
|
||||
// agent.did is for legacy data, before March 2023
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const giverDid = claim.agent?.identifier || (claim.agent as any)?.did;
|
||||
const giverInfo = didInfo(
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
);
|
||||
let gaveAmount = claim.object?.amountOfThisGood
|
||||
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
||||
: "";
|
||||
if (claim.description) {
|
||||
if (gaveAmount) {
|
||||
gaveAmount = gaveAmount + ", and also: ";
|
||||
}
|
||||
gaveAmount = gaveAmount + claim.description;
|
||||
}
|
||||
if (!gaveAmount) {
|
||||
gaveAmount = "something not described";
|
||||
}
|
||||
// recipient.did is for legacy data, before March 2023
|
||||
const gaveRecipientId =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
claim.recipient?.identifier || (claim.recipient as any)?.did;
|
||||
const gaveRecipientInfo = gaveRecipientId
|
||||
? " to " +
|
||||
didInfo(
|
||||
gaveRecipientId,
|
||||
this.activeDid,
|
||||
this.allMyDids,
|
||||
this.allContacts,
|
||||
)
|
||||
: "";
|
||||
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
|
||||
}
|
||||
|
||||
onClickLoadClaim(jwtId: string) {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(jwtId),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
displayAmount(code: string, amt: number) {
|
||||
return "" + amt + " " + this.currencyShortWordForCode(code, amt === 1);
|
||||
}
|
||||
|
||||
currencyShortWordForCode(unitCode: string, single: boolean) {
|
||||
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
||||
}
|
||||
|
||||
openDialog(giver: GiverInputInfo) {
|
||||
(this.$refs.customDialog as GiftedDialog).open(giver);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
163
src/views/IdentitySwitcherView.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<router-link
|
||||
:to="{ name: 'account' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
|
||||
Switch Identity
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Identity List -->
|
||||
|
||||
<!-- Current Identity - Display First! -->
|
||||
<div class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-4">
|
||||
<fa icon="circle-check" class="fa-fw text-blue-600 text-xl mr-3"></fa>
|
||||
<span class="overflow-hidden">
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<b>ID:</b> <code>{{ activeDid }}</code>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Other Identity/ies -->
|
||||
<ul class="mb-4">
|
||||
<li
|
||||
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
|
||||
v-for="ident in otherIdentities"
|
||||
:key="ident.did"
|
||||
@click="switchAccount(ident.did)"
|
||||
>
|
||||
<fa icon="circle" class="fa-fw text-slate-400 text-xl mr-3"></fa>
|
||||
<span class="overflow-hidden">
|
||||
<h2 class="text-xl font-semibold mb-0"></h2>
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<b>ID:</b> <code>{{ ident.did }}</code>
|
||||
</div>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Actions -->
|
||||
<!-- id used by puppeteer test script -->
|
||||
<router-link
|
||||
id="start-link"
|
||||
:to="{ name: 'start' }"
|
||||
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
Add Another Identity…
|
||||
</router-link>
|
||||
<a
|
||||
href="#"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md mb-8"
|
||||
@click="switchAccount('0')"
|
||||
>
|
||||
No Identity
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { AppString } from "@/constants/app";
|
||||
import { db, accountsDB } from "@/db/index";
|
||||
import { AccountsSchema } from "@/db/tables/accounts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class IdentitySwitcherView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
Constants = AppString;
|
||||
public accounts: typeof AccountsSchema;
|
||||
public activeDid = "";
|
||||
public apiServer = "";
|
||||
public apiServerInput = "";
|
||||
public otherIdentities: Array<{ did: string }> = [];
|
||||
public showContactGives = false;
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse((account?.identity as string) || "null");
|
||||
return identity;
|
||||
}
|
||||
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.apiServerInput = settings?.apiServer || "";
|
||||
this.showContactGives = !!settings?.showContactGivesInline;
|
||||
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
|
||||
if (identity) {
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: identity.did,
|
||||
});
|
||||
}
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
for (let n = 0; n < accounts.length; n++) {
|
||||
const did = JSON.parse(accounts[n].identity)["did"];
|
||||
if (did && this.activeDid !== did) {
|
||||
this.otherIdentities.push({ did: did });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Loading Accounts",
|
||||
text: "Clear your cache and start over (after data backup).",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error("Telling user to clear cache at page create because:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async switchAccount(did?: string) {
|
||||
// 0 means none
|
||||
if (did === "0") {
|
||||
did = undefined;
|
||||
}
|
||||
await db.open();
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: did,
|
||||
});
|
||||
this.activeDid = did || "";
|
||||
this.otherIdentities = [];
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
for (let n = 0; n < accounts.length; n++) {
|
||||
const did = JSON.parse(accounts[n].identity)["did"];
|
||||
if (did && this.activeDid !== did) {
|
||||
this.otherIdentities.push({ did: did });
|
||||
}
|
||||
}
|
||||
this.$router.push({ name: "account" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,49 +1,130 @@
|
||||
<template>
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<a
|
||||
href="start.html"
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><i class="fa-solid fa-chevron-left fa-fw"></i
|
||||
></a>
|
||||
>
|
||||
<fa icon="chevron-left"></fa>
|
||||
</button>
|
||||
Import Existing Identity
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Import Account Form -->
|
||||
<form>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Enter your seed phrase below to import your identity on this device.
|
||||
</p>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Enter your seed phrase below to import your identity on this device.
|
||||
</p>
|
||||
<!-- id used by puppeteer test script -->
|
||||
<input
|
||||
id="seed-input"
|
||||
type="text"
|
||||
placeholder="Seed Phrase"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="mnemonic"
|
||||
/>
|
||||
{{ mnemonic }}
|
||||
<h3
|
||||
class="text-sm uppercase font-semibold mb-3"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
Advanced
|
||||
</h3>
|
||||
<div v-if="showAdvanced">
|
||||
Enter a custom derivation path
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Seed Phrase"
|
||||
class="block w-full rounded border-slate-400 mb-4"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="derivationPath"
|
||||
/>
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Import Identity"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
For previous uPort or Endorser users,
|
||||
<a @click="derivationPath = UPORT_DERIVATION_PATH" class="text-blue-500">
|
||||
click here to use that value.
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<button
|
||||
@click="from_mnemonic()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
@click="onCancelClick()"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import {
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
deriveAddress,
|
||||
newIdentifier,
|
||||
} from "../libs/crypto";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ImportAccountView extends Vue {}
|
||||
export default class ImportAccountView extends Vue {
|
||||
UPORT_DERIVATION_PATH = "m/7696500'/0'/0'/0'"; // for legacy imports, likely never used
|
||||
|
||||
mnemonic = "";
|
||||
address = "";
|
||||
privateHex = "";
|
||||
publicHex = "";
|
||||
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
|
||||
showAdvanced = false;
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
public async from_mnemonic() {
|
||||
const mne: string = this.mnemonic.trim().toLowerCase();
|
||||
if (this.mnemonic.trim().length > 0) {
|
||||
[this.address, this.privateHex, this.publicHex] = deriveAddress(
|
||||
mne,
|
||||
this.derivationPath,
|
||||
);
|
||||
|
||||
const newId = newIdentifier(
|
||||
this.address,
|
||||
this.publicHex,
|
||||
this.privateHex,
|
||||
this.derivationPath,
|
||||
);
|
||||
|
||||
try {
|
||||
await accountsDB.open();
|
||||
await accountsDB.accounts.add({
|
||||
dateCreated: new Date().toISOString(),
|
||||
derivationPath: this.derivationPath,
|
||||
did: newId.did,
|
||||
identity: JSON.stringify(newId),
|
||||
mnemonic: mne,
|
||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||
});
|
||||
|
||||
// record that as the active DID
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: newId.did,
|
||||
});
|
||||
this.$router.push({ name: "account" });
|
||||
} catch (err) {
|
||||
console.error("Error saving mnemonic & updating settings:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
154
src/views/ImportDerivedAccountView.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
>
|
||||
<fa icon="chevron-left"></fa>
|
||||
</button>
|
||||
Derive from Existing Identity
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Import Account Form -->
|
||||
|
||||
<div>
|
||||
<p class="text-center text-xl mb-4 font-light">
|
||||
Will increment the maximum derivation path from the existing seed.
|
||||
</p>
|
||||
|
||||
<p v-if="didArrays.length > 1">
|
||||
Choose existing DIDs from same seed phrase to compute derivation.
|
||||
</p>
|
||||
<ul class="mb-4">
|
||||
<li
|
||||
class="block bg-slate-100 rounded-md flex items-center px-4 py-3 mb-2"
|
||||
v-for="dids in didArrays"
|
||||
:key="dids[0]"
|
||||
@click="switchAccount(dids[0])"
|
||||
>
|
||||
<fa
|
||||
v-if="dids[0] == selectedArrayFirstDid"
|
||||
icon="circle"
|
||||
class="fa-fw text-blue-400 text-xl mr-3"
|
||||
></fa>
|
||||
<fa
|
||||
v-else
|
||||
icon="circle"
|
||||
class="fa-fw text-slate-400 text-xl mr-3"
|
||||
></fa>
|
||||
<span class="overflow-hidden">
|
||||
<div class="text-sm text-slate-500 truncate">
|
||||
<code>{{ dids.join(",") }}</code>
|
||||
</div>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<button
|
||||
@click="incrementDerivation()"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
Increment and Import
|
||||
</button>
|
||||
<button
|
||||
@click="onCancelClick()"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import {
|
||||
DEFAULT_ROOT_DERIVATION_PATH,
|
||||
deriveAddress,
|
||||
newIdentifier,
|
||||
nextDerivationPath,
|
||||
} from "../libs/crypto";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class ImportAccountView extends Vue {
|
||||
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
|
||||
didArrays: Array<Array<string>> = [];
|
||||
selectedArrayFirstDid = "";
|
||||
|
||||
async mounted() {
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
const seedDids: Record<string, Array<string>> = {};
|
||||
accounts.forEach((account) => {
|
||||
const prevDids: Array<string> = seedDids[account.mnemonic] || [];
|
||||
seedDids[account.mnemonic] = prevDids.concat([account.did]);
|
||||
});
|
||||
this.didArrays = Object.values(seedDids);
|
||||
this.selectedArrayFirstDid = this.didArrays[0][0];
|
||||
}
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
public switchAccount(did: string) {
|
||||
this.selectedArrayFirstDid = did;
|
||||
}
|
||||
|
||||
public async incrementDerivation() {
|
||||
await accountsDB.open();
|
||||
// find the maximum derivation path for the selected DIDs
|
||||
const selectedArray: Array<string> =
|
||||
this.didArrays.find((dids) => dids[0] === this.selectedArrayFirstDid) ||
|
||||
[];
|
||||
const allMatchingAccounts = await accountsDB.accounts
|
||||
.where("did")
|
||||
.anyOf(...selectedArray)
|
||||
.toArray();
|
||||
const accountWithMaxDeriv = allMatchingAccounts[0];
|
||||
allMatchingAccounts.slice(1).forEach((account) => {
|
||||
if (account.derivationPath > accountWithMaxDeriv.derivationPath) {
|
||||
accountWithMaxDeriv.derivationPath = account.derivationPath;
|
||||
}
|
||||
});
|
||||
// increment the last number in that max derivation path
|
||||
const newDerivPath = nextDerivationPath(accountWithMaxDeriv.derivationPath);
|
||||
|
||||
const mne: string = accountWithMaxDeriv.mnemonic;
|
||||
|
||||
const [address, privateHex, publicHex] = deriveAddress(mne, newDerivPath);
|
||||
|
||||
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
|
||||
|
||||
try {
|
||||
await accountsDB.accounts.add({
|
||||
dateCreated: new Date().toISOString(),
|
||||
derivationPath: newDerivPath,
|
||||
did: newId.did,
|
||||
identity: JSON.stringify(newId),
|
||||
mnemonic: mne,
|
||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||
});
|
||||
|
||||
// record that as the active DID
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: newId.did,
|
||||
});
|
||||
this.$router.push({ name: "account" });
|
||||
} catch (err) {
|
||||
console.error("Error saving mnemonic & updating settings:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,53 +1,78 @@
|
||||
<template>
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<a
|
||||
href="account-view.html"
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><i class="fa-solid fa-chevron-left fa-fw"></i
|
||||
></a>
|
||||
[New/Edit] Identity
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</button>
|
||||
Edit Identity
|
||||
</h1>
|
||||
</div>
|
||||
<form>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="First Name"
|
||||
class="block w-full rounded border-slate-400 mb-4"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Last Name"
|
||||
class="block w-full rounded border-slate-400 mb-4"
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<!-- SHOW ME instead while processing saving changes -->
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="onClickSaveChanges()"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<!-- SHOW ME instead while processing saving changes -->
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="onClickCancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class NewEditAccountView extends Vue {}
|
||||
export default class NewEditAccountView extends Vue {
|
||||
givenName = "";
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.givenName =
|
||||
(settings?.firstName || "") +
|
||||
(settings?.lastName ? ` ${settings.lastName}` : ""); // deprecated, pre v 0.1.3
|
||||
}
|
||||
|
||||
onClickSaveChanges() {
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
firstName: this.givenName,
|
||||
lastName: "", // deprecated, pre v 0.1.3
|
||||
});
|
||||
localStorage.setItem("firstName", this.givenName as string);
|
||||
localStorage.setItem("lastName", ""); // deprecated, pre v 0.1.3
|
||||
this.$router.back();
|
||||
}
|
||||
|
||||
onClickCancel() {
|
||||
this.$router.back();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,67 +1,66 @@
|
||||
<template>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<a
|
||||
href="project-view.html"
|
||||
<router-link
|
||||
:to="{ name: 'project' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><i class="fa-solid fa-chevron-left fa-fw"></i
|
||||
></a>
|
||||
><fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</router-link>
|
||||
|
||||
Make Commitment
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Project Details -->
|
||||
<form>
|
||||
<select class="block w-full rounded border-slate-400 mb-4">
|
||||
<option disabled>Choose a commitment type…</option>
|
||||
<option selected>Time</option>
|
||||
<option>Cryptocurrency</option>
|
||||
<option>Money</option>
|
||||
</select>
|
||||
|
||||
<!-- Time amount -->
|
||||
<div class="mb-4 flex items-stretch">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0.0"
|
||||
class="block w-full rounded-l border-slate-400"
|
||||
/>
|
||||
<span
|
||||
class="px-4 py-2 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
>hours</span
|
||||
>
|
||||
</div>
|
||||
<select class="block w-full rounded border border-slate-400 mb-4 px-3 py-2">
|
||||
<option disabled>Choose a commitment type…</option>
|
||||
<option selected>Time</option>
|
||||
<option>Cryptocurrency</option>
|
||||
<option>Money</option>
|
||||
</select>
|
||||
|
||||
<!-- Crypto amount -->
|
||||
<!-- Time amount -->
|
||||
<div class="mb-4 flex items-stretch">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0.0"
|
||||
class="block w-full rounded-l border border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<span
|
||||
class="px-4 py-2 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
>hours</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Money amount -->
|
||||
<!-- Crypto amount -->
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Commit"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Money amount -->
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Commit"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Options({
|
||||
@Component({
|
||||
components: {},
|
||||
})
|
||||
export default class NewEditCommitmentView extends Vue {}
|
||||
|
||||
@@ -1,78 +1,392 @@
|
||||
<template>
|
||||
<QuickNav selected="Projects"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Cancel -->
|
||||
<a
|
||||
href="project-view.html"
|
||||
<router-link
|
||||
:to="{ name: 'project' }"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><i class="fa-solid fa-chevron-left fa-fw"></i
|
||||
></a>
|
||||
|
||||
[New/Edit] Project
|
||||
><fa icon="chevron-left" class="fa-fw"></fa
|
||||
></router-link>
|
||||
Edit Idea
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Project Details -->
|
||||
<form>
|
||||
<!-- Image - (see design model) Empty -->
|
||||
<!-- Image - (see design model) Empty -->
|
||||
|
||||
<!-- Image - Populated -->
|
||||
<div class="relative mb-4 rounded-md overflow-hidden">
|
||||
<div class="absolute top-3 right-3 flex gap-2">
|
||||
<button
|
||||
class="text-md font-bold uppercase bg-blue-600 text-white px-3 py-2 rounded"
|
||||
>
|
||||
<i class="fa-solid fa-pen fa-fw"></i>
|
||||
</button>
|
||||
<button
|
||||
class="text-md font-bold uppercase bg-red-600 text-white px-3 py-2 rounded"
|
||||
>
|
||||
<i class="fa-solid fa-trash-can fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
<img src="https://picsum.photos/800/400" class="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Idea Name"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
v-model="fullClaim.name"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
placeholder="Description"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
rows="5"
|
||||
v-model="fullClaim.description"
|
||||
maxlength="5000"
|
||||
></textarea>
|
||||
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
|
||||
{{ fullClaim.description?.length }}/5000 max. characters
|
||||
</div>
|
||||
|
||||
<input
|
||||
v-model="fullClaim.url"
|
||||
placeholder="Website"
|
||||
class="block w-full rounded border border-slate-400 mb-4 px-3 py-2"
|
||||
/>
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Project Name"
|
||||
class="block w-full rounded border-slate-400 mb-4"
|
||||
type="checkbox"
|
||||
class="mr-2"
|
||||
v-model="includeLocation"
|
||||
@click="includeLocation = !includeLocation"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
placeholder="Description"
|
||||
class="block w-full rounded border-slate-400 mb-4"
|
||||
rows="5"
|
||||
></textarea>
|
||||
<div class="text-xs text-slate-500 italic -mt-3 mb-4">
|
||||
88/500 max. characters
|
||||
<label for="includeLocation">Include Location</label>
|
||||
</div>
|
||||
<div v-if="includeLocation" style="height: 600px; width: 800px">
|
||||
<div class="px-2 py-2">
|
||||
For your security, we recommend you choose a location nearby but not
|
||||
exactly at the place.
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<input
|
||||
type="submit"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
value="Save Project"
|
||||
<l-map
|
||||
ref="map"
|
||||
v-model:zoom="zoom"
|
||||
:center="[0, 0]"
|
||||
@click="
|
||||
(event) => {
|
||||
latitude = event.latlng.lat;
|
||||
longitude = event.latlng.lng;
|
||||
}
|
||||
"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
layer-type="base"
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
<l-marker
|
||||
v-if="latitude && longitude"
|
||||
:lat-lng="[latitude, longitude]"
|
||||
@click="maybeEraseLatLong()"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
:disabled="isHiddenSave"
|
||||
class="block w-full text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-2"
|
||||
@click="onSaveProjectClick()"
|
||||
>
|
||||
<!-- SHOW if in idle state -->
|
||||
<span :class="{ hidden: isHiddenSave }">Save Project</span>
|
||||
|
||||
<!-- SHOW if in saving state; DISABLE button while in saving state -->
|
||||
<span :class="{ hidden: isHiddenSpinner }">
|
||||
<!-- icon no worky? -->
|
||||
<i class="fa-solid fa-spinner fa-spin-pulse"></i>
|
||||
Saving…</span
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="onCancelClick()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { AxiosError } from "axios";
|
||||
import * as didJwt from "did-jwt";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||
|
||||
@Options({
|
||||
components: {},
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken, SimpleSigner } from "@/libs/crypto";
|
||||
import { useAppStore } from "@/store/app";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { PlanVerifiableCredential } from "@/libs/endorserServer";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { LMap, LMarker, LTileLayer, QuickNav },
|
||||
})
|
||||
export default class NewEditProjectView extends Vue {}
|
||||
export default class NewEditProjectView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
errorMessage = "";
|
||||
fullClaim: PlanVerifiableCredential = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "PlanAction",
|
||||
name: "",
|
||||
description: "",
|
||||
}; // this default is only to avoid errors before plan is loaded
|
||||
includeLocation = false;
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
numAccounts = 0;
|
||||
zoom = 2;
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
projectId = localStorage.getItem("projectId") || "";
|
||||
isHiddenSave = false;
|
||||
isHiddenSpinner = true;
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
|
||||
if (this.projectId) {
|
||||
if (this.numAccounts === 0) {
|
||||
console.error("Error: no account was found.");
|
||||
} else {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"An ID is chosen but there are no keys for it so it cannot be used to talk with the service.",
|
||||
);
|
||||
}
|
||||
this.LoadProject(identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async LoadProject(identity: IIdentifier) {
|
||||
const url =
|
||||
this.apiServer +
|
||||
"/api/claim/byHandle/" +
|
||||
encodeURIComponent(this.projectId);
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
this.fullClaim = resp.data.claim;
|
||||
if (this.fullClaim?.location) {
|
||||
this.includeLocation = true;
|
||||
this.latitude = this.fullClaim.location.geo.latitude;
|
||||
this.longitude = this.fullClaim.location.geo.longitude;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Got error retrieving that project", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async SaveProject(identity: IIdentifier) {
|
||||
// Make a claim
|
||||
const vcClaim: PlanVerifiableCredential = this.fullClaim;
|
||||
if (this.projectId) {
|
||||
vcClaim.identifier = this.projectId;
|
||||
}
|
||||
if (this.includeLocation) {
|
||||
vcClaim.location = {
|
||||
geo: {
|
||||
"@type": "GeoCoordinates",
|
||||
latitude: this.latitude,
|
||||
longitude: this.longitude,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Make a payload for the claim
|
||||
const vcPayload = {
|
||||
vc: {
|
||||
"@context": ["https://www.w3.org/2018/credentials/v1"],
|
||||
type: ["VerifiableCredential"],
|
||||
credentialSubject: vcClaim,
|
||||
},
|
||||
};
|
||||
// create a signature using private key of identity
|
||||
if (identity.keys[0].privateKeyHex != null) {
|
||||
const privateKeyHex: string = identity.keys[0].privateKeyHex;
|
||||
const signer = await SimpleSigner(privateKeyHex);
|
||||
const alg = undefined;
|
||||
// create a JWT for the request
|
||||
const vcJwt: string = await didJwt.createJWT(vcPayload, {
|
||||
alg: alg,
|
||||
issuer: identity.did,
|
||||
signer: signer,
|
||||
});
|
||||
|
||||
// Make the xhr request payload
|
||||
|
||||
const payload = JSON.stringify({ jwtEncoded: vcJwt });
|
||||
const url = this.apiServer + "/api/v2/claim";
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await this.axios.post(url, payload, { headers });
|
||||
// handleId is new in server v release-1.6.0; remove fullIri when that
|
||||
// version shows up here: https://api.endorser.ch/api-docs/
|
||||
if (resp.data?.success?.handleId || resp.data?.success?.fullIri) {
|
||||
this.errorMessage = "";
|
||||
|
||||
// handleId is new in server v release-1.6.0; remove fullIri when that
|
||||
// version shows up here: https://api.endorser.ch/api-docs/
|
||||
useAppStore().setProjectId(
|
||||
resp.data.success.handleId || resp.data.success.fullIri,
|
||||
);
|
||||
setTimeout(
|
||||
function (that: NewEditProjectView) {
|
||||
that.$router.push({ name: "project" });
|
||||
},
|
||||
2000,
|
||||
this,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
"Got unexpected 'data' inside response from server",
|
||||
resp,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Saving Idea",
|
||||
text: "Server did not save the idea. Try again.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
let userMessage = "There was an error saving the project.";
|
||||
const serverError = error as AxiosError<{
|
||||
error?: { message?: string };
|
||||
}>;
|
||||
if (serverError) {
|
||||
console.log("Got error from server", serverError);
|
||||
if (Object.prototype.hasOwnProperty.call(serverError, "message")) {
|
||||
userMessage = serverError.response?.data?.error?.message || ""; // This is info for the user.
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "User Message",
|
||||
text: userMessage,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Server Message",
|
||||
text: JSON.stringify(serverError.toJSON()),
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
"Here's the full error trying to save the claim:",
|
||||
error,
|
||||
);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Claim Error",
|
||||
text: error as string,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// Now set that error for the user to see.
|
||||
this.errorMessage = userMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async onSaveProjectClick() {
|
||||
this.isHiddenSave = true;
|
||||
this.isHiddenSpinner = false;
|
||||
|
||||
if (this.numAccounts === 0) {
|
||||
console.error("Error: there is no account.");
|
||||
} else {
|
||||
const identity = await this.getIdentity(this.activeDid);
|
||||
this.SaveProject(identity);
|
||||
}
|
||||
}
|
||||
|
||||
public maybeEraseLatLong() {
|
||||
if (window.confirm("Are you sure you don't want to mark a location?")) {
|
||||
this.latitude = 0;
|
||||
this.longitude = 0;
|
||||
this.includeLocation = false;
|
||||
}
|
||||
}
|
||||
|
||||
public onCancelClick() {
|
||||
this.$router.back();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
95
src/views/NewIdentifierView.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Your Identity
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center py-12">
|
||||
<span />
|
||||
<span v-if="loading">
|
||||
<span class="text-xl">Creating... </span>
|
||||
<fa
|
||||
icon="spinner"
|
||||
class="fa-spin fa-spin-pulse"
|
||||
color="green"
|
||||
size="128"
|
||||
></fa>
|
||||
</span>
|
||||
<span v-else>
|
||||
<span class="text-xl">Created!</span>
|
||||
<fa
|
||||
icon="burst"
|
||||
class="fa-beat px-12"
|
||||
color="green"
|
||||
style="
|
||||
--fa-animation-duration: 1s;
|
||||
--fa-animation-direction: reverse;
|
||||
--fa-animation-iteration-count: 1;
|
||||
--fa-beat-scale: 6;
|
||||
"
|
||||
></fa>
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "dexie-export-import";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { deriveAddress, generateSeed, newIdentifier } from "@/libs/crypto";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class NewIdentifierView extends Vue {
|
||||
loading = true;
|
||||
|
||||
async mounted() {
|
||||
const mnemonic = generateSeed();
|
||||
// address is 0x... ETH address, without "did:eth:"
|
||||
const [address, privateHex, publicHex, derivationPath] =
|
||||
deriveAddress(mnemonic);
|
||||
|
||||
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
||||
const identity = JSON.stringify(newId);
|
||||
|
||||
await accountsDB.open();
|
||||
await accountsDB.accounts.add({
|
||||
dateCreated: new Date().toISOString(),
|
||||
derivationPath: derivationPath,
|
||||
did: newId.did,
|
||||
identity: identity,
|
||||
mnemonic: mnemonic,
|
||||
publicKeyHex: newId.keys[0].publicKeyHex,
|
||||
});
|
||||
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
activeDid: newId.did,
|
||||
});
|
||||
|
||||
this.loading = false;
|
||||
setTimeout(() => {
|
||||
this.$router.push({ name: "home" });
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,132 +1,706 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200">
|
||||
<ul class="flex text-2xl p-2 gap-2">
|
||||
<!-- Home Feed -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<a href="" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-house-chimney fa-fw"></i
|
||||
></a>
|
||||
</li>
|
||||
<!-- Search -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<a href="search.html" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-magnifying-glass fa-fw"></i
|
||||
></a>
|
||||
</li>
|
||||
<!-- Projects -->
|
||||
<li class="basis-1/5 rounded-md bg-slate-400 text-white">
|
||||
<a href="" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-folder-open fa-fw"></i
|
||||
></a>
|
||||
</li>
|
||||
<!-- Commitments -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<a href="" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-hand fa-fw rotate-45"></i
|
||||
></a>
|
||||
</li>
|
||||
<!-- Profile -->
|
||||
<li class="basis-1/5 rounded-md text-slate-500">
|
||||
<a href="account-view.html" class="block text-center py-3 px-1"
|
||||
><i class="fa-solid fa-circle-user fa-fw"></i
|
||||
></a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<QuickNav />
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24">
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div id="ViewBreadcrumb" class="mb-8">
|
||||
<h1 class="text-lg text-center font-light relative px-7">
|
||||
<!-- Back -->
|
||||
<a href="" class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
><i class="fa-solid fa-chevron-left fa-fw"></i
|
||||
></a>
|
||||
<!-- Context Menu -->
|
||||
<a
|
||||
href=""
|
||||
class="text-lg text-center px-2 py-1 absolute -right-2 -top-1"
|
||||
><i class="fa-solid fa-ellipsis-vertical fa-fw"></i
|
||||
></a>
|
||||
|
||||
View Project
|
||||
<button
|
||||
@click="$router.go(-1)"
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</button>
|
||||
Idea
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Project Details -->
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mb-4">
|
||||
<!-- Image -->
|
||||
<div class="-mx-4 -mt-3 mb-3">
|
||||
<img src="https://picsum.photos/800/400" class="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Canyon cleanup</h2>
|
||||
<div class="flex justify-between gap-4 text-sm mb-3">
|
||||
<span
|
||||
><i class="fa-solid fa-user fa-fw text-slate-400"></i> Rotary</span
|
||||
>
|
||||
<span
|
||||
><i class="fa-solid fa-calendar fa-fw text-slate-400"></i> 8 days
|
||||
ago</span
|
||||
>
|
||||
<div class="block pb-4 flex gap-4">
|
||||
<div class="flex-none w-16 pt-1">
|
||||
<EntityIcon
|
||||
:entityId="projectId"
|
||||
:iconSize="64"
|
||||
class="block border border-slate-300 rounded-md"
|
||||
></EntityIcon>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden">
|
||||
<h2 class="text-xl font-semibold">{{ name }}</h2>
|
||||
<div class="text-sm mb-3">
|
||||
<div class="truncate">
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ issuer }}
|
||||
</div>
|
||||
<div v-if="timeSince">
|
||||
<fa icon="calendar" class="fa-fw text-slate-400"></fa>
|
||||
{{ timeSince }}
|
||||
</div>
|
||||
<div v-if="latitude || longitude">
|
||||
<fa icon="location-dot" class="fa-fw text-slate-400"></fa>
|
||||
<a
|
||||
:href="getOpenStreetMapUrl()"
|
||||
target="_blank"
|
||||
class="underline"
|
||||
>Map View
|
||||
<fa icon="arrow-up-right-from-square" class="fa-fw" />
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="url">
|
||||
<fa icon="globe" class="fa-fw text-slate-400"></fa>
|
||||
<a :href="addScheme(url)" target="_blank" class="underline"
|
||||
>{{ domainForWebsite(this.url) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-slate-500">
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem
|
||||
accusantium doloremque laudantium…
|
||||
<a href="" class="uppercase text-xs font-semibold text-slate-700"
|
||||
>Read More</a
|
||||
<div v-if="!expanded">
|
||||
{{ truncatedDesc }}
|
||||
<a
|
||||
v-if="description.length >= truncateLength"
|
||||
@click="expandText"
|
||||
class="uppercase text-xs font-semibold text-slate-700"
|
||||
>... Read More</a
|
||||
>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ description }}
|
||||
<a
|
||||
@click="collapseText"
|
||||
class="uppercase text-xs font-semibold text-slate-700"
|
||||
>- Read Less</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="issuer == activeDid"
|
||||
type="button"
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="onEditClick()"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeDid" class="mb-4">
|
||||
<div class="text-center">
|
||||
<button
|
||||
@click="openOfferDialog({ name: 'you', did: activeDid })"
|
||||
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
I offer…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeDid">
|
||||
<div class="text-center">
|
||||
<button
|
||||
@click="openGiftDialog({ name: 'you', did: activeDid })"
|
||||
class="block w-full text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
I gave…
|
||||
</button>
|
||||
<p class="mt-2 mb-4 text-center">Or, record a contribution from:</p>
|
||||
</div>
|
||||
|
||||
<ul class="grid grid-cols-4 gap-x-3 gap-y-5 text-center mb-5">
|
||||
<li @click="openGiftDialog()">
|
||||
<EntityIcon
|
||||
:entityId="null"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs italic font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
Anonymous/Unnamed
|
||||
</h3>
|
||||
</li>
|
||||
<li
|
||||
v-for="contact in allContacts"
|
||||
:key="contact.did"
|
||||
@click="openGiftDialog(contact)"
|
||||
>
|
||||
<EntityIcon
|
||||
:entityId="contact.did"
|
||||
:iconSize="64"
|
||||
class="mx-auto border border-slate-300 rounded-md mb-1"
|
||||
></EntityIcon>
|
||||
<h3
|
||||
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ contact.name || "(no name)" }}
|
||||
</h3>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Ideally, this button should only be visible when the active account has more than 7 or 11 contacts in their list (we want to limit the grid count above to 8 or 12 accounts to keep it compact) -->
|
||||
<router-link
|
||||
v-if="allContacts.length >= 7"
|
||||
:to="{ name: 'contact-gives' }"
|
||||
class="block text-center text-md font-bold uppercase bg-slate-500 text-white px-2 py-3 rounded-md"
|
||||
>
|
||||
Show More Contacts…
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Gifts to & from this -->
|
||||
<div class="grid items-start grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||
Offered To This Idea
|
||||
</h3>
|
||||
|
||||
<div v-if="offersToThis.length === 0">
|
||||
(None yet. Record one above.)
|
||||
</div>
|
||||
|
||||
<ul v-else class="text-sm border-t border-slate-300">
|
||||
<li
|
||||
v-for="offer in offersToThis"
|
||||
:key="offer.id"
|
||||
class="py-1.5 border-b border-slate-300"
|
||||
>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span>
|
||||
<fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ didInfo(offer.agentDid, activeDid, allMyDids, allContacts) }}
|
||||
</span>
|
||||
<a @click="onClickLoadClaim(offer.jwtId)">
|
||||
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
|
||||
</a>
|
||||
<span v-if="offer.amount">
|
||||
<fa
|
||||
:icon="iconForUnitCode(offer.unit)"
|
||||
class="fa-fw text-slate-400"
|
||||
/>{{ offer.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="offer.objectDescription" class="text-slate-500">
|
||||
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
||||
{{ offer.objectDescription }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">Given To This Idea</h3>
|
||||
|
||||
<div v-if="givesToThis.length === 0">(None yet. Record one above.)</div>
|
||||
|
||||
<ul v-else class="text-sm border-t border-slate-300">
|
||||
<li
|
||||
v-for="give in givesToThis"
|
||||
:key="give.id"
|
||||
class="py-1.5 border-b border-slate-300"
|
||||
>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span
|
||||
><fa icon="user" class="fa-fw text-slate-400"></fa>
|
||||
{{ didInfo(give.agentDid, activeDid, allMyDids, allContacts) }}
|
||||
</span>
|
||||
<a @click="onClickLoadClaim(give.jwtId)">
|
||||
<fa icon="circle-info" class="pl-2 pt-1 text-slate-500"></fa>
|
||||
</a>
|
||||
<span v-if="give.amount">
|
||||
<fa
|
||||
:icon="iconForUnitCode(give.unit)"
|
||||
class="fa-fw text-slate-400"
|
||||
/>{{ give.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="give.description" class="text-slate-500">
|
||||
<fa icon="comment" class="fa-fw text-slate-400"></fa>
|
||||
{{ give.description }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid items-start grid-cols-1 gap-4">
|
||||
<div
|
||||
v-if="fulfillersToThis.length > 0"
|
||||
class="bg-slate-100 px-4 py-3 rounded-md"
|
||||
>
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||
Contributions To This Idea
|
||||
</h3>
|
||||
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
|
||||
<div class="text-center">
|
||||
<div v-for="plan in fulfillersToThis" :key="plan.handleId">
|
||||
<button
|
||||
@click="onClickLoadProject(plan.handleId)"
|
||||
class="text-blue-500"
|
||||
>
|
||||
{{ plan.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||
Contributions By This Idea
|
||||
</h3>
|
||||
<!-- centering because long, wrapped project names didn't left align with blank or "text-left" -->
|
||||
<div class="text-center">
|
||||
<button
|
||||
@click="onClickLoadProject(fulfilledByThis.handleId)"
|
||||
class="text-blue-500"
|
||||
>
|
||||
{{ fulfilledByThis.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commit -->
|
||||
<a
|
||||
href="commitment-edit.html"
|
||||
class="block text-center text-lg font-bold uppercase bg-blue-600 text-white px-2 py-3 rounded-md mb-8"
|
||||
>Make Commitment</a
|
||||
<GiftedDialog
|
||||
ref="customGiveDialog"
|
||||
message="Received from"
|
||||
:projectId="this.projectId"
|
||||
>
|
||||
|
||||
<!-- Commitments -->
|
||||
<div class="bg-slate-100 px-4 py-3 rounded-md">
|
||||
<h3 class="text-sm uppercase font-semibold mb-3">Commitments</h3>
|
||||
|
||||
<ul class="text-sm border-t border-slate-300">
|
||||
<li class="flex justify-between gap-4 py-1.5 border-b border-slate-300">
|
||||
<span>[Username]</span>
|
||||
<span
|
||||
>5 hours <i class="fa-solid fa-spinner fa-fw text-slate-400"></i
|
||||
></span>
|
||||
</li>
|
||||
|
||||
<li class="flex justify-between gap-4 py-1.5 border-b border-slate-300">
|
||||
<span>[Username]</span>
|
||||
<span
|
||||
>US$ 20.00
|
||||
<i class="fa-solid fa-circle-check fa-fw text-lime-500"></i
|
||||
></span>
|
||||
</li>
|
||||
|
||||
<li class="flex justify-between gap-4 py-1.5 border-b border-slate-300">
|
||||
<span>[Username]</span>
|
||||
<span
|
||||
>0.1 BTC <i class="fa-solid fa-spinner fa-fw text-slate-400"></i
|
||||
></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</GiftedDialog>
|
||||
<OfferDialog ref="customOfferDialog" :projectId="this.projectId">
|
||||
</OfferDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Options, Vue } from "vue-class-component";
|
||||
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
||||
import * as moment from "moment";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
@Options({
|
||||
components: {},
|
||||
import GiftedDialog from "@/components/GiftedDialog.vue";
|
||||
import OfferDialog from "@/components/OfferDialog.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { Contact } from "@/db/tables/contacts";
|
||||
import { MASTER_SETTINGS_KEY, Settings } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { isGlobalUri } from "@/libs/util";
|
||||
import {
|
||||
didInfo,
|
||||
GiverInputInfo,
|
||||
GiveServerRecord,
|
||||
OfferServerRecord,
|
||||
PlanServerRecord,
|
||||
} from "@/libs/endorserServer";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { EntityIcon, GiftedDialog, OfferDialog, QuickNav, TopMessage },
|
||||
})
|
||||
export default class ProjectViewView extends Vue {}
|
||||
export default class ProjectViewView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeDid = "";
|
||||
allMyDids: Array<string> = [];
|
||||
allContacts: Array<Contact> = [];
|
||||
apiServer = "";
|
||||
description = "";
|
||||
expanded = false;
|
||||
fulfilledByThis: PlanServerRecord | null = null;
|
||||
fulfillersToThis: Array<PlanServerRecord> = [];
|
||||
givesToThis: Array<GiveServerRecord> = [];
|
||||
issuer = "";
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
name = "";
|
||||
offersToThis: Array<OfferServerRecord> = [];
|
||||
projectId = localStorage.getItem("projectId") || ""; // handle ID
|
||||
timeSince = "";
|
||||
truncatedDesc = "";
|
||||
truncateLength = 40;
|
||||
url = "";
|
||||
|
||||
async created() {
|
||||
await db.open();
|
||||
const settings = (await db.settings.get(MASTER_SETTINGS_KEY)) as Settings;
|
||||
this.activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
this.allContacts = await db.contacts.toArray();
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = accountsDB.accounts;
|
||||
const accountsArr = await accounts?.toArray();
|
||||
this.allMyDids = accountsArr.map((acc) => acc.did);
|
||||
const account = accountsArr.find((acc) => acc.did === this.activeDid);
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
const pathParam = window.location.pathname.substring("/project/".length);
|
||||
if (pathParam) {
|
||||
this.projectId = decodeURIComponent(pathParam);
|
||||
}
|
||||
this.LoadProject(this.projectId, identity);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = (await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first()) as Account;
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
return identity;
|
||||
}
|
||||
|
||||
public async getHeaders(identity: IIdentifier) {
|
||||
const token = await accessToken(identity);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
onEditClick() {
|
||||
localStorage.setItem("projectId", this.projectId as string);
|
||||
const route = {
|
||||
name: "new-edit-project",
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
didInfo(
|
||||
did: string,
|
||||
activeDid: string,
|
||||
dids: Array<string>,
|
||||
contacts: Array<Contact>,
|
||||
) {
|
||||
return didInfo(did, activeDid, dids, contacts);
|
||||
}
|
||||
|
||||
expandText() {
|
||||
this.expanded = true;
|
||||
}
|
||||
|
||||
collapseText() {
|
||||
this.expanded = false;
|
||||
}
|
||||
|
||||
async LoadProject(projectId: string, identity: IIdentifier) {
|
||||
this.projectId = projectId;
|
||||
|
||||
const url =
|
||||
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
|
||||
const headers: RawAxiosRequestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (identity) {
|
||||
const token = await accessToken(identity);
|
||||
headers["Authorization"] = "Bearer " + token;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200) {
|
||||
const startTime = resp.data.startTime;
|
||||
if (startTime != null) {
|
||||
const eventDate = new Date(startTime);
|
||||
const now = moment.now();
|
||||
this.timeSince = moment.utc(now).to(eventDate);
|
||||
}
|
||||
this.issuer = resp.data.issuer;
|
||||
this.name = resp.data.claim?.name || "(no name)";
|
||||
this.description = resp.data.claim?.description || "(no description)";
|
||||
this.truncatedDesc = this.description.slice(0, this.truncateLength);
|
||||
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
|
||||
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
|
||||
this.url = resp.data.claim?.url || "";
|
||||
} else {
|
||||
// actually, axios throws an error on 404 so we probably never get here
|
||||
console.log("Error getting project:", resp);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was a problem getting that project. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Error retrieving project:", error);
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError.response?.status === 404) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "That project does not exist.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving that project. See logs for more info.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const givesInUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/givesForPlans?planIds=" +
|
||||
encodeURIComponent(JSON.stringify([projectId]));
|
||||
try {
|
||||
const resp = await this.axios.get(givesInUrl, { headers });
|
||||
if (resp.status === 200 && resp.data.data) {
|
||||
this.givesToThis = resp.data.data;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to retrieve gives to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving gives to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Error retrieving gives to this project:",
|
||||
serverError.message,
|
||||
);
|
||||
}
|
||||
|
||||
const offersToUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/offersToPlans?planIds=" +
|
||||
encodeURIComponent(JSON.stringify([projectId]));
|
||||
try {
|
||||
const resp = await this.axios.get(offersToUrl, { headers });
|
||||
if (resp.status === 200 && resp.data.data) {
|
||||
this.offersToThis = resp.data.data;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to retrieve offers to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving offers to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Error retrieving offers to this project:",
|
||||
serverError.message,
|
||||
);
|
||||
}
|
||||
|
||||
const fulfilledByUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/planFulfilledByPlan?planHandleId=" +
|
||||
encodeURIComponent(projectId);
|
||||
try {
|
||||
const resp = await this.axios.get(fulfilledByUrl, { headers });
|
||||
if (resp.status === 200) {
|
||||
this.fulfilledByThis = resp.data.data;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to retrieve plans fulfilled by this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving plans fulfilled by this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Error retrieving plans fulfilled by this project:",
|
||||
serverError.message,
|
||||
);
|
||||
}
|
||||
|
||||
const fulfillersToUrl =
|
||||
this.apiServer +
|
||||
"/api/v2/report/planFulfillersToPlan?planHandleId=" +
|
||||
encodeURIComponent(projectId);
|
||||
try {
|
||||
const resp = await this.axios.get(fulfillersToUrl, { headers });
|
||||
if (resp.status === 200) {
|
||||
this.fulfillersToThis = resp.data.data;
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to retrieve plan fulfillers to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const serverError = error as AxiosError;
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong retrieving plan fulfillers to this project.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Error retrieving plan fulfillers to this project:",
|
||||
serverError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clicking on a project entry found in the list
|
||||
* @param id of the project
|
||||
**/
|
||||
async onClickLoadProject(projectId: string) {
|
||||
localStorage.setItem("projectId", projectId);
|
||||
const route = {
|
||||
path: "/project/" + encodeURIComponent(projectId),
|
||||
};
|
||||
this.$router.push(route);
|
||||
this.LoadProject(projectId, await this.getIdentity(this.activeDid));
|
||||
}
|
||||
|
||||
getOpenStreetMapUrl() {
|
||||
// Google URL is https://maps.google.com/?q=LAT,LONG
|
||||
return (
|
||||
"https://www.openstreetmap.org/?mlat=" +
|
||||
this.latitude +
|
||||
"&mlon=" +
|
||||
this.longitude +
|
||||
"#map=15/" +
|
||||
this.latitude +
|
||||
"/" +
|
||||
this.longitude
|
||||
);
|
||||
}
|
||||
|
||||
openGiftDialog(contact: GiverInputInfo) {
|
||||
(this.$refs.customGiveDialog as GiftedDialog).open(contact);
|
||||
}
|
||||
|
||||
openOfferDialog() {
|
||||
(this.$refs.customOfferDialog as OfferDialog).open();
|
||||
}
|
||||
|
||||
onClickLoadClaim(jwtId: string) {
|
||||
const route = {
|
||||
path: "/claim/" + encodeURIComponent(jwtId),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
UNIT_CODES: Record<string, Record<string, string>> = {
|
||||
BTC: {
|
||||
name: "Bitcoin",
|
||||
faIcon: "bitcoin-sign",
|
||||
},
|
||||
HUR: {
|
||||
name: "hours",
|
||||
faIcon: "clock",
|
||||
},
|
||||
USD: {
|
||||
name: "US Dollars",
|
||||
faIcon: "dollar",
|
||||
},
|
||||
};
|
||||
|
||||
iconForUnitCode(unitCode: string) {
|
||||
return this.UNIT_CODES[unitCode]?.faIcon || "question";
|
||||
}
|
||||
|
||||
// return an HTTPS URL if it's not a global URL
|
||||
addScheme(url: string) {
|
||||
if (!isGlobalUri(url)) {
|
||||
return "https://" + url;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// return just the domain for display, if possible
|
||||
domainForWebsite(url: string) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
if (!hostname) {
|
||||
// happens for non-http URLs
|
||||
return url;
|
||||
} else if (url.endsWith(hostname)) {
|
||||
// it's just the domain
|
||||
return hostname;
|
||||
} else {
|
||||
// there's more, but don't bother displaying the whole thing
|
||||
return hostname + "...";
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// must not be a valid URL
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
262
src/views/ProjectsView.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<QuickNav selected="Projects"></QuickNav>
|
||||
<TopMessage />
|
||||
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Your Ideas
|
||||
</h1>
|
||||
|
||||
<!-- Quick Search -->
|
||||
|
||||
<div id="QuickSearch" class="mb-4 flex">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-2"
|
||||
/>
|
||||
<button
|
||||
class="px-4 rounded-r bg-slate-200 border border-l-0 border-slate-400"
|
||||
>
|
||||
<fa icon="magnifying-glass" class="fa-fw"></fa>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New Project -->
|
||||
<button
|
||||
class="fixed right-6 bottom-24 text-center text-4xl leading-none bg-blue-600 text-white w-14 py-2.5 rounded-full"
|
||||
@click="onClickNewProject()"
|
||||
>
|
||||
<fa icon="plus" class="fa-fw"></fa>
|
||||
</button>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
<div
|
||||
class="fixed left-6 bottom-24 text-center text-4xl leading-none bg-slate-400 text-white w-14 py-2.5 rounded-full"
|
||||
v-if="isLoading"
|
||||
>
|
||||
<fa icon="spinner" class="fa-spin-pulse"></fa>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<InfiniteScroll @reached-bottom="loadMoreData">
|
||||
<ul class="border-t border-slate-300">
|
||||
<li
|
||||
class="border-b border-slate-300"
|
||||
v-for="project in projects"
|
||||
:key="project.handleId"
|
||||
>
|
||||
<a
|
||||
@click="onClickLoadProject(project.handleId)"
|
||||
class="block py-4 flex gap-4"
|
||||
>
|
||||
<div class="flex-none w-12">
|
||||
<EntityIcon
|
||||
:entityId="project.handleId"
|
||||
:iconSize="48"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md"
|
||||
></EntityIcon>
|
||||
</div>
|
||||
|
||||
<div class="grow overflow-hidden">
|
||||
<h2 class="text-base font-semibold">{{ project.name }}</h2>
|
||||
<div class="text-sm truncate">
|
||||
{{ project.description }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import { accessToken } from "@/libs/crypto";
|
||||
import { IIdentifier } from "@veramo/core";
|
||||
import InfiniteScroll from "@/components/InfiniteScroll.vue";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
import EntityIcon from "@/components/EntityIcon.vue";
|
||||
import TopMessage from "@/components/TopMessage.vue";
|
||||
import { ProjectData } from "@/libs/endorserServer";
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: { InfiniteScroll, QuickNav, EntityIcon, TopMessage },
|
||||
})
|
||||
export default class ProjectsView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
apiServer = "";
|
||||
projects: ProjectData[] = [];
|
||||
current: IIdentifier;
|
||||
isLoading = false;
|
||||
numAccounts = 0;
|
||||
|
||||
async beforeCreate() {
|
||||
await accountsDB.open();
|
||||
this.numAccounts = await accountsDB.accounts.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core project data loader
|
||||
* @param url the url used to fetch the data
|
||||
* @param token Authorization token
|
||||
**/
|
||||
async dataLoader(url: string, token: string) {
|
||||
const headers: { [key: string]: string } = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
const resp = await this.axios.get(url, { headers });
|
||||
if (resp.status === 200 || !resp.data.data) {
|
||||
const plans: ProjectData[] = resp.data.data;
|
||||
for (const plan of plans) {
|
||||
const { name, description, handleId, issuerDid, rowid } = plan;
|
||||
this.projects.push({ name, description, handleId, issuerDid, rowid });
|
||||
}
|
||||
} else {
|
||||
console.log("Bad server response & data:", resp.status, resp.data);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to get projects from the server. Try again later.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error: any) {
|
||||
console.error("Got error loading projects:", error.message || error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Got an error loading projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data loader used by infinite scroller
|
||||
* @param payload is the flag from the InfiniteScroll indicating if it should load
|
||||
**/
|
||||
async loadMoreData(payload: boolean) {
|
||||
if (this.projects.length > 0 && payload) {
|
||||
const latestProject = this.projects[this.projects.length - 1];
|
||||
const url = `${this.apiServer}/api/v2/report/plansByIssuer?beforeId=${latestProject.rowid}`;
|
||||
const token = await accessToken(this.current);
|
||||
await this.dataLoader(url, token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clicking on a project entry found in the list
|
||||
* @param id of the project
|
||||
**/
|
||||
onClickLoadProject(id: string) {
|
||||
localStorage.setItem("projectId", id);
|
||||
const route = {
|
||||
path: "/project/" + encodeURIComponent(id),
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load projects initially
|
||||
* @param identity of the user
|
||||
**/
|
||||
async LoadProjects(identity: IIdentifier) {
|
||||
const url = `${this.apiServer}/api/v2/report/plansByIssuer`;
|
||||
const token: string = await accessToken(identity);
|
||||
await this.dataLoader(url, token);
|
||||
}
|
||||
|
||||
public async getIdentity(activeDid: string) {
|
||||
await accountsDB.open();
|
||||
const account = await accountsDB.accounts
|
||||
.where("did")
|
||||
.equals(activeDid)
|
||||
.first();
|
||||
const identity = JSON.parse(account?.identity || "null");
|
||||
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
"Attempted to load project records with no identity available.",
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 'created' hook runs when the Vue instance is first created
|
||||
**/
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
const activeDid = settings?.activeDid || "";
|
||||
this.apiServer = settings?.apiServer || "";
|
||||
|
||||
if (this.numAccounts === 0) {
|
||||
console.error("No accounts found.");
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "You need an identity to load your projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
} else {
|
||||
const identity = await this.getIdentity(activeDid);
|
||||
this.current = identity;
|
||||
this.LoadProjects(identity);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Error initializing:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Something went wrong loading your projects.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handling clicking on the new project button
|
||||
**/
|
||||
onClickNewProject(): void {
|
||||
localStorage.removeItem("projectId");
|
||||
const route = {
|
||||
name: "new-edit-project",
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
286
src/views/SearchAreaView.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<fa icon="chevron-left" class="fa-fw"></fa>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Area for Nearby Search
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-2 py-4">
|
||||
This location is only stored on your device. It is used to show you more
|
||||
appropriate projects but is not stored on any servers.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button v-if="!searchBox && !isNewMarkerSet" class="m-4 px-4 py-2">
|
||||
Click to Choose a Location for Nearby Search
|
||||
</button>
|
||||
<button
|
||||
v-if="isNewMarkerSet"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="storeSearchBox"
|
||||
>
|
||||
Store This Location for Nearby Search
|
||||
</button>
|
||||
<button
|
||||
v-if="searchBox"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="forgetSearchBox"
|
||||
>
|
||||
Delete Stored Location
|
||||
</button>
|
||||
<button
|
||||
v-if="searchBox"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="resetLatLong"
|
||||
>
|
||||
Reset Marker
|
||||
</button>
|
||||
<button
|
||||
v-if="isNewMarkerSet"
|
||||
class="m-4 px-4 py-2 rounded-md bg-blue-200 text-blue-500"
|
||||
@click="isNewMarkerSet = false"
|
||||
>
|
||||
Erase Marker
|
||||
</button>
|
||||
<div v-if="isNewMarkerSet">
|
||||
Click on the pin to erase it. Click anywhere else to set a different
|
||||
different corner.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 600px; width: 800px">
|
||||
<l-map
|
||||
ref="map"
|
||||
:center="[localCenterLat, localCenterLong]"
|
||||
v-model:zoom="localZoom"
|
||||
@click="setMapPoint"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
layer-type="base"
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
<l-marker
|
||||
v-if="isNewMarkerSet"
|
||||
:lat-lng="[localCenterLat, localCenterLong]"
|
||||
@click="isNewMarkerSet = false"
|
||||
/>
|
||||
<l-rectangle
|
||||
v-if="isNewMarkerSet"
|
||||
:bounds="[
|
||||
[localCenterLat - localLatDiff, localCenterLong - localLongDiff],
|
||||
[localCenterLat + localLatDiff, localCenterLong + localLongDiff],
|
||||
]"
|
||||
:weight="1"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { LeafletMouseEvent } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import {
|
||||
LMap,
|
||||
LMarker,
|
||||
LRectangle,
|
||||
LTileLayer,
|
||||
} from "@vue-leaflet/vue-leaflet";
|
||||
|
||||
import { db } from "@/db/index";
|
||||
import { BoundingBox, MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
const DEFAULT_LAT_LONG_DIFF = 0.01;
|
||||
const WORLD_ZOOM = 2;
|
||||
const DEFAULT_ZOOM = 2;
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QuickNav,
|
||||
LRectangle,
|
||||
LMap,
|
||||
LMarker,
|
||||
LTileLayer,
|
||||
},
|
||||
})
|
||||
export default class DiscoverView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
isChoosingSearchBox = false;
|
||||
isNewMarkerSet = false;
|
||||
|
||||
// "local" vars are for the currently selected map box
|
||||
localCenterLat = 0;
|
||||
localCenterLong = 0;
|
||||
localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
localZoom = DEFAULT_ZOOM;
|
||||
|
||||
// searchBox reflects what is stored in the database
|
||||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||
|
||||
async mounted() {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
this.searchBox = settings?.searchBoxes?.[0] || null;
|
||||
this.resetLatLong();
|
||||
}
|
||||
|
||||
setMapPoint(event: LeafletMouseEvent) {
|
||||
if (this.isNewMarkerSet) {
|
||||
this.localLatDiff = Math.abs(event.latlng.lat - this.localCenterLat);
|
||||
this.localLongDiff = Math.abs(event.latlng.lng - this.localCenterLong);
|
||||
} else {
|
||||
// marker is not set
|
||||
this.localCenterLat = event.latlng.lat;
|
||||
this.localCenterLong = event.latlng.lng;
|
||||
|
||||
let latDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
let longDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
// Guess at a size for the bounding box.
|
||||
// This doesn't seem like the right approach but it's the only way I can find to get the screen bounds.
|
||||
const bounds = event.target.boxZoom?._map?.getBounds();
|
||||
if (bounds) {
|
||||
latDiff = Math.abs(bounds._northEast.lat - bounds._southWest.lat) / 8;
|
||||
longDiff = Math.abs(bounds._northEast.lng - bounds._southWest.lng) / 8;
|
||||
}
|
||||
this.localLatDiff = latDiff;
|
||||
this.localLongDiff = longDiff;
|
||||
this.isNewMarkerSet = true;
|
||||
}
|
||||
}
|
||||
|
||||
public resetLatLong() {
|
||||
if (this.searchBox?.bbox) {
|
||||
const bbox = this.searchBox.bbox;
|
||||
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
|
||||
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
|
||||
this.localLatDiff = (bbox.maxLat - bbox.minLat) / 2;
|
||||
this.localLongDiff = (bbox.eastLong - bbox.westLong) / 2;
|
||||
this.localZoom = WORLD_ZOOM;
|
||||
this.isNewMarkerSet = true;
|
||||
} else {
|
||||
this.isNewMarkerSet = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async storeSearchBox() {
|
||||
if (this.localCenterLong || this.localCenterLat) {
|
||||
try {
|
||||
const newSearchBox = {
|
||||
name: "Local",
|
||||
bbox: {
|
||||
eastLong: this.localCenterLong + this.localLongDiff,
|
||||
maxLat: this.localCenterLat + this.localLatDiff,
|
||||
minLat: this.localCenterLat - this.localLatDiff,
|
||||
westLong: this.localCenterLong - this.localLongDiff,
|
||||
},
|
||||
};
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [newSearchBox],
|
||||
});
|
||||
this.searchBox = newSearchBox;
|
||||
this.isChoosingSearchBox = false;
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Saved",
|
||||
text: "That has been saved in your preferences.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
this.$router.back();
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Updating Search Settings",
|
||||
text: "Try going to a different page and then coming back.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to retry the location search setting because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "warning",
|
||||
title: "No Location Selected",
|
||||
text: "Select a location on the map.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async forgetSearchBox() {
|
||||
try {
|
||||
await db.open();
|
||||
db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
searchBoxes: [],
|
||||
});
|
||||
this.searchBox = null;
|
||||
this.localCenterLat = 0;
|
||||
this.localCenterLong = 0;
|
||||
this.localLatDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
this.localLongDiff = DEFAULT_LAT_LONG_DIFF;
|
||||
this.localZoom = DEFAULT_ZOOM;
|
||||
this.isChoosingSearchBox = false;
|
||||
this.isNewMarkerSet = false;
|
||||
} catch (err) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Updating Search Settings",
|
||||
text: "Try going to a different page and then coming back.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
console.error(
|
||||
"Telling user to retry the location search setting because:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public cancelSearchBoxSelect() {
|
||||
this.isChoosingSearchBox = false;
|
||||
this.localZoom = WORLD_ZOOM;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
111
src/views/SeedBackupView.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<QuickNav selected="Profile"></QuickNav>
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4 mb-8">
|
||||
Seed Backup
|
||||
</h1>
|
||||
|
||||
<div class="flex justify-between py-2">
|
||||
<span />
|
||||
<span>
|
||||
<router-link
|
||||
:to="{ name: 'help' }"
|
||||
class="text-xs uppercase bg-blue-500 text-white px-1.5 py-1 rounded-md ml-1"
|
||||
>
|
||||
Help
|
||||
</router-link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="activeAccount">
|
||||
<p class="text-center mb-4">
|
||||
<b class="text-red-600">BEWARE!</b> Anyone who has this seed phrase will
|
||||
be able impersonate you and take over any digital holdings based on it.
|
||||
Reveal it when you are somewhere only you can see your screen, and
|
||||
record it somewhere only you have access.
|
||||
<i>Don't take a screenshot or send it to any online service.</i>
|
||||
</p>
|
||||
|
||||
<p v-if="numAccounts > 1">
|
||||
<b class="text-orange-600">Note:</b> You have more than one identity
|
||||
stored in this browser. If they are all based on the same seed as the
|
||||
current identity, this one backup is sufficient; however, if you have
|
||||
different seeds for other identities, you will have to back them up
|
||||
separately.
|
||||
</p>
|
||||
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden p-4 mb-4">
|
||||
<button
|
||||
class="block w-full text-center text-md uppercase bg-slate-500 text-white px-1.5 py-2 rounded-md"
|
||||
@click="showSeedPhrase"
|
||||
>
|
||||
Reveal my Seed Phrase
|
||||
</button>
|
||||
|
||||
<p v-if="showSeed" class="text-center text-slate-700 mt-2">
|
||||
{{ activeAccount.mnemonic }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>You do not have an active identity.</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { accountsDB, db } from "@/db/index";
|
||||
import * as R from "ramda";
|
||||
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
|
||||
import QuickNav from "@/components/QuickNav.vue";
|
||||
|
||||
interface Account {
|
||||
mnemonic: string;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
group: string;
|
||||
type: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class SeedBackupView extends Vue {
|
||||
$notify!: (notification: Notification, timeout?: number) => void;
|
||||
|
||||
activeAccount: Account | null | undefined = null;
|
||||
numAccounts = 0;
|
||||
showSeed = false;
|
||||
|
||||
// 'created' hook runs when the Vue instance is first created
|
||||
async created() {
|
||||
try {
|
||||
await db.open();
|
||||
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
|
||||
const activeDid = settings?.activeDid || "";
|
||||
|
||||
await accountsDB.open();
|
||||
const accounts = await accountsDB.accounts.toArray();
|
||||
this.numAccounts = accounts.length;
|
||||
this.activeAccount = R.find((acc) => acc.did === activeDid, accounts);
|
||||
} catch (err: unknown) {
|
||||
console.error("Got an error loading an identity:", err);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Loading Account",
|
||||
text: "Got an error loading your seed data.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
showSeedPhrase() {
|
||||
this.showSeed = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||