Compare commits
910 Commits
v0.3.2
...
bunker-enc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c3aff0407 | ||
|
|
aad35d41db | ||
|
|
cc6189a5d9 | ||
|
|
18bf8f9a2c | ||
|
|
37f3a32a1c | ||
|
|
c9678564a5 | ||
|
|
721c18c509 | ||
|
|
9e30fe683b | ||
|
|
7fff50c146 | ||
|
|
fc1c845b67 | ||
|
|
c2ec1f3677 | ||
|
|
0cbd357856 | ||
|
|
26ea9ed547 | ||
|
|
9cbbecb32c | ||
|
|
db12c89731 | ||
|
|
6f413deb90 | ||
|
|
0127e2dc86 | ||
|
|
7743928702 | ||
|
|
bf76150fc1 | ||
|
|
c62107172b | ||
|
|
a253587dfa | ||
|
|
1938533d53 | ||
|
|
28943c55bd | ||
|
|
791bbb68b6 | ||
|
|
ec8adcc794 | ||
|
|
68058e7661 | ||
|
|
416c62369c | ||
|
|
a19dd53423 | ||
|
|
79ec33b79a | ||
|
|
be881b957c | ||
|
|
244872e9f2 | ||
|
|
1397f7f0f4 | ||
|
|
96424dd65c | ||
|
|
9efc5459fb | ||
|
|
7e02168e54 | ||
|
|
f8e6b3e828 | ||
|
|
c06176bfc9 | ||
|
|
e2a1701000 | ||
|
|
d7703ceef4 | ||
|
|
93daabc673 | ||
|
|
9264245944 | ||
|
|
f56423040b | ||
|
|
4b91504a50 | ||
|
|
1f0f7fef5e | ||
|
|
6aced653fb | ||
|
|
0899482869 | ||
|
|
1bdfa1e6e1 | ||
|
|
f22a8f15c0 | ||
|
|
bf6394fc7d | ||
|
|
6f08586e8f | ||
|
|
d60a4a24ad | ||
|
|
51069f3623 | ||
|
|
1407af22e3 | ||
|
|
ea6220277d | ||
|
|
fbffa03dad | ||
|
|
a74760d804 | ||
|
|
c4b0a712d2 | ||
|
|
1fecf9c7f4 | ||
|
|
7be21203d9 | ||
|
|
f65f2c6597 | ||
|
|
227def4328 | ||
|
|
b506624f57 | ||
|
|
fbb6a0a153 | ||
|
|
528de32689 | ||
|
|
230e5380ca | ||
|
|
349237d097 | ||
|
|
d4df9f0424 | ||
|
|
2f68e84002 | ||
|
|
b18dcc29cd | ||
|
|
680169e312 | ||
|
|
11753c4515 | ||
|
|
bd29dfd65f | ||
|
|
4b1ae838e5 | ||
|
|
85599d3103 | ||
|
|
4603c5a258 | ||
|
|
ec45fbc5e8 | ||
|
|
53400334b2 | ||
|
|
af4ff7081a | ||
|
|
7f21b8ed76 | ||
|
|
55e44dcc9c | ||
|
|
59dac947ab | ||
|
|
7d33c3c024 | ||
|
|
38a014ef84 | ||
|
|
f451348430 | ||
|
|
685aaf43b0 | ||
|
|
d6a20b5272 | ||
|
|
d8d7a19fa1 | ||
|
|
63626fae3a | ||
|
|
de09ef2935 | ||
|
|
bcb28a63a7 | ||
|
|
a479903ce3 | ||
|
|
567d105261 | ||
|
|
83743c5a9f | ||
|
|
0b8f88ea1d | ||
|
|
fadc755930 | ||
|
|
f67f171e64 | ||
|
|
449c59015e | ||
|
|
4d697e6a79 | ||
|
|
04ae70873a | ||
|
|
2f8a64826a | ||
|
|
11cb3542ee | ||
|
|
905296621c | ||
|
|
769484bc0d | ||
|
|
27ff4cef22 | ||
|
|
a352e2616e | ||
|
|
77cbb9394f | ||
|
|
39c8b3dfe4 | ||
|
|
7bd11e695e | ||
|
|
a76b703d36 | ||
|
|
df51173405 | ||
|
|
a79d7f9eaf | ||
|
|
1032a46456 | ||
|
|
ae997758ab | ||
|
|
91a827324d | ||
|
|
bf849c9faa | ||
|
|
118ab46ac0 | ||
|
|
d2f2b689f9 | ||
|
|
5229e45566 | ||
|
|
b17043e85d | ||
|
|
19ca909ef5 | ||
|
|
f7ff309b6e | ||
|
|
ea5a8486b9 | ||
|
|
58897b3436 | ||
|
|
6a59ecfa47 | ||
|
|
272066c6e0 | ||
|
|
0426c9d3b0 | ||
|
|
c22419ba0e | ||
|
|
8278fed2fb | ||
|
|
b24a65b490 | ||
|
|
fb509fabd8 | ||
|
|
d21285123f | ||
|
|
1029b6be0c | ||
|
|
3fff9455a1 | ||
|
|
8c6232e029 | ||
|
|
f6c562e9be | ||
|
|
a92b14e877 | ||
|
|
b69a956247 | ||
|
|
82a8dcf6eb | ||
|
|
8e19e22289 | ||
|
|
e167b57810 | ||
|
|
ba3b82e6b5 | ||
|
|
b5edfbb2c9 | ||
|
|
48048f877a | ||
|
|
bd1afc54c3 | ||
|
|
a2c4bed0f5 | ||
|
|
9bad49fe5f | ||
|
|
2aa6536496 | ||
|
|
bd6d8a0342 | ||
|
|
dc8e86bc57 | ||
|
|
32b843908e | ||
|
|
5a71480459 | ||
|
|
17455aa47b | ||
|
|
4cc32c27de | ||
|
|
99bfe209a5 | ||
|
|
0a28bfbd50 | ||
|
|
ba9fb109f6 | ||
|
|
ec9d2fcb49 | ||
|
|
f841043e03 | ||
|
|
94dc95e1f0 | ||
|
|
32a5145d8f | ||
|
|
a856e8ca26 | ||
|
|
d54306cf92 | ||
|
|
9fdb96b64e | ||
|
|
c50aa3a243 | ||
|
|
adef1a922c | ||
|
|
99df4d6761 | ||
|
|
5f6a414953 | ||
|
|
ed17a68986 | ||
|
|
bedf3daed1 | ||
|
|
2c913cf7e8 | ||
|
|
aff5bff03b | ||
|
|
e90f902f0b | ||
|
|
d763aa5f15 | ||
|
|
9d6b1f6f84 | ||
|
|
9eb2f35dbf | ||
|
|
5f33ad3ba0 | ||
|
|
3db4855532 | ||
|
|
3305be1da5 | ||
|
|
fe55e87496 | ||
|
|
f78f1a3460 | ||
|
|
e73d89739b | ||
|
|
7e2b4b46c9 | ||
|
|
fddf79e0c6 | ||
|
|
cf2098a723 | ||
|
|
5568437663 | ||
|
|
7bfd7fdf6c | ||
|
|
e6876d141f | ||
|
|
5bb81b3c22 | ||
|
|
1e8e58fa05 | ||
|
|
f44e36e4bf | ||
|
|
11c7564f8c | ||
|
|
a064376bd8 | ||
|
|
292e8e9bda | ||
|
|
951a3699ca | ||
|
|
860ec70b1c | ||
|
|
2b69c72939 | ||
|
|
b98d774cbf | ||
|
|
8972571a18 | ||
|
|
ab5d5dca58 | ||
|
|
e383356af1 | ||
|
|
165d10c49b | ||
|
|
e0869c436b | ||
|
|
95432fc276 | ||
|
|
1982d25fa8 | ||
|
|
2fc64b6028 | ||
|
|
6e8686a49d | ||
|
|
fd5ce80a06 | ||
|
|
ac4185e2cc | ||
|
|
9217077283 | ||
|
|
b7c14b5c7c | ||
|
|
9b3cc41770 | ||
|
|
4c4bd2214c | ||
|
|
93c31650f4 | ||
|
|
7f0d99fc29 | ||
|
|
eb6dbe1644 | ||
|
|
474da25f77 | ||
|
|
02eaa1c8f8 | ||
|
|
8800791723 | ||
|
|
6758b9678b | ||
|
|
63f58e010f | ||
|
|
85649ae283 | ||
|
|
d0b814e39d | ||
|
|
f4a227e40a | ||
|
|
6ef0a6dd71 | ||
|
|
5502d71ac4 | ||
|
|
5e1146b015 | ||
|
|
8f89165711 | ||
|
|
674634326f | ||
|
|
30eaec5770 | ||
|
|
0ff3c864a9 | ||
|
|
ab2ca1f5e7 | ||
|
|
cf2d227f61 | ||
|
|
2c9e6cc54e | ||
|
|
8da0a06711 | ||
|
|
be8d857223 | ||
|
|
d50bcd700e | ||
|
|
820ab1d902 | ||
|
|
f5e9e5bf61 | ||
|
|
40b43532e8 | ||
|
|
51a3008730 | ||
|
|
e30cbc72c3 | ||
|
|
6f913262f4 | ||
|
|
0f0462e6ac | ||
|
|
e353f0e2d6 | ||
|
|
ee1365d3ca | ||
|
|
a215d0b026 | ||
|
|
b8d76c0bd8 | ||
|
|
233169b082 | ||
|
|
72b9a04cd2 | ||
|
|
432715efb6 | ||
|
|
8b2b954dde | ||
|
|
c2d2bd8106 | ||
|
|
a5c3085c59 | ||
|
|
c0332f08d6 | ||
|
|
38a1d6caec | ||
|
|
39dd607e7b | ||
|
|
9dc0db3e06 | ||
|
|
b1eb58a385 | ||
|
|
f3c6404f76 | ||
|
|
1a42a6422d | ||
|
|
2e2de4ccda | ||
|
|
4325d3a519 | ||
|
|
51115c5f68 | ||
|
|
2aa6fe860b | ||
|
|
86f39eacf8 | ||
|
|
d15daef3ea | ||
|
|
281c70cdea | ||
|
|
d6d6087543 | ||
|
|
d06e38bc19 | ||
|
|
cfc8eb0bbc | ||
|
|
b85f9b79c3 | ||
|
|
1b0045c737 | ||
|
|
3dc8d7d440 | ||
|
|
bf9ca48d64 | ||
|
|
70441f3d59 | ||
|
|
431f28e861 | ||
|
|
3b1fc095c4 | ||
|
|
9a6c7a29d0 | ||
|
|
c1d173f40e | ||
|
|
f03ec5df8c | ||
|
|
6c74a12636 | ||
|
|
39797803d3 | ||
|
|
c66c1e928d | ||
|
|
f934b641bb | ||
|
|
1128a11603 | ||
|
|
9f90718918 | ||
|
|
067a07fc00 | ||
|
|
1811cf045e | ||
|
|
270b4f429f | ||
|
|
380acbb55f | ||
|
|
c384f0b4fb | ||
|
|
27cf393a03 | ||
|
|
8831726913 | ||
|
|
2f4327874c | ||
|
|
483845962e | ||
|
|
c44b1d6349 | ||
|
|
79f28a142d | ||
|
|
02dd537cd9 | ||
|
|
5af1f14a0b | ||
|
|
664f59a9cc | ||
|
|
7d3641aab7 | ||
|
|
7924df4c67 | ||
|
|
68a8eed4af | ||
|
|
887db84ce7 | ||
|
|
05348fbfeb | ||
|
|
38eb6716f8 | ||
|
|
d7f9cd30eb | ||
|
|
922d041e0e | ||
|
|
76f4588c85 | ||
|
|
e163b92a7e | ||
|
|
11925a42b0 | ||
|
|
acf45530ca | ||
|
|
3792ad6abf | ||
|
|
bf98b307e8 | ||
|
|
d15392f41e | ||
|
|
f26a024255 | ||
|
|
bf9f894c0d | ||
|
|
53a7b7d1c5 | ||
|
|
a12a883cc6 | ||
|
|
0cf076b010 | ||
|
|
e2c712033f | ||
|
|
e38237ca8e | ||
|
|
1fff44fc6c | ||
|
|
4e50073e07 | ||
|
|
0ce64fe83f | ||
|
|
ef848aa93e | ||
|
|
67b287d75d | ||
|
|
b795dfd2c6 | ||
|
|
c68d855983 | ||
|
|
fb1c19e64b | ||
|
|
384c16e29d | ||
|
|
789982bd76 | ||
|
|
8bccc9de48 | ||
|
|
ec8584b4d2 | ||
|
|
54bd59fa2d | ||
|
|
b19f5f55f7 | ||
|
|
0964f25f97 | ||
|
|
5f3e6335c1 | ||
|
|
f30c894c87 | ||
|
|
bec769ac1b | ||
|
|
cb3748e06f | ||
|
|
d5a24f0a46 | ||
|
|
401a8241bd | ||
|
|
2193a7a863 | ||
|
|
e6bc4d7fda | ||
|
|
aee9f73316 | ||
|
|
aef7b4cea4 | ||
|
|
c9a8a3b91e | ||
|
|
0c7b11bdf8 | ||
|
|
8c151a5855 | ||
|
|
9b54fa9c14 | ||
|
|
99d7705404 | ||
|
|
eaa590b8e2 | ||
|
|
715fd8cf10 | ||
|
|
99a9709605 | ||
|
|
65d330d5ed | ||
|
|
1d1d389a03 | ||
|
|
0392389355 | ||
|
|
cf2a500a07 | ||
|
|
7d3748202e | ||
|
|
d7f90faea9 | ||
|
|
cb0066aac9 | ||
|
|
b48397b7a6 | ||
|
|
82ab8419e3 | ||
|
|
142a2414d3 | ||
|
|
081bd95f60 | ||
|
|
300aed0589 | ||
|
|
b2b23c66cf | ||
|
|
838bb6aa3d | ||
|
|
f14ecc5acb | ||
|
|
d533e23dc0 | ||
|
|
eefcf99364 | ||
|
|
1c0790bfb6 | ||
|
|
29e351ba78 | ||
|
|
7592c5c327 | ||
|
|
f5018204ab | ||
|
|
7ae74268fd | ||
|
|
52e959a7f5 | ||
|
|
4f03a2c276 | ||
|
|
bc4c96ee35 | ||
|
|
a866040fc1 | ||
|
|
c90fad268a | ||
|
|
8ef1f775f9 | ||
|
|
90af87339c | ||
|
|
9007b1ca71 | ||
|
|
0b7e6145de | ||
|
|
bf1b608d96 | ||
|
|
7db0f2a05c | ||
|
|
165b4d4b9f | ||
|
|
a7106138c4 | ||
|
|
a498bfab38 | ||
|
|
3dd2980283 | ||
|
|
2e2a1a2c9d | ||
|
|
b9666bf037 | ||
|
|
ab1e964d3a | ||
|
|
1500744a96 | ||
|
|
394311622d | ||
|
|
c7f3991ddd | ||
|
|
e05efaa4f6 | ||
|
|
c96347a331 | ||
|
|
d721e84e42 | ||
|
|
dcbe4bd23e | ||
|
|
e11184426e | ||
|
|
ebea872c72 | ||
|
|
8e57d3d491 | ||
|
|
ca339ac0b2 | ||
|
|
abb6819c40 | ||
|
|
de314894ff | ||
|
|
2939747ebf | ||
|
|
a4548306e7 | ||
|
|
f16c1720a6 | ||
|
|
5b2ee94062 | ||
|
|
3091ad7fd4 | ||
|
|
5b7488295c | ||
|
|
bea62ddc4b | ||
|
|
44d6b1fb2a | ||
|
|
02ec8dd936 | ||
|
|
765ce0ac5e | ||
|
|
a1f7c3e34a | ||
|
|
2e5eb08b54 | ||
|
|
46a6d4fe0c | ||
|
|
84ea0df550 | ||
|
|
0f58b166ce | ||
|
|
f65d39023c | ||
|
|
0b3c7efbc1 | ||
|
|
ecb462562f | ||
|
|
c5a3d00371 | ||
|
|
d3b7a8ddde | ||
|
|
0eee203a9b | ||
|
|
cd5a95dea3 | ||
|
|
f348ddaf73 | ||
|
|
9f09093c80 | ||
|
|
490c6c9bdc | ||
|
|
4eb0ede76b | ||
|
|
02c1b6b783 | ||
|
|
9eed448da6 | ||
|
|
f8d621bcdc | ||
|
|
5cbe2246d3 | ||
|
|
f29a180cbd | ||
|
|
0ca3771906 | ||
|
|
6dab126f88 | ||
|
|
6c74d04984 | ||
|
|
1e00ff5e35 | ||
|
|
71fa334f61 | ||
|
|
d3ee995221 | ||
|
|
6812584b8c | ||
|
|
47ddf8ebe1 | ||
|
|
36897e7f15 | ||
|
|
f18315be02 | ||
|
|
38d77b02f5 | ||
|
|
5b77a93bba | ||
|
|
e1c11a7450 | ||
|
|
d96ee50f5a | ||
|
|
d4a172ba7e | ||
|
|
52ddb8dd7d | ||
|
|
8c16614752 | ||
|
|
700d7cc5fa | ||
|
|
017703dab2 | ||
|
|
c59fdb14f1 | ||
|
|
0c104f95d9 | ||
|
|
acbefae501 | ||
|
|
2ce83ef88a | ||
|
|
dab3412ecd | ||
|
|
988b3164d2 | ||
|
|
4161053821 | ||
|
|
60054c4865 | ||
|
|
f4e8aa576c | ||
|
|
30a495bcd1 | ||
|
|
6dde0eb220 | ||
|
|
90d8ef3423 | ||
|
|
f626a8ec9b | ||
|
|
a7c7535236 | ||
|
|
5b0f2821d6 | ||
|
|
be045557b8 | ||
|
|
a0c92182f9 | ||
|
|
f33d33556b | ||
|
|
9aff889835 | ||
|
|
420df1fbdd | ||
|
|
2946ede5ac | ||
|
|
6ec28e6a9d | ||
|
|
820daa489e | ||
|
|
b162596013 | ||
|
|
e581237e16 | ||
|
|
fcc329cc7c | ||
|
|
c9544e0fd2 | ||
|
|
d7906cfb95 | ||
|
|
13cd6aeb11 | ||
|
|
d4821d18fb | ||
|
|
b86bf48382 | ||
|
|
c595f94567 | ||
|
|
82058c0ef4 | ||
|
|
a1f3424b38 | ||
|
|
14ab749ef1 | ||
|
|
61dd4b2089 | ||
|
|
fb2fe1cc63 | ||
|
|
720f12ce1c | ||
|
|
423ebb403f | ||
|
|
c90fb66bb8 | ||
|
|
188de7ab1d | ||
|
|
0b1cf267a7 | ||
|
|
19f68612a5 | ||
|
|
1b1600d6f2 | ||
|
|
ce67c19ece | ||
|
|
f754ce3cfe | ||
|
|
19a86525cb | ||
|
|
29213ceb1c | ||
|
|
d25a9b1735 | ||
|
|
0f03706166 | ||
|
|
b1f79e3844 | ||
|
|
243d9b17ef | ||
|
|
50a6cf6499 | ||
|
|
8f7991e971 | ||
|
|
0aba54bd23 | ||
|
|
23833b2cff | ||
|
|
d5076ff53e | ||
|
|
41e452be1e | ||
|
|
f267df8f60 | ||
|
|
7426c9d1fc | ||
|
|
93d0c1052b | ||
|
|
6537650757 | ||
|
|
a95f9b522b | ||
|
|
47d1335842 | ||
|
|
168095e133 | ||
|
|
5c7b413a8d | ||
|
|
bca6458e44 | ||
|
|
ebdfa3b5a3 | ||
|
|
17480dddbf | ||
|
|
2a422fbeb9 | ||
|
|
22961ee479 | ||
|
|
18db905974 | ||
|
|
689963c041 | ||
|
|
3f8869fd75 | ||
|
|
129aced1a2 | ||
|
|
69febf4356 | ||
|
|
65051c9c1f | ||
|
|
ba8229d464 | ||
|
|
9251b017d4 | ||
|
|
1ae76031f3 | ||
|
|
994d834a0b | ||
|
|
67a4e17055 | ||
|
|
1e82e3f240 | ||
|
|
f973c75ff5 | ||
|
|
28316a71c5 | ||
|
|
cfc12e2d78 | ||
|
|
7464a8b505 | ||
|
|
938d79663b | ||
|
|
cc0ad69275 | ||
|
|
810ff060f8 | ||
|
|
5e03ef70a6 | ||
|
|
f05fb29c7b | ||
|
|
e737b1f7f0 | ||
|
|
21a7be2f98 | ||
|
|
4c720aa049 | ||
|
|
6b240b01ec | ||
|
|
945894e3db | ||
|
|
667397e528 | ||
|
|
e4b0d6d1cd | ||
|
|
3cdda2dcb7 | ||
|
|
876ecc808d | ||
|
|
34671bd067 | ||
|
|
a6285f6a1d | ||
|
|
36508d600a | ||
|
|
a304bb7c26 | ||
|
|
04bab96a07 | ||
|
|
22ebbff755 | ||
|
|
b43f40597f | ||
|
|
fe3af25c5f | ||
|
|
ffafc6f64d | ||
|
|
eadab9a37f | ||
|
|
13b1692931 | ||
|
|
3a78289fee | ||
|
|
12c70b06de | ||
|
|
c7c82954ad | ||
|
|
3b639e2783 | ||
|
|
946584236d | ||
|
|
aadbf2084f | ||
|
|
3d7b649cba | ||
|
|
caa07012a7 | ||
|
|
ad5cd875de | ||
|
|
0a4bc2cfbb | ||
|
|
605dd41939 | ||
|
|
8679ae7a37 | ||
|
|
3c1e4312c9 | ||
|
|
53ed6849af | ||
|
|
4b95e6c262 | ||
|
|
40ab215c4d | ||
|
|
823927525f | ||
|
|
6277824b32 | ||
|
|
f94e4ba900 | ||
|
|
acf14ccee9 | ||
|
|
f882b63359 | ||
|
|
7b1e3be39b | ||
|
|
ee17018076 | ||
|
|
1dd2e1dc38 | ||
|
|
4cd1aa89ad | ||
|
|
e667cf05c2 | ||
|
|
7512375728 | ||
|
|
f108e2e70a | ||
|
|
daa43ec4c4 | ||
|
|
ab2223e739 | ||
|
|
e8cbb3af4b | ||
|
|
f374a9af28 | ||
|
|
f2cbc66a97 | ||
|
|
1627d4f53e | ||
|
|
b93a4d072a | ||
|
|
3e4bc97684 | ||
|
|
3c0c20f61c | ||
|
|
dae63e210b | ||
|
|
dc500cc296 | ||
|
|
1fc1e4f102 | ||
|
|
524b5e1559 | ||
|
|
930de76d1f | ||
|
|
b85fc820d1 | ||
|
|
b145aee29d | ||
|
|
a0e65a48f1 | ||
|
|
ccdfc54cdc | ||
|
|
61ce338b8c | ||
|
|
47de9a75b7 | ||
|
|
607f3d46f0 | ||
|
|
bdbc08fdf1 | ||
|
|
3a28160ae8 | ||
|
|
e03696eed7 | ||
|
|
f80fa3de7f | ||
|
|
4518fc16a7 | ||
|
|
7f2b70779b | ||
|
|
cc9cc47b51 | ||
|
|
a19cb8b6dc | ||
|
|
c564d1608b | ||
|
|
c146a8f7ec | ||
|
|
48cde27a5b | ||
|
|
fdf0644bbb | ||
|
|
ec7371c43b | ||
|
|
35204ee400 | ||
|
|
d1031b3342 | ||
|
|
db67e94b9e | ||
|
|
a0e5ba3a63 | ||
|
|
f3f80449a6 | ||
|
|
bd0b4e848f | ||
|
|
4f5ba99214 | ||
|
|
aab67d8375 | ||
|
|
dbc0a48194 | ||
|
|
6a84646b0b | ||
|
|
e921967082 | ||
|
|
ec34bc3d04 | ||
|
|
96ce12b952 | ||
|
|
1066c43d6c | ||
|
|
914557a61d | ||
|
|
3df2f248ff | ||
|
|
d2770d58e2 | ||
|
|
933182567d | ||
|
|
f9fa2f05f0 | ||
|
|
919bb8151f | ||
|
|
6f82674c9b | ||
|
|
8caf9988fc | ||
|
|
036ee20d98 | ||
|
|
b86545dcc8 | ||
|
|
8bdccd9c9e | ||
|
|
9a14185fa5 | ||
|
|
53a6053464 | ||
|
|
e27d7ee26c | ||
|
|
98203e6b6f | ||
|
|
8469740141 | ||
|
|
8fff2bce52 | ||
|
|
30b98fc744 | ||
|
|
7a190b7d35 | ||
|
|
e3149c40c7 | ||
|
|
91743518bd | ||
|
|
fd2e4079ab | ||
|
|
ec423cad80 | ||
|
|
8f8441b0e0 | ||
|
|
3c20d45dba | ||
|
|
75c4e20dc9 | ||
|
|
9d27595d31 | ||
|
|
b7d90a790b | ||
|
|
c49d850f74 | ||
|
|
4c11c5fc54 | ||
|
|
44befab6d3 | ||
|
|
02a2f4b85e | ||
|
|
43d54b5734 | ||
|
|
b7896be507 | ||
|
|
eeb40306da | ||
|
|
749b47ac5c | ||
|
|
42f59f2b19 | ||
|
|
2bf6e742f1 | ||
|
|
2a2049e678 | ||
|
|
146aa85e76 | ||
|
|
a26c7497b5 | ||
|
|
da67135f5e | ||
|
|
aebb6d1762 | ||
|
|
8f5cf6a0b4 | ||
|
|
875017db96 | ||
|
|
c0f34b684d | ||
|
|
613956bbaf | ||
|
|
041ba5c05b | ||
|
|
05c21cfd6d | ||
|
|
4898f99ae1 | ||
|
|
be920e8c44 | ||
|
|
0fa5ac536b | ||
|
|
cef359af29 | ||
|
|
2de72b73c1 | ||
|
|
a794331c1a | ||
|
|
e09be543bc | ||
|
|
88085c48d2 | ||
|
|
e32010771b | ||
|
|
03e7484e71 | ||
|
|
d9fd4ec286 | ||
|
|
8f14f0347c | ||
|
|
9b5bb8496f | ||
|
|
9264a78c95 | ||
|
|
326d571871 | ||
|
|
744e86b290 | ||
|
|
e46b68da7e | ||
|
|
811a962632 | ||
|
|
eb82e8762a | ||
|
|
d919da153f | ||
|
|
8389d5811a | ||
|
|
0aa0c44441 | ||
|
|
49ea7504a1 | ||
|
|
6602fb9359 | ||
|
|
731eb6915a | ||
|
|
3459179310 | ||
|
|
b1f951daf5 | ||
|
|
caebcec0af | ||
|
|
5f50f4b8d6 | ||
|
|
3039208ba0 | ||
|
|
397c956e87 | ||
|
|
cf47ceb74b | ||
|
|
da7aa2c115 | ||
|
|
c0046bc04c | ||
|
|
2f8f6a0652 | ||
|
|
9a6f788b98 | ||
|
|
c1a628260c | ||
|
|
7b0bd7077c | ||
|
|
7d47f0a86e | ||
|
|
44fcd74cbe | ||
|
|
5ac0e7ed87 | ||
|
|
743968f7fb | ||
|
|
e1a3ae4b4d | ||
|
|
acf13448ae | ||
|
|
a5daa8b56c | ||
|
|
267169c5c1 | ||
|
|
89272dd9a3 | ||
|
|
d059212238 | ||
|
|
0d8a3576a6 | ||
|
|
8910c2750a | ||
|
|
12393d6df4 | ||
|
|
6c0a2439ad | ||
|
|
d83712127b | ||
|
|
55325cd7ad | ||
|
|
82e508fca6 | ||
|
|
8ff32e9363 | ||
|
|
477308632b | ||
|
|
9ffd06f5e3 | ||
|
|
a89c87819a | ||
|
|
b09ae3bae3 | ||
|
|
6ea8c0d40e | ||
|
|
079501337c | ||
|
|
5bf0382227 | ||
|
|
0199c59014 | ||
|
|
44fb63fc59 | ||
|
|
13a28d2dbd | ||
|
|
f87a7da32e | ||
|
|
8fdf9938c2 | ||
|
|
ee4d480961 | ||
|
|
bd866549a0 | ||
|
|
7c39f1d821 | ||
|
|
e6a7bb4c98 | ||
|
|
14cf3189b8 | ||
|
|
66b060627a | ||
|
|
d9bcf14baa | ||
|
|
c571e6ebf7 | ||
|
|
fb06a1aec3 | ||
|
|
5a0d08641b | ||
|
|
8a8419385e | ||
|
|
0d5dc6e785 | ||
|
|
1d90333803 | ||
|
|
91e6e62688 | ||
|
|
619a8a9753 | ||
|
|
0fe38e94d3 | ||
|
|
722e8adbdf | ||
|
|
886d5ac08c | ||
|
|
89d5ba4c37 | ||
|
|
b8b9f82d91 | ||
|
|
b3fc9bb5c3 | ||
|
|
d2ebcd8fbe | ||
|
|
68c9623c35 | ||
|
|
496d1df404 | ||
|
|
ea1046fe13 | ||
|
|
6d58d6e7f3 | ||
|
|
e1420140d1 | ||
|
|
484c2e0c2f | ||
|
|
31f7d53829 | ||
|
|
e3debfa5df | ||
|
|
a1305fba81 | ||
|
|
ca95d6c7f4 | ||
|
|
5513fc9850 | ||
|
|
86de98e644 | ||
|
|
fd374cd705 | ||
|
|
20b4658bef | ||
|
|
0850ba250c | ||
|
|
b71d188fd8 | ||
|
|
579f6b9a96 | ||
|
|
d9403a73c6 | ||
|
|
747811fa94 | ||
|
|
489e480394 | ||
|
|
418bcb0295 | ||
|
|
88f01554e7 | ||
|
|
c85092a644 | ||
|
|
096478bcec | ||
|
|
b8de4a85e0 | ||
|
|
a5b7cedfaa | ||
|
|
0adb8d6766 | ||
|
|
6a6b8c4fad | ||
|
|
4f952816ea | ||
|
|
76835e2509 | ||
|
|
63af770c83 | ||
|
|
165c427e5f | ||
|
|
a0e30aa197 | ||
|
|
3a8203d26e | ||
|
|
ffe848883e | ||
|
|
078a13c45d | ||
|
|
8a69d5bc6b | ||
|
|
6783ff23f9 | ||
|
|
72a264a01e | ||
|
|
5a67be8096 | ||
|
|
9a929a6be4 | ||
|
|
e0ca010026 | ||
|
|
8bd5d7aadf | ||
|
|
9115c38cde | ||
|
|
0c7c1d54d9 | ||
|
|
d529d83eb8 | ||
|
|
a3127c7836 | ||
|
|
4d5fe1f425 | ||
|
|
c7a4de9786 | ||
|
|
d873718e88 | ||
|
|
706276839a | ||
|
|
d281ca5f87 | ||
|
|
6a9036bfef | ||
|
|
1b242f75c6 | ||
|
|
7ffd37289d | ||
|
|
cb859ae599 | ||
|
|
a17346c9c2 | ||
|
|
c17a39588d | ||
|
|
33cee9c0c2 | ||
|
|
e6d2920c27 | ||
|
|
d8195dbe2a | ||
|
|
4843f129c4 | ||
|
|
fcd1218dc4 | ||
|
|
eef0f971d7 | ||
|
|
ff09a8aba0 | ||
|
|
0c4b523d05 | ||
|
|
de7a435a01 | ||
|
|
124d399d1f | ||
|
|
e22cf71b15 | ||
|
|
670997ed36 | ||
|
|
1ccb6388e3 | ||
|
|
7d5be8d6aa | ||
|
|
133e4756b2 | ||
|
|
39ada734d5 | ||
|
|
19d88c5fba | ||
|
|
461b0936e2 | ||
|
|
e9ee5e87be | ||
|
|
5e66c5ef76 | ||
|
|
307dc3d726 | ||
|
|
e514a5f063 | ||
|
|
880b7974f4 | ||
|
|
47048f435f | ||
|
|
53ad492729 | ||
|
|
eb4da419ae | ||
|
|
c66dfc9e2e | ||
|
|
a31f05d498 | ||
|
|
6548e89c54 | ||
|
|
8a21b46ebd | ||
|
|
bc5fe1ae30 | ||
|
|
b57ea3f640 | ||
|
|
3b55d64468 | ||
|
|
4caf1f0b22 | ||
|
|
1eb9911645 | ||
|
|
38268c453c | ||
|
|
9686b80b09 | ||
|
|
f32dec16fb | ||
|
|
cb444b532f | ||
|
|
962062130a | ||
|
|
e429931139 | ||
|
|
e56d28f82a | ||
|
|
13a30d35c4 | ||
|
|
e3174d8777 | ||
|
|
829a8d5dca | ||
|
|
00978e2e64 | ||
|
|
a5fcf36e83 | ||
|
|
a92a9ee3a3 | ||
|
|
f39e34c699 | ||
|
|
b58f34d587 | ||
|
|
76d1d4544e | ||
|
|
5e56176e2d | ||
|
|
a2a4e7e454 | ||
|
|
b266288b0f | ||
|
|
1619e328da | ||
|
|
b852dad243 | ||
|
|
1552a5f106 | ||
|
|
0feaffb21b | ||
|
|
9b3a4e20de | ||
|
|
c83b972a68 | ||
|
|
2e96f93d81 | ||
|
|
1e8182d984 | ||
|
|
b20a67d4d0 | ||
|
|
60975b449d | ||
|
|
704fce4d80 | ||
|
|
4d1eb0f9fd | ||
|
|
ceafe277d3 |
@@ -5,4 +5,4 @@ alwaysApply: false
|
|||||||
|
|
||||||
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
||||||
|
|
||||||
Never write "Loading" - always show a spinner, and just a spinner.
|
Never write "Loading" - always show a loading placeholder (or a loading spinner, when appropriate).
|
||||||
|
|||||||
8
.cursor/rules/mobile-first-ui-ux.mdc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
description: anything related to UI/UX
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||||
|
|
||||||
|
Let's not show too many error messages, and more importantly: let's not make them red. Nothing is ever this tragic.
|
||||||
6
.gitignore
vendored
@@ -7,3 +7,9 @@ dist
|
|||||||
# Misc
|
# Misc
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Reference Projects
|
||||||
|
applesauce
|
||||||
|
primal-web-app
|
||||||
|
Amber
|
||||||
|
|
||||||
|
|||||||
77
Amber.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
## Boris ↔ Amber bunker: current findings
|
||||||
|
|
||||||
|
- **Environment**
|
||||||
|
- Client: Boris (web) using `applesauce` stack (`NostrConnectSigner`, `RelayPool`).
|
||||||
|
- Bunker: Amber (mobile).
|
||||||
|
- We restored a `nostr-connect` account from localStorage and re-wired the signer to the app `RelayPool` before use.
|
||||||
|
|
||||||
|
## What we changed client-side
|
||||||
|
|
||||||
|
- **Signer wiring**
|
||||||
|
- Bound `NostrConnectSigner.subscriptionMethod/publishMethod` to the app `RelayPool` at startup.
|
||||||
|
- After deserialization, recreated the signer with pool context and merged its relays with app `RELAYS` (includes local relays).
|
||||||
|
- Opened the signer subscription and performed a guarded `connect()` with default permissions including `nip04_encrypt/decrypt` and `nip44_encrypt/decrypt`.
|
||||||
|
|
||||||
|
- **Probes and timeouts**
|
||||||
|
- Initial probe tried `decrypt('invalid-ciphertext')` → timed out.
|
||||||
|
- Switched to roundtrip probes: `encrypt(self, ... )` then `decrypt(self, cipher)` for both nip-44 and nip-04.
|
||||||
|
- Increased probe timeout from 3s → 10s; increased bookmark decrypt timeout from 15s → 30s.
|
||||||
|
|
||||||
|
- **Logging**
|
||||||
|
- Added logs for publish/subscribe and parsed the NIP-46 request content length.
|
||||||
|
- Confirmed NIP‑46 request events are kind `24133` with a single `p` tag (expected). The method is inside the encrypted content, so it prints as `method: undefined` (expected).
|
||||||
|
|
||||||
|
## Evidence from logs (client)
|
||||||
|
|
||||||
|
```
|
||||||
|
[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription
|
||||||
|
[bunker] 🔗 Signer relays merged with app RELAYS: (19) [...]
|
||||||
|
[bunker] subscribe via signer: { relays: [...], filters: [...] }
|
||||||
|
[bunker] ✅ Signer subscription opened
|
||||||
|
[bunker] publish via signer: { relays: [...], kind: 24133, tags: [['p', <remote>]], contentLength: 260|304|54704 }
|
||||||
|
[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
|
||||||
|
[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)… → probe timeout after 10000ms
|
||||||
|
bookmarkProcessing.ts: ❌ nip44.decrypt failed: Decrypt timeout after 30000ms
|
||||||
|
bookmarkProcessing.ts: ❌ nip04.decrypt failed: Decrypt timeout after 30000ms
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Final signer status shows `listening: true`, `isConnected: true`, and requests are published to 19 relays (includes Amber’s).
|
||||||
|
|
||||||
|
## Evidence from Amber (device)
|
||||||
|
|
||||||
|
- Activity screen shows multiple entries for: “Encrypt data using nip 4” and “Encrypt data using nip 44” with green checkmarks.
|
||||||
|
- No entries for “Decrypt data using nip 4” or “Decrypt data using nip 44”.
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
- Transport and publish paths are working: Boris is publishing NIP‑46 requests (kind 24133) and Amber receives them (ENCRYPT activity visible).
|
||||||
|
- The persistent failure is specific to DECRYPT handling: Amber does not show any DECRYPT activity and Boris receives no decrypt responses within 10–30s windows.
|
||||||
|
- Client-side wiring is likely correct (subscription open, permissions requested, relays merged). The remaining issue appears provider-side in Amber’s NIP‑46 decrypt handling or permission gating.
|
||||||
|
|
||||||
|
## Repro steps (quick)
|
||||||
|
|
||||||
|
1) Revoke Boris in Amber.
|
||||||
|
2) Reconnect with a fresh bunker URI; approve signing and both encrypt/decrypt scopes for nip‑04 and nip‑44.
|
||||||
|
3) Keep Amber unlocked and foregrounded.
|
||||||
|
4) Reload Boris; observe:
|
||||||
|
- Logs showing `publish via signer` for kind 24133.
|
||||||
|
- In Amber, activity should include “Decrypt data using nip 4/44”.
|
||||||
|
|
||||||
|
If DECRYPT entries still don’t appear:
|
||||||
|
|
||||||
|
- This points to Amber’s NIP‑46 provider not executing/authorizing `nip04_decrypt`/`nip44_decrypt` methods, or not publishing responses.
|
||||||
|
|
||||||
|
## Suggestions for Amber-side debugging
|
||||||
|
|
||||||
|
- Verify permission gating allows `nip04_decrypt` and `nip44_decrypt` (not just encrypt).
|
||||||
|
- Confirm the provider recognizes NIP‑46 methods `nip04_decrypt` and `nip44_decrypt` in the decrypted payload and routes them to decrypt routines.
|
||||||
|
- Ensure the response event is published back to the same relays and correctly addressed to the client (`p` tag set and content encrypted back to client pubkey).
|
||||||
|
- Add activity logging for “Decrypt …” attempts and failures to surface denial/exception states.
|
||||||
|
|
||||||
|
## Current conclusion
|
||||||
|
|
||||||
|
- Client is configured and publishing requests correctly; encryption proves end‑to‑end path is alive.
|
||||||
|
- The missing DECRYPT activity in Amber is the blocker. Fixing Amber’s NIP‑46 decrypt handling should resolve bookmark decryption in Boris without further client changes.
|
||||||
|
|
||||||
|
|
||||||
1329
CHANGELOG.md
89
FEATURES.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Boris Features
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- **Purpose**: A calm, fast, Nostr‑first reader that turns your bookmarks into a focused reading app.
|
||||||
|
- **Layout**: Three‑pane interface — bookmarks (left), reader (center), highlights (right). Collapsible sidebars.
|
||||||
|
- **Content**: Renders both Nostr long‑form posts (kind:30023) and regular web URLs.
|
||||||
|
- **Social layer**: Highlights shown by level — mine, friends, nostrverse — each with its own color and visibility toggle.
|
||||||
|
|
||||||
|
## Reader Experience
|
||||||
|
|
||||||
|
- **Distraction‑free view**: Clean typography, optional hero image, summary, and published date.
|
||||||
|
- **Reading time**: Displays estimated reading time for text or duration for supported videos.
|
||||||
|
- **Progress**: Reading progress indicator with completion state.
|
||||||
|
- **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content).
|
||||||
|
- **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes.
|
||||||
|
|
||||||
|
## Highlights (NIP‑84)
|
||||||
|
|
||||||
|
- **Levels**: Mine, friends, nostrverse; toggle per level; colors configurable in settings.
|
||||||
|
- **Interactions**: Click a highlight to scroll to its position; count indicator in the header.
|
||||||
|
- **Creation**: Select text and use the floating highlighter button to publish a highlight.
|
||||||
|
- **Attribution**: Automatically tags article authors for Nostr posts so they can see highlights.
|
||||||
|
|
||||||
|
## Zap Splits (NIP‑57)
|
||||||
|
|
||||||
|
- **Configurable splits**: Weight‑based sliders for highlighter, author(s), and Boris (defaults 50/50/2.1).
|
||||||
|
- **Presets**: Quick buttons for common split configurations.
|
||||||
|
- **Respect source**: If the source article has zap tags, author weights are proportionally preserved.
|
||||||
|
|
||||||
|
## Bookmarks & Reading List (NIP‑51 + Web)
|
||||||
|
|
||||||
|
- **Ingestion**: Collects list bookmarks and items from kinds 10003/30003/30001.
|
||||||
|
- **Web bookmarks**: Supports NIP‑B0 (kind:39701) for standalone URL bookmarks.
|
||||||
|
- **Add Bookmark**: Modal with auto title/description extraction and keywords/tags suggestion (adds “boris” when helpful).
|
||||||
|
- **Views**: Reading list in compact, cards, or large preview modes; quick toggles to switch.
|
||||||
|
- **Archive**: “Read” items appear in your archive; can mark articles/web pages as read.
|
||||||
|
|
||||||
|
## Explore & Profiles
|
||||||
|
|
||||||
|
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
|
||||||
|
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
|
||||||
|
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- **Supporter page**: Displays avatars of users who zapped Boris (kind:9735 receipts).
|
||||||
|
- **Thresholds**: Shows supporters who sent ≥ 2100 sats; whales (≥ 69420 sats) get special styling with a bolt badge.
|
||||||
|
- **Profile integration**: Fetches and displays profile pictures and names for all supporters.
|
||||||
|
- **Stats**: Total supporter count and zap count displayed at the bottom.
|
||||||
|
|
||||||
|
## Video
|
||||||
|
|
||||||
|
- **Embedded player**: Plays supported videos (e.g., YouTube) inline with duration display.
|
||||||
|
- **Metadata**: Fetches YouTube title/description/transcript when available.
|
||||||
|
- **Deep links**: Open in native apps via platform‑specific URL schemes.
|
||||||
|
|
||||||
|
## Settings (NIP‑78 Application Data)
|
||||||
|
|
||||||
|
- **Theme**: System/light/dark with color variants (dark: black/midnight/charcoal; light: paper‑white/sepia/ivory).
|
||||||
|
- **Reading**: Font family (preloaded), font size, highlight style (marker/underline), per‑level colors.
|
||||||
|
- **Layout & startup**: Default view modes, auto‑collapse preferences, show/hide highlights.
|
||||||
|
- **Zap Splits**: Weight sliders and presets for NIP‑57 splits.
|
||||||
|
- **Offline/Flight Mode**: Local image cache with size limit and clear controls; “use local relay as cache”; rebroadcast preferences.
|
||||||
|
- **Relays**: Relay overview and status in Settings; educational links.
|
||||||
|
- **PWA**: Install prompt when available.
|
||||||
|
|
||||||
|
## Offline, PWA, and Sync
|
||||||
|
|
||||||
|
- **PWA**: Installable; service worker registered; periodic update checks with in‑app toast.
|
||||||
|
- **Flight Mode**: Operates with local relays only; highlights created offline are stored locally and synced later.
|
||||||
|
- **Relay indicator**: Floating status indicator shows Connecting/Offline/Flight Mode and connected counts.
|
||||||
|
|
||||||
|
## Relays & Accounts
|
||||||
|
|
||||||
|
- **Applesauce stack**: Accounts, event store, relay pool, and blueprints power Nostr interactions.
|
||||||
|
- **Multi‑relay**: Grouped connections with keep‑alive subscription; local+remote partitioning for fast queries.
|
||||||
|
- **Persistence**: Accounts restored from local storage; settings saved to NIP‑78 and watched for updates.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
- **Identity**: No email or new account; uses your existing Nostr signer/identity.
|
||||||
|
- **Data**: Bookmarks and highlights live on Nostr; reading/rendering happens locally in your browser.
|
||||||
|
|
||||||
|
## Conveniences
|
||||||
|
|
||||||
|
- **Share/copy**: One‑click copy or share for articles and videos.
|
||||||
|
- **Open on Nostr**: Deep links to portals and `nostr:` schemes for long‑form articles.
|
||||||
|
- **Mobile UX**: Floating open buttons for Bookmarks/Highlights, focus trapping, and backdrop controls.
|
||||||
|
|
||||||
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Gigi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ Boris turns your Nostr bookmarks into a calm, fast, and focused reading experien
|
|||||||
|
|
||||||
## Live
|
## Live
|
||||||
|
|
||||||
- App: [https://xn--bris-v0b.com/](https://xn--bris-v0b.com/)
|
- App: [https://read.withboris.com/](https://read.withboris.com/)
|
||||||
|
|
||||||
## The Vision
|
## The Vision
|
||||||
|
|
||||||
|
|||||||
188
TAILWIND_MIGRATION.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Tailwind CSS Migration Status
|
||||||
|
|
||||||
|
## ✅ Completed (Core Infrastructure)
|
||||||
|
|
||||||
|
### Phase 1: Setup & Foundation
|
||||||
|
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
|
||||||
|
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
|
||||||
|
- [x] Create `src/styles/tailwind.css` with base/components/utilities
|
||||||
|
- [x] Import Tailwind before existing CSS in `main.tsx`
|
||||||
|
- [x] Enable Tailwind preflight (CSS reset)
|
||||||
|
|
||||||
|
### Phase 2: Base Styles Reconciliation
|
||||||
|
- [x] Add CSS variables for user-settable theme colors
|
||||||
|
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
|
||||||
|
- `--reading-font`, `--reading-font-size`
|
||||||
|
- [x] Simplify `global.css` to work with Tailwind preflight
|
||||||
|
- [x] Remove redundant base styles handled by Tailwind
|
||||||
|
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
|
||||||
|
|
||||||
|
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
|
||||||
|
- [x] Switch from pane-scrolling to document-scrolling
|
||||||
|
- [x] Make sidebars sticky on desktop (`position: sticky`)
|
||||||
|
- [x] Update `app.css` to remove fixed container heights
|
||||||
|
- [x] Update `ThreePaneLayout.tsx` to use window scroll
|
||||||
|
- [x] Fix reading position tracking to work with document scroll
|
||||||
|
- [x] Maintain mobile overlay behavior
|
||||||
|
|
||||||
|
### Phase 4: Component Migrations
|
||||||
|
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
|
||||||
|
- Removed 80+ lines of CSS
|
||||||
|
- Added shimmer animation to Tailwind config
|
||||||
|
- Z-index layering maintained (1102)
|
||||||
|
|
||||||
|
- [x] **Mobile UI Elements**: Tailwind utilities
|
||||||
|
- Mobile hamburger button
|
||||||
|
- Mobile highlights button
|
||||||
|
- Mobile backdrop
|
||||||
|
- Removed 60+ lines of CSS
|
||||||
|
|
||||||
|
- [x] **App Container**: Tailwind utilities
|
||||||
|
- Responsive padding (p-0 md:p-4)
|
||||||
|
- Min-height viewport support
|
||||||
|
|
||||||
|
## 📊 Impact & Metrics
|
||||||
|
|
||||||
|
### Lines of CSS Removed
|
||||||
|
- `global.css`: ~50 lines removed
|
||||||
|
- `reader.css`: ~80 lines removed (progress indicator)
|
||||||
|
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
|
||||||
|
- `sidebar.css`: ~30 lines removed (mobile hamburger)
|
||||||
|
- **Total**: ~190 lines removed
|
||||||
|
|
||||||
|
### Key Achievements
|
||||||
|
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
|
||||||
|
2. **Tailwind Integration**: Fully functional with preflight enabled
|
||||||
|
3. **No Breaking Changes**: All existing functionality preserved
|
||||||
|
4. **Type Safety**: TypeScript checks passing
|
||||||
|
5. **Lint Clean**: ESLint checks passing
|
||||||
|
6. **Responsive**: Mobile/tablet/desktop layouts working
|
||||||
|
|
||||||
|
## 🔄 Remaining Work (Incremental)
|
||||||
|
|
||||||
|
The following migrations are **optional enhancements** that can be done as components are touched:
|
||||||
|
|
||||||
|
### High-Value Components
|
||||||
|
- [ ] **ContentPanel** - Large component, high impact
|
||||||
|
- Reader header, meta info, loading states
|
||||||
|
- Mark as read button
|
||||||
|
- Article/video menus
|
||||||
|
|
||||||
|
- [ ] **BookmarkList & BookmarkItem** - Core UI
|
||||||
|
- Card layouts (compact/cards/large views)
|
||||||
|
- Bookmark metadata display
|
||||||
|
- Interactive states
|
||||||
|
|
||||||
|
- [ ] **HighlightsPanel** - Feature-rich
|
||||||
|
- Header with toggles
|
||||||
|
- Highlight items
|
||||||
|
- Level-based styling
|
||||||
|
|
||||||
|
- [ ] **Settings Components** - Forms & controls
|
||||||
|
- Color pickers
|
||||||
|
- Font selectors
|
||||||
|
- Toggle switches
|
||||||
|
- Sliders
|
||||||
|
|
||||||
|
### CSS Files to Prune
|
||||||
|
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
|
||||||
|
- `src/styles/components/cards.css` - Bookmark card styles
|
||||||
|
- `src/styles/components/modals.css` - Modal dialogs
|
||||||
|
- `src/styles/layout/highlights.css` - Highlight panel layout
|
||||||
|
|
||||||
|
## 🎯 Migration Strategy
|
||||||
|
|
||||||
|
### For New Components
|
||||||
|
Use Tailwind utilities from the start. Reference:
|
||||||
|
```tsx
|
||||||
|
// Good: Tailwind utilities
|
||||||
|
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
|
||||||
|
|
||||||
|
// Avoid: New CSS classes
|
||||||
|
<div className="custom-component">
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Existing Components
|
||||||
|
Migrate incrementally when touching files:
|
||||||
|
1. Replace layout utilities (flex, grid, spacing, sizing)
|
||||||
|
2. Replace color/background utilities
|
||||||
|
3. Replace typography utilities
|
||||||
|
4. Replace responsive variants
|
||||||
|
5. Remove old CSS rules
|
||||||
|
6. Keep file under 210 lines
|
||||||
|
|
||||||
|
### CSS Variable Usage
|
||||||
|
Dynamic values should still use CSS variables or inline styles:
|
||||||
|
```tsx
|
||||||
|
// User-settable colors
|
||||||
|
style={{ backgroundColor: settings.highlightColorMine }}
|
||||||
|
|
||||||
|
// Or reference CSS variable
|
||||||
|
className="bg-[var(--highlight-color-mine)]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Technical Notes
|
||||||
|
|
||||||
|
### Z-Index Layering
|
||||||
|
- Mobile sidepanes: `z-[1001]`
|
||||||
|
- Mobile backdrop: `z-[999]`
|
||||||
|
- Progress indicator: `z-[1102]`
|
||||||
|
- Mobile buttons: `z-[900]`
|
||||||
|
- Relay status: `z-[999]`
|
||||||
|
- Modals: `z-[10000]`
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
- Mobile: `< 768px`
|
||||||
|
- Tablet: `768px - 1024px`
|
||||||
|
- Desktop: `> 1024px`
|
||||||
|
|
||||||
|
Use Tailwind: `md:` (768px), `lg:` (1024px)
|
||||||
|
|
||||||
|
### Safe Area Insets
|
||||||
|
Mobile notch support:
|
||||||
|
```tsx
|
||||||
|
style={{
|
||||||
|
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||||
|
left: 'calc(1rem + env(safe-area-inset-left))'
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Animations
|
||||||
|
Add to `tailwind.config.js`:
|
||||||
|
```js
|
||||||
|
keyframes: {
|
||||||
|
shimmer: {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(100%)' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Success Criteria Met
|
||||||
|
|
||||||
|
- [x] Tailwind CSS fully integrated and functional
|
||||||
|
- [x] Document scrolling working correctly
|
||||||
|
- [x] Reading position tracking accurate
|
||||||
|
- [x] Progress indicator always visible
|
||||||
|
- [x] No TypeScript errors
|
||||||
|
- [x] No linting errors
|
||||||
|
- [x] Mobile responsiveness maintained
|
||||||
|
- [x] Theme colors (user settings) working
|
||||||
|
- [x] All existing features functional
|
||||||
|
|
||||||
|
## 🚀 Next Steps
|
||||||
|
|
||||||
|
1. **Ship It**: Current state is production-ready
|
||||||
|
2. **Incremental Migration**: Convert components as you touch them
|
||||||
|
3. **Monitor**: Watch for any CSS conflicts
|
||||||
|
4. **Cleanup**: Eventually remove unused CSS files
|
||||||
|
5. **Document**: Update component docs with Tailwind patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: ✅ **CORE MIGRATION COMPLETE**
|
||||||
|
**Date**: 2025-01-14
|
||||||
|
**Commits**: 8 conventional commits
|
||||||
|
**Lines Removed**: ~190 lines of CSS
|
||||||
|
**Breaking Changes**: None
|
||||||
|
|
||||||
304
api/article-og.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { AddressPointer } from 'nostr-tools/nip19'
|
||||||
|
import { NostrEvent, Filter } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
// Relay configuration (from src/config/relays.ts)
|
||||||
|
const RELAYS = [
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://nos.lol',
|
||||||
|
'wss://relay.nostr.band',
|
||||||
|
'wss://relay.dergigi.com',
|
||||||
|
'wss://wot.dergigi.com',
|
||||||
|
'wss://relay.snort.social',
|
||||||
|
'wss://relay.current.fyi',
|
||||||
|
'wss://nostr-pub.wellorder.net',
|
||||||
|
'wss://purplepag.es',
|
||||||
|
'wss://relay.primal.net'
|
||||||
|
]
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
html: string
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const memoryCache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||||
|
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArticleMetadata {
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
image: string
|
||||||
|
author: string
|
||||||
|
published?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEventsFromRelays(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
relayUrls: string[],
|
||||||
|
filter: Filter,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<NostrEvent[]> {
|
||||||
|
const events: NostrEvent[] = []
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => resolve(), timeoutMs)
|
||||||
|
|
||||||
|
// `request` emits NostrEvent objects directly
|
||||||
|
relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
events.push(event)
|
||||||
|
},
|
||||||
|
error: () => resolve(),
|
||||||
|
complete: () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by created_at and return most recent first
|
||||||
|
return events.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
|
||||||
|
const relayPool = new RelayPool()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode naddr
|
||||||
|
const decoded = nip19.decode(naddr)
|
||||||
|
if (decoded.type !== 'naddr') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = decoded.data as AddressPointer
|
||||||
|
|
||||||
|
// Determine relay URLs
|
||||||
|
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
||||||
|
|
||||||
|
// Fetch article and profile in parallel
|
||||||
|
const [articleEvents, profileEvents] = await Promise.all([
|
||||||
|
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||||
|
kinds: [pointer.kind],
|
||||||
|
authors: [pointer.pubkey],
|
||||||
|
'#d': [pointer.identifier || '']
|
||||||
|
}, 5000),
|
||||||
|
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||||
|
kinds: [0],
|
||||||
|
authors: [pointer.pubkey]
|
||||||
|
}, 3000)
|
||||||
|
])
|
||||||
|
|
||||||
|
if (articleEvents.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const article = articleEvents[0]
|
||||||
|
|
||||||
|
// Extract article metadata
|
||||||
|
const title = getArticleTitle(article) || 'Untitled Article'
|
||||||
|
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
||||||
|
const image = getArticleImage(article) || '/boris-social-1200.png'
|
||||||
|
|
||||||
|
// Extract author name from profile
|
||||||
|
let authorName = pointer.pubkey.slice(0, 8) + '...'
|
||||||
|
if (profileEvents.length > 0) {
|
||||||
|
try {
|
||||||
|
const profileData = JSON.parse(profileEvents[0].content)
|
||||||
|
authorName = profileData.display_name || profileData.name || authorName
|
||||||
|
} catch {
|
||||||
|
// Use fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
author: authorName,
|
||||||
|
published: article.created_at
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch article metadata:', err)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
// No explicit close needed; pool manages connections internally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||||
|
const baseUrl = 'https://read.withboris.com'
|
||||||
|
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||||
|
|
||||||
|
const title = meta?.title || 'Boris – Nostr Bookmarks'
|
||||||
|
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||||
|
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||||
|
const author = meta?.author || 'Boris'
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(description)}" />
|
||||||
|
<link rel="canonical" href="${articleUrl}" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Social Media -->
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content="${articleUrl}" />
|
||||||
|
<meta property="og:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta property="og:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta property="og:image" content="${escapeHtml(image)}" />
|
||||||
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
||||||
|
<meta property="article:author" content="${escapeHtml(author)}" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:url" content="${articleUrl}" />
|
||||||
|
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
||||||
|
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
||||||
|
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>
|
||||||
|
<p>Redirecting to <a href="/">Boris</a>...</p>
|
||||||
|
</noscript>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCrawler(userAgent: string | undefined): boolean {
|
||||||
|
if (!userAgent) return false
|
||||||
|
const crawlers = [
|
||||||
|
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
|
||||||
|
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
|
||||||
|
]
|
||||||
|
const ua = userAgent.toLowerCase()
|
||||||
|
return crawlers.some(crawler => ua.includes(crawler))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||||
|
|
||||||
|
if (!naddr) {
|
||||||
|
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = req.headers['user-agent'] as string | undefined
|
||||||
|
const isCrawlerRequest = isCrawler(userAgent)
|
||||||
|
|
||||||
|
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] request', JSON.stringify({
|
||||||
|
naddr,
|
||||||
|
ua: userAgent || null,
|
||||||
|
isCrawlerRequest,
|
||||||
|
path: req.url || null
|
||||||
|
}))
|
||||||
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a regular browser (not a bot), serve HTML that loads SPA
|
||||||
|
// Use history.replaceState to set the URL before the SPA boots
|
||||||
|
if (!isCrawlerRequest) {
|
||||||
|
const articlePath = `/a/${naddr}`
|
||||||
|
// Serve a minimal HTML that sets up the URL and loads the SPA
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Boris - Loading Article...</title>
|
||||||
|
<script>
|
||||||
|
// Set the URL to the article path before SPA loads
|
||||||
|
if (window.location.pathname !== '${articlePath}') {
|
||||||
|
history.replaceState(null, '', '${articlePath}');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</script>` : ''}
|
||||||
|
<script>
|
||||||
|
// Redirect to index.html which will load the SPA
|
||||||
|
// The history state is already set, so SPA will see the correct URL
|
||||||
|
window.location.replace('/');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache for bots/crawlers
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(naddr)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
setCacheHeaders(res)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(cached.html)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch metadata
|
||||||
|
const meta = await fetchArticleMetadata(naddr)
|
||||||
|
|
||||||
|
// Generate HTML
|
||||||
|
const html = generateHtml(naddr, meta)
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
|
||||||
|
|
||||||
|
// Send response
|
||||||
|
setCacheHeaders(res)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error generating article OG HTML:', err)
|
||||||
|
|
||||||
|
// Fallback to basic HTML with SPA boot
|
||||||
|
const html = generateHtml(naddr, null)
|
||||||
|
setCacheHeaders(res, 3600)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
201
api/video-meta.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { getSubtitles } from '@treeee/youtube-caption-extractor'
|
||||||
|
|
||||||
|
type Caption = { start: number; dur: number; text: string }
|
||||||
|
|
||||||
|
type Subtitle = { start: string | number; dur: string | number; text: string }
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
body: unknown
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type VimeoOEmbedResponse = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
author_name: string
|
||||||
|
author_url: string
|
||||||
|
provider_name: string
|
||||||
|
provider_url: string
|
||||||
|
type: string
|
||||||
|
version: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
html: string
|
||||||
|
thumbnail_url: string
|
||||||
|
thumbnail_width: number
|
||||||
|
thumbnail_height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache for 7 days
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const memoryCache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
function buildKey(videoId: string, lang: string, preferAuto?: string | string[], source?: string) {
|
||||||
|
return `${source || 'video'}|${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(res: VercelResponse, data: unknown) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||||
|
return res.status(200).json(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bad(res: VercelResponse, code: number, message: string) {
|
||||||
|
return res.status(code).json({ error: message })
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractVideoId(url: string): { id: string; source: 'youtube' | 'vimeo' } | null {
|
||||||
|
// YouTube patterns
|
||||||
|
const youtubePatterns = [
|
||||||
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||||
|
/youtube\.com\/v\/([^&\n?#]+)/
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of youtubePatterns) {
|
||||||
|
const match = url.match(pattern)
|
||||||
|
if (match) {
|
||||||
|
return { id: match[1], source: 'youtube' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vimeo patterns
|
||||||
|
const vimeoPatterns = [
|
||||||
|
/vimeo\.com\/(\d+)/,
|
||||||
|
/player\.vimeo\.com\/video\/(\d+)/
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pattern of vimeoPatterns) {
|
||||||
|
const match = url.match(pattern)
|
||||||
|
if (match) {
|
||||||
|
return { id: match[1], source: 'vimeo' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
|
||||||
|
for (const lang of preferredLangs) {
|
||||||
|
try {
|
||||||
|
const caps = await getSubtitles({ videoID, lang })
|
||||||
|
if (Array.isArray(caps) && caps.length > 0) {
|
||||||
|
// Convert the returned subtitles to our Caption format
|
||||||
|
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
|
||||||
|
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
|
||||||
|
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
|
||||||
|
text: cap.text
|
||||||
|
}))
|
||||||
|
return { caps: convertedCaps, lang, isAuto: !manualFirst }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||||
|
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||||
|
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||||
|
|
||||||
|
const response = await fetch(oembedUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: VimeoOEmbedResponse = await response.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.title || '',
|
||||||
|
description: data.description || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const url = (req.query.url as string | undefined)?.trim()
|
||||||
|
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||||
|
|
||||||
|
if (!url && !videoId) {
|
||||||
|
return bad(res, 400, 'Missing url or videoId parameter')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract video info from URL or use provided videoId
|
||||||
|
let videoInfo: { id: string; source: 'youtube' | 'vimeo' }
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
const extracted = extractVideoId(url)
|
||||||
|
if (!extracted) {
|
||||||
|
return bad(res, 400, 'Unsupported video URL. Only YouTube and Vimeo are supported.')
|
||||||
|
}
|
||||||
|
videoInfo = extracted
|
||||||
|
} else {
|
||||||
|
// If only videoId is provided, assume YouTube for backward compatibility
|
||||||
|
videoInfo = { id: videoId!, source: 'youtube' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||||
|
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||||
|
const preferAuto = req.query.preferAuto === 'true'
|
||||||
|
|
||||||
|
const cacheKey = buildKey(videoInfo.id, lang, preferAuto ? 'auto' : undefined, videoInfo.source)
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(cacheKey)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
return ok(res, cached.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (videoInfo.source === 'youtube') {
|
||||||
|
// YouTube handling
|
||||||
|
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
|
||||||
|
const title = ''
|
||||||
|
const description = ''
|
||||||
|
|
||||||
|
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||||
|
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||||
|
|
||||||
|
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||||
|
// Manual first
|
||||||
|
selected = await pickCaptions(videoInfo.id, langs, true)
|
||||||
|
if (!selected) {
|
||||||
|
// Try auto
|
||||||
|
selected = await pickCaptions(videoInfo.id, langs, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const captions = selected?.caps || []
|
||||||
|
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||||
|
const response = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
captions,
|
||||||
|
transcript,
|
||||||
|
lang: selected?.lang || lang,
|
||||||
|
isAuto: selected?.isAuto || false,
|
||||||
|
source: 'youtube'
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||||
|
return ok(res, response)
|
||||||
|
} else if (videoInfo.source === 'vimeo') {
|
||||||
|
// Vimeo handling
|
||||||
|
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||||
|
transcript: '', // No transcript available
|
||||||
|
lang: 'en', // Default language
|
||||||
|
isAuto: false, // Not applicable for Vimeo
|
||||||
|
source: 'vimeo'
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||||
|
return ok(res, response)
|
||||||
|
} else {
|
||||||
|
return bad(res, 400, 'Unsupported video source')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return bad(res, 500, `Failed to fetch ${videoInfo.source} metadata`)
|
||||||
|
}
|
||||||
|
}
|
||||||
93
api/vimeo-meta.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
body: unknown
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type VimeoOEmbedResponse = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
author_name: string
|
||||||
|
author_url: string
|
||||||
|
provider_name: string
|
||||||
|
provider_url: string
|
||||||
|
type: string
|
||||||
|
version: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
html: string
|
||||||
|
thumbnail_url: string
|
||||||
|
thumbnail_width: number
|
||||||
|
thumbnail_height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache for 7 days
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const memoryCache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
function buildKey(videoId: string) {
|
||||||
|
return `vimeo|${videoId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(res: VercelResponse, data: unknown) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||||
|
return res.status(200).json(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bad(res: VercelResponse, code: number, message: string) {
|
||||||
|
return res.status(code).json({ error: message })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||||
|
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||||
|
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||||
|
|
||||||
|
const response = await fetch(oembedUrl)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: VimeoOEmbedResponse = await response.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: data.title || '',
|
||||||
|
description: data.description || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||||
|
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||||
|
|
||||||
|
// Validate that videoId is a number
|
||||||
|
if (!/^\d+$/.test(videoId)) {
|
||||||
|
return bad(res, 400, 'Invalid Vimeo video ID - must be numeric')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = buildKey(videoId)
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(cacheKey)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
return ok(res, cached.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { title, description } = await getVimeoMetadata(videoId)
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||||
|
transcript: '', // No transcript available
|
||||||
|
lang: 'en', // Default language
|
||||||
|
isAuto: false, // Not applicable for Vimeo
|
||||||
|
source: 'vimeo'
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||||
|
return ok(res, response)
|
||||||
|
} catch (e) {
|
||||||
|
return bad(res, 500, 'Failed to fetch Vimeo metadata')
|
||||||
|
}
|
||||||
|
}
|
||||||
101
api/youtube-meta.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
import { getSubtitles } from '@treeee/youtube-caption-extractor'
|
||||||
|
|
||||||
|
type Caption = { start: number; dur: number; text: string }
|
||||||
|
|
||||||
|
type Subtitle = { start: string | number; dur: string | number; text: string }
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
body: unknown
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache for 7 days
|
||||||
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const memoryCache = new Map<string, CacheEntry>()
|
||||||
|
|
||||||
|
function buildKey(videoId: string, lang: string, preferAuto?: string | string[]) {
|
||||||
|
return `${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(res: VercelResponse, data: unknown) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||||
|
return res.status(200).json(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bad(res: VercelResponse, code: number, message: string) {
|
||||||
|
return res.status(code).json({ error: message })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
|
||||||
|
for (const lang of preferredLangs) {
|
||||||
|
try {
|
||||||
|
const caps = await getSubtitles({ videoID, lang })
|
||||||
|
if (Array.isArray(caps) && caps.length > 0) {
|
||||||
|
// Convert the returned subtitles to our Caption format
|
||||||
|
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
|
||||||
|
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
|
||||||
|
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
|
||||||
|
text: cap.text
|
||||||
|
}))
|
||||||
|
return { caps: convertedCaps, lang, isAuto: !manualFirst }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||||
|
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||||
|
|
||||||
|
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||||
|
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||||
|
const preferAuto = req.query.preferAuto === 'true'
|
||||||
|
|
||||||
|
const cacheKey = buildKey(videoId, lang, preferAuto ? 'auto' : undefined)
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = memoryCache.get(cacheKey)
|
||||||
|
if (cached && cached.expires > now) {
|
||||||
|
return ok(res, cached.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Since getVideoDetails doesn't exist, we'll use a simple approach
|
||||||
|
// In a real implementation, you might want to use YouTube's API or other methods
|
||||||
|
const title = '' // Will be populated from captions or other sources
|
||||||
|
const description = ''
|
||||||
|
|
||||||
|
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||||
|
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||||
|
|
||||||
|
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||||
|
// Manual first
|
||||||
|
selected = await pickCaptions(videoId, langs, true)
|
||||||
|
if (!selected) {
|
||||||
|
// Try auto
|
||||||
|
selected = await pickCaptions(videoId, langs, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const captions = selected?.caps || []
|
||||||
|
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||||
|
const response = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
captions,
|
||||||
|
transcript,
|
||||||
|
lang: selected?.lang || lang,
|
||||||
|
isAuto: selected?.isAuto || false,
|
||||||
|
source: 'youtube'
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||||
|
return ok(res, response)
|
||||||
|
} catch (e) {
|
||||||
|
return bad(res, 500, 'Failed to fetch YouTube metadata')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
22
index.html
@@ -2,24 +2,36 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Boris - Nostr Bookmarks</title>
|
<title>Boris - Nostr Bookmarks</title>
|
||||||
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
<link rel="canonical" href="https://xn--bris-v0b.com/" />
|
<link rel="canonical" href="https://read.withboris.com/" />
|
||||||
|
|
||||||
<!-- Open Graph / Social Media -->
|
<!-- Open Graph / Social Media -->
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://xn--bris-v0b.com/" />
|
<meta property="og:url" content="https://read.withboris.com/" />
|
||||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
|
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
<meta property="og:site_name" content="Boris" />
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:url" content="https://xn--bris-v0b.com/" />
|
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||||
|
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||||
|
|
||||||
|
<!-- Default to system theme until settings load from Nostr -->
|
||||||
|
<script>
|
||||||
|
document.documentElement.className = 'theme-system';
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
6958
package-lock.json
generated
26
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.3.2",
|
"version": "0.6.24",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -12,8 +12,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||||
|
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||||
|
"@vercel/node": "^5.3.26",
|
||||||
"applesauce-accounts": "^4.0.0",
|
"applesauce-accounts": "^4.0.0",
|
||||||
"applesauce-content": "^4.0.0",
|
"applesauce-content": "^4.0.0",
|
||||||
"applesauce-core": "^4.0.0",
|
"applesauce-core": "^4.0.0",
|
||||||
@@ -22,25 +25,38 @@
|
|||||||
"applesauce-react": "^4.0.0",
|
"applesauce-react": "^4.0.0",
|
||||||
"applesauce-relay": "^4.0.0",
|
"applesauce-relay": "^4.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"fast-average-color": "^9.5.0",
|
||||||
"nostr-tools": "^2.4.0",
|
"nostr-tools": "^2.4.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-loading-skeleton": "^3.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-player": "^2.16.0",
|
||||||
"react-router-dom": "^7.9.3",
|
"react-router-dom": "^7.9.3",
|
||||||
"reading-time-estimator": "^1.14.0",
|
"reading-time-estimator": "^1.14.0",
|
||||||
"remark-gfm": "^4.0.1"
|
"rehype-prism-plus": "^2.0.1",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"use-pull-to-refresh": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.14",
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.0.8"
|
"vite": "^5.0.8",
|
||||||
|
"vite-plugin-pwa": "^1.0.3",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"root": true,
|
"root": true,
|
||||||
@@ -59,7 +75,8 @@
|
|||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@typescript-eslint",
|
"@typescript-eslint",
|
||||||
"react-refresh"
|
"react-refresh",
|
||||||
|
"react-hooks"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"react-refresh/only-export-components": [
|
"react-refresh/only-export-components": [
|
||||||
@@ -68,6 +85,7 @@
|
|||||||
"allowConstantExport": true
|
"allowConstantExport": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
"@typescript-eslint/no-unused-vars": [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
|
|||||||
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
6
public/_routes.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"include": ["/*"],
|
||||||
|
"exclude": ["/assets/*", "/robots.txt", "/sw.js", "/_headers", "/_redirects"]
|
||||||
|
}
|
||||||
|
|
||||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/boris-social-1200.png
Normal file
|
After Width: | Height: | Size: 819 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 564 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
37
public/manifest.webmanifest
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "Boris - Nostr Bookmarks",
|
||||||
|
"short_name": "Boris",
|
||||||
|
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#0f172a",
|
||||||
|
"background_color": "#0b1220",
|
||||||
|
"orientation": "any",
|
||||||
|
"categories": ["productivity", "social", "utilities"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
215
public/pwa.svg
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="649.67538"
|
||||||
|
height="568.22024"
|
||||||
|
viewBox="0 0 649.67538 568.22024"
|
||||||
|
role="img"
|
||||||
|
artist="Katerina Limpitsouni"
|
||||||
|
source="https://undraw.co/"
|
||||||
|
version="1.1"
|
||||||
|
id="svg31"
|
||||||
|
sodipodi:docname="pwa.svg"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs31" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview31"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="1.6866359"
|
||||||
|
inkscape:cx="303.56285"
|
||||||
|
inkscape:cy="531.82789"
|
||||||
|
inkscape:window-width="3840"
|
||||||
|
inkscape:window-height="1027"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="svg31" />
|
||||||
|
<path
|
||||||
|
d="M397.23858,566.04035,390.539,618.81819l-9.85909-59.95407c-47.3817-18.18194-102.78179-21.713-102.78179-21.713s-12.22552,114.50728,28.139,162.38683,82.92182,40.60129,118.03379,11.00042c35.1114-29.60039,49.48123-70.31412,9.11675-118.19368C424.20327,581.68766,411.521,573.04476,397.23858,566.04035Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#f2f2f2"
|
||||||
|
id="path1" />
|
||||||
|
<path
|
||||||
|
d="M384.1004,626.79762l1.98958,2.36c22.98681,27.551,36.40476,52.8555,40.0327,75.5803.05864.33032.09573.65881.15431.98919l-1.53846.23773-1.48187.20991c-3.64942-24.76543-19.47993-50.77428-39.52347-74.8103-.63842-.781-1.28663-1.57364-1.95824-2.34655-8.57477-10.1-17.832-19.82437-27.217-28.9415-.72021-.712-1.46191-1.42587-2.20361-2.13968-12.44963-11.96994-25.01434-22.84351-36.237-32.03036-.7903-.653-1.59224-1.296-2.38439-1.92739-19.05943-15.4717-33.9044-25.802-37.21424-28.06849-.39875-.28343-.62465-.43273-.67573-.46958l.844-1.25121.00183-.02155.85568-1.26106c.05113.03692.81117.53546,2.18233,1.49814,5.15056,3.57268,18.987,13.39417,36.1433,27.27236.77059.62957,1.57259,1.27267,2.36284,1.92555,9.11521,7.44575,19.072,15.96086,29.1037,25.25221q3.78542,3.49455,7.37706,6.9724c.75332.704,1.495,1.41783,2.21523,2.12988Q372.14864,612.73905,384.1004,626.79762Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path2" />
|
||||||
|
<path
|
||||||
|
d="M315.8701,561.67759c-.6941.76509-1.39989,1.54-2.13716,2.30139a84.299,84.299,0,0,1-6.3038,5.89408,82.00518,82.00518,0,0,1-32.26683,16.72907c.03131,1.03285.06269,2.06578.09217,3.12018a85.04164,85.04164,0,0,0,34.14459-17.51256,87.22471,87.22471,0,0,0,6.71826-6.30338c.72551-.75156,1.43131-1.52651,2.11561-2.30323a84.3256,84.3256,0,0,0,13.87772-21.35332q-1.56615-.32858-3.06776-.65165A81.72351,81.72351,0,0,1,315.8701,561.67759Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path3" />
|
||||||
|
<path
|
||||||
|
d="M354.7137,595.82775q-1.15019,1.08949-2.35939,2.109c-.23552.21856-.49252.43522-.7379.64208a82.4401,82.4401,0,0,1-74.51659,16.59042c.1138,1.08323.22759,2.1666.36294,3.25167a85.5013,85.5013,0,0,0,76.12358-17.5054c.32717-.27581.65427-.55157.97158-.83909.80793-.70112,1.59437-1.40414,2.371-2.11878a85.04917,85.04917,0,0,0,24.39782-41.355c-.955-.37409-1.91-.74825-2.87668-1.11248A81.874,81.874,0,0,1,354.7137,595.82775Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path4" />
|
||||||
|
<path
|
||||||
|
d="M384.1004,626.79762c-.75869.75952-1.53717,1.49572-2.32545,2.22029-.84674.77374-1.70328,1.53585-2.57954,2.27457a82.66307,82.66307,0,0,1-98.92522,5.60818c.27211,1.38968.5343,2.76759.82973,4.13747a85.69022,85.69022,0,0,0,100.06542-7.409c.87626-.73872,1.74266-1.48914,2.56785-2.26471.80983-.72274,1.58831-1.45893,2.35679-2.20683a85.43958,85.43958,0,0,0,25.37276-57.38712c-.97424-.6577-1.97364-1.27419-2.98289-1.90237A82.39644,82.39644,0,0,1,384.1004,626.79762Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path5" />
|
||||||
|
<path
|
||||||
|
d="M648.03621,300.20693V215.13007a49.24034,49.24034,0,0,0-49.24-49.24019H418.54942a49.24029,49.24029,0,0,0-49.2406,49.24V271.632h-3.16709v19.90855h3.16709V312.7763h-3.16709v30.52644h3.16709V356.5751h-3.16709v30.52643h3.16709v294.7669a49.23993,49.23993,0,0,0,49.23995,49.24019H598.79561a49.24028,49.24028,0,0,0,49.2406-49.24V360.76613h3.10552v-60.5592Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#3f3d56"
|
||||||
|
id="path6" />
|
||||||
|
<path
|
||||||
|
d="M600.78268,178.70047H577.2545a17.47031,17.47031,0,0,1-16.17511,24.06836H457.81825a17.4703,17.4703,0,0,1-16.17512-24.06839H419.66775a36.772,36.772,0,0,0-36.772,36.772V681.526a36.772,36.772,0,0,0,36.772,36.77205h181.115a36.772,36.772,0,0,0,36.772-36.772h0V215.47244A36.772,36.772,0,0,0,600.78268,178.70047Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path7" />
|
||||||
|
<path
|
||||||
|
d="M605.33827,340.8917H415.11207a5.0058,5.0058,0,0,1-5-5V258.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V335.8917A5.00573,5.00573,0,0,1,605.33827,340.8917Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path8" />
|
||||||
|
<path
|
||||||
|
d="M587.22522,377.41807h-154a5.5,5.5,0,0,1,0-11h154a5.5,5.5,0,0,1,0,11Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path9" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,405.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path10" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,432.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path11" />
|
||||||
|
<path
|
||||||
|
d="M605.33827,571.8917H415.11207a5.0058,5.0058,0,0,1-5-5V489.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V566.8917A5.00573,5.00573,0,0,1,605.33827,571.8917Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path12" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,608.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path13" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,636.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path14" />
|
||||||
|
<path
|
||||||
|
d="M587.22523,663.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path15" />
|
||||||
|
<path
|
||||||
|
d="M760.06605,312.22721c-1.93457-14.18963-4.36084-29.42431-14.3689-39.66754a33.65518,33.65518,0,0,0-48.62622.5033c-7.28515,7.77185-10.50244,18.68475-10.79687,29.33325s2.07714,21.17865,4.708,31.50122a97.0913,97.0913,0,0,0,40.52124-7.97583,65.28916,65.28916,0,0,1,9.71558-3.81427c3.376-.85925,5.78247,1.303,8.92285,2.81073l1.72388-3.30078c1.41113,2.62616,5.78076,1.84772,7.36572-.67737C760.81605,318.41483,760.46888,315.18107,760.06605,312.22721Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path16" />
|
||||||
|
<polygon
|
||||||
|
points="612.434 535.007 602.208 541.77 571.257 505.545 586.349 495.564 612.434 535.007"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="polygon16" />
|
||||||
|
<path
|
||||||
|
d="M896.7595,709.08432,863.787,730.89015l-.27582-.417a15.38729,15.38729,0,0,1,4.34573-21.32122l.00081-.00054,20.13853-13.31819Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path17" />
|
||||||
|
<polygon
|
||||||
|
points="480.429 553.116 468.169 553.116 462.337 505.828 480.431 505.829 480.429 553.116"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="polygon17" />
|
||||||
|
<path
|
||||||
|
d="M758.71777,730.89015l-39.53076-.00146v-.5a15.3873,15.3873,0,0,1,15.38647-15.38623h.001l24.144.001Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path18" />
|
||||||
|
<path
|
||||||
|
d="M668.3639,394.03709l-46.28906-33.06561a8.99743,8.99743,0,1,0-10.80762,7.74816c5.78613,4.85816,48.04785,46.88825,54.09888,44.67127,6.1416-2.25012,32.99341-6.32324,32.99341-6.32324l.74755-25.4953Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="path19" />
|
||||||
|
<path
|
||||||
|
d="M704.73272,454.19782l.437,58.1781s10.01741,86.201,13.712,100.76318,18.69148,81.94564,18.69148,81.94564l24.3788-3.93292-15.69975-88.09791,4.74535-73.017,27.36445,73.178L847.847,675.848l17.61024-14.2095-60.48051-88.88116-18.47283-72.811s2.29785-37.66031-18.40081-52.16322Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path20" />
|
||||||
|
<circle
|
||||||
|
cx="443.5739"
|
||||||
|
cy="133.65539"
|
||||||
|
r="26.72083"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="circle20" />
|
||||||
|
<rect
|
||||||
|
x="722.98731"
|
||||||
|
y="465.33587"
|
||||||
|
width="24.29166"
|
||||||
|
height="31.57916"
|
||||||
|
transform="translate(-279.66359 789.41207) rotate(-65.86746)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="rect20" />
|
||||||
|
<path
|
||||||
|
d="M593.23271,362.65743"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path21" />
|
||||||
|
<path
|
||||||
|
d="M761.53382,350.95884c-3.14892-6.267-4.67895-14.009-11.39209-16.04077-4.5332-1.372-22.86841.68408-27,3-6.87231,3.85236-.64453,11.07111-4.699,17.82642q-6.61121,11.01552-13.22241,22.031c-3.03,5.04852-6.0918,10.16889-7.73023,15.82434-1.63818,5.65546-1.717,12.00305,1.074,17.18756,2.4978,4.64045,7.02294,7.93158,9.53515,12.56433,2.61231,4.81806-2.07715,26.33136-4.50854,31.24341l1.167.539a263.08934,263.08934,0,0,0,48.448-1.63024c3.9873-.50489,8.12744-1.16449,11.41308-3.47895,4.83985-3.40918,6.75318-9.5954,7.949-15.39337A129.67713,129.67713,0,0,0,761.53382,350.95884Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path22" />
|
||||||
|
<path
|
||||||
|
d="M706.84845,411.65133c7.23924-7.1146,14.51542-14.27181,20.47486-22.48827s10.5936-17.62115,11.88744-27.68835a20.50914,20.50914,0,0,0-.64136-9.62007c-1.11054-3.049-3.56912-5.755-6.73861-6.45068-5.07194-1.11355-9.6829,2.93435-13.30226,6.6577q-16.00732,16.46812-32.01478,32.936,10.19649,13.42191,20.393,26.84353Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path23" />
|
||||||
|
<path
|
||||||
|
d="M785.75257,417.13127c-2.25-6.14148-6.32324-32.99323-6.32324-32.99323l-25.49512-.74756,12.4646,30.7431-34.01367,47.61615s.063.10462.17749.2912a8.99538,8.99538,0,1,0,7.54468,9.55927.62106.62106,0,0,0,.77978-.13385C744.67176,466.7169,788.00257,423.27281,785.75257,417.13127Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#9e616a"
|
||||||
|
id="path24" />
|
||||||
|
<path
|
||||||
|
d="M788.34461,400.17338c-2.34008-9.87665-4.69751-19.807-8.64282-29.15894s-9.59326-18.18512-17.53711-24.50317a20.50909,20.50909,0,0,0-8.563-4.43085c-3.18359-.62805-6.77148.07483-9.00732,2.42658-3.57813,3.76318-2.50147,9.80365-1.18921,14.8277q5.80444,22.2203,11.60864,44.44061,16.76184-1.77667,33.52344-3.55347Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#e4e4e4"
|
||||||
|
id="path25" />
|
||||||
|
<path
|
||||||
|
d="M752.14124,301.6237c-.83545-6.464-1.708-12.98224-3.67065-19.06879-1.96265-6.08661-5.12622-11.78747-9.66431-15.23547-7.1853-5.459-16.488-4.40613-24.54394-1.266-6.23,2.42846-12.31153,6.1195-16.70484,12.05346-4.39355,5.934-6.86108,14.40119-5.2268,22.1601q12.88989-3.58722,25.77954-7.1745l-.94068.783c5.57642,3.14221,9.81153,9.64361,11.07691,17.00482a28.7171,28.7171,0,0,1-4.53662,21.03778q8.79089-3.67337,17.58178-7.34662c3.61744-1.51153,7.489-3.25317,9.634-7.13025C753.41273,312.94608,752.83485,306.98814,752.14124,301.6237Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#2f2e41"
|
||||||
|
id="path26" />
|
||||||
|
<path
|
||||||
|
d="M625.98113,343.51431,608.792,369.31226a4.46863,4.46863,0,0,1-3.75549,2.00125,4.47943,4.47943,0,0,1-4.13509-2.75491,4.12763,4.12763,0,0,1-.2689-.85745,4.51165,4.51165,0,0,1,.66976-3.37929l17.18913-25.79794a4.5,4.5,0,1,1,7.48973,4.99039Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path27" />
|
||||||
|
<path
|
||||||
|
d="M610.17821,367.23178l-3.47923,5.19091-6.15652,5.42689a2.45095,2.45095,0,0,1-3.94221-2.627l2.69471-7.8881,3.39353-5.09311Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#3f3d56"
|
||||||
|
id="path28" />
|
||||||
|
<path
|
||||||
|
d="M626.74053,329.98545l-8.6142,7.59289a2.45233,2.45233,0,0,0,.26168,3.88081l1.62984,1.086-4.71315,7.07363a1,1,0,0,0,1.66439,1.109l4.71314-7.07362,1.62985,1.086a2.45552,2.45552,0,0,0,3.39872-.675,2.46816,2.46816,0,0,0,.28357-.57793l3.69013-10.8738a2.45251,2.45251,0,0,0-3.944-2.62786Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#3f3d56"
|
||||||
|
id="path29" />
|
||||||
|
<path
|
||||||
|
d="M516.97522,187.41807h-27a2,2,0,0,1,0-4h27a2,2,0,0,1,0,4Z"
|
||||||
|
transform="translate(-275.16231 -165.88988)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path31" />
|
||||||
|
<circle
|
||||||
|
cx="255.31291"
|
||||||
|
cy="19.52819"
|
||||||
|
r="2"
|
||||||
|
fill="#fff"
|
||||||
|
id="circle31" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
@@ -1,5 +1,5 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: https://xn--bris-v0b.com/sitemap.xml
|
Sitemap: https://read.withboris.com/sitemap.xml
|
||||||
|
|
||||||
|
|||||||
1
public/thank-you.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
235
public/zaps.svg
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="720.44"
|
||||||
|
height="718.635"
|
||||||
|
viewBox="0 0 720.44 718.635"
|
||||||
|
role="img"
|
||||||
|
artist="Katerina Limpitsouni"
|
||||||
|
source="https://undraw.co/"
|
||||||
|
version="1.1"
|
||||||
|
id="svg30"
|
||||||
|
sodipodi:docname="zaps.svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||||
|
id="defs30" /><sodipodi:namedview
|
||||||
|
id="namedview30"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:zoom="2.6746164"
|
||||||
|
inkscape:cx="38.510195"
|
||||||
|
inkscape:cy="485.67712"
|
||||||
|
inkscape:window-width="3840"
|
||||||
|
inkscape:window-height="1027"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="25"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="g27" /><g
|
||||||
|
transform="translate(-600 -181)"
|
||||||
|
id="g30"><g
|
||||||
|
transform="translate(783.85 181)"
|
||||||
|
id="g2"><path
|
||||||
|
d="M624.7,249.968h-3.952V141.8a62.6,62.6,0,0,0-62.6-62.6H328.97a62.6,62.6,0,0,0-62.6,62.6V735.225a62.6,62.6,0,0,0,62.6,62.6H558.143a62.6,62.6,0,0,0,62.6-62.6V326.965H624.7Z"
|
||||||
|
transform="translate(-266.365 -79.193)"
|
||||||
|
fill="#090814"
|
||||||
|
id="path1" /><path
|
||||||
|
d="M560.888,95.686H530.974a22.212,22.212,0,0,1-20.565,30.6h-131.3a22.212,22.212,0,0,1-20.566-30.6H330.607a46.752,46.752,0,0,0-46.752,46.752V735a46.752,46.752,0,0,0,46.752,46.752H560.879A46.752,46.752,0,0,0,607.63,735V142.439a46.752,46.752,0,0,0-46.744-46.752Z"
|
||||||
|
transform="translate(-266.577 -79.397)"
|
||||||
|
fill="#fff"
|
||||||
|
id="path2" /></g><path
|
||||||
|
d="M8,0H256a8,8,0,0,1,8,8V72a8,8,0,0,1-8,8H8a8,8,0,0,1-8-8V8A8,8,0,0,1,8,0Z"
|
||||||
|
transform="translate(828 265)"
|
||||||
|
fill="#f2f2f2"
|
||||||
|
id="path3" /><path
|
||||||
|
d="M8,0H256a8.065,8.065,0,0,1,8,8.128V475.474a8.065,8.065,0,0,1-8,8.128H8a8.065,8.065,0,0,1-8-8.128V8.128A8.065,8.065,0,0,1,8,0Z"
|
||||||
|
transform="translate(828 358.398)"
|
||||||
|
fill="#f2f2f2"
|
||||||
|
id="path4" /><g
|
||||||
|
transform="translate(623.104 296.398)"
|
||||||
|
id="g9"><rect
|
||||||
|
width="278.304"
|
||||||
|
height="69.313"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect4" /><rect
|
||||||
|
width="272.003"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(3.151 3.151)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect5" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-53.047 -325.676)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path5" /><g
|
||||||
|
transform="translate(17.038 13.546)"
|
||||||
|
id="g7"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path6" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.7844991 20.830193,9.2808662 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(72.886 17.036)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path8" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(72.886 37.98)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path9" /></g><g
|
||||||
|
transform="translate(1003.278 402.469)"
|
||||||
|
id="g14"><rect
|
||||||
|
width="279.354"
|
||||||
|
height="69.313"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect9" /><rect
|
||||||
|
width="272.003"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(3.151 3.151)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect10" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-52.751 -325.287)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path10" /><g
|
||||||
|
transform="translate(17.334 13.936)"
|
||||||
|
id="g12"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path11" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2-7"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.181 17.426)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path13" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.181 38.369)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path14" /></g><g
|
||||||
|
transform="translate(663.012 510.639)"
|
||||||
|
id="g19"><rect
|
||||||
|
width="279.354"
|
||||||
|
height="69.313"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect14" /><rect
|
||||||
|
width="272.003"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(3.151 3.151)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect15" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-52.814 -325.25)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path15" /><g
|
||||||
|
transform="translate(17.272 13.972)"
|
||||||
|
id="g17"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path16" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2-0"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.119 17.463)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path18" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.119 38.406)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path19" /></g><g
|
||||||
|
transform="translate(1041.086 616.711)"
|
||||||
|
id="g24"><rect
|
||||||
|
width="279.354"
|
||||||
|
height="70.364"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect19" /><rect
|
||||||
|
width="272.003"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(4.201 4.201)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect20" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-52.163 -324.86)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path20" /><g
|
||||||
|
transform="translate(17.922 14.362)"
|
||||||
|
id="g22"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path21" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2-9"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.77 17.853)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path23" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.77 38.796)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path24" /></g><g
|
||||||
|
transform="translate(600 723.832)"
|
||||||
|
id="g29"><rect
|
||||||
|
width="279.354"
|
||||||
|
height="69.313"
|
||||||
|
rx="16"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#090814"
|
||||||
|
id="rect24" /><rect
|
||||||
|
width="273.053"
|
||||||
|
height="63.012"
|
||||||
|
rx="15"
|
||||||
|
transform="translate(3.151 3.151)"
|
||||||
|
fill="#fff"
|
||||||
|
id="rect25" /><path
|
||||||
|
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||||
|
transform="translate(-52.631 -325.518)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path25" /><g
|
||||||
|
transform="translate(17.454 13.704)"
|
||||||
|
id="g27"><path
|
||||||
|
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||||
|
transform="translate(0 0)"
|
||||||
|
fill="#6c63ff"
|
||||||
|
id="path26" /><path
|
||||||
|
fill="#ffffff"
|
||||||
|
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||||
|
id="path2-8-2-98"
|
||||||
|
style="stroke-width:0.575581" /></g><path
|
||||||
|
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.301 17.194)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path28" /><path
|
||||||
|
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||||
|
transform="translate(73.301 38.138)"
|
||||||
|
fill="#e6e6e6"
|
||||||
|
id="path29" /></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 17 KiB |
428
src/App.tsx
@@ -4,14 +4,21 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||||
import { EventStore } from 'applesauce-core'
|
import { EventStore } from 'applesauce-core'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { getDefaultBunkerPermissions } from './services/nostrConnect'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
|
import Debug from './components/Debug'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
|
import RouteDebug from './components/RouteDebug'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
|
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
|
import { DebugBus } from './utils/debugBus'
|
||||||
|
|
||||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||||
@@ -27,8 +34,7 @@ function AppRoutes({
|
|||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
accountManager.setActive(undefined as never)
|
accountManager.clearActive()
|
||||||
localStorage.removeItem('active')
|
|
||||||
showToast('Logged out successfully')
|
showToast('Logged out successfully')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +67,110 @@ function AppRoutes({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/support"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/explore"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/explore/writings"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me"
|
||||||
|
element={<Navigate to="/me/highlights" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/highlights"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/reading-list"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/reads"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/reads/:filter"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/links"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/writings"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/p/:npub"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/p/:npub/writings"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/debug" element={<Debug />} />
|
||||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
@@ -71,6 +181,7 @@ function App() {
|
|||||||
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
||||||
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
||||||
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
||||||
|
const isOnline = useOnlineStatus()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeApp = async () => {
|
const initializeApp = async () => {
|
||||||
@@ -81,20 +192,57 @@ function App() {
|
|||||||
// Register common account types (needed for deserialization)
|
// Register common account types (needed for deserialization)
|
||||||
registerCommonAccountTypes(accounts)
|
registerCommonAccountTypes(accounts)
|
||||||
|
|
||||||
|
// Create relay pool and set it up BEFORE loading accounts
|
||||||
|
// NostrConnectAccount.fromJSON needs this to restore the signer
|
||||||
|
const pool = new RelayPool()
|
||||||
|
// Wire the signer to use this pool; make publish non-blocking so callers don't
|
||||||
|
// wait for every relay send to finish. Responses still resolve the pending request.
|
||||||
|
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
|
||||||
|
NostrConnectSigner.publishMethod = (relays: string[], event: unknown) => {
|
||||||
|
const result: any = pool.publish(relays, event as any)
|
||||||
|
if (result && typeof (result as any).subscribe === 'function') {
|
||||||
|
try { (result as any).subscribe({ complete: () => {}, error: () => {} }) } catch {}
|
||||||
|
}
|
||||||
|
// Return an already-resolved promise so upstream await finishes immediately
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
console.log('[bunker] ✅ Wired NostrConnectSigner to RelayPool publish/subscription (before account load)')
|
||||||
|
|
||||||
|
// Create a relay group for better event deduplication and management
|
||||||
|
pool.group(RELAYS)
|
||||||
|
console.log('[bunker] Created relay group with', RELAYS.length, 'relays (including local)')
|
||||||
|
|
||||||
// Load persisted accounts from localStorage
|
// Load persisted accounts from localStorage
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
const accountsJson = localStorage.getItem('accounts')
|
||||||
|
console.log('[bunker] Raw accounts from localStorage:', accountsJson)
|
||||||
|
|
||||||
|
const json = JSON.parse(accountsJson || '[]')
|
||||||
|
console.log('[bunker] Parsed accounts:', json.length, 'accounts')
|
||||||
|
|
||||||
await accounts.fromJSON(json)
|
await accounts.fromJSON(json)
|
||||||
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
|
console.log('[bunker] Loaded', accounts.accounts.length, 'accounts from storage')
|
||||||
|
console.log('[bunker] Account types:', accounts.accounts.map(a => ({ id: a.id, type: a.type })))
|
||||||
|
|
||||||
// Load active account from storage
|
// Load active account from storage
|
||||||
const activeId = localStorage.getItem('active')
|
const activeId = localStorage.getItem('active')
|
||||||
if (activeId && accounts.getAccount(activeId)) {
|
console.log('[bunker] Active ID from localStorage:', activeId)
|
||||||
accounts.setActive(activeId)
|
|
||||||
console.log('Restored active account:', activeId)
|
if (activeId) {
|
||||||
|
const account = accounts.getAccount(activeId)
|
||||||
|
console.log('[bunker] Found account for ID?', !!account, account?.type)
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
accounts.setActive(activeId)
|
||||||
|
console.log('[bunker] ✅ Restored active account:', activeId, 'type:', account.type)
|
||||||
|
} else {
|
||||||
|
console.warn('[bunker] ⚠️ Active ID found but account not in list')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[bunker] No active account ID in localStorage')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load accounts from storage:', err)
|
console.error('[bunker] ❌ Failed to load accounts from storage:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to accounts changes and persist to localStorage
|
// Subscribe to accounts changes and persist to localStorage
|
||||||
@@ -111,12 +259,197 @@ function App() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const pool = new RelayPool()
|
// Reconnect bunker signers when active account changes
|
||||||
|
// Keep track of which accounts we've already reconnected to avoid double-connecting
|
||||||
|
const reconnectedAccounts = new Set<string>()
|
||||||
|
|
||||||
// Create a relay group for better event deduplication and management
|
const bunkerReconnectSub = accounts.active$.subscribe(async (account) => {
|
||||||
pool.group(RELAYS)
|
console.log('[bunker] Active account changed:', {
|
||||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
hasAccount: !!account,
|
||||||
console.log('Relay URLs:', RELAYS)
|
type: account?.type,
|
||||||
|
id: account?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
if (account && account.type === 'nostr-connect') {
|
||||||
|
const nostrConnectAccount = account as Accounts.NostrConnectAccount<unknown>
|
||||||
|
// Disable applesauce account queueing so decrypt requests aren't serialized behind earlier ops
|
||||||
|
try {
|
||||||
|
if (!(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue) {
|
||||||
|
(nostrConnectAccount as unknown as { disableQueue?: boolean }).disableQueue = true
|
||||||
|
console.log('[bunker] ⚙️ Disabled account request queueing for nostr-connect')
|
||||||
|
}
|
||||||
|
} catch (err) { console.warn('[bunker] failed to disable queue', err) }
|
||||||
|
// Note: for Amber bunker, the remote signer pubkey is the user's pubkey. This is expected.
|
||||||
|
|
||||||
|
// Skip if we've already reconnected this account
|
||||||
|
if (reconnectedAccounts.has(account.id)) {
|
||||||
|
console.log('[bunker] ⏭️ Already reconnected this account, skipping')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[bunker] Account detected. Status:', {
|
||||||
|
listening: nostrConnectAccount.signer.listening,
|
||||||
|
isConnected: nostrConnectAccount.signer.isConnected,
|
||||||
|
hasRemote: !!nostrConnectAccount.signer.remote,
|
||||||
|
bunkerRelays: nostrConnectAccount.signer.relays
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For restored signers, ensure they have the pool's subscription methods
|
||||||
|
// The signer was created in fromJSON without pool context, so we need to recreate it
|
||||||
|
const signerData = nostrConnectAccount.toJSON().signer
|
||||||
|
|
||||||
|
// Add bunker's relays to the pool BEFORE recreating the signer
|
||||||
|
// This ensures the pool has all relays when the signer sets up its methods
|
||||||
|
const bunkerRelays = signerData.relays || []
|
||||||
|
const existingRelayUrls = new Set(Array.from(pool.relays.keys()))
|
||||||
|
const newBunkerRelays = bunkerRelays.filter(url => !existingRelayUrls.has(url))
|
||||||
|
|
||||||
|
if (newBunkerRelays.length > 0) {
|
||||||
|
console.log('[bunker] Adding bunker relays to pool BEFORE signer recreation:', newBunkerRelays)
|
||||||
|
pool.group(newBunkerRelays)
|
||||||
|
} else {
|
||||||
|
console.log('[bunker] Bunker relays already in pool')
|
||||||
|
}
|
||||||
|
|
||||||
|
const recreatedSigner = new NostrConnectSigner({
|
||||||
|
relays: signerData.relays,
|
||||||
|
pubkey: nostrConnectAccount.pubkey,
|
||||||
|
remote: signerData.remote,
|
||||||
|
signer: nostrConnectAccount.signer.signer, // Use the existing SimpleSigner
|
||||||
|
pool: pool
|
||||||
|
})
|
||||||
|
// Ensure local relays are included for NIP-46 request/response traffic (e.g., Amber bunker)
|
||||||
|
try {
|
||||||
|
const mergedRelays = Array.from(new Set([...(signerData.relays || []), ...RELAYS]))
|
||||||
|
recreatedSigner.relays = mergedRelays
|
||||||
|
console.log('[bunker] 🔗 Signer relays merged with app RELAYS:', mergedRelays)
|
||||||
|
} catch (err) { console.warn('[bunker] failed to merge signer relays', err) }
|
||||||
|
|
||||||
|
// Replace the signer on the account
|
||||||
|
nostrConnectAccount.signer = recreatedSigner
|
||||||
|
console.log('[bunker] ✅ Signer recreated with pool context')
|
||||||
|
|
||||||
|
// Debug: log publish/subscription calls made by signer (decrypt/sign requests)
|
||||||
|
// IMPORTANT: bind originals to preserve `this` context used internally by the signer
|
||||||
|
const originalPublish = (recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod.bind(recreatedSigner)
|
||||||
|
;(recreatedSigner as unknown as { publishMethod: (relays: string[], event: unknown) => unknown }).publishMethod = (relays: string[], event: unknown) => {
|
||||||
|
try {
|
||||||
|
let method: string | undefined
|
||||||
|
const content = (event as { content?: unknown })?.content
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as { method?: string; id?: unknown }
|
||||||
|
method = parsed?.method
|
||||||
|
} catch (err) { console.warn('[bunker] failed to parse event content', err) }
|
||||||
|
}
|
||||||
|
const summary = {
|
||||||
|
relays,
|
||||||
|
kind: (event as { kind?: number })?.kind,
|
||||||
|
method,
|
||||||
|
// include tags array for debugging (NIP-46 expects method tag)
|
||||||
|
tags: (event as { tags?: unknown })?.tags,
|
||||||
|
contentLength: typeof content === 'string' ? content.length : undefined
|
||||||
|
}
|
||||||
|
console.log('[bunker] publish via signer:', summary)
|
||||||
|
try { DebugBus.info('bunker', 'publish', summary) } catch (err) { console.warn('[bunker] failed to log to DebugBus', err) }
|
||||||
|
} catch (err) { console.warn('[bunker] failed to log publish summary', err) }
|
||||||
|
// Fire-and-forget publish: trigger the publish but do not return the
|
||||||
|
// Observable/Promise to upstream to avoid their awaiting of completion.
|
||||||
|
const result = originalPublish(relays, event)
|
||||||
|
if (result && typeof (result as { subscribe?: unknown }).subscribe === 'function') {
|
||||||
|
try { (result as { subscribe: (h: { complete?: () => void; error?: (e: unknown) => void }) => unknown }).subscribe({ complete: () => {}, error: () => {} }) } catch {}
|
||||||
|
}
|
||||||
|
// If it's a Promise, simply ignore it (no await) so it resolves in the background.
|
||||||
|
// Return a benign object so callers that probe for a "subscribe" property
|
||||||
|
// (e.g., applesauce makeRequest) won't throw on `"subscribe" in result`.
|
||||||
|
return {} as unknown as never
|
||||||
|
}
|
||||||
|
const originalSubscribe = (recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod.bind(recreatedSigner)
|
||||||
|
;(recreatedSigner as unknown as { subscriptionMethod: (relays: string[], filters: unknown[]) => unknown }).subscriptionMethod = (relays: string[], filters: unknown[]) => {
|
||||||
|
try {
|
||||||
|
console.log('[bunker] subscribe via signer:', { relays, filters })
|
||||||
|
try { DebugBus.info('bunker', 'subscribe', { relays, filters }) } catch (err) { console.warn('[bunker] failed to log subscribe to DebugBus', err) }
|
||||||
|
} catch (err) { console.warn('[bunker] failed to log subscribe summary', err) }
|
||||||
|
return originalSubscribe(relays, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Just ensure the signer is listening for responses - don't call connect() again
|
||||||
|
// The fromBunkerURI already connected with permissions during login
|
||||||
|
if (!nostrConnectAccount.signer.listening) {
|
||||||
|
console.log('[bunker] Opening signer subscription...')
|
||||||
|
await nostrConnectAccount.signer.open()
|
||||||
|
console.log('[bunker] ✅ Signer subscription opened')
|
||||||
|
} else {
|
||||||
|
console.log('[bunker] ✅ Signer already listening')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt a guarded reconnect to ensure Amber authorizes decrypt operations
|
||||||
|
try {
|
||||||
|
if (nostrConnectAccount.signer.remote && !reconnectedAccounts.has(account.id)) {
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
console.log('[bunker] Attempting guarded connect() with permissions to ensure decrypt perms', { count: permissions.length })
|
||||||
|
await nostrConnectAccount.signer.connect(undefined, permissions)
|
||||||
|
console.log('[bunker] ✅ Guarded connect() succeeded with permissions')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[bunker] ⚠️ Guarded connect() failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the subscription a moment to fully establish before allowing decrypt operations
|
||||||
|
// This ensures the signer is ready to handle and receive responses
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
console.log("[bunker] Subscription ready after startup delay")
|
||||||
|
// Fire-and-forget: probe decrypt path to verify Amber responds to NIP-46 decrypt
|
||||||
|
try {
|
||||||
|
const withTimeout = async <T,>(p: Promise<T>, ms = 10000): Promise<T> => {
|
||||||
|
return await Promise.race([
|
||||||
|
p,
|
||||||
|
new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`probe timeout after ${ms}ms`)), ms)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
setTimeout(async () => {
|
||||||
|
const self = nostrConnectAccount.pubkey
|
||||||
|
// Try a roundtrip so the bunker can respond successfully
|
||||||
|
try {
|
||||||
|
console.log('[bunker] 🔎 Probe nip44 roundtrip (encrypt→decrypt)…')
|
||||||
|
const cipher44 = await withTimeout(nostrConnectAccount.signer.nip44!.encrypt(self, 'probe-nip44'))
|
||||||
|
const plain44 = await withTimeout(nostrConnectAccount.signer.nip44!.decrypt(self, cipher44))
|
||||||
|
console.log('[bunker] 🔎 Probe nip44 responded:', typeof plain44 === 'string' ? plain44 : typeof plain44)
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[bunker] 🔎 Probe nip44 result:', err instanceof Error ? err.message : err)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
console.log('[bunker] 🔎 Probe nip04 roundtrip (encrypt→decrypt)…')
|
||||||
|
const cipher04 = await withTimeout(nostrConnectAccount.signer.nip04!.encrypt(self, 'probe-nip04'))
|
||||||
|
const plain04 = await withTimeout(nostrConnectAccount.signer.nip04!.decrypt(self, cipher04))
|
||||||
|
console.log('[bunker] 🔎 Probe nip04 responded:', typeof plain04 === 'string' ? plain04 : typeof plain04)
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[bunker] 🔎 Probe nip04 result:', err instanceof Error ? err.message : err)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[bunker] 🔎 Probe setup failed:', err)
|
||||||
|
}
|
||||||
|
// The bunker remembers the permissions from the initial connection
|
||||||
|
nostrConnectAccount.signer.isConnected = true
|
||||||
|
|
||||||
|
console.log('[bunker] Final signer status:', {
|
||||||
|
listening: nostrConnectAccount.signer.listening,
|
||||||
|
isConnected: nostrConnectAccount.signer.isConnected,
|
||||||
|
remote: nostrConnectAccount.signer.remote,
|
||||||
|
relays: nostrConnectAccount.signer.relays
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark this account as reconnected
|
||||||
|
reconnectedAccounts.add(account.id)
|
||||||
|
console.log('[bunker] 🎉 Signer ready for signing')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bunker] ❌ Failed to open signer:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
// Keep all relay connections alive indefinitely by creating a persistent subscription
|
||||||
// This prevents disconnection when no other subscriptions are active
|
// This prevents disconnection when no other subscriptions are active
|
||||||
@@ -128,8 +461,7 @@ function App() {
|
|||||||
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
console.log('🔗 Created keep-alive subscription for', RELAYS.length, 'relay(s)')
|
||||||
|
|
||||||
// Store subscription for cleanup
|
// Store subscription for cleanup
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
;(pool as unknown as { _keepAliveSubscription: typeof keepAliveSub })._keepAliveSubscription = keepAliveSub
|
||||||
;(pool as any)._keepAliveSubscription = keepAliveSub
|
|
||||||
|
|
||||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||||
const addressLoader = createAddressLoader(pool, {
|
const addressLoader = createAddressLoader(pool, {
|
||||||
@@ -147,11 +479,11 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
accountsSub.unsubscribe()
|
accountsSub.unsubscribe()
|
||||||
activeSub.unsubscribe()
|
activeSub.unsubscribe()
|
||||||
|
bunkerReconnectSub.unsubscribe()
|
||||||
// Clean up keep-alive subscription if it exists
|
// Clean up keep-alive subscription if it exists
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const poolWithSub = pool as unknown as { _keepAliveSubscription?: { unsubscribe: () => void } }
|
||||||
if ((pool as any)._keepAliveSubscription) {
|
if (poolWithSub._keepAliveSubscription) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
poolWithSub._keepAliveSubscription.unsubscribe()
|
||||||
(pool as any)._keepAliveSubscription.unsubscribe()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +496,26 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (cleanup) cleanup()
|
if (cleanup) cleanup()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isOnline, showToast])
|
||||||
|
|
||||||
|
// Monitor online/offline status
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOnline) {
|
||||||
|
showToast('You are offline. Some features may be limited.')
|
||||||
|
}
|
||||||
|
}, [isOnline, showToast])
|
||||||
|
|
||||||
|
// Listen for service worker updates
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSWUpdate = () => {
|
||||||
|
showToast('New version available! Refresh to update.')
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('sw-update-available', handleSWUpdate)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('sw-update-available', handleSWUpdate)
|
||||||
|
}
|
||||||
|
}, [showToast])
|
||||||
|
|
||||||
if (!eventStore || !accountManager || !relayPool) {
|
if (!eventStore || !accountManager || !relayPool) {
|
||||||
return (
|
return (
|
||||||
@@ -175,22 +526,25 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventStoreProvider eventStore={eventStore}>
|
<SkeletonThemeProvider>
|
||||||
<AccountsProvider manager={accountManager}>
|
<EventStoreProvider eventStore={eventStore}>
|
||||||
<BrowserRouter>
|
<AccountsProvider manager={accountManager}>
|
||||||
<div className="app">
|
<BrowserRouter>
|
||||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||||
</div>
|
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||||
</BrowserRouter>
|
<RouteDebug />
|
||||||
{toastMessage && (
|
</div>
|
||||||
<Toast
|
</BrowserRouter>
|
||||||
message={toastMessage}
|
{toastMessage && (
|
||||||
type={toastType}
|
<Toast
|
||||||
onClose={clearToast}
|
message={toastMessage}
|
||||||
/>
|
type={toastType}
|
||||||
)}
|
onClose={clearToast}
|
||||||
</AccountsProvider>
|
/>
|
||||||
</EventStoreProvider>
|
)}
|
||||||
|
</AccountsProvider>
|
||||||
|
</EventStoreProvider>
|
||||||
|
</SkeletonThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
@@ -139,7 +140,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
clearTimeout(fetchTimeoutRef.current)
|
clearTimeout(fetchTimeoutRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [url]) // Only depend on url
|
}, [url, title, description, tagsInput])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -182,7 +183,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
@@ -279,7 +280,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
src/components/ArchiveFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
|
||||||
|
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||||
|
|
||||||
|
interface ArchiveFiltersProps {
|
||||||
|
selectedFilter: ArchiveFilterType
|
||||||
|
onFilterChange: (filter: ArchiveFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||||
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||||
|
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => {
|
||||||
|
const isActive = selectedFilter === filter.type
|
||||||
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArchiveFilters
|
||||||
|
|
||||||
58
src/components/AuthorCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
interface AuthorCardProps {
|
||||||
|
authorPubkey: string
|
||||||
|
clickable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
|
||||||
|
|
||||||
|
const getAuthorName = () => {
|
||||||
|
if (profile?.name) return profile.name
|
||||||
|
if (profile?.display_name) return profile.display_name
|
||||||
|
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorImage = profile?.picture || profile?.image
|
||||||
|
const authorBio = profile?.about
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (clickable) {
|
||||||
|
const npub = nip19.npubEncode(authorPubkey)
|
||||||
|
navigate(`/p/${npub}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`author-card ${clickable ? 'author-card-clickable' : ''}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={clickable ? { cursor: 'pointer' } : undefined}
|
||||||
|
>
|
||||||
|
<div className="author-card-avatar">
|
||||||
|
{authorImage ? (
|
||||||
|
<img src={authorImage} alt={getAuthorName()} />
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon icon={faUserCircle} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="author-card-content">
|
||||||
|
<div className="author-card-name">{getAuthorName()}</div>
|
||||||
|
{authorBio && (
|
||||||
|
<p className="author-card-bio">{authorBio}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthorCard
|
||||||
|
|
||||||
107
src/components/BlogPostCard.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faCalendar, faUser, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { formatDistance } from 'date-fns'
|
||||||
|
import { BlogPostPreview } from '../services/exploreService'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
|
||||||
|
interface BlogPostCardProps {
|
||||||
|
post: BlogPostPreview
|
||||||
|
href: string
|
||||||
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
||||||
|
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||||
|
const displayName = profile?.name || profile?.display_name ||
|
||||||
|
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||||
|
|
||||||
|
const publishedDate = post.published || post.event.created_at
|
||||||
|
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||||
|
addSuffix: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
|
||||||
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
<div className="blog-post-card-image">
|
||||||
|
{post.image ? (
|
||||||
|
<img
|
||||||
|
src={post.image}
|
||||||
|
alt={post.title}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="blog-post-image-placeholder">
|
||||||
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="blog-post-card-content">
|
||||||
|
<h3 className="blog-post-card-title">{post.title}</h3>
|
||||||
|
{post.summary && (
|
||||||
|
<p className="blog-post-card-summary">{post.summary}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reading progress indicator - replaces the dividing line */}
|
||||||
|
{readingProgress !== undefined && readingProgress > 0 ? (
|
||||||
|
<div
|
||||||
|
className="blog-post-reading-progress"
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
height: '1px',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
marginTop: '1rem'
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="blog-post-card-meta" style={{ borderTop: 'none', paddingTop: '0.75rem' }}>
|
||||||
|
<span className="blog-post-card-author">
|
||||||
|
<FontAwesomeIcon icon={faUser} />
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<span className="blog-post-card-date">
|
||||||
|
<FontAwesomeIcon icon={faCalendar} />
|
||||||
|
{formattedDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlogPostCard
|
||||||
|
|
||||||
44
src/components/BookmarkFilters.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faNewspaper, faStickyNote, faCirclePlay } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
import { faGlobe, faAsterisk, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
export type BookmarkFilterType = 'all' | 'article' | 'external' | 'video' | 'note' | 'web'
|
||||||
|
|
||||||
|
interface BookmarkFiltersProps {
|
||||||
|
selectedFilter: BookmarkFilterType
|
||||||
|
onFilterChange: (filter: BookmarkFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookmarkFilters: React.FC<BookmarkFiltersProps> = ({
|
||||||
|
selectedFilter,
|
||||||
|
onFilterChange
|
||||||
|
}) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'article' as const, icon: faNewspaper, label: 'Articles' },
|
||||||
|
{ type: 'external' as const, icon: faLink, label: 'External Articles' },
|
||||||
|
{ type: 'video' as const, icon: faCirclePlay, label: 'Videos' },
|
||||||
|
{ type: 'note' as const, icon: faStickyNote, label: 'Notes' },
|
||||||
|
{ type: 'web' as const, icon: faGlobe, label: 'Web' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BookmarkFilters
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
|
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
||||||
@@ -11,17 +13,15 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
|||||||
import { CompactView } from './BookmarkViews/CompactView'
|
import { CompactView } from './BookmarkViews/CompactView'
|
||||||
import { LargeView } from './BookmarkViews/LargeView'
|
import { LargeView } from './BookmarkViews/LargeView'
|
||||||
import { CardView } from './BookmarkViews/CardView'
|
import { CardView } from './BookmarkViews/CardView'
|
||||||
import { UserSettings } from '../services/settingsService'
|
|
||||||
|
|
||||||
interface BookmarkItemProps {
|
interface BookmarkItemProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
index: number
|
index: number
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
viewMode?: ViewMode
|
viewMode?: ViewMode
|
||||||
settings?: UserSettings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
|
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
|
|
||||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||||
@@ -68,18 +68,41 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
return short(bookmark.pubkey) // fallback to short pubkey
|
return short(bookmark.pubkey) // fallback to short pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
// use helper from kindIcon.ts
|
// Get content type icon based on bookmark kind and URL classification
|
||||||
|
const getContentTypeIcon = (): IconDefinition => {
|
||||||
|
if (isArticle) return faNewspaper // Nostr-native article
|
||||||
|
|
||||||
|
// For web bookmarks, classify the URL to determine icon
|
||||||
|
if (isWebBookmark && firstUrlClassification) {
|
||||||
|
switch (firstUrlClassification.type) {
|
||||||
|
case 'youtube':
|
||||||
|
case 'video':
|
||||||
|
return faCirclePlay
|
||||||
|
case 'image':
|
||||||
|
return faCamera
|
||||||
|
case 'article':
|
||||||
|
return faLink // External article
|
||||||
|
default:
|
||||||
|
return faGlobe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUrls) return faStickyNote // Just a text note
|
||||||
|
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
|
||||||
|
if (firstUrlClassification?.type === 'article') return faLink // External article
|
||||||
|
return faFileLines
|
||||||
|
}
|
||||||
|
|
||||||
const getIconForUrlType = (url: string) => {
|
const getIconForUrlType = (url: string) => {
|
||||||
const classification = classifyUrl(url)
|
const classification = classifyUrl(url)
|
||||||
switch (classification.type) {
|
switch (classification.type) {
|
||||||
case 'youtube':
|
case 'youtube':
|
||||||
case 'video':
|
case 'video':
|
||||||
return faPlay
|
return faCirclePlay
|
||||||
case 'image':
|
case 'image':
|
||||||
return faEye
|
return faCamera
|
||||||
default:
|
default:
|
||||||
return faBookOpen
|
return faFileLines
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,24 +133,24 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
|||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
getIconForUrlType,
|
|
||||||
firstUrlClassification,
|
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
eventNevent,
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
settings
|
contentTypeIcon: getContentTypeIcon()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'compact') {
|
if (viewMode === 'compact') {
|
||||||
return <CompactView {...sharedProps} />
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||||
|
const { articleImage, ...compactProps } = sharedProps
|
||||||
|
return <CompactView {...compactProps} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'large') {
|
if (viewMode === 'large') {
|
||||||
const previewImage = articleImage || instantPreview || ogImage
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
return <LargeView {...sharedProps} previewImage={previewImage} />
|
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import React from 'react'
|
import React, { useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
import SidebarHeader from './SidebarHeader'
|
import SidebarHeader from './SidebarHeader'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
|
import CompactButton from './CompactButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
|
import { BookmarkSkeleton } from './Skeletons'
|
||||||
|
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import AddBookmarkModal from './AddBookmarkModal'
|
||||||
|
import { createWebBookmark } from '../services/webBookmarkService'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import LoginOptions from './LoginOptions'
|
||||||
|
|
||||||
interface BookmarkListProps {
|
interface BookmarkListProps {
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
@@ -22,8 +35,10 @@ interface BookmarkListProps {
|
|||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
isRefreshing?: boolean
|
isRefreshing?: boolean
|
||||||
|
lastFetchTime?: number | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
isMobile?: boolean
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,40 +54,67 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
|
lastFetchTime,
|
||||||
loading = false,
|
loading = false,
|
||||||
relayPool,
|
relayPool,
|
||||||
|
isMobile = false,
|
||||||
settings
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
// Helper to check if a bookmark has either content or a URL
|
const navigate = useNavigate()
|
||||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||||
// Check if has content (text)
|
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||||
const hasContent = ib.content && ib.content.trim().length > 0
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||||
// Check if has URL
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
let hasUrl = false
|
|
||||||
|
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||||
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
if (!activeAccount || !relayPool) {
|
||||||
if (ib.kind === 39701) {
|
throw new Error('Please login to create bookmarks')
|
||||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
|
||||||
hasUrl = !!dTag && dTag.trim().length > 0
|
|
||||||
} else {
|
|
||||||
// For other bookmarks, extract URLs from content
|
|
||||||
const urls = extractUrlsFromContent(ib.content || '')
|
|
||||||
hasUrl = urls.length > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always show articles (kind:30023) as they have special handling
|
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||||
if (ib.kind === 30023) return true
|
|
||||||
|
|
||||||
// Otherwise, must have either content or URL
|
|
||||||
return hasContent || hasUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull-to-refresh for bookmarks
|
||||||
|
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !onRefresh
|
||||||
|
})
|
||||||
|
|
||||||
// Merge and flatten all individual bookmarks from all lists
|
// Merge and flatten all individual bookmarks from all lists
|
||||||
// Re-sort after flattening to ensure newest first across all lists
|
|
||||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
.filter(hasContentOrUrl)
|
.filter(hasContent)
|
||||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
|
||||||
|
// Apply filter
|
||||||
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||||
|
|
||||||
|
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||||
|
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||||
|
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||||
|
|
||||||
|
// Group non-set bookmarks as before
|
||||||
|
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||||
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||||
|
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||||
|
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
||||||
|
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add bookmark sets as additional sections
|
||||||
|
bookmarkSets.forEach(set => {
|
||||||
|
sections.push({
|
||||||
|
key: `set-${set.name}`,
|
||||||
|
title: set.title || set.name,
|
||||||
|
items: set.bookmarks
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
// Check if the selected URL is in bookmarks
|
// Check if the selected URL is in bookmarks
|
||||||
@@ -102,59 +144,127 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
|||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
onRefresh={onRefresh}
|
isMobile={isMobile}
|
||||||
isRefreshing={isRefreshing}
|
|
||||||
relayPool={relayPool}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{allIndividualBookmarks.length > 0 && (
|
||||||
<div className="loading">
|
<BookmarkFilters
|
||||||
<FontAwesomeIcon icon={faSpinner} spin />
|
selectedFilter={selectedFilter}
|
||||||
|
onFilterChange={setSelectedFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!activeAccount ? (
|
||||||
|
<LoginOptions />
|
||||||
|
) : filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>No bookmarks match this filter.</p>
|
||||||
</div>
|
</div>
|
||||||
) : allIndividualBookmarks.length === 0 ? (
|
) : allIndividualBookmarks.length === 0 ? (
|
||||||
<div className="empty-state">
|
loading ? (
|
||||||
<p>No bookmarks found.</p>
|
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
</div>
|
{Array.from({ length: viewMode === 'large' ? 4 : viewMode === 'cards' ? 6 : 8 }).map((_, i) => (
|
||||||
) : (
|
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||||
<div className="bookmarks-list">
|
))}
|
||||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
</div>
|
||||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
|
||||||
<BookmarkItem
|
|
||||||
key={`${individualBookmark.id}-${index}`}
|
|
||||||
bookmark={individualBookmark}
|
|
||||||
index={index}
|
|
||||||
onSelectUrl={onSelectUrl}
|
|
||||||
viewMode={viewMode}
|
|
||||||
settings={settings}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>No bookmarks found.</p>
|
||||||
|
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={bookmarksListRef}
|
||||||
|
className="bookmarks-list"
|
||||||
|
>
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isPulling || isRefreshing || false}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
|
{sections.filter(s => s.items.length > 0).map(section => (
|
||||||
|
<div key={section.key} className="bookmarks-section">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
||||||
|
{section.key === 'web' && activeAccount && (
|
||||||
|
<CompactButton
|
||||||
|
icon={faPlus}
|
||||||
|
onClick={() => setShowAddModal(true)}
|
||||||
|
title="Add web bookmark"
|
||||||
|
ariaLabel="Add web bookmark"
|
||||||
|
className="bookmark-section-action"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
|
{section.items.map((individualBookmark, index) => (
|
||||||
|
<BookmarkItem
|
||||||
|
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||||
|
bookmark={individualBookmark}
|
||||||
|
index={index}
|
||||||
|
onSelectUrl={onSelectUrl}
|
||||||
|
viewMode={viewMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="view-mode-controls">
|
<div className="view-mode-controls">
|
||||||
<IconButton
|
<div className="view-mode-left">
|
||||||
icon={faList}
|
<IconButton
|
||||||
onClick={() => onViewModeChange('compact')}
|
icon={faHeart}
|
||||||
title="Compact list view"
|
onClick={() => navigate('/support')}
|
||||||
ariaLabel="Compact list view"
|
title="Support Boris"
|
||||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
ariaLabel="Support"
|
||||||
/>
|
variant="ghost"
|
||||||
<IconButton
|
style={{ color: friendsColor }}
|
||||||
icon={faThLarge}
|
/>
|
||||||
onClick={() => onViewModeChange('cards')}
|
</div>
|
||||||
title="Cards view"
|
<div className="view-mode-right">
|
||||||
ariaLabel="Cards view"
|
{onRefresh && (
|
||||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
<IconButton
|
||||||
/>
|
icon={faRotate}
|
||||||
<IconButton
|
onClick={onRefresh}
|
||||||
icon={faImage}
|
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||||
onClick={() => onViewModeChange('large')}
|
ariaLabel="Refresh bookmarks"
|
||||||
title="Large preview view"
|
variant="ghost"
|
||||||
ariaLabel="Large preview view"
|
disabled={isRefreshing}
|
||||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
spin={isRefreshing}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
icon={faList}
|
||||||
|
onClick={() => onViewModeChange('compact')}
|
||||||
|
title="Compact list view"
|
||||||
|
ariaLabel="Compact list view"
|
||||||
|
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faThLarge}
|
||||||
|
onClick={() => onViewModeChange('cards')}
|
||||||
|
title="Cards view"
|
||||||
|
ariaLabel="Cards view"
|
||||||
|
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faImage}
|
||||||
|
onClick={() => onViewModeChange('large')}
|
||||||
|
title="Large preview view"
|
||||||
|
ariaLabel="Large preview view"
|
||||||
|
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showAddModal && (
|
||||||
|
<AddBookmarkModal
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSave={handleSaveBookmark}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
import IconButton from '../IconButton'
|
|
||||||
import { classifyUrl } from '../../utils/helpers'
|
import { classifyUrl } from '../../utils/helpers'
|
||||||
import { IconGetter } from './shared'
|
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||||
|
import { getEventUrl } from '../../config/nostrGateways'
|
||||||
|
|
||||||
interface CardViewProps {
|
interface CardViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -16,15 +17,13 @@ interface CardViewProps {
|
|||||||
hasUrls: boolean
|
hasUrls: boolean
|
||||||
extractedUrls: string[]
|
extractedUrls: string[]
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
getIconForUrlType: IconGetter
|
|
||||||
firstUrlClassification: { buttonText: string } | null
|
|
||||||
authorNpub: string
|
authorNpub: string
|
||||||
eventNevent?: string
|
eventNevent?: string
|
||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleImage?: string
|
articleImage?: string
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
settings?: UserSettings
|
contentTypeIcon: IconDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardView: React.FC<CardViewProps> = ({
|
export const CardView: React.FC<CardViewProps> = ({
|
||||||
@@ -33,27 +32,56 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
getIconForUrlType,
|
|
||||||
firstUrlClassification,
|
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
eventNevent,
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleImage,
|
articleImage,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
settings
|
contentTypeIcon
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(articleImage, settings)
|
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||||
|
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||||
|
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
||||||
|
|
||||||
|
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||||
|
|
||||||
const contentLength = (bookmark.content || '').length
|
const contentLength = (bookmark.content || '').length
|
||||||
const shouldTruncate = !expanded && contentLength > 210
|
const shouldTruncate = !expanded && contentLength > 210
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
|
||||||
|
// Determine which image to use (article image, instant preview, or OG image)
|
||||||
|
const previewImage = articleImage || instantPreview || ogImage
|
||||||
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
|
|
||||||
|
// Fetch OG image if we don't have any other image
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (firstUrl && !articleImage && !instantPreview && !ogImage) {
|
||||||
|
fetchOgImage(firstUrl).then(setOgImage)
|
||||||
|
}
|
||||||
|
}, [firstUrl, articleImage, instantPreview, ogImage])
|
||||||
|
|
||||||
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
|
|
||||||
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
triggerOpen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div
|
||||||
{isArticle && cachedImage && (
|
key={`${bookmark.id}-${index}`}
|
||||||
|
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||||
|
onClick={triggerOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
{cachedImage && (
|
||||||
<div
|
<div
|
||||||
className="article-hero-image"
|
className="article-hero-image"
|
||||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||||
@@ -62,28 +90,17 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
<div className="bookmark-header">
|
<div className="bookmark-header">
|
||||||
<span className="bookmark-type">
|
<span className="bookmark-type">
|
||||||
{isWebBookmark ? (
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
<span className="fa-layers fa-fw">
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
|
||||||
</span>
|
|
||||||
) : bookmark.isPrivate ? (
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{eventNevent ? (
|
{eventNevent ? (
|
||||||
<a
|
<a
|
||||||
href={`https://search.dergigi.com/e/${eventNevent}`}
|
href={getEventUrl(eventNevent)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="bookmark-date-link"
|
className="bookmark-date-link"
|
||||||
title="Open event in search"
|
title="Open event in search"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{formatDate(bookmark.created_at)}
|
{formatDate(bookmark.created_at)}
|
||||||
</a>
|
</a>
|
||||||
@@ -95,31 +112,21 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
{extractedUrls.length > 0 && (
|
{extractedUrls.length > 0 && (
|
||||||
<div className="bookmark-urls">
|
<div className="bookmark-urls">
|
||||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||||
const classification = classifyUrl(url)
|
|
||||||
return (
|
return (
|
||||||
<div key={urlIndex} className="url-row">
|
<button
|
||||||
<button
|
key={urlIndex}
|
||||||
className="bookmark-url"
|
className="bookmark-url"
|
||||||
onClick={() => onSelectUrl?.(url)}
|
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||||
title="Open in reader"
|
title="Open in reader"
|
||||||
>
|
>
|
||||||
{url}
|
{url}
|
||||||
</button>
|
</button>
|
||||||
<IconButton
|
|
||||||
icon={getIconForUrlType(url)}
|
|
||||||
ariaLabel={classification.buttonText}
|
|
||||||
title={classification.buttonText}
|
|
||||||
variant="success"
|
|
||||||
size={32}
|
|
||||||
onClick={(e) => { e.preventDefault(); onSelectUrl?.(url) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{extractedUrls.length > 1 && (
|
{extractedUrls.length > 1 && (
|
||||||
<button
|
<button
|
||||||
className="expand-toggle-urls"
|
className="expand-toggle-urls"
|
||||||
onClick={() => setUrlsExpanded(v => !v)}
|
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
||||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||||
>
|
>
|
||||||
@@ -148,7 +155,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
{contentLength > 210 && (
|
{contentLength > 210 && (
|
||||||
<button
|
<button
|
||||||
className="expand-toggle"
|
className="expand-toggle"
|
||||||
onClick={() => setExpanded(v => !v)}
|
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||||
title={expanded ? 'Collapse' : 'Expand'}
|
title={expanded ? 'Collapse' : 'Expand'}
|
||||||
>
|
>
|
||||||
@@ -158,21 +165,16 @@ export const CardView: React.FC<CardViewProps> = ({
|
|||||||
|
|
||||||
<div className="bookmark-footer">
|
<div className="bookmark-footer">
|
||||||
<div className="bookmark-meta-minimal">
|
<div className="bookmark-meta-minimal">
|
||||||
<a
|
<Link
|
||||||
href={`https://search.dergigi.com/p/${authorNpub}`}
|
to={`/p/${authorNpub}`}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="author-link-minimal"
|
className="author-link-minimal"
|
||||||
title="Open author in search"
|
title="Open author profile"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{getAuthorDisplayName()}
|
{getAuthorDisplayName()}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{(hasUrls && firstUrlClassification) || bookmark.kind === 30023 ? (
|
{/* CTA removed */}
|
||||||
<button className="read-now-button-minimal" onClick={handleReadNow}>
|
|
||||||
{bookmark.kind === 30023 ? 'Read Article' : firstUrlClassification?.buttonText}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
import { IconGetter } from './shared'
|
|
||||||
|
|
||||||
interface CompactViewProps {
|
interface CompactViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -12,10 +11,8 @@ interface CompactViewProps {
|
|||||||
hasUrls: boolean
|
hasUrls: boolean
|
||||||
extractedUrls: string[]
|
extractedUrls: string[]
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
getIconForUrlType: IconGetter
|
|
||||||
firstUrlClassification: { buttonText: string } | null
|
|
||||||
articleImage?: string
|
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
|
contentTypeIcon: IconDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CompactView: React.FC<CompactViewProps> = ({
|
export const CompactView: React.FC<CompactViewProps> = ({
|
||||||
@@ -24,9 +21,8 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
hasUrls,
|
hasUrls,
|
||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
getIconForUrlType,
|
articleSummary,
|
||||||
firstUrlClassification,
|
contentTypeIcon
|
||||||
articleSummary
|
|
||||||
}) => {
|
}) => {
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
const isWebBookmark = bookmark.kind === 39701
|
||||||
@@ -56,42 +52,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
tabIndex={isClickable ? 0 : undefined}
|
tabIndex={isClickable ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
{isWebBookmark ? (
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
<span className="fa-layers fa-fw">
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
|
||||||
</span>
|
|
||||||
) : bookmark.isPrivate ? (
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{displayText && (
|
{displayText && (
|
||||||
<div className="compact-text">
|
<div className="compact-text">
|
||||||
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
|
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
|
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||||
{isClickable && (
|
{/* CTA removed */}
|
||||||
<button
|
|
||||||
className="compact-read-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (isArticle) {
|
|
||||||
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
|
||||||
} else {
|
|
||||||
onSelectUrl?.(extractedUrls[0])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
import { formatDate } from '../../utils/bookmarkUtils'
|
import { formatDate } from '../../utils/bookmarkUtils'
|
||||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||||
import { IconGetter } from './shared'
|
import { IconGetter } from './shared'
|
||||||
import { useImageCache } from '../../hooks/useImageCache'
|
import { useImageCache } from '../../hooks/useImageCache'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { getEventUrl } from '../../config/nostrGateways'
|
||||||
|
|
||||||
interface LargeViewProps {
|
interface LargeViewProps {
|
||||||
bookmark: IndividualBookmark
|
bookmark: IndividualBookmark
|
||||||
@@ -14,14 +16,14 @@ interface LargeViewProps {
|
|||||||
extractedUrls: string[]
|
extractedUrls: string[]
|
||||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||||
getIconForUrlType: IconGetter
|
getIconForUrlType: IconGetter
|
||||||
firstUrlClassification: { buttonText: string } | null
|
|
||||||
previewImage: string | null
|
previewImage: string | null
|
||||||
authorNpub: string
|
authorNpub: string
|
||||||
eventNevent?: string
|
eventNevent?: string
|
||||||
getAuthorDisplayName: () => string
|
getAuthorDisplayName: () => string
|
||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
settings?: UserSettings
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -31,24 +33,50 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
extractedUrls,
|
extractedUrls,
|
||||||
onSelectUrl,
|
onSelectUrl,
|
||||||
getIconForUrlType,
|
getIconForUrlType,
|
||||||
firstUrlClassification,
|
|
||||||
previewImage,
|
previewImage,
|
||||||
authorNpub,
|
authorNpub,
|
||||||
eventNevent,
|
eventNevent,
|
||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
settings
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
|
// Calculate progress display (matching readingProgressUtils.ts logic)
|
||||||
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
triggerOpen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div
|
||||||
|
key={`${bookmark.id}-${index}`}
|
||||||
|
className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||||
|
onClick={triggerOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
{(hasUrls || (isArticle && cachedImage)) && (
|
{(hasUrls || (isArticle && cachedImage)) && (
|
||||||
<div
|
<div
|
||||||
className="large-preview-image"
|
className="large-preview-image"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
if (isArticle) {
|
if (isArticle) {
|
||||||
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
} else {
|
} else {
|
||||||
@@ -76,35 +104,55 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reading progress indicator for articles - shown only if there's progress */}
|
||||||
|
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="large-footer">
|
<div className="large-footer">
|
||||||
|
<span className="bookmark-type-large">
|
||||||
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
|
</span>
|
||||||
<span className="large-author">
|
<span className="large-author">
|
||||||
<a
|
<Link
|
||||||
href={`https://search.dergigi.com/p/${authorNpub}`}
|
to={`/p/${authorNpub}`}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="author-link-minimal"
|
className="author-link-minimal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{getAuthorDisplayName()}
|
{getAuthorDisplayName()}
|
||||||
</a>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{eventNevent && (
|
{eventNevent && (
|
||||||
<a
|
<a
|
||||||
href={`https://search.dergigi.com/e/${eventNevent}`}
|
href={getEventUrl(eventNevent)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="bookmark-date-link"
|
className="bookmark-date-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{formatDate(bookmark.created_at)}
|
{formatDate(bookmark.created_at)}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasUrls && firstUrlClassification) || isArticle ? (
|
{/* CTA removed */}
|
||||||
<button className="large-read-button" onClick={handleReadNow}>
|
|
||||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
|
||||||
{isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
|||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventStore } from 'applesauce-react/hooks'
|
import { useEventStore } from 'applesauce-react/hooks'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useSettings } from '../hooks/useSettings'
|
import { useSettings } from '../hooks/useSettings'
|
||||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||||
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||||
@@ -13,6 +14,9 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
|||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
|
import Explore from './Explore'
|
||||||
|
import Me from './Me'
|
||||||
|
import Support from './Support'
|
||||||
import { classifyHighlights } from '../utils/highlightClassification'
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
|
||||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||||
@@ -23,23 +27,59 @@ interface BookmarksProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||||
const { naddr } = useParams<{ naddr?: string }>()
|
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const previousLocationRef = useRef<string>()
|
const previousLocationRef = useRef<string>()
|
||||||
|
|
||||||
|
// Check for highlight navigation state
|
||||||
|
const navigationState = location.state as { highlightId?: string; openHighlights?: boolean } | null
|
||||||
|
|
||||||
const externalUrl = location.pathname.startsWith('/r/')
|
const externalUrl = location.pathname.startsWith('/r/')
|
||||||
? decodeURIComponent(location.pathname.slice(3))
|
? decodeURIComponent(location.pathname.slice(3))
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const showSettings = location.pathname === '/settings'
|
const showSettings = location.pathname === '/settings'
|
||||||
|
const showExplore = location.pathname.startsWith('/explore')
|
||||||
|
const showMe = location.pathname.startsWith('/me')
|
||||||
|
const showProfile = location.pathname.startsWith('/p/')
|
||||||
|
const showSupport = location.pathname === '/support'
|
||||||
|
|
||||||
// Track previous location for going back from settings
|
// Extract tab from explore routes
|
||||||
|
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
|
// Extract tab from me routes
|
||||||
|
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||||
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
|
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||||
|
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||||
|
location.pathname === '/me/links' ? 'links' :
|
||||||
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
|
// Extract tab from profile routes
|
||||||
|
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||||
|
|
||||||
|
// Decode npub or nprofile to pubkey for profile view
|
||||||
|
let profilePubkey: string | undefined
|
||||||
|
if (npub && showProfile) {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(npub)
|
||||||
|
if (decoded.type === 'npub') {
|
||||||
|
profilePubkey = decoded.data
|
||||||
|
} else if (decoded.type === 'nprofile') {
|
||||||
|
profilePubkey = decoded.data.pubkey
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to decode npub/nprofile:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track previous location for going back from settings/me/explore/profile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSettings) {
|
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
||||||
previousLocationRef.current = location.pathname
|
previousLocationRef.current = location.pathname
|
||||||
}
|
}
|
||||||
}, [location.pathname, showSettings])
|
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
||||||
|
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
@@ -65,6 +105,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
isMobile,
|
||||||
|
isSidebarOpen,
|
||||||
|
toggleSidebar,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
setIsCollapsed,
|
setIsCollapsed,
|
||||||
isHighlightsCollapsed,
|
isHighlightsCollapsed,
|
||||||
@@ -85,6 +128,29 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setHighlightVisibility
|
setHighlightVisibility
|
||||||
} = useBookmarksUI({ settings })
|
} = useBookmarksUI({ settings })
|
||||||
|
|
||||||
|
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
|
||||||
|
const prevPathnameRef = useRef<string>(location.pathname)
|
||||||
|
useEffect(() => {
|
||||||
|
// Only close if pathname actually changed, not on initial render or other state changes
|
||||||
|
if (isMobile && isSidebarOpen && prevPathnameRef.current !== location.pathname) {
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
prevPathnameRef.current = location.pathname
|
||||||
|
}, [location.pathname, isMobile, isSidebarOpen, toggleSidebar])
|
||||||
|
|
||||||
|
// Handle highlight navigation from explore page
|
||||||
|
useEffect(() => {
|
||||||
|
if (navigationState?.highlightId && navigationState?.openHighlights) {
|
||||||
|
// Open the highlights sidebar
|
||||||
|
setIsHighlightsCollapsed(false)
|
||||||
|
// Select the highlight (scroll happens automatically in useHighlightInteractions)
|
||||||
|
setSelectedHighlightId(navigationState.highlightId)
|
||||||
|
|
||||||
|
// Clear the state after handling to avoid re-triggering
|
||||||
|
navigate(location.pathname, { replace: true, state: {} })
|
||||||
|
}
|
||||||
|
}, [navigationState, setIsHighlightsCollapsed, setSelectedHighlightId, navigate, location.pathname])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
bookmarksLoading,
|
bookmarksLoading,
|
||||||
@@ -94,6 +160,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setHighlightsLoading,
|
setHighlightsLoading,
|
||||||
followedPubkeys,
|
followedPubkeys,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
|
lastFetchTime,
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll
|
handleRefreshAll
|
||||||
} = useBookmarksData({
|
} = useBookmarksData({
|
||||||
@@ -101,6 +168,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
accountManager,
|
||||||
naddr,
|
naddr,
|
||||||
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings
|
||||||
@@ -113,7 +181,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setReaderLoading,
|
setReaderLoading,
|
||||||
readerContent,
|
readerContent,
|
||||||
setReaderContent,
|
setReaderContent,
|
||||||
handleSelectUrl
|
handleSelectUrl: baseHandleSelectUrl
|
||||||
} = useContentSelection({
|
} = useContentSelection({
|
||||||
relayPool,
|
relayPool,
|
||||||
settings,
|
settings,
|
||||||
@@ -122,6 +190,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
setCurrentArticle
|
setCurrentArticle
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Wrap handleSelectUrl to close mobile sidebar when selecting content
|
||||||
|
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||||
|
if (isMobile && isSidebarOpen) {
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
baseHandleSelectUrl(url, bookmark)
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
highlightButtonRef,
|
highlightButtonRef,
|
||||||
handleTextSelection,
|
handleTextSelection,
|
||||||
@@ -177,17 +253,27 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
<ThreePaneLayout
|
<ThreePaneLayout
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||||
|
isSidebarOpen={isSidebarOpen}
|
||||||
showSettings={showSettings}
|
showSettings={showSettings}
|
||||||
|
showExplore={showExplore}
|
||||||
|
showMe={showMe}
|
||||||
|
showProfile={showProfile}
|
||||||
|
showSupport={showSupport}
|
||||||
bookmarks={bookmarks}
|
bookmarks={bookmarks}
|
||||||
bookmarksLoading={bookmarksLoading}
|
bookmarksLoading={bookmarksLoading}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
|
lastFetchTime={lastFetchTime}
|
||||||
|
onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
onOpenSettings={() => {
|
onOpenSettings={() => {
|
||||||
navigate('/settings')
|
navigate('/settings')
|
||||||
setIsCollapsed(true)
|
if (isMobile) {
|
||||||
|
toggleSidebar()
|
||||||
|
} else {
|
||||||
|
setIsCollapsed(true)
|
||||||
|
}
|
||||||
setIsHighlightsCollapsed(true)
|
setIsHighlightsCollapsed(true)
|
||||||
}}
|
}}
|
||||||
onRefresh={handleRefreshAll}
|
onRefresh={handleRefreshAll}
|
||||||
@@ -215,6 +301,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
onClearSelection={handleClearSelection}
|
onClearSelection={handleClearSelection}
|
||||||
currentUserPubkey={activeAccount?.pubkey}
|
currentUserPubkey={activeAccount?.pubkey}
|
||||||
followedPubkeys={followedPubkeys}
|
followedPubkeys={followedPubkeys}
|
||||||
|
activeAccount={activeAccount}
|
||||||
|
currentArticle={currentArticle}
|
||||||
highlights={highlights}
|
highlights={highlights}
|
||||||
highlightsLoading={highlightsLoading}
|
highlightsLoading={highlightsLoading}
|
||||||
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
||||||
@@ -225,6 +313,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
highlightButtonRef={highlightButtonRef}
|
highlightButtonRef={highlightButtonRef}
|
||||||
onCreateHighlight={handleCreateHighlight}
|
onCreateHighlight={handleCreateHighlight}
|
||||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||||
|
explore={showExplore ? (
|
||||||
|
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||||
|
) : undefined}
|
||||||
|
me={showMe ? (
|
||||||
|
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||||
|
) : undefined}
|
||||||
|
profile={showProfile && profilePubkey ? (
|
||||||
|
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
|
||||||
|
) : undefined}
|
||||||
|
support={showSupport ? (
|
||||||
|
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||||
|
) : undefined}
|
||||||
toastMessage={toastMessage ?? undefined}
|
toastMessage={toastMessage ?? undefined}
|
||||||
toastType={toastType}
|
toastType={toastType}
|
||||||
onClearToast={clearToast}
|
onClearToast={clearToast}
|
||||||
|
|||||||
41
src/components/CompactButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
|
||||||
|
interface CompactButtonProps {
|
||||||
|
icon?: IconDefinition
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
|
title?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
disabled?: boolean
|
||||||
|
spin?: boolean
|
||||||
|
className?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const CompactButton: React.FC<CompactButtonProps> = ({
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
ariaLabel,
|
||||||
|
disabled = false,
|
||||||
|
spin = false,
|
||||||
|
className = '',
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`compact-button ${className}`.trim()}
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
aria-label={ariaLabel || title}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{icon && <FontAwesomeIcon icon={icon} spin={spin} />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CompactButton
|
||||||
|
|
||||||
56
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmText?: string
|
||||||
|
cancelText?: string
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
variant?: 'danger' | 'warning' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
variant = 'warning'
|
||||||
|
}) => {
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||||
|
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={`confirm-dialog-icon ${variant}`}>
|
||||||
|
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||||
|
</div>
|
||||||
|
<h3 className="confirm-dialog-title">{title}</h3>
|
||||||
|
<p className="confirm-dialog-message">{message}</p>
|
||||||
|
<div className="confirm-dialog-actions">
|
||||||
|
<button
|
||||||
|
className="confirm-dialog-btn cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`confirm-dialog-btn confirm ${variant}`}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmDialog
|
||||||
|
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import ReactPlayer from 'react-player'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import rehypeRaw from 'rehype-raw'
|
||||||
|
import rehypePrism from 'rehype-prism-plus'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
import 'prismjs/themes/prism-tomorrow.css'
|
||||||
|
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { ContentSkeleton } from './Skeletons'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { readingTime } from 'reading-time-estimator'
|
import { readingTime } from 'reading-time-estimator'
|
||||||
import { hexToRgb } from '../utils/colorHelpers'
|
import { hexToRgb } from '../utils/colorHelpers'
|
||||||
@@ -12,6 +23,26 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
|||||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import {
|
||||||
|
createEventReaction,
|
||||||
|
createWebsiteReaction,
|
||||||
|
hasMarkedEventAsRead,
|
||||||
|
hasMarkedWebsiteAsRead
|
||||||
|
} from '../services/reactionService'
|
||||||
|
import AuthorCard from './AuthorCard'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||||
|
import { classifyUrl } from '../utils/helpers'
|
||||||
|
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||||
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
|
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||||
|
import { EventFactory } from 'applesauce-factory'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import {
|
||||||
|
generateArticleIdentifier,
|
||||||
|
loadReadingPosition,
|
||||||
|
saveReadingPosition
|
||||||
|
} from '../services/readingPositionService'
|
||||||
|
|
||||||
interface ContentPanelProps {
|
interface ContentPanelProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -32,9 +63,15 @@ interface ContentPanelProps {
|
|||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
followedPubkeys?: Set<string>
|
followedPubkeys?: Set<string>
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
activeAccount?: IAccount | null
|
||||||
|
currentArticle?: NostrEvent | null
|
||||||
// For highlight creation
|
// For highlight creation
|
||||||
onTextSelection?: (text: string) => void
|
onTextSelection?: (text: string) => void
|
||||||
onClearSelection?: () => void
|
onClearSelection?: () => void
|
||||||
|
// For reading progress indicator positioning
|
||||||
|
isSidebarCollapsed?: boolean
|
||||||
|
isHighlightsCollapsed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||||
@@ -51,15 +88,33 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
highlightStyle = 'marker',
|
highlightStyle = 'marker',
|
||||||
highlightColor = '#ffff00',
|
highlightColor = '#ffff00',
|
||||||
settings,
|
settings,
|
||||||
|
relayPool,
|
||||||
|
activeAccount,
|
||||||
|
currentArticle,
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||||
currentUserPubkey,
|
currentUserPubkey,
|
||||||
followedPubkeys = new Set(),
|
followedPubkeys = new Set(),
|
||||||
onTextSelection,
|
onTextSelection,
|
||||||
onClearSelection
|
onClearSelection,
|
||||||
|
isSidebarCollapsed = false,
|
||||||
|
isHighlightsCollapsed = false
|
||||||
}) => {
|
}) => {
|
||||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
|
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||||
|
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||||
|
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||||
|
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||||
|
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||||
|
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||||
|
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
|
||||||
|
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
||||||
|
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
|
||||||
|
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const externalMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||||
|
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||||
|
|
||||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||||
html,
|
html,
|
||||||
@@ -74,13 +129,192 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
followedPubkeys
|
followedPubkeys
|
||||||
})
|
})
|
||||||
|
|
||||||
const { contentRef, handleMouseUp } = useHighlightInteractions({
|
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
selectedHighlightId,
|
selectedHighlightId,
|
||||||
onTextSelection,
|
onTextSelection,
|
||||||
onClearSelection
|
onClearSelection
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get event store for reading position service
|
||||||
|
const eventStore = Hooks.useEventStore()
|
||||||
|
|
||||||
|
// Reading position tracking - only for text content, not videos
|
||||||
|
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||||
|
|
||||||
|
// Generate article identifier for saving/loading position
|
||||||
|
const articleIdentifier = useMemo(() => {
|
||||||
|
if (!selectedUrl) return null
|
||||||
|
return generateArticleIdentifier(selectedUrl)
|
||||||
|
}, [selectedUrl])
|
||||||
|
|
||||||
|
// Callback to save reading position
|
||||||
|
const handleSavePosition = useCallback(async (position: number) => {
|
||||||
|
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
|
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
|
||||||
|
hasAccount: !!activeAccount,
|
||||||
|
hasRelayPool: !!relayPool,
|
||||||
|
hasEventStore: !!eventStore,
|
||||||
|
hasIdentifier: !!articleIdentifier
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!settings?.syncReadingPosition) {
|
||||||
|
console.log('⏭️ [ContentPanel] Sync disabled in settings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const factory = new EventFactory({ signer: activeAccount })
|
||||||
|
await saveReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
factory,
|
||||||
|
articleIdentifier,
|
||||||
|
{
|
||||||
|
position,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
scrollTop: window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [ContentPanel] Failed to save reading position:', error)
|
||||||
|
}
|
||||||
|
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||||
|
|
||||||
|
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||||
|
enabled: isTextContent,
|
||||||
|
syncEnabled: settings?.syncReadingPosition,
|
||||||
|
onSave: handleSavePosition,
|
||||||
|
onReadingComplete: () => {
|
||||||
|
// Optional: Auto-mark as read when reading is complete
|
||||||
|
if (activeAccount && !isMarkedAsRead) {
|
||||||
|
// Could trigger auto-mark as read here if desired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load saved reading position when article loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
|
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
|
||||||
|
isTextContent,
|
||||||
|
hasAccount: !!activeAccount,
|
||||||
|
hasRelayPool: !!relayPool,
|
||||||
|
hasEventStore: !!eventStore,
|
||||||
|
hasIdentifier: !!articleIdentifier
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!settings?.syncReadingPosition) {
|
||||||
|
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
|
||||||
|
|
||||||
|
const loadPosition = async () => {
|
||||||
|
try {
|
||||||
|
const savedPosition = await loadReadingPosition(
|
||||||
|
relayPool,
|
||||||
|
eventStore,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
articleIdentifier
|
||||||
|
)
|
||||||
|
|
||||||
|
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||||
|
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
|
||||||
|
// Wait for content to be fully rendered before scrolling
|
||||||
|
setTimeout(() => {
|
||||||
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: scrollTop,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||||
|
}, 500) // Give content time to render
|
||||||
|
} else if (savedPosition) {
|
||||||
|
if (savedPosition.position === 1) {
|
||||||
|
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
||||||
|
} else {
|
||||||
|
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPosition()
|
||||||
|
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||||
|
|
||||||
|
// Save position before unmounting or changing article
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveNow) {
|
||||||
|
saveNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [saveNow, selectedUrl])
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
if (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||||
|
setShowExternalMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||||
|
|
||||||
|
// Check available space and position menu upward if needed
|
||||||
|
useEffect(() => {
|
||||||
|
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (value: boolean) => void) => {
|
||||||
|
if (!menuRef.current) return
|
||||||
|
|
||||||
|
const menuWrapper = menuRef.current
|
||||||
|
const menuElement = menuWrapper.querySelector('.article-menu') as HTMLElement
|
||||||
|
if (!menuElement) return
|
||||||
|
|
||||||
|
const rect = menuWrapper.getBoundingClientRect()
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom
|
||||||
|
const menuHeight = menuElement.offsetHeight || 300 // estimate if not rendered yet
|
||||||
|
|
||||||
|
// Open upward if there's not enough space below (with 20px buffer)
|
||||||
|
setOpenUpward(spaceBelow < menuHeight + 20 && rect.top > menuHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showArticleMenu) {
|
||||||
|
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
||||||
|
}
|
||||||
|
if (showVideoMenu) {
|
||||||
|
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||||
|
}
|
||||||
|
if (showExternalMenu) {
|
||||||
|
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
||||||
|
}
|
||||||
|
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||||
|
|
||||||
const readingStats = useMemo(() => {
|
const readingStats = useMemo(() => {
|
||||||
const content = markdown || html || ''
|
const content = markdown || html || ''
|
||||||
if (!content) return null
|
if (!content) return null
|
||||||
@@ -90,6 +324,319 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const hasHighlights = relevantHighlights.length > 0
|
const hasHighlights = relevantHighlights.length > 0
|
||||||
|
|
||||||
|
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||||
|
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||||
|
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||||
|
|
||||||
|
// Track external video duration (in seconds) for display in header
|
||||||
|
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||||
|
// Load YouTube metadata/captions when applicable
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!selectedUrl) return setYtMeta(null)
|
||||||
|
const id = extractYouTubeId(selectedUrl)
|
||||||
|
if (!id) return setYtMeta(null)
|
||||||
|
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||||
|
const data = await getYouTubeMeta(id, locale)
|
||||||
|
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||||
|
} catch {
|
||||||
|
setYtMeta(null)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [selectedUrl])
|
||||||
|
|
||||||
|
const formatDuration = (totalSeconds: number): string => {
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = Math.floor(totalSeconds % 60)
|
||||||
|
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||||
|
const ss = String(seconds).padStart(2, '0')
|
||||||
|
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Get article links for menu
|
||||||
|
const getArticleLinks = () => {
|
||||||
|
if (!currentArticle) return null
|
||||||
|
|
||||||
|
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const relayHints = RELAYS.filter(r =>
|
||||||
|
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||||
|
).slice(0, 3)
|
||||||
|
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: currentArticle.pubkey,
|
||||||
|
identifier: dTag,
|
||||||
|
relays: relayHints
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check for source URL in 'r' tags
|
||||||
|
const sourceUrl = currentArticle.tags.find(t => t[0] === 'r')?.[1]
|
||||||
|
|
||||||
|
return {
|
||||||
|
portal: getNostrUrl(naddr),
|
||||||
|
native: `nostr:${naddr}`,
|
||||||
|
naddr,
|
||||||
|
sourceUrl,
|
||||||
|
borisUrl: `${window.location.origin}/a/${naddr}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleLinks = getArticleLinks()
|
||||||
|
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
setShowArticleMenu(!showArticleMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||||
|
|
||||||
|
const handleOpenPortal = () => {
|
||||||
|
if (articleLinks) {
|
||||||
|
window.open(articleLinks.portal, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenNative = () => {
|
||||||
|
if (articleLinks) {
|
||||||
|
window.location.href = articleLinks.native
|
||||||
|
}
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareBoris = async () => {
|
||||||
|
try {
|
||||||
|
if (!articleLinks) return
|
||||||
|
|
||||||
|
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||||
|
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||||
|
title: title || 'Article',
|
||||||
|
url: articleLinks.borisUrl
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(articleLinks.borisUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareOriginal = async () => {
|
||||||
|
try {
|
||||||
|
if (!articleLinks?.sourceUrl) return
|
||||||
|
|
||||||
|
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||||
|
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||||
|
title: title || 'Article',
|
||||||
|
url: articleLinks.sourceUrl
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(articleLinks.sourceUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyBoris = async () => {
|
||||||
|
try {
|
||||||
|
if (!articleLinks) return
|
||||||
|
await navigator.clipboard.writeText(articleLinks.borisUrl)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Copy failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyOriginal = async () => {
|
||||||
|
try {
|
||||||
|
if (!articleLinks?.sourceUrl) return
|
||||||
|
await navigator.clipboard.writeText(articleLinks.sourceUrl)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Copy failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenSearch = () => {
|
||||||
|
if (articleLinks) {
|
||||||
|
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
setShowArticleMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video actions
|
||||||
|
const handleOpenVideoExternal = () => {
|
||||||
|
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenVideoNative = () => {
|
||||||
|
if (!selectedUrl) return
|
||||||
|
const native = buildNativeVideoUrl(selectedUrl)
|
||||||
|
if (native) {
|
||||||
|
window.location.href = native
|
||||||
|
} else {
|
||||||
|
window.location.href = selectedUrl
|
||||||
|
}
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyVideoUrl = async () => {
|
||||||
|
try {
|
||||||
|
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Clipboard copy failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareVideoUrl = async () => {
|
||||||
|
try {
|
||||||
|
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||||
|
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
|
||||||
|
} else if (selectedUrl) {
|
||||||
|
await navigator.clipboard.writeText(selectedUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowVideoMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// External article actions
|
||||||
|
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||||
|
|
||||||
|
const handleOpenExternalUrl = () => {
|
||||||
|
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowExternalMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyExternalUrl = async () => {
|
||||||
|
try {
|
||||||
|
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Clipboard copy failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowExternalMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShareExternalUrl = async () => {
|
||||||
|
try {
|
||||||
|
if (!selectedUrl) return
|
||||||
|
const borisUrl = `${window.location.origin}/r/${encodeURIComponent(selectedUrl)}`
|
||||||
|
|
||||||
|
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||||
|
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||||
|
title: title || 'Article',
|
||||||
|
url: borisUrl
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(borisUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Share failed', e)
|
||||||
|
} finally {
|
||||||
|
setShowExternalMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchExternalUrl = () => {
|
||||||
|
if (selectedUrl) {
|
||||||
|
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
setShowExternalMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if article is already marked as read when URL/article changes
|
||||||
|
useEffect(() => {
|
||||||
|
const checkReadStatus = async () => {
|
||||||
|
if (!activeAccount || !relayPool || !selectedUrl) {
|
||||||
|
setIsMarkedAsRead(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCheckingReadStatus(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let hasRead = false
|
||||||
|
if (isNostrArticle && currentArticle) {
|
||||||
|
hasRead = await hasMarkedEventAsRead(
|
||||||
|
currentArticle.id,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
hasRead = await hasMarkedWebsiteAsRead(
|
||||||
|
selectedUrl,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setIsMarkedAsRead(hasRead)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check read status:', error)
|
||||||
|
} finally {
|
||||||
|
setIsCheckingReadStatus(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkReadStatus()
|
||||||
|
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||||
|
|
||||||
|
const handleMarkAsRead = () => {
|
||||||
|
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantly update UI with checkmark animation
|
||||||
|
setIsMarkedAsRead(true)
|
||||||
|
setShowCheckAnimation(true)
|
||||||
|
|
||||||
|
// Reset animation after it completes
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowCheckAnimation(false)
|
||||||
|
}, 600)
|
||||||
|
|
||||||
|
// Fire-and-forget: publish in background without blocking UI
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
if (isNostrArticle && currentArticle) {
|
||||||
|
await createEventReaction(
|
||||||
|
currentArticle.id,
|
||||||
|
currentArticle.pubkey,
|
||||||
|
currentArticle.kind,
|
||||||
|
activeAccount,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
console.log('✅ Marked nostr article as read')
|
||||||
|
} else if (selectedUrl) {
|
||||||
|
await createWebsiteReaction(
|
||||||
|
selectedUrl,
|
||||||
|
activeAccount,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
console.log('✅ Marked website as read')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark as read:', error)
|
||||||
|
// Revert UI state on error
|
||||||
|
setIsMarkedAsRead(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedUrl) {
|
if (!selectedUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="reader empty">
|
<div className="reader empty">
|
||||||
@@ -100,10 +647,8 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="reader loading">
|
<div className="reader" aria-busy="true">
|
||||||
<div className="loading-spinner">
|
<ContentSkeleton />
|
||||||
<FontAwesomeIcon icon={faSpinner} spin />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -111,56 +656,314 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
const highlightRgb = hexToRgb(highlightColor)
|
const highlightRgb = hexToRgb(highlightColor)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
<>
|
||||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
{/* Reading Progress Indicator - Outside reader for fixed positioning */}
|
||||||
|
{isTextContent && (
|
||||||
|
<ReadingProgressIndicator
|
||||||
|
progress={progressPercentage}
|
||||||
|
isComplete={isReadingComplete}
|
||||||
|
showPercentage={true}
|
||||||
|
isSidebarCollapsed={isSidebarCollapsed}
|
||||||
|
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||||
|
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||||
{markdown && (
|
{markdown && (
|
||||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown
|
||||||
{markdown}
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||||
|
components={{
|
||||||
|
img: ({ src, alt, ...props }) => (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{processedMarkdown || markdown}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ReaderHeader
|
<ReaderHeader
|
||||||
title={title}
|
title={ytMeta?.title || title}
|
||||||
image={image}
|
image={image}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
published={published}
|
published={published}
|
||||||
readingTimeText={readingStats ? readingStats.text : null}
|
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
||||||
hasHighlights={hasHighlights}
|
hasHighlights={hasHighlights}
|
||||||
highlightCount={relevantHighlights.length}
|
highlightCount={relevantHighlights.length}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
highlights={relevantHighlights}
|
||||||
|
highlightVisibility={highlightVisibility}
|
||||||
/>
|
/>
|
||||||
{markdown || html ? (
|
{isExternalVideo ? (
|
||||||
markdown ? (
|
<>
|
||||||
renderedMarkdownHtml && finalHtml ? (
|
<div className="reader-video">
|
||||||
<div
|
<ReactPlayer
|
||||||
ref={contentRef}
|
url={selectedUrl as string}
|
||||||
className="reader-markdown"
|
controls
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
width="100%"
|
||||||
onMouseUp={handleMouseUp}
|
height="auto"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: '16/9'
|
||||||
|
}}
|
||||||
|
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<div className="reader-markdown">
|
{ytMeta?.description && (
|
||||||
<div className="loading-spinner">
|
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
{ytMeta.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ytMeta?.transcript && (
|
||||||
|
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||||
|
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||||
|
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||||
|
{ytMeta.transcript}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
) : (
|
<div className="article-menu-container">
|
||||||
<div
|
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||||
ref={contentRef}
|
<button
|
||||||
className="reader-html"
|
className="article-menu-btn"
|
||||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
onClick={toggleVideoMenu}
|
||||||
onMouseUp={handleMouseUp}
|
title="More options"
|
||||||
/>
|
>
|
||||||
)
|
<FontAwesomeIcon icon={faEllipsisH} />
|
||||||
|
</button>
|
||||||
|
{showVideoMenu && (
|
||||||
|
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||||
|
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open Link</span>
|
||||||
|
</button>
|
||||||
|
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} />
|
||||||
|
<span>Open in Native App</span>
|
||||||
|
</button>
|
||||||
|
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
<span>Copy URL</span>
|
||||||
|
</button>
|
||||||
|
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||||
|
<FontAwesomeIcon icon={faShare} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activeAccount && (
|
||||||
|
<div className="mark-as-read-container">
|
||||||
|
<button
|
||||||
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||||
|
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||||
|
spin={isCheckingReadStatus}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : markdown || html ? (
|
||||||
|
<>
|
||||||
|
{markdown ? (
|
||||||
|
renderedMarkdownHtml && finalHtml ? (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="reader-markdown"
|
||||||
|
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||||
|
onMouseUp={handleSelectionEnd}
|
||||||
|
onTouchEnd={handleSelectionEnd}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="reader-markdown">
|
||||||
|
<div className="loading-spinner">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className="reader-html"
|
||||||
|
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||||
|
onMouseUp={handleSelectionEnd}
|
||||||
|
onTouchEnd={handleSelectionEnd}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Article menu for external URLs */}
|
||||||
|
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
||||||
|
<div className="article-menu-container">
|
||||||
|
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||||
|
<button
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={toggleExternalMenu}
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsisH} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showExternalMenu && (
|
||||||
|
<div className={`article-menu ${externalMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleShareExternalUrl}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faShare} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleCopyExternalUrl}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
<span>Copy URL</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleOpenExternalUrl}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open Original</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleSearchExternalUrl}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSearch} />
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Article menu for nostr-native articles */}
|
||||||
|
{isNostrArticle && currentArticle && articleLinks && (
|
||||||
|
<div className="article-menu-container">
|
||||||
|
<div className="article-menu-wrapper" ref={articleMenuRef}>
|
||||||
|
<button
|
||||||
|
className="article-menu-btn"
|
||||||
|
onClick={handleMenuToggle}
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsisH} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showArticleMenu && (
|
||||||
|
<div className={`article-menu ${articleMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleShareBoris}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faShare} />
|
||||||
|
<span>Share</span>
|
||||||
|
</button>
|
||||||
|
{articleLinks.sourceUrl && (
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleShareOriginal}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faShare} />
|
||||||
|
<span>Share Original</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleCopyBoris}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
<span>Copy Link</span>
|
||||||
|
</button>
|
||||||
|
{articleLinks.sourceUrl && (
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleCopyOriginal}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
<span>Copy Original</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleOpenSearch}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSearch} />
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open with njump</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="article-menu-item"
|
||||||
|
onClick={handleOpenNative}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} />
|
||||||
|
<span>Open with Native App</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mark as Read button */}
|
||||||
|
{activeAccount && (
|
||||||
|
<div className="mark-as-read-container">
|
||||||
|
<button
|
||||||
|
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||||
|
onClick={handleMarkAsRead}
|
||||||
|
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||||
|
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||||
|
spin={isCheckingReadStatus}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Author info card for nostr-native articles */}
|
||||||
|
{isNostrArticle && currentArticle && (
|
||||||
|
<div className="author-card-container">
|
||||||
|
<AuthorCard authorPubkey={currentArticle.pubkey} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="reader empty">
|
<div className="reader empty">
|
||||||
<p>No readable content found for this URL.</p>
|
<p>No readable content found for this URL.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
395
src/components/Debug.tsx
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Accounts } from 'applesauce-accounts'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
||||||
|
import { DebugBus, type DebugLogEntry } from '../utils/debugBus'
|
||||||
|
import VersionFooter from './VersionFooter'
|
||||||
|
|
||||||
|
const defaultPayload = 'The quick brown fox jumps over the lazy dog.'
|
||||||
|
|
||||||
|
const Debug: React.FC = () => {
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const [payload, setPayload] = useState<string>(defaultPayload)
|
||||||
|
const [cipher44, setCipher44] = useState<string>('')
|
||||||
|
const [cipher04, setCipher04] = useState<string>('')
|
||||||
|
const [plain44, setPlain44] = useState<string>('')
|
||||||
|
const [plain04, setPlain04] = useState<string>('')
|
||||||
|
const [tEncrypt44, setTEncrypt44] = useState<number | null>(null)
|
||||||
|
const [tEncrypt04, setTEncrypt04] = useState<number | null>(null)
|
||||||
|
const [tDecrypt44, setTDecrypt44] = useState<number | null>(null)
|
||||||
|
const [tDecrypt04, setTDecrypt04] = useState<number | null>(null)
|
||||||
|
const [logs, setLogs] = useState<DebugLogEntry[]>(DebugBus.snapshot())
|
||||||
|
const [debugEnabled, setDebugEnabled] = useState<boolean>(() => localStorage.getItem('debug') === '*')
|
||||||
|
|
||||||
|
// Bunker login state
|
||||||
|
const [bunkerUri, setBunkerUri] = useState<string>('')
|
||||||
|
const [isBunkerLoading, setIsBunkerLoading] = useState<boolean>(false)
|
||||||
|
const [bunkerError, setBunkerError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Live timing state
|
||||||
|
const [liveTiming, setLiveTiming] = useState<{
|
||||||
|
nip44?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
|
nip04?: { type: 'encrypt' | 'decrypt'; startTime: number }
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300)))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Live timer effect - triggers re-renders for live timing updates
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
// Force re-render to update live timing display
|
||||||
|
setLiveTiming(prev => prev)
|
||||||
|
}, 16) // ~60fps for smooth updates
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const signer = useMemo(() => (activeAccount as unknown as { signer?: unknown })?.signer, [activeAccount])
|
||||||
|
const pubkey = (activeAccount as unknown as { pubkey?: string })?.pubkey
|
||||||
|
|
||||||
|
const hasNip04 = typeof (signer as { nip04?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip04?.encrypt === 'function'
|
||||||
|
const hasNip44 = typeof (signer as { nip44?: { encrypt?: unknown; decrypt?: unknown } } | undefined)?.nip44?.encrypt === 'function'
|
||||||
|
|
||||||
|
const doEncrypt = async (mode: 'nip44' | 'nip04') => {
|
||||||
|
if (!signer || !pubkey) return
|
||||||
|
try {
|
||||||
|
const api = (signer as { [key: string]: { encrypt: (pubkey: string, message: string) => Promise<string> } })[mode]
|
||||||
|
DebugBus.info('debug', `encrypt start ${mode}`, { pubkey, len: payload.length })
|
||||||
|
|
||||||
|
// Start live timing
|
||||||
|
const start = performance.now()
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: { type: 'encrypt', startTime: start } }))
|
||||||
|
|
||||||
|
const cipher = await api.encrypt(pubkey, payload)
|
||||||
|
const ms = Math.round(performance.now() - start)
|
||||||
|
|
||||||
|
// Stop live timing
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `encrypt done ${mode}`, { len: typeof cipher === 'string' ? cipher.length : -1, ms })
|
||||||
|
if (mode === 'nip44') setCipher44(cipher)
|
||||||
|
else setCipher04(cipher)
|
||||||
|
if (mode === 'nip44') setTEncrypt44(ms)
|
||||||
|
else setTEncrypt04(ms)
|
||||||
|
} catch (e) {
|
||||||
|
// Stop live timing on error
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
DebugBus.error('debug', `encrypt error ${mode}`, e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDecrypt = async (mode: 'nip44' | 'nip04') => {
|
||||||
|
if (!signer || !pubkey) return
|
||||||
|
try {
|
||||||
|
const api = (signer as { [key: string]: { decrypt: (pubkey: string, ciphertext: string) => Promise<string> } })[mode]
|
||||||
|
const cipher = mode === 'nip44' ? cipher44 : cipher04
|
||||||
|
if (!cipher) {
|
||||||
|
DebugBus.warn('debug', `no cipher to decrypt for ${mode}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DebugBus.info('debug', `decrypt start ${mode}`, { len: cipher.length })
|
||||||
|
|
||||||
|
// Start live timing
|
||||||
|
const start = performance.now()
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: { type: 'decrypt', startTime: start } }))
|
||||||
|
|
||||||
|
const plain = await api.decrypt(pubkey, cipher)
|
||||||
|
const ms = Math.round(performance.now() - start)
|
||||||
|
|
||||||
|
// Stop live timing
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
|
||||||
|
DebugBus.info('debug', `decrypt done ${mode}`, { len: typeof plain === 'string' ? plain.length : -1, ms })
|
||||||
|
if (mode === 'nip44') setPlain44(String(plain))
|
||||||
|
else setPlain04(String(plain))
|
||||||
|
if (mode === 'nip44') setTDecrypt44(ms)
|
||||||
|
else setTDecrypt04(ms)
|
||||||
|
} catch (e) {
|
||||||
|
// Stop live timing on error
|
||||||
|
setLiveTiming(prev => ({ ...prev, [mode]: undefined }))
|
||||||
|
DebugBus.error('debug', `decrypt error ${mode}`, e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDebug = () => {
|
||||||
|
const next = !debugEnabled
|
||||||
|
setDebugEnabled(next)
|
||||||
|
if (next) localStorage.setItem('debug', '*')
|
||||||
|
else localStorage.removeItem('debug')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBunkerLogin = async () => {
|
||||||
|
if (!bunkerUri.trim()) {
|
||||||
|
setBunkerError('Please enter a bunker URI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bunkerUri.startsWith('bunker://')) {
|
||||||
|
setBunkerError('Invalid bunker URI. Must start with bunker://')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsBunkerLoading(true)
|
||||||
|
setBunkerError(null)
|
||||||
|
|
||||||
|
// Create signer from bunker URI with default permissions
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
|
||||||
|
|
||||||
|
// Get pubkey from signer
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
|
||||||
|
// Create account from signer
|
||||||
|
const account = new Accounts.NostrConnectAccount(pubkey, signer)
|
||||||
|
|
||||||
|
// Add to account manager and set active
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
|
||||||
|
// Clear input on success
|
||||||
|
setBunkerUri('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bunker] Login failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
|
||||||
|
|
||||||
|
// Check for permission-related errors
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
setBunkerError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
||||||
|
} else {
|
||||||
|
setBunkerError(errorMessage)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsBunkerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeBox = ({ value }: { value: string }) => (
|
||||||
|
<div className="h-20 overflow-y-auto font-mono text-xs leading-relaxed p-2 bg-gray-100 dark:bg-gray-800 rounded whitespace-pre-wrap break-all">
|
||||||
|
{value || '—'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const getLiveTiming = (mode: 'nip44' | 'nip04', type: 'encrypt' | 'decrypt') => {
|
||||||
|
const timing = liveTiming[mode]
|
||||||
|
if (timing && timing.type === type) {
|
||||||
|
const elapsed = Math.round(performance.now() - timing.startTime)
|
||||||
|
return elapsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stat = ({ label, value, mode, type }: {
|
||||||
|
label: string;
|
||||||
|
value?: string | number | null;
|
||||||
|
mode?: 'nip44' | 'nip04';
|
||||||
|
type?: 'encrypt' | 'decrypt';
|
||||||
|
}) => {
|
||||||
|
const liveValue = mode && type ? getLiveTiming(mode, type) : null
|
||||||
|
const isLive = !!liveValue
|
||||||
|
|
||||||
|
let displayValue: string
|
||||||
|
if (isLive) {
|
||||||
|
displayValue = ''
|
||||||
|
} else if (value !== null && value !== undefined) {
|
||||||
|
displayValue = `${value}ms`
|
||||||
|
} else {
|
||||||
|
displayValue = '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="badge" style={{ marginRight: 8 }}>
|
||||||
|
<FontAwesomeIcon icon={faClock} style={{ marginRight: 4, fontSize: '0.8em' }} />
|
||||||
|
{label}: {isLive ? (
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="animate-spin" style={{ fontSize: '0.8em' }} />
|
||||||
|
) : (
|
||||||
|
displayValue
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-view">
|
||||||
|
<div className="settings-header">
|
||||||
|
<h2>Debug</h2>
|
||||||
|
<div className="settings-header-actions">
|
||||||
|
<span className="opacity-70">Active pubkey:</span> <code className="text-sm">{pubkey || 'none'}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-content">
|
||||||
|
|
||||||
|
{/* Bunker Login Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Bunker Connection</h3>
|
||||||
|
{!activeAccount ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Connect to your bunker (Nostr Connect signer) to enable encryption/decryption testing</div>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="bunker://..."
|
||||||
|
value={bunkerUri}
|
||||||
|
onChange={(e) => setBunkerUri(e.target.value)}
|
||||||
|
disabled={isBunkerLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleBunkerLogin}
|
||||||
|
disabled={isBunkerLoading || !bunkerUri.trim()}
|
||||||
|
>
|
||||||
|
{isBunkerLoading ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bunkerError && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 mb-2">{bunkerError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm opacity-70">Connected to bunker</div>
|
||||||
|
<div className="text-sm font-mono">{pubkey}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
background: 'rgb(220 38 38)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgb(220 38 38)',
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgb(185 28 28)'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'rgb(220 38 38)'}
|
||||||
|
onClick={() => accountManager.removeAccount(activeAccount)}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encryption Tools Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Encryption Tools</h3>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">Payload</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea w-full bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
|
||||||
|
value={payload}
|
||||||
|
onChange={e => setPayload(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-3 justify-end">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setPayload(defaultPayload)}>Reset</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => { setCipher44(''); setCipher04(''); setPlain44(''); setPlain04(''); setTEncrypt44(null); setTEncrypt04(null); setTDecrypt44(null); setTDecrypt04(null) }}>Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid" style={{ gap: 12, gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)' }}>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-44</label>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button className="btn btn-primary" onClick={() => doEncrypt('nip44')} disabled={!hasNip44}>Encrypt</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => doDecrypt('nip44')} disabled={!cipher44}>Decrypt</button>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm opacity-70 mb-2">Encrypted:</label>
|
||||||
|
<CodeBox value={cipher44} />
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="text-sm opacity-70">Plain:</span>
|
||||||
|
<CodeBox value={plain44} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-04</label>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button className="btn btn-primary" onClick={() => doEncrypt('nip04')} disabled={!hasNip04}>Encrypt</button>
|
||||||
|
<button className="btn btn-secondary" onClick={() => doDecrypt('nip04')} disabled={!cipher04}>Decrypt</button>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm opacity-70 mb-2">Encrypted:</label>
|
||||||
|
<CodeBox value={cipher04} />
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="text-sm opacity-70">Plain:</span>
|
||||||
|
<CodeBox value={plain04} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Performance Timing Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Performance Timing</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Encryption and decryption operation durations</div>
|
||||||
|
<div className="grid" style={{ gap: 12, gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)' }}>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-44</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Stat label="enc" value={tEncrypt44} mode="nip44" type="encrypt" />
|
||||||
|
<Stat label="dec" value={tDecrypt44} mode="nip44" type="decrypt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="setting-group">
|
||||||
|
<label className="setting-label">NIP-04</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Stat label="enc" value={tEncrypt04} mode="nip04" type="encrypt" />
|
||||||
|
<Stat label="dec" value={tDecrypt04} mode="nip04" type="decrypt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Debug Logs Section */}
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Debug Logs</h3>
|
||||||
|
<div className="text-sm opacity-70 mb-3">Recent bunker logs:</div>
|
||||||
|
<div className="max-h-192 overflow-y-auto font-mono text-xs leading-relaxed">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-sm opacity-50 italic">No logs yet</div>
|
||||||
|
) : (
|
||||||
|
logs.slice(-200).map((l, i) => (
|
||||||
|
<div key={i} className="mb-1 p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||||
|
<span className="opacity-70">[{new Date(l.ts).toLocaleTimeString()}]</span> <span className="font-semibold">{l.level.toUpperCase()}</span> {l.source}: {l.message}
|
||||||
|
{l.data !== undefined && (
|
||||||
|
<span className="opacity-70"> — {typeof l.data === 'string' ? l.data : JSON.stringify(l.data)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-end mb-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={debugEnabled}
|
||||||
|
onChange={toggleDebug}
|
||||||
|
className="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Show all applesauce debug logs</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setLogs([])}>Clear logs</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VersionFooter />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Debug
|
||||||
431
src/components/Explore.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import IconButton from './IconButton'
|
||||||
|
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { fetchContacts } from '../services/contactService'
|
||||||
|
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||||
|
import { fetchHighlightsFromAuthors } from '../services/highlightService'
|
||||||
|
import { fetchProfiles } from '../services/profileService'
|
||||||
|
import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import BlogPostCard from './BlogPostCard'
|
||||||
|
import { HighlightItem } from './HighlightItem'
|
||||||
|
import { getCachedPosts, upsertCachedPost, setCachedPosts, getCachedHighlights, upsertCachedHighlight, setCachedHighlights } from '../services/exploreCache'
|
||||||
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
|
import { classifyHighlights } from '../utils/highlightClassification'
|
||||||
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
|
|
||||||
|
interface ExploreProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
settings?: UserSettings
|
||||||
|
activeTab?: TabType
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'writings' | 'highlights'
|
||||||
|
|
||||||
|
const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, activeTab: propActiveTab }) => {
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||||
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
|
||||||
|
// Visibility filters (defaults from settings, or friends only)
|
||||||
|
const [visibility, setVisibility] = useState<HighlightVisibility>({
|
||||||
|
nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false,
|
||||||
|
friends: settings?.defaultHighlightVisibilityFriends ?? true,
|
||||||
|
mine: settings?.defaultHighlightVisibilityMine ?? false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update local state when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (propActiveTab) {
|
||||||
|
setActiveTab(propActiveTab)
|
||||||
|
}
|
||||||
|
}, [propActiveTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!activeAccount) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// show spinner but keep existing data
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Seed from in-memory cache if available to avoid empty flash
|
||||||
|
// Use functional update to check current state without creating dependency
|
||||||
|
const cachedPosts = getCachedPosts(activeAccount.pubkey)
|
||||||
|
if (cachedPosts && cachedPosts.length > 0) {
|
||||||
|
setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev)
|
||||||
|
}
|
||||||
|
const cachedHighlights = getCachedHighlights(activeAccount.pubkey)
|
||||||
|
if (cachedHighlights && cachedHighlights.length > 0) {
|
||||||
|
setHighlights(prev => prev.length === 0 ? cachedHighlights : prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the user's contacts (friends)
|
||||||
|
const contacts = await fetchContacts(
|
||||||
|
relayPool,
|
||||||
|
activeAccount.pubkey,
|
||||||
|
(partial) => {
|
||||||
|
// Store followed pubkeys for highlight classification
|
||||||
|
setFollowedPubkeys(partial)
|
||||||
|
// When local contacts are available, kick off early fetch
|
||||||
|
if (partial.size > 0) {
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const partialArray = Array.from(partial)
|
||||||
|
|
||||||
|
// Fetch blog posts
|
||||||
|
fetchBlogPostsFromAuthors(
|
||||||
|
relayPool,
|
||||||
|
partialArray,
|
||||||
|
relayUrls,
|
||||||
|
(post) => {
|
||||||
|
setBlogPosts((prev) => {
|
||||||
|
const exists = prev.some(p => p.event.id === post.event.id)
|
||||||
|
if (exists) return prev
|
||||||
|
const next = [...prev, post]
|
||||||
|
return next.sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||||
|
}
|
||||||
|
).then((all) => {
|
||||||
|
setBlogPosts((prev) => {
|
||||||
|
const byId = new Map(prev.map(p => [p.event.id, p]))
|
||||||
|
for (const post of all) byId.set(post.event.id, post)
|
||||||
|
const merged = Array.from(byId.values()).sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
setCachedPosts(activeAccount.pubkey, merged)
|
||||||
|
return merged
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch highlights
|
||||||
|
fetchHighlightsFromAuthors(
|
||||||
|
relayPool,
|
||||||
|
partialArray,
|
||||||
|
(highlight) => {
|
||||||
|
setHighlights((prev) => {
|
||||||
|
const exists = prev.some(h => h.id === highlight.id)
|
||||||
|
if (exists) return prev
|
||||||
|
const next = [...prev, highlight]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight))
|
||||||
|
}
|
||||||
|
).then((all) => {
|
||||||
|
setHighlights((prev) => {
|
||||||
|
const byId = new Map(prev.map(h => [h.id, h]))
|
||||||
|
for (const highlight of all) byId.set(highlight.id, highlight)
|
||||||
|
const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at)
|
||||||
|
setCachedHighlights(activeAccount.pubkey, merged)
|
||||||
|
return merged
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Always proceed to load nostrverse content even if no contacts
|
||||||
|
// (removed blocking error for empty contacts)
|
||||||
|
|
||||||
|
// Store final followed pubkeys
|
||||||
|
setFollowedPubkeys(contacts)
|
||||||
|
|
||||||
|
// Fetch both friends content and nostrverse content in parallel
|
||||||
|
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||||
|
const contactsArray = Array.from(contacts)
|
||||||
|
const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([
|
||||||
|
fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls),
|
||||||
|
fetchHighlightsFromAuthors(relayPool, contactsArray),
|
||||||
|
fetchNostrverseBlogPosts(relayPool, relayUrls, 50),
|
||||||
|
fetchNostrverseHighlights(relayPool, 100)
|
||||||
|
])
|
||||||
|
|
||||||
|
// Merge and deduplicate all posts
|
||||||
|
const allPosts = [...friendsPosts, ...nostrversePosts]
|
||||||
|
const postsByKey = new Map<string, BlogPostPreview>()
|
||||||
|
for (const post of allPosts) {
|
||||||
|
const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}`
|
||||||
|
const existing = postsByKey.get(key)
|
||||||
|
if (!existing || post.event.created_at > existing.event.created_at) {
|
||||||
|
postsByKey.set(key, post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => {
|
||||||
|
const timeA = a.published || a.event.created_at
|
||||||
|
const timeB = b.published || b.event.created_at
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merge and deduplicate all highlights
|
||||||
|
const allHighlights = [...friendsHighlights, ...nostriverseHighlights]
|
||||||
|
const highlightsByKey = new Map<string, Highlight>()
|
||||||
|
for (const highlight of allHighlights) {
|
||||||
|
highlightsByKey.set(highlight.id, highlight)
|
||||||
|
}
|
||||||
|
const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at)
|
||||||
|
|
||||||
|
// Fetch profiles for all blog post authors to cache them
|
||||||
|
if (uniquePosts.length > 0) {
|
||||||
|
const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author)))
|
||||||
|
fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => {
|
||||||
|
console.error('Failed to fetch author profiles:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// No blocking errors - let empty states handle messaging
|
||||||
|
setBlogPosts(uniquePosts)
|
||||||
|
setCachedPosts(activeAccount.pubkey, uniquePosts)
|
||||||
|
|
||||||
|
setHighlights(uniqueHighlights)
|
||||||
|
setCachedHighlights(activeAccount.pubkey, uniqueHighlights)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load data:', err)
|
||||||
|
// No blocking error - user can pull-to-refresh
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
}, [relayPool, activeAccount, refreshTrigger, eventStore, settings])
|
||||||
|
|
||||||
|
// Pull-to-refresh
|
||||||
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
setRefreshTrigger(prev => prev + 1)
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !activeAccount
|
||||||
|
})
|
||||||
|
|
||||||
|
const getPostUrl = (post: BlogPostPreview) => {
|
||||||
|
// Get the d-tag identifier
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
|
||||||
|
// Create naddr
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
|
||||||
|
return `/a/${naddr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Classify highlights with levels based on user context and apply visibility filters
|
||||||
|
const classifiedHighlights = useMemo(() => {
|
||||||
|
const classified = classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||||
|
return classified.filter(h => {
|
||||||
|
if (h.level === 'mine' && !visibility.mine) return false
|
||||||
|
if (h.level === 'friends' && !visibility.friends) return false
|
||||||
|
if (h.level === 'nostrverse' && !visibility.nostrverse) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
||||||
|
|
||||||
|
// Filter blog posts by future dates and visibility, and add level classification
|
||||||
|
const filteredBlogPosts = useMemo(() => {
|
||||||
|
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||||
|
return blogPosts
|
||||||
|
.filter(post => {
|
||||||
|
// Filter out future dates
|
||||||
|
const publishedTime = post.published || post.event.created_at
|
||||||
|
if (publishedTime > maxFutureTime) return false
|
||||||
|
|
||||||
|
// Apply visibility filters
|
||||||
|
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||||
|
const isFriend = followedPubkeys.has(post.author)
|
||||||
|
const isNostrverse = !isMine && !isFriend
|
||||||
|
|
||||||
|
if (isMine && !visibility.mine) return false
|
||||||
|
if (isFriend && !visibility.friends) return false
|
||||||
|
if (isNostrverse && !visibility.nostrverse) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(post => {
|
||||||
|
// Add level classification
|
||||||
|
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||||
|
const isFriend = followedPubkeys.has(post.author)
|
||||||
|
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||||
|
return { ...post, level }
|
||||||
|
})
|
||||||
|
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'writings':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return filteredBlogPosts.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{filteredBlogPosts.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
level={post.level}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'highlights':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<HighlightSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return classifiedHighlights.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{classifiedHighlights.map((highlight) => (
|
||||||
|
<HighlightItem
|
||||||
|
key={highlight.id}
|
||||||
|
highlight={highlight}
|
||||||
|
relayPool={relayPool}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show content progressively - no blocking error screens
|
||||||
|
const hasData = highlights.length > 0 || blogPosts.length > 0
|
||||||
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="explore-container">
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
|
<div className="explore-header">
|
||||||
|
<h1>
|
||||||
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
|
Explore
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Visibility filters */}
|
||||||
|
<div className="highlight-level-toggles" style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||||
|
<IconButton
|
||||||
|
icon={faArrowsRotate}
|
||||||
|
onClick={() => setRefreshTrigger(prev => prev + 1)}
|
||||||
|
title="Refresh content"
|
||||||
|
ariaLabel="Refresh content"
|
||||||
|
variant="ghost"
|
||||||
|
spin={loading || isRefreshing}
|
||||||
|
disabled={loading || isRefreshing}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
|
onClick={() => setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })}
|
||||||
|
title="Toggle nostrverse content"
|
||||||
|
ariaLabel="Toggle nostrverse content"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: visibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||||
|
opacity: visibility.nostrverse ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
|
onClick={() => setVisibility({ ...visibility, friends: !visibility.friends })}
|
||||||
|
title={activeAccount ? "Toggle friends content" : "Login to see friends content"}
|
||||||
|
ariaLabel="Toggle friends content"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!activeAccount}
|
||||||
|
style={{
|
||||||
|
color: visibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
|
opacity: visibility.friends ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
|
onClick={() => setVisibility({ ...visibility, mine: !visibility.mine })}
|
||||||
|
title={activeAccount ? "Toggle my content" : "Login to see your content"}
|
||||||
|
ariaLabel="Toggle my content"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!activeAccount}
|
||||||
|
style={{
|
||||||
|
color: visibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
|
opacity: visibility.mine ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="me-tabs">
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||||
|
data-tab="highlights"
|
||||||
|
onClick={() => navigate('/explore')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span className="tab-label">Highlights</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||||
|
data-tab="writings"
|
||||||
|
onClick={() => navigate('/explore/writings')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
|
<span className="tab-label">Writings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderTabContent()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Explore
|
||||||
|
|
||||||
104
src/components/HighlightCitation.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { fetchArticleTitle } from '../services/articleTitleResolver'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
|
||||||
|
interface HighlightCitationProps {
|
||||||
|
highlight: Highlight
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HighlightCitation: React.FC<HighlightCitationProps> = ({
|
||||||
|
highlight,
|
||||||
|
relayPool
|
||||||
|
}) => {
|
||||||
|
const [articleTitle, setArticleTitle] = useState<string>()
|
||||||
|
|
||||||
|
// Extract author pubkey from p tag directly
|
||||||
|
const authorPubkey = useMemo(() => {
|
||||||
|
// First try the extracted author from highlight.author
|
||||||
|
if (highlight.author) {
|
||||||
|
return highlight.author
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract directly from p tag
|
||||||
|
const pTag = highlight.tags.find(t => t[0] === 'p')
|
||||||
|
if (pTag && pTag[1]) {
|
||||||
|
console.log('📝 Found author from p tag:', pTag[1])
|
||||||
|
return pTag[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}, [highlight.author, highlight.tags])
|
||||||
|
|
||||||
|
const authorProfile = useEventModel(Models.ProfileModel, authorPubkey ? [authorPubkey] : null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlight.eventReference || !relayPool) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTitle = async () => {
|
||||||
|
try {
|
||||||
|
if (!highlight.eventReference) return
|
||||||
|
|
||||||
|
// Convert eventReference to naddr if needed
|
||||||
|
let naddr: string
|
||||||
|
if (highlight.eventReference.includes(':')) {
|
||||||
|
const parts = highlight.eventReference.split(':')
|
||||||
|
const kind = parseInt(parts[0])
|
||||||
|
const pubkey = parts[1]
|
||||||
|
const identifier = parts[2] || ''
|
||||||
|
|
||||||
|
naddr = nip19.naddrEncode({
|
||||||
|
kind,
|
||||||
|
pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
naddr = highlight.eventReference
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = await fetchArticleTitle(relayPool, naddr)
|
||||||
|
if (title) {
|
||||||
|
setArticleTitle(title)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load article title:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTitle()
|
||||||
|
}, [highlight.eventReference, relayPool])
|
||||||
|
|
||||||
|
const authorName = authorProfile?.name || authorProfile?.display_name
|
||||||
|
|
||||||
|
// For nostr-native content with article reference
|
||||||
|
if (highlight.eventReference && (authorName || articleTitle)) {
|
||||||
|
return (
|
||||||
|
<div className="highlight-citation">
|
||||||
|
— {authorName || 'Unknown'}{articleTitle ? `, ${articleTitle}` : ''}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For web URLs
|
||||||
|
if (highlight.urlReference) {
|
||||||
|
try {
|
||||||
|
const url = new URL(highlight.urlReference)
|
||||||
|
return (
|
||||||
|
<div className="highlight-citation">
|
||||||
|
— {url.hostname}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,14 +1,174 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
|
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faHighlighter, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faComments } from '@fortawesome/free-regular-svg-icons'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models, IEventStore } from 'applesauce-core'
|
import { Models, IEventStore } from 'applesauce-core'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { areAllRelaysLocal } from '../utils/helpers'
|
import { areAllRelaysLocal } from '../utils/helpers'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||||
|
import { createDeletionRequest } from '../services/deletionService'
|
||||||
|
import { getNostrUrl } from '../config/nostrGateways'
|
||||||
|
import CompactButton from './CompactButton'
|
||||||
|
import { HighlightCitation } from './HighlightCitation'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
// Helper to detect if a URL is an image
|
||||||
|
const isImageUrl = (url: string): boolean => {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const pathname = urlObj.pathname.toLowerCase()
|
||||||
|
return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/.test(pathname)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to render a nostr identifier
|
||||||
|
const renderNostrId = (nostrUri: string, index: number): React.ReactElement => {
|
||||||
|
try {
|
||||||
|
// Remove nostr: prefix
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
const decoded = nip19.decode(identifier)
|
||||||
|
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'npub': {
|
||||||
|
const pubkey = decoded.data
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={`/p/${nip19.npubEncode(pubkey)}`}
|
||||||
|
className="highlight-comment-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
@{pubkey.slice(0, 8)}...
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'nprofile': {
|
||||||
|
const { pubkey } = decoded.data
|
||||||
|
const npub = nip19.npubEncode(pubkey)
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={`/p/${npub}`}
|
||||||
|
className="highlight-comment-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
@{pubkey.slice(0, 8)}...
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'naddr': {
|
||||||
|
const { kind, pubkey, identifier } = decoded.data
|
||||||
|
// Check if it's a blog post (kind:30023)
|
||||||
|
if (kind === 30023) {
|
||||||
|
const naddr = nip19.naddrEncode({ kind, pubkey, identifier })
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={`/a/${naddr}`}
|
||||||
|
className="highlight-comment-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{identifier || 'Article'}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// For other kinds, show shortened identifier
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
nostr:{identifier.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'note': {
|
||||||
|
const eventId = decoded.data
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
note:{eventId.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'nevent': {
|
||||||
|
const { id } = decoded.data
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
event:{id.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Fallback for unrecognized types
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
{identifier.slice(0, 20)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If decoding fails, show shortened identifier
|
||||||
|
const identifier = nostrUri.replace(/^nostr:/, '')
|
||||||
|
return (
|
||||||
|
<span key={index} className="highlight-comment-nostr-id">
|
||||||
|
{identifier.slice(0, 20)}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component to render comment with links, inline images, and nostr identifiers
|
||||||
|
const CommentContent: React.FC<{ text: string }> = ({ text }) => {
|
||||||
|
// Pattern to match both http(s) URLs and nostr: URIs
|
||||||
|
const urlPattern = /((?:https?:\/\/|nostr:)[^\s]+)/g
|
||||||
|
const parts = text.split(urlPattern)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
// Handle nostr: URIs
|
||||||
|
if (part.startsWith('nostr:')) {
|
||||||
|
return renderNostrId(part, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle http(s) URLs
|
||||||
|
if (part.match(/^https?:\/\//)) {
|
||||||
|
if (isImageUrl(part)) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={part}
|
||||||
|
alt="Comment attachment"
|
||||||
|
className="highlight-comment-image"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={part}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="highlight-comment-link"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={index}>{part}</span>
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface HighlightWithLevel extends Highlight {
|
interface HighlightWithLevel extends Highlight {
|
||||||
level?: 'mine' | 'friends' | 'nostrverse'
|
level?: 'mine' | 'friends' | 'nostrverse'
|
||||||
@@ -22,21 +182,32 @@ interface HighlightItemProps {
|
|||||||
relayPool?: RelayPool | null
|
relayPool?: RelayPool | null
|
||||||
eventStore?: IEventStore | null
|
eventStore?: IEventStore | null
|
||||||
onHighlightUpdate?: (highlight: Highlight) => void
|
onHighlightUpdate?: (highlight: Highlight) => void
|
||||||
|
onHighlightDelete?: (highlightId: string) => void
|
||||||
|
showCitation?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||||
highlight,
|
highlight,
|
||||||
onSelectUrl,
|
// onSelectUrl is not used but kept in props for API compatibility
|
||||||
isSelected,
|
isSelected,
|
||||||
onHighlightClick,
|
onHighlightClick,
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore,
|
eventStore,
|
||||||
onHighlightUpdate
|
onHighlightUpdate,
|
||||||
|
onHighlightDelete,
|
||||||
|
showCitation = true
|
||||||
}) => {
|
}) => {
|
||||||
const itemRef = useRef<HTMLDivElement>(null)
|
const itemRef = useRef<HTMLDivElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
||||||
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
||||||
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [showMenu, setShowMenu] = useState(false)
|
||||||
|
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// Resolve the profile of the user who made the highlight
|
// Resolve the profile of the user who made the highlight
|
||||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||||
@@ -87,27 +258,76 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isSelected])
|
}, [isSelected])
|
||||||
|
|
||||||
|
// Close menu and reset delete confirm when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowMenu(false)
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMenu || showDeleteConfirm) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showMenu, showDeleteConfirm])
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
|
// If onHighlightClick is provided, use it (legacy behavior)
|
||||||
if (onHighlightClick) {
|
if (onHighlightClick) {
|
||||||
onHighlightClick(highlight.id)
|
onHighlightClick(highlight.id)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Otherwise, navigate to the article that this highlight references
|
||||||
const handleLinkClick = (url: string, e: React.MouseEvent) => {
|
|
||||||
if (onSelectUrl) {
|
|
||||||
e.preventDefault()
|
|
||||||
onSelectUrl(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSourceLink = () => {
|
|
||||||
if (highlight.eventReference) {
|
if (highlight.eventReference) {
|
||||||
return `https://search.dergigi.com/e/${highlight.eventReference}`
|
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||||
|
const parts = highlight.eventReference.split(':')
|
||||||
|
|
||||||
|
// If it's an article coordinate (3 parts) and kind is 30023, navigate to it
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [kind, pubkey, identifier] = parts
|
||||||
|
|
||||||
|
if (kind === '30023') {
|
||||||
|
// Encode as naddr and navigate
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
navigate(`/a/${naddr}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (highlight.urlReference) {
|
||||||
|
// Navigate to external URL
|
||||||
|
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
|
||||||
}
|
}
|
||||||
return highlight.urlReference
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceLink = getSourceLink()
|
const getHighlightLinks = () => {
|
||||||
|
// Encode the highlight event itself (kind 9802) as a nevent
|
||||||
|
// Get non-local relays for the hint
|
||||||
|
const relayHints = RELAYS.filter(r =>
|
||||||
|
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||||
|
).slice(0, 3) // Include up to 3 relay hints
|
||||||
|
|
||||||
|
const nevent = nip19.neventEncode({
|
||||||
|
id: highlight.id,
|
||||||
|
relays: relayHints,
|
||||||
|
author: highlight.pubkey,
|
||||||
|
kind: 9802
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
portal: getNostrUrl(nevent),
|
||||||
|
native: `nostr:${nevent}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlightLinks = getHighlightLinks()
|
||||||
|
|
||||||
// Handle rebroadcast to all relays
|
// Handle rebroadcast to all relays
|
||||||
const handleRebroadcast = async (e: React.MouseEvent) => {
|
const handleRebroadcast = async (e: React.MouseEvent) => {
|
||||||
@@ -172,13 +392,13 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
// Always show relay list, use plane icon for local-only
|
// Always show relay list, use plane icon for local-only
|
||||||
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
const isLocalOrOffline = highlight.isLocalOnly || showOfflineIndicator
|
||||||
|
|
||||||
// Show server icon with relay info if available
|
// Show highlighter icon with relay info if available
|
||||||
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
if (highlight.publishedRelays && highlight.publishedRelays.length > 0) {
|
||||||
const relayNames = highlight.publishedRelays.map(url =>
|
const relayNames = highlight.publishedRelays.map(url =>
|
||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
icon: isLocalOrOffline ? faPlane : faServer,
|
icon: isLocalOrOffline ? faPlane : faHighlighter,
|
||||||
tooltip: relayNames.join('\n'),
|
tooltip: relayNames.join('\n'),
|
||||||
spin: false
|
spin: false
|
||||||
}
|
}
|
||||||
@@ -189,7 +409,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
icon: faServer,
|
icon: faHighlighter,
|
||||||
tooltip: relayNames.join('\n'),
|
tooltip: relayNames.join('\n'),
|
||||||
spin: false
|
spin: false
|
||||||
}
|
}
|
||||||
@@ -200,7 +420,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
icon: faServer,
|
icon: faHighlighter,
|
||||||
tooltip: relayNames.join('\n'),
|
tooltip: relayNames.join('\n'),
|
||||||
spin: false
|
spin: false
|
||||||
}
|
}
|
||||||
@@ -208,64 +428,210 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
|||||||
|
|
||||||
const relayIndicator = getRelayIndicatorInfo()
|
const relayIndicator = getRelayIndicatorInfo()
|
||||||
|
|
||||||
|
// Check if current user can delete this highlight
|
||||||
|
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!activeAccount || !relayPool) {
|
||||||
|
console.warn('Cannot delete: no account or relay pool')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createDeletionRequest(
|
||||||
|
highlight.id,
|
||||||
|
9802, // kind for highlights
|
||||||
|
'Deleted by user',
|
||||||
|
activeAccount,
|
||||||
|
relayPool
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('✅ Highlight deletion request published')
|
||||||
|
|
||||||
|
// Notify parent to remove this highlight from the list
|
||||||
|
if (onHighlightDelete) {
|
||||||
|
onHighlightDelete(highlight.id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete highlight:', error)
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuToggle = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
// Reset delete confirm state when opening/closing menu
|
||||||
|
if (!showMenu) {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
}
|
||||||
|
setShowMenu(!showMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenPortal = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
window.open(highlightLinks.portal, '_blank', 'noopener,noreferrer')
|
||||||
|
setShowMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenNative = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
window.location.href = highlightLinks.native
|
||||||
|
setShowMenu(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMenuDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowMenu(false)
|
||||||
|
setShowDeleteConfirm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleConfirmDelete()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
ref={itemRef}
|
ref={itemRef}
|
||||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||||
data-highlight-id={highlight.id}
|
data-highlight-id={highlight.id}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
<div className="highlight-quote-icon">
|
<div className="highlight-header">
|
||||||
<FontAwesomeIcon icon={faQuoteLeft} />
|
<CompactButton
|
||||||
{relayIndicator && (
|
className="highlight-timestamp"
|
||||||
<div
|
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||||
className="highlight-relay-indicator"
|
onClick={(e) => {
|
||||||
title={relayIndicator.tooltip}
|
e.stopPropagation()
|
||||||
onClick={handleRebroadcast}
|
window.location.href = highlightLinks.native
|
||||||
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
|
}}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
|
{formatDateCompact(highlight.created_at)}
|
||||||
</div>
|
</CompactButton>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CompactButton
|
||||||
|
className="highlight-quote-button"
|
||||||
|
icon={faQuoteLeft}
|
||||||
|
title="Quote"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* relay indicator lives in footer for consistent padding/alignment */}
|
||||||
|
|
||||||
<div className="highlight-content">
|
<div className="highlight-content">
|
||||||
<blockquote className="highlight-text">
|
<blockquote className="highlight-text">
|
||||||
{highlight.content}
|
{highlight.content}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|
||||||
|
{showCitation && (
|
||||||
|
<HighlightCitation
|
||||||
|
highlight={highlight}
|
||||||
|
relayPool={relayPool}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{highlight.comment && (
|
{highlight.comment && (
|
||||||
<div className="highlight-comment">
|
<div className="highlight-comment">
|
||||||
{highlight.comment}
|
<FontAwesomeIcon icon={faComments} flip="horizontal" className="highlight-comment-icon" />
|
||||||
|
<div className="highlight-comment-text">
|
||||||
|
<CommentContent text={highlight.comment} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="highlight-meta">
|
<div className="highlight-footer">
|
||||||
<span className="highlight-author">
|
<div className="highlight-footer-left">
|
||||||
{getUserDisplayName()}
|
{relayIndicator && (
|
||||||
</span>
|
<CompactButton
|
||||||
<span className="highlight-meta-separator">•</span>
|
className="highlight-relay-indicator"
|
||||||
<span className="highlight-time">
|
icon={relayIndicator.icon}
|
||||||
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
|
spin={relayIndicator.spin}
|
||||||
</span>
|
title={relayIndicator.tooltip}
|
||||||
|
onClick={handleRebroadcast}
|
||||||
|
disabled={!relayPool || !eventStore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="highlight-author">
|
||||||
|
{getUserDisplayName()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{sourceLink && (
|
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||||
<a
|
{showDeleteConfirm && canDelete && (
|
||||||
href={sourceLink}
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.5rem' }}>
|
||||||
target="_blank"
|
<span style={{ fontSize: '0.875rem', color: 'rgb(220 38 38)', fontWeight: 500 }}>Confirm?</span>
|
||||||
rel="noopener noreferrer"
|
<button
|
||||||
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
|
onClick={handleConfirmDeleteClick}
|
||||||
className="highlight-source"
|
disabled={isDeleting}
|
||||||
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
|
title="Confirm deletion"
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
color: 'rgb(220 38 38)',
|
||||||
</a>
|
background: 'rgba(220, 38, 38, 0.1)',
|
||||||
)}
|
border: '1px solid rgb(220 38 38)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '0.375rem',
|
||||||
|
cursor: isDeleting ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: '33px',
|
||||||
|
minHeight: '33px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CompactButton
|
||||||
|
icon={faEllipsisH}
|
||||||
|
onClick={handleMenuToggle}
|
||||||
|
title="More options"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showMenu && (
|
||||||
|
<div className="highlight-menu">
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item"
|
||||||
|
onClick={handleOpenPortal}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open with njump</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item"
|
||||||
|
onClick={handleOpenNative}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMobileAlt} />
|
||||||
|
<span>Open with Native App</span>
|
||||||
|
</button>
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
className="highlight-menu-item highlight-menu-item-danger"
|
||||||
|
onClick={handleMenuDeleteClick}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
|
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
|
||||||
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
||||||
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { HighlightSkeleton } from './Skeletons'
|
||||||
|
|
||||||
export interface HighlightVisibility {
|
export interface HighlightVisibility {
|
||||||
nostrverse: boolean
|
nostrverse: boolean
|
||||||
@@ -32,6 +36,7 @@ interface HighlightsPanelProps {
|
|||||||
followedPubkeys?: Set<string>
|
followedPubkeys?: Set<string>
|
||||||
relayPool?: RelayPool | null
|
relayPool?: RelayPool | null
|
||||||
eventStore?: IEventStore | null
|
eventStore?: IEventStore | null
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||||
@@ -50,7 +55,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
onHighlightVisibilityChange,
|
onHighlightVisibilityChange,
|
||||||
followedPubkeys = new Set(),
|
followedPubkeys = new Set(),
|
||||||
relayPool,
|
relayPool,
|
||||||
eventStore
|
eventStore,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
const [showHighlights, setShowHighlights] = useState(true)
|
const [showHighlights, setShowHighlights] = useState(true)
|
||||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||||
@@ -60,6 +66,18 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
setShowHighlights(newValue)
|
setShowHighlights(newValue)
|
||||||
onToggleHighlights?.(newValue)
|
onToggleHighlights?.(newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull-to-refresh for highlights
|
||||||
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
if (onRefresh) {
|
||||||
|
onRefresh()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !onRefresh
|
||||||
|
})
|
||||||
|
|
||||||
// Keep track of highlight updates
|
// Keep track of highlight updates
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -72,6 +90,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleHighlightDelete = (highlightId: string) => {
|
||||||
|
// Remove highlight from local state
|
||||||
|
setLocalHighlights(prev => prev.filter(h => h.id !== highlightId))
|
||||||
|
}
|
||||||
|
|
||||||
const filteredHighlights = useFilteredHighlights({
|
const filteredHighlights = useFilteredHighlights({
|
||||||
highlights: localHighlights,
|
highlights: localHighlights,
|
||||||
selectedUrl,
|
selectedUrl,
|
||||||
@@ -85,6 +108,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
<HighlightsPanelCollapsed
|
<HighlightsPanelCollapsed
|
||||||
hasHighlights={filteredHighlights.length > 0}
|
hasHighlights={filteredHighlights.length > 0}
|
||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
|
settings={settings}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,8 +128,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && filteredHighlights.length === 0 ? (
|
{loading && filteredHighlights.length === 0 ? (
|
||||||
<div className="highlights-loading">
|
<div className="highlights-list" aria-busy="true">
|
||||||
<FontAwesomeIcon icon={faHighlighter} spin />
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<HighlightSkeleton key={i} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filteredHighlights.length === 0 ? (
|
) : filteredHighlights.length === 0 ? (
|
||||||
<div className="highlights-empty">
|
<div className="highlights-empty">
|
||||||
@@ -119,6 +145,10 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="highlights-list">
|
<div className="highlights-list">
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
{filteredHighlights.map((highlight) => (
|
{filteredHighlights.map((highlight) => (
|
||||||
<HighlightItem
|
<HighlightItem
|
||||||
key={highlight.id}
|
key={highlight.id}
|
||||||
@@ -129,6 +159,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
|||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
eventStore={eventStore}
|
eventStore={eventStore}
|
||||||
onHighlightUpdate={handleHighlightUpdate}
|
onHighlightUpdate={handleHighlightUpdate}
|
||||||
|
onHighlightDelete={handleHighlightDelete}
|
||||||
|
showCitation={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faChevronRight } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faChevronRight } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
|
||||||
interface HighlightsPanelCollapsedProps {
|
interface HighlightsPanelCollapsedProps {
|
||||||
hasHighlights: boolean
|
hasHighlights: boolean
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
|
settings?: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
|
const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
|
||||||
hasHighlights,
|
hasHighlights,
|
||||||
onToggleCollapse
|
onToggleCollapse,
|
||||||
|
settings
|
||||||
}) => {
|
}) => {
|
||||||
|
const highlightColor = settings?.highlightColorMine || '#ffff00'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="highlights-container collapsed">
|
<div className="highlights-container collapsed">
|
||||||
<button
|
<button
|
||||||
@@ -19,8 +24,12 @@ const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
|
|||||||
title="Expand highlights panel"
|
title="Expand highlights panel"
|
||||||
aria-label="Expand highlights panel"
|
aria-label="Expand highlights panel"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} className={hasHighlights ? 'glow' : ''} />
|
<FontAwesomeIcon
|
||||||
<FontAwesomeIcon icon={faChevronRight} />
|
icon={faHighlighter}
|
||||||
|
className={hasHighlights ? 'glow' : ''}
|
||||||
|
style={{ color: highlightColor }}
|
||||||
|
/>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} style={{ color: highlightColor }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { HighlightVisibility } from '../HighlightsPanel'
|
import { HighlightVisibility } from '../HighlightsPanel'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
interface HighlightsPanelHeaderProps {
|
interface HighlightsPanelHeaderProps {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -32,76 +32,81 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
|||||||
<div className="highlights-actions-left">
|
<div className="highlights-actions-left">
|
||||||
{onHighlightVisibilityChange && (
|
{onHighlightVisibilityChange && (
|
||||||
<div className="highlight-level-toggles">
|
<div className="highlight-level-toggles">
|
||||||
<button
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
onClick={() => onHighlightVisibilityChange({
|
onClick={() => onHighlightVisibilityChange({
|
||||||
...highlightVisibility,
|
...highlightVisibility,
|
||||||
nostrverse: !highlightVisibility.nostrverse
|
nostrverse: !highlightVisibility.nostrverse
|
||||||
})}
|
})}
|
||||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
|
||||||
title="Toggle nostrverse highlights"
|
title="Toggle nostrverse highlights"
|
||||||
aria-label="Toggle nostrverse highlights"
|
ariaLabel="Toggle nostrverse highlights"
|
||||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
variant="ghost"
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faNetworkWired} />
|
color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||||
</button>
|
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||||
<button
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
onClick={() => onHighlightVisibilityChange({
|
onClick={() => onHighlightVisibilityChange({
|
||||||
...highlightVisibility,
|
...highlightVisibility,
|
||||||
friends: !highlightVisibility.friends
|
friends: !highlightVisibility.friends
|
||||||
})}
|
})}
|
||||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
|
||||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||||
aria-label="Toggle friends highlights"
|
ariaLabel="Toggle friends highlights"
|
||||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
variant="ghost"
|
||||||
disabled={!currentUserPubkey}
|
disabled={!currentUserPubkey}
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faUserGroup} />
|
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
</button>
|
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||||
<button
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
onClick={() => onHighlightVisibilityChange({
|
onClick={() => onHighlightVisibilityChange({
|
||||||
...highlightVisibility,
|
...highlightVisibility,
|
||||||
mine: !highlightVisibility.mine
|
mine: !highlightVisibility.mine
|
||||||
})}
|
})}
|
||||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
|
||||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||||
aria-label="Toggle my highlights"
|
ariaLabel="Toggle my highlights"
|
||||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
variant="ghost"
|
||||||
disabled={!currentUserPubkey}
|
disabled={!currentUserPubkey}
|
||||||
>
|
style={{
|
||||||
<FontAwesomeIcon icon={faUser} />
|
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
</button>
|
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
<button
|
<IconButton
|
||||||
|
icon={faRotate}
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className="refresh-highlights-btn"
|
|
||||||
title="Refresh highlights"
|
title="Refresh highlights"
|
||||||
aria-label="Refresh highlights"
|
ariaLabel="Refresh highlights"
|
||||||
|
variant="ghost"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
spin={loading}
|
||||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
/>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<button
|
<IconButton
|
||||||
|
icon={showHighlights ? faEye : faEyeSlash}
|
||||||
onClick={onToggleHighlights}
|
onClick={onToggleHighlights}
|
||||||
className="toggle-highlight-display-btn"
|
|
||||||
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||||
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
ariaLabel={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||||
>
|
variant="ghost"
|
||||||
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
/>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<IconButton
|
||||||
|
icon={faChevronRight}
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="toggle-highlights-btn"
|
|
||||||
title="Collapse highlights panel"
|
title="Collapse highlights panel"
|
||||||
aria-label="Collapse highlights panel"
|
ariaLabel="Collapse highlights panel"
|
||||||
>
|
variant="ghost"
|
||||||
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
|
style={{ transform: 'rotate(180deg)' }}
|
||||||
</button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface IconButtonProps {
|
|||||||
size?: number
|
size?: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
spin?: boolean
|
spin?: boolean
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
const IconButton: React.FC<IconButtonProps> = ({
|
const IconButton: React.FC<IconButtonProps> = ({
|
||||||
@@ -21,15 +23,17 @@ const IconButton: React.FC<IconButtonProps> = ({
|
|||||||
variant = 'ghost',
|
variant = 'ghost',
|
||||||
size = 33,
|
size = 33,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
spin = false
|
spin = false,
|
||||||
|
className = '',
|
||||||
|
style
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`icon-button ${variant}`}
|
className={`icon-button ${variant} ${className}`.trim()}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={ariaLabel || title}
|
aria-label={ariaLabel || title}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: size, height: size, ...style }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={icon} spin={spin} />
|
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||||
|
|||||||
179
src/components/LoginOptions.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { Accounts } from 'applesauce-accounts'
|
||||||
|
import { NostrConnectSigner } from 'applesauce-signers'
|
||||||
|
import { getDefaultBunkerPermissions } from '../services/nostrConnect'
|
||||||
|
|
||||||
|
const LoginOptions: React.FC = () => {
|
||||||
|
const accountManager = Hooks.useAccountManager()
|
||||||
|
const [showBunkerInput, setShowBunkerInput] = useState(false)
|
||||||
|
const [bunkerUri, setBunkerUri] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleExtensionLogin = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Extension login failed:', err)
|
||||||
|
setError('Login failed. Please install a nostr browser extension and try again.')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBunkerLogin = async () => {
|
||||||
|
if (!bunkerUri.trim()) {
|
||||||
|
setError('Please enter a bunker URI')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bunkerUri.startsWith('bunker://')) {
|
||||||
|
setError('Invalid bunker URI. Must start with bunker://')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Create signer from bunker URI with default permissions
|
||||||
|
const permissions = getDefaultBunkerPermissions()
|
||||||
|
const signer = await NostrConnectSigner.fromBunkerURI(bunkerUri, { permissions })
|
||||||
|
|
||||||
|
// Get pubkey from signer
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
|
||||||
|
// Create account from signer
|
||||||
|
const account = new Accounts.NostrConnectAccount(pubkey, signer)
|
||||||
|
|
||||||
|
// Add to account manager and set active
|
||||||
|
accountManager.addAccount(account)
|
||||||
|
accountManager.setActive(account)
|
||||||
|
|
||||||
|
// Clear input on success
|
||||||
|
setBunkerUri('')
|
||||||
|
setShowBunkerInput(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bunker] Login failed:', err)
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to connect to bunker'
|
||||||
|
|
||||||
|
// Check for permission-related errors
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
setError('Your bunker connection is missing signing permissions. Reconnect and approve signing.')
|
||||||
|
} else {
|
||||||
|
setError(errorMessage)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p style={{ marginBottom: '1rem' }}>Login with:</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxWidth: '300px', margin: '0 auto' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleExtensionLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: isLoading ? 'wait' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && !showBunkerInput ? 'Connecting...' : 'Extension'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!showBunkerInput ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBunkerInput(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: isLoading ? 'wait' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bunker
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="bunker://..."
|
||||||
|
value={bunkerUri}
|
||||||
|
onChange={(e) => setBunkerUri(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleBunkerLogin()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleBunkerLogin}
|
||||||
|
disabled={isLoading || !bunkerUri.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
flex: 1,
|
||||||
|
cursor: isLoading || !bunkerUri.trim() ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading || !bunkerUri.trim() ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading && showBunkerInput ? 'Connecting...' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowBunkerInput(false)
|
||||||
|
setBunkerUri('')
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isLoading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: 'var(--color-error, #ef4444)', marginTop: '1rem', fontSize: '0.9rem' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p style={{ marginTop: '1.5rem', fontSize: '0.9rem' }}>
|
||||||
|
If you aren't on nostr yet, start here:{' '}
|
||||||
|
<a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">
|
||||||
|
nstart.me
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginOptions
|
||||||
|
|
||||||
732
src/components/Me.tsx
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { Hooks } from 'applesauce-react'
|
||||||
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Highlight } from '../types/highlights'
|
||||||
|
import { HighlightItem } from './HighlightItem'
|
||||||
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
|
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||||
|
import { fetchLinks } from '../services/linksService'
|
||||||
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
|
import AuthorCard from './AuthorCard'
|
||||||
|
import BlogPostCard from './BlogPostCard'
|
||||||
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
|
import IconButton from './IconButton'
|
||||||
|
import { ViewMode } from './Bookmarks'
|
||||||
|
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||||
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
|
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||||
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||||
|
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||||
|
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||||
|
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
interface MeProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
activeTab?: TabType
|
||||||
|
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||||
|
|
||||||
|
// Valid reading progress filters
|
||||||
|
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
|
||||||
|
|
||||||
|
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
|
// Use provided pubkey or fall back to active account
|
||||||
|
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||||
|
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||||
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
|
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
|
const [links, setLinks] = useState<ReadItem[]>([])
|
||||||
|
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
|
|
||||||
|
// Initialize reading progress filter from URL param
|
||||||
|
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||||
|
? (urlFilter as ReadingProgressFilterType)
|
||||||
|
: 'all'
|
||||||
|
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||||
|
|
||||||
|
// Update local state when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (propActiveTab) {
|
||||||
|
setActiveTab(propActiveTab)
|
||||||
|
}
|
||||||
|
}, [propActiveTab])
|
||||||
|
|
||||||
|
// Sync filter state with URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||||
|
? (urlFilter as ReadingProgressFilterType)
|
||||||
|
: 'all'
|
||||||
|
setReadingProgressFilter(filterFromUrl)
|
||||||
|
}, [urlFilter])
|
||||||
|
|
||||||
|
// Handler to change reading progress filter and update URL
|
||||||
|
const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => {
|
||||||
|
setReadingProgressFilter(filter)
|
||||||
|
if (activeTab === 'reads') {
|
||||||
|
if (filter === 'all') {
|
||||||
|
navigate('/me/reads', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate(`/me/reads/${filter}`, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab-specific loading functions
|
||||||
|
const loadHighlightsTab = async () => {
|
||||||
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
|
// Only show loading skeleton if tab hasn't been loaded yet
|
||||||
|
const hasBeenLoaded = loadedTabs.has('highlights')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
||||||
|
setHighlights(userHighlights)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load highlights:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWritingsTab = async () => {
|
||||||
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('writings')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||||
|
setWritings(userWritings)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load writings:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadReadingListTab = async () => {
|
||||||
|
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('reading-list')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
try {
|
||||||
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
|
setBookmarks(newBookmarks)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load bookmarks:', err)
|
||||||
|
setBookmarks([])
|
||||||
|
}
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load reading list:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadReadsTab = async () => {
|
||||||
|
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('reads')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
|
// Ensure bookmarks are loaded
|
||||||
|
let fetchedBookmarks: Bookmark[] = bookmarks
|
||||||
|
if (bookmarks.length === 0) {
|
||||||
|
try {
|
||||||
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
|
fetchedBookmarks = newBookmarks
|
||||||
|
setBookmarks(newBookmarks)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load bookmarks:', err)
|
||||||
|
fetchedBookmarks = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive reads from bookmarks immediately
|
||||||
|
const initialReads = deriveReadsFromBookmarks(fetchedBookmarks)
|
||||||
|
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
||||||
|
setReadsMap(initialMap)
|
||||||
|
setReads(initialReads)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('reads'))
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
|
||||||
|
// Background enrichment: merge reading progress and mark-as-read
|
||||||
|
// Only update items that are already in our map
|
||||||
|
fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => {
|
||||||
|
console.log('📈 [Reads] Enrichment item received:', {
|
||||||
|
id: item.id.slice(0, 20) + '...',
|
||||||
|
progress: item.readingProgress,
|
||||||
|
hasProgress: item.readingProgress !== undefined && item.readingProgress > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
setReadsMap(prevMap => {
|
||||||
|
// Only update if item exists in our current map
|
||||||
|
if (!prevMap.has(item.id)) {
|
||||||
|
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
|
||||||
|
return prevMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMap = new Map(prevMap)
|
||||||
|
const merged = mergeReadItem(newMap, item)
|
||||||
|
if (merged) {
|
||||||
|
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
|
||||||
|
// Update reads array after map is updated
|
||||||
|
setReads(Array.from(newMap.values()))
|
||||||
|
return newMap
|
||||||
|
}
|
||||||
|
return prevMap
|
||||||
|
})
|
||||||
|
}).catch(err => console.warn('Failed to enrich reads:', err))
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load reads:', err)
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLinksTab = async () => {
|
||||||
|
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('links')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
|
// Ensure bookmarks are loaded
|
||||||
|
let fetchedBookmarks: Bookmark[] = bookmarks
|
||||||
|
if (bookmarks.length === 0) {
|
||||||
|
try {
|
||||||
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
|
fetchedBookmarks = newBookmarks
|
||||||
|
setBookmarks(newBookmarks)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load bookmarks:', err)
|
||||||
|
fetchedBookmarks = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive links from bookmarks immediately
|
||||||
|
const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks)
|
||||||
|
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||||
|
setLinksMap(initialMap)
|
||||||
|
setLinks(initialLinks)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('links'))
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
|
||||||
|
// Background enrichment: merge reading progress and mark-as-read
|
||||||
|
// Only update items that are already in our map
|
||||||
|
fetchLinks(relayPool, viewingPubkey, (item) => {
|
||||||
|
setLinksMap(prevMap => {
|
||||||
|
// Only update if item exists in our current map
|
||||||
|
if (!prevMap.has(item.id)) return prevMap
|
||||||
|
|
||||||
|
const newMap = new Map(prevMap)
|
||||||
|
if (mergeReadItem(newMap, item)) {
|
||||||
|
// Update links array after map is updated
|
||||||
|
setLinks(Array.from(newMap.values()))
|
||||||
|
return newMap
|
||||||
|
}
|
||||||
|
return prevMap
|
||||||
|
})
|
||||||
|
}).catch(err => console.warn('Failed to enrich links:', err))
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load links:', err)
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load active tab data
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewingPubkey || !activeTab) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load cached data immediately if available
|
||||||
|
if (isOwnProfile) {
|
||||||
|
const cached = getCachedMeData(viewingPubkey)
|
||||||
|
if (cached) {
|
||||||
|
setHighlights(cached.highlights)
|
||||||
|
setBookmarks(cached.bookmarks)
|
||||||
|
setReads(cached.reads || [])
|
||||||
|
setLinks(cached.links || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data for active tab (refresh in background if already loaded)
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'highlights':
|
||||||
|
loadHighlightsTab()
|
||||||
|
break
|
||||||
|
case 'writings':
|
||||||
|
loadWritingsTab()
|
||||||
|
break
|
||||||
|
case 'reading-list':
|
||||||
|
loadReadingListTab()
|
||||||
|
break
|
||||||
|
case 'reads':
|
||||||
|
loadReadsTab()
|
||||||
|
break
|
||||||
|
case 'links':
|
||||||
|
loadLinksTab()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||||
|
|
||||||
|
|
||||||
|
// Pull-to-refresh - reload active tab without clearing state
|
||||||
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
|
onRefresh: () => {
|
||||||
|
// Just trigger refresh - loaders will merge new data
|
||||||
|
setRefreshTrigger(prev => prev + 1)
|
||||||
|
},
|
||||||
|
maximumPullLength: 240,
|
||||||
|
refreshThreshold: 80,
|
||||||
|
isDisabled: !viewingPubkey
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleHighlightDelete = (highlightId: string) => {
|
||||||
|
setHighlights(prev => {
|
||||||
|
const updated = prev.filter(h => h.id !== highlightId)
|
||||||
|
// Update cache when highlight is deleted (own profile only)
|
||||||
|
if (isOwnProfile && viewingPubkey) {
|
||||||
|
updateCachedHighlights(viewingPubkey, updated)
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPostUrl = (post: BlogPostPreview) => {
|
||||||
|
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const naddr = nip19.naddrEncode({
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: post.author,
|
||||||
|
identifier: dTag
|
||||||
|
})
|
||||||
|
return `/a/${naddr}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReadItemUrl = (item: ReadItem) => {
|
||||||
|
if (item.type === 'article') {
|
||||||
|
// ID is already in naddr format
|
||||||
|
return `/a/${item.id}`
|
||||||
|
} else if (item.url) {
|
||||||
|
return `/r/${encodeURIComponent(item.url)}`
|
||||||
|
}
|
||||||
|
return '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => {
|
||||||
|
if (item.event) {
|
||||||
|
return {
|
||||||
|
event: item.event,
|
||||||
|
title: item.title || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || item.event.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock event for external URLs
|
||||||
|
const mockEvent = {
|
||||||
|
id: item.id,
|
||||||
|
pubkey: item.author || '',
|
||||||
|
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [] as string[][],
|
||||||
|
content: item.title || item.url || 'Untitled',
|
||||||
|
sig: ''
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: mockEvent as unknown as import('nostr-tools').NostrEvent,
|
||||||
|
title: item.title || item.url || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||||
|
if (bookmark && bookmark.kind === 30023) {
|
||||||
|
// For kind:30023 articles, navigate to the article route
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
if (dTag && bookmark.pubkey) {
|
||||||
|
const pointer = {
|
||||||
|
identifier: dTag,
|
||||||
|
kind: 30023,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
}
|
||||||
|
const naddr = nip19.naddrEncode(pointer)
|
||||||
|
navigate(`/a/${naddr}`)
|
||||||
|
}
|
||||||
|
} else if (url) {
|
||||||
|
// For regular URLs, navigate to the reader route
|
||||||
|
navigate(`/r/${encodeURIComponent(url)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and flatten all individual bookmarks
|
||||||
|
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
.filter(hasContent)
|
||||||
|
|
||||||
|
// Apply bookmark filter
|
||||||
|
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
||||||
|
|
||||||
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
|
// Apply reading progress filter
|
||||||
|
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
||||||
|
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
||||||
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||||
|
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||||
|
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
||||||
|
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Show content progressively - no blocking error screens
|
||||||
|
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||||
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'highlights':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<HighlightSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return highlights.length === 0 && !loading ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No highlights yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="highlights-list me-highlights-list">
|
||||||
|
{highlights.map((highlight) => (
|
||||||
|
<HighlightItem
|
||||||
|
key={highlight.id}
|
||||||
|
highlight={{ ...highlight, level: 'mine' }}
|
||||||
|
relayPool={relayPool}
|
||||||
|
onHighlightDelete={handleHighlightDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'reading-list':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="bookmarks-list">
|
||||||
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BookmarkSkeleton key={i} viewMode={viewMode} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return allIndividualBookmarks.length === 0 && !loading ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No bookmarks yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bookmarks-list">
|
||||||
|
{allIndividualBookmarks.length > 0 && (
|
||||||
|
<BookmarkFilters
|
||||||
|
selectedFilter={bookmarkFilter}
|
||||||
|
onFilterChange={setBookmarkFilter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filteredBookmarks.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No bookmarks match this filter.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sections.filter(s => s.items.length > 0).map(section => (
|
||||||
|
<div key={section.key} className="bookmarks-section">
|
||||||
|
<h3 className="bookmarks-section-title">{section.title}</h3>
|
||||||
|
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||||
|
{section.items.map((individualBookmark, index) => (
|
||||||
|
<BookmarkItem
|
||||||
|
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||||
|
bookmark={individualBookmark}
|
||||||
|
index={index}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onSelectUrl={handleSelectUrl}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)))}
|
||||||
|
<div className="view-mode-controls" style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '1rem',
|
||||||
|
marginTop: '1rem',
|
||||||
|
borderTop: '1px solid var(--border-color)'
|
||||||
|
}}>
|
||||||
|
<IconButton
|
||||||
|
icon={faList}
|
||||||
|
onClick={() => setViewMode('compact')}
|
||||||
|
title="Compact list view"
|
||||||
|
ariaLabel="Compact list view"
|
||||||
|
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faThLarge}
|
||||||
|
onClick={() => setViewMode('cards')}
|
||||||
|
title="Cards view"
|
||||||
|
ariaLabel="Cards view"
|
||||||
|
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faImage}
|
||||||
|
onClick={() => setViewMode('large')}
|
||||||
|
title="Large preview view"
|
||||||
|
ariaLabel="Large preview view"
|
||||||
|
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'reads':
|
||||||
|
// Show loading skeletons only while initially loading
|
||||||
|
if (loading && !loadedTabs.has('reads')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show empty state if loaded but no reads
|
||||||
|
if (reads.length === 0 && loadedTabs.has('reads')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles read yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show reads with filters
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReadingProgressFilters
|
||||||
|
selectedFilter={readingProgressFilter}
|
||||||
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
|
/>
|
||||||
|
{filteredReads.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles match this filter.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{filteredReads.map((item) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={item.id}
|
||||||
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'links':
|
||||||
|
// Show loading skeletons only while initially loading
|
||||||
|
if (loading && !loadedTabs.has('links')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show empty state if loaded but no links
|
||||||
|
if (links.length === 0 && loadedTabs.has('links')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No links with reading progress yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show links with filters
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReadingProgressFilters
|
||||||
|
selectedFilter={readingProgressFilter}
|
||||||
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
|
/>
|
||||||
|
{filteredLinks.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No links match this filter.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{filteredLinks.map((item) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={item.id}
|
||||||
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'writings':
|
||||||
|
if (showSkeletons) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return writings.length === 0 && !loading ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles written yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{writings.map((post) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={post.event.id}
|
||||||
|
post={post}
|
||||||
|
href={getPostUrl(post)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="explore-container">
|
||||||
|
<RefreshIndicator
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
pullPosition={pullPosition}
|
||||||
|
/>
|
||||||
|
<div className="explore-header">
|
||||||
|
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
|
||||||
|
|
||||||
|
<div className="me-tabs">
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||||
|
data-tab="highlights"
|
||||||
|
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span className="tab-label">Highlights</span>
|
||||||
|
</button>
|
||||||
|
{isOwnProfile && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||||
|
data-tab="reading-list"
|
||||||
|
onClick={() => navigate('/me/reading-list')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBookmark} />
|
||||||
|
<span className="tab-label">Bookmarks</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||||
|
data-tab="reads"
|
||||||
|
onClick={() => navigate('/me/reads')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
|
<span className="tab-label">Reads</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
||||||
|
data-tab="links"
|
||||||
|
onClick={() => navigate('/me/links')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faLink} />
|
||||||
|
<span className="tab-label">Links</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||||
|
data-tab="writings"
|
||||||
|
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} />
|
||||||
|
<span className="tab-label">Writings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="me-tab-content">
|
||||||
|
{renderTabContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Me
|
||||||
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { useImageCache } from '../hooks/useImageCache'
|
import { useImageCache } from '../hooks/useImageCache'
|
||||||
|
import { useAdaptiveTextColor } from '../hooks/useAdaptiveTextColor'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { Highlight, HighlightLevel } from '../types/highlights'
|
||||||
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
|
import { hexToRgb } from '../utils/colorHelpers'
|
||||||
|
|
||||||
interface ReaderHeaderProps {
|
interface ReaderHeaderProps {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -14,6 +18,8 @@ interface ReaderHeaderProps {
|
|||||||
hasHighlights: boolean
|
hasHighlights: boolean
|
||||||
highlightCount: number
|
highlightCount: number
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
|
highlights?: Highlight[]
|
||||||
|
highlightVisibility?: HighlightVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||||
@@ -24,40 +30,100 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
readingTimeText,
|
readingTimeText,
|
||||||
hasHighlights,
|
hasHighlights,
|
||||||
highlightCount,
|
highlightCount,
|
||||||
settings
|
settings,
|
||||||
|
highlights = [],
|
||||||
|
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(image, settings)
|
const cachedImage = useImageCache(image)
|
||||||
|
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||||
if (cachedImage) {
|
const isLongSummary = summary && summary.length > 150
|
||||||
|
|
||||||
|
// Determine the dominant highlight color based on visibility and priority
|
||||||
|
const getHighlightIndicatorStyles = useMemo(() => (isOverlay: boolean) => {
|
||||||
|
if (!highlights.length) return undefined
|
||||||
|
|
||||||
|
// Count highlights by level that are visible
|
||||||
|
const visibleLevels = new Set<HighlightLevel>()
|
||||||
|
highlights.forEach(h => {
|
||||||
|
if (h.level && highlightVisibility[h.level]) {
|
||||||
|
visibleLevels.add(h.level)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let hexColor: string | undefined
|
||||||
|
// Priority: nostrverse > friends > mine
|
||||||
|
if (visibleLevels.has('nostrverse') && highlightVisibility.nostrverse) {
|
||||||
|
hexColor = settings?.highlightColorNostrverse || '#9333ea'
|
||||||
|
} else if (visibleLevels.has('friends') && highlightVisibility.friends) {
|
||||||
|
hexColor = settings?.highlightColorFriends || '#f97316'
|
||||||
|
} else if (visibleLevels.has('mine') && highlightVisibility.mine) {
|
||||||
|
hexColor = settings?.highlightColorMine || '#ffff00'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hexColor) return undefined
|
||||||
|
|
||||||
|
const rgb = hexToRgb(hexColor)
|
||||||
|
return {
|
||||||
|
backgroundColor: `rgba(${rgb}, 0.1)`,
|
||||||
|
borderColor: `rgba(${rgb}, 0.3)`,
|
||||||
|
// Only force white color in overlay context, otherwise let CSS handle it
|
||||||
|
...(isOverlay && { color: '#fff' })
|
||||||
|
}
|
||||||
|
}, [highlights, highlightVisibility, settings])
|
||||||
|
|
||||||
|
// Show hero section if we have an image OR a title
|
||||||
|
if (cachedImage || title) {
|
||||||
return (
|
return (
|
||||||
<div className="reader-hero-image">
|
<>
|
||||||
<img src={cachedImage} alt={title || 'Article image'} />
|
<div className="reader-hero-image">
|
||||||
{formattedDate && (
|
{cachedImage ? (
|
||||||
<div className="publish-date-topright">
|
<img src={cachedImage} alt={title || 'Article image'} />
|
||||||
{formattedDate}
|
) : (
|
||||||
</div>
|
<div className="reader-hero-placeholder">
|
||||||
)}
|
<FontAwesomeIcon icon={faNewspaper} />
|
||||||
{title && (
|
|
||||||
<div className="reader-header-overlay">
|
|
||||||
<h2 className="reader-title">{title}</h2>
|
|
||||||
{summary && <p className="reader-summary">{summary}</p>}
|
|
||||||
<div className="reader-meta">
|
|
||||||
{readingTimeText && (
|
|
||||||
<div className="reading-time">
|
|
||||||
<FontAwesomeIcon icon={faClock} />
|
|
||||||
<span>{readingTimeText}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasHighlights && (
|
|
||||||
<div className="highlight-indicator">
|
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{formattedDate && (
|
||||||
|
<div
|
||||||
|
className="publish-date-topright"
|
||||||
|
style={{
|
||||||
|
color: textColor
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formattedDate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<div className="reader-header-overlay">
|
||||||
|
<h2 className="reader-title">{title}</h2>
|
||||||
|
{summary && <p className={`reader-summary ${isLongSummary ? 'hide-on-mobile' : ''}`}>{summary}</p>}
|
||||||
|
<div className="reader-meta">
|
||||||
|
{readingTimeText && (
|
||||||
|
<div className="reading-time">
|
||||||
|
<FontAwesomeIcon icon={faClock} />
|
||||||
|
<span>{readingTimeText}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasHighlights && (
|
||||||
|
<div
|
||||||
|
className="highlight-indicator"
|
||||||
|
style={getHighlightIndicatorStyles(true)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isLongSummary && (
|
||||||
|
<div className="reader-summary-below-image">
|
||||||
|
<p className="reader-summary">{summary}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +132,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
{title && (
|
{title && (
|
||||||
<div className="reader-header">
|
<div className="reader-header">
|
||||||
{formattedDate && (
|
{formattedDate && (
|
||||||
<div className="publish-date-topright">
|
<div
|
||||||
|
className="publish-date-topright"
|
||||||
|
style={{
|
||||||
|
color: textColor
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -80,7 +151,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasHighlights && (
|
{hasHighlights && (
|
||||||
<div className="highlight-indicator">
|
<div
|
||||||
|
className="highlight-indicator"
|
||||||
|
style={getHighlightIndicatorStyles(false)}
|
||||||
|
>
|
||||||
<FontAwesomeIcon icon={faHighlighter} />
|
<FontAwesomeIcon icon={faHighlighter} />
|
||||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
src/components/ReadingProgressFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
|
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
|
||||||
|
|
||||||
|
interface ReadingProgressFiltersProps {
|
||||||
|
selectedFilter: ReadingProgressFilterType
|
||||||
|
onFilterChange: (filter: ReadingProgressFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||||
|
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||||
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => {
|
||||||
|
const isActive = selectedFilter === filter.type
|
||||||
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadingProgressFilters
|
||||||
|
|
||||||
83
src/components/ReadingProgressIndicator.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface ReadingProgressIndicatorProps {
|
||||||
|
progress: number // 0 to 100
|
||||||
|
isComplete?: boolean
|
||||||
|
showPercentage?: boolean
|
||||||
|
className?: string
|
||||||
|
isSidebarCollapsed?: boolean
|
||||||
|
isHighlightsCollapsed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> = ({
|
||||||
|
progress,
|
||||||
|
isComplete = false,
|
||||||
|
showPercentage = true,
|
||||||
|
className = '',
|
||||||
|
isSidebarCollapsed = false,
|
||||||
|
isHighlightsCollapsed = false
|
||||||
|
}) => {
|
||||||
|
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||||
|
|
||||||
|
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
|
||||||
|
const progressDecimal = clampedProgress / 100
|
||||||
|
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
|
||||||
|
|
||||||
|
// Determine bar color based on state
|
||||||
|
let barColorClass = ''
|
||||||
|
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
barColorClass = 'bg-green-500'
|
||||||
|
barColorStyle = undefined
|
||||||
|
} else if (isStarted) {
|
||||||
|
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||||
|
const leftOffset = isSidebarCollapsed
|
||||||
|
? 'var(--sidebar-collapsed-width)'
|
||||||
|
: 'var(--sidebar-width)'
|
||||||
|
const rightOffset = isHighlightsCollapsed
|
||||||
|
? 'var(--highlights-collapsed-width)'
|
||||||
|
: 'var(--highlights-width)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`reading-progress-bar fixed bottom-0 left-0 right-0 z-[1102] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}
|
||||||
|
style={{
|
||||||
|
'--left-offset': leftOffset,
|
||||||
|
'--right-offset': rightOffset,
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)',
|
||||||
|
opacity: 0.95
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-1 h-0.5 rounded-full overflow-hidden relative"
|
||||||
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
|
||||||
|
style={{
|
||||||
|
width: `${clampedProgress}%`,
|
||||||
|
backgroundColor: barColorStyle
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showPercentage && (
|
||||||
|
<div
|
||||||
|
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||||
|
isComplete ? 'text-green-500' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
src/components/RefreshIndicator.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faArrowRotateRight } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
interface RefreshIndicatorProps {
|
||||||
|
isRefreshing: boolean
|
||||||
|
pullPosition: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const THRESHOLD = 80
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple pull-to-refresh visual indicator
|
||||||
|
*/
|
||||||
|
const RefreshIndicator: React.FC<RefreshIndicatorProps> = ({
|
||||||
|
isRefreshing,
|
||||||
|
pullPosition
|
||||||
|
}) => {
|
||||||
|
const isVisible = isRefreshing || pullPosition > 0
|
||||||
|
if (!isVisible) return null
|
||||||
|
|
||||||
|
const opacity = Math.min(pullPosition / THRESHOLD, 1)
|
||||||
|
const translateY = isRefreshing ? THRESHOLD / 3 : pullPosition / 3
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${translateY}px`,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 30,
|
||||||
|
opacity,
|
||||||
|
transition: isRefreshing ? 'opacity 0.2s' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'var(--surface-secondary, #ffffff)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faArrowRotateRight}
|
||||||
|
style={{
|
||||||
|
transform: isRefreshing ? 'none' : `rotate(${pullPosition}deg)`,
|
||||||
|
color: 'var(--accent-color, #3b82f6)'
|
||||||
|
}}
|
||||||
|
className={isRefreshing ? 'fa-spin' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RefreshIndicator
|
||||||
|
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faPlane, faGlobe, faCircle } from '@fortawesome/free-solid-svg-icons'
|
import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { isLocalRelay } from '../utils/helpers'
|
import { isLocalRelay } from '../utils/helpers'
|
||||||
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
|
|
||||||
interface RelayStatusIndicatorProps {
|
interface RelayStatusIndicatorProps {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
|
showOnMobile?: boolean // Control visibility based on scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||||
|
relayPool,
|
||||||
|
showOnMobile = true
|
||||||
|
}) => {
|
||||||
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
||||||
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
||||||
|
const [isConnecting, setIsConnecting] = useState(true)
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
if (!relayPool) return null
|
if (!relayPool) return null
|
||||||
|
|
||||||
@@ -25,47 +33,140 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
|||||||
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
|
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
|
||||||
const offlineMode = connectedUrls.length === 0
|
const offlineMode = connectedUrls.length === 0
|
||||||
|
|
||||||
|
// Show "Connecting" for first few seconds or until relays connect
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectedUrls.length > 0) {
|
||||||
|
// Connected! Stop showing connecting state
|
||||||
|
setIsConnecting(false)
|
||||||
|
} else {
|
||||||
|
// No connections yet - show connecting for 8 seconds
|
||||||
|
setIsConnecting(true)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsConnecting(false)
|
||||||
|
}, 8000)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [connectedUrls.length])
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('🔌 Relay Status Indicator:', {
|
console.log('🔌 Relay Status Indicator:', {
|
||||||
mode: offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
||||||
totalStatuses: relayStatuses.length,
|
totalStatuses: relayStatuses.length,
|
||||||
connectedCount: connectedUrls.length,
|
connectedCount: connectedUrls.length,
|
||||||
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
||||||
hasLocalRelay,
|
hasLocalRelay,
|
||||||
hasRemoteRelay
|
hasRemoteRelay,
|
||||||
|
isConnecting
|
||||||
})
|
})
|
||||||
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay])
|
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||||
|
|
||||||
// Don't show indicator when fully connected
|
// Don't show indicator when fully connected (but show when connecting)
|
||||||
if (!localOnlyMode && !offlineMode) return null
|
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setIsExpanded(!isExpanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On mobile, default to collapsed (icon only). On desktop, always show details.
|
||||||
|
const showDetails = !isMobile || isExpanded
|
||||||
|
|
||||||
|
// On mobile when collapsed, make it circular like the highlight button
|
||||||
|
const isCollapsedOnMobile = isMobile && !isExpanded
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relay-status-indicator" title={
|
<div
|
||||||
offlineMode
|
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
|
||||||
? 'Offline - No relays connected'
|
title={
|
||||||
: 'Local Relays Only - Highlights will be marked as local'
|
!isMobile ? (
|
||||||
}>
|
isConnecting
|
||||||
|
? 'Connecting to relays...'
|
||||||
|
: offlineMode
|
||||||
|
? 'Offline - No relays connected'
|
||||||
|
: 'Local Relays Only - Highlights will be marked as local'
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '32px',
|
||||||
|
left: '32px',
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: showDetails ? '0.5rem' : '0',
|
||||||
|
padding: isCollapsedOnMobile ? '0.875rem' : (showDetails ? '0.75rem 1rem' : '0.75rem'),
|
||||||
|
width: isCollapsedOnMobile ? '56px' : 'auto',
|
||||||
|
height: isCollapsedOnMobile ? '56px' : 'auto',
|
||||||
|
backgroundColor: 'rgba(39, 39, 42, 0.9)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
border: '1px solid rgb(82, 82, 91)',
|
||||||
|
borderRadius: isCollapsedOnMobile ? '50%' : '12px',
|
||||||
|
color: 'rgb(228, 228, 231)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||||
|
cursor: isMobile ? 'pointer' : 'default',
|
||||||
|
opacity: isMobile && !showOnMobile ? 0 : 1,
|
||||||
|
visibility: isMobile && !showOnMobile ? 'hidden' : 'visible',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
userSelect: 'none',
|
||||||
|
justifyContent: isCollapsedOnMobile ? 'center' : 'flex-start'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="relay-status-icon">
|
<div className="relay-status-icon">
|
||||||
<FontAwesomeIcon icon={offlineMode ? faCircle : faPlane} />
|
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relay-status-text">
|
{showDetails && (
|
||||||
{offlineMode ? (
|
<>
|
||||||
<>
|
<div
|
||||||
<span className="relay-status-title">Offline</span>
|
className="relay-status-text"
|
||||||
<span className="relay-status-subtitle">No relays connected</span>
|
style={{
|
||||||
</>
|
display: 'flex',
|
||||||
) : (
|
flexDirection: 'column',
|
||||||
<>
|
gap: '0.125rem'
|
||||||
<span className="relay-status-title">Flight Mode</span>
|
}}
|
||||||
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
|
>
|
||||||
</>
|
{isConnecting ? (
|
||||||
)}
|
<span className="relay-status-title">Connecting</span>
|
||||||
</div>
|
) : offlineMode ? (
|
||||||
{!offlineMode && (
|
<>
|
||||||
<div className="relay-status-pulse">
|
<span className="relay-status-title">Offline</span>
|
||||||
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
<span
|
||||||
</div>
|
className="relay-status-subtitle"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
opacity: 0.7,
|
||||||
|
fontWeight: 400
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No relays connected
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="relay-status-title">Flight Mode</span>
|
||||||
|
<span
|
||||||
|
className="relay-status-subtitle"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
opacity: 0.7,
|
||||||
|
fontWeight: 400
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!offlineMode && !isConnecting && (
|
||||||
|
<div className="relay-status-pulse">
|
||||||
|
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models, Helpers } from 'applesauce-core'
|
import { Models, Helpers } from 'applesauce-core'
|
||||||
import { decode, npubEncode } from 'nostr-tools/nip19'
|
import { decode, npubEncode } from 'nostr-tools/nip19'
|
||||||
@@ -24,14 +25,12 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
|
|||||||
|
|
||||||
if (npub) {
|
if (npub) {
|
||||||
return (
|
return (
|
||||||
<a
|
<Link
|
||||||
href={`https://search.dergigi.com/p/${npub}`}
|
to={`/p/${npub}`}
|
||||||
className="nostr-mention"
|
className="nostr-mention"
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
@{display}
|
@{display}
|
||||||
</a>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
src/components/RouteDebug.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation, useMatch } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function RouteDebug() {
|
||||||
|
const location = useLocation()
|
||||||
|
const matchArticle = useMatch('/a/:naddr')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (params.get('debug') !== '1') return
|
||||||
|
|
||||||
|
const info: Record<string, unknown> = {
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: location.search || null,
|
||||||
|
matchedArticleRoute: Boolean(matchArticle),
|
||||||
|
referrer: document.referrer || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.pathname === '/') {
|
||||||
|
// Unexpected during deep-link refresh tests
|
||||||
|
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||||
|
} else {
|
||||||
|
console.debug('[RouteDebug]', info)
|
||||||
|
}
|
||||||
|
}, [location, matchArticle])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,13 +4,14 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { loadFont } from '../utils/fontLoader'
|
import { loadFont } from '../utils/fontLoader'
|
||||||
|
import ThemeSettings from './Settings/ThemeSettings'
|
||||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||||
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
|
||||||
import ZapSettings from './Settings/ZapSettings'
|
import ZapSettings from './Settings/ZapSettings'
|
||||||
import OfflineModeSettings from './Settings/OfflineModeSettings'
|
|
||||||
import RelaySettings from './Settings/RelaySettings'
|
import RelaySettings from './Settings/RelaySettings'
|
||||||
|
import PWASettings from './Settings/PWASettings'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
|
import VersionFooter from './VersionFooter'
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: UserSettings = {
|
const DEFAULT_SETTINGS: UserSettings = {
|
||||||
collapseOnArticleOpen: true,
|
collapseOnArticleOpen: true,
|
||||||
@@ -21,10 +22,10 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
readingFont: 'source-serif-4',
|
readingFont: 'source-serif-4',
|
||||||
fontSize: 21,
|
fontSize: 21,
|
||||||
highlightStyle: 'marker',
|
highlightStyle: 'marker',
|
||||||
highlightColor: '#ffff00',
|
highlightColor: '#fde047',
|
||||||
highlightColorNostrverse: '#9333ea',
|
highlightColorNostrverse: '#9333ea',
|
||||||
highlightColorFriends: '#f97316',
|
highlightColorFriends: '#f97316',
|
||||||
highlightColorMine: '#ffff00',
|
highlightColorMine: '#fde047',
|
||||||
defaultHighlightVisibilityNostrverse: true,
|
defaultHighlightVisibilityNostrverse: true,
|
||||||
defaultHighlightVisibilityFriends: true,
|
defaultHighlightVisibilityFriends: true,
|
||||||
defaultHighlightVisibilityMine: true,
|
defaultHighlightVisibilityMine: true,
|
||||||
@@ -33,6 +34,8 @@ const DEFAULT_SETTINGS: UserSettings = {
|
|||||||
zapSplitAuthorWeight: 50,
|
zapSplitAuthorWeight: 50,
|
||||||
useLocalRelayAsCache: true,
|
useLocalRelayAsCache: true,
|
||||||
rebroadcastToAllRelays: false,
|
rebroadcastToAllRelays: false,
|
||||||
|
paragraphAlignment: 'justify',
|
||||||
|
syncReadingPosition: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
@@ -57,7 +60,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
return migrated
|
return migrated
|
||||||
})
|
})
|
||||||
const isInitialMount = useRef(true)
|
const isInitialMount = useRef(true)
|
||||||
const saveTimeoutRef = useRef<number | null>(null)
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const isLocallyUpdating = useRef(false)
|
const isLocallyUpdating = useRef(false)
|
||||||
|
|
||||||
// Poll for relay status updates
|
// Poll for relay status updates
|
||||||
@@ -158,13 +161,14 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-content">
|
<div className="settings-content">
|
||||||
|
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
|
||||||
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
|
||||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||||
|
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
<VersionFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
125
src/components/Settings/LayoutBehaviorSettings.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
|
interface LayoutBehaviorSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Layout & Behavior</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Bookmark View</label>
|
||||||
|
<div className="setting-buttons">
|
||||||
|
<IconButton
|
||||||
|
icon={faList}
|
||||||
|
onClick={() => onUpdate({ defaultViewMode: 'compact' })}
|
||||||
|
title="Compact list view"
|
||||||
|
ariaLabel="Compact list view"
|
||||||
|
variant={(settings.defaultViewMode || 'compact') === 'compact' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faThLarge}
|
||||||
|
onClick={() => onUpdate({ defaultViewMode: 'cards' })}
|
||||||
|
title="Cards view"
|
||||||
|
ariaLabel="Cards view"
|
||||||
|
variant={settings.defaultViewMode === 'cards' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faImage}
|
||||||
|
onClick={() => onUpdate({ defaultViewMode: 'large' })}
|
||||||
|
title="Large preview view"
|
||||||
|
ariaLabel="Large preview view"
|
||||||
|
variant={settings.defaultViewMode === 'large' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="collapseOnArticleOpen" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="collapseOnArticleOpen"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.collapseOnArticleOpen !== false}
|
||||||
|
onChange={(e) => onUpdate({ collapseOnArticleOpen: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Collapse bookmark bar when opening an article</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="sidebarCollapsed"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.sidebarCollapsed !== false}
|
||||||
|
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Start with bookmarks sidebar collapsed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="highlightsCollapsed"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.highlightsCollapsed !== false}
|
||||||
|
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Start with highlights panel collapsed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="rebroadcastToAllRelays"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.rebroadcastToAllRelays ?? false}
|
||||||
|
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Rebroadcast events while browsing</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="autoCollapseSidebarOnMobile"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||||
|
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Auto-collapse sidebar on small screens</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="syncReadingPosition" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="syncReadingPosition"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.syncReadingPosition ?? false}
|
||||||
|
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Sync reading position across devices</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LayoutBehaviorSettings
|
||||||
|
|
||||||
@@ -3,15 +3,15 @@ import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
|||||||
import { UserSettings } from '../../services/settingsService'
|
import { UserSettings } from '../../services/settingsService'
|
||||||
import IconButton from '../IconButton'
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
interface LayoutNavigationSettingsProps {
|
interface LayoutBehaviorSettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
onUpdate: (updates: Partial<UserSettings>) => void
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ settings, onUpdate }) => {
|
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Layout & Navigation</h3>
|
<h3 className="section-title">Layout & Behavior</h3>
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Default Bookmark View</label>
|
<label>Default Bookmark View</label>
|
||||||
@@ -52,9 +52,61 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
|
|||||||
<span>Collapse bookmark bar when opening an article</span>
|
<span>Collapse bookmark bar when opening an article</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="sidebarCollapsed"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.sidebarCollapsed !== false}
|
||||||
|
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Start with bookmarks sidebar collapsed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="highlightsCollapsed"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.highlightsCollapsed !== false}
|
||||||
|
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Start with highlights panel collapsed</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="rebroadcastToAllRelays"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.rebroadcastToAllRelays ?? false}
|
||||||
|
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Rebroadcast events while browsing</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="autoCollapseSidebarOnMobile"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||||
|
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Auto-collapse sidebar on small screens</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LayoutNavigationSettings
|
export default LayoutBehaviorSettings
|
||||||
|
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { faTrash } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { UserSettings } from '../../services/settingsService'
|
|
||||||
import { getImageCacheStats, clearImageCache } from '../../services/imageCacheService'
|
|
||||||
import IconButton from '../IconButton'
|
|
||||||
|
|
||||||
interface OfflineModeSettingsProps {
|
|
||||||
settings: UserSettings
|
|
||||||
onUpdate: (updates: Partial<UserSettings>) => void
|
|
||||||
onClose?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [cacheStats, setCacheStats] = useState(getImageCacheStats())
|
|
||||||
|
|
||||||
const handleLinkClick = (url: string) => {
|
|
||||||
if (onClose) onClose()
|
|
||||||
navigate(`/r/${encodeURIComponent(url)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearCache = async () => {
|
|
||||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
|
||||||
await clearImageCache()
|
|
||||||
setCacheStats(getImageCacheStats())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cache stats when settings change
|
|
||||||
useEffect(() => {
|
|
||||||
const updateStats = () => setCacheStats(getImageCacheStats())
|
|
||||||
const interval = setInterval(updateStats, 2000) // Update every 2 seconds
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="settings-section">
|
|
||||||
<h3 className="section-title">Flight Mode</h3>
|
|
||||||
|
|
||||||
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
|
||||||
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
|
||||||
<input
|
|
||||||
id="enableImageCache"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.enableImageCache ?? true}
|
|
||||||
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Use local image cache</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{(settings.enableImageCache ?? true) && (
|
|
||||||
<div style={{
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.5rem'
|
|
||||||
}}>
|
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
|
||||||
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
|
||||||
<input
|
|
||||||
id="imageCacheSizeMB"
|
|
||||||
type="number"
|
|
||||||
min="10"
|
|
||||||
max="500"
|
|
||||||
value={settings.imageCacheSizeMB ?? 210}
|
|
||||||
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
|
||||||
style={{
|
|
||||||
width: '50px',
|
|
||||||
padding: '0.15rem 0.35rem',
|
|
||||||
background: 'var(--surface-secondary)',
|
|
||||||
border: '1px solid var(--border-color, #333)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
color: 'inherit',
|
|
||||||
fontSize: 'inherit',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
MB used )
|
|
||||||
</span>
|
|
||||||
<IconButton
|
|
||||||
icon={faTrash}
|
|
||||||
onClick={handleClearCache}
|
|
||||||
title="Clear cache"
|
|
||||||
variant="ghost"
|
|
||||||
size={28}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="useLocalRelayAsCache"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.useLocalRelayAsCache ?? true}
|
|
||||||
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Use local relays as cache</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
marginTop: '1.5rem',
|
|
||||||
padding: '1rem',
|
|
||||||
background: 'var(--surface-secondary)',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
lineHeight: '1.6'
|
|
||||||
}}>
|
|
||||||
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
|
|
||||||
Boris works best with a local relay. Consider running{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
|
||||||
>
|
|
||||||
Citrine
|
|
||||||
</a>
|
|
||||||
{' or '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
|
||||||
>
|
|
||||||
nostr-relay-tray
|
|
||||||
</a>
|
|
||||||
. Don't know what relays are? Learn more{' '}
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
handleLinkClick('https://nostr.how/en/relays')
|
|
||||||
}}
|
|
||||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
{' and '}
|
|
||||||
<a
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
|
||||||
}}
|
|
||||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OfflineModeSettings
|
|
||||||
|
|
||||||
210
src/components/Settings/PWASettings.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { faDownload, faCheckCircle, faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { usePWAInstall } from '../../hooks/usePWAInstall'
|
||||||
|
import { useIsMobile } from '../../hooks/useMediaQuery'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
||||||
|
|
||||||
|
interface PWASettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const { isInstallable, isInstalled, installApp } = usePWAInstall()
|
||||||
|
const [cacheStats, setCacheStats] = useState<{
|
||||||
|
totalSizeMB: number
|
||||||
|
itemCount: number
|
||||||
|
items: Array<{ url: string, sizeMB: number }>
|
||||||
|
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (isInstalled) return
|
||||||
|
const success = await installApp()
|
||||||
|
if (success) {
|
||||||
|
console.log('App installed successfully')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLinkClick = (url: string) => {
|
||||||
|
if (onClose) onClose()
|
||||||
|
navigate(`/r/${encodeURIComponent(url)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearCache = async () => {
|
||||||
|
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||||
|
await clearImageCache()
|
||||||
|
const stats = await getImageCacheStatsAsync()
|
||||||
|
setCacheStats(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache stats periodically
|
||||||
|
useEffect(() => {
|
||||||
|
const updateStats = async () => {
|
||||||
|
const stats = await getImageCacheStatsAsync()
|
||||||
|
setCacheStats(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats() // Initial load
|
||||||
|
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">App & Airplane Mode</h3>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
|
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
Boris is offline‑first by design. You can read, create highlights, and browse your library without being connected to the internet. Boris will store changes locally and sync later.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Flight Mode Section - Checkboxes First */}
|
||||||
|
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||||
|
<input
|
||||||
|
id="enableImageCache"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.enableImageCache ?? true}
|
||||||
|
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Use local image cache</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{(settings.enableImageCache ?? true) && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem'
|
||||||
|
}}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||||
|
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||||
|
<input
|
||||||
|
id="imageCacheSizeMB"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="500"
|
||||||
|
value={settings.imageCacheSizeMB ?? 210}
|
||||||
|
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||||
|
style={{
|
||||||
|
width: '50px',
|
||||||
|
padding: '0.15rem 0.35rem',
|
||||||
|
background: 'var(--surface-secondary)',
|
||||||
|
border: '1px solid var(--border-color, #333)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: 'inherit',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
MB used )
|
||||||
|
</span>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faTrash}
|
||||||
|
onClick={handleClearCache}
|
||||||
|
title="Clear cache"
|
||||||
|
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PWA Install Section - Paragraphs */}
|
||||||
|
<div className="setting-group">
|
||||||
|
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '0.75rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
<strong>Note:</strong> Boris works best with a local relay. Consider running{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||||
|
>
|
||||||
|
Citrine
|
||||||
|
</a>
|
||||||
|
{' or '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||||
|
>
|
||||||
|
nostr-relay-tray
|
||||||
|
</a>
|
||||||
|
{' '}to bring full offline functionality to Boris. Don't know what relays are? Learn more{' '}
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleLinkClick('https://nostr.how/en/relays')
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
{' and '}
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||||
|
<input
|
||||||
|
id="useLocalRelayAsCache"
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.useLocalRelayAsCache ?? true}
|
||||||
|
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
|
||||||
|
className="setting-checkbox"
|
||||||
|
/>
|
||||||
|
<span>Use local relays as cache</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group">
|
||||||
|
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
Install Boris on your device for a native app experience.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
className="zap-preset-btn"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||||
|
disabled={isInstalled || !isInstallable}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isInstalled ? faCheckCircle : faDownload} />
|
||||||
|
{isInstalled ? 'Installed' : 'Install App'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<img
|
||||||
|
src="/pwa.svg"
|
||||||
|
alt="Progressive Web App"
|
||||||
|
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PWASettings
|
||||||
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser, faAlignLeft, faAlignJustify } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { UserSettings } from '../../services/settingsService'
|
||||||
import IconButton from '../IconButton'
|
import IconButton from '../IconButton'
|
||||||
import ColorPicker from '../ColorPicker'
|
import ColorPicker from '../ColorPicker'
|
||||||
@@ -20,35 +19,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Reading & Display</h3>
|
<h3 className="section-title">Reading & Display</h3>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
|
||||||
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
|
|
||||||
<label htmlFor="readingFont">Reading Font</label>
|
|
||||||
<div className="setting-control">
|
|
||||||
<FontSelector
|
|
||||||
value={settings.readingFont || 'source-serif-4'}
|
|
||||||
onChange={(font) => onUpdate({ readingFont: font })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
|
|
||||||
<label>Font Size</label>
|
|
||||||
<div className="setting-buttons">
|
|
||||||
{[16, 18, 21, 24, 28, 32].map(size => (
|
|
||||||
<button
|
|
||||||
key={size}
|
|
||||||
onClick={() => onUpdate({ fontSize: size })}
|
|
||||||
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
|
|
||||||
title={`${size}px`}
|
|
||||||
style={{ fontSize: `${size - 2}px` }}
|
|
||||||
>
|
|
||||||
A
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label>Highlight Style</label>
|
<label>Highlight Style</label>
|
||||||
<div className="setting-buttons">
|
<div className="setting-buttons">
|
||||||
@@ -69,11 +39,99 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Paragraph Alignment</label>
|
||||||
|
<div className="setting-buttons">
|
||||||
|
<IconButton
|
||||||
|
icon={faAlignLeft}
|
||||||
|
onClick={() => onUpdate({ paragraphAlignment: 'left' })}
|
||||||
|
title="Left aligned"
|
||||||
|
ariaLabel="Left aligned"
|
||||||
|
variant={settings.paragraphAlignment === 'left' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faAlignJustify}
|
||||||
|
onClick={() => onUpdate({ paragraphAlignment: 'justify' })}
|
||||||
|
title="Justified"
|
||||||
|
ariaLabel="Justified"
|
||||||
|
variant={(settings.paragraphAlignment || 'justify') === 'justify' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Default Highlight Visibility</label>
|
||||||
|
<div className="highlight-level-toggles">
|
||||||
|
<IconButton
|
||||||
|
icon={faNetworkWired}
|
||||||
|
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||||
|
title="Nostrverse highlights"
|
||||||
|
ariaLabel="Toggle nostrverse highlights by default"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||||
|
opacity: (settings.defaultHighlightVisibilityNostrverse !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUserGroup}
|
||||||
|
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
||||||
|
title="Friends highlights"
|
||||||
|
ariaLabel="Toggle friends highlights by default"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||||
|
opacity: (settings.defaultHighlightVisibilityFriends !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faUser}
|
||||||
|
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
||||||
|
title="My highlights"
|
||||||
|
ariaLabel="Toggle my highlights by default"
|
||||||
|
variant="ghost"
|
||||||
|
style={{
|
||||||
|
color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||||
|
opacity: (settings.defaultHighlightVisibilityMine !== false) ? 1 : 0.4
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label htmlFor="readingFont">Reading Font</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<FontSelector
|
||||||
|
value={settings.readingFont || 'source-serif-4'}
|
||||||
|
onChange={(font) => onUpdate({ readingFont: font })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label className="setting-label">Font Size</label>
|
||||||
|
<div className="setting-control">
|
||||||
|
<div className="setting-buttons">
|
||||||
|
{[16, 18, 21, 24, 28, 32].map(size => (
|
||||||
|
<button
|
||||||
|
key={size}
|
||||||
|
onClick={() => onUpdate({ fontSize: size })}
|
||||||
|
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
|
||||||
|
title={`${size}px`}
|
||||||
|
style={{ fontSize: `${size - 2}px` }}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
<div className="setting-group setting-inline">
|
||||||
<label className="setting-label">My Highlights</label>
|
<label className="setting-label">My Highlights</label>
|
||||||
<div className="setting-control">
|
<div className="setting-control">
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
selectedColor={settings.highlightColorMine || '#ffff00'}
|
selectedColor={settings.highlightColorMine || '#fde047'}
|
||||||
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,39 +157,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="setting-group setting-inline">
|
|
||||||
<label>Default Highlight Visibility</label>
|
|
||||||
<div className="highlight-level-toggles">
|
|
||||||
<button
|
|
||||||
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
|
||||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
|
|
||||||
title="Nostrverse highlights"
|
|
||||||
aria-label="Toggle nostrverse highlights by default"
|
|
||||||
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faNetworkWired} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
|
||||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
|
|
||||||
title="Friends highlights"
|
|
||||||
aria-label="Toggle friends highlights by default"
|
|
||||||
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faUserGroup} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
|
||||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
|
|
||||||
title="My highlights"
|
|
||||||
aria-label="Toggle my highlights by default"
|
|
||||||
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faUser} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label htmlFor="showHighlights" className="checkbox-label">
|
<label htmlFor="showHighlights" className="checkbox-label">
|
||||||
<input
|
<input
|
||||||
@@ -152,7 +177,8 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
|||||||
style={{
|
style={{
|
||||||
fontFamily: previewFontFamily,
|
fontFamily: previewFontFamily,
|
||||||
fontSize: `${settings.fontSize || 21}px`,
|
fontSize: `${settings.fontSize || 21}px`,
|
||||||
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
|
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
|
||||||
|
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<h3>The Quick Brown Fox</h3>
|
<h3>The Quick Brown Fox</h3>
|
||||||
|
|||||||
@@ -100,13 +100,16 @@ const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{
|
<div
|
||||||
fontSize: '0.9rem',
|
className="relay-url"
|
||||||
fontFamily: 'var(--font-mono, monospace)',
|
style={{
|
||||||
whiteSpace: 'nowrap',
|
fontSize: '0.9rem',
|
||||||
overflow: 'hidden',
|
fontFamily: 'var(--font-mono, monospace)',
|
||||||
textOverflow: 'ellipsis'
|
whiteSpace: 'nowrap',
|
||||||
}}>
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formatRelayUrl(relay.url)}
|
{formatRelayUrl(relay.url)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { UserSettings } from '../../services/settingsService'
|
|
||||||
|
|
||||||
interface StartupPreferencesSettingsProps {
|
|
||||||
settings: UserSettings
|
|
||||||
onUpdate: (updates: Partial<UserSettings>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
|
|
||||||
return (
|
|
||||||
<div className="settings-section">
|
|
||||||
<h3 className="section-title">Startup & Behavior</h3>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="sidebarCollapsed"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.sidebarCollapsed !== false}
|
|
||||||
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Start with bookmarks sidebar collapsed</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="highlightsCollapsed"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.highlightsCollapsed !== false}
|
|
||||||
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Start with highlights panel collapsed</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
|
||||||
<input
|
|
||||||
id="rebroadcastToAllRelays"
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings.rebroadcastToAllRelays ?? false}
|
|
||||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
|
||||||
className="setting-checkbox"
|
|
||||||
/>
|
|
||||||
<span>Rebroadcast events while browsing</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StartupPreferencesSettings
|
|
||||||
|
|
||||||
107
src/components/Settings/ThemeSettings.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { faSun, faMoon, faDesktop } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import IconButton from '../IconButton'
|
||||||
|
|
||||||
|
type DarkColorTheme = 'black' | 'midnight' | 'charcoal'
|
||||||
|
type LightColorTheme = 'paper-white' | 'sepia' | 'ivory'
|
||||||
|
|
||||||
|
interface ThemeSettingsProps {
|
||||||
|
settings: UserSettings
|
||||||
|
onUpdate: (updates: Partial<UserSettings>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeSettings: React.FC<ThemeSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
const currentTheme = settings.theme ?? 'system'
|
||||||
|
const currentDarkColor = settings.darkColorTheme ?? 'midnight'
|
||||||
|
const currentLightColor = settings.lightColorTheme ?? 'sepia'
|
||||||
|
|
||||||
|
// Determine which color picker to show based on current theme
|
||||||
|
const showDarkColors = currentTheme === 'dark' || currentTheme === 'system'
|
||||||
|
const showLightColors = currentTheme === 'light' || currentTheme === 'system'
|
||||||
|
|
||||||
|
// Color definitions for swatches
|
||||||
|
const darkColors = {
|
||||||
|
black: '#000000',
|
||||||
|
midnight: '#18181b',
|
||||||
|
charcoal: '#1c1c1e'
|
||||||
|
}
|
||||||
|
|
||||||
|
const lightColors = {
|
||||||
|
'paper-white': '#ffffff',
|
||||||
|
sepia: '#f4f1ea',
|
||||||
|
ivory: '#fffff0'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="section-title">Theme</h3>
|
||||||
|
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Appearance</label>
|
||||||
|
<div className="setting-buttons">
|
||||||
|
<IconButton
|
||||||
|
icon={faSun}
|
||||||
|
onClick={() => onUpdate({ theme: 'light' })}
|
||||||
|
title="Light theme"
|
||||||
|
ariaLabel="Light theme"
|
||||||
|
variant={currentTheme === 'light' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faMoon}
|
||||||
|
onClick={() => onUpdate({ theme: 'dark' })}
|
||||||
|
title="Dark theme"
|
||||||
|
ariaLabel="Dark theme"
|
||||||
|
variant={currentTheme === 'dark' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faDesktop}
|
||||||
|
onClick={() => onUpdate({ theme: 'system' })}
|
||||||
|
title="Use system preference"
|
||||||
|
ariaLabel="Use system preference"
|
||||||
|
variant={currentTheme === 'system' ? 'primary' : 'ghost'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDarkColors && (
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Dark Theme</label>
|
||||||
|
<div className="color-picker">
|
||||||
|
{Object.entries(darkColors).map(([key, color]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`color-swatch ${currentDarkColor === key ? 'active' : ''}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onClick={() => onUpdate({ darkColorTheme: key as DarkColorTheme })}
|
||||||
|
title={key.charAt(0).toUpperCase() + key.slice(1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showLightColors && (
|
||||||
|
<div className="setting-group setting-inline">
|
||||||
|
<label>Light Theme</label>
|
||||||
|
<div className="color-picker">
|
||||||
|
{Object.entries(lightColors).map(([key, color]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className={`color-swatch ${currentLightColor === key ? 'active' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
border: color === '#ffffff' ? '2px solid #e5e7eb' : '1px solid #e5e7eb'
|
||||||
|
}}
|
||||||
|
onClick={() => onUpdate({ lightColorTheme: key as LightColorTheme })}
|
||||||
|
title={key.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeSettings
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { UserSettings } from '../../services/settingsService'
|
import { UserSettings } from '../../services/settingsService'
|
||||||
|
import { useIsMobile } from '../../hooks/useMediaQuery'
|
||||||
|
|
||||||
interface ZapSettingsProps {
|
interface ZapSettingsProps {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
@@ -7,6 +8,7 @@ interface ZapSettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
|
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
|
||||||
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
|
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
|
||||||
const authorWeight = settings.zapSplitAuthorWeight ?? 50
|
const authorWeight = settings.zapSplitAuthorWeight ?? 50
|
||||||
@@ -42,98 +44,119 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
|||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
<h3 className="section-title">Zap Splits</h3>
|
<h3 className="section-title">Zap Splits</h3>
|
||||||
|
|
||||||
<div className="setting-group">
|
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
|
||||||
<label className="setting-label">Presets</label>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
<div className="zap-preset-buttons">
|
<div className="setting-group">
|
||||||
<button
|
<label className="setting-label">Presets</label>
|
||||||
onClick={() => applyPreset(presets.default)}
|
<div className="zap-preset-buttons">
|
||||||
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
<button
|
||||||
title="You: 49%, Author: 49%, Boris: 2%"
|
onClick={() => applyPreset(presets.default)}
|
||||||
>
|
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
||||||
Default
|
title="You: 49%, Author: 49%, Boris: 2%"
|
||||||
</button>
|
>
|
||||||
<button
|
Default
|
||||||
onClick={() => applyPreset(presets.generous)}
|
</button>
|
||||||
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
<button
|
||||||
title="You: 6%, Author: 83%, Boris: 11%"
|
onClick={() => applyPreset(presets.generous)}
|
||||||
>
|
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
||||||
Generous
|
title="You: 6%, Author: 83%, Boris: 11%"
|
||||||
</button>
|
>
|
||||||
<button
|
Generous
|
||||||
onClick={() => applyPreset(presets.selfless)}
|
</button>
|
||||||
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
<button
|
||||||
title="You: 1%, Author: 80%, Boris: 19%"
|
onClick={() => applyPreset(presets.selfless)}
|
||||||
>
|
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
||||||
Selfless
|
title="You: 1%, Author: 80%, Boris: 19%"
|
||||||
</button>
|
>
|
||||||
<button
|
Selfless
|
||||||
onClick={() => applyPreset(presets.boris)}
|
</button>
|
||||||
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
<button
|
||||||
title="You: 10%, Author: 10%, Boris: 80%"
|
onClick={() => applyPreset(presets.boris)}
|
||||||
>
|
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
||||||
Boris 🧡
|
title="You: 10%, Author: 10%, Boris: 80%"
|
||||||
</button>
|
>
|
||||||
</div>
|
Boris 🧡
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
<div className="setting-group">
|
|
||||||
<label className="setting-label">Your Share</label>
|
|
||||||
<div className="zap-split-container">
|
|
||||||
<div className="zap-split-labels">
|
|
||||||
<span className="zap-split-label">Weight: {highlighterWeight}</span>
|
|
||||||
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="range"
|
<div className="setting-group">
|
||||||
min="0"
|
<div className="zap-split-container">
|
||||||
max="100"
|
<div className="zap-split-labels">
|
||||||
value={highlighterWeight}
|
<span className="zap-split-label">Your Share: {highlighterWeight}</span>
|
||||||
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||||
className="zap-split-slider"
|
</div>
|
||||||
/>
|
<input
|
||||||
</div>
|
type="range"
|
||||||
</div>
|
min="0"
|
||||||
|
max="100"
|
||||||
<div className="setting-group">
|
value={highlighterWeight}
|
||||||
<label className="setting-label">Author(s) Share</label>
|
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
||||||
<div className="zap-split-container">
|
className="zap-split-slider"
|
||||||
<div className="zap-split-labels">
|
list="highlighter-ticks"
|
||||||
<span className="zap-split-label">Weight: {authorWeight}</span>
|
/>
|
||||||
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
<datalist id="highlighter-ticks">
|
||||||
|
<option value="50" label="50%"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
value={authorWeight}
|
|
||||||
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
|
||||||
className="zap-split-slider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
<label className="setting-label">Support Boris</label>
|
<div className="zap-split-container">
|
||||||
<div className="zap-split-container">
|
<div className="zap-split-labels">
|
||||||
<div className="zap-split-labels">
|
<span className="zap-split-label">Author's Share: {authorWeight}</span>
|
||||||
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
|
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||||
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={authorWeight}
|
||||||
|
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
||||||
|
className="zap-split-slider"
|
||||||
|
list="author-ticks"
|
||||||
|
/>
|
||||||
|
<datalist id="author-ticks">
|
||||||
|
<option value="50" label="50%"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="10"
|
|
||||||
step="0.1"
|
|
||||||
value={borisWeight}
|
|
||||||
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
|
||||||
className="zap-split-slider"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="zap-split-description">
|
<div className="setting-group">
|
||||||
Weights determine zap splits when highlighting nostr-native content.
|
<div className="zap-split-container">
|
||||||
If the content has multiple authors, their share is divided proportionally.
|
<div className="zap-split-labels">
|
||||||
|
<span className="zap-split-label">Boris' Share: {borisWeight.toFixed(1)}</span>
|
||||||
|
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.1"
|
||||||
|
value={borisWeight}
|
||||||
|
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
||||||
|
className="zap-split-slider"
|
||||||
|
list="boris-ticks"
|
||||||
|
/>
|
||||||
|
<datalist id="boris-ticks">
|
||||||
|
<option value="5" label="5"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||||
|
Weights determine zap splits when highlighting nostr-native content.
|
||||||
|
If the content has multiple authors, their share is divided proportionally.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<img
|
||||||
|
src="/zaps.svg"
|
||||||
|
alt="Zap Splits"
|
||||||
|
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome, faPlus } from '@fortawesome/free-solid-svg-icons'
|
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { useEventModel } from 'applesauce-react/hooks'
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
import { Models } from 'applesauce-core'
|
import { Models } from 'applesauce-core'
|
||||||
import { Accounts } from 'applesauce-accounts'
|
import { Accounts } from 'applesauce-accounts'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import AddBookmarkModal from './AddBookmarkModal'
|
|
||||||
import { createWebBookmark } from '../services/webBookmarkService'
|
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
|
|
||||||
interface SidebarHeaderProps {
|
interface SidebarHeaderProps {
|
||||||
onToggleCollapse: () => void
|
onToggleCollapse: () => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
onRefresh?: () => void
|
isMobile?: boolean
|
||||||
isRefreshing?: boolean
|
|
||||||
relayPool: RelayPool | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing, relayPool }) => {
|
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const accountManager = Hooks.useAccountManager()
|
const accountManager = Hooks.useAccountManager()
|
||||||
@@ -37,7 +30,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
accountManager.setActive(account)
|
accountManager.setActive(account)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed:', error)
|
console.error('Login failed:', error)
|
||||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
||||||
} finally {
|
} finally {
|
||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
}
|
}
|
||||||
@@ -55,38 +48,40 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
|
||||||
if (!activeAccount || !relayPool) {
|
|
||||||
throw new Error('Please login to create bookmarks')
|
|
||||||
}
|
|
||||||
|
|
||||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
|
||||||
|
|
||||||
// Refresh bookmarks after creating
|
|
||||||
if (onRefresh) {
|
|
||||||
onRefresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileImage = getProfileImage()
|
const profileImage = getProfileImage()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-header-bar">
|
<div className="sidebar-header-bar">
|
||||||
<button
|
{isMobile ? (
|
||||||
onClick={onToggleCollapse}
|
<IconButton
|
||||||
className="toggle-sidebar-btn"
|
icon={faTimes}
|
||||||
title="Collapse bookmarks sidebar"
|
onClick={onToggleCollapse}
|
||||||
aria-label="Collapse bookmarks sidebar"
|
title="Close sidebar"
|
||||||
>
|
ariaLabel="Close sidebar"
|
||||||
<FontAwesomeIcon icon={faChevronRight} />
|
variant="ghost"
|
||||||
</button>
|
className="mobile-close-btn"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="toggle-sidebar-btn"
|
||||||
|
title="Collapse bookmarks sidebar"
|
||||||
|
aria-label="Collapse bookmarks sidebar"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="sidebar-header-right">
|
<div className="sidebar-header-right">
|
||||||
<div
|
<div
|
||||||
className="profile-avatar"
|
className="profile-avatar"
|
||||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
onClick={
|
||||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
activeAccount
|
||||||
|
? () => navigate('/me')
|
||||||
|
: (isConnecting ? () => {} : handleLogin)
|
||||||
|
}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{profileImage ? (
|
{profileImage ? (
|
||||||
<img src={profileImage} alt={getUserDisplayName()} />
|
<img src={profileImage} alt={getUserDisplayName()} />
|
||||||
@@ -101,6 +96,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Home"
|
ariaLabel="Home"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={faNewspaper}
|
||||||
|
onClick={() => navigate('/explore')}
|
||||||
|
title="Explore"
|
||||||
|
ariaLabel="Explore"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faGear}
|
icon={faGear}
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
@@ -108,26 +110,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
ariaLabel="Settings"
|
ariaLabel="Settings"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
{onRefresh && (
|
|
||||||
<IconButton
|
|
||||||
icon={faRotate}
|
|
||||||
onClick={onRefresh}
|
|
||||||
title="Refresh bookmarks"
|
|
||||||
ariaLabel="Refresh bookmarks"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
spin={isRefreshing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeAccount && (
|
|
||||||
<IconButton
|
|
||||||
icon={faPlus}
|
|
||||||
onClick={() => setShowAddModal(true)}
|
|
||||||
title="Add bookmark"
|
|
||||||
ariaLabel="Add bookmark"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeAccount ? (
|
{activeAccount ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={faRightFromBracket}
|
icon={faRightFromBracket}
|
||||||
@@ -147,12 +129,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showAddModal && (
|
|
||||||
<AddBookmarkModal
|
|
||||||
onClose={() => setShowAddModal(false)}
|
|
||||||
onSave={handleSaveBookmark}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/components/Skeletons/BlogPostSkeleton.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Skeleton from 'react-loading-skeleton'
|
||||||
|
|
||||||
|
export const BlogPostSkeleton: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="blog-post-card"
|
||||||
|
style={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="blog-post-card-image">
|
||||||
|
<Skeleton height={200} style={{ display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
<div className="blog-post-card-content">
|
||||||
|
<Skeleton
|
||||||
|
height={24}
|
||||||
|
width="85%"
|
||||||
|
style={{ marginBottom: '0.75rem' }}
|
||||||
|
className="blog-post-card-title"
|
||||||
|
/>
|
||||||
|
<Skeleton
|
||||||
|
count={2}
|
||||||
|
style={{ marginBottom: '0.5rem' }}
|
||||||
|
className="blog-post-card-summary"
|
||||||
|
/>
|
||||||
|
<div className="blog-post-card-meta" style={{ display: 'flex', gap: '1rem' }}>
|
||||||
|
<span className="blog-post-card-author" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<Skeleton width={100} height={14} />
|
||||||
|
</span>
|
||||||
|
<span className="blog-post-card-date" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<Skeleton width={80} height={14} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
80
src/components/Skeletons/BookmarkSkeleton.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Skeleton from 'react-loading-skeleton'
|
||||||
|
import { ViewMode } from '../Bookmarks'
|
||||||
|
|
||||||
|
interface BookmarkSkeletonProps {
|
||||||
|
viewMode: ViewMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BookmarkSkeleton: React.FC<BookmarkSkeletonProps> = ({ viewMode }) => {
|
||||||
|
if (viewMode === 'compact') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bookmark-item-compact"
|
||||||
|
style={{ padding: '0.75rem', marginBottom: '0.5rem' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start' }}>
|
||||||
|
<Skeleton width={40} height={40} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Skeleton width="80%" height={16} style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<Skeleton width="60%" height={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'cards') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bookmark-card"
|
||||||
|
style={{
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Skeleton height={160} style={{ display: 'block' }} />
|
||||||
|
<div style={{ padding: '1rem' }}>
|
||||||
|
<Skeleton height={20} width="90%" style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton count={2} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||||
|
<Skeleton width={80} height={14} />
|
||||||
|
<Skeleton width={60} height={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// large view
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bookmark-large"
|
||||||
|
style={{
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Skeleton height={240} style={{ display: 'block' }} />
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<Skeleton height={24} width="85%" style={{ marginBottom: '0.75rem' }} />
|
||||||
|
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
|
||||||
|
<Skeleton circle width={32} height={32} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Skeleton width={120} height={14} style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<Skeleton width={100} height={12} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
66
src/components/Skeletons/ContentSkeleton.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Skeleton from 'react-loading-skeleton'
|
||||||
|
|
||||||
|
export const ContentSkeleton: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="reader-content"
|
||||||
|
style={{
|
||||||
|
maxWidth: '900px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: '2rem 1rem'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Title */}
|
||||||
|
<Skeleton
|
||||||
|
height={48}
|
||||||
|
width="90%"
|
||||||
|
style={{ marginBottom: '1rem' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Byline / Meta */}
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', alignItems: 'center' }}>
|
||||||
|
<Skeleton circle width={40} height={40} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Skeleton width={150} height={16} style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<Skeleton width={200} height={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover image */}
|
||||||
|
<Skeleton
|
||||||
|
height={400}
|
||||||
|
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Paragraphs */}
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton width="80%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<Skeleton count={4} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton width="65%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton width="90%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Another image placeholder */}
|
||||||
|
<Skeleton
|
||||||
|
height={300}
|
||||||
|
style={{ marginBottom: '2rem', display: 'block', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<Skeleton count={3} style={{ marginBottom: '0.5rem' }} />
|
||||||
|
<Skeleton width="75%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
36
src/components/Skeletons/HighlightSkeleton.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Skeleton from 'react-loading-skeleton'
|
||||||
|
|
||||||
|
export const HighlightSkeleton: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="highlight-item"
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)'
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Author line with avatar */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||||
|
<Skeleton circle width={24} height={24} />
|
||||||
|
<Skeleton width={120} height={14} />
|
||||||
|
<Skeleton width={60} height={12} style={{ marginLeft: 'auto' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Highlight content */}
|
||||||
|
<div style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<Skeleton count={2} style={{ marginBottom: '0.25rem' }} />
|
||||||
|
<Skeleton width="70%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Citation/context */}
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<Skeleton width="90%" height={12} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
49
src/components/Skeletons/SkeletonThemeProvider.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { SkeletonTheme } from 'react-loading-skeleton'
|
||||||
|
|
||||||
|
interface SkeletonThemeProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SkeletonThemeProvider: React.FC<SkeletonThemeProviderProps> = ({ children }) => {
|
||||||
|
const [colors, setColors] = useState({
|
||||||
|
baseColor: '#27272a',
|
||||||
|
highlightColor: '#52525b'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateColors = () => {
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement)
|
||||||
|
const baseColor = rootStyles.getPropertyValue('--color-bg-elevated').trim() || '#27272a'
|
||||||
|
const highlightColor = rootStyles.getPropertyValue('--color-border-subtle').trim() || '#52525b'
|
||||||
|
|
||||||
|
setColors({ baseColor, highlightColor })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateColors()
|
||||||
|
|
||||||
|
// Watch for theme changes via MutationObserver
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
||||||
|
updateColors()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class']
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SkeletonTheme baseColor={colors.baseColor} highlightColor={colors.highlightColor}>
|
||||||
|
{children}
|
||||||
|
</SkeletonTheme>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
6
src/components/Skeletons/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { SkeletonThemeProvider } from './SkeletonThemeProvider'
|
||||||
|
export { BookmarkSkeleton } from './BookmarkSkeleton'
|
||||||
|
export { BlogPostSkeleton } from './BlogPostSkeleton'
|
||||||
|
export { HighlightSkeleton } from './HighlightSkeleton'
|
||||||
|
export { ContentSkeleton } from './ContentSkeleton'
|
||||||
|
|
||||||
235
src/components/Support.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||||
|
import { fetchProfiles } from '../services/profileService'
|
||||||
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { Models } from 'applesauce-core'
|
||||||
|
import { useEventModel } from 'applesauce-react/hooks'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
interface SupportProps {
|
||||||
|
relayPool: RelayPool
|
||||||
|
eventStore: IEventStore
|
||||||
|
settings: UserSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupporterProfile = ZapSender
|
||||||
|
|
||||||
|
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
|
||||||
|
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSupporters = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const zappers = await fetchBorisZappers(relayPool)
|
||||||
|
|
||||||
|
if (zappers.length > 0) {
|
||||||
|
const pubkeys = zappers.map(z => z.pubkey)
|
||||||
|
await fetchProfiles(relayPool, eventStore, pubkeys, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSupporters(zappers)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load supporters:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSupporters()
|
||||||
|
}, [relayPool, eventStore, settings])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
|
||||||
|
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
|
||||||
|
<div className="text-center mb-16 md:mb-20">
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<img
|
||||||
|
src="/thank-you.svg"
|
||||||
|
alt="Thank you"
|
||||||
|
className="w-56 h-56 md:w-72 md:h-72 opacity-90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Thank You!
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
Your{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.readwithboris.com/#pricing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
zaps
|
||||||
|
</a>
|
||||||
|
{' '}help keep this project alive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{supporters.length === 0 ? (
|
||||||
|
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
<p>No supporters yet. Be the first to zap Boris!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Whales Section */}
|
||||||
|
{supporters.filter(s => s.isWhale).length > 0 && (
|
||||||
|
<div className="mb-16 md:mb-20">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Legends
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
|
||||||
|
{supporters.filter(s => s.isWhale).map(supporter => (
|
||||||
|
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={true} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Regular Supporters Section */}
|
||||||
|
{supporters.filter(s => !s.isWhale).length > 0 && (
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
|
||||||
|
Supporters
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
|
||||||
|
{supporters.filter(s => !s.isWhale).map(supporter => (
|
||||||
|
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={false} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-16 md:mt-20 pt-8 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<p className="text-base" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
|
Zap{' '}
|
||||||
|
<a
|
||||||
|
href="https://njump.me/npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
Boris
|
||||||
|
</a>
|
||||||
|
{' '}a{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.readwithboris.com/#pricing"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
style={{ color: 'var(--color-primary)' }}
|
||||||
|
>
|
||||||
|
meaningful amount of sats
|
||||||
|
</a>
|
||||||
|
{' '}and your avatar will show above.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
Total supporters: {supporters.length} •
|
||||||
|
Total zaps: {supporters.reduce((sum, s) => sum + s.zapCount, 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupporterCardProps {
|
||||||
|
supporter: SupporterProfile
|
||||||
|
isWhale: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
|
||||||
|
|
||||||
|
const picture = profile?.picture
|
||||||
|
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const npub = nip19.npubEncode(supporter.pubkey)
|
||||||
|
navigate(`/p/${npub}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className={`rounded-full overflow-hidden flex items-center justify-center cursor-pointer transition-transform hover:scale-105
|
||||||
|
${isWhale ? 'w-24 h-24 md:w-28 md:h-28 ring-4 ring-yellow-400' : 'w-10 h-10 md:w-12 md:h-12'}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--color-bg-elevated)'
|
||||||
|
}}
|
||||||
|
title={`${name} • ${supporter.totalSats.toLocaleString()} sats`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{picture ? (
|
||||||
|
<img
|
||||||
|
src={picture}
|
||||||
|
alt={name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faUserCircle}
|
||||||
|
className={isWhale ? 'text-5xl' : 'text-3xl'}
|
||||||
|
style={{ color: 'var(--color-border)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Whale Badge */}
|
||||||
|
{isWhale && (
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
|
||||||
|
style={{ borderColor: 'var(--color-bg)' }}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHeart} className="text-zinc-900 text-sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name and Total */}
|
||||||
|
<div className="mt-2 text-center">
|
||||||
|
<p
|
||||||
|
className={`font-medium truncate max-w-full ${isWhale ? 'text-sm' : 'text-xs'}`}
|
||||||
|
style={{ color: 'var(--color-text)' }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={isWhale ? 'text-xs' : 'text-[10px]'}
|
||||||
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
>
|
||||||
|
{supporter.totalSats.toLocaleString()} sats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Support
|
||||||
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
import { BookmarkList } from './BookmarkList'
|
import { BookmarkList } from './BookmarkList'
|
||||||
@@ -16,18 +18,28 @@ import { UserSettings } from '../services/settingsService'
|
|||||||
import { HighlightVisibility } from './HighlightsPanel'
|
import { HighlightVisibility } from './HighlightsPanel'
|
||||||
import { HighlightButtonRef } from './HighlightButton'
|
import { HighlightButtonRef } from './HighlightButton'
|
||||||
import { BookmarkReference } from '../utils/contentLoader'
|
import { BookmarkReference } from '../utils/contentLoader'
|
||||||
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
|
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
|
||||||
interface ThreePaneLayoutProps {
|
interface ThreePaneLayoutProps {
|
||||||
// Layout state
|
// Layout state
|
||||||
isCollapsed: boolean
|
isCollapsed: boolean
|
||||||
isHighlightsCollapsed: boolean
|
isHighlightsCollapsed: boolean
|
||||||
|
isSidebarOpen: boolean
|
||||||
showSettings: boolean
|
showSettings: boolean
|
||||||
|
showExplore?: boolean
|
||||||
|
showMe?: boolean
|
||||||
|
showProfile?: boolean
|
||||||
|
showSupport?: boolean
|
||||||
|
|
||||||
// Bookmarks pane
|
// Bookmarks pane
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
bookmarksLoading: boolean
|
bookmarksLoading: boolean
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
isRefreshing: boolean
|
isRefreshing: boolean
|
||||||
|
lastFetchTime?: number | null
|
||||||
onToggleSidebar: () => void
|
onToggleSidebar: () => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
onViewModeChange: (mode: ViewMode) => void
|
onViewModeChange: (mode: ViewMode) => void
|
||||||
@@ -52,6 +64,8 @@ interface ThreePaneLayoutProps {
|
|||||||
onClearSelection: () => void
|
onClearSelection: () => void
|
||||||
currentUserPubkey?: string
|
currentUserPubkey?: string
|
||||||
followedPubkeys: Set<string>
|
followedPubkeys: Set<string>
|
||||||
|
activeAccount?: IAccount | null
|
||||||
|
currentArticle?: NostrEvent | null
|
||||||
|
|
||||||
// Highlights pane
|
// Highlights pane
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
@@ -71,17 +85,233 @@ interface ThreePaneLayoutProps {
|
|||||||
toastMessage?: string
|
toastMessage?: string
|
||||||
toastType?: 'success' | 'error'
|
toastType?: 'success' | 'error'
|
||||||
onClearToast: () => void
|
onClearToast: () => void
|
||||||
|
|
||||||
|
// Optional Explore content
|
||||||
|
explore?: React.ReactNode
|
||||||
|
|
||||||
|
// Optional Me content
|
||||||
|
me?: React.ReactNode
|
||||||
|
|
||||||
|
// Optional Profile content
|
||||||
|
profile?: React.ReactNode
|
||||||
|
|
||||||
|
// Optional Support content
|
||||||
|
support?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||||
|
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Detect scroll direction and position to hide/show mobile buttons
|
||||||
|
// Only hide on scroll down when viewing article content
|
||||||
|
const isViewingArticle = !!(props.selectedUrl)
|
||||||
|
const scrollDirection = useScrollDirection({
|
||||||
|
threshold: 10,
|
||||||
|
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && isViewingArticle
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track if we're at the top of the page
|
||||||
|
const [isAtTop, setIsAtTop] = useState(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile || !isViewingArticle) return
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsAtTop(window.scrollY <= 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll() // Check initial position
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [isMobile, isViewingArticle])
|
||||||
|
|
||||||
|
// Bookmark button: hide only when scrolling down
|
||||||
|
const showBookmarkButton = scrollDirection !== 'down'
|
||||||
|
// Highlights button: hide when scrolling down OR at the top
|
||||||
|
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
||||||
|
|
||||||
|
// Lock body scroll when mobile sidebar or highlights is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
|
||||||
|
document.body.classList.add('mobile-sidebar-open')
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('mobile-sidebar-open')
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove('mobile-sidebar-open')
|
||||||
|
}
|
||||||
|
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
|
||||||
|
|
||||||
|
// Handle ESC key to close sidebar or highlights
|
||||||
|
useEffect(() => {
|
||||||
|
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
|
||||||
|
|
||||||
|
if (!isMobile) return
|
||||||
|
if (!isSidebarOpen && isHighlightsCollapsed) return
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (isSidebarOpen) {
|
||||||
|
onToggleSidebar()
|
||||||
|
} else if (!isHighlightsCollapsed) {
|
||||||
|
onToggleHighlightsPanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape)
|
||||||
|
}, [isMobile, props])
|
||||||
|
|
||||||
|
// Trap focus in sidebar when open on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile || !props.isSidebarOpen || !sidebarRef.current) return
|
||||||
|
|
||||||
|
const sidebar = sidebarRef.current
|
||||||
|
const focusableElements = sidebar.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
const firstElement = focusableElements[0]
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1]
|
||||||
|
|
||||||
|
const handleTab = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
lastElement?.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
firstElement?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebar.addEventListener('keydown', handleTab)
|
||||||
|
firstElement?.focus()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
sidebar.removeEventListener('keydown', handleTab)
|
||||||
|
}
|
||||||
|
}, [isMobile, props.isSidebarOpen])
|
||||||
|
|
||||||
|
// Trap focus in highlights panel when open on mobile
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile || props.isHighlightsCollapsed || !highlightsRef.current) return
|
||||||
|
|
||||||
|
const highlights = highlightsRef.current
|
||||||
|
const focusableElements = highlights.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
const firstElement = focusableElements[0]
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1]
|
||||||
|
|
||||||
|
const handleTab = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
lastElement?.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
firstElement?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
highlights.addEventListener('keydown', handleTab)
|
||||||
|
firstElement?.focus()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
highlights.removeEventListener('keydown', handleTab)
|
||||||
|
}
|
||||||
|
}, [isMobile, props.isHighlightsCollapsed])
|
||||||
|
|
||||||
|
const handleBackdropClick = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
if (props.isSidebarOpen) {
|
||||||
|
props.onToggleSidebar()
|
||||||
|
} else if (!props.isHighlightsCollapsed) {
|
||||||
|
props.onToggleHighlightsPanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Mobile bookmark button - always show except on settings page */}
|
||||||
|
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && (
|
||||||
|
<button
|
||||||
|
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||||
|
showBookmarkButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||||
|
left: 'calc(1rem + env(safe-area-inset-left))',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px'
|
||||||
|
}}
|
||||||
|
onClick={props.onToggleSidebar}
|
||||||
|
aria-label="Open bookmarks"
|
||||||
|
aria-expanded={props.isSidebarOpen}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBookmark} size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile highlights button - only show when viewing article content */}
|
||||||
|
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && isViewingArticle && (
|
||||||
|
<button
|
||||||
|
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||||
|
showHighlightsButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||||
|
right: 'calc(1rem + env(safe-area-inset-right))',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
backgroundColor: `${props.settings.highlightColorMine || '#fde047'}B3`,
|
||||||
|
color: '#000'
|
||||||
|
}}
|
||||||
|
onClick={props.onToggleHighlightsPanel}
|
||||||
|
aria-label="Open highlights"
|
||||||
|
aria-expanded={!props.isHighlightsCollapsed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faHighlighter} size="sm" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile backdrop */}
|
||||||
|
{isMobile && (
|
||||||
|
<div
|
||||||
|
className={`fixed inset-0 bg-black/45 z-[999] transition-opacity duration-300 ${
|
||||||
|
(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'block opacity-100' : 'hidden opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={`three-pane ${props.isCollapsed ? 'sidebar-collapsed' : ''} ${props.isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
<div className={`three-pane ${props.isCollapsed ? 'sidebar-collapsed' : ''} ${props.isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
||||||
<div className="pane sidebar">
|
<div
|
||||||
|
ref={sidebarRef}
|
||||||
|
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
||||||
|
aria-hidden={isMobile && !props.isSidebarOpen}
|
||||||
|
>
|
||||||
<BookmarkList
|
<BookmarkList
|
||||||
bookmarks={props.bookmarks}
|
bookmarks={props.bookmarks}
|
||||||
onSelectUrl={props.onSelectUrl}
|
onSelectUrl={props.onSelectUrl}
|
||||||
isCollapsed={props.isCollapsed}
|
isCollapsed={isMobile ? false : props.isCollapsed}
|
||||||
onToggleCollapse={props.onToggleSidebar}
|
onToggleCollapse={props.onToggleSidebar}
|
||||||
onLogout={props.onLogout}
|
onLogout={props.onLogout}
|
||||||
viewMode={props.viewMode}
|
viewMode={props.viewMode}
|
||||||
@@ -90,12 +320,17 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
onOpenSettings={props.onOpenSettings}
|
onOpenSettings={props.onOpenSettings}
|
||||||
onRefresh={props.onRefresh}
|
onRefresh={props.onRefresh}
|
||||||
isRefreshing={props.isRefreshing}
|
isRefreshing={props.isRefreshing}
|
||||||
|
lastFetchTime={props.lastFetchTime}
|
||||||
loading={props.bookmarksLoading}
|
loading={props.bookmarksLoading}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
|
isMobile={isMobile}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="pane main">
|
<div
|
||||||
|
ref={mainPaneRef}
|
||||||
|
className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}
|
||||||
|
>
|
||||||
{props.showSettings ? (
|
{props.showSettings ? (
|
||||||
<Settings
|
<Settings
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
@@ -103,6 +338,26 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
onClose={props.onCloseSettings}
|
onClose={props.onCloseSettings}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
/>
|
/>
|
||||||
|
) : props.showExplore && props.explore ? (
|
||||||
|
// Render Explore inside the main pane to keep side panels
|
||||||
|
<>
|
||||||
|
{props.explore}
|
||||||
|
</>
|
||||||
|
) : props.showMe && props.me ? (
|
||||||
|
// Render Me inside the main pane to keep side panels
|
||||||
|
<>
|
||||||
|
{props.me}
|
||||||
|
</>
|
||||||
|
) : props.showProfile && props.profile ? (
|
||||||
|
// Render Profile inside the main pane to keep side panels
|
||||||
|
<>
|
||||||
|
{props.profile}
|
||||||
|
</>
|
||||||
|
) : props.showSupport && props.support ? (
|
||||||
|
// Render Support inside the main pane to keep side panels
|
||||||
|
<>
|
||||||
|
{props.support}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ContentPanel
|
<ContentPanel
|
||||||
loading={props.readerLoading}
|
loading={props.readerLoading}
|
||||||
@@ -125,10 +380,19 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
currentUserPubkey={props.currentUserPubkey}
|
currentUserPubkey={props.currentUserPubkey}
|
||||||
followedPubkeys={props.followedPubkeys}
|
followedPubkeys={props.followedPubkeys}
|
||||||
settings={props.settings}
|
settings={props.settings}
|
||||||
|
relayPool={props.relayPool}
|
||||||
|
activeAccount={props.activeAccount}
|
||||||
|
currentArticle={props.currentArticle}
|
||||||
|
isSidebarCollapsed={props.isCollapsed}
|
||||||
|
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pane highlights">
|
<div
|
||||||
|
ref={highlightsRef}
|
||||||
|
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
||||||
|
aria-hidden={isMobile && props.isHighlightsCollapsed}
|
||||||
|
>
|
||||||
<HighlightsPanel
|
<HighlightsPanel
|
||||||
highlights={props.highlights}
|
highlights={props.highlights}
|
||||||
loading={props.highlightsLoading}
|
loading={props.highlightsLoading}
|
||||||
@@ -146,6 +410,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
followedPubkeys={props.followedPubkeys}
|
followedPubkeys={props.followedPubkeys}
|
||||||
relayPool={props.relayPool}
|
relayPool={props.relayPool}
|
||||||
eventStore={props.eventStore}
|
eventStore={props.eventStore}
|
||||||
|
settings={props.settings}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,10 +418,13 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
|||||||
<HighlightButton
|
<HighlightButton
|
||||||
ref={props.highlightButtonRef}
|
ref={props.highlightButtonRef}
|
||||||
onHighlight={props.onCreateHighlight}
|
onHighlight={props.onCreateHighlight}
|
||||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
highlightColor={props.settings.highlightColorMine || '#ffff00'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<RelayStatusIndicator relayPool={props.relayPool} />
|
<RelayStatusIndicator
|
||||||
|
relayPool={props.relayPool}
|
||||||
|
showOnMobile={showBookmarkButton}
|
||||||
|
/>
|
||||||
{props.toastMessage && (
|
{props.toastMessage && (
|
||||||
<Toast
|
<Toast
|
||||||
message={props.toastMessage}
|
message={props.toastMessage}
|
||||||
|
|||||||
32
src/components/VersionFooter.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* global __APP_VERSION__, __GIT_COMMIT__, __GIT_COMMIT_URL__, __RELEASE_URL__ */
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const VersionFooter: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
|
||||||
|
<span>
|
||||||
|
{typeof __RELEASE_URL__ !== 'undefined' && __RELEASE_URL__ ? (
|
||||||
|
<a href={__RELEASE_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
`Version ${typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
|
||||||
|
<span>
|
||||||
|
{' '}·{' '}
|
||||||
|
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
|
||||||
|
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VersionFooter
|
||||||
15
src/config/kinds.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Nostr event kinds used throughout the application
|
||||||
|
export const KINDS = {
|
||||||
|
Highlights: 9802, // NIP-?? user highlights
|
||||||
|
BlogPost: 30023, // NIP-23 long-form article
|
||||||
|
AppData: 30078, // NIP-78 application data (reading positions)
|
||||||
|
List: 30001, // NIP-51 list (addressable)
|
||||||
|
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||||
|
ListSimple: 10003, // NIP-51 simple list
|
||||||
|
WebBookmark: 39701, // NIP-B0 web bookmark
|
||||||
|
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
|
||||||
|
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
||||||
|
|
||||||
12
src/config/network.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Centralized network configuration for relay queries
|
||||||
|
// Keep timeouts modest for local-first, longer for remote; tweak per use-case
|
||||||
|
|
||||||
|
export const LOCAL_TIMEOUT_MS = 1200
|
||||||
|
export const REMOTE_TIMEOUT_MS = 6000
|
||||||
|
|
||||||
|
// Contacts often need a bit more time on mobile networks
|
||||||
|
export const CONTACTS_REMOTE_TIMEOUT_MS = 9000
|
||||||
|
|
||||||
|
// Future knobs could live here (e.g., max limits per kind)
|
||||||
|
|
||||||
|
|
||||||
37
src/config/nostrGateways.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Nostr gateway URLs for viewing events and profiles on the web
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const NOSTR_GATEWAY = 'https://nostr.at' as const
|
||||||
|
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a profile URL on the gateway
|
||||||
|
*/
|
||||||
|
export function getProfileUrl(npub: string): string {
|
||||||
|
return `${NOSTR_GATEWAY}/${npub}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an event URL on the gateway
|
||||||
|
*/
|
||||||
|
export function getEventUrl(nevent: string): string {
|
||||||
|
return `${NOSTR_GATEWAY}/${nevent}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a general nostr link on the gateway
|
||||||
|
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
||||||
|
*/
|
||||||
|
export function getNostrUrl(identifier: string): string {
|
||||||
|
// nostr.at uses simple /{identifier} format for all types
|
||||||
|
return `${NOSTR_GATEWAY}/${identifier}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a search portal URL with a query
|
||||||
|
*/
|
||||||
|
export function getSearchUrl(query: string): string {
|
||||||
|
return `${SEARCH_PORTAL}/?q=${encodeURIComponent(query)}`
|
||||||
|
}
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
export const RELAYS = [
|
export const RELAYS = [
|
||||||
'ws://localhost:10547',
|
'ws://localhost:10547',
|
||||||
'ws://localhost:4869',
|
'ws://localhost:4869',
|
||||||
|
'wss://relay.nsec.app',
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
'wss://nos.lol',
|
'wss://nos.lol',
|
||||||
'wss://relay.nostr.band',
|
'wss://relay.nostr.band',
|
||||||
|
|||||||
90
src/hooks/useAdaptiveTextColor.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { FastAverageColor } from 'fast-average-color'
|
||||||
|
|
||||||
|
interface AdaptiveTextColor {
|
||||||
|
textColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to determine optimal text color based on image background
|
||||||
|
* Samples the top-right corner of the image to ensure publication date is readable
|
||||||
|
*
|
||||||
|
* @param imageUrl - The URL of the image to analyze
|
||||||
|
* @returns Object containing textColor for optimal contrast
|
||||||
|
*/
|
||||||
|
export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveTextColor {
|
||||||
|
const [colors, setColors] = useState<AdaptiveTextColor>({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageUrl) {
|
||||||
|
// No image, use default white text
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fac = new FastAverageColor()
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const width = img.naturalWidth
|
||||||
|
const height = img.naturalHeight
|
||||||
|
|
||||||
|
// Sample top-right corner (last 25% width, first 25% height)
|
||||||
|
const color = fac.getColor(img, {
|
||||||
|
left: Math.floor(width * 0.75),
|
||||||
|
top: 0,
|
||||||
|
width: Math.floor(width * 0.25),
|
||||||
|
height: Math.floor(height * 0.25)
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Adaptive color detected:', {
|
||||||
|
hex: color.hex,
|
||||||
|
rgb: color.rgb,
|
||||||
|
isLight: color.isLight,
|
||||||
|
isDark: color.isDark
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use library's built-in isLight check for optimal contrast
|
||||||
|
if (color.isLight) {
|
||||||
|
console.log('Light background detected, using black text')
|
||||||
|
setColors({
|
||||||
|
textColor: '#000000'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log('Dark background detected, using white text')
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback to default on error
|
||||||
|
console.error('Error analyzing image color:', error)
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
// Fallback to default if image fails to load
|
||||||
|
setColors({
|
||||||
|
textColor: '#ffffff'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
img.src = imageUrl
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
fac.destroy()
|
||||||
|
}
|
||||||
|
}, [imageUrl])
|
||||||
|
|
||||||
|
return colors
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { IAccount, AccountManager } from 'applesauce-accounts'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
@@ -9,11 +10,10 @@ import { UserSettings } from '../services/settingsService'
|
|||||||
|
|
||||||
interface UseBookmarksDataParams {
|
interface UseBookmarksDataParams {
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
activeAccount: IAccount | undefined
|
||||||
activeAccount: any
|
accountManager: AccountManager
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
accountManager: any
|
|
||||||
naddr?: string
|
naddr?: string
|
||||||
|
externalUrl?: string
|
||||||
currentArticleCoordinate?: string
|
currentArticleCoordinate?: string
|
||||||
currentArticleEventId?: string
|
currentArticleEventId?: string
|
||||||
settings?: UserSettings
|
settings?: UserSettings
|
||||||
@@ -24,6 +24,7 @@ export const useBookmarksData = ({
|
|||||||
activeAccount,
|
activeAccount,
|
||||||
accountManager,
|
accountManager,
|
||||||
naddr,
|
naddr,
|
||||||
|
externalUrl,
|
||||||
currentArticleCoordinate,
|
currentArticleCoordinate,
|
||||||
currentArticleEventId,
|
currentArticleEventId,
|
||||||
settings
|
settings
|
||||||
@@ -34,6 +35,7 @@ export const useBookmarksData = ({
|
|||||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
|
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleFetchContacts = useCallback(async () => {
|
const handleFetchContacts = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
@@ -43,10 +45,14 @@ export const useBookmarksData = ({
|
|||||||
|
|
||||||
const handleFetchBookmarks = useCallback(async () => {
|
const handleFetchBookmarks = useCallback(async () => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
|
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
|
||||||
setBookmarksLoading(true)
|
setBookmarksLoading(true)
|
||||||
try {
|
try {
|
||||||
const fullAccount = accountManager.getActive()
|
const fullAccount = accountManager.getActive()
|
||||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
|
// merge-friendly: updater form that preserves visible list until replacement
|
||||||
|
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
|
||||||
|
setBookmarks(() => next)
|
||||||
|
}, settings)
|
||||||
} finally {
|
} finally {
|
||||||
setBookmarksLoading(false)
|
setBookmarksLoading(false)
|
||||||
}
|
}
|
||||||
@@ -93,6 +99,7 @@ export const useBookmarksData = ({
|
|||||||
await handleFetchBookmarks()
|
await handleFetchBookmarks()
|
||||||
await handleFetchHighlights()
|
await handleFetchHighlights()
|
||||||
await handleFetchContacts()
|
await handleFetchContacts()
|
||||||
|
setLastFetchTime(Date.now())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to refresh data:', err)
|
console.error('Failed to refresh data:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -100,15 +107,23 @@ export const useBookmarksData = ({
|
|||||||
}
|
}
|
||||||
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data (avoid clearing on route-only changes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !activeAccount) return
|
if (!relayPool || !activeAccount) return
|
||||||
|
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
|
||||||
handleFetchBookmarks()
|
handleFetchBookmarks()
|
||||||
if (!naddr) {
|
}, [relayPool, activeAccount, handleFetchBookmarks])
|
||||||
|
|
||||||
|
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relayPool || !activeAccount) return
|
||||||
|
// Only fetch general highlights when not viewing an article (naddr) or external URL
|
||||||
|
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||||
|
if (!naddr && !externalUrl) {
|
||||||
handleFetchHighlights()
|
handleFetchHighlights()
|
||||||
}
|
}
|
||||||
handleFetchContacts()
|
handleFetchContacts()
|
||||||
}, [relayPool, activeAccount?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookmarks,
|
bookmarks,
|
||||||
@@ -119,6 +134,7 @@ export const useBookmarksData = ({
|
|||||||
setHighlightsLoading,
|
setHighlightsLoading,
|
||||||
followedPubkeys,
|
followedPubkeys,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
|
lastFetchTime,
|
||||||
handleFetchBookmarks,
|
handleFetchBookmarks,
|
||||||
handleFetchHighlights,
|
handleFetchHighlights,
|
||||||
handleRefreshAll
|
handleRefreshAll
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
import { ViewMode } from '../components/Bookmarks'
|
import { ViewMode } from '../components/Bookmarks'
|
||||||
|
import { useIsMobile } from './useMediaQuery'
|
||||||
|
|
||||||
interface UseBookmarksUIParams {
|
interface UseBookmarksUIParams {
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true)
|
const [isCollapsed, setIsCollapsed] = useState(true)
|
||||||
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true)
|
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true)
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
||||||
@@ -23,6 +26,16 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
|||||||
mine: true
|
mine: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Auto-collapse sidebar on mobile based on settings
|
||||||
|
useEffect(() => {
|
||||||
|
const autoCollapse = settings.autoCollapseSidebarOnMobile !== false
|
||||||
|
if (isMobile && autoCollapse) {
|
||||||
|
setIsSidebarOpen(false)
|
||||||
|
} else if (!isMobile) {
|
||||||
|
setIsSidebarOpen(true)
|
||||||
|
}
|
||||||
|
}, [isMobile, settings.autoCollapseSidebarOnMobile])
|
||||||
|
|
||||||
// Apply UI settings
|
// Apply UI settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
||||||
@@ -34,7 +47,15 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
|||||||
})
|
})
|
||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
|
const toggleSidebar = useCallback(() => {
|
||||||
|
setIsSidebarOpen(prev => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isMobile,
|
||||||
|
isSidebarOpen,
|
||||||
|
setIsSidebarOpen,
|
||||||
|
toggleSidebar,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
setIsCollapsed,
|
setIsCollapsed,
|
||||||
isHighlightsCollapsed,
|
isHighlightsCollapsed,
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ import { fetchReadableContent, ReadableContent } from '../services/readerService
|
|||||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
|
|
||||||
|
// Helper to extract filename from URL
|
||||||
|
function getFilenameFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
const pathname = urlObj.pathname
|
||||||
|
const filename = pathname.substring(pathname.lastIndexOf('/') + 1)
|
||||||
|
// Decode URI component to handle special characters
|
||||||
|
return decodeURIComponent(filename) || url
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface UseExternalUrlLoaderProps {
|
interface UseExternalUrlLoaderProps {
|
||||||
url: string | undefined
|
url: string | undefined
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
@@ -11,7 +24,7 @@ interface UseExternalUrlLoaderProps {
|
|||||||
setReaderContent: (content: ReadableContent | undefined) => void
|
setReaderContent: (content: ReadableContent | undefined) => void
|
||||||
setReaderLoading: (loading: boolean) => void
|
setReaderLoading: (loading: boolean) => void
|
||||||
setIsCollapsed: (collapsed: boolean) => void
|
setIsCollapsed: (collapsed: boolean) => void
|
||||||
setHighlights: (highlights: Highlight[]) => void
|
setHighlights: (highlights: Highlight[] | ((prev: Highlight[]) => Highlight[])) => void
|
||||||
setHighlightsLoading: (loading: boolean) => void
|
setHighlightsLoading: (loading: boolean) => void
|
||||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||||
setCurrentArticleEventId: (id: string | undefined) => void
|
setCurrentArticleEventId: (id: string | undefined) => void
|
||||||
@@ -57,9 +70,23 @@ export function useExternalUrlLoader({
|
|||||||
|
|
||||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||||
if (typeof fetchHighlightsForUrl === 'function') {
|
if (typeof fetchHighlightsForUrl === 'function') {
|
||||||
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
|
const seen = new Set<string>()
|
||||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
await fetchHighlightsForUrl(
|
||||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
relayPool,
|
||||||
|
url,
|
||||||
|
(highlight) => {
|
||||||
|
if (seen.has(highlight.id)) return
|
||||||
|
seen.add(highlight.id)
|
||||||
|
setHighlights((prev) => {
|
||||||
|
if (prev.some(h => h.id === highlight.id)) return prev
|
||||||
|
const next = [...prev, highlight]
|
||||||
|
return next.sort((a, b) => b.created_at - a.created_at)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Highlights are already set via the streaming callback
|
||||||
|
// No need to set them again as that could cause a flash/disappearance
|
||||||
|
console.log(`📌 Finished fetching highlights for URL`)
|
||||||
} else {
|
} else {
|
||||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
console.log('📌 Highlight fetching for URLs not yet implemented')
|
||||||
}
|
}
|
||||||
@@ -70,8 +97,10 @@ export function useExternalUrlLoader({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load external URL:', err)
|
console.error('Failed to load external URL:', err)
|
||||||
|
// For videos and other media files, use the filename as the title
|
||||||
|
const filename = getFilenameFromUrl(url)
|
||||||
setReaderContent({
|
setReaderContent({
|
||||||
title: 'Error Loading Content',
|
title: filename,
|
||||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||||
url
|
url
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useCallback, useRef } from 'react'
|
import { useCallback, useRef } from 'react'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { IEventStore } from 'applesauce-core'
|
import { IEventStore } from 'applesauce-core'
|
||||||
|
import { IAccount } from 'applesauce-accounts'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { ReadableContent } from '../services/readerService'
|
import { ReadableContent } from '../services/readerService'
|
||||||
import { createHighlight } from '../services/highlightCreationService'
|
import { createHighlight } from '../services/highlightCreationService'
|
||||||
import { HighlightButtonRef } from '../components/HighlightButton'
|
import { HighlightButtonRef } from '../components/HighlightButton'
|
||||||
import { UserSettings } from '../services/settingsService'
|
import { UserSettings } from '../services/settingsService'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
|
||||||
interface UseHighlightCreationParams {
|
interface UseHighlightCreationParams {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
activeAccount: IAccount | undefined
|
||||||
activeAccount: any
|
|
||||||
relayPool: RelayPool | null
|
relayPool: RelayPool | null
|
||||||
eventStore: IEventStore | null
|
eventStore: IEventStore | null
|
||||||
currentArticle: NostrEvent | undefined
|
currentArticle: NostrEvent | undefined
|
||||||
@@ -31,6 +33,7 @@ export const useHighlightCreation = ({
|
|||||||
settings
|
settings
|
||||||
}: UseHighlightCreationParams) => {
|
}: UseHighlightCreationParams) => {
|
||||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||||
|
const { showToast } = useToast()
|
||||||
|
|
||||||
const handleTextSelection = useCallback((text: string) => {
|
const handleTextSelection = useCallback((text: string) => {
|
||||||
highlightButtonRef.current?.updateSelection(text)
|
highlightButtonRef.current?.updateSelection(text)
|
||||||
@@ -77,14 +80,33 @@ export const useHighlightCreation = ({
|
|||||||
publishedRelays: newHighlight.publishedRelays
|
publishedRelays: newHighlight.publishedRelays
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Clear the browser's text selection immediately to allow DOM update
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection) {
|
||||||
|
selection.removeAllRanges()
|
||||||
|
}
|
||||||
|
|
||||||
highlightButtonRef.current?.clearSelection()
|
highlightButtonRef.current?.clearSelection()
|
||||||
onHighlightCreated(newHighlight)
|
|
||||||
|
// Force synchronous render to show highlight immediately
|
||||||
|
flushSync(() => {
|
||||||
|
onHighlightCreated(newHighlight)
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to create highlight:', error)
|
console.error('❌ Failed to create highlight:', error)
|
||||||
|
|
||||||
|
// Show user-friendly error messages
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create highlight'
|
||||||
|
if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
|
showToast('Reconnect bunker and approve signing permissions to create highlights')
|
||||||
|
} else {
|
||||||
|
showToast(`Failed to create highlight: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Re-throw to allow parent to handle
|
// Re-throw to allow parent to handle
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings])
|
}, [activeAccount, relayPool, eventStore, currentArticle, selectedUrl, readerContent, onHighlightCreated, settings, showToast])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
highlightButtonRef,
|
highlightButtonRef,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback, useRef } from 'react'
|
import { useEffect, useCallback, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface UseHighlightInteractionsParams {
|
interface UseHighlightInteractionsParams {
|
||||||
onHighlightClick?: (highlightId: string) => void
|
onHighlightClick?: (highlightId: string) => void
|
||||||
@@ -14,6 +14,25 @@ export const useHighlightInteractions = ({
|
|||||||
onClearSelection
|
onClearSelection
|
||||||
}: UseHighlightInteractionsParams) => {
|
}: UseHighlightInteractionsParams) => {
|
||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [contentVersion, setContentVersion] = useState(0)
|
||||||
|
|
||||||
|
// Watch for DOM changes (highlights being added/removed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentRef.current) return
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
// Increment version to trigger re-attachment of handlers
|
||||||
|
setContentVersion(prev => prev + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(contentRef.current, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: false
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Attach click handlers to highlight marks
|
// Attach click handlers to highlight marks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,27 +56,45 @@ export const useHighlightInteractions = ({
|
|||||||
mark.removeEventListener('click', handler)
|
mark.removeEventListener('click', handler)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [onHighlightClick])
|
}, [onHighlightClick, contentVersion])
|
||||||
|
|
||||||
// Scroll to selected highlight
|
// Scroll to selected highlight with retry mechanism
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedHighlightId || !contentRef.current) return
|
if (!selectedHighlightId || !contentRef.current) return
|
||||||
|
|
||||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
let attempts = 0
|
||||||
|
const maxAttempts = 20 // Try for up to 2 seconds
|
||||||
|
const retryDelay = 100
|
||||||
|
|
||||||
if (markElement) {
|
const tryScroll = () => {
|
||||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
if (!contentRef.current) return
|
||||||
|
|
||||||
const htmlElement = markElement as HTMLElement
|
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||||
setTimeout(() => {
|
|
||||||
htmlElement.classList.add('highlight-pulse')
|
if (markElement) {
|
||||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}, 500)
|
|
||||||
|
const htmlElement = markElement as HTMLElement
|
||||||
|
setTimeout(() => {
|
||||||
|
htmlElement.classList.add('highlight-pulse')
|
||||||
|
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||||
|
}, 500)
|
||||||
|
} else if (attempts < maxAttempts) {
|
||||||
|
attempts++
|
||||||
|
setTimeout(tryScroll, retryDelay)
|
||||||
|
} else {
|
||||||
|
console.warn('Could not find mark element for highlight after', maxAttempts, 'attempts:', selectedHighlightId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [selectedHighlightId])
|
|
||||||
|
// Start trying after a small initial delay
|
||||||
|
const timeoutId = setTimeout(tryScroll, 100)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}, [selectedHighlightId, contentVersion])
|
||||||
|
|
||||||
// Handle text selection
|
// Handle text selection (works for both mouse and touch)
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleSelectionEnd = useCallback(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || selection.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
@@ -76,6 +113,6 @@ export const useHighlightInteractions = ({
|
|||||||
}, 10)
|
}, 10)
|
||||||
}, [onTextSelection, onClearSelection])
|
}, [onTextSelection, onClearSelection])
|
||||||
|
|
||||||
return { contentRef, handleMouseUp }
|
return { contentRef, handleSelectionEnd }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,116 +1,28 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { cacheImage, getCachedImage, loadCachedImage } from '../services/imageCacheService'
|
|
||||||
import { UserSettings } from '../services/settingsService'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to cache and retrieve images using Cache API
|
* Hook to return image URL for display
|
||||||
|
* Service Worker handles all caching transparently
|
||||||
|
* Images are cached on first load and available offline automatically
|
||||||
*
|
*
|
||||||
* @param imageUrl - The URL of the image to cache
|
* @param imageUrl - The URL of the image to display
|
||||||
* @param settings - User settings to determine if caching is enabled
|
* @returns The image URL (Service Worker handles caching)
|
||||||
* @returns The cached blob URL or the original URL
|
|
||||||
*/
|
*/
|
||||||
export function useImageCache(
|
export function useImageCache(
|
||||||
imageUrl: string | undefined,
|
imageUrl: string | undefined
|
||||||
settings: UserSettings | undefined
|
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const [cachedUrl, setCachedUrl] = useState<string | undefined>(imageUrl)
|
// Service Worker handles everything - just return the URL as-is
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
return imageUrl
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!imageUrl) {
|
|
||||||
setCachedUrl(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If caching is disabled, just use the original URL
|
|
||||||
const enableCache = settings?.enableImageCache ?? true // Default to enabled
|
|
||||||
if (!enableCache) {
|
|
||||||
setCachedUrl(imageUrl)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store imageUrl in local variable for closure
|
|
||||||
const urlToCache = imageUrl
|
|
||||||
|
|
||||||
// Check if image is in cache metadata (fast synchronous check)
|
|
||||||
const isCached = getCachedImage(urlToCache)
|
|
||||||
|
|
||||||
if (isCached) {
|
|
||||||
// Load the cached image asynchronously
|
|
||||||
loadCachedImage(urlToCache)
|
|
||||||
.then(blobUrl => {
|
|
||||||
if (blobUrl) {
|
|
||||||
console.log('📦 Using cached image:', urlToCache.substring(0, 50))
|
|
||||||
setCachedUrl(blobUrl)
|
|
||||||
} else {
|
|
||||||
// Not actually cached, fall through to caching logic
|
|
||||||
setCachedUrl(urlToCache)
|
|
||||||
cacheInBackground()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Failed to load cached image:', err)
|
|
||||||
setCachedUrl(urlToCache)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Not cached, show original and cache in background
|
|
||||||
setCachedUrl(urlToCache)
|
|
||||||
cacheInBackground()
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheInBackground() {
|
|
||||||
if (!isLoading) {
|
|
||||||
setIsLoading(true)
|
|
||||||
const maxSize = settings?.imageCacheSizeMB ?? 210
|
|
||||||
|
|
||||||
cacheImage(urlToCache, maxSize)
|
|
||||||
.then(blobUrl => {
|
|
||||||
setCachedUrl(blobUrl)
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Failed to cache image:', err)
|
|
||||||
// Keep using original URL on error
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup: revoke blob URLs when component unmounts or URL changes
|
|
||||||
return () => {
|
|
||||||
if (cachedUrl && cachedUrl.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(cachedUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
|
|
||||||
|
|
||||||
return cachedUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simpler hook variant that just caches on mount if enabled
|
* Pre-load image to ensure it's cached by Service Worker
|
||||||
* Useful for preloading article cover images
|
* Triggers a fetch so the SW can cache it even if not visible yet
|
||||||
*/
|
*/
|
||||||
export function useCacheImageOnLoad(
|
export function useCacheImageOnLoad(
|
||||||
imageUrl: string | undefined,
|
imageUrl: string | undefined
|
||||||
settings: UserSettings | undefined
|
|
||||||
): void {
|
): void {
|
||||||
useEffect(() => {
|
// Service Worker will cache on first fetch
|
||||||
if (!imageUrl) return
|
// This hook is now a no-op, kept for API compatibility
|
||||||
|
// The browser will automatically fetch and cache images when they're used in <img> tags
|
||||||
const enableCache = settings?.enableImageCache ?? true
|
void imageUrl
|
||||||
if (!enableCache) return
|
|
||||||
|
|
||||||
// Check if already cached (fast metadata check)
|
|
||||||
const isCached = getCachedImage(imageUrl)
|
|
||||||
if (isCached) return
|
|
||||||
|
|
||||||
// Cache in background
|
|
||||||
const maxSize = settings?.imageCacheSizeMB ?? 210
|
|
||||||
cacheImage(imageUrl, maxSize).catch(err => {
|
|
||||||
console.error('Failed to cache image:', err)
|
|
||||||
})
|
|
||||||
}, [imageUrl, settings?.enableImageCache, settings?.imageCacheSizeMB])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,86 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
|
||||||
|
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
||||||
|
* Also processes nostr: URIs in the markdown and resolves article titles
|
||||||
*/
|
*/
|
||||||
export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, previewRef: React.RefObject<HTMLDivElement> } => {
|
export const useMarkdownToHTML = (
|
||||||
|
markdown?: string,
|
||||||
|
relayPool?: RelayPool | null
|
||||||
|
): {
|
||||||
|
renderedHtml: string
|
||||||
|
previewRef: React.RefObject<HTMLDivElement>
|
||||||
|
processedMarkdown: string
|
||||||
|
} => {
|
||||||
const previewRef = useRef<HTMLDivElement>(null)
|
const previewRef = useRef<HTMLDivElement>(null)
|
||||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||||
|
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!markdown) {
|
if (!markdown) {
|
||||||
setRenderedHtml('')
|
setRenderedHtml('')
|
||||||
|
setProcessedMarkdown('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📝 Converting markdown to HTML...')
|
let isCancelled = false
|
||||||
|
|
||||||
const rafId = requestAnimationFrame(() => {
|
const processMarkdown = async () => {
|
||||||
if (previewRef.current) {
|
// Extract all naddr references
|
||||||
const html = previewRef.current.innerHTML
|
const naddrs = extractNaddrUris(markdown)
|
||||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
|
||||||
setRenderedHtml(html)
|
let processed: string
|
||||||
|
|
||||||
|
if (naddrs.length > 0 && relayPool) {
|
||||||
|
// Fetch article titles for all naddrs
|
||||||
|
try {
|
||||||
|
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
|
||||||
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
|
// Replace nostr URIs with resolved titles
|
||||||
|
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||||
|
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch article titles:', error)
|
||||||
|
// Fall back to basic replacement
|
||||||
|
processed = replaceNostrUrisInMarkdown(markdown)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
// No articles to resolve, use basic replacement
|
||||||
|
processed = replaceNostrUrisInMarkdown(markdown)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
|
setProcessedMarkdown(processed)
|
||||||
|
|
||||||
return () => cancelAnimationFrame(rafId)
|
console.log('📝 Converting markdown to HTML...')
|
||||||
}, [markdown])
|
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
if (previewRef.current && !isCancelled) {
|
||||||
|
const html = previewRef.current.innerHTML
|
||||||
|
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||||
|
setRenderedHtml(html)
|
||||||
|
} else if (!isCancelled) {
|
||||||
|
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return { renderedHtml, previewRef }
|
return () => cancelAnimationFrame(rafId)
|
||||||
|
}
|
||||||
|
|
||||||
|
processMarkdown()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
}, [markdown, relayPool])
|
||||||
|
|
||||||
|
return { renderedHtml, previewRef, processedMarkdown }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef
|
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef
|
||||||
|
|||||||
62
src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if a media query matches
|
||||||
|
* @param query The media query string (e.g., '(max-width: 768px)')
|
||||||
|
* @returns true if the media query matches, false otherwise
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return window.matchMedia(query).matches
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia(query)
|
||||||
|
|
||||||
|
// Update state if the media query changes
|
||||||
|
const handleChange = (event: MediaQueryListEvent) => {
|
||||||
|
setMatches(event.matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modern browsers
|
||||||
|
if (mediaQuery.addEventListener) {
|
||||||
|
mediaQuery.addEventListener('change', handleChange)
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||||
|
}
|
||||||
|
// Legacy browsers
|
||||||
|
else {
|
||||||
|
mediaQuery.addListener(handleChange)
|
||||||
|
return () => mediaQuery.removeListener(handleChange)
|
||||||
|
}
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if the user is on a coarse pointer device (touch)
|
||||||
|
* @returns true if the user is using a coarse pointer (touch), false otherwise
|
||||||
|
*/
|
||||||
|
export function useIsCoarsePointer(): boolean {
|
||||||
|
return useMediaQuery('(pointer: coarse)')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if the viewport is mobile-sized
|
||||||
|
* @returns true if viewport width is <= 768px, false otherwise
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 768px)')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect if the viewport is tablet-sized
|
||||||
|
* @returns true if viewport width is <= 1024px, false otherwise
|
||||||
|
*/
|
||||||
|
export function useIsTablet(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 1024px)')
|
||||||
|
}
|
||||||
|
|
||||||
28
src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export function useOnlineStatus() {
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => {
|
||||||
|
console.log('🌐 Back online')
|
||||||
|
setIsOnline(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOffline = () => {
|
||||||
|
console.log('📴 Gone offline')
|
||||||
|
setIsOnline(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return isOnline
|
||||||
|
}
|
||||||
|
|
||||||
74
src/hooks/usePWAInstall.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePWAInstall() {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
||||||
|
const [isInstallable, setIsInstallable] = useState(false)
|
||||||
|
const [isInstalled, setIsInstalled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if app is already installed
|
||||||
|
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||||
|
setIsInstalled(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for the beforeinstallprompt event
|
||||||
|
const handleBeforeInstallPrompt = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const installPromptEvent = e as BeforeInstallPromptEvent
|
||||||
|
setDeferredPrompt(installPromptEvent)
|
||||||
|
setIsInstallable(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for successful installation
|
||||||
|
const handleAppInstalled = () => {
|
||||||
|
setIsInstalled(true)
|
||||||
|
setIsInstallable(false)
|
||||||
|
setDeferredPrompt(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||||
|
window.addEventListener('appinstalled', handleAppInstalled)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||||
|
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const installApp = async () => {
|
||||||
|
if (!deferredPrompt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deferredPrompt.prompt()
|
||||||
|
const choiceResult = await deferredPrompt.userChoice
|
||||||
|
|
||||||
|
if (choiceResult.outcome === 'accepted') {
|
||||||
|
console.log('✅ PWA installed')
|
||||||
|
setIsInstallable(false)
|
||||||
|
setDeferredPrompt(null)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
console.log('❌ PWA installation dismissed')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error installing PWA:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isInstallable,
|
||||||
|
isInstalled,
|
||||||
|
installApp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
138
src/hooks/useReadingPosition.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface UseReadingPositionOptions {
|
||||||
|
enabled?: boolean
|
||||||
|
onPositionChange?: (position: number) => void
|
||||||
|
onReadingComplete?: () => void
|
||||||
|
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||||
|
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||||
|
onSave?: (position: number) => void // Callback for saving position
|
||||||
|
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReadingPosition = ({
|
||||||
|
enabled = true,
|
||||||
|
onPositionChange,
|
||||||
|
onReadingComplete,
|
||||||
|
readingCompleteThreshold = 0.9,
|
||||||
|
syncEnabled = false,
|
||||||
|
onSave,
|
||||||
|
autoSaveInterval = 5000
|
||||||
|
}: UseReadingPositionOptions = {}) => {
|
||||||
|
const [position, setPosition] = useState(0)
|
||||||
|
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||||
|
const hasTriggeredComplete = useRef(false)
|
||||||
|
const lastSavedPosition = useRef(0)
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
// Debounced save function
|
||||||
|
const scheduleSave = useCallback((currentPosition: number) => {
|
||||||
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
|
// Don't save if position is too low (< 5%)
|
||||||
|
if (currentPosition < 0.05) return
|
||||||
|
|
||||||
|
// Don't save if position hasn't changed significantly (less than 1%)
|
||||||
|
// But always save if we've reached 100% (completion)
|
||||||
|
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
||||||
|
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
||||||
|
|
||||||
|
if (!hasSignificantChange && !hasReachedCompletion) return
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new save
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
lastSavedPosition.current = currentPosition
|
||||||
|
onSave(currentPosition)
|
||||||
|
}, autoSaveInterval)
|
||||||
|
}, [syncEnabled, onSave, autoSaveInterval])
|
||||||
|
|
||||||
|
// Immediate save function
|
||||||
|
const saveNow = useCallback(() => {
|
||||||
|
if (!syncEnabled || !onSave) return
|
||||||
|
|
||||||
|
// Cancel any pending saves
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
saveTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save if position is meaningful (>= 5%)
|
||||||
|
if (position >= 0.05) {
|
||||||
|
lastSavedPosition.current = position
|
||||||
|
onSave(position)
|
||||||
|
}
|
||||||
|
}, [syncEnabled, onSave, position])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
// Get the main content area (reader content)
|
||||||
|
const readerContent = document.querySelector('.reader-html, .reader-markdown')
|
||||||
|
if (!readerContent) return
|
||||||
|
|
||||||
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||||
|
const windowHeight = window.innerHeight
|
||||||
|
const documentHeight = document.documentElement.scrollHeight
|
||||||
|
|
||||||
|
// Calculate position based on how much of the content has been scrolled through
|
||||||
|
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
||||||
|
const maxScroll = documentHeight - windowHeight
|
||||||
|
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||||
|
|
||||||
|
// If we're within 5px of the bottom, consider it 100%
|
||||||
|
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||||
|
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||||
|
|
||||||
|
setPosition(clampedProgress)
|
||||||
|
onPositionChange?.(clampedProgress)
|
||||||
|
|
||||||
|
// Schedule auto-save if sync is enabled
|
||||||
|
scheduleSave(clampedProgress)
|
||||||
|
|
||||||
|
// Check if reading is complete
|
||||||
|
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||||
|
setIsReadingComplete(true)
|
||||||
|
hasTriggeredComplete.current = true
|
||||||
|
onReadingComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial calculation
|
||||||
|
handleScroll()
|
||||||
|
|
||||||
|
// Add scroll listener
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
window.addEventListener('resize', handleScroll, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
window.removeEventListener('resize', handleScroll)
|
||||||
|
|
||||||
|
// Clear save timer on unmount
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||||
|
|
||||||
|
// Reset reading complete state when enabled changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setIsReadingComplete(false)
|
||||||
|
hasTriggeredComplete.current = false
|
||||||
|
}
|
||||||
|
}, [enabled])
|
||||||
|
|
||||||
|
return {
|
||||||
|
position,
|
||||||
|
isReadingComplete,
|
||||||
|
progressPercentage: Math.round(position * 100),
|
||||||
|
saveNow
|
||||||
|
}
|
||||||
|
}
|
||||||