Compare commits
1929 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c9fd7678d | ||
|
|
01313d66b2 | ||
|
|
eaeacbfb1b | ||
|
|
5328998c21 | ||
|
|
ee066c7c4b | ||
|
|
130aebb514 | ||
|
|
dbec1e630e | ||
|
|
583b966616 | ||
|
|
9e509ac845 | ||
|
|
4ea2d8918e | ||
|
|
6667688193 | ||
|
|
30a1629f23 | ||
|
|
ba50faeebb | ||
|
|
3c0d784e3d | ||
|
|
3e75310e1f | ||
|
|
2b01d6d8d7 | ||
|
|
be6d60d7a9 | ||
|
|
3c0815d55b | ||
|
|
abd8584cb8 | ||
|
|
5a910f80b3 | ||
|
|
67bdfd853e | ||
|
|
fc8e986acb | ||
|
|
64539785c2 | ||
|
|
f530455146 | ||
|
|
70dc66e1ae | ||
|
|
3e5894d9e0 | ||
|
|
d7ac9d1bfc | ||
|
|
5160c5fb75 | ||
|
|
d9c7964410 | ||
|
|
110952b4c4 | ||
|
|
ece17dc0dd | ||
|
|
35e1087531 | ||
|
|
59c4b62770 | ||
|
|
4be9ce5ff3 | ||
|
|
92a811cabd | ||
|
|
897cdc8ac7 | ||
|
|
418ff482ae | ||
|
|
8858d6d1f2 | ||
|
|
41f8a28631 | ||
|
|
da0771d5e5 | ||
|
|
c03995367a | ||
|
|
0430105061 | ||
|
|
afd4d02dad | ||
|
|
d634ebf14c | ||
|
|
613230256a | ||
|
|
2da1736c99 | ||
|
|
1914092520 | ||
|
|
4a6f93a14f | ||
|
|
9da8142078 | ||
|
|
cafdb5b04b | ||
|
|
697774978e | ||
|
|
8c2c2fe2fc | ||
|
|
e3770463a1 | ||
|
|
d3ad23e9e4 | ||
|
|
79d1e0b30d | ||
|
|
1370bae4d6 | ||
|
|
01fbdf38fa | ||
|
|
be6b07a0cf | ||
|
|
1b057029c8 | ||
|
|
b6b567f20b | ||
|
|
c4c109fe0e | ||
|
|
4c8c921b03 | ||
|
|
69a2e5cda3 | ||
|
|
c2adf8d5b8 | ||
|
|
5c8d257923 | ||
|
|
55dae33e60 | ||
|
|
57d8e9061f | ||
|
|
4642679842 | ||
|
|
ba18743fab | ||
|
|
e739356733 | ||
|
|
cae4aed8d2 | ||
|
|
6c6a08d8e0 | ||
|
|
deafbae945 | ||
|
|
0b23c609f1 | ||
|
|
0359061321 | ||
|
|
bc7a5f6be4 | ||
|
|
be36f8f6e8 | ||
|
|
3b8d084c76 | ||
|
|
ce1cdca6a0 | ||
|
|
2f380dce1b | ||
|
|
63b7bb24cf | ||
|
|
2401ef8d16 | ||
|
|
62df3c0197 | ||
|
|
ba2dbca226 | ||
|
|
810b131b92 | ||
|
|
1d5bcde293 | ||
|
|
2fcf584e40 | ||
|
|
ecc6791892 | ||
|
|
8bf19c1e69 | ||
|
|
273ab4ae60 | ||
|
|
ec8fedc73d | ||
|
|
e6b1d4b032 | ||
|
|
a89572f85f | ||
|
|
bf4f5bc88b | ||
|
|
f4ce1f337e | ||
|
|
5e8afa15b2 | ||
|
|
7b1d0bb778 | ||
|
|
c0aec66f0f | ||
|
|
31481453f5 | ||
|
|
9724ec230c | ||
|
|
9e4be96522 | ||
|
|
91097515f2 | ||
|
|
230c3bb5b2 | ||
|
|
7a71db9de4 | ||
|
|
7261afc428 | ||
|
|
ddde7d038b | ||
|
|
4d3d66fbe2 | ||
|
|
b98a44def2 | ||
|
|
c6e972b165 | ||
|
|
7b7146b502 | ||
|
|
f00700cb41 | ||
|
|
c3e099f095 | ||
|
|
6393c24575 | ||
|
|
17378f3686 | ||
|
|
d7453302f7 | ||
|
|
07f5146534 | ||
|
|
d04851af72 | ||
|
|
6aff0eb4e6 | ||
|
|
2f5409b485 | ||
|
|
3aa7dc32d3 | ||
|
|
f5cd6ebdbc | ||
|
|
a7289cda74 | ||
|
|
ada3a3b4fd | ||
|
|
a21e216eb9 | ||
|
|
b85fe7f219 | ||
|
|
4efb3031de | ||
|
|
6b66acb2cf | ||
|
|
481e1e840e | ||
|
|
e296b49821 | ||
|
|
254118f845 | ||
|
|
88f5a98c55 | ||
|
|
572a5dd200 | ||
|
|
8dce00db44 | ||
|
|
0ab73deffd | ||
|
|
9863dc6dd0 | ||
|
|
797f93d467 | ||
|
|
c602f3d531 | ||
|
|
dfd45a58bd | ||
|
|
70a832530e | ||
|
|
4fc32eafd7 | ||
|
|
6579d34b90 | ||
|
|
a5bf8f88cd | ||
|
|
55264314b8 | ||
|
|
23a9e9154b | ||
|
|
0ee373c1f3 | ||
|
|
29b40bc91d | ||
|
|
10b7363b06 | ||
|
|
e37f15975d | ||
|
|
ce0f61c543 | ||
|
|
ea62abfc9a | ||
|
|
15a2b6ba7e | ||
|
|
10773526e4 | ||
|
|
facd7f1414 | ||
|
|
29bf80108d | ||
|
|
00bbb56ec6 | ||
|
|
2e057f5c96 | ||
|
|
936fa6a172 | ||
|
|
5d5ad40f4e | ||
|
|
55ee303fc5 | ||
|
|
f30f11ce2d | ||
|
|
f5e57b3319 | ||
|
|
d5fe4f8eb4 | ||
|
|
4f4f226d8c | ||
|
|
5b7712c274 | ||
|
|
8da71a98da | ||
|
|
f9fc59718a | ||
|
|
9bc4c3201c | ||
|
|
eb4ea0cc9c | ||
|
|
4b2e63bb5b | ||
|
|
817f018d4d | ||
|
|
9160b4ef90 | ||
|
|
e8d1817566 | ||
|
|
419b203fcf | ||
|
|
528b32ccf7 | ||
|
|
bc0c9932c8 | ||
|
|
5827d7a246 | ||
|
|
5dd08c43a6 | ||
|
|
13c5fc244a | ||
|
|
b8b52fe662 | ||
|
|
f4c0a48868 | ||
|
|
78b98e77c6 | ||
|
|
8cbaf60755 | ||
|
|
62ac60887e | ||
|
|
fe6243e889 | ||
|
|
8abd8ecaab | ||
|
|
c860422a5a | ||
|
|
083151460a | ||
|
|
c8f97ef386 | ||
|
|
c9a22a5eaf | ||
|
|
f926732070 | ||
|
|
3fbe6e9d9b | ||
|
|
225230381f | ||
|
|
b58d3a1b8b | ||
|
|
ffedce4351 | ||
|
|
a4fdb67913 | ||
|
|
6ba46421b9 | ||
|
|
d201961046 | ||
|
|
614a7f9da7 | ||
|
|
113ab3e596 | ||
|
|
c95870bfe5 | ||
|
|
e69e9335f9 | ||
|
|
fd21cd77dd | ||
|
|
d1880acaf9 | ||
|
|
428b507257 | ||
|
|
043dab0731 | ||
|
|
a7b4d9c0d8 | ||
|
|
6f9c95e351 | ||
|
|
7a53063ea8 | ||
|
|
ec4d5cac4f | ||
|
|
f8557cba88 | ||
|
|
5dead039f5 | ||
|
|
ea38392df4 | ||
|
|
0cc9d90a94 | ||
|
|
8c7b86c458 | ||
|
|
0e29546fc5 | ||
|
|
c9fa90d07c | ||
|
|
7d8e0525b1 | ||
|
|
ddf69876a6 | ||
|
|
1d78e75e38 | ||
|
|
7ed58fe3c6 | ||
|
|
058c79570b | ||
|
|
ec8fbcdf38 | ||
|
|
76673295bf | ||
|
|
084401b9c3 | ||
|
|
49038b10f7 | ||
|
|
45ea26810a | ||
|
|
18c8b2d803 | ||
|
|
f4a7482b3b | ||
|
|
32dff128f4 | ||
|
|
a00b2d6574 | ||
|
|
10c6e7d522 | ||
|
|
df6a256d51 | ||
|
|
fbdb74ee68 | ||
|
|
9ad7250da0 | ||
|
|
8c86984ea1 | ||
|
|
834b3f6210 | ||
|
|
105b8d5042 | ||
|
|
7ca8fd9ca1 | ||
|
|
0067c2800a | ||
|
|
688c8796f4 | ||
|
|
56af65742b | ||
|
|
629ad26d40 | ||
|
|
4b0e499c10 | ||
|
|
4697b46cba | ||
|
|
7f17e7daed | ||
|
|
a1fcb7597b | ||
|
|
35e46d0685 | ||
|
|
e2c0f3658b | ||
|
|
a3541ec16a | ||
|
|
08d0d3e7f3 | ||
|
|
2c47b2bee3 | ||
|
|
e6008b5ec4 | ||
|
|
e214ddf8d5 | ||
|
|
52927c6188 | ||
|
|
92ca9dd983 | ||
|
|
ed8be34c12 | ||
|
|
93bddfd05e | ||
|
|
8c99101bb3 | ||
|
|
d874f9ded4 | ||
|
|
d7be4d3d94 | ||
|
|
a2fa312839 | ||
|
|
f7e1e09928 | ||
|
|
f0832b07cb | ||
|
|
7c253df291 | ||
|
|
bb7cd9cbde | ||
|
|
56c4182985 | ||
|
|
cb6c7536bf | ||
|
|
fbfe8cbda0 | ||
|
|
6129d2d7eb | ||
|
|
16b30c922a | ||
|
|
c42ad66be6 | ||
|
|
f36c13fea1 | ||
|
|
4fd9d579e0 | ||
|
|
e65a8a13ea | ||
|
|
6ddb97d43e | ||
|
|
89082603de | ||
|
|
a9a3227433 | ||
|
|
60cb3514b2 | ||
|
|
4aeaa05f0b | ||
|
|
9c06552278 | ||
|
|
000f8dde82 | ||
|
|
9ffc218002 | ||
|
|
68a188f099 | ||
|
|
8829bb3810 | ||
|
|
5164d2f536 | ||
|
|
451538fcf7 | ||
|
|
82a02ef339 | ||
|
|
b834d4951f | ||
|
|
edc3b669be | ||
|
|
522826311d | ||
|
|
e69b9951d5 | ||
|
|
c6300222ea | ||
|
|
5aa6ee899c | ||
|
|
4b76f97d2b | ||
|
|
5ccfe71c55 | ||
|
|
97fc902cdb | ||
|
|
7e935951d7 | ||
|
|
810c6d68d2 | ||
|
|
003dc70f4f | ||
|
|
371ff64a95 | ||
|
|
b0de5adbf3 | ||
|
|
d1d2b07c33 | ||
|
|
d9b2444c1a | ||
|
|
e7fae27031 | ||
|
|
eb0c7b0a32 | ||
|
|
3d7ad0dd9a | ||
|
|
ae04feb311 | ||
|
|
7b59f911ef | ||
|
|
d3444da647 | ||
|
|
66738d0c9c | ||
|
|
7e187acd68 | ||
|
|
c751372a54 | ||
|
|
7fc98fc7da | ||
|
|
b56f45694e | ||
|
|
091ca521ef | ||
|
|
c7a17b0a76 | ||
|
|
26468b996a | ||
|
|
c4e240d87c | ||
|
|
04713f47c8 | ||
|
|
37ab3493db | ||
|
|
a4cb3c628e | ||
|
|
8492da8a13 | ||
|
|
98d7c64a56 | ||
|
|
25f121e5ed | ||
|
|
4c8797c99c | ||
|
|
1155aa285d | ||
|
|
239bbb8263 | ||
|
|
e6b9330108 | ||
|
|
935b767c2e | ||
|
|
8acf3295b5 | ||
|
|
48c3a12fa0 | ||
|
|
a1dea514b7 | ||
|
|
d4788439cb | ||
|
|
0a60ad354c | ||
|
|
c565a20344 | ||
|
|
735ee88f0b | ||
|
|
9405ce2cc0 | ||
|
|
115277d88a | ||
|
|
6925c11dbd | ||
|
|
984d835892 | ||
|
|
23353a4960 | ||
|
|
955bcda032 | ||
|
|
81ad5d7a2c | ||
|
|
dada7980ec | ||
|
|
511a308646 | ||
|
|
65c8f2cc81 | ||
|
|
238c18bc48 | ||
|
|
873a08fee1 | ||
|
|
7e89228803 | ||
|
|
fc630923a4 | ||
|
|
928f911d03 | ||
|
|
7ffcd3fe1b | ||
|
|
0efbaf643a | ||
|
|
f1bf8e54ae | ||
|
|
b4aa6ab6c8 | ||
|
|
72431f0202 | ||
|
|
04a326c0e4 | ||
|
|
931966f4f3 | ||
|
|
8808cc4779 | ||
|
|
988c959eba | ||
|
|
c0b658d9e6 | ||
|
|
3190246f34 | ||
|
|
d957b4a5f9 | ||
|
|
bef9e5705c | ||
|
|
eb2affa518 | ||
|
|
07b9a3c033 | ||
|
|
3ca14ae06a | ||
|
|
7caa0c2112 | ||
|
|
9c69f5bc01 | ||
|
|
125a2e0f17 | ||
|
|
1b4360b897 | ||
|
|
4775d6e47b | ||
|
|
33fc553c55 | ||
|
|
25cad81c50 | ||
|
|
02a2099c1f | ||
|
|
1cda186b1a | ||
|
|
f10291b6c6 | ||
|
|
26ab5d6765 | ||
|
|
5edeed0747 | ||
|
|
c878ce432f | ||
|
|
81798897c8 | ||
|
|
63840fadbc | ||
|
|
36aa057035 | ||
|
|
30afd2421c | ||
|
|
53a1d90bd8 | ||
|
|
2ecf6beef2 | ||
|
|
85910a20e9 | ||
|
|
66cf7790b3 | ||
|
|
4a9eb7e4bb | ||
|
|
07446696c1 | ||
|
|
a29f9d9a4b | ||
|
|
d49c9baec3 | ||
|
|
8c9d4a12ee | ||
|
|
fce69e4657 | ||
|
|
b387509f88 | ||
|
|
8dc8bdc0d6 | ||
|
|
00caae8363 | ||
|
|
2ead8570a7 | ||
|
|
408315466b | ||
|
|
c651836554 | ||
|
|
03a1e70fce | ||
|
|
ab5a11a24f | ||
|
|
8cd6ed472c | ||
|
|
055181b744 | ||
|
|
e331a3920b | ||
|
|
c62bccb470 | ||
|
|
ea351ea293 | ||
|
|
d806a07c60 | ||
|
|
c0ea096f1f | ||
|
|
011d4a1672 | ||
|
|
4836a737c6 | ||
|
|
5712b2ee17 | ||
|
|
32dd17694e | ||
|
|
3ebc932a6a | ||
|
|
8f351d9bef | ||
|
|
5ae3ea94e4 | ||
|
|
f203d453a4 | ||
|
|
0d5cba121b | ||
|
|
0cd6a48a46 | ||
|
|
4e07ce2b5c | ||
|
|
85a525e301 | ||
|
|
03e4a6d723 | ||
|
|
ab28af1abe | ||
|
|
7fceed5301 | ||
|
|
0077816afa | ||
|
|
cb01423147 | ||
|
|
61b0712d36 | ||
|
|
12d7843377 | ||
|
|
9293c0a0d4 | ||
|
|
bb9522197a | ||
|
|
450a2e0664 | ||
|
|
41e35f3ec8 | ||
|
|
a9bc98abe3 | ||
|
|
47bca03532 | ||
|
|
942021371c | ||
|
|
ea2f178730 | ||
|
|
4b5c8d9efe | ||
|
|
28ebf13c3a | ||
|
|
5d52e63dd9 | ||
|
|
1a0e024050 | ||
|
|
e627a0d970 | ||
|
|
48668d94ad | ||
|
|
e08c431dd9 | ||
|
|
5ee58ad6f0 | ||
|
|
ac0a4f0586 | ||
|
|
b6f4c153e5 | ||
|
|
4fdaf5f555 | ||
|
|
b4ee9d6c00 | ||
|
|
7c73c74730 | ||
|
|
c20aa089fa | ||
|
|
b0e15c22ea | ||
|
|
d58a2c065a | ||
|
|
53135e7ee8 | ||
|
|
5c48ca9e6c | ||
|
|
c4a280f3d8 | ||
|
|
ba2943c722 | ||
|
|
26f6ffc83a | ||
|
|
bcf075a72c | ||
|
|
02d458d192 | ||
|
|
a349d8af68 | ||
|
|
0dbaf32aac | ||
|
|
e8c41ef3a8 | ||
|
|
e43a44e986 | ||
|
|
f14b8ed277 | ||
|
|
bbfe8a64cb | ||
|
|
bcf3c2dab0 | ||
|
|
d5404fd260 | ||
|
|
54bc662e43 | ||
|
|
42546ca97e | ||
|
|
5c13cf0eb9 | ||
|
|
2a9d44ae9a | ||
|
|
38414ae7b6 | ||
|
|
3ecb3e80ac | ||
|
|
4968828488 | ||
|
|
4db3cd24df | ||
|
|
45c6d3da77 | ||
|
|
4aab1da3c6 | ||
|
|
bf5dfa1c15 | ||
|
|
7549bdd2b4 | ||
|
|
1bb2525ab2 | ||
|
|
22a556f612 | ||
|
|
056611e87c | ||
|
|
6debe24880 | ||
|
|
56559bddab | ||
|
|
9ec74eccb4 | ||
|
|
3d2f45c20d | ||
|
|
fb2eedd5ba | ||
|
|
e278b4a00e | ||
|
|
0beaa611f6 | ||
|
|
14ca2daa39 | ||
|
|
714eb3ae83 | ||
|
|
6286d663c9 | ||
|
|
b5db2079d2 | ||
|
|
b3b30b9bd9 | ||
|
|
0b6a726503 | ||
|
|
609334c5a6 | ||
|
|
4852c7aec3 | ||
|
|
b1e3d33694 | ||
|
|
2bfc557071 | ||
|
|
e1216109bc | ||
|
|
990b8f390c | ||
|
|
e6f6cd4ff3 | ||
|
|
7deb745651 | ||
|
|
70f3ca8067 | ||
|
|
bb8497a997 | ||
|
|
2127e2ec0a | ||
|
|
9be4011d54 | ||
|
|
c534edfeb5 | ||
|
|
adc8cd7243 | ||
|
|
522d2d3b9c | ||
|
|
046933a05e | ||
|
|
9143288de2 | ||
|
|
6053ca6c0e | ||
|
|
084197530e | ||
|
|
9f366ca811 | ||
|
|
7c07e6f004 | ||
|
|
3d4d7e0342 | ||
|
|
1a8f241aad | ||
|
|
33e938b76a | ||
|
|
e2db546066 | ||
|
|
def9ee52e2 | ||
|
|
1afe10be03 | ||
|
|
fa44641fa2 | ||
|
|
9a1ef85c93 | ||
|
|
b848cf5aa7 | ||
|
|
8057e18ebc | ||
|
|
76e09ef34e | ||
|
|
00cb2dc274 | ||
|
|
ed46e91432 | ||
|
|
88d75fb0d8 | ||
|
|
a1d7a73459 | ||
|
|
687f89729b | ||
|
|
6bf678e01f | ||
|
|
a18aec2f96 | ||
|
|
1c0cf303a0 | ||
|
|
5c7ae73982 | ||
|
|
4e9c69a1cf | ||
|
|
78375be8bf | ||
|
|
b684725094 | ||
|
|
ff52602c3a | ||
|
|
ce704c5e26 | ||
|
|
4503e4ed17 | ||
|
|
01c384c43a | ||
|
|
dda2de58a8 | ||
|
|
0365acbf7a | ||
|
|
bbf1ab7180 | ||
|
|
83bf1f1d3a | ||
|
|
fdf04fed0e | ||
|
|
acce32bfa7 | ||
|
|
614c45ac7d | ||
|
|
c4c0199a1b | ||
|
|
a53ebb9355 | ||
|
|
06e12930c7 | ||
|
|
0f7655773a | ||
|
|
26660461d4 | ||
|
|
b41ee91db5 | ||
|
|
746dd8d37a | ||
|
|
fb4a57027d | ||
|
|
c97660bed0 | ||
|
|
fd8c8812a3 | ||
|
|
0101392858 | ||
|
|
cc3f82d693 | ||
|
|
d21997c918 | ||
|
|
74fec12f5c | ||
|
|
59525f8fa7 | ||
|
|
3c6d3befb2 | ||
|
|
dfa72c80bc | ||
|
|
c6e534b9db | ||
|
|
032ab6a85d | ||
|
|
830c066ebf | ||
|
|
c432388515 | ||
|
|
476deba93a | ||
|
|
ffb4f2386d | ||
|
|
21716163cb | ||
|
|
ca924148a5 | ||
|
|
37aa9b84ae | ||
|
|
c7bd7d4d7d | ||
|
|
d81a50e696 | ||
|
|
dda9943dbe | ||
|
|
2b4b9f24a1 | ||
|
|
2af77f22d6 | ||
|
|
f142e5812d | ||
|
|
ed901fc181 | ||
|
|
87a068899a | ||
|
|
115f683128 | ||
|
|
111568fc2e | ||
|
|
825136b5ff | ||
|
|
eae34b1121 | ||
|
|
b9d7a6a3bb | ||
|
|
1e5375f8f9 | ||
|
|
f597c603bf | ||
|
|
b93dd0a59e | ||
|
|
a5740e4349 | ||
|
|
dacbd05911 | ||
|
|
65c66e0feb | ||
|
|
52f9131f99 | ||
|
|
cfc946ad12 | ||
|
|
a207a0554c | ||
|
|
675e898163 | ||
|
|
d2167d8605 | ||
|
|
de849d3447 | ||
|
|
6c20b0b83e | ||
|
|
a09b70a991 | ||
|
|
2427a3e08b | ||
|
|
1104f9b850 | ||
|
|
dc48700e9e | ||
|
|
f0b0c39328 | ||
|
|
aad74cf682 | ||
|
|
d449478204 | ||
|
|
d4f6536caa | ||
|
|
1eac00f71c | ||
|
|
ca1170a9f0 | ||
|
|
79dda03bac | ||
|
|
6c8e0b8573 | ||
|
|
17c14722fd | ||
|
|
48612ee118 | ||
|
|
205c676999 | ||
|
|
54e0dd0478 | ||
|
|
2de8d7515e | ||
|
|
a251d16432 | ||
|
|
599caba912 | ||
|
|
3477c43465 | ||
|
|
200dac7946 | ||
|
|
e60829946d | ||
|
|
ef12a84285 | ||
|
|
6a18ae3f27 | ||
|
|
a250e95950 | ||
|
|
b174ae452b | ||
|
|
0b63bce357 | ||
|
|
de0d10e792 | ||
|
|
b358b340b4 | ||
|
|
455aba7f4f | ||
|
|
fde0437157 | ||
|
|
480c95bd63 | ||
|
|
972f957685 | ||
|
|
40ff04e5dc | ||
|
|
b3c028bd7a | ||
|
|
51ec6a54fa | ||
|
|
7a29b16ee8 | ||
|
|
7af6fd8248 | ||
|
|
e1c93169b5 | ||
|
|
f4716d5a1e | ||
|
|
f5c06ce420 | ||
|
|
9492f85d80 | ||
|
|
b1303a3ba2 | ||
|
|
5c9cfe5e6f | ||
|
|
b89b5322b8 | ||
|
|
945feba6b2 | ||
|
|
c8af4b907b | ||
|
|
298e8928cf | ||
|
|
8cb67d2976 | ||
|
|
32b8382641 | ||
|
|
007e97463b | ||
|
|
e4f190698d | ||
|
|
b3be07b17e | ||
|
|
72f8977071 | ||
|
|
3dbf00344e | ||
|
|
ffdf0b12cd | ||
|
|
a51150c729 | ||
|
|
37e14b397c | ||
|
|
e48af7ee7d | ||
|
|
3eb3dd371a | ||
|
|
8ef6551560 | ||
|
|
b1f5f3dd28 | ||
|
|
6074c4b7bd | ||
|
|
9906dd43c7 | ||
|
|
17699f66f8 | ||
|
|
80a29e654d | ||
|
|
4184fda247 | ||
|
|
7460ff7055 | ||
|
|
3137b86cee | ||
|
|
b2ca84bb7e | ||
|
|
7d692dd730 | ||
|
|
8850a89aa7 | ||
|
|
57b01dd204 | ||
|
|
8aa1da36b6 | ||
|
|
2dbe29d632 | ||
|
|
7fa891b4fc | ||
|
|
6cb7412cf3 | ||
|
|
157322834b | ||
|
|
1a13a0fee1 | ||
|
|
37256255bf | ||
|
|
75e01c899e | ||
|
|
ef0d6eab89 | ||
|
|
5d54b1b0f4 | ||
|
|
522f953b4f | ||
|
|
15f02c7115 | ||
|
|
174c877eee | ||
|
|
fd9ec736d7 | ||
|
|
2c94025ba3 | ||
|
|
bfadf35c40 | ||
|
|
f3b69caa12 | ||
|
|
18a83a5b0b | ||
|
|
bd9669b782 | ||
|
|
e05713aa7f | ||
|
|
bc3e1f0a6f | ||
|
|
063d01b5ca | ||
|
|
81c38d7749 | ||
|
|
a29842b084 | ||
|
|
bb5adcdaf6 | ||
|
|
537e17a219 | ||
|
|
03ce50153e | ||
|
|
15d01ad7fc | ||
|
|
e2b29e2c2f | ||
|
|
ce7ae84e0f | ||
|
|
01eb545f15 | ||
|
|
706738c7f1 | ||
|
|
6afa78cde9 | ||
|
|
71f5710bba | ||
|
|
0d87043f91 | ||
|
|
e25375fb7a | ||
|
|
41822999c8 | ||
|
|
07444bc7c2 | ||
|
|
ec48e5b0b7 | ||
|
|
e8e2e9297f | ||
|
|
4f871dd5ca | ||
|
|
f5f07a591a | ||
|
|
4c11e6918f | ||
|
|
403b9c0508 | ||
|
|
ee8ba75371 | ||
|
|
a2773fb180 | ||
|
|
ca36d588fc | ||
|
|
1e65707b7f | ||
|
|
eddf34ce55 | ||
|
|
0fb43aa33c | ||
|
|
b273b02da4 | ||
|
|
0b997f9673 | ||
|
|
bdb2ae57a8 | ||
|
|
b5e563679a | ||
|
|
992c104262 | ||
|
|
555154031e | ||
|
|
acb083e429 | ||
|
|
4a527d192d | ||
|
|
39c3bf17dd | ||
|
|
afc8c84f41 | ||
|
|
a085e04c4d | ||
|
|
2f82b0db34 | ||
|
|
0124c2b17d | ||
|
|
d2cfbbc9f3 | ||
|
|
c59f48822c | ||
|
|
b2d6584c4a | ||
|
|
8f7cafb240 | ||
|
|
08fd0f15ff | ||
|
|
dbb1bfe587 | ||
|
|
fe4b7a5a85 | ||
|
|
d8df5d76e5 | ||
|
|
b65dcc5ade | ||
|
|
a5c387a19e | ||
|
|
07c38d9a9f | ||
|
|
20ac8a444b | ||
|
|
7b601c9c7f | ||
|
|
8d2f74daa4 | ||
|
|
01e82dca5f | ||
|
|
094bb407ed | ||
|
|
338baa55ec | ||
|
|
d06d20a33e | ||
|
|
d46ba6b92b | ||
|
|
ec2639039d | ||
|
|
3a211ded2e | ||
|
|
c2131e3654 | ||
|
|
594fb59395 | ||
|
|
f44378ec84 | ||
|
|
0f6b366f62 | ||
|
|
8d0a5997ee | ||
|
|
347cb3417e | ||
|
|
337fcebd10 | ||
|
|
e057b130e9 | ||
|
|
19a0765a1a | ||
|
|
b81cd3240b | ||
|
|
1e6105b076 | ||
|
|
d8d89b3463 | ||
|
|
459564cb2d | ||
|
|
084df35184 | ||
|
|
81912babeb | ||
|
|
3943fc7d95 | ||
|
|
3999dc930c | ||
|
|
d4dea16456 | ||
|
|
ed38cb33a5 | ||
|
|
4cf5a0f4c8 | ||
|
|
8f0d526af2 | ||
|
|
6ca3881841 | ||
|
|
d8e765a04f | ||
|
|
40ff572f94 | ||
|
|
cc4275dc03 | ||
|
|
b387f4a0db | ||
|
|
1a096031c4 | ||
|
|
83a60b4091 | ||
|
|
b292407ec2 | ||
|
|
952c337b76 | ||
|
|
e947b887fe | ||
|
|
bd1e5485d7 | ||
|
|
e095c3318b | ||
|
|
d75a08b519 | ||
|
|
d55a616fe0 | ||
|
|
2146cb3576 | ||
|
|
ae260e74f6 | ||
|
|
355410c03c | ||
|
|
718ad51fac | ||
|
|
4242a8679f | ||
|
|
4ff9ff699b | ||
|
|
7a76673274 | ||
|
|
bd03ca5136 | ||
|
|
b3e1e4b909 | ||
|
|
4bb22183df | ||
|
|
e72f8f4245 | ||
|
|
5b52f48bce | ||
|
|
f07a157a2a | ||
|
|
ca40854106 | ||
|
|
d6a8209b31 | ||
|
|
731e1f1f15 | ||
|
|
b4a2a8fb98 | ||
|
|
5a3e4ee5ca | ||
|
|
ab2cf0aeec | ||
|
|
9de6a02b30 | ||
|
|
9fb7892bfe | ||
|
|
546f4556f6 | ||
|
|
471de104bc | ||
|
|
d30be1536d | ||
|
|
6c0678ed61 | ||
|
|
4883b8a190 | ||
|
|
14742ed4ad | ||
|
|
1d8bd56862 | ||
|
|
94b8f9fe1c | ||
|
|
27412211a5 | ||
|
|
f8c4960079 | ||
|
|
b2e0bcf995 | ||
|
|
fcf6639d38 | ||
|
|
d540cb91a9 | ||
|
|
f69cc6f1b1 | ||
|
|
607f2ff407 | ||
|
|
ba6bf8c091 | ||
|
|
7e4c938dfd | ||
|
|
7f36d55320 | ||
|
|
d9634a134c | ||
|
|
4f8868d4b1 | ||
|
|
956546585c | ||
|
|
3ca0a92442 | ||
|
|
213f7e48c9 | ||
|
|
8b66fd522d | ||
|
|
fdf5009999 | ||
|
|
bbdba0ef16 | ||
|
|
a63602df7a | ||
|
|
587120f984 | ||
|
|
e72ca0de7e | ||
|
|
c44c27d3d2 | ||
|
|
df4e201ccd | ||
|
|
c8c0e9ec1a | ||
|
|
9a4a84a367 | ||
|
|
1dc3424411 | ||
|
|
c13745e913 | ||
|
|
25c12309f2 | ||
|
|
4b632da5af | ||
|
|
87c364b8ee | ||
|
|
efa48fbc8a | ||
|
|
21df6c1d21 | ||
|
|
39d2ceb94b | ||
|
|
1dad013d60 | ||
|
|
add7a03f88 | ||
|
|
0cefaa6d48 | ||
|
|
f08e73f359 | ||
|
|
cf9ce26438 | ||
|
|
fd74a5a82e | ||
|
|
3109104928 | ||
|
|
b1ec4df2e4 | ||
|
|
1609e149a8 | ||
|
|
298c483d0e | ||
|
|
dc917b75b1 | ||
|
|
ad32bdab44 | ||
|
|
5e8b2e1c87 | ||
|
|
08b4afd287 | ||
|
|
0de31f643b | ||
|
|
5e815eb3c4 | ||
|
|
48c93a2120 | ||
|
|
ad5de42172 | ||
|
|
32bafedaad | ||
|
|
c67fd11be9 | ||
|
|
8b59c72848 | ||
|
|
c35b2f3bfc | ||
|
|
ac63ad4612 | ||
|
|
e1dea1c752 | ||
|
|
25648e2327 | ||
|
|
18ac04bb0f | ||
|
|
5263ee58b2 | ||
|
|
af542b89f7 | ||
|
|
684b675fca | ||
|
|
c29044eca1 | ||
|
|
a36510fcc8 | ||
|
|
bc21ace416 | ||
|
|
57e521e2ff | ||
|
|
ac6ebb9e8d | ||
|
|
54bef54635 | ||
|
|
593e201f79 | ||
|
|
d7b24253fe | ||
|
|
33961abd86 | ||
|
|
37e0e1d42f | ||
|
|
1121f9c918 | ||
|
|
582203f5da | ||
|
|
8c0f193738 | ||
|
|
ebe42956ad | ||
|
|
b8f8df8927 | ||
|
|
2c66ca4fdd | ||
|
|
49f813e880 | ||
|
|
da6fed80d1 | ||
|
|
b901d9b8c9 | ||
|
|
b41d46ac57 | ||
|
|
4f0189f3e0 | ||
|
|
c956e7a802 | ||
|
|
dcbc8409e0 | ||
|
|
fd58568cf0 | ||
|
|
0f81fa53d2 | ||
|
|
44655dc81c | ||
|
|
749667aefd | ||
|
|
dd94418c26 | ||
|
|
55a5375e46 | ||
|
|
df76de7352 | ||
|
|
1fb1a1b2b1 | ||
|
|
f998edb2aa | ||
|
|
7c2cb9a0c7 | ||
|
|
0690a365da | ||
|
|
a20d05aba8 | ||
|
|
4362ae95ba | ||
|
|
d658814399 | ||
|
|
39e14d70ee | ||
|
|
2e58cfdb75 | ||
|
|
fcaa724c00 | ||
|
|
8806b4141e | ||
|
|
7bd159766b | ||
|
|
4df15d603f | ||
|
|
b453c3efe5 | ||
|
|
56590ef8a4 | ||
|
|
7c133136b9 | ||
|
|
41881639aa | ||
|
|
416003f078 | ||
|
|
bbfcd0efa3 | ||
|
|
150e4332c3 | ||
|
|
49649765c7 | ||
|
|
726b7bfa93 | ||
|
|
265f838868 | ||
|
|
6e2e5b5520 | ||
|
|
100ea2f64a | ||
|
|
4e7ed1ee33 | ||
|
|
8ab6aed1aa | ||
|
|
4ff096014c | ||
|
|
03b60b6ca9 | ||
|
|
e30b832e05 | ||
|
|
e646de85a7 | ||
|
|
70a7a0e344 | ||
|
|
b444abeb3e | ||
|
|
c72f56917d | ||
|
|
192283d6b2 | ||
|
|
6be6fa1966 | ||
|
|
510553b055 | ||
|
|
6c4616892e | ||
|
|
1e79a099b8 | ||
|
|
31a22327f1 | ||
|
|
c1712bebc6 | ||
|
|
cd91541245 | ||
|
|
4c1fc83256 | ||
|
|
34c7a33576 | ||
|
|
23ecfeeb4f | ||
|
|
9703f83eb3 | ||
|
|
0f3cc03d00 | ||
|
|
6f7ba1f9fc | ||
|
|
e1b85e4a1b | ||
|
|
b308dd58cc | ||
|
|
9f4c0479ce | ||
|
|
2c57817dde | ||
|
|
ba85c54d7c | ||
|
|
a80e5c3a65 | ||
|
|
22e2c34da8 | ||
|
|
00a8e4c2c5 | ||
|
|
10d0a4079c | ||
|
|
589f7f3c22 | ||
|
|
d1126a7eb0 | ||
|
|
9f4e72a0e1 | ||
|
|
a024295379 | ||
|
|
dc2b2ec488 | ||
|
|
0c5f5975aa | ||
|
|
dc3f682d2d | ||
|
|
2db8876c66 | ||
|
|
8f6201b0f7 | ||
|
|
4b146c70ad | ||
|
|
0118034b4b | ||
|
|
39217053ca | ||
|
|
fba190c826 | ||
|
|
5e9d528e16 | ||
|
|
c5921d88fc | ||
|
|
eb980b0ea1 | ||
|
|
de5b4216f7 | ||
|
|
495ff57b19 | ||
|
|
57948cf6e3 | ||
|
|
1aebbbcabd | ||
|
|
25b4cb072d | ||
|
|
1cdacc3a08 | ||
|
|
34d9466d09 | ||
|
|
c182c4ce66 | ||
|
|
dbb9bd1282 | ||
|
|
8019d2d6cc | ||
|
|
459cdb2e0b | ||
|
|
a230cd9513 | ||
|
|
0c44a25e85 | ||
|
|
34f3d04370 | ||
|
|
1f3e6b7e16 | ||
|
|
47d49a200a | ||
|
|
e1767d6e52 | ||
|
|
0f8e343cd2 | ||
|
|
23ab487baf | ||
|
|
22e5d38ef5 | ||
|
|
5819ccb528 | ||
|
|
42a2fd77cf | ||
|
|
ab93a8b0b3 | ||
|
|
84437eafa6 | ||
|
|
0107d848e0 | ||
|
|
5eeac96a0d | ||
|
|
9351c115be | ||
|
|
f95a11096c | ||
|
|
4203d179e6 | ||
|
|
78dfc9cb1c | ||
|
|
0bef307d77 | ||
|
|
b0da806f7a | ||
|
|
badecd1d81 | ||
|
|
6418e8ee30 | ||
|
|
09115c9658 | ||
|
|
74e3866bd7 | ||
|
|
408de78c13 | ||
|
|
c0451c18b3 | ||
|
|
f303d26c1e | ||
|
|
1b58a34859 | ||
|
|
82ea416e67 | ||
|
|
efd4fbad70 | ||
|
|
01bd15121b | ||
|
|
a9c2495349 | ||
|
|
e7c50b50ed | ||
|
|
6e25b289d2 | ||
|
|
157267eaf7 | ||
|
|
a317f9137a | ||
|
|
5dad3d22ea | ||
|
|
be85df456b | ||
|
|
2e172a08c7 | ||
|
|
bb1069ca60 | ||
|
|
d8141a1628 | ||
|
|
de9f7c4baf | ||
|
|
fa9b3116f1 | ||
|
|
dcf9d52961 | ||
|
|
1da93e2cc7 | ||
|
|
1d1bab988e | ||
|
|
dcc6ad3af3 | ||
|
|
d57f266789 | ||
|
|
c3395e1eff | ||
|
|
ca59ec2dbe | ||
|
|
79788125f3 | ||
|
|
2154f20fa4 | ||
|
|
afe40b6a89 | ||
|
|
ba4b3bd6b8 | ||
|
|
e423b5d745 | ||
|
|
6de8eca7ea | ||
|
|
9d68cfcaf0 | ||
|
|
225de11e6a | ||
|
|
916581bbd0 | ||
|
|
1cbb35840f | ||
|
|
7a1d769e39 | ||
|
|
8254bf934c | ||
|
|
5e2f20542f | ||
|
|
551a707ee4 | ||
|
|
024b15b4f9 | ||
|
|
1935df4143 | ||
|
|
3f99f90076 | ||
|
|
53cb445dde | ||
|
|
6e46947220 | ||
|
|
9b65e1671b | ||
|
|
d5c741db35 | ||
|
|
11e0780b6e | ||
|
|
f153541570 | ||
|
|
f066af88e7 | ||
|
|
97e1eef799 | ||
|
|
1bcd902817 | ||
|
|
2484568b21 | ||
|
|
085cc47ea5 | ||
|
|
aac36a88f3 | ||
|
|
1f2ebc82b7 | ||
|
|
9781949064 | ||
|
|
b06ef3781a | ||
|
|
b32213cb7b | ||
|
|
ac4c7d2421 | ||
|
|
824a49b80f | ||
|
|
13efd50d80 | ||
|
|
6fb091d20f | ||
|
|
518ab85cae | ||
|
|
f5124ad8b5 | ||
|
|
6f80900aa8 | ||
|
|
06b80e9281 | ||
|
|
51b39d9365 | ||
|
|
f7d2d8fc95 | ||
|
|
f34fb94c1a | ||
|
|
3107224e50 | ||
|
|
e1c481c534 | ||
|
|
945a2dd3eb | ||
|
|
e318945eb1 | ||
|
|
926709568d | ||
|
|
da040e799c | ||
|
|
694976cb6e | ||
|
|
3f7bd1846a | ||
|
|
714898b4c3 | ||
|
|
4efc9b6990 | ||
|
|
73c3beaff1 | ||
|
|
a6bdccd4ef | ||
|
|
8007991e7d | ||
|
|
0e5d1ed1c3 | ||
|
|
91dc2f4f71 | ||
|
|
950bab3023 | ||
|
|
29082a10e6 | ||
|
|
65c1227d88 | ||
|
|
5d121a68cf | ||
|
|
ad07d2b8b1 | ||
|
|
c5aef78085 | ||
|
|
522ebc8aa2 | ||
|
|
199b3761b5 | ||
|
|
daf7b45e45 | ||
|
|
fc71b953c7 | ||
|
|
74ccd4a001 | ||
|
|
3c09f6ca55 | ||
|
|
c7dbe8599d | ||
|
|
ca036b6676 | ||
|
|
5ae87c8e03 | ||
|
|
9774fc4f65 | ||
|
|
d0891fb652 | ||
|
|
e388e2a1c7 | ||
|
|
d9ab354338 | ||
|
|
9ea0a0e214 | ||
|
|
131ddf0355 | ||
|
|
8abe71a0fe | ||
|
|
43e27a7e68 | ||
|
|
b784d277e4 | ||
|
|
cb443157da | ||
|
|
c886015d92 | ||
|
|
3161247da9 | ||
|
|
743a250131 | ||
|
|
4fb4b21a9e | ||
|
|
e1a7d3ebc5 | ||
|
|
72b8b156ac | ||
|
|
134dafb608 | ||
|
|
d5102b6422 | ||
|
|
a2cfb9d423 | ||
|
|
bef70f94ab | ||
|
|
4233fffe74 | ||
|
|
81c214748d | ||
|
|
c6a61dc8c8 | ||
|
|
483092d40d | ||
|
|
88cb02f6bc | ||
|
|
9628188730 | ||
|
|
2e66134bf8 | ||
|
|
424fe4d1e9 | ||
|
|
2b6f9568de | ||
|
|
4b270bce8b | ||
|
|
6b077e67db | ||
|
|
4c79ea0679 | ||
|
|
8c4c4c25aa | ||
|
|
a37dbe2c06 | ||
|
|
5e10cb2d16 | ||
|
|
58316c5c1d | ||
|
|
55f092f161 | ||
|
|
ab5049127a | ||
|
|
5f99067e56 | ||
|
|
3a89e61bd8 | ||
|
|
06edfa2fee | ||
|
|
77bfd72458 | ||
|
|
5ddf19be4d | ||
|
|
6657b47746 | ||
|
|
5690efb07a | ||
|
|
05600cba08 | ||
|
|
e3b4120b2c | ||
|
|
1059245fd9 | ||
|
|
87c8d310b3 | ||
|
|
fdc4999556 | ||
|
|
d28a8db4ff | ||
|
|
ab9e7d10dd | ||
|
|
3ff72b26b9 | ||
|
|
107ae70651 | ||
|
|
04de19033e | ||
|
|
089ac70cd3 | ||
|
|
ae40a9ead9 | ||
|
|
152806b7f6 | ||
|
|
06beb8e704 | ||
|
|
64f2b94685 | ||
|
|
5a42eb98ab | ||
|
|
404b87d78d | ||
|
|
dcb8fbdbf4 | ||
|
|
0fe513d7f5 | ||
|
|
0be05325e4 | ||
|
|
75b39308cd | ||
|
|
35ded81713 | ||
|
|
07c85280cd | ||
|
|
43f1d86be0 | ||
|
|
82f5ed4c44 | ||
|
|
0b53ad4b4d | ||
|
|
56ad41d10c | ||
|
|
249a4564e0 | ||
|
|
efb2413720 | ||
|
|
1226acefd6 | ||
|
|
76f7d7bc90 | ||
|
|
a5cb2641fd | ||
|
|
57fc64af79 | ||
|
|
f8b7b8b698 | ||
|
|
3da6befe10 | ||
|
|
a50d61c3ce | ||
|
|
b7568975e7 | ||
|
|
4b9475310f | ||
|
|
639f726c83 | ||
|
|
7997c486cf | ||
|
|
2569d00bd0 | ||
|
|
2cd80d8fa1 | ||
|
|
eedca4db9b | ||
|
|
1d352a76ce | ||
|
|
17670aabf9 | ||
|
|
3456b3d90e | ||
|
|
f3da5a9026 | ||
|
|
00cc63b7cd | ||
|
|
8df80ce738 | ||
|
|
12e7a783b0 | ||
|
|
be86a15351 | ||
|
|
2c5022e7b4 | ||
|
|
f4a996fcb9 | ||
|
|
fdbf508bbf | ||
|
|
500fafa5b2 | ||
|
|
bfa315c68b | ||
|
|
4972f085a3 | ||
|
|
9c13261929 | ||
|
|
e36dc4a913 | ||
|
|
4cccb56ee3 | ||
|
|
3199af570d | ||
|
|
7dad47b3c8 | ||
|
|
fbd50bad1d | ||
|
|
10469bae7b | ||
|
|
b6a000a001 | ||
|
|
59539e7e90 | ||
|
|
a2c41bc5ec | ||
|
|
c4a06858fb | ||
|
|
15b0f05a05 | ||
|
|
67feee9aa1 | ||
|
|
185fb57b8c | ||
|
|
e9039f8208 | ||
|
|
440d1b3ba0 | ||
|
|
9c7a6c64b0 | ||
|
|
7cc63fe849 | ||
|
|
5647e8219d | ||
|
|
81629fab7a | ||
|
|
992d2033f3 | ||
|
|
d52d4a1278 | ||
|
|
57a44c5952 | ||
|
|
a04161ac7c | ||
|
|
47e46f13c3 | ||
|
|
5535bd91c8 | ||
|
|
8747a00de6 | ||
|
|
c926b86926 | ||
|
|
010ac9aa7c | ||
|
|
4ab0c337f1 | ||
|
|
f814c42fdd | ||
|
|
02aee3e625 | ||
|
|
52a32cfdd1 | ||
|
|
6faa7b2efe | ||
|
|
f8481413c9 | ||
|
|
7d4baa7046 | ||
|
|
0951d01383 | ||
|
|
da34472a6f | ||
|
|
a24eaaed50 | ||
|
|
26813c582f | ||
|
|
6067ac73e2 | ||
|
|
b1d94b67f4 | ||
|
|
452f4e69fd | ||
|
|
e89b6e3ea0 | ||
|
|
977bab4745 | ||
|
|
26c73109fe | ||
|
|
65f911ad51 | ||
|
|
f8ed5ebd6a | ||
|
|
e4cb61bebe | ||
|
|
7d5310af42 | ||
|
|
f68c610c0d | ||
|
|
ccfb6a6d73 | ||
|
|
da55996e22 | ||
|
|
ecd8400a34 | ||
|
|
03914883bc | ||
|
|
9981e1f3bd | ||
|
|
4d1df66025 | ||
|
|
a0f64e188b | ||
|
|
08407a1094 | ||
|
|
445ea3bb2e | ||
|
|
0e0aab98b1 | ||
|
|
721d5eb0c1 | ||
|
|
6d99dbc3a7 | ||
|
|
2be31f649b | ||
|
|
828ac27c03 | ||
|
|
b3d614002f | ||
|
|
2b2000ca10 | ||
|
|
8d7428d099 | ||
|
|
57f8322f31 | ||
|
|
bee7bc4294 | ||
|
|
28702065bc | ||
|
|
c248057081 | ||
|
|
6186f5e138 | ||
|
|
2201d8176d | ||
|
|
2ba6819876 | ||
|
|
a393b2a370 | ||
|
|
59fe713df2 | ||
|
|
4b8efaca9a | ||
|
|
a26100a8d0 | ||
|
|
8c52f4718c | ||
|
|
85b5c3c4ec | ||
|
|
4fd559e4c7 | ||
|
|
a337d0ddc7 | ||
|
|
9e4cb7071e | ||
|
|
c3f1707343 | ||
|
|
1ed058a553 | ||
|
|
0500a8178d | ||
|
|
7d0059f573 | ||
|
|
4e3b882362 | ||
|
|
13cf47873e | ||
|
|
7ee23ec38f | ||
|
|
eebf17c42c | ||
|
|
f84536788b | ||
|
|
4bbfdc2cb2 | ||
|
|
211fec35e3 | ||
|
|
b8214a46ae | ||
|
|
549ef91c81 | ||
|
|
cede65313b | ||
|
|
d897a7400f | ||
|
|
47f059213f | ||
|
|
8af51bbf08 | ||
|
|
53d9f5ddc6 | ||
|
|
06fffdccc8 | ||
|
|
aa13dc68fc | ||
|
|
813876dd90 | ||
|
|
596c7d65c5 | ||
|
|
ce8dcb75bf | ||
|
|
1bd51b5565 | ||
|
|
1f9ec305b4 | ||
|
|
be0f6e57d7 | ||
|
|
b268e9ee74 | ||
|
|
e97774435b | ||
|
|
93586bc5bb | ||
|
|
fe23089714 | ||
|
|
e743986f38 | ||
|
|
a6c9b700ed | ||
|
|
afa3fcb524 | ||
|
|
b9aeb648d6 | ||
|
|
5f5df1e5b7 | ||
|
|
ad885679e4 | ||
|
|
e002bebfbe | ||
|
|
a8a41e2b3d | ||
|
|
31940caa84 | ||
|
|
880334054e | ||
|
|
5f03ad5597 | ||
|
|
1efa3f055d | ||
|
|
8ccf11278b | ||
|
|
8a9e7ab4c3 | ||
|
|
c0fa7c0c51 | ||
|
|
022dfd4709 | ||
|
|
71e08aacc3 | ||
|
|
337eca87f2 | ||
|
|
074aceff8f | ||
|
|
cdc6cf229a | ||
|
|
1f33513dc9 | ||
|
|
b095b91ff2 | ||
|
|
454a62dbb9 | ||
|
|
5f7cc12157 | ||
|
|
97ef1ee201 | ||
|
|
a318568b72 | ||
|
|
5bb9949440 | ||
|
|
c33e91d5d0 | ||
|
|
ca65ef3cb7 | ||
|
|
9ebdbc81d0 | ||
|
|
b64985349e | ||
|
|
625fd9d1a4 | ||
|
|
eac5fdcec0 | ||
|
|
970b4d5d97 | ||
|
|
f741bc818d | ||
|
|
5f04c24187 | ||
|
|
a382bef336 | ||
|
|
4ddf28f344 | ||
|
|
0dc650305a | ||
|
|
697093d1c9 | ||
|
|
622f7a4479 | ||
|
|
c4b607804b | ||
|
|
864f008679 | ||
|
|
25f309bcb0 | ||
|
|
1354361ad9 | ||
|
|
8136c7b072 | ||
|
|
c9243e7249 | ||
|
|
1a487da3d9 | ||
|
|
b52395751c | ||
|
|
cfa6cc9a83 | ||
|
|
f203384b00 | ||
|
|
9ac3be455c | ||
|
|
20b74a9dcd | ||
|
|
3b848a5a86 | ||
|
|
a9b5e865a5 | ||
|
|
ab46a1b99d | ||
|
|
4a08465f5b | ||
|
|
a7960d6cd6 | ||
|
|
3caea77dde | ||
|
|
fdaa3b7f93 | ||
|
|
4f433b4456 | ||
|
|
309a9ad4fb | ||
|
|
b0e7431e72 | ||
|
|
158118d183 | ||
|
|
382e37fc5a | ||
|
|
3390676847 | ||
|
|
544a995312 | ||
|
|
f209d49bb5 | ||
|
|
42ed691fdc | ||
|
|
98d2e9d266 | ||
|
|
6111158896 | ||
|
|
3267fc653c | ||
|
|
7250608767 | ||
|
|
e82063e435 | ||
|
|
6d4c44bc25 | ||
|
|
2bc94d8792 | ||
|
|
4ca3edd789 | ||
|
|
d6859efde2 | ||
|
|
3f8cbfa259 | ||
|
|
5d18c9371d | ||
|
|
631990e3bb | ||
|
|
4ae7338f94 | ||
|
|
0d1e51cb21 | ||
|
|
475fb833ea | ||
|
|
580b030ee4 | ||
|
|
6a7cbc70d6 | ||
|
|
d76f60639c | ||
|
|
e2bea407ee | ||
|
|
558fed31aa | ||
|
|
f6513d40c8 | ||
|
|
259f9baa59 | ||
|
|
db5650e276 | ||
|
|
51ebbbc569 | ||
|
|
5184661652 | ||
|
|
7853a14ce6 | ||
|
|
a01e78ace9 | ||
|
|
f7eb576d0d | ||
|
|
34f1ad8fae | ||
|
|
c60f0991df | ||
|
|
d505fd0795 | ||
|
|
93cf506535 | ||
|
|
bfb37e55d4 | ||
|
|
92afc5cb33 | ||
|
|
75cb611701 | ||
|
|
2ec1dd58a5 | ||
|
|
7d59af54de | ||
|
|
2b5f47b3de | ||
|
|
16eebfb9a4 | ||
|
|
9025218671 | ||
|
|
6bccb546bb | ||
|
|
29d49046a0 | ||
|
|
717af9ffaf | ||
|
|
00060c9f43 | ||
|
|
759ff46c92 | ||
|
|
41957cdceb | ||
|
|
d418e3a1c9 | ||
|
|
f650124428 | ||
|
|
795d109c76 | ||
|
|
6868b3effc | ||
|
|
26747b7013 | ||
|
|
5198f8aa60 | ||
|
|
552da48a32 | ||
|
|
db8a688620 | ||
|
|
3088028d05 | ||
|
|
fd62ef865d | ||
|
|
ed74ed00ed | ||
|
|
741317aaaf | ||
|
|
9b6ecd4e6b | ||
|
|
7863b3358e | ||
|
|
e1be68ec3d | ||
|
|
a054186d4b | ||
|
|
2d5c549c83 | ||
|
|
9f6072dfe1 | ||
|
|
69c44fe1ab | ||
|
|
4fa7b2443e | ||
|
|
25a69592bb | ||
|
|
44e0b26990 | ||
|
|
c4496f8dc8 | ||
|
|
9e296231d9 | ||
|
|
49b3f05d65 | ||
|
|
f124b9c050 | ||
|
|
63a86f7c06 | ||
|
|
fd0f523c64 | ||
|
|
487e605520 | ||
|
|
9e169e1f4b | ||
|
|
9612e7ebcd | ||
|
|
f66162efe7 | ||
|
|
656642697b | ||
|
|
feb70f85f8 | ||
|
|
ab1981559b | ||
|
|
c8852d9a8e | ||
|
|
9ac8dc7fd1 | ||
|
|
c9419d99e6 | ||
|
|
a1f4a83e72 | ||
|
|
a8abd5d427 | ||
|
|
629d1b0630 | ||
|
|
97c368f63a | ||
|
|
3266a444d0 | ||
|
|
1c246f71f8 | ||
|
|
96945dfc4a | ||
|
|
30eb3001ef | ||
|
|
bdd8636390 | ||
|
|
f762d2a271 | ||
|
|
cf2efc2b92 | ||
|
|
7670da4cba | ||
|
|
d87f9f2a21 | ||
|
|
6e690f3fea | ||
|
|
6321002617 | ||
|
|
15ec362428 | ||
|
|
454004e705 | ||
|
|
e14b414fc1 | ||
|
|
c4b47a5915 | ||
|
|
957c252cd7 | ||
|
|
d6a6c21762 | ||
|
|
834580cfdf | ||
|
|
de13cfb555 | ||
|
|
4f87508834 | ||
|
|
682a044f32 | ||
|
|
bdb5d90b1d | ||
|
|
01880f4456 | ||
|
|
39f78ce7e8 | ||
|
|
755c6b92da | ||
|
|
2eab9c2837 | ||
|
|
63861789de | ||
|
|
086c353eff | ||
|
|
4fe5b44655 | ||
|
|
036547e260 | ||
|
|
696f434c90 | ||
|
|
0c654d9346 | ||
|
|
a2c393b06b | ||
|
|
eae2c2b102 | ||
|
|
d9e49e3484 | ||
|
|
a28d4c2f1c | ||
|
|
9af055ec54 | ||
|
|
0d41171e9d | ||
|
|
08af826ae9 | ||
|
|
4fd577d7c5 | ||
|
|
2c8efebe98 | ||
|
|
93c9fb53ac | ||
|
|
5a4d249cf9 | ||
|
|
4cc7bdee37 | ||
|
|
a6af568411 | ||
|
|
576a6a094a | ||
|
|
e671e4b6f5 | ||
|
|
a66b2a4c70 | ||
|
|
f1ae409535 | ||
|
|
a4b56b477d | ||
|
|
d9c389812a | ||
|
|
074ef3645f | ||
|
|
cc3aa413e8 | ||
|
|
7f90c09227 | ||
|
|
f6f4d8ccc9 | ||
|
|
31afce8304 | ||
|
|
2c4ff856cd | ||
|
|
f59974e310 | ||
|
|
70e2c12a6b | ||
|
|
11f3c6ce6f | ||
|
|
e213c4640b | ||
|
|
959c5eaa59 | ||
|
|
66fa510b26 | ||
|
|
f26a3b31ac | ||
|
|
724fbf579e | ||
|
|
f192f8e3cd | ||
|
|
f13c3d19fb | ||
|
|
b51a09efcc | ||
|
|
6004043782 | ||
|
|
f9fd0dc2c3 | ||
|
|
eb5411cd20 | ||
|
|
da3c7a02f0 | ||
|
|
e67d05007f | ||
|
|
b0a9a6a08e | ||
|
|
d848ea35f4 | ||
|
|
350f20effe | ||
|
|
b6dc8f98fe | ||
|
|
1b762ee48d | ||
|
|
cc3d7f1eac | ||
|
|
4107282fbf | ||
|
|
c29ffc3fcd | ||
|
|
f648bcda13 | ||
|
|
aa0044eed2 | ||
|
|
2312a721ae | ||
|
|
b93fc39b00 | ||
|
|
2dc2cd700f | ||
|
|
d69e534f8b | ||
|
|
1de9ddd394 | ||
|
|
77c68d4e11 | ||
|
|
2a0d1dcfce | ||
|
|
5a19cca407 | ||
|
|
4e8773ecde | ||
|
|
4c7dada809 | ||
|
|
65690b15da | ||
|
|
8ba07812ce | ||
|
|
2dd8f35001 | ||
|
|
2d15aa88d4 | ||
|
|
e4257e50f0 | ||
|
|
33ebc07915 | ||
|
|
bc07299626 | ||
|
|
25e8aeef53 | ||
|
|
a2ed34abf3 | ||
|
|
36a7b7b91a | ||
|
|
b4e8b7375f | ||
|
|
153b635bdb | ||
|
|
80af72465e | ||
|
|
a91a8f9993 | ||
|
|
a0ccc7fe07 | ||
|
|
c162c9ae0e | ||
|
|
25542cdff3 | ||
|
|
16d0ae60c1 | ||
|
|
b1937eb8c0 | ||
|
|
3f6b468021 | ||
|
|
92d929b704 | ||
|
|
737ae75c28 | ||
|
|
79ced4eca4 | ||
|
|
329ac44c11 | ||
|
|
f65a91dfed | ||
|
|
2a79207427 | ||
|
|
70be3d10d0 | ||
|
|
3500a40599 | ||
|
|
090ffa9921 | ||
|
|
b12198fdcf | ||
|
|
826ee18666 | ||
|
|
f9d8b37b1a | ||
|
|
e626cb6b40 | ||
|
|
20697ad9e4 | ||
|
|
0800385b96 | ||
|
|
d6e326e8be | ||
|
|
8b969a6d36 | ||
|
|
d520e13c88 | ||
|
|
ae4081001c | ||
|
|
5a48b597b9 | ||
|
|
c8a953db7c | ||
|
|
d20ec144ff | ||
|
|
0147a82b0a | ||
|
|
8732a78d01 | ||
|
|
015254ae40 | ||
|
|
712bf405bb | ||
|
|
3a46a157f9 | ||
|
|
2a4ff926ae | ||
|
|
58941116c8 | ||
|
|
a13146d722 | ||
|
|
02e6f392b4 | ||
|
|
d4515bd643 | ||
|
|
a73555b7ca | ||
|
|
983d9ee1b9 | ||
|
|
e800dfe796 | ||
|
|
b0c59be340 | ||
|
|
dca12b6467 | ||
|
|
5a0d98cbd0 | ||
|
|
9cbaf22270 | ||
|
|
a64687f64f | ||
|
|
d229aab8c9 | ||
|
|
2ff94c1458 | ||
|
|
3b9f3ea81d | ||
|
|
23f12ad3cf | ||
|
|
01e7c1f183 | ||
|
|
37d60bc9b9 | ||
|
|
cd5d3903fe | ||
|
|
6904cfd224 | ||
|
|
c430e2c8f4 | ||
|
|
0cf8a94b24 | ||
|
|
ff3674aca7 | ||
|
|
b50498fa46 | ||
|
|
571f71c7f0 | ||
|
|
091c50ec84 | ||
|
|
e473dc8843 | ||
|
|
886af11d3a | ||
|
|
c72fd7ee9c | ||
|
|
7dc76b4222 | ||
|
|
5011e23050 | ||
|
|
89d9a90901 | ||
|
|
05128b12a8 | ||
|
|
c287ca9ea8 | ||
|
|
5122cda6db | ||
|
|
a39626f867 | ||
|
|
c7abae10b7 | ||
|
|
9a8f35fd8a | ||
|
|
0341cc1630 | ||
|
|
d307d233f0 | ||
|
|
5931b9625b | ||
|
|
fb837f5b97 | ||
|
|
8cfe95b3cf | ||
|
|
5fd73ac1e1 | ||
|
|
b51a574038 | ||
|
|
51b39f0775 | ||
|
|
17c4f96c94 | ||
|
|
89bf907613 | ||
|
|
641d0e45fd | ||
|
|
b3e579d8b7 | ||
|
|
fcb61c89d5 | ||
|
|
3483d78c2c | ||
|
|
36b14d0b3a | ||
|
|
2f8b68ec62 | ||
|
|
cb65cac333 | ||
|
|
d12ffc3d0d | ||
|
|
921744167e | ||
|
|
ebd96c4759 | ||
|
|
dd9876fc43 | ||
|
|
e0de614f30 | ||
|
|
30260883fb | ||
|
|
91c331e5f3 | ||
|
|
db803bcd23 | ||
|
|
cd482ea890 | ||
|
|
a2497c939a | ||
|
|
2e5249d30b | ||
|
|
b1d60c19d5 | ||
|
|
d28a82b33a | ||
|
|
787821f64b | ||
|
|
612b15fecc | ||
|
|
d88d5a1352 | ||
|
|
8584ddd00e | ||
|
|
4f572b5a10 | ||
|
|
90a0882c59 | ||
|
|
759344bb34 | ||
|
|
c9b65a3c43 | ||
|
|
b06e600946 | ||
|
|
2777751e54 | ||
|
|
b4493b2e8d | ||
|
|
55d5f6524d | ||
|
|
c7d376adf2 | ||
|
|
56bf69a770 | ||
|
|
22f9287d8b | ||
|
|
ca47d9272c | ||
|
|
0d61f5523a | ||
|
|
863ea9089a | ||
|
|
ad2af95ebd | ||
|
|
d65092c203 | ||
|
|
7982698880 | ||
|
|
afbdff8a88 | ||
|
|
cec07208ac | ||
|
|
c1b82d0fd2 | ||
|
|
c69e9d4b69 | ||
|
|
cc66c7f5ce | ||
|
|
7d3a689577 | ||
|
|
7b5915cdf7 | ||
|
|
5cc99366ef | ||
|
|
73289543e7 | ||
|
|
716b8b5b9a | ||
|
|
c4f6c9383c | ||
|
|
7d77d478c1 | ||
|
|
572c0d0717 | ||
|
|
b786b7b2d6 | ||
|
|
3253858c7f | ||
|
|
a5fe61078d | ||
|
|
528adae3d0 | ||
|
|
d3ff0edbff | ||
|
|
61cfee222f | ||
|
|
a96eb50784 | ||
|
|
8219e19c1b | ||
|
|
3d56a6915f | ||
|
|
4d6502f5e2 | ||
|
|
f0f245884f | ||
|
|
815f9178bf | ||
|
|
2df88f4280 | ||
|
|
156a1b4aa8 | ||
|
|
962eda7860 | ||
|
|
954ce9e85c | ||
|
|
ff27ddd442 | ||
|
|
f9cc2ad70a | ||
|
|
4bcd45a795 | ||
|
|
9e7ccd6e20 | ||
|
|
631c5930e9 | ||
|
|
0ddb182642 | ||
|
|
de2f0e74c8 | ||
|
|
ad4ee6ccc9 | ||
|
|
1670df02db | ||
|
|
0268e647cd | ||
|
|
4b9315c13c | ||
|
|
14a9948dd2 | ||
|
|
060ec98b0c | ||
|
|
9c1efe381e | ||
|
|
6a1b052d16 | ||
|
|
d6151d541e | ||
|
|
3e90277e1e | ||
|
|
641cbdfe85 | ||
|
|
c36e9b36d8 | ||
|
|
ecc3acce93 | ||
|
|
33a2ca55f0 | ||
|
|
9d0bbec4b3 | ||
|
|
4de0b3cffd | ||
|
|
06221a474b | ||
|
|
e99a42b7af | ||
|
|
37822e8409 | ||
|
|
2e477e6c99 | ||
|
|
360ee98d8d | ||
|
|
69afd7720a | ||
|
|
a75590c493 | ||
|
|
2acb65f6b3 | ||
|
|
1e1a58b58c | ||
|
|
aeadb5aeb8 | ||
|
|
3e2f01d56d | ||
|
|
cad97e639a | ||
|
|
e2632f1802 | ||
|
|
9aa0bb2bde | ||
|
|
f015d5f7ed | ||
|
|
74f8f7f9a4 | ||
|
|
2598538de9 | ||
|
|
a78a00df2b | ||
|
|
92f6beb64e | ||
|
|
3920b71613 | ||
|
|
d661150665 | ||
|
|
ab29c80dab | ||
|
|
e5384e27e5 | ||
|
|
06cdc6eb63 | ||
|
|
da284c793e | ||
|
|
c2cef91eb3 | ||
|
|
19da1aff45 | ||
|
|
5f2206e766 | ||
|
|
e272308823 | ||
|
|
8491c40890 | ||
|
|
dfa7013cbd | ||
|
|
1a7ceb333d | ||
|
|
d3a30b87f4 | ||
|
|
dd61c04d63 | ||
|
|
5496e874c4 | ||
|
|
56d13288ff | ||
|
|
618111ab05 | ||
|
|
55d02495a3 | ||
|
|
83fc586e03 | ||
|
|
ae3dc9b22c | ||
|
|
12e0f9459b | ||
|
|
fd1dd54b99 | ||
|
|
32cbb2a82c | ||
|
|
048b7c08ca | ||
|
|
d98251e34a | ||
|
|
1eea4e8fc1 | ||
|
|
da330bc615 | ||
|
|
1134250954 | ||
|
|
d8fddd4128 | ||
|
|
42656cd690 | ||
|
|
746f9517d9 | ||
|
|
2a373de5f5 | ||
|
|
b507f00929 | ||
|
|
aea8a254bf | ||
|
|
4247665ba4 | ||
|
|
d59d0a6a42 | ||
|
|
8d40ed0bda | ||
|
|
67bc893e22 | ||
|
|
0f5d3b34a5 | ||
|
|
39b577e935 | ||
|
|
91380d2aed | ||
|
|
f995c24264 | ||
|
|
c7db0ec643 | ||
|
|
f9e000034f | ||
|
|
8ddd2d6290 | ||
|
|
f6c8666f06 | ||
|
|
1bf173fcae | ||
|
|
696866f065 | ||
|
|
c82283c7f0 | ||
|
|
0e62f25557 | ||
|
|
e2c51f44bf | ||
|
|
d4768392a6 | ||
|
|
580744819d | ||
|
|
959794de10 | ||
|
|
3a32c09feb | ||
|
|
09bfce4590 | ||
|
|
092697a4b2 | ||
|
|
a36de9424e | ||
|
|
db4bc2afb2 | ||
|
|
73bfc07082 | ||
|
|
b3cf88aac3 | ||
|
|
69bcc61a01 | ||
|
|
bdc124052c | ||
|
|
cfcee9eb99 | ||
|
|
e93c873c17 | ||
|
|
2b1bb6a299 | ||
|
|
3d7c039fd4 | ||
|
|
9971f1d4bf | ||
|
|
59702d3d50 | ||
|
|
736e6ec075 | ||
|
|
abdd6d4142 | ||
|
|
16edc85e39 | ||
|
|
bbeb92a9da | ||
|
|
daaf9ac70b | ||
|
|
0805353a9e | ||
|
|
594ff954b1 | ||
|
|
cf5203687e | ||
|
|
55ad2d664d | ||
|
|
4d1485a61f | ||
|
|
f260378c93 | ||
|
|
08f9175705 | ||
|
|
ca7cc322d7 | ||
|
|
a631befdf9 | ||
|
|
b7875644bc | ||
|
|
9d8507a1e4 | ||
|
|
50042aa36d | ||
|
|
d21cf6286a | ||
|
|
b0709abfa2 | ||
|
|
dedb729dfe | ||
|
|
27e2f26d5d | ||
|
|
ae30c3865d | ||
|
|
b3453af0fe | ||
|
|
550dcbc081 | ||
|
|
5af1f81bc3 | ||
|
|
d306f972cc | ||
|
|
0f6747f2db | ||
|
|
559b96d56f | ||
|
|
69606429b8 | ||
|
|
824fe68194 | ||
|
|
b056feda59 | ||
|
|
32635dedb0 | ||
|
|
835f9374f6 | ||
|
|
edece5e17f | ||
|
|
298317d352 | ||
|
|
c7637eb941 | ||
|
|
c8f9e0ac9d | ||
|
|
3e3f259920 | ||
|
|
c88baf3c2c | ||
|
|
0d27dabd62 | ||
|
|
5c29594b3d | ||
|
|
1e71d0ae2f | ||
|
|
a4c72481d1 | ||
|
|
23ecb433fb | ||
|
|
6b9ad4a947 | ||
|
|
9a44f53e5f | ||
|
|
bce31df7e6 | ||
|
|
4bdd33b44f | ||
|
|
7ab5d4e113 | ||
|
|
92c454b3e9 | ||
|
|
a7f588b724 | ||
|
|
2ca7d2b7fc | ||
|
|
fe60956362 | ||
|
|
9c20df510d | ||
|
|
7dab3bfb1e | ||
|
|
b281f19344 | ||
|
|
d25cc64152 | ||
|
|
a1b0ffe2c9 | ||
|
|
17dcd9458e | ||
|
|
e2d645d414 | ||
|
|
d4b7f44987 |
6
.babelrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"presets": [['@babel/preset-env', { "targets": { "esmodules": true } }]],
|
||||
"plugins": [
|
||||
["@babel/plugin-proposal-decorators", { "legacy": true }]
|
||||
]
|
||||
}
|
||||
19
.eslintrc
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"parser": "vue-eslint-parser",
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
"parser": "@babel/eslint-parser",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/essential"
|
||||
"plugin:vue/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"vue",
|
||||
"html",
|
||||
"node"
|
||||
"@babel"
|
||||
],
|
||||
"env": {
|
||||
"es6": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
@@ -24,6 +25,14 @@
|
||||
"LM_TOTAL": false
|
||||
},
|
||||
"rules": {
|
||||
"vue/html-indent": ["warn", 4, {
|
||||
"alignAttributesVertically": false
|
||||
}],
|
||||
"vue/max-attributes-per-line": "off",
|
||||
"vue/html-self-closing": "off",
|
||||
"vue/no-v-html": "off",
|
||||
"vue/no-v-model-argument": "off",
|
||||
|
||||
"strict": 0,
|
||||
"indent": [0, 4, {
|
||||
"SwitchCase": 1
|
||||
|
||||
10
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
/node_modules
|
||||
/server/data
|
||||
/server/public
|
||||
/server/ipfs
|
||||
/dist
|
||||
/node_modules
|
||||
/server/.liberama*
|
||||
/dist
|
||||
dev*.sh
|
||||
|
||||
|
||||
106
LICENSE.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# CC0 1.0 Universal
|
||||
|
||||
## Statement of Purpose
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer
|
||||
exclusive Copyright and Related Rights (defined below) upon the creator and
|
||||
subsequent owner(s) (each and all, an “owner”) of an original work of
|
||||
authorship and/or a database (each, a “Work”).
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the
|
||||
purpose of contributing to a commons of creative, cultural and scientific works
|
||||
(“Commons”) that the public can reliably and without fear of later claims of
|
||||
infringement build upon, modify, incorporate in other works, reuse and
|
||||
redistribute as freely as possible in any form whatsoever and for any purposes,
|
||||
including without limitation commercial purposes. These owners may contribute
|
||||
to the Commons to promote the ideal of a free culture and the further
|
||||
production of creative, cultural and scientific works, or to gain reputation or
|
||||
greater distribution for their Work in part through the use and efforts of
|
||||
others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation of
|
||||
additional consideration or compensation, the person associating CC0 with a
|
||||
Work (the “Affirmer”), to the extent that he or she is an owner of Copyright
|
||||
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and
|
||||
publicly distribute the Work under its terms, with knowledge of his or her
|
||||
Copyright and Related Rights in the Work and the meaning and intended legal
|
||||
effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||
protected by copyright and related or neighboring rights (“Copyright and
|
||||
Related Rights”). Copyright and Related Rights include, but are not limited
|
||||
to, the following:
|
||||
1. the right to reproduce, adapt, distribute, perform, display,
|
||||
communicate, and translate a Work;
|
||||
2. moral rights retained by the original author(s) and/or performer(s);
|
||||
3. publicity and privacy rights pertaining to a person’s image or likeness
|
||||
depicted in a Work;
|
||||
4. rights protecting against unfair competition in regards to a Work,
|
||||
subject to the limitations in paragraph 4(i), below;
|
||||
5. rights protecting the extraction, dissemination, use and reuse of data
|
||||
in a Work;
|
||||
6. database rights (such as those arising under Directive 96/9/EC of the
|
||||
European Parliament and of the Council of 11 March 1996 on the legal
|
||||
protection of databases, and under any national implementation thereof,
|
||||
including any amended or successor version of such directive); and
|
||||
7. other similar, equivalent or corresponding rights throughout the world
|
||||
based on applicable law or treaty, and any national implementations
|
||||
thereof.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention of,
|
||||
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
||||
unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright
|
||||
and Related Rights and associated claims and causes of action, whether now
|
||||
known or unknown (including existing as well as future claims and causes of
|
||||
action), in the Work (i) in all territories worldwide, (ii) for the maximum
|
||||
duration provided by applicable law or treaty (including future time
|
||||
extensions), (iii) in any current or future medium and for any number of
|
||||
copies, and (iv) for any purpose whatsoever, including without limitation
|
||||
commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes
|
||||
the Waiver for the benefit of each member of the public at large and to the
|
||||
detriment of Affirmer’s heirs and successors, fully intending that such Waiver
|
||||
shall not be subject to revocation, rescission, cancellation, termination, or
|
||||
any other legal or equitable action to disrupt the quiet enjoyment of the Work
|
||||
by the public as contemplated by Affirmer’s express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason be
|
||||
judged legally invalid or ineffective under applicable law, then the Waiver
|
||||
shall be preserved to the maximum extent permitted taking into account
|
||||
Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver
|
||||
is so judged Affirmer hereby grants to each affected person a royalty-free, non
|
||||
transferable, non sublicensable, non exclusive, irrevocable and unconditional
|
||||
license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in
|
||||
all territories worldwide, (ii) for the maximum duration provided by applicable
|
||||
law or treaty (including future time extensions), (iii) in any current or
|
||||
future medium and for any number of copies, and (iv) for any purpose
|
||||
whatsoever, including without limitation commercial, advertising or promotional
|
||||
purposes (the “License”). The License shall be deemed effective as of the date
|
||||
CC0 was applied by Affirmer to the Work. Should any part of the License for any
|
||||
reason be judged legally invalid or ineffective under applicable law, such
|
||||
partial invalidity or ineffectiveness shall not invalidate the remainder of the
|
||||
License, and in such case Affirmer hereby affirms that he or she will not (i)
|
||||
exercise any of his or her remaining Copyright and Related Rights in the Work
|
||||
or (ii) assert any associated claims and causes of action with respect to the
|
||||
Work, in either case contrary to Affirmer’s express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
1. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||
surrendered, licensed or otherwise affected by this document.
|
||||
2. Affirmer offers the Work as-is and makes no representations or
|
||||
warranties of any kind concerning the Work, express, implied, statutory
|
||||
or otherwise, including without limitation warranties of title,
|
||||
merchantability, fitness for a particular purpose, non infringement, or
|
||||
the absence of latent or other defects, accuracy, or the present or
|
||||
absence of errors, whether or not discoverable, all to the greatest
|
||||
extent permissible under applicable law.
|
||||
3. Affirmer disclaims responsibility for clearing rights of other persons
|
||||
that may apply to the Work or any use thereof, including without
|
||||
limitation any person’s Copyright and Related Rights in the Work.
|
||||
Further, Affirmer disclaims responsibility for obtaining any necessary
|
||||
consents, permissions or other rights required for any use of the Work.
|
||||
4. Affirmer understands and acknowledges that Creative Commons is not a
|
||||
party to this document and has no duty or obligation with respect to
|
||||
this CC0 or use of the Work.
|
||||
|
||||
For more information, please see
|
||||
http://creativecommons.org/publicdomain/zero/1.0/.
|
||||
163
README.md
@@ -1,3 +1,160 @@
|
||||
# Liberama
|
||||
|
||||
Свободный обмен книгами в формате fb2
|
||||
# Liberama
|
||||
|
||||
Браузерная онлайн-читалка книг.
|
||||
|
||||
Выглядит соледующим образом: <img src="https://omnireader.ru/favicon.ico" width="14px"/>[OmniReader](https://omnireader.ru)
|
||||
|
||||

|
||||

|
||||
|
||||
При запуске приложения, по умолчанию веб-сервер доступен по адресу [http://127.0.0.1:44080](http://127.0.0.1:44080)
|
||||
|
||||
Для указания местоположения рабочей директории, воспользуйтесь [параметрами командной строки](#cli).
|
||||
Дополнительные параметры сервера настраиваются в [конфигурационном файле](#config).
|
||||
|
||||
[Отблагодарить автора проекта](https://donatty.com/liberama)
|
||||
|
||||
##
|
||||
* [Возможности читалки](#capabilities)
|
||||
* [Использование](#usage)
|
||||
* [Параметры командной строки](#cli)
|
||||
* [Конфигурация](#config)
|
||||
* [Разворачивание на VPS](#vps)
|
||||
* [Сборка проекта](#build)
|
||||
* [Разработка](#development)
|
||||
|
||||
<a id="capabilities" />
|
||||
|
||||
## Возможности читалки
|
||||
- загрузка любой страницы интернета
|
||||
- синхронизация данных (настроек и читаемых книг) между различными устройствами
|
||||
- работа в автономном режиме (без связи)
|
||||
- изменение цвета фона, текста, размер и тип шрифта и прочее
|
||||
- установка и запоминание текущей позиции и настроек в браузере и на сервере
|
||||
- кэширование файлов книг на клиенте и на сервере
|
||||
- открытие книг с локального диска
|
||||
- плавный скроллинг текста
|
||||
- анимация перелистывания
|
||||
- поиск по тексту и копирование фрагмента
|
||||
- запоминание недавних книг, скачивание книги из читалки в формате fb2
|
||||
- управление кликом и с клавиатуры
|
||||
- регистрация не требуется
|
||||
- поддерживаемые браузеры: Google Chrome, Mozilla Firefox последних версий
|
||||
- релизы сервера под Linux, MacOS и Windows
|
||||
|
||||
<a id="usage" />
|
||||
|
||||
## Использование
|
||||
Приложение представляет собой полноценный веб-сервер в виде единого исполнимого файла.
|
||||
При первом запуске, будет создана рабочая директория `.liberama` (по умолчанию - в той же папке, где исполнимый файл),
|
||||
в которой хранится конфигурационный файл `config.json`, файлы веб-приложения, файлы базы данных, журналы и прочее.
|
||||
Изменить рабочую директорию можно с помощью cli-параметра --app-dir
|
||||
|
||||
По умолчанию веб-интерфейс будет доступен по адресу [http://127.0.0.1:44080](http://127.0.0.1:44080)
|
||||
|
||||
<a id="cli" />
|
||||
|
||||
### Параметры командной строки
|
||||
Запустите `liberama --help`, чтобы увидеть список опций:
|
||||
```console
|
||||
Usage: liberama [options]
|
||||
|
||||
Options:
|
||||
--help Показать опции командной строки
|
||||
--app-dir=<dirpath> Задать рабочую директорию, по умолчанию: <execDir>/.liberama
|
||||
--auto-repair Починить БД приложения при запуске, если она повреждена
|
||||
```
|
||||
|
||||
<a id="config" />
|
||||
|
||||
### Конфигурация
|
||||
При первом запуске в рабочей директории будет создан конфигурационный файл `config.json`:
|
||||
```js
|
||||
{
|
||||
// Максимальный размер файла загружаемой книги (в байтах)
|
||||
"maxUploadFileSize": 52428800,
|
||||
|
||||
// Максимальный размер каталога <appDir>/public-files/tmp для хранения конвертированных
|
||||
// файлов книг пользователей (в байтах)
|
||||
"maxTempPublicDirSize": 536870912,
|
||||
|
||||
// Максимальный размер каталога <appDir>/public-files/upload для хранения
|
||||
// загруженных в /upload (кнопка "Загрузить файл с диска") файлов книг пользователей (в байтах)
|
||||
"maxUploadPublicDirSize": 209715200,
|
||||
|
||||
// Использование внешних конвертеров (только в среде Linux)
|
||||
// Без них читалка может работать только с файлами формата fb2, txt, html, xml
|
||||
// Инструкции установки внешних конвертеров см. в docs/omnireader.ru/README.md
|
||||
"useExternalBookConverter": false,
|
||||
|
||||
// Настройки для списка серверов.
|
||||
// Приложение может запускать одновременно несколько веб-серверов на разных портах
|
||||
"servers": [
|
||||
{
|
||||
// Произвольное название сервера
|
||||
"serverName": "1",
|
||||
|
||||
// Режим работы сервера:
|
||||
// "reader" - обычная читалка
|
||||
// "omnireader" - модификации для сайта omnireader.ru
|
||||
// "liberama" - модификации для сайта liberama.top
|
||||
// "book_update_checker" - сервер обновлений
|
||||
"mode": "reader",
|
||||
|
||||
// Хост, порт сервера
|
||||
"ip": "0.0.0.0",
|
||||
"port": "44080"
|
||||
}
|
||||
],
|
||||
|
||||
// Настройки удаленного хранилища
|
||||
"remoteStorage": false,
|
||||
|
||||
// Для веб-приложения: включение/выключение работы с сервером обновлений
|
||||
"bucEnabled": false,
|
||||
|
||||
// Подключение себя, как клиента, к серверу обновлений
|
||||
"bucServer": false
|
||||
|
||||
// Сcылка для открытия в новом окне брауpера по клику на кнопку "Сетевая библиотека"
|
||||
// Если не задано, открывается внутренний менеджер библиотек с использванием фрейма
|
||||
"networkLibraryLink": "http://samlib.ru/"
|
||||
}
|
||||
```
|
||||
|
||||
При необходимости, можно настроить нужный параметр в этом файле вручную.
|
||||
|
||||
<a id="vps" />
|
||||
|
||||
## VPS
|
||||
Для разворачивания читалки на чистом VPS с нуля смотрите [docs/omnireader.ru](docs/omnireader.ru/README.md)
|
||||
|
||||
<a id="build" />
|
||||
|
||||
### Сборка проекта
|
||||
Сборка только в среде Linux.
|
||||
Необходима версия node.js не ниже 16.
|
||||
|
||||
Для сборки linux-arm64 необходимо предварительно установить [QEMU](https://wiki.debian.org/QemuUserEmulation).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/bookpauk/liberama
|
||||
cd liberama
|
||||
npm i
|
||||
```
|
||||
|
||||
#### Релизы
|
||||
```sh
|
||||
npm run release
|
||||
```
|
||||
|
||||
Результат сборки будет доступен в каталоге `dist/release`
|
||||
|
||||
<a id="development" />
|
||||
|
||||
### Разработка
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Связаться с автором проекта: [bookpauk@gmail.com](mailto:bookpauk@gmail.com)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
const stream = require('stream');
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
const got = require('got');
|
||||
const decompress = require('decompress');
|
||||
const decompressTargz = require('decompress-targz');
|
||||
|
||||
const distDir = path.resolve(__dirname, '../dist');
|
||||
const publicDir = `${distDir}/tmp/public`;
|
||||
const outDir = `${distDir}/linux`;
|
||||
|
||||
const tempDownloadDir = `${distDir}/tmp/download`;
|
||||
|
||||
async function main() {
|
||||
await fs.emptyDir(outDir);
|
||||
// перемещаем public на место
|
||||
if (await fs.pathExists(publicDir))
|
||||
await fs.move(publicDir, `${outDir}/public`);
|
||||
|
||||
await fs.ensureDir(tempDownloadDir);
|
||||
|
||||
//sqlite3
|
||||
const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v4.0.4/node-v64-linux-x64.tar.gz';
|
||||
const sqliteDecompressedFilename = `${tempDownloadDir}/node-v64-linux-x64/node_sqlite3.node`;
|
||||
|
||||
if (!await fs.pathExists(sqliteDecompressedFilename)) {
|
||||
// Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку
|
||||
await pipeline(got.stream(sqliteRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/sqlite.tar.gz`));
|
||||
console.log(`done downloading ${sqliteRemoteUrl}`);
|
||||
|
||||
//распаковываем
|
||||
await decompress(`${tempDownloadDir}/sqlite.tar.gz`, `${tempDownloadDir}`, {
|
||||
plugins: [
|
||||
decompressTargz()
|
||||
]
|
||||
});
|
||||
console.log('files decompressed');
|
||||
}
|
||||
// копируем в дистрибутив
|
||||
await fs.copy(sqliteDecompressedFilename, `${outDir}/node_sqlite3.node`);
|
||||
console.log(`copied ${sqliteDecompressedFilename} to ${outDir}/node_sqlite3.node`);
|
||||
|
||||
//ipfs
|
||||
const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs`;
|
||||
if (!await fs.pathExists(ipfsDecompressedFilename)) {
|
||||
// Скачиваем ipfs
|
||||
const ipfsRemoteUrl = 'https://dist.ipfs.io/go-ipfs/v0.4.18/go-ipfs_v0.4.18_linux-amd64.tar.gz';
|
||||
|
||||
await pipeline(got.stream(ipfsRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/ipfs.tar.gz`));
|
||||
console.log(`done downloading ${ipfsRemoteUrl}`);
|
||||
|
||||
//распаковываем
|
||||
await decompress(`${tempDownloadDir}/ipfs.tar.gz`, `${tempDownloadDir}`, {
|
||||
plugins: [
|
||||
decompressTargz()
|
||||
]
|
||||
});
|
||||
console.log('files decompressed');
|
||||
}
|
||||
|
||||
// копируем в дистрибутив
|
||||
await fs.copy(ipfsDecompressedFilename, `${outDir}/ipfs`);
|
||||
console.log(`copied ${tempDownloadDir}/go-ipfs/ipfs to ${outDir}/ipfs`);
|
||||
//для development
|
||||
const devIpfsFile = path.resolve(__dirname, '../server/ipfs');
|
||||
if (!await fs.pathExists(devIpfsFile)) {
|
||||
await fs.copy(ipfsDecompressedFilename, devIpfsFile);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
51
build/prepkg.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const showdown = require('showdown');
|
||||
|
||||
const platform = process.argv[2];
|
||||
|
||||
const distDir = path.resolve(__dirname, '../dist');
|
||||
const tmpDir = `${distDir}/tmp`;
|
||||
const publicDir = `${tmpDir}/public`;
|
||||
const outDir = `${distDir}/${platform}`;
|
||||
|
||||
async function build() {
|
||||
if (!platform)
|
||||
throw new Error(`Please set platform`);
|
||||
|
||||
await fs.emptyDir(outDir);
|
||||
|
||||
//добавляем readme в релиз
|
||||
let readme = await fs.readFile(path.resolve(__dirname, '../README.md'), 'utf-8');
|
||||
const converter = new showdown.Converter();
|
||||
readme = converter.makeHtml(readme);
|
||||
await fs.writeFile(`${outDir}/readme.html`, readme);
|
||||
|
||||
// перемещаем public на место
|
||||
if (await fs.pathExists(publicDir)) {
|
||||
|
||||
const zipFile = `${tmpDir}/public.zip`;
|
||||
const jsonFile = `${distDir}/public.json`;//distDir !!!
|
||||
|
||||
await fs.remove(zipFile);
|
||||
execSync(`zip -r ${zipFile} .`, {cwd: publicDir, stdio: 'inherit'});
|
||||
|
||||
const data = (await fs.readFile(zipFile)).toString('base64');
|
||||
await fs.writeFile(jsonFile, JSON.stringify({data}));
|
||||
} else {
|
||||
throw new Error(`publicDir: ${publicDir} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await build();
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
33
build/release.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const pckg = require('../package.json');
|
||||
|
||||
const distDir = path.resolve(__dirname, '../dist');
|
||||
const outDir = `${distDir}/release`;
|
||||
|
||||
async function makeRelease(target) {
|
||||
const srcDir = `${distDir}/${target}`;
|
||||
|
||||
if (await fs.pathExists(srcDir)) {
|
||||
const zipFile = `${outDir}/${pckg.name}-${pckg.version}-${target}.zip`;
|
||||
|
||||
execSync(`zip -r ${zipFile} .`, {cwd: srcDir, stdio: 'inherit'});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await fs.emptyDir(outDir);
|
||||
await makeRelease('win');
|
||||
await makeRelease('linux');
|
||||
await makeRelease('linux-arm64');
|
||||
await makeRelease('macos');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,66 +1,77 @@
|
||||
const path = require('path');
|
||||
//const webpack = require('webpack');
|
||||
const VueLoaderPlugin = require('vue-loader/lib/plugin');
|
||||
const DefinePlugin = require('webpack').DefinePlugin;
|
||||
const { VueLoaderPlugin } = require('vue-loader');
|
||||
|
||||
const clientDir = path.resolve(__dirname, '../client');
|
||||
|
||||
module.exports = {
|
||||
resolve: {
|
||||
alias: {
|
||||
ws: false,
|
||||
//vue: '@vue/compat'
|
||||
}
|
||||
},
|
||||
entry: [`${clientDir}/main.js`],
|
||||
output: {
|
||||
publicPath: '/app/',
|
||||
clean: true
|
||||
},
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: "vue-loader"
|
||||
loader: 'vue-loader',
|
||||
/*options: {
|
||||
compilerOptions: {
|
||||
compatConfig: {
|
||||
MODE: 2
|
||||
}
|
||||
}
|
||||
}*/
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: /node_modules/,
|
||||
query: {
|
||||
options: {
|
||||
presets: [['@babel/preset-env', { targets: { esmodules: true } }]],
|
||||
plugins: [
|
||||
'syntax-dynamic-import',
|
||||
'transform-decorators-legacy',
|
||||
'transform-class-properties',
|
||||
// ["component", { "libraryName": "element-ui", "styleLibraryName": `~${clientDir}/theme` } ]
|
||||
['@babel/plugin-proposal-decorators', { legacy: true }]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.gif$/,
|
||||
loader: "url-loader",
|
||||
options: {
|
||||
name: "images/[name]-[hash:6].[ext]"
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.png$/,
|
||||
loader: "url-loader",
|
||||
options: {
|
||||
name: "images/[name]-[hash:6].[ext]"
|
||||
}
|
||||
test: /\.(gif|png)$/,
|
||||
type: 'asset/inline',
|
||||
},
|
||||
{
|
||||
test: /\.jpg$/,
|
||||
loader: "file-loader",
|
||||
options: {
|
||||
name: "images/[name]-[hash:6].[ext]"
|
||||
}
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: 'images/[name]-[hash:6][ext]'
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
test: /\.(ttf|eot|woff|woff2)$/,
|
||||
loader: "file-loader",
|
||||
options: {
|
||||
name: "fonts/[name]-[hash:6].[ext]"
|
||||
}
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: 'fonts/[name]-[hash:6][ext]'
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
plugins: [
|
||||
new DefinePlugin({
|
||||
__VUE_OPTIONS_API__: true,
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
__QUASAR_SSR__: false,
|
||||
__QUASAR_SSR_SERVER__: false,
|
||||
__QUASAR_SSR_CLIENT__: false,
|
||||
__QUASAR_VERSION__: false,
|
||||
}),
|
||||
new VueLoaderPlugin(),
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const pckg = require('../package.json');
|
||||
|
||||
const merge = require('webpack-merge');
|
||||
const { merge } = require('webpack-merge');
|
||||
const baseWpConfig = require('./webpack.base.config');
|
||||
|
||||
baseWpConfig.entry.unshift('webpack-hot-middleware/client');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
|
||||
const publicDir = path.resolve(__dirname, '../server/public');
|
||||
const publicDir = path.resolve(__dirname, `../server/.${pckg.name}/public`);
|
||||
const clientDir = path.resolve(__dirname, '../client');
|
||||
|
||||
module.exports = merge(baseWpConfig, {
|
||||
mode: 'development',
|
||||
devtool: "#inline-source-map",
|
||||
devtool: 'inline-source-map',
|
||||
output: {
|
||||
path: `${publicDir}/app`,
|
||||
filename: 'bundle.js'
|
||||
path: `${publicDir}${baseWpConfig.output.publicPath}`,
|
||||
filename: 'bundle.js',
|
||||
},
|
||||
|
||||
module: {
|
||||
@@ -38,6 +39,6 @@ module.exports = merge(baseWpConfig, {
|
||||
template: `${clientDir}/index.html.template`,
|
||||
filename: `${publicDir}/index.html`
|
||||
}),
|
||||
new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}])
|
||||
new CopyWebpackPlugin({patterns: [{context: `${clientDir}/assets`, from: `${clientDir}/assets/*`, to: `${publicDir}/`}]})
|
||||
]
|
||||
});
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
const path = require('path');
|
||||
//const webpack = require('webpack');
|
||||
|
||||
const merge = require('webpack-merge');
|
||||
const { merge } = require('webpack-merge');
|
||||
const baseWpConfig = require('./webpack.base.config');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
||||
const CssMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const CleanWebpackPlugin = require('clean-webpack-plugin');
|
||||
//const { CleanWebpackPlugin } = require('clean-webpack-plugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const {GenerateSW} = require('workbox-webpack-plugin');
|
||||
|
||||
const publicDir = path.resolve(__dirname, '../dist/tmp/public');
|
||||
const clientDir = path.resolve(__dirname, '../client');
|
||||
@@ -16,8 +17,8 @@ const clientDir = path.resolve(__dirname, '../client');
|
||||
module.exports = merge(baseWpConfig, {
|
||||
mode: 'production',
|
||||
output: {
|
||||
path: `${publicDir}/app_new`,
|
||||
filename: 'bundle.[contenthash].js'
|
||||
path: `${publicDir}${baseWpConfig.output.publicPath}`,
|
||||
filename: 'bundle.[contenthash].js',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
@@ -32,12 +33,19 @@ module.exports = merge(baseWpConfig, {
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin(),
|
||||
new OptimizeCSSAssetsPlugin()
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
terserOptions: {
|
||||
format: {
|
||||
comments: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
new CssMinimizerWebpackPlugin()
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin([publicDir], {root: path.resolve(__dirname, '..')}),
|
||||
//new CleanWebpackPlugin({ cleanOnceBeforeBuildPatterns: [`${publicDir}/**`] }),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: "[name].[contenthash].css"
|
||||
}),
|
||||
@@ -45,6 +53,15 @@ module.exports = merge(baseWpConfig, {
|
||||
template: `${clientDir}/index.html.template`,
|
||||
filename: `${publicDir}/index.html`
|
||||
}),
|
||||
new CopyWebpackPlugin([{from: `${clientDir}/assets/*`, to: `${publicDir}/`, flatten: true}])
|
||||
new CopyWebpackPlugin({patterns:
|
||||
[{context: `${clientDir}/assets`, from: `${clientDir}/assets/*`, to: `${publicDir}/` }]
|
||||
}),
|
||||
new GenerateSW({
|
||||
cacheId: 'liberama',
|
||||
swDest: `${publicDir}/service-worker.js`,
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [new RegExp('^/api'), new RegExp('^/ws'), new RegExp('^/tmp'),],
|
||||
skipWaiting: true,
|
||||
}),
|
||||
]
|
||||
});
|
||||
|
||||
64
build/win.js
@@ -1,64 +0,0 @@
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
const stream = require('stream');
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
const got = require('got');
|
||||
const decompress = require('decompress');
|
||||
const decompressTargz = require('decompress-targz');
|
||||
|
||||
const distDir = path.resolve(__dirname, '../dist');
|
||||
const publicDir = `${distDir}/tmp/public`;
|
||||
const outDir = `${distDir}/win`;
|
||||
|
||||
const tempDownloadDir = `${distDir}/tmp/download`;
|
||||
|
||||
async function main() {
|
||||
await fs.emptyDir(outDir);
|
||||
// перемещаем public на место
|
||||
if (await fs.pathExists(publicDir))
|
||||
await fs.move(publicDir, `${outDir}/public`);
|
||||
|
||||
await fs.ensureDir(tempDownloadDir);
|
||||
|
||||
//sqlite3
|
||||
const sqliteRemoteUrl = 'https://mapbox-node-binary.s3.amazonaws.com/sqlite3/v4.0.4/node-v64-win32-x64.tar.gz';
|
||||
const sqliteDecompressedFilename = `${tempDownloadDir}/node-v64-win32-x64/node_sqlite3.node`;
|
||||
|
||||
if (!await fs.pathExists(sqliteDecompressedFilename)) {
|
||||
// Скачиваем node_sqlite3.node для винды, т.к. pkg не включает его в сборку
|
||||
await pipeline(got.stream(sqliteRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/sqlite.tar.gz`));
|
||||
console.log(`done downloading ${sqliteRemoteUrl}`);
|
||||
|
||||
//распаковываем
|
||||
await decompress(`${tempDownloadDir}/sqlite.tar.gz`, `${tempDownloadDir}`, {
|
||||
plugins: [
|
||||
decompressTargz()
|
||||
]
|
||||
});
|
||||
console.log('files decompressed');
|
||||
}
|
||||
// копируем в дистрибутив
|
||||
await fs.copy(sqliteDecompressedFilename, `${outDir}/node_sqlite3.node`);
|
||||
console.log(`copied ${sqliteDecompressedFilename} to ${outDir}/node_sqlite3.node`);
|
||||
|
||||
//ipfs
|
||||
const ipfsDecompressedFilename = `${tempDownloadDir}/go-ipfs/ipfs.exe`;
|
||||
if (!await fs.pathExists(ipfsDecompressedFilename)) {
|
||||
// Скачиваем ipfs
|
||||
const ipfsRemoteUrl = 'https://dist.ipfs.io/go-ipfs/v0.4.18/go-ipfs_v0.4.18_windows-amd64.zip';
|
||||
|
||||
await pipeline(got.stream(ipfsRemoteUrl), fs.createWriteStream(`${tempDownloadDir}/ipfs.zip`));
|
||||
console.log(`done downloading ${ipfsRemoteUrl}`);
|
||||
|
||||
//распаковываем
|
||||
await decompress(`${tempDownloadDir}/ipfs.zip`, `${tempDownloadDir}`);
|
||||
console.log('files decompressed');
|
||||
}
|
||||
// копируем в дистрибутив
|
||||
await fs.copy(ipfsDecompressedFilename, `${outDir}/ipfs.exe`);
|
||||
console.log(`copied ${ipfsDecompressedFilename} to ${outDir}/ipfs.exe`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,13 +1,21 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api'
|
||||
});
|
||||
import wsc from './webSocketConnection';
|
||||
|
||||
class Misc {
|
||||
async loadConfig() {
|
||||
const response = await api.post('/config', {params: ['name', 'version', 'mode']});
|
||||
return response.data;
|
||||
async loadConfig(_configHash) {
|
||||
|
||||
const query = {
|
||||
params: [
|
||||
'name', 'version', 'mode', 'maxUploadFileSize', 'useExternalBookConverter',
|
||||
'acceptFileExt', 'bucEnabled', 'branch', 'networkLibraryLink', 'restricted'
|
||||
],
|
||||
_configHash,
|
||||
};
|
||||
|
||||
const config = await wsc.message(await wsc.send(Object.assign({action: 'get-config'}, query)));
|
||||
if (config.error)
|
||||
throw new Error(config.error);
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,86 +1,126 @@
|
||||
import axios from 'axios';
|
||||
import {sleep} from '../share/utils';
|
||||
import * as utils from '../share/utils';
|
||||
import * as cryptoUtils from '../share/cryptoUtils';
|
||||
import wsc from './webSocketConnection';
|
||||
|
||||
const maxFileUploadSize = 50*1024*1024;
|
||||
const api = axios.create({
|
||||
baseURL: '/api/reader'
|
||||
baseURL: '/api/reader'
|
||||
});
|
||||
|
||||
const workerApi = axios.create({
|
||||
baseURL: '/api/worker'
|
||||
});
|
||||
/*const workerApi = axios.create({
|
||||
baseURL: '/api/worker'
|
||||
});*/
|
||||
|
||||
class Reader {
|
||||
async loadBook(url, callback) {
|
||||
const refreshPause = 200;
|
||||
constructor() {
|
||||
}
|
||||
|
||||
async getWorkerStateFinish(workerId, callback) {
|
||||
if (!callback) callback = () => {};
|
||||
|
||||
let response = await api.post('/load-book', {type: 'url', url});
|
||||
let response = {};
|
||||
const requestId = await wsc.send({action: 'worker-get-state-finish', workerId});
|
||||
|
||||
const workerId = response.data.workerId;
|
||||
let prevResponse = false;
|
||||
while (1) {// eslint-disable-line no-constant-condition
|
||||
response = await wsc.message(requestId);
|
||||
|
||||
if (!response.state && prevResponse !== false) {//экономия траффика
|
||||
callback(prevResponse);
|
||||
} else {//были изменения worker state
|
||||
if (!response.state)
|
||||
throw new Error('Неверный ответ api');
|
||||
callback(response);
|
||||
prevResponse = response;
|
||||
}
|
||||
|
||||
if (response.state == 'finish' || response.state == 'error') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async loadBook(opts, callback) {
|
||||
if (!callback) callback = () => {};
|
||||
|
||||
let response = await wsc.message(await wsc.send(Object.assign({action: 'load-book'}, opts)));
|
||||
const workerId = response.workerId;
|
||||
if (!workerId)
|
||||
throw new Error('Неверный ответ api');
|
||||
|
||||
callback({totalSteps: 4});
|
||||
callback(response);
|
||||
|
||||
let i = 0;
|
||||
while (1) {// eslint-disable-line no-constant-condition
|
||||
callback(response.data);
|
||||
response = await this.getWorkerStateFinish(workerId, callback);
|
||||
|
||||
if (response.data.state == 'finish') {//воркер закончил работу, можно скачивать кешированный на сервере файл
|
||||
if (response) {
|
||||
if (response.state == 'finish') {//воркер закончил работу, можно скачивать кешированный на сервере файл
|
||||
callback({step: 4});
|
||||
const book = await this.loadCachedBook(response.data.path, callback);
|
||||
return Object.assign({}, response.data, {data: book.data});
|
||||
const book = await this.loadCachedBook(response.path, callback, response.size);
|
||||
return Object.assign({}, response, {data: book.data});
|
||||
}
|
||||
if (response.data.state == 'error') {
|
||||
let errMes = response.data.error;
|
||||
|
||||
if (response.state == 'error') {
|
||||
let errMes = response.error;
|
||||
if (errMes.indexOf('getaddrinfo') >= 0 ||
|
||||
errMes.indexOf('ECONNRESET') >= 0 ||
|
||||
errMes.indexOf('EINVAL') >= 0 ||
|
||||
errMes.indexOf('404') >= 0)
|
||||
errMes = `Ресурс не найден по адресу: ${response.data.url}`;
|
||||
errMes = `Ресурс не найден по адресу: ${response.url}`;
|
||||
throw new Error(errMes);
|
||||
}
|
||||
if (i > 0)
|
||||
await sleep(refreshPause);
|
||||
|
||||
i++;
|
||||
if (i > 30*1000/refreshPause) {//30 сек ждем телодвижений воркера
|
||||
throw new Error('Слишком долгое время ожидания');
|
||||
}
|
||||
//проверка воркера
|
||||
const prevProgress = response.data.progress;
|
||||
response = await workerApi.post('/get-state', {workerId});
|
||||
i = (prevProgress != response.data.progress ? 1 : i);
|
||||
} else {
|
||||
throw new Error('Пустой ответ сервера');
|
||||
}
|
||||
}
|
||||
|
||||
async loadCachedBook(url, callback){
|
||||
const response = await axios.head(url);
|
||||
async checkCachedBook(url) {
|
||||
let estSize = -1;
|
||||
try {
|
||||
const response = await axios.head(url, {headers: {'Cache-Control': 'no-cache'}});
|
||||
|
||||
let estSize = 1000000;
|
||||
if (response.headers['content-length']) {
|
||||
estSize = response.headers['content-length'];
|
||||
if (response.headers['content-length']) {
|
||||
estSize = response.headers['content-length'];
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
|
||||
return estSize;
|
||||
}
|
||||
|
||||
async loadCachedBook(url, callback, estSize = -1) {
|
||||
if (!callback) callback = () => {};
|
||||
|
||||
callback({state: 'loading', progress: 0});
|
||||
|
||||
//получение размера файла
|
||||
if (estSize && estSize < 0) {
|
||||
estSize = await this.checkCachedBook(url);
|
||||
}
|
||||
|
||||
//получение файла
|
||||
estSize = (estSize > 0 ? estSize : 1000000);
|
||||
const options = {
|
||||
onDownloadProgress: progress => {
|
||||
onDownloadProgress: (progress) => {
|
||||
while (progress.loaded > estSize) estSize *= 1.5;
|
||||
|
||||
if (callback)
|
||||
callback({state: 'loading', progress: Math.round((progress.loaded*100)/estSize)});
|
||||
callback({progress: Math.round((progress.loaded*100)/estSize)});
|
||||
}
|
||||
}
|
||||
//загрузка
|
||||
|
||||
return await axios.get(url, options);
|
||||
}
|
||||
|
||||
async uploadFile(file, callback) {
|
||||
if (file.size > maxFileUploadSize)
|
||||
throw new Error(`Размер файла превышает ${maxFileUploadSize} байт`);
|
||||
async uploadFile(file, maxUploadFileSize = 10*1024*1024, callback) {
|
||||
if (file.size > maxUploadFileSize)
|
||||
throw new Error(`Размер файла превышает ${maxUploadFileSize} байт`);
|
||||
|
||||
let formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', file, file.name);
|
||||
|
||||
const options = {
|
||||
headers: {
|
||||
@@ -104,6 +144,58 @@ class Reader {
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async storage(request) {
|
||||
const response = await wsc.message(await wsc.send({action: 'reader-storage', body: request}));
|
||||
|
||||
if (response.error)
|
||||
throw new Error(response.error);
|
||||
|
||||
if (!response.state)
|
||||
throw new Error('Неверный ответ api');
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
makeUrlFromBuf(buf) {
|
||||
const key = utils.toHex(cryptoUtils.sha256(buf));
|
||||
return `disk://${key}`;
|
||||
}
|
||||
|
||||
async uploadFileBuf(buf, url) {
|
||||
if (!url)
|
||||
url = this.makeUrlFromBuf(buf);
|
||||
|
||||
let response;
|
||||
try {
|
||||
await axios.head(url.replace('disk://', '/upload/'), {headers: {'Cache-Control': 'no-cache'}});
|
||||
response = await wsc.message(await wsc.send({action: 'upload-file-touch', url}));
|
||||
} catch (e) {
|
||||
response = await wsc.message(await wsc.send({action: 'upload-file-buf', buf}));
|
||||
}
|
||||
|
||||
if (response.error)
|
||||
throw new Error(response.error);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async getUploadedFileBuf(url) {
|
||||
url = url.replace('disk://', '/upload/');
|
||||
return (await axios.get(url)).data;
|
||||
}
|
||||
|
||||
async checkBuc(bookUrls) {
|
||||
const response = await wsc.message(await wsc.send({action: 'check-buc', bookUrls}));
|
||||
|
||||
if (response.error)
|
||||
throw new Error(response.error);
|
||||
|
||||
if (!response.data)
|
||||
throw new Error(`response.data is empty`);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export default new Reader();
|
||||
3
client/api/webSocketConnection.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import WebSocketConnection from '../../server/core/WebSocketConnection';
|
||||
|
||||
export default new WebSocketConnection();
|
||||
|
Before Width: | Height: | Size: 227 B After Width: | Height: | Size: 1.3 KiB |
2
client/assets/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /?*url=
|
||||
5
client/assets/sw-register.js
Normal file
@@ -0,0 +1,5 @@
|
||||
(function() {
|
||||
if('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/service-worker.js');
|
||||
}
|
||||
})();
|
||||
@@ -1,157 +1,183 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-aside v-if="showAsideBar" :width="asideWidth">
|
||||
<div class="app-name"><span v-html="appName"></span></div>
|
||||
<el-button class="el-button-collapse" @click="toggleCollapse" :icon="buttonCollapseIcon"></el-button>
|
||||
<el-menu class="el-menu-vertical" :default-active="rootRoute" :collapse="isCollapse" router>
|
||||
<el-menu-item index="/cardindex">
|
||||
<i class="el-icon-search"></i>
|
||||
<span :class="itemTitleClass('/cardindex')" slot="title">{{ this.itemRuText['/cardindex'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/reader">
|
||||
<i class="el-icon-tickets"></i>
|
||||
<span :class="itemTitleClass('/reader')" slot="title">{{ this.itemRuText['/reader'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/forum" disabled>
|
||||
<i class="el-icon-message"></i>
|
||||
<span :class="itemTitleClass('/forum')" slot="title">{{ this.itemRuText['/forum'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/income">
|
||||
<i class="el-icon-upload"></i>
|
||||
<span :class="itemTitleClass('/income')" slot="title">{{ this.itemRuText['/income'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/sources">
|
||||
<i class="el-icon-menu"></i>
|
||||
<span :class="itemTitleClass('/sources')" slot="title">{{ this.itemRuText['/sources'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/settings">
|
||||
<i class="el-icon-setting"></i>
|
||||
<span :class="itemTitleClass('/settings')" slot="title">{{ this.itemRuText['/settings'] }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/help">
|
||||
<i class="el-icon-question"></i>
|
||||
<span :class="itemTitleClass('/help')" slot="title">{{ this.itemRuText['/help'] }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<div class="fit row">
|
||||
<Notify ref="notify" />
|
||||
<StdDialog ref="stdDialog" />
|
||||
|
||||
<el-main v-if="showMain" :style="{padding: (isReaderActive ? 0 : '5px')}">
|
||||
<keep-alive>
|
||||
<router-view></router-view>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive v-if="showPage">
|
||||
<component :is="Component" class="col" />
|
||||
</keep-alive>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from './vueComponent.js';
|
||||
|
||||
export default @Component({
|
||||
import Notify from './share/Notify.vue';
|
||||
import StdDialog from './share/StdDialog.vue';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
|
||||
import miscApi from '../api/misc';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Notify,
|
||||
StdDialog,
|
||||
},
|
||||
watch: {
|
||||
rootRoute: function() {
|
||||
mode: function() {
|
||||
this.setAppTitle();
|
||||
this.redirectIfNeeded();
|
||||
},
|
||||
mode: function() {
|
||||
this.redirectIfNeeded();
|
||||
}
|
||||
nightMode() {
|
||||
this.setNightMode();
|
||||
},
|
||||
},
|
||||
|
||||
})
|
||||
class App extends Vue {
|
||||
itemRuText = {
|
||||
'/cardindex': 'Картотека',
|
||||
'/reader': 'Читалка',
|
||||
'/forum': 'Форум-чат',
|
||||
'/income': 'Поступления',
|
||||
'/sources': 'Источники',
|
||||
'/settings': 'Параметры',
|
||||
'/help': 'Справка',
|
||||
}
|
||||
};
|
||||
class App {
|
||||
_options = componentOptions;
|
||||
showPage = false;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.dispatch = this.$store.dispatch;
|
||||
this.state = this.$store.state;
|
||||
this.uistate = this.$store.state.uistate;
|
||||
this.config = this.$store.state.config;
|
||||
|
||||
// set-app-title
|
||||
this.$root.$on('set-app-title', this.setAppTitle);
|
||||
//dark mode
|
||||
let darkMode = null;
|
||||
this.$root.setDarkMode = (value) => {
|
||||
if (darkMode !== value) {
|
||||
const vars = [
|
||||
'--bg-app-color', '--text-app-color', '--bg-dialog-color', '--text-anchor-color',
|
||||
'--bg-loader-color', '--bg-input-color', '--bg-btn-color1', '--bg-btn-color2',
|
||||
'--bg-header-color1', '--bg-header-color2', '--bg-header-color3',
|
||||
'--bg-menu-color1', '--bg-menu-color2', '--text-menu-color', '--text-ubtn-color',
|
||||
'--text-tb-normal', '--bg-tb-normal', '--bg-tb-hover',
|
||||
'--text-tb-active', '--bg-tb-active', '--bg-tb-active-hover',
|
||||
'--text-tb-disabled', '--bg-tb-disabled',
|
||||
'--bg-selected-item-color1', '--bg-selected-item-color2',
|
||||
];
|
||||
|
||||
//global keyHooks
|
||||
this.keyHooks = [];
|
||||
this.keyHook = (event) => {
|
||||
for (const hook of this.keyHooks)
|
||||
let root = document.querySelector(':root');
|
||||
let cs = getComputedStyle(root);
|
||||
|
||||
let mode = (value ? '-dark' : '-light');
|
||||
for (const v of vars) {
|
||||
const propValue = cs.getPropertyValue(`${v}${mode}`);
|
||||
root.style.setProperty(v, propValue);
|
||||
}
|
||||
|
||||
darkMode = value;
|
||||
}
|
||||
};
|
||||
|
||||
//root route
|
||||
let cachedRoute = '';
|
||||
let cachedPath = '';
|
||||
this.$root.getRootRoute = () => {
|
||||
if (this.$route.path != cachedPath) {
|
||||
cachedPath = this.$route.path;
|
||||
const m = cachedPath.match(/^(\/[^/]*).*$/i);
|
||||
cachedRoute = (m ? m[1] : this.$route.path);
|
||||
|
||||
}
|
||||
return cachedRoute;
|
||||
};
|
||||
|
||||
this.$router.beforeEach((to, from, next) => {
|
||||
//распознавание хоста, если присутствует домен 3-уровня "b.", то разрешена только определенная страница
|
||||
if (window.location.host.indexOf('b.') == 0 && to.path != '/external-libs' && to.path != '/404') {
|
||||
next('/404');
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
this.$root.isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent);
|
||||
|
||||
// setAppTitle
|
||||
this.$root.setAppTitle = this.setAppTitle;
|
||||
|
||||
//sanitize
|
||||
this.$root.sanitize = sanitizeHtml;
|
||||
|
||||
//global event hooks
|
||||
this.eventHooks = {};
|
||||
this.$root.eventHook = (hookName, event) => {
|
||||
if (!this.eventHooks[hookName])
|
||||
return;
|
||||
for (const hook of this.eventHooks[hookName])
|
||||
hook(event);
|
||||
}
|
||||
|
||||
this.$root.addKeyHook = (hook) => {
|
||||
if (this.keyHooks.indexOf(hook) < 0)
|
||||
this.keyHooks.push(hook);
|
||||
this.$root.addEventHook = (hookName, hook) => {
|
||||
if (!this.eventHooks[hookName])
|
||||
this.eventHooks[hookName] = [];
|
||||
if (this.eventHooks[hookName].indexOf(hook) < 0)
|
||||
this.eventHooks[hookName].push(hook);
|
||||
}
|
||||
|
||||
this.$root.removeKeyHook = (hook) => {
|
||||
const i = this.keyHooks.indexOf(hook);
|
||||
this.$root.removeEventHook = (hookName, hook) => {
|
||||
if (!this.eventHooks[hookName])
|
||||
return;
|
||||
const i = this.eventHooks[hookName].indexOf(hook);
|
||||
if (i >= 0)
|
||||
this.keyHooks.splice(i, 1);
|
||||
this.eventHooks[hookName].splice(i, 1);
|
||||
}
|
||||
|
||||
document.addEventListener('keyup', (event) => {
|
||||
this.keyHook(event);
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
this.keyHook(event);
|
||||
});
|
||||
window.addEventListener('resize', () => {
|
||||
this.$root.$emit('resize');
|
||||
this.$root.eventHook('key', event);
|
||||
});
|
||||
document.addEventListener('keypress', (event) => {
|
||||
this.$root.eventHook('key', event);
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
this.$root.eventHook('key', event);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', (event) => {
|
||||
this.$root.eventHook('resize', event);
|
||||
});
|
||||
|
||||
this.setNightMode();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.dispatch('config/loadConfig');
|
||||
this.$watch('apiError', function(newError) {
|
||||
if (newError) {
|
||||
this.$notify.error({
|
||||
title: 'Ошибка API',
|
||||
dangerouslyUseHTMLString: true,
|
||||
message: newError.response.config.url + '<br>' + newError.response.statusText
|
||||
});
|
||||
this.$root.notify = this.$refs.notify;
|
||||
this.$root.stdDialog = this.$refs.stdDialog;
|
||||
|
||||
this.setAppTitle();
|
||||
(async() => {
|
||||
//загрузим конфиг сервера
|
||||
try {
|
||||
const config = await miscApi.loadConfig(this.config._configHash);
|
||||
|
||||
if (!config._useCached)
|
||||
this.commit('config/setConfig', config);
|
||||
|
||||
this.showPage = true;
|
||||
} catch(e) {
|
||||
//проверим, не получен ли конфиг ранее
|
||||
if (!this.mode) {
|
||||
this.$root.notify.error(e.message, 'Ошибка API');
|
||||
} else {
|
||||
//вероятно, работаем в оффлайне
|
||||
this.showPage = true;
|
||||
}
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleCollapse() {
|
||||
this.commit('uistate/setAsideBarCollapse', !this.uistate.asideBarCollapse);
|
||||
this.$root.$emit('resize');
|
||||
}
|
||||
|
||||
get isCollapse() {
|
||||
return this.uistate.asideBarCollapse;
|
||||
}
|
||||
|
||||
get asideWidth() {
|
||||
if (this.uistate.asideBarCollapse) {
|
||||
return '64px';
|
||||
} else {
|
||||
return '170px';
|
||||
}
|
||||
}
|
||||
|
||||
get buttonCollapseIcon() {
|
||||
if (this.uistate.asideBarCollapse) {
|
||||
return 'el-icon-d-arrow-right';
|
||||
} else {
|
||||
return 'el-icon-d-arrow-left';
|
||||
}
|
||||
}
|
||||
|
||||
get appName() {
|
||||
if (this.isCollapse)
|
||||
return '<br><br>';
|
||||
else
|
||||
return `${this.config.name} <br>v${this.config.version}`;
|
||||
//запросим persistent storage
|
||||
if (navigator.storage && navigator.storage.persist) {
|
||||
navigator.storage.persist();
|
||||
}
|
||||
await this.$router.isReady();
|
||||
this.redirectIfNeeded();
|
||||
})();
|
||||
}
|
||||
|
||||
get apiError() {
|
||||
@@ -159,18 +185,26 @@ class App extends Vue {
|
||||
}
|
||||
|
||||
get rootRoute() {
|
||||
const m = this.$route.path.match(/^(\/[^/]*).*$/i);
|
||||
this.$root.rootRoute = (m ? m[1] : this.$route.path);
|
||||
return this.$root.getRootRoute();
|
||||
}
|
||||
|
||||
return this.$root.rootRoute;
|
||||
get nightMode() {
|
||||
return this.$store.state.reader.settings.nightMode;
|
||||
}
|
||||
|
||||
setNightMode() {
|
||||
this.$root.setDarkMode(this.nightMode);
|
||||
this.$q.dark.set(this.nightMode);
|
||||
}
|
||||
|
||||
setAppTitle(title) {
|
||||
if (!title) {
|
||||
if (this.mode == 'omnireader') {
|
||||
if (this.mode == 'liberama') {
|
||||
document.title = `Liberama Reader - всегда с вами`;
|
||||
} else if (this.mode == 'omnireader') {
|
||||
document.title = `Omni Reader - всегда с вами`;
|
||||
} else if (this.config && this.mode !== null) {
|
||||
document.title = `${this.config.name} - ${this.itemRuText[this.$root.rootRoute]}`;
|
||||
document.title = `Универсальная читалка книг и ресурсов интернета`;
|
||||
}
|
||||
} else {
|
||||
document.title = title;
|
||||
@@ -185,104 +219,194 @@ class App extends Vue {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
get showAsideBar() {
|
||||
return (this.mode !== null && this.mode != 'reader' && this.mode != 'omnireader');
|
||||
}
|
||||
|
||||
get isReaderActive() {
|
||||
return this.rootRoute == '/reader';
|
||||
}
|
||||
|
||||
get showMain() {
|
||||
return (this.showAsideBar || this.isReaderActive);
|
||||
}
|
||||
|
||||
redirectIfNeeded() {
|
||||
if ((this.mode == 'reader' || this.mode == 'omnireader') && (this.rootRoute != '/reader')) {
|
||||
//старый url
|
||||
const search = window.location.search.substr(1);
|
||||
const url = search.split('url=')[1] || '';
|
||||
if (url) {
|
||||
window.location = `/#/reader?url=${url}`;
|
||||
} else {
|
||||
this.$router.replace('/reader');
|
||||
}
|
||||
const search = window.location.search.substr(1);
|
||||
|
||||
//распознавание параметра url вида "?url=<link>" и редирект при необходимости
|
||||
const s = search.split('url=');
|
||||
const url = s[1] || '';
|
||||
if (url) {
|
||||
window.history.replaceState({}, '', '/');
|
||||
this.$router.replace({ path: '/reader', query: {url} });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(App);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-name {
|
||||
margin-left: 10px;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
line-height: 140%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.bold-font {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.el-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.el-aside {
|
||||
line-height: 1;
|
||||
background-color: #ccc;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
padding: 0;
|
||||
background-color: #E6EDF4;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.el-menu-vertical:not(.el-menu--collapse) {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.el-menu--collapse {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.el-button-collapse, .el-button-collapse:focus, .el-button-collapse:active, .el-button-collapse:hover {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
border: 0;
|
||||
}
|
||||
.el-menu-item {
|
||||
font-size: 85%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body, html, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font: normal 12pt ReaderDefault;
|
||||
/* color schemes */
|
||||
:root {
|
||||
/* current */
|
||||
--bg-app-color: #fff;
|
||||
--text-app-color: #000;
|
||||
--bg-dialog-color: #fff;
|
||||
--text-anchor-color: #00f;
|
||||
--bg-loader-color: #ebe2c9;
|
||||
--bg-input-color: #eee;
|
||||
--bg-btn-color1: #1976d2;
|
||||
--bg-btn-color2: #eee;
|
||||
--bg-header-color1: #007000;
|
||||
--bg-header-color2: #59b04f;
|
||||
--bg-header-color3: #bbdefb;
|
||||
--bg-menu-color1: #eee;
|
||||
--bg-menu-color2: #e0e0e0;
|
||||
--text-menu-color: #757575;
|
||||
--text-ubtn-color: #bbb;
|
||||
|
||||
--text-tb-normal: #3e843e;
|
||||
--bg-tb-normal: #e6edf4;
|
||||
--bg-tb-hover: #fff;
|
||||
--text-tb-active: #fff;
|
||||
--bg-tb-active: #8ab45f;
|
||||
--bg-tb-active-hover: #81c581;
|
||||
--text-tb-disabled: #d3d3d3;
|
||||
--bg-tb-disabled: #808080;
|
||||
|
||||
--bg-selected-item-color1: #b0f0b0;
|
||||
--bg-selected-item-color2: #d0f5d0;
|
||||
|
||||
/* light */
|
||||
--bg-app-color-light: #fff;
|
||||
--text-app-color-light: #000;
|
||||
--bg-dialog-color-light: #fff;
|
||||
--text-anchor-color-light: #00f;
|
||||
--bg-loader-color-light: #ebe2c9;
|
||||
--bg-input-color-light: #eee;
|
||||
--bg-btn-color1-light: #1976d2;
|
||||
--bg-btn-color2-light: #eee;
|
||||
--bg-header-color1-light: #007000;
|
||||
--bg-header-color2-light: #59b04f;
|
||||
--bg-header-color3-light: #bbdefb;
|
||||
--bg-menu-color1-light: #eee;
|
||||
--bg-menu-color2-light: #e0e0e0;
|
||||
--text-menu-color-light: #757575;
|
||||
--text-ubtn-color-light: #bbb;
|
||||
|
||||
--text-tb-normal-light: #3e843e;
|
||||
--bg-tb-normal-light: #e6edf4;
|
||||
--bg-tb-hover-light: #fff;
|
||||
--text-tb-active-light: #fff;
|
||||
--bg-tb-active-light: #8ab45f;
|
||||
--bg-tb-active-hover-light: #81c581;
|
||||
--text-tb-disabled-light: #d3d3d3;
|
||||
--bg-tb-disabled-light: #808080;
|
||||
|
||||
--bg-selected-item-color1-light: #b0f0b0;
|
||||
--bg-selected-item-color2-light: #d0f5d0;
|
||||
|
||||
/* dark */
|
||||
--bg-app-color-dark: #222;
|
||||
--text-app-color-dark: #ccc;
|
||||
--bg-dialog-color-dark: #444;
|
||||
--text-anchor-color-dark: #09f;
|
||||
--bg-loader-color-dark: #222;
|
||||
--bg-input-color-dark: #333;
|
||||
--bg-btn-color1-dark: #00695c;
|
||||
--bg-btn-color2-dark: #333;
|
||||
--bg-header-color1-dark: #004000;
|
||||
--bg-header-color2-dark: #29901f;
|
||||
--bg-header-color3-dark: #335673;
|
||||
--bg-menu-color1-dark: #333;
|
||||
--bg-menu-color2-dark: #424242;
|
||||
--text-menu-color-dark: #858585;
|
||||
--text-ubtn-color-dark: #555;
|
||||
|
||||
--text-tb-normal-dark: #3e843e;
|
||||
--bg-tb-normal-dark: #ddd;
|
||||
--bg-tb-hover-dark: #ccc;
|
||||
--text-tb-active-dark: #ddd;
|
||||
--bg-tb-active-dark: #7aa44f;
|
||||
--bg-tb-active-hover-dark: #71b571;
|
||||
--text-tb-disabled-dark: #d3d3d3;
|
||||
--bg-tb-disabled-dark: #808080;
|
||||
|
||||
--bg-selected-item-color1-dark: #605020;
|
||||
--bg-selected-item-color2-dark: #403010;
|
||||
}
|
||||
|
||||
.el-tabs__content {
|
||||
flex: 1;
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
a {
|
||||
color: var(--text-anchor-color);
|
||||
}
|
||||
|
||||
.bg-app, .text-bg-app {
|
||||
background-color: var(--bg-app-color);
|
||||
}
|
||||
|
||||
.text-app {
|
||||
color: var(--text-app-color);
|
||||
}
|
||||
|
||||
.bg-dialog {
|
||||
background-color: var(--bg-dialog-color);
|
||||
}
|
||||
|
||||
.bg-input {
|
||||
background-color: var(--bg-input-color);
|
||||
}
|
||||
|
||||
.bg-btn1 {
|
||||
background-color: var(--bg-btn-color1);
|
||||
}
|
||||
|
||||
.bg-btn2 {
|
||||
background-color: var(--bg-btn-color2);
|
||||
}
|
||||
|
||||
.bg-menu-1 {
|
||||
background-color: var(--bg-menu-color1);
|
||||
}
|
||||
|
||||
.bg-menu-2 {
|
||||
background-color: var(--bg-menu-color2);
|
||||
}
|
||||
|
||||
.text-menu {
|
||||
color: var(--text-menu-color);
|
||||
}
|
||||
|
||||
.bg-header-3 {
|
||||
background-color: var(--bg-header-color3);
|
||||
}
|
||||
|
||||
/* main section */
|
||||
body, html, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font: normal 12pt ReaderDefault;
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.q-notifications__list--top {
|
||||
top: 55px !important;
|
||||
}
|
||||
|
||||
.dborder {
|
||||
border: 2px solid magenta !important;
|
||||
}
|
||||
|
||||
.icon-rotate {
|
||||
vertical-align: middle;
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
} to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.notify-button-icon {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Book в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Book extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Card в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Card extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<el-container direction="vertical">
|
||||
<el-tabs type="border-card" style="height: 100%;" v-model="selectedTab">
|
||||
<el-tab-pane label="Поиск"></el-tab-pane>
|
||||
<el-tab-pane label="Автор"></el-tab-pane>
|
||||
<el-tab-pane label="Книга"></el-tab-pane>
|
||||
<el-tab-pane label="История"></el-tab-pane>
|
||||
<keep-alive>
|
||||
<router-view></router-view>
|
||||
</keep-alive>
|
||||
</el-tabs>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import _ from 'lodash';
|
||||
|
||||
const rootRoute = '/cardindex';
|
||||
const tab2Route = [
|
||||
'/cardindex/search',
|
||||
'/cardindex/card',
|
||||
'/cardindex/book',
|
||||
'/cardindex/history',
|
||||
];
|
||||
let lastActiveTab = null;
|
||||
|
||||
export default @Component({
|
||||
watch: {
|
||||
selectedTab: function(newValue, oldValue) {
|
||||
lastActiveTab = newValue;
|
||||
this.setRouteByTab(newValue);
|
||||
},
|
||||
curRoute: function(newValue, oldValue) {
|
||||
this.setTabByRoute(newValue);
|
||||
},
|
||||
},
|
||||
})
|
||||
class CardIndex extends Vue {
|
||||
selectedTab = null;
|
||||
|
||||
mounted() {
|
||||
this.setTabByRoute(this.curRoute);
|
||||
}
|
||||
|
||||
setTabByRoute(route) {
|
||||
const t = _.indexOf(tab2Route, route);
|
||||
if (t >= 0) {
|
||||
if (t !== this.selectedTab)
|
||||
this.selectedTab = t.toString();
|
||||
} else {
|
||||
if (route == rootRoute && lastActiveTab !== null)
|
||||
this.setRouteByTab(lastActiveTab);
|
||||
}
|
||||
}
|
||||
|
||||
setRouteByTab(tab) {
|
||||
const t = Number(tab);
|
||||
if (tab2Route[t] !== this.curRoute) {
|
||||
this.$router.replace(tab2Route[t]);
|
||||
}
|
||||
}
|
||||
|
||||
get curRoute() {
|
||||
const m = this.$route.path.match(/^(\/[^\/]*\/[^\/]*).*$/i);
|
||||
return (m ? m[1] : this.$route.path);
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел History в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class History extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Search в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Search extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<Window ref="window" width="600px" height="95%" @close="close">
|
||||
<template #header>
|
||||
Настроить закладки
|
||||
</template>
|
||||
|
||||
<div class="col column fit">
|
||||
<div class="row items-center top-panel bg-menu-2">
|
||||
<q-btn :disabled="!selected" class="q-mr-md" round dense color="blue" icon="la la-check" size="16px" @click.stop="openSelected">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Открыть выбранную закладку
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-input ref="search" v-model="search" bg-color="input" class="col" outlined dense placeholder="Найти">
|
||||
<template #append>
|
||||
<q-icon v-if="search !== ''" name="la la-times" class="cursor-pointer" @click="resetSearch" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<div class="col row">
|
||||
<div class="left-panel column items-center no-wrap bg-menu-1">
|
||||
<q-btn class="q-my-sm" round dense color="blue" icon="la la-plus" size="14px" @click.stop="addBookmark">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Добавить закладку
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn :disabled="!ticked.length" class="q-mb-sm" round dense color="blue" icon="la la-minus" size="14px" @click.stop="delBookmark">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Удалить отмеченные закладки
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn :disabled="!selected || selected.indexOf('r-') == 0" class="q-mb-sm" round dense color="blue" icon="la la-edit" size="14px" @click.stop="editBookmark">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Редактировать закладку
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn :disabled="!ticked.length" class="q-mb-sm" round dense color="blue" icon="la la-arrow-up" size="14px" @click.stop="moveBookmark(false)">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Переместить отмеченные вверх
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn :disabled="!ticked.length" class="q-mb-sm" round dense color="blue" icon="la la-arrow-down" size="14px" @click.stop="moveBookmark(true)">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Переместить отмеченные вниз
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn class="q-mb-sm" round dense color="blue" icon="la la-broom" size="14px" @click.stop="setDefaultBookmarks">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Установить по умолчанию
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<div class="space" />
|
||||
</div>
|
||||
|
||||
<div class="col fit tree">
|
||||
<div v-show="nodes.length" class="checkbox-tick-all">
|
||||
<q-checkbox v-model="tickAll" size="36px" label="Выбрать все" @update:model-value="makeTickAll" />
|
||||
</div>
|
||||
<q-tree
|
||||
v-model:selected="selected"
|
||||
v-model:ticked="ticked"
|
||||
v-model:expanded="expanded"
|
||||
class="q-my-xs"
|
||||
color="input"
|
||||
:nodes="nodes"
|
||||
node-key="key"
|
||||
tick-strategy="leaf"
|
||||
selected-color="black"
|
||||
:filter="search"
|
||||
no-nodes-label="Закладок пока нет"
|
||||
no-results-label="Ничего не найдено"
|
||||
>
|
||||
<template #default-header="p">
|
||||
<div class="q-px-xs" :class="{selected: selected == p.key}">
|
||||
{{ p.node.label }}
|
||||
</div>
|
||||
</template>
|
||||
</q-tree>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import * as lu from '../linkUtils';
|
||||
import rstore from '../../../store/modules/reader';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
watch: {
|
||||
ticked() {
|
||||
this.checkAllTicked();
|
||||
},
|
||||
}
|
||||
};
|
||||
class BookmarkSettings {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
libs: Object,
|
||||
addBookmarkVisible: Boolean,
|
||||
};
|
||||
|
||||
search = '';
|
||||
selected = '';
|
||||
ticked = [];
|
||||
expanded = [];
|
||||
tickAll = false;
|
||||
|
||||
created() {
|
||||
this.afterInit = true;
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.$refs.window.init();
|
||||
}
|
||||
|
||||
get nodes() {
|
||||
const result = [];
|
||||
|
||||
const expanded = [];
|
||||
this.links = {};
|
||||
this.libs.groups.forEach(group => {
|
||||
const rkey = `r-${group.r}`;
|
||||
const g = {label: group.r, key: rkey, children: []};
|
||||
this.links[rkey] = {l: group.r, c: ''};
|
||||
|
||||
group.list.forEach(link => {
|
||||
const key = link.l;
|
||||
g.children.push({
|
||||
label: (link.c ? link.c + ' ': '') + lu.removeOrigin(link.l),
|
||||
key
|
||||
});
|
||||
|
||||
this.links[key] = link;
|
||||
if (link.l == this.libs.startLink && expanded.indexOf(rkey) < 0) {
|
||||
expanded.push(rkey);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
result.push(g);
|
||||
});
|
||||
|
||||
if (this.afterInit) {
|
||||
this.$nextTick(() => {
|
||||
this.expanded = expanded;
|
||||
});
|
||||
this.afterInit = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
makeTickAll() {
|
||||
if (this.tickAll) {
|
||||
const newTicked = [];
|
||||
for (const key of Object.keys(this.links)) {
|
||||
if (key.indexOf('r-') != 0)
|
||||
newTicked.push(key);
|
||||
}
|
||||
this.ticked = newTicked;
|
||||
} else {
|
||||
this.ticked = [];
|
||||
}
|
||||
}
|
||||
|
||||
checkAllTicked() {
|
||||
const ticked = new Set(this.ticked);
|
||||
|
||||
let newTickAll = !!(this.nodes.length);
|
||||
for (const key of Object.keys(this.links)) {
|
||||
if (key.indexOf('r-') != 0 && !ticked.has(key))
|
||||
newTickAll = false;
|
||||
}
|
||||
this.tickAll = newTickAll;
|
||||
}
|
||||
|
||||
resetSearch() {
|
||||
this.search = '';
|
||||
this.$refs.search.focus();
|
||||
}
|
||||
|
||||
openSelected() {
|
||||
if (!this.selected)
|
||||
return;
|
||||
if (this.selected.indexOf('r-') === 0) {//rootLink
|
||||
this.$emit('do-action', {action: 'setRootLink', data: this.links[this.selected].l});
|
||||
} else {//selectedLink
|
||||
this.$emit('do-action', {action: 'setSelectedLink', data: this.links[this.selected].l});
|
||||
}
|
||||
this.close();
|
||||
}
|
||||
|
||||
editBookmark() {
|
||||
this.$emit('do-action', {action: 'editBookmark', data: {link: this.links[this.selected].l, desc: this.links[this.selected].c}});
|
||||
}
|
||||
|
||||
addBookmark() {
|
||||
this.$emit('do-action', {action: 'addBookmark'});
|
||||
}
|
||||
|
||||
async delBookmark() {
|
||||
const newLibs = _.cloneDeep(this.libs);
|
||||
|
||||
if (await this.$root.stdDialog.confirm(`Подтвердите удаление ${this.ticked.length} закладок:`, ' ')) {
|
||||
const ticked = new Set(this.ticked);
|
||||
for (let i = newLibs.groups.length - 1; i >= 0; i--) {
|
||||
const g = newLibs.groups[i];
|
||||
for (let j = g.list.length - 1; j >= 0; j--) {
|
||||
if (ticked.has(g.list[j].l)) {
|
||||
delete g.list[j];
|
||||
}
|
||||
}
|
||||
g.list = g.list.filter(v => v);
|
||||
if (!g.list.length)
|
||||
delete newLibs.groups[i];
|
||||
else {
|
||||
const item = lu.getListItemByLink(g.list, g.s);
|
||||
if (!item)
|
||||
g.s = g.list[0].l;
|
||||
}
|
||||
}
|
||||
|
||||
newLibs.groups = newLibs.groups.filter(v => v);
|
||||
this.ticked = [];
|
||||
this.selected = '';
|
||||
this.$emit('do-action', {action: 'setLibs', data: newLibs});
|
||||
}
|
||||
}
|
||||
|
||||
moveBookmark(down = false) {
|
||||
const newLibs = _.cloneDeep(this.libs);
|
||||
|
||||
const ticked = new Set(this.ticked);
|
||||
let moved = false;
|
||||
let prevFull = false;
|
||||
if (!down) {
|
||||
for (let i = 0; i < newLibs.groups.length; i++) {
|
||||
const g = newLibs.groups[i];
|
||||
let count = 0;
|
||||
for (let j = 0; j < g.list.length; j++) {
|
||||
if (ticked.has(g.list[j].l)) {
|
||||
if (j > 0 && !ticked.has(g.list[j - 1].l)) {
|
||||
[g.list[j], g.list[j - 1]] = [g.list[j - 1], g.list[j]];
|
||||
moved = true;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count == g.list.length && !prevFull && i > 0) {
|
||||
const gs = newLibs.groups;
|
||||
[gs[i], gs[i - 1]] = [gs[i - 1], gs[i]];
|
||||
moved = true;
|
||||
} else
|
||||
prevFull = (count == g.list.length);
|
||||
}
|
||||
} else {
|
||||
for (let i = newLibs.groups.length - 1; i >= 0; i--) {
|
||||
const g = newLibs.groups[i];
|
||||
let count = 0;
|
||||
for (let j = g.list.length - 1; j >= 0; j--) {
|
||||
if (ticked.has(g.list[j].l)) {
|
||||
if (j < g.list.length - 1 && !ticked.has(g.list[j + 1].l)) {
|
||||
[g.list[j], g.list[j + 1]] = [g.list[j + 1], g.list[j]];
|
||||
moved = true;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count == g.list.length && !prevFull && i < newLibs.groups.length - 1) {
|
||||
const gs = newLibs.groups;
|
||||
[gs[i], gs[i + 1]] = [gs[i + 1], gs[i]];
|
||||
moved = true;
|
||||
} else
|
||||
prevFull = (count == g.list.length);
|
||||
}
|
||||
}
|
||||
|
||||
if (moved)
|
||||
this.$emit('do-action', {action: 'setLibs', data: newLibs});
|
||||
}
|
||||
|
||||
async setDefaultBookmarks() {
|
||||
const result = await this.$root.stdDialog.prompt(`Введите 'да' для сброса всех закладок в предустановленные значения:`, ' ', {
|
||||
inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
|
||||
});
|
||||
|
||||
if (result && result.value && result.value.toLowerCase() == 'да') {
|
||||
this.$emit('do-action', {action: 'setLibs', data: _.cloneDeep(
|
||||
Object.assign({helpShowed: true}, rstore.libsDefaults)
|
||||
)});
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.afterInit = false;
|
||||
this.$emit('close');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (this.addBookmarkVisible)
|
||||
return false;
|
||||
|
||||
if (event.type == 'keydown' && event.key == 'Escape') {
|
||||
this.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default vueComponent(BookmarkSettings);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top-panel {
|
||||
height: 50px;
|
||||
border-bottom: 1px solid gray;
|
||||
padding: 0 10px 0 12px;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
width: 60px;
|
||||
height: 100%;
|
||||
border-right: 1px solid gray;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tree {
|
||||
padding: 0px 10px 10px 10px;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.selected {
|
||||
text-shadow: 0 0 20px yellow, 0 0 15px yellow, 0 0 10px yellow, 0 0 10px yellow, 0 0 5px yellow;
|
||||
}
|
||||
|
||||
.checkbox-tick-all {
|
||||
border-bottom: 1px solid #bbbbbb;
|
||||
margin-bottom: 7px;
|
||||
padding: 5px 5px 2px 16px;
|
||||
}
|
||||
|
||||
.space {
|
||||
min-height: 1px;
|
||||
width: 1px;
|
||||
}
|
||||
</style>
|
||||
990
client/components/ExternalLibs/ExternalLibs.vue
Normal file
@@ -0,0 +1,990 @@
|
||||
<template>
|
||||
<Window ref="window" margin="2px" @close="close">
|
||||
<template #header>
|
||||
{{ header }}
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<span class="header-button row justify-center items-center" @mousedown.stop @click="fullScreenToggle">
|
||||
<q-icon :name="(fullScreenActive ? 'la la-compress-arrows-alt': 'la la-expand-arrows-alt')" size="16px" />
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">На весь экран</q-tooltip>
|
||||
</span>
|
||||
<span class="header-button row justify-center items-center" @mousedown.stop @click="changeScale(0.1)">
|
||||
<q-icon name="la la-plus" size="16px" />
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Увеличить масштаб</q-tooltip>
|
||||
</span>
|
||||
<span class="header-button row justify-center items-center" @mousedown.stop @click="changeScale(-0.1)">
|
||||
<q-icon name="la la-minus" size="16px" />
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Уменьшить масштаб</q-tooltip>
|
||||
</span>
|
||||
<span class="header-button row justify-center items-center" @mousedown.stop @click="showHelp">
|
||||
<q-icon name="la la-question-circle" size="16px" />
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">Справка</q-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div v-show="ready" class="col column" style="min-width: 600px">
|
||||
<div class="row items-center q-px-sm" style="height: 50px">
|
||||
<q-select
|
||||
ref="rootLink"
|
||||
v-model="rootLink"
|
||||
class="q-mr-sm"
|
||||
bg-color="input"
|
||||
:options="rootLinkOptions"
|
||||
style="width: 230px"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options display-value-sanitize options-sanitize
|
||||
@popup-show="onSelectPopupShow" @popup-hide="onSelectPopupHide"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-btn class="q-mr-xs" round dense color="blue" icon="la la-plus" size="12px" @click.stop="addBookmark">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Добавить закладку
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn round dense color="blue" icon="la la-bars" size="12px" @click.stop="bookmarkSettings">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Настроить закладки
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
<template #selected>
|
||||
<div style="overflow: hidden; white-space: nowrap;">
|
||||
{{ rootLinkWithoutProtocol }}
|
||||
</div>
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<q-select
|
||||
ref="selectedLink"
|
||||
v-model="selectedLink"
|
||||
class="q-mr-sm"
|
||||
bg-color="input"
|
||||
:options="selectedLinkOptions"
|
||||
style="width: 50px"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options hide-selected display-value-sanitize options-sanitize
|
||||
@popup-show="onSelectPopupShow" @popup-hide="onSelectPopupHide"
|
||||
>
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Закладки
|
||||
</q-tooltip>
|
||||
</q-select>
|
||||
|
||||
<q-input
|
||||
ref="input"
|
||||
v-model="bookUrl"
|
||||
class="col q-mr-sm"
|
||||
bg-color="input"
|
||||
outlined dense
|
||||
placeholder="Скопируйте сюда ссылку на книгу и нажмите 'Открыть'"
|
||||
@focus="selectAllOnFocus" @keydown="bookUrlKeyDown"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-btn class="q-mr-xs" round dense color="blue" icon="la la-home" size="12px" @click="goToLink(selectedLink)">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Вернуться на стартовую страницу
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn :disabled="!bookUrl" round dense color="blue" icon="la la-angle-double-down" size="12px" @click="openBookUrlInFrame">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Загрузить URL во фрейм
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
<template #append>
|
||||
<q-btn round dense color="blue" icon="la la-cog" size="12px" @click.stop="optionsVisible = true">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Опции
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<q-btn :disabled="!bookUrl" color="green-7" no-caps size="14px" @click="submitUrl()">
|
||||
Открыть
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Открыть в читалке
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="separator"></div>
|
||||
|
||||
<div ref="frameBox" class="col fit" style="position: relative; background-color: white">
|
||||
<div ref="frameWrap" class="overflow-hidden">
|
||||
<iframe v-if="frameVisible" ref="frame" :src="frameSrc" frameborder="0" allow="clipboard-read; clipboard-write"></iframe>
|
||||
</div>
|
||||
<div v-show="transparentLayoutVisible" ref="transparentLayout" class="fit transparent-layout" @click="transparentLayoutClick"></div>
|
||||
</div>
|
||||
|
||||
<Dialog ref="dialogAddBookmark" v-model="addBookmarkVisible">
|
||||
<template #header>
|
||||
<div class="row items-center">
|
||||
<q-icon class="q-mr-sm" name="la la-bookmark" size="28px"></q-icon>
|
||||
<div v-if="addBookmarkMode == 'edit'">
|
||||
Редактировать закладку
|
||||
</div>
|
||||
<div v-else>
|
||||
Добавить закладку
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="q-mx-md row">
|
||||
<q-input
|
||||
ref="bookmarkLink"
|
||||
v-model="bookmarkLink"
|
||||
class="col q-mr-sm"
|
||||
bg-color="input"
|
||||
outlined dense
|
||||
placeholder="Ссылка для закладки" maxlength="2000" @focus="selectAllOnFocus" @keydown="bookmarkLinkKeyDown"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<q-select
|
||||
ref="defaultRootLink"
|
||||
v-model="defaultRootLink"
|
||||
class="q-mr-sm"
|
||||
bg-color="input"
|
||||
:options="defaultRootLinkOptions"
|
||||
style="width: 50px"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options hide-selected display-value-sanitize options-sanitize
|
||||
>
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Предустановленные ссылки
|
||||
</q-tooltip>
|
||||
</q-select>
|
||||
</div>
|
||||
|
||||
<div class="q-mx-md q-mt-md">
|
||||
<q-input
|
||||
ref="bookmarkDesc"
|
||||
v-model="bookmarkDesc"
|
||||
class="col q-mr-sm"
|
||||
bg-color="input"
|
||||
outlined dense
|
||||
placeholder="Описание" style="width: 400px" maxlength="100" @focus="selectAllOnFocus" @keydown="bookmarkDescKeyDown"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<q-btn v-close-popup class="q-px-md q-ml-sm" dense no-caps>
|
||||
Отмена
|
||||
</q-btn>
|
||||
<q-btn :disabled="!bookmarkLink" class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okAddBookmark">
|
||||
OK
|
||||
</q-btn>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog ref="options" v-model="optionsVisible">
|
||||
<template #header>
|
||||
<div class="row items-center">
|
||||
<q-icon class="q-mr-sm" name="la la-cog" size="28px"></q-icon>
|
||||
Опции
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="q-mx-md column">
|
||||
<q-checkbox v-model="closeAfterSubmit" size="36px" label="Закрыть окно при отправке ссылки в читалку" />
|
||||
<q-checkbox v-model="openInFrameOnEnter" size="36px" label="Открывать ссылку во фрейме при нажатии 'Enter'" />
|
||||
<q-checkbox v-model="openInFrameOnAdd" size="36px" label="Активировать новую закладку после добавления" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="optionsVisible = false">
|
||||
OK
|
||||
</q-btn>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<BookmarkSettings
|
||||
v-if="bookmarkSettingsActive"
|
||||
ref="bookmarkSettings"
|
||||
:libs="libs"
|
||||
:add-bookmark-visible="addBookmarkVisible"
|
||||
@do-action="doAction" @close="closeBookmarkSettings"
|
||||
>
|
||||
</BookmarkSettings>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../vueComponent.js';
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
import Window from '../share/Window.vue';
|
||||
import Dialog from '../share/Dialog.vue';
|
||||
import BookmarkSettings from './BookmarkSettings/BookmarkSettings.vue';
|
||||
|
||||
import rstore from '../../store/modules/reader';
|
||||
import * as utils from '../../share/utils';
|
||||
import * as lu from './linkUtils';
|
||||
|
||||
const proxySubst = {
|
||||
'http://flibusta.is': 'http://b.liberama.top:23480',
|
||||
'http://fantasy-worlds.org': 'http://b.liberama.top:23580',
|
||||
};
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Window,
|
||||
Dialog,
|
||||
BookmarkSettings
|
||||
},
|
||||
watch: {
|
||||
libs() {
|
||||
this.loadLibs();
|
||||
},
|
||||
defaultRootLink() {
|
||||
this.updateBookmarkLink();
|
||||
},
|
||||
bookUrl(newValue) {
|
||||
const value = lu.addProtocol(newValue);
|
||||
const subst = this.makeProxySubst(value, true);
|
||||
if (value != subst) {
|
||||
this.$nextTick(() => {
|
||||
this.bookUrl = subst;
|
||||
});
|
||||
}
|
||||
},
|
||||
bookmarkLink(newValue) {
|
||||
const value = lu.addProtocol(newValue);
|
||||
const subst = this.makeProxySubst(value, true);
|
||||
if (value != subst) {
|
||||
this.$nextTick(() => {
|
||||
this.bookmarkLink = subst;
|
||||
});
|
||||
}
|
||||
},
|
||||
closeAfterSubmit(newValue) {
|
||||
this.commitProp('closeAfterSubmit', newValue);
|
||||
},
|
||||
openInFrameOnEnter(newValue) {
|
||||
this.commitProp('openInFrameOnEnter', newValue);
|
||||
},
|
||||
openInFrameOnAdd(newValue) {
|
||||
this.commitProp('openInFrameOnAdd', newValue);
|
||||
},
|
||||
rootLink() {
|
||||
this.rootLinkInput();
|
||||
},
|
||||
selectedLink() {
|
||||
this.selectedLinkInput();
|
||||
},
|
||||
}
|
||||
};
|
||||
class ExternalLibs {
|
||||
_options = componentOptions;
|
||||
|
||||
ready = false;
|
||||
frameVisible = false;
|
||||
rootLink = '';
|
||||
selectedLink = '';
|
||||
frameSrc = '';
|
||||
bookUrl = '';
|
||||
libs = {};
|
||||
fullScreenActive = false;
|
||||
transparentLayoutVisible = false;
|
||||
|
||||
addBookmarkVisible = false;
|
||||
optionsVisible = false;
|
||||
|
||||
addBookmarkMode = '';
|
||||
bookmarkLink = '';
|
||||
bookmarkDesc = '';
|
||||
defaultRootLink = '';
|
||||
|
||||
bookmarkSettingsActive = false;
|
||||
|
||||
closeAfterSubmit = false;
|
||||
openInFrameOnEnter = false;
|
||||
openInFrameOnAdd = false;
|
||||
frameScale = 1;
|
||||
|
||||
inpxReady = false;
|
||||
inpxTitle = '';
|
||||
inpxUrl = '';
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.oldStartLink = '';
|
||||
this.justOpened = true;
|
||||
this.$root.addEventHook('key', this.keyHook);
|
||||
|
||||
this.$root.addEventHook('resize', async() => {
|
||||
await utils.sleep(200);
|
||||
this.frameResize();
|
||||
});
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
this.fullScreenActive = (document.fullscreenElement !== null);
|
||||
});
|
||||
|
||||
this.debouncedGoToLink = _.debounce((link) => {
|
||||
this.goToLink(link);
|
||||
}, 100, {'maxWait':200});
|
||||
}
|
||||
|
||||
mounted() {
|
||||
(async() => {
|
||||
//подождем this.mode
|
||||
let i = 0;
|
||||
while(!this.mode && i < 100) {
|
||||
await utils.sleep(100);
|
||||
i++;
|
||||
}
|
||||
|
||||
this.libsDefaults = rstore.getLibsDefaults(this.mode);
|
||||
|
||||
this.$refs.window.init();
|
||||
|
||||
this.opener = null;
|
||||
const host = window.location.host;
|
||||
const openerHost = (host.indexOf('b.') == 0 ? host.substring(2) : host);
|
||||
const openerOrigin1 = `http://${openerHost}`;
|
||||
const openerOrigin2 = `https://${openerHost}`;
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
//from inpx-web
|
||||
if (_.isObject(event.data) && event.data.from === 'inpx-web') {
|
||||
//console.log(event);
|
||||
|
||||
this.inpxOrigin = event.origin;
|
||||
|
||||
this.recvInpxMessage(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
//from parent
|
||||
if (event.origin !== openerOrigin1 && event.origin !== openerOrigin2)
|
||||
return;
|
||||
|
||||
if (!_.isObject(event.data) || event.data.from != 'LibsPage')
|
||||
return;
|
||||
if (event.origin == openerOrigin1)
|
||||
this.opener = window.opener;
|
||||
else
|
||||
this.opener = event.source;
|
||||
|
||||
this.openerOrigin = event.origin;
|
||||
|
||||
this.recvMessage(event.data);
|
||||
});
|
||||
|
||||
//Ожидаем родителя
|
||||
i = 0;
|
||||
while(!this.opener) {
|
||||
await utils.sleep(1000);
|
||||
i++;
|
||||
if (i >= 5) {
|
||||
await this.$root.stdDialog.alert('Нет связи с читалкой. Окно будет закрыто', 'Ошибка');
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
|
||||
//Проверка закрытия родительского окна
|
||||
while(this.opener) {
|
||||
await this.checkOpener();
|
||||
await utils.sleep(1000);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
recvMessage(d) {
|
||||
if (d.type == 'mes') {
|
||||
switch(d.data) {
|
||||
case 'hello': this.sendMessage({type: 'mes', data: 'ready'}); break;
|
||||
}
|
||||
} else if (d.type == 'libs') {
|
||||
this.ready = true;
|
||||
if (d.data)
|
||||
this.libs = _.cloneDeep(d.data);
|
||||
if (d.sets)
|
||||
this.updateSets(d.sets);
|
||||
} else if (d.type == 'notify') {
|
||||
this.$root.notify.success(d.data, '', {position: 'bottom-right'});
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(d) {
|
||||
(async() => {
|
||||
await this.checkOpener();
|
||||
if (this.opener && this.openerOrigin)
|
||||
this.opener.postMessage(Object.assign({}, {from: 'ExternalLibs'}, d), this.openerOrigin);
|
||||
})();
|
||||
}
|
||||
|
||||
recvInpxMessage(d) {
|
||||
if (d.type == 'mes') {
|
||||
switch(d.data) {
|
||||
case 'hello-from-inpx-web':
|
||||
this.sendInpxMessage({type: 'mes', data: 'ready'});
|
||||
break;
|
||||
case 'ready':
|
||||
this.inpxReady = true;
|
||||
break;
|
||||
}
|
||||
} else if (d.type == 'submitUrl') {
|
||||
this.submitUrl(d.data);
|
||||
} else if (d.type == 'titleChange') {
|
||||
this.inpxTitle = d.data;
|
||||
} else if (d.type == 'urlChange') {
|
||||
this.inpxUrl = d.data;
|
||||
}
|
||||
}
|
||||
|
||||
sendInpxMessage(d) {
|
||||
if (this.$refs.frame && this.inpxOrigin)
|
||||
this.$refs.frame.contentWindow.postMessage(Object.assign({}, {from: 'ExternalLibs'}, d), this.inpxOrigin);
|
||||
}
|
||||
|
||||
async checkOpener() {
|
||||
if (this.opener.closed) {
|
||||
await this.$root.stdDialog.alert('Потеряна связь с читалкой. Окно будет закрыто', 'Ошибка');
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
|
||||
updateSets(sets) {
|
||||
if (sets.nightMode !== this.nightMode)
|
||||
this.commit('reader/nightModeToggle');
|
||||
}
|
||||
|
||||
commitLibs(libs) {
|
||||
this.sendMessage({type: 'libs', data: libs});
|
||||
}
|
||||
|
||||
commitProp(prop, value) {
|
||||
let libs = _.cloneDeep(this.libs);
|
||||
libs[prop] = value;
|
||||
this.commitLibs(libs);
|
||||
}
|
||||
|
||||
loadLibs() {
|
||||
const libs = this.libs;
|
||||
|
||||
if (!libs.helpShowed) {
|
||||
this.showHelp();
|
||||
(async() => {
|
||||
await utils.sleep(1000);
|
||||
this.commitProp('helpShowed', true);
|
||||
})();
|
||||
}
|
||||
|
||||
this.selectedLink = libs.startLink;
|
||||
this.closeAfterSubmit = libs.closeAfterSubmit || false;
|
||||
this.openInFrameOnEnter = libs.openInFrameOnEnter || false;
|
||||
this.openInFrameOnAdd = libs.openInFrameOnAdd || false;
|
||||
|
||||
this.frameScale = 1;
|
||||
const index = lu.getSafeRootIndexByUrl(this.libs.groups, this.selectedLink);
|
||||
if (index >= 0)
|
||||
this.frameScale = this.libs.groups[index].frameScale || 1;
|
||||
|
||||
this.updateStartLink();
|
||||
}
|
||||
|
||||
doAction(event) {
|
||||
switch (event.action) {
|
||||
case 'setLibs': this.commitLibs(event.data); break;
|
||||
case 'setRootLink': this.rootLink = event.data; this.rootLinkInput(); break;
|
||||
case 'setSelectedLink': this.selectedLink = event.data; this.selectedLinkInput(); break;
|
||||
case 'editBookmark': this.addBookmark('edit', event.data.link, event.data.desc); break;
|
||||
case 'addBookmark': this.addBookmark('add'); break;
|
||||
}
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
get nightMode() {
|
||||
return this.$store.state.reader.settings.nightMode;
|
||||
}
|
||||
|
||||
get header() {
|
||||
let result = [this.ready ? 'Сетевая библиотека' : 'Загрузка...'];
|
||||
if (this.ready && this.selectedLink) {
|
||||
|
||||
if (this.inpxReady && this.inpxTitle) {
|
||||
result.push(this.inpxTitle);
|
||||
result.push(lu.removeProtocol(this.inpxUrl));
|
||||
} else {
|
||||
result.push(this.libs.comment);
|
||||
result.push(lu.removeProtocol(this.libs.startLink));
|
||||
}
|
||||
}
|
||||
|
||||
result = result.filter(s => s).join(' | ');
|
||||
this.$root.setAppTitle(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
get rootLinkWithoutProtocol() {
|
||||
return lu.removeProtocol(this.rootLink);
|
||||
}
|
||||
|
||||
updateSelectedLinkByRoot() {
|
||||
if (!this.ready)
|
||||
return;
|
||||
|
||||
const index = lu.getSafeRootIndexByUrl(this.libs.groups, this.rootLink);
|
||||
if (index >= 0)
|
||||
this.selectedLink = this.libs.groups[index].s;
|
||||
else
|
||||
this.selectedLink = '';
|
||||
}
|
||||
|
||||
updateStartLink(force) {
|
||||
if (!this.ready)
|
||||
return;
|
||||
|
||||
let index = -1;
|
||||
try {
|
||||
this.rootLink = lu.getOrigin(this.selectedLink);
|
||||
index = lu.getRootIndexByUrl(this.libs.groups, this.rootLink);
|
||||
} catch(e) {
|
||||
//
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
let libs = _.cloneDeep(this.libs);
|
||||
const com = this.getCommentByLink(libs.groups[index].list, this.selectedLink);
|
||||
if (libs.groups[index].s != this.selectedLink ||
|
||||
libs.startLink != this.selectedLink ||
|
||||
libs.comment != com) {
|
||||
libs.groups[index].s = this.selectedLink;
|
||||
libs.startLink = this.selectedLink;
|
||||
libs.comment = com;
|
||||
this.commitLibs(libs);
|
||||
}
|
||||
|
||||
if (force || this.oldStartLink != libs.startLink) {
|
||||
this.oldStartLink = libs.startLink;
|
||||
this.debouncedGoToLink(this.selectedLink);
|
||||
}
|
||||
} else {
|
||||
this.rootLink = '';
|
||||
this.selectedLink = '';
|
||||
this.debouncedGoToLink(this.selectedLink);
|
||||
}
|
||||
}
|
||||
|
||||
get rootLinkOptions() {
|
||||
let result = [];
|
||||
if (!this.ready)
|
||||
return result;
|
||||
|
||||
this.libs.groups.forEach(group => {
|
||||
result.push({label: lu.removeProtocol(group.r), value: group.r});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
get defaultRootLinkOptions() {
|
||||
let result = [];
|
||||
|
||||
this.libsDefaults.groups.forEach(group => {
|
||||
result.push({label: lu.removeProtocol(group.r), value: group.r});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
get selectedLinkOptions() {
|
||||
let result = [];
|
||||
if (!this.ready)
|
||||
return result;
|
||||
|
||||
const index = lu.getSafeRootIndexByUrl(this.libs.groups, this.rootLink);
|
||||
if (index >= 0) {
|
||||
this.libs.groups[index].list.forEach(link => {
|
||||
result.push({label: (link.c ? link.c + ' ': '') + lu.removeOrigin(link.l), value: link.l});
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
openBookUrlInFrame() {
|
||||
if (this.bookUrl) {
|
||||
this.goToLink(lu.addProtocol(this.bookUrl));
|
||||
}
|
||||
}
|
||||
|
||||
goToLink(link) {
|
||||
this.inpxReady = false;
|
||||
this.inpxTitle = '';
|
||||
this.inpxUrl = '';
|
||||
this.inpxOrigin = false;
|
||||
|
||||
if (!this.ready || !link)
|
||||
return;
|
||||
|
||||
if (!link) {
|
||||
this.frameVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.frameSrc = this.makeProxySubst(link);
|
||||
|
||||
this.frameVisible = false;
|
||||
this.$nextTick(() => {
|
||||
this.frameVisible = true;
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.frame) {
|
||||
this.$refs.frame.contentWindow.location.reload(true);
|
||||
this.$refs.frame.contentWindow.focus();
|
||||
this.frameResize();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
frameResize() {
|
||||
this.$refs.frameWrap.style = 'width: 1px; height: 1px;';
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.frame) {
|
||||
const w = this.$refs.frameBox.offsetWidth;
|
||||
const h = this.$refs.frameBox.offsetHeight;
|
||||
const normalSize = `width: ${w}px; height: ${h}px;`;
|
||||
this.$refs.frameWrap.style = normalSize;
|
||||
if (this.frameScale != 1) {
|
||||
const s = this.frameScale;
|
||||
this.$refs.frame.style = `width: ${w/s}px; height: ${h/s}px; transform: scale(${s}); transform-origin: 0 0;`;
|
||||
} else {
|
||||
this.$refs.frame.style = normalSize;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
changeScale(delta) {
|
||||
if ((this.frameScale > 0.1 && delta <= 0) || (this.frameScale < 5 && delta >= 0)) {
|
||||
this.frameScale = _.round(this.frameScale + delta, 1);
|
||||
|
||||
const index = lu.getSafeRootIndexByUrl(this.libs.groups, this.selectedLink);
|
||||
if (index >= 0) {
|
||||
let libs = _.cloneDeep(this.libs);
|
||||
libs.groups[index].frameScale = this.frameScale;
|
||||
this.commitLibs(libs);
|
||||
}
|
||||
|
||||
this.frameResize();
|
||||
this.$root.notify.success(`Масштаб изменен: ${(this.frameScale*100).toFixed(0)}%`, '', {position: 'bottom-right'});
|
||||
}
|
||||
}
|
||||
|
||||
getCommentByLink(list, link) {
|
||||
const item = lu.getListItemByLink(list, link);
|
||||
return (item ? item.c : '');
|
||||
}
|
||||
|
||||
makeProxySubst(url, reverse = false) {
|
||||
for (const [key, value] of Object.entries(proxySubst)) {
|
||||
if (reverse && value == url.substring(0, value.length)) {
|
||||
return key + url.substring(value.length);
|
||||
} else if (!reverse && key == url.substring(0, key.length)) {
|
||||
return value + url.substring(key.length);
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
selectAllOnFocus(event) {
|
||||
if (event.target.select)
|
||||
event.target.select();
|
||||
}
|
||||
|
||||
rootLinkInput() {
|
||||
this.updateSelectedLinkByRoot();
|
||||
this.updateStartLink(true);
|
||||
}
|
||||
|
||||
selectedLinkInput() {
|
||||
this.updateStartLink(true);
|
||||
}
|
||||
|
||||
submitUrl(url) {
|
||||
if (!url) {
|
||||
url = this.bookUrl;
|
||||
this.bookUrl = '';
|
||||
}
|
||||
|
||||
if (url) {
|
||||
this.sendMessage({type: 'submitUrl', data: {
|
||||
url,
|
||||
force: true
|
||||
}});
|
||||
if (this.closeAfterSubmit)
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
addBookmark(mode = 'add', link = '', desc = '') {
|
||||
|
||||
if (mode == 'edit') {
|
||||
this.editBookmarkLink = this.bookmarkLink = link;
|
||||
this.editBookmarkDesc = this.bookmarkDesc = desc;
|
||||
} else {
|
||||
this.bookmarkLink = this.bookUrl;
|
||||
this.bookmarkDesc = '';
|
||||
|
||||
if (!this.bookmarkLink && this.inpxReady && this.inpxUrl) {
|
||||
this.bookmarkLink = this.inpxUrl;
|
||||
if (this.inpxTitle)
|
||||
this.bookmarkDesc = this.inpxTitle;
|
||||
}
|
||||
}
|
||||
|
||||
this.addBookmarkMode = mode;
|
||||
this.addBookmarkVisible = true;
|
||||
this.$nextTick(async() => {
|
||||
await this.$refs.dialogAddBookmark.waitShown();
|
||||
this.$refs.bookmarkLink.focus();
|
||||
});
|
||||
}
|
||||
|
||||
updateBookmarkLink() {
|
||||
const index = lu.getSafeRootIndexByUrl(this.libsDefaults.groups, this.defaultRootLink);
|
||||
if (index >= 0) {
|
||||
this.bookmarkLink = this.libsDefaults.groups[index].s;
|
||||
this.bookmarkDesc = this.getCommentByLink(this.libsDefaults.groups[index].list, this.bookmarkLink);
|
||||
} else {
|
||||
this.bookmarkLink = '';
|
||||
this.bookmarkDesc = '';
|
||||
}
|
||||
}
|
||||
|
||||
bookmarkLinkKeyDown(event) {
|
||||
if (event.key == 'Enter') {
|
||||
this.$refs.bookmarkDesc.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
bookmarkDescKeyDown(event) {
|
||||
if (event.key == 'Enter') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.okAddBookmark();
|
||||
}
|
||||
}
|
||||
|
||||
async okAddBookmark() {
|
||||
if (!this.bookmarkLink)
|
||||
return;
|
||||
|
||||
const link = (this.addBookmarkMode == 'edit' ? lu.addProtocol(this.editBookmarkLink) : lu.addProtocol(this.bookmarkLink));
|
||||
let index = -1;
|
||||
try {
|
||||
index = lu.getRootIndexByUrl(this.libs.groups, link);
|
||||
} catch (e) {
|
||||
await this.$root.stdDialog.alert('Неверный формат ссылки', 'Ошибка');
|
||||
return;
|
||||
}
|
||||
|
||||
let libs = _.cloneDeep(this.libs);
|
||||
|
||||
//добавление
|
||||
//есть группа в закладках
|
||||
if (index >= 0) {
|
||||
const item = lu.getListItemByLink(libs.groups[index].list, link);
|
||||
|
||||
//редактирование
|
||||
if (item && this.addBookmarkMode == 'edit') {
|
||||
if (item) {
|
||||
//редактируем
|
||||
item.l = link;
|
||||
item.c = this.bookmarkDesc;
|
||||
this.commitLibs(libs);
|
||||
} else {
|
||||
await this.$root.stdDialog.alert('Не удалось отредактировать закладку', 'Ошибка');
|
||||
}
|
||||
} else if (!item) {
|
||||
//добавляем
|
||||
if (libs.groups[index].list.length >= 100) {
|
||||
await this.$root.stdDialog.alert('Достигнут предел количества закладок для этого сайта', 'Ошибка');
|
||||
return;
|
||||
}
|
||||
|
||||
libs.groups[index].list.push({l: link, c: this.bookmarkDesc});
|
||||
|
||||
if (this.openInFrameOnAdd) {
|
||||
libs.startLink = link;
|
||||
libs.comment = this.bookmarkDesc;
|
||||
}
|
||||
|
||||
this.commitLibs(libs);
|
||||
} else if (item.c != this.bookmarkDesc) {
|
||||
if (await this.$root.stdDialog.confirm(`Такая закладка уже существует с другим описанием.<br>` +
|
||||
`Заменить '${this.$root.sanitize(item.c)}' на '${this.$root.sanitize(this.bookmarkDesc)}'?`, ' ')) {
|
||||
item.c = this.bookmarkDesc;
|
||||
this.commitLibs(libs);
|
||||
} else
|
||||
return;
|
||||
} else {
|
||||
await this.$root.stdDialog.alert('Такая закладка уже существует', ' ');
|
||||
return;
|
||||
}
|
||||
} else {//нет группы в закладках
|
||||
if (libs.groups.length >= 100) {
|
||||
await this.$root.stdDialog.alert('Достигнут предел количества различных сайтов в закладках', 'Ошибка');
|
||||
return;
|
||||
}
|
||||
|
||||
//добавляем сначала группу
|
||||
libs.groups.push({r: lu.getOrigin(link), s: link, list: []});
|
||||
|
||||
index = lu.getSafeRootIndexByUrl(libs.groups, link);
|
||||
if (index >= 0)
|
||||
libs.groups[index].list.push({l: link, c: this.bookmarkDesc});
|
||||
|
||||
if (this.openInFrameOnAdd) {
|
||||
libs.startLink = link;
|
||||
libs.comment = this.bookmarkDesc;
|
||||
}
|
||||
|
||||
this.commitLibs(libs);
|
||||
}
|
||||
|
||||
this.addBookmarkVisible = false;
|
||||
}
|
||||
|
||||
fullScreenToggle() {
|
||||
this.fullScreenActive = !this.fullScreenActive;
|
||||
if (this.fullScreenActive) {
|
||||
this.$q.fullscreen.request();
|
||||
} else {
|
||||
this.$q.fullscreen.exit();
|
||||
}
|
||||
}
|
||||
|
||||
transparentLayoutClick() {
|
||||
this.transparentLayoutVisible = false;
|
||||
}
|
||||
|
||||
onSelectPopupShow() {
|
||||
this.transparentLayoutVisible = true;
|
||||
}
|
||||
|
||||
onSelectPopupHide() {
|
||||
this.transparentLayoutVisible = false;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.sendMessage({type: 'close'});
|
||||
}
|
||||
|
||||
bookUrlKeyDown(event) {
|
||||
if (event.key == 'Enter') {
|
||||
if (!this.openInFrameOnEnter) {
|
||||
this.submitUrl();
|
||||
} else {
|
||||
if (this.bookUrl)
|
||||
this.goToLink(this.bookUrl);
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
bookmarkSettings() {
|
||||
this.bookmarkSettingsActive = true;
|
||||
this.$nextTick(() => {
|
||||
this.$refs.bookmarkSettings.init();
|
||||
});
|
||||
}
|
||||
|
||||
closeBookmarkSettings() {
|
||||
this.bookmarkSettingsActive = false;
|
||||
}
|
||||
|
||||
showHelp() {
|
||||
this.$root.stdDialog.alert(`
|
||||
<p>Окно 'Сетевая библиотека' позволяет открывать ссылки в читалке без переключения между окнами,
|
||||
что особенно актуально для мобильных устройств. Имеется возможность управлять закладками
|
||||
на понравившиеся ресурсы, книги или страницы авторов. Открытие ссылок и навигация происходят во фрейме, но,
|
||||
к сожалению, в нем открываются не все страницы.</p>` +
|
||||
|
||||
(this.mode === 'liberama' ?
|
||||
`<p>Доступ к сайтам <span style="color: blue">http://flibusta.is</span> и <span style="color: blue">http://fantasy-worlds.org</span> работает через прокси.
|
||||
|
||||
<br><span style="color: red"><b>ПРЕДУПРЕЖДЕНИЕ!</b></span>
|
||||
Доступ предназначен только для просмотра и скачивания книг. Авторизоваться на этих сайтах
|
||||
из фрейма категорически не рекомендуется, т.к. ваше подключение не защищено и данные могут попасть
|
||||
к третьим лицам.
|
||||
</p>
|
||||
`
|
||||
: '') +
|
||||
|
||||
`<p>Из-за проблем с безопасностью, навигация 'вперед-назад' во фрейме осуществляется с помощью контекстного меню правой кнопкой мыши.
|
||||
На мобильных устройствах для этого служит системная клавиша 'Назад (стрелка влево)' и опция 'Вперед (стрелка вправо)' в меню браузера.
|
||||
</p>
|
||||
<p>Приятного пользования ;-)
|
||||
</p>
|
||||
`, 'Справка', {iconName: 'la la-info-circle'});
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (this.$root.getRootRoute() == '/external-libs') {
|
||||
if (this.$root.stdDialog.active)
|
||||
return false;
|
||||
|
||||
if (this.bookmarkSettingsActive && this.$refs.bookmarkSettings.keyHook(event))
|
||||
return true;
|
||||
|
||||
if (this.addBookmarkVisible || this.optionsVisible)
|
||||
return false;
|
||||
|
||||
if (event.type == 'keydown' && event.key == 'F4') {
|
||||
this.addBookmark();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.type == 'keydown' && event.key == 'Escape' &&
|
||||
(document.activeElement != this.$refs.rootLink.$refs.target || !this.$refs.rootLink.menu) &&
|
||||
(document.activeElement != this.$refs.selectedLink.$refs.target || !this.$refs.selectedLink.menu)
|
||||
) {
|
||||
this.close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ExternalLibs);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.separator {
|
||||
height: 1px;
|
||||
background-color: #A0A0A0;
|
||||
}
|
||||
|
||||
.header-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-button:hover {
|
||||
color: white;
|
||||
background-color: #39902F;
|
||||
}
|
||||
|
||||
.transparent-layout {
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
48
client/components/ExternalLibs/linkUtils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
export function addProtocol(url) {
|
||||
if ((url.indexOf('http://') != 0) && (url.indexOf('https://') != 0))
|
||||
return 'http://' + url;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function removeProtocol(url) {
|
||||
return url.replace(/(^\w+:|^)\/\//, '');
|
||||
}
|
||||
|
||||
export function getOrigin(url) {
|
||||
const parsed = new URL(url);
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
export function removeOrigin(url) {
|
||||
const parsed = new URL(url);
|
||||
const result = url.substring(parsed.origin.length);
|
||||
return (result ? result : '/');
|
||||
}
|
||||
|
||||
export function getRootIndexByUrl(groups, url) {
|
||||
const origin = getOrigin(url);
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
if (groups[i].r == origin)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function getSafeRootIndexByUrl(groups, url) {
|
||||
let index = -1;
|
||||
try {
|
||||
index = getRootIndexByUrl(groups, url);
|
||||
} catch(e) {
|
||||
//
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export function getListItemByLink(list, link) {
|
||||
for (const item of list) {
|
||||
if (item.l == link)
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Help в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Help extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Income в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Income extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Страница не найдена
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class NotFound404 extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
@@ -6,15 +6,12 @@
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import {sleep} from '../../../share/utils';
|
||||
import {clickMap, clickMapText} from '../share/clickMap';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class ClickMapPage extends Vue {
|
||||
class ClickMapPage {
|
||||
fontSize = '200%';
|
||||
|
||||
created() {
|
||||
@@ -53,6 +50,8 @@ class ClickMapPage extends Vue {
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ClickMapPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
|
||||
548
client/components/Reader/ContentsPage/ContentsPage.vue
Normal file
@@ -0,0 +1,548 @@
|
||||
<template>
|
||||
<Window ref="window" width="600px" @close="close">
|
||||
<template #header>
|
||||
Оглавление/закладки
|
||||
</template>
|
||||
|
||||
<div class="bg-menu-1 row">
|
||||
<q-tabs
|
||||
v-model="selectedTab"
|
||||
active-color="app"
|
||||
active-bg-color="app"
|
||||
indicator-color="bg-app"
|
||||
dense
|
||||
no-caps
|
||||
inline-label
|
||||
class="no-mp bg-menu-2 text-menu"
|
||||
>
|
||||
<q-tab name="contents" icon="la la-list" label="Оглавление" />
|
||||
<q-tab name="images" icon="la la-image" label="Изображения" />
|
||||
<!--q-tab name="bookmarks" icon="la la-bookmark" label="Закладки" /-->
|
||||
</q-tabs>
|
||||
</div>
|
||||
|
||||
<div class="q-mb-sm" />
|
||||
|
||||
<div v-show="selectedTab == 'contents'" ref="tabPanelContents" class="tab-panel">
|
||||
<div>
|
||||
<div v-for="item in contents" :key="item.key" class="column" style="width: 540px">
|
||||
<div :ref="`mainitem${item.key}`" class="row q-px-sm no-wrap" :class="{'item': !item.isBookPos, 'item-book-pos': item.isBookPos}">
|
||||
<div v-if="item.list.length" class="row justify-center items-center expand-button clickable" @click="expandClick(item.key)">
|
||||
<q-icon name="la la-caret-right" class="icon" :class="{'expanded-icon': item.expanded}" color="green-8" size="24px" />
|
||||
</div>
|
||||
<div v-else class="no-expand-button clickable" @click="setBookPos(item.offset)">
|
||||
<q-icon name="la la-stop" class="icon" style="visibility: hidden" size="24px" />
|
||||
</div>
|
||||
<div class="col row clickable" @click="setBookPos(item.offset)">
|
||||
<div :style="item.indentStyle"></div>
|
||||
<div class="q-mr-sm col overflow-hidden column justify-center" :style="item.labelStyle" v-html="item.label"></div>
|
||||
<div class="column justify-center">
|
||||
{{ item.perc }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="item.expanded" :ref="`subdiv${item.key}`" class="subitems-transition">
|
||||
<div
|
||||
v-for="subitem in item.list"
|
||||
:ref="`subitem${subitem.key}`"
|
||||
:key="subitem.key" class="row q-px-sm no-wrap" :class="{'subitem': !subitem.isBookPos, 'subitem-book-pos': subitem.isBookPos}"
|
||||
>
|
||||
<div class="col row clickable" @click="setBookPos(subitem.offset)">
|
||||
<div class="no-expand-button"></div>
|
||||
<div :style="subitem.indentStyle"></div>
|
||||
<div class="q-mr-sm col overflow-hidden column justify-center" :style="item.labelStyle" v-html="subitem.label"></div>
|
||||
<div class="column justify-center">
|
||||
{{ subitem.perc }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!contents.length" class="column justify-center items-center" style="height: 100px">
|
||||
Оглавление отсутствует
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="selectedTab == 'images'" ref="tabPanelImages" class="tab-panel">
|
||||
<div>
|
||||
<div v-for="item in images" :key="item.key" class="column" style="width: 540px">
|
||||
<div :ref="`image${item.key}`" class="row q-px-sm no-wrap" :class="{'item': !item.isBookPos, 'item-book-pos': item.isBookPos}">
|
||||
<div class="col row clickable" @click="setBookPos(item.offset)">
|
||||
<div class="image-thumb-box row justify-center items-center">
|
||||
<div v-show="!imageLoaded[item.id]" class="image-thumb column justify-center">
|
||||
<i class="loading-img-icon la la-images"></i>
|
||||
</div>
|
||||
<img v-show="imageLoaded[item.id]" class="image-thumb" :src="imageSrc[item.id]" />
|
||||
</div>
|
||||
<div class="no-expand-button column justify-center items-center">
|
||||
<div class="image-num">
|
||||
{{ item.num }}
|
||||
</div>
|
||||
<div v-show="item.type == 'image/jpeg'" class="image-type text-black it-jpg-color row justify-center">
|
||||
JPG
|
||||
</div>
|
||||
<div v-show="item.type == 'image/png'" class="image-type text-black it-png-color row justify-center">
|
||||
PNG
|
||||
</div>
|
||||
<div v-show="!item.local" class="image-type text-black it-net-color row justify-center">
|
||||
INET
|
||||
</div>
|
||||
</div>
|
||||
<div :style="item.indentStyle"></div>
|
||||
<div class="q-mr-sm col overflow-hidden column justify-center" :style="item.labelStyle" v-html="item.label"></div>
|
||||
<div class="column justify-center">
|
||||
{{ item.perc }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!images.length" class="column justify-center items-center" style="height: 100px">
|
||||
Изображения отсутствуют
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="selectedTab == 'bookmarks'" class="tab-panel">
|
||||
<div class="column justify-center items-center" style="height: 100px">
|
||||
Раздел находится в разработке
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
//import _ from 'lodash';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import * as utils from '../../../share/utils';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
watch: {
|
||||
bookPos() {
|
||||
this.updateBookPosSelection();
|
||||
},
|
||||
selectedTab() {
|
||||
this.updateBookPosScrollTop();
|
||||
},
|
||||
},
|
||||
};
|
||||
class ContentsPage {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
bookPos: Number,
|
||||
isVisible: Boolean,
|
||||
};
|
||||
|
||||
selectedTab = 'contents';
|
||||
contents = [];
|
||||
images = [];
|
||||
imageSrc = [];
|
||||
imageLoaded = [];
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
async init(currentBook, parsed) {
|
||||
this.$refs.window.init();
|
||||
|
||||
//закладки
|
||||
|
||||
//проверим, надо ли обновлять списки
|
||||
if (this.parsed == parsed) {
|
||||
this.updateBookPosSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
//далее формирование оглавления
|
||||
this.parsed = parsed;
|
||||
this.contents = [];
|
||||
await this.$nextTick();
|
||||
|
||||
const pc = parsed.contents;
|
||||
const ims = parsed.images;
|
||||
const newpc = [];
|
||||
if (pc.length) {//если есть оглавление
|
||||
//преобразуем все, кроме первого, разделы body в title-subtitle
|
||||
let curSubtitles = [];
|
||||
let prevBodyIndex = -1;
|
||||
for (let i = 0; i < pc.length; i++) {
|
||||
const cont = pc[i];
|
||||
if (prevBodyIndex != cont.bodyIndex)
|
||||
curSubtitles = [];
|
||||
|
||||
prevBodyIndex = cont.bodyIndex;
|
||||
|
||||
if (cont.bodyIndex > 1) {
|
||||
if (cont.inset < 1) {
|
||||
newpc.push(Object.assign({}, cont, {subtitles: curSubtitles}));
|
||||
} else {
|
||||
curSubtitles.push(Object.assign({}, cont, {inset: cont.inset - 1}));
|
||||
}
|
||||
} else {
|
||||
newpc.push(cont);
|
||||
}
|
||||
}
|
||||
} else {//попробуем вытащить из images
|
||||
for (let i = 0; i < ims.length; i++) {
|
||||
const image = ims[i];
|
||||
|
||||
if (image.alt) {
|
||||
newpc.push({paraIndex: image.paraIndex, title: image.alt, inset: 1, bodyIndex: 0, subtitles: []});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prepareLabel = (title, bolder = false) => {
|
||||
let titleParts = title.split('<p>');
|
||||
const textParts = titleParts.filter(v => v).map(v => `<div>${utils.removeHtmlTags(v)}</div>`);
|
||||
if (bolder && textParts.length > 1)
|
||||
textParts[0] = `<b>${textParts[0]}</b>`;
|
||||
return textParts.join('');
|
||||
}
|
||||
|
||||
const getIndentStyle = inset => `width: ${inset*20}px`;
|
||||
|
||||
const getLabelStyle = (inset) => {
|
||||
const fontSizes = ['110%', '100%', '90%', '85%'];
|
||||
inset = (inset > 3 ? 3 : inset);
|
||||
return `font-size: ${fontSizes[inset]}`;
|
||||
};
|
||||
|
||||
//формируем newContents
|
||||
let i = 0;
|
||||
const newContents = [];
|
||||
newpc.forEach((cont) => {
|
||||
const label = prepareLabel(cont.title, true);
|
||||
const indentStyle = getIndentStyle(cont.inset);
|
||||
const labelStyle = getLabelStyle(cont.inset);
|
||||
|
||||
let j = 0;
|
||||
const list = [];
|
||||
cont.subtitles.forEach((sub) => {
|
||||
const l = prepareLabel(sub.title);
|
||||
const s = getIndentStyle(sub.inset + 1);
|
||||
const ls = getLabelStyle(cont.inset + 1);
|
||||
const p = parsed.para[sub.paraIndex];
|
||||
list[j] = {perc: (p.offset/parsed.textLength*100).toFixed(2), label: l, key: j, offset: p.offset, indentStyle: s, labelStyle: ls};
|
||||
j++;
|
||||
});
|
||||
|
||||
const p = parsed.para[cont.paraIndex];
|
||||
newContents[i] = {perc: (p.offset/parsed.textLength*100).toFixed(0), label, key: i, offset: p.offset, indentStyle, labelStyle, expanded: false, list};
|
||||
|
||||
i++;
|
||||
});
|
||||
|
||||
this.contents = newContents;
|
||||
|
||||
//формируем newImages
|
||||
const newImages = [];
|
||||
for (i = 0; i < ims.length; i++) {
|
||||
const image = ims[i];
|
||||
const bin = parsed.binary[image.id];
|
||||
const type = (bin ? bin.type : '');
|
||||
|
||||
const label = (image.alt ? image.alt : '<span style="font-size: 90%; color: var(--bg-menu-color2)"><i>Без названия</i></span>');
|
||||
const indentStyle = getIndentStyle(1);
|
||||
const labelStyle = getLabelStyle(1);
|
||||
|
||||
const p = parsed.para[image.paraIndex];
|
||||
newImages.push({perc: (p.offset/parsed.textLength*100).toFixed(0), label, key: i, offset: p.offset,
|
||||
indentStyle, labelStyle, type, num: image.num, id: image.id, local: image.local});
|
||||
}
|
||||
|
||||
this.images = newImages;
|
||||
|
||||
if (this.selectedTab == 'contents' && !this.contents.length && this.images.length)
|
||||
this.selectedTab = 'images';
|
||||
|
||||
//выделим на bookPos
|
||||
this.updateBookPosSelection();
|
||||
|
||||
//асинхронная загрузка изображений
|
||||
this.imageSrc = [];
|
||||
this.imageLoaded = [];
|
||||
await utils.sleep(50);
|
||||
(async() => {
|
||||
for (i = 0; i < ims.length; i++) {
|
||||
const {id, local} = ims[i];
|
||||
const bin = this.parsed.binary[id];
|
||||
if (local)
|
||||
this.imageSrc[id] = (bin ? `data:${bin.type};base64,${bin.data}` : '');
|
||||
else
|
||||
this.imageSrc[id] = id;
|
||||
this.imageLoaded[id] = true;
|
||||
await utils.sleep(5);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async updateBookPosSelection() {
|
||||
if (!this.isVisible)
|
||||
return;
|
||||
|
||||
await this.$nextTick();
|
||||
const bp = this.bookPos;
|
||||
|
||||
for (let i = 0; i < this.contents.length; i++) {
|
||||
const item = this.contents[i];
|
||||
const nextOffset = (i < this.contents.length - 1 ? this.contents[i + 1].offset : this.parsed.textLength);
|
||||
|
||||
if (bp >= item.offset && bp < nextOffset) {
|
||||
item.isBookPos = true;
|
||||
} else if (item.isBookPos) {
|
||||
item.isBookPos = false;
|
||||
}
|
||||
|
||||
for (let j = 0; j < item.list.length; j++) {
|
||||
const subitem = item.list[j];
|
||||
const nextSubOffset = (j < item.list.length - 1 ? item.list[j + 1].offset : nextOffset);
|
||||
|
||||
if (bp >= subitem.offset && bp < nextSubOffset) {
|
||||
subitem.isBookPos = true;
|
||||
this.updateBookPosScrollTop('contents', item, subitem, j);
|
||||
} else if (subitem.isBookPos) {
|
||||
subitem.isBookPos = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.images.length; i++) {
|
||||
const img = this.images[i];
|
||||
const nextOffset = (i < this.images.length - 1 ? this.images[i + 1].offset : this.parsed.textLength);
|
||||
|
||||
if (bp >= img.offset && bp < nextOffset) {
|
||||
this.images[i].isBookPos = true;
|
||||
} else if (img.isBookPos) {
|
||||
this.images[i].isBookPos = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateBookPosScrollTop();
|
||||
}
|
||||
|
||||
/*getOffsetTop(key) {
|
||||
let el = this.getFirstElem(this.$refs[`mainitem${key}`]);
|
||||
return (el ? el.offsetTop : 0);
|
||||
}*/
|
||||
|
||||
async updateBookPosScrollTop() {
|
||||
try {
|
||||
await this.$nextTick();
|
||||
|
||||
if (this.selectedTab == 'contents') {
|
||||
let item;
|
||||
let subitem;
|
||||
let i;
|
||||
|
||||
//ищем выделенные item
|
||||
for(const _item of this.contents) {
|
||||
if (_item.isBookPos) {
|
||||
item = _item;
|
||||
for (let ii = 0; ii < item.list.length; ii++) {
|
||||
const _subitem = item.list[ii];
|
||||
if (_subitem.isBookPos) {
|
||||
subitem = _subitem;
|
||||
i = ii;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
//вычисляем и смещаем tabPanel.scrollTop
|
||||
let el = this.getFirstElem(this.$refs[`mainitem${item.key}`]);
|
||||
let elShift = 0;
|
||||
if (subitem && item.expanded) {
|
||||
const subEl = this.getFirstElem(this.$refs[`subitem${subitem.key}`]);
|
||||
elShift = el.offsetHeight - subEl.offsetHeight*(i + 1);
|
||||
} else {
|
||||
elShift = el.offsetHeight;
|
||||
}
|
||||
|
||||
const tabPanel = this.$refs.tabPanelContents;
|
||||
const halfH = tabPanel.clientHeight/2;
|
||||
const newScrollTop = el.offsetTop - halfH - elShift;
|
||||
if (newScrollTop < 20 + tabPanel.scrollTop - halfH || newScrollTop > -20 + tabPanel.scrollTop + halfH)
|
||||
tabPanel.scrollTop = newScrollTop;
|
||||
}
|
||||
|
||||
if (this.selectedTab == 'images') {
|
||||
let item;
|
||||
|
||||
//ищем выделенные item
|
||||
for(const _item of this.images) {
|
||||
if (_item.isBookPos) {
|
||||
item = _item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
//вычисляем и смещаем tabPanel.scrollTop
|
||||
let el = this.getFirstElem(this.$refs[`image${item.key}`]);
|
||||
|
||||
const tabPanel = this.$refs.tabPanelImages;
|
||||
const halfH = tabPanel.clientHeight/2;
|
||||
const newScrollTop = el.offsetTop - halfH - el.offsetHeight/2;
|
||||
|
||||
if (newScrollTop < 20 + tabPanel.scrollTop - halfH || newScrollTop > -20 + tabPanel.scrollTop + halfH)
|
||||
tabPanel.scrollTop = newScrollTop;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
getFirstElem(items) {
|
||||
return (Array.isArray(items) ? items[0] : items);
|
||||
}
|
||||
|
||||
async expandClick(key) {
|
||||
const item = this.contents[key];
|
||||
const expanded = !item.expanded;
|
||||
|
||||
if (!expanded) {
|
||||
let subdiv = this.getFirstElem(this.$refs[`subdiv${key}`]);
|
||||
subdiv.style.height = '0';
|
||||
await utils.sleep(200);
|
||||
}
|
||||
|
||||
this.contents[key].expanded = expanded;
|
||||
|
||||
if (expanded) {
|
||||
await this.$nextTick();
|
||||
let subdiv = this.getFirstElem(this.$refs[`subdiv${key}`]);
|
||||
subdiv.style.height = subdiv.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
async setBookPos(newValue) {
|
||||
this.$emit('book-pos-changed', {bookPos: newValue});
|
||||
this.close();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('do-action', {action: 'contents'});
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (!this.$root.stdDialog.active && event.type == 'keydown' && event.key == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ContentsPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-panel {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
font-size: 90%;
|
||||
padding: 0 10px 0px 10px;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
padding: 10px 0 10px 0;
|
||||
}
|
||||
|
||||
.item, .subitem, .item-book-pos, .subitem-book-pos {
|
||||
border-bottom: 1px solid var(--bg-menu-color2);
|
||||
}
|
||||
|
||||
.item:hover, .subitem:hover {
|
||||
background-color: var(--bg-menu-color2);
|
||||
}
|
||||
|
||||
.item-book-pos {
|
||||
opacity: 1;
|
||||
background-color: var(--bg-selected-item-color1);
|
||||
}
|
||||
|
||||
.subitem-book-pos {
|
||||
opacity: 1;
|
||||
background-color: var(--bg-selected-item-color2);
|
||||
}
|
||||
|
||||
.item-book-pos:hover {
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
|
||||
.subitem-book-pos:hover {
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
|
||||
.expand-button, .no-expand-button {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.subitems-transition {
|
||||
height: 0;
|
||||
transition: height 0.2s linear;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.expanded-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.image-num {
|
||||
font-size: 120%;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.image-type {
|
||||
border: 1px solid black;
|
||||
border-radius: 6px;
|
||||
font-size: 80%;
|
||||
padding: 2px 0 2px 0;
|
||||
width: 34px;
|
||||
}
|
||||
.it-jpg-color {
|
||||
background: linear-gradient(to right, #fabc3d, #ffec6d);
|
||||
}
|
||||
.it-png-color {
|
||||
background: linear-gradient(to right, #4bc4e5, #6bf4ff);
|
||||
}
|
||||
.it-net-color {
|
||||
background: linear-gradient(to right, #00c400, #00f400);
|
||||
}
|
||||
|
||||
.image-thumb-box {
|
||||
width: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-thumb {
|
||||
height: 50px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.loading-img-icon {
|
||||
font-size: 250%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +1,30 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Скопировать текст
|
||||
</template>
|
||||
<Window @close="close">
|
||||
<template #header>
|
||||
Скопировать текст
|
||||
</template>
|
||||
|
||||
<div ref="text" class="text" tabindex="-1">
|
||||
<div v-html="text"></div>
|
||||
</div>
|
||||
</Window>
|
||||
<div ref="text" class="text" tabindex="-1">
|
||||
<div v-html="text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import {sleep} from '../../../share/utils';
|
||||
|
||||
export default @Component({
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
})
|
||||
class CopyTextPage extends Vue {
|
||||
};
|
||||
class CopyTextPage {
|
||||
_options = componentOptions;
|
||||
|
||||
text = null;
|
||||
initStep = null;
|
||||
initPercentage = 0;
|
||||
@@ -55,18 +52,21 @@ class CopyTextPage extends Vue {
|
||||
from = (from < 0 ? 0 : from);
|
||||
to = paraIndex + 100;
|
||||
to = (to > parsed.para.length ? parsed.para.length : to);
|
||||
cut = '<p>..... Текст вырезан. Если хотите скопировать больше, поставьте в настройках галочку "Загружать весь текст"';
|
||||
cut = '<dd>..... Текст вырезан. Если хотите скопировать больше, поставьте в настройках галочку "Загружать весь текст"';
|
||||
}
|
||||
|
||||
if (from > 0)
|
||||
text += cut;
|
||||
for (let i = from; i < to; i++) {
|
||||
const p = parsed.para[i];
|
||||
if (p.addIndex > 0)
|
||||
continue;
|
||||
|
||||
const parts = parsed.splitToStyle(p.text);
|
||||
if (this.stopInit)
|
||||
return;
|
||||
|
||||
text += `<p id="p${i}" class="copyPara">`;
|
||||
text += `<dd id="p${i}" class="copyPara"> `;
|
||||
for (const part of parts)
|
||||
text += part.text;
|
||||
|
||||
@@ -95,37 +95,22 @@ class CopyTextPage extends Vue {
|
||||
|
||||
close() {
|
||||
this.stopInit = true;
|
||||
this.$emit('copy-text-toggle');
|
||||
this.$emit('do-action', {action: 'copyText'});
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && (event.code == 'Escape')) {
|
||||
if (event.type == 'keydown' && event.key == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(CopyTextPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@@ -1,66 +1,93 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h4>Возможности читалки:</h4>
|
||||
<span class="text-h6 text-bold">Возможности читалки:</span>
|
||||
<ul>
|
||||
<li>загрузка любой страницы интернета</li>
|
||||
<li>синхронизация данных (настроек и читаемых книг) между различными устройствами</li>
|
||||
<li>работа в автономном режиме (без связи)</li>
|
||||
<li>изменение цвета фона, текста, размер и тип шрифта и прочее</li>
|
||||
<li>установка и запоминание текущей позиции и настроек в браузере (в будущем планируется сохранение и на сервер)</li>
|
||||
<li>установка и запоминание текущей позиции и настроек в браузере и на сервере</li>
|
||||
<li>кэширование файлов книг на клиенте и на сервере</li>
|
||||
<li>открытие книг с локального диска</li>
|
||||
<li>плавный скроллинг текста</li>
|
||||
<li>анимация перелистывания (скоро)</li>
|
||||
<li>анимация перелистывания</li>
|
||||
<li>поиск по тексту и копирование фрагмента</li>
|
||||
<li>запоминание недавних книг, скачивание книги из читалки в формате fb2</li>
|
||||
<li>управление кликом и с клавиатуры</li>
|
||||
<li>подключение к интернету не обязательно для чтения книги после ее загрузки</li>
|
||||
<li>регистрация не требуется</li>
|
||||
<li>поддерживаемые браузеры: Google Chrome, Mozilla Firefox последних версий</li>
|
||||
</ul>
|
||||
|
||||
<p>В качестве URL можно задавать html-страничку с книгой, либо прямую ссылку
|
||||
на файл из онлайн-библиотеки (например, скопировав адрес ссылки или кнопки "скачать fb2").</p>
|
||||
<p>Поддерживаемые форматы: <strong>html, txt, fb2, fb2.zip</strong></p>
|
||||
<p>
|
||||
В качестве URL книги можно задавать html-страничку с книгой, либо прямую ссылку
|
||||
на файл из онлайн-библиотеки (например, скопировав адрес ссылки или кнопки "скачать fb2").
|
||||
</p>
|
||||
<p>Поддерживаемые форматы: <b>fb2, fb2.zip, html, txt</b> и другие.</p>
|
||||
|
||||
<div v-html="automationHtml"></div>
|
||||
<div v-show="mode == 'omnireader' || mode == 'liberama'">
|
||||
<p>
|
||||
Вы можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
|
||||
<br><strong>{{ bookmarkText }}</strong>
|
||||
<q-icon class="copy-icon" name="la la-copy" @click="copyText(bookmarkText, 'Код для адреса закладки успешно скопирован в буфер обмена')">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
|
||||
Скопировать
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
|
||||
<br>или перетащив на панель закладок следующую ссылку:
|
||||
<br><a style="margin-left: 50px" :href="bookmarkText">{{ (mode == 'omnireader' ? 'Omni' : 'Liberama') }} Reader</a>
|
||||
<br>Тогда, активировав получившуюся закладку на любой странице интернета, вы автоматически загрузите эту страницу в Omni Reader.
|
||||
<br>В Chrome для Android можно вызывать такую закладку по имени прямо в адресной строке браузера (имя стоит сделать попроще).
|
||||
</p>
|
||||
</div>
|
||||
<p>Связаться с разработчиком: <a href="mailto:bookpauk@gmail.com">bookpauk@gmail.com</a></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class CommonHelpPage extends Vue {
|
||||
import {copyTextToClipboard} from '../../../../share/utils';
|
||||
|
||||
class CommonHelpPage {
|
||||
created() {
|
||||
this.config = this.$store.state.config;
|
||||
}
|
||||
|
||||
get automationHtml() {
|
||||
if (this.config.mode == 'omnireader') {
|
||||
return `<p>Вы можете добавить в свой браузер закладку, указав в ее свойствах вместо адреса следующий код:
|
||||
<br><strong>javascript:location.href='http://omnireader.ru/?url='+location.href;</strong>
|
||||
<br>Тогда, нажав на получившуюся кнопку на любой странице интернета, вы автоматически откроете ее в Omni Reader.</p>`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
get bookmarkText() {
|
||||
return `javascript:location.href='${window.location.protocol}//${window.location.host}/#/reader?url='+location.href;`
|
||||
}
|
||||
|
||||
async copyText(text, mes) {
|
||||
const result = await copyTextToClipboard(text);
|
||||
const msg = (result ? mes : 'Копирование не удалось');
|
||||
if (result)
|
||||
this.$root.notify.success(msg);
|
||||
else
|
||||
this.$root.notify.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(CommonHelpPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
.copy-icon {
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 120%;
|
||||
color: var(--text-anchor-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="box">
|
||||
<p class="p">Проект существует исключительно на личном энтузиазме.</p>
|
||||
<p class="p">Чтобы энтузиазма было побольше, вы можете пожертвовать на развитие проекта любую сумму:</p>
|
||||
<div class="address">
|
||||
<img class="logo" src="./assets/yandex.png">
|
||||
<el-button class="button" @click="donateYandexMoney">Пожертвовать</el-button><br>
|
||||
<div class="para">{{ yandexAddress }}</div>
|
||||
</div>
|
||||
<div class="column items-center" style="width: 500px">
|
||||
<p class="p">
|
||||
Здесь вы можете пожертвовать на развитие проекта:
|
||||
</p>
|
||||
|
||||
<div class="address">
|
||||
<img class="logo" src="./assets/bitcoin.png">
|
||||
<el-button class="button" @click="copyAddress(bitcoinAddress, 'Bitcoin')">Скопировать</el-button><br>
|
||||
<div class="para">{{ bitcoinAddress }}</div>
|
||||
</div>
|
||||
<q-btn no-caps class="q-my-lg" color="green-8" size="14px" style="width: 200px" @click="makeDonation">
|
||||
<q-icon class="q-mr-xs" name="la la-donate" size="24px" />
|
||||
Поддержать проект
|
||||
</q-btn>
|
||||
|
||||
<div class="address">
|
||||
<img class="logo" src="./assets/litecoin.png">
|
||||
<el-button class="button" @click="copyAddress(litecoinAddress, 'Litecoin')">Скопировать</el-button><br>
|
||||
<div class="para">{{ litecoinAddress }}</div>
|
||||
</div>
|
||||
|
||||
<div class="address">
|
||||
<img class="logo" src="./assets/monero.png">
|
||||
<el-button class="button" @click="copyAddress(moneroAddress, 'Monero')">Скопировать</el-button><br>
|
||||
<div class="para">{{ moneroAddress }}</div>
|
||||
<div style="font-size: 60%">
|
||||
* Ваш донат является подарком автору проекта
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,45 +19,29 @@
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import {copyTextToClipboard} from '../../../../share/utils';
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class DonateHelpPage extends Vue {
|
||||
yandexAddress = '410018702323056';
|
||||
bitcoinAddress = '3EbgZ7MK1UVaN38Gty5DCBtS4PknM4Ut85';
|
||||
litecoinAddress = 'MP39Riec4oSNB3XMjiquKoLWxbufRYNXxZ';
|
||||
moneroAddress = '8BQPnvHcPSHM5gMQsmuypDgx9NNsYqwXKfDDuswEyF2Q2ewQSfd2pkK6ydH2wmMyq2JViZvy9DQ35hLMx7g72mFWNJTPtnz';
|
||||
import * as utils from '../../../../share/utils';
|
||||
|
||||
class DonateHelpPage {
|
||||
created() {
|
||||
}
|
||||
|
||||
donateYandexMoney() {
|
||||
window.open(`https://money.yandex.ru/to/${this.yandexAddress}`, '_blank');
|
||||
}
|
||||
|
||||
async copyAddress(address, prefix) {
|
||||
const result = await copyTextToClipboard(address);
|
||||
const msg = (result ? `${prefix}-адрес ${address} успешно скопирован в буфер обмена` : 'Копирование не удалось');
|
||||
if (result)
|
||||
this.$notify.success({message: msg});
|
||||
else
|
||||
this.$notify.error({message: msg});
|
||||
makeDonation() {
|
||||
utils.makeDonation();
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(DonateHelpPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.p {
|
||||
@@ -78,33 +49,4 @@ class DonateHelpPage extends Vue {
|
||||
padding: 0;
|
||||
text-indent: 20px;
|
||||
}
|
||||
|
||||
.box {
|
||||
flex: 1;
|
||||
max-width: 550px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.address {
|
||||
padding-top: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.para {
|
||||
margin: 10px 10px 10px 40px;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 130px;
|
||||
position: relative;
|
||||
top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
@@ -1,100 +1,103 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Справка
|
||||
</template>
|
||||
<Window style="z-index: 200" @close="close">
|
||||
<template #header>
|
||||
Справка
|
||||
</template>
|
||||
|
||||
<el-tabs type="border-card" v-model="selectedTab">
|
||||
<el-tab-pane class="tab" label="Общее">
|
||||
<CommonHelpPage></CommonHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Клавиатура">
|
||||
<HotkeysHelpPage></HotkeysHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Мышь/тачпад">
|
||||
<MouseHelpPage></MouseHelpPage>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Помочь проекту" name="donate">
|
||||
<DonateHelpPage></DonateHelpPage>
|
||||
</el-tab-pane>
|
||||
<div class="col column" style="min-width: 600px">
|
||||
<div class="bg-menu-1 row">
|
||||
<q-tabs
|
||||
v-model="selectedTab"
|
||||
active-color="app"
|
||||
active-bg-color="app"
|
||||
indicator-color="bg-app"
|
||||
dense
|
||||
no-caps
|
||||
inline-label
|
||||
class="bg-menu-2 text-menu"
|
||||
>
|
||||
<q-tab v-for="btn in buttons" :key="btn.value" :name="btn.value" :label="btn.label" />
|
||||
</q-tabs>
|
||||
</div>
|
||||
|
||||
</el-tabs>
|
||||
</Window>
|
||||
<keep-alive>
|
||||
<component :is="activePage" ref="page" class="col"></component>
|
||||
</keep-alive>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import CommonHelpPage from './CommonHelpPage/CommonHelpPage.vue';
|
||||
import HotkeysHelpPage from './HotkeysHelpPage/HotkeysHelpPage.vue';
|
||||
import MouseHelpPage from './MouseHelpPage/MouseHelpPage.vue';
|
||||
import VersionHistoryPage from './VersionHistoryPage/VersionHistoryPage.vue';
|
||||
import DonateHelpPage from './DonateHelpPage/DonateHelpPage.vue';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
CommonHelpPage,
|
||||
HotkeysHelpPage,
|
||||
MouseHelpPage,
|
||||
DonateHelpPage,
|
||||
},
|
||||
})
|
||||
class HelpPage extends Vue {
|
||||
selectedTab = null;
|
||||
const pages = {
|
||||
'CommonHelpPage': CommonHelpPage,
|
||||
'HotkeysHelpPage': HotkeysHelpPage,
|
||||
'MouseHelpPage': MouseHelpPage,
|
||||
'VersionHistoryPage': VersionHistoryPage,
|
||||
'DonateHelpPage': DonateHelpPage,
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
['CommonHelpPage', 'Общее'],
|
||||
['MouseHelpPage', 'Мышь/тачскрин'],
|
||||
['HotkeysHelpPage', 'Клавиатура'],
|
||||
['VersionHistoryPage', 'История версий'],
|
||||
//['DonateHelpPage', 'Помочь проекту'],
|
||||
];
|
||||
|
||||
const componentOptions = {
|
||||
components: Object.assign({ Window }, pages),
|
||||
};
|
||||
class HelpPage {
|
||||
_options = componentOptions;
|
||||
|
||||
selectedTab = 'CommonHelpPage';
|
||||
|
||||
close() {
|
||||
this.$emit('help-toggle');
|
||||
this.$emit('do-action', {action: 'help'});
|
||||
}
|
||||
|
||||
get activePage() {
|
||||
if (pages[this.selectedTab])
|
||||
return pages[this.selectedTab];
|
||||
return null;
|
||||
}
|
||||
|
||||
get buttons() {
|
||||
let result = [];
|
||||
for (const tab of tabs)
|
||||
result.push({label: tab[1], value: tab[0]});
|
||||
return result;
|
||||
}
|
||||
|
||||
activateDonateHelpPage() {
|
||||
this.selectedTab = 'donate';
|
||||
this.selectedTab = 'DonateHelpPage';
|
||||
}
|
||||
|
||||
activateVersionHistoryHelpPage() {
|
||||
this.selectedTab = 'VersionHistoryPage';
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && (event.code == 'Escape')) {
|
||||
if (event.type == 'keydown' && event.key == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(HelpPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-tab-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h4>Управление с помощью горячих клавиш:</h4>
|
||||
<ul>
|
||||
<li><b>F1, H</b> - открыть справку</li>
|
||||
<li><b>Escape</b> - показать/скрыть страницу загрузки</li>
|
||||
<li><b>Tab</b> - показать/скрыть панель управления</li>
|
||||
<li><b>PageUp, Left, Shift+Space, Backspace</b> - страницу назад</li>
|
||||
<li><b>PageDown, Right, Space</b> - страницу вперед</li>
|
||||
<li><b>Home</b> - в начало книги</li>
|
||||
<li><b>End</b> - в конец книги</li>
|
||||
<li><b>Up</b> - строчку назад</li>
|
||||
<li><b>Down</b> - строчку вперёд</li>
|
||||
<li><b>A, Shift+A</b> - изменить размер шрифта</li>
|
||||
<li><b>Enter, F, F11, ` (апостроф)</b> - вкл./выкл. полный экран</li>
|
||||
<li><b>Z</b> - вкл./выкл. плавный скроллинг текста</li>
|
||||
<li><b>Shift+Down/Shift+Up</b> - увеличить/уменьшить скорость скроллинга
|
||||
<li><b>P</b> - установить страницу</li>
|
||||
<li><b>Ctrl+F</b> - найти в тексте</li>
|
||||
<li><b>Ctrl+C</b> - скопировать текст со страницы</li>
|
||||
<li><b>R</b> - принудительно обновить книгу в обход кэша</li>
|
||||
<li><b>X</b> - открыть недавние</li>
|
||||
<li><b>S</b> - открыть окно настроек</li>
|
||||
</ul>
|
||||
<div style="font-size: 120%">
|
||||
<div class="text-h6 text-bold">
|
||||
Доступны следующие клавиатурные команды:
|
||||
</div>
|
||||
<br>
|
||||
</div>
|
||||
<div class="q-mb-md" style="width: 550px">
|
||||
<div class="text-right text-italic" style="font-size: 80%">
|
||||
* Изменить сочетания клавиш можно в настройках
|
||||
</div>
|
||||
<UserHotKeys v-model="userHotKeys" readonly />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
import UserHotKeys from '../../SettingsPage/KeysTab/UserHotKeys/UserHotKeys.vue';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
UserHotKeys,
|
||||
},
|
||||
};
|
||||
class HotkeysHelpPage {
|
||||
_options = componentOptions;
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class HotkeysHelpPage extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
get userHotKeys() {
|
||||
return this.$store.state.reader.settings.userHotKeys;
|
||||
}
|
||||
|
||||
set userHotKeys(value) {
|
||||
//no setter
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default vueComponent(HotkeysHelpPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h4>Управление с помощью мыши/тачпада:</h4>
|
||||
<span class="text-h6 text-bold">Управление с помощью мыши/тачскрина:</span>
|
||||
<ul>
|
||||
<li><b>ЛКМ/ТАЧ</b> по экрану в одну из областей - активация действия:</li>
|
||||
<div class="click-map-page">
|
||||
<ClickMapPage ref="clickMapPage"></ClickMapPage>
|
||||
</div>
|
||||
<div class="click-map-page">
|
||||
<ClickMapPage ref="clickMapPage"></ClickMapPage>
|
||||
</div>
|
||||
|
||||
<li><b>ПКМ</b> - показать/скрыть панель управления</li>
|
||||
<li><b>СКМ</b> - вкл./выкл. плавный скроллинг текста</li>
|
||||
<br>
|
||||
<li>Жесты для тачскрина:</li>
|
||||
<ul>
|
||||
<li style="list-style-type: square">
|
||||
от центра вверх/двойной тап по центру: на весь экран
|
||||
</li>
|
||||
<li style="list-style-type: square">
|
||||
от центра вниз: плавный скроллинг
|
||||
</li>
|
||||
<li style="list-style-type: square">
|
||||
от центра вправо: увеличить скорость скроллинга
|
||||
</li>
|
||||
<li style="list-style-type: square">
|
||||
от центра влево: уменьшить скорость скроллинга
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
* Для управления с помощью мыши/тачскрина необходимо установить галочку "Включить управление кликом" в настройках
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
import ClickMapPage from '../../ClickMapPage/ClickMapPage.vue';
|
||||
|
||||
export default @Component({
|
||||
const componentOptions = {
|
||||
components: {
|
||||
ClickMapPage,
|
||||
},
|
||||
})
|
||||
class MouseHelpPage extends Vue {
|
||||
};
|
||||
class MouseHelpPage {
|
||||
_options = componentOptions;
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
@@ -33,22 +52,19 @@ class MouseHelpPage extends Vue {
|
||||
this.$refs.clickMapPage.$el.style.backgroundColor = '#478355';
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(MouseHelpPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.click-map-page {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div id="versionHistoryPage" class="page">
|
||||
<span class="text-h6 text-bold">История версий:</span>
|
||||
<br><br>
|
||||
|
||||
<span v-for="(item, index) in versionHeader" :key="index" class="clickable" @click="showRelease(item)">
|
||||
<p>
|
||||
{{ item }}
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<br>
|
||||
|
||||
<div v-for="item in versionContent" :id="item.key" :key="item.key">
|
||||
<span v-html="item.content"></span>
|
||||
<br>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
import {versionHistory} from '../../versionHistory';
|
||||
|
||||
class VersionHistoryPage {
|
||||
versionHeader = [];
|
||||
versionContent = [];
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
let vh = [];
|
||||
for (const v of versionHistory) {
|
||||
vh.push(`${v.version} (${v.releaseDate})`);
|
||||
}
|
||||
this.versionHeader = vh;
|
||||
|
||||
let vc = [];
|
||||
for (const v of versionHistory) {
|
||||
let header = `${v.version} (${v.releaseDate})`;
|
||||
vc.push({key: header, content: 'Версия ' + header + v.content});
|
||||
}
|
||||
this.versionContent = vc;
|
||||
}
|
||||
|
||||
showRelease(id) {
|
||||
let el = document.getElementById(id);
|
||||
if (el) {
|
||||
document.getElementById('versionHistoryPage').scrollTop = el.offsetTop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(VersionHistoryPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
font-size: 120%;
|
||||
line-height: 130%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
color: var(--text-anchor-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,251 +0,0 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Последние 100 открытых книг
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="tableData"
|
||||
style="width: 100%"
|
||||
size="mini"
|
||||
height="1px"
|
||||
stripe
|
||||
border
|
||||
:default-sort = "{prop: 'touchDateTime', order: 'descending'}"
|
||||
:header-cell-style = "headerCellStyle"
|
||||
>
|
||||
|
||||
<el-table-column
|
||||
prop="touchDateTime"
|
||||
min-width="90px"
|
||||
sortable
|
||||
>
|
||||
<template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<span style="font-size: 90%">Время<br>просм.</span>
|
||||
</template>
|
||||
<template slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<div class="desc" @click="loadBook(scope.row.url)">
|
||||
{{ scope.row.touchDate }}<br>
|
||||
{{ scope.row.touchTime }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
>
|
||||
<template slot="header" slot-scope="scope"><!-- eslint-disable-line vue/no-unused-vars -->
|
||||
<!--el-input ref="input"
|
||||
:value="search" @input="search = $event"
|
||||
size="mini"
|
||||
style="margin: 0; padding: 0; vertical-align: bottom; margin-top: 10px"
|
||||
placeholder="Найти"/-->
|
||||
<div class="el-input el-input--mini">
|
||||
<input class="el-input__inner"
|
||||
placeholder="Найти"
|
||||
style="margin: 0; padding: 0; vertical-align: bottom; margin-top: 20px; padding: 0 10px 0 10px"
|
||||
:value="search" @input="search = $event.target.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table-column
|
||||
min-width="300px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<div class="desc" @click="loadBook(scope.row.url)">
|
||||
<span style="color: green">{{ scope.row.desc.author }}</span><br>
|
||||
<span>{{ scope.row.desc.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
min-width="100px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<a v-show="isUrl(scope.row.url)" :href="scope.row.url" target="_blank">Оригинал</a><br>
|
||||
<a :href="scope.row.path" :download="getFileNameFromPath(scope.row.path)">Скачать FB2</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column
|
||||
width="60px"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
size="mini"
|
||||
style="width: 30px; padding: 7px 0 7px 0; margin-left: 4px"
|
||||
@click="handleDel(scope.row.key)"><i class="el-icon-close"></i>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
</Window>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import path from 'path';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {formatDate} from '../../../share/utils';
|
||||
import Window from '../../share/Window.vue';
|
||||
import bookManager from '../share/bookManager';
|
||||
|
||||
export default @Component({
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
watch: {
|
||||
search: function() {
|
||||
this.updateTableData();
|
||||
}
|
||||
},
|
||||
})
|
||||
class HistoryPage extends Vue {
|
||||
search = null;
|
||||
tableData = null;
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.updateTableData();
|
||||
this.mostRecentBook = bookManager.mostRecentBook();
|
||||
}
|
||||
|
||||
updateTableData() {
|
||||
let result = [];
|
||||
|
||||
for (let bookKey in bookManager.recent) {
|
||||
const book = bookManager.recent[bookKey];
|
||||
let d = new Date();
|
||||
d.setTime(book.touchTime);
|
||||
const t = formatDate(d).split(' ');
|
||||
|
||||
let perc = '';
|
||||
let textLen = '';
|
||||
const p = (book.bookPosSeen ? book.bookPosSeen : (book.bookPos ? book.bookPos : 0));
|
||||
if (book.textLength) {
|
||||
perc = ` [${((p/book.textLength)*100).toFixed(2)}%]`;
|
||||
textLen = ` ${Math.round(book.textLength/1000)}k`;
|
||||
}
|
||||
|
||||
const fb2 = (book.fb2 ? book.fb2 : {});
|
||||
result.push({
|
||||
touchDateTime: book.touchTime,
|
||||
touchDate: t[0],
|
||||
touchTime: t[1],
|
||||
desc: {
|
||||
title: `"${fb2.bookTitle}"${perc}${textLen}`,
|
||||
author: _.compact([
|
||||
fb2.lastName,
|
||||
fb2.firstName,
|
||||
fb2.middleName
|
||||
]).join(' '),
|
||||
},
|
||||
url: book.url,
|
||||
path: book.path,
|
||||
key: book.key,
|
||||
});
|
||||
}
|
||||
|
||||
const search = this.search;
|
||||
this.tableData = result.filter(item => {
|
||||
return !search ||
|
||||
item.touchTime.includes(search) ||
|
||||
item.touchDate.includes(search) ||
|
||||
item.desc.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.desc.author.toLowerCase().includes(search.toLowerCase())
|
||||
});
|
||||
}
|
||||
|
||||
headerCellStyle(cell) {
|
||||
let result = {margin: 0, padding: 0};
|
||||
if (cell.columnIndex > 0) {
|
||||
result['border-bottom'] = 0;
|
||||
}
|
||||
if (cell.rowIndex > 0) {
|
||||
result.height = '0px';
|
||||
result['border-right'] = 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getFileNameFromPath(fb2Path) {
|
||||
return path.basename(fb2Path).substr(0, 10) + '.fb2';
|
||||
}
|
||||
|
||||
openOriginal(url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
openFb2(path) {
|
||||
window.open(path, '_blank');
|
||||
}
|
||||
|
||||
async handleDel(key) {
|
||||
await bookManager.delRecentBook({key});
|
||||
this.updateTableData();
|
||||
|
||||
const newRecent = bookManager.mostRecentBook();
|
||||
if (this.mostRecentBook != newRecent)
|
||||
this.$emit('load-book', newRecent);
|
||||
|
||||
this.mostRecentBook = newRecent;
|
||||
if (!this.mostRecentBook)
|
||||
this.close();
|
||||
}
|
||||
|
||||
loadBook(url) {
|
||||
this.$emit('load-book', {url});
|
||||
this.close();
|
||||
}
|
||||
|
||||
isUrl(url) {
|
||||
return (url.indexOf('file://') != 0);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('history-toggle');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && event.code == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.desc {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
144
client/components/Reader/LibsPage/LibsPage.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="hidden"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import * as utils from '../../../share/utils';
|
||||
import rstore from '../../../store/modules/reader';
|
||||
import _ from 'lodash';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Window
|
||||
},
|
||||
watch: {
|
||||
libs: function() {
|
||||
this.sendLibs();
|
||||
},
|
||||
}
|
||||
};
|
||||
class LibsPage {
|
||||
_options = componentOptions;
|
||||
|
||||
created() {
|
||||
this.popupWindow = null;
|
||||
this.commit = this.$store.commit;
|
||||
this.messageListener = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.mode)
|
||||
return;
|
||||
|
||||
//TODO: убрать условие с mode в 24г
|
||||
if (!this.libs || !this.libs.groups || (this.mode === 'omnireader' && this.libs.mode !== this.mode)) {
|
||||
const defaults = rstore.getLibsDefaults(this.mode);
|
||||
this.commit('reader/setLibs', defaults);
|
||||
}
|
||||
|
||||
this.childReady = false;
|
||||
const subdomain = (window.location.protocol != 'http:' ? 'b.' : '');
|
||||
this.origin = `http://${subdomain}${window.location.host}`;
|
||||
|
||||
this.messageListener = (event) => {
|
||||
if (event.origin !== this.origin)
|
||||
return;
|
||||
|
||||
//console.log(event.data);
|
||||
|
||||
this.recvMessage(event.data);
|
||||
};
|
||||
|
||||
this.popupWindow = window.open(`${this.origin}/#/external-libs`);
|
||||
|
||||
if (this.popupWindow) {
|
||||
|
||||
window.addEventListener('message', this.messageListener);
|
||||
|
||||
//Проверка закрытия окна
|
||||
(async() => {
|
||||
while(this.popupWindow) {
|
||||
if (this.popupWindow && this.popupWindow.closed)
|
||||
this.close();
|
||||
await utils.sleep(1000);
|
||||
}
|
||||
})();
|
||||
|
||||
//Установление связи с окном
|
||||
(async() => {
|
||||
let i = 0;
|
||||
while(!this.childReady && this.popupWindow && i < 100) {
|
||||
this.sendMessage({type: 'mes', data: 'hello'});
|
||||
await utils.sleep(100);
|
||||
i++;
|
||||
}
|
||||
this.sendLibs();
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
recvMessage(d) {
|
||||
if (d.type == 'mes') {
|
||||
switch(d.data) {
|
||||
case 'ready':
|
||||
this.childReady = true;
|
||||
break;
|
||||
}
|
||||
} else if (d.type == 'libs') {
|
||||
this.commit('reader/setLibs', d.data);
|
||||
} else if (d.type == 'close') {
|
||||
this.close();
|
||||
} else if (d.type == 'submitUrl') {
|
||||
this.$emit('load-book', d.data);
|
||||
this.sendMessage({type: 'notify', data: 'Ссылка передана в читалку'});
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(d) {
|
||||
if (this.popupWindow)
|
||||
this.popupWindow.postMessage(Object.assign({}, {from: 'LibsPage'}, d), this.origin);
|
||||
}
|
||||
|
||||
done() {
|
||||
window.removeEventListener('message', this.messageListener);
|
||||
if (this.popupWindow) {
|
||||
this.popupWindow.close();
|
||||
this.popupWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
get libs() {
|
||||
return this.$store.state.reader.libs;
|
||||
}
|
||||
|
||||
get nightMode() {
|
||||
return this.$store.state.reader.settings.nightMode;
|
||||
}
|
||||
|
||||
sendLibs() {
|
||||
this.sendMessage({type: 'libs', data: _.cloneDeep(this.libs), sets: {nightMode: this.nightMode}});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('libs-close');
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(LibsPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.separator {
|
||||
height: 1px;
|
||||
background-color: #A0A0A0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div id="vue-github-corner">
|
||||
<a :href="url" id="github-corner" target="_blank" aria-label="View source on Github" >
|
||||
<svg id="github-corner-svg"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 250 250"
|
||||
:width="size" :height="size"
|
||||
:style="svgStyle" >
|
||||
<path :d="svgPath1" @mouseenter="flipColor" @mouseleave="flipColor"></path>
|
||||
<path :d="svgPath2" :style="gitStyle" class="octo-arm"></path>
|
||||
<path :d="svgPath3" :style="gitStyle" class="octo-body"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'GithubCorner',
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
default: '/'
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 80
|
||||
},
|
||||
colorScheme: {
|
||||
type: String,
|
||||
default: 'auto'
|
||||
},
|
||||
cornerColor: {
|
||||
type: String,
|
||||
default: '#625D5D'
|
||||
},
|
||||
gitColor: {
|
||||
type: String,
|
||||
default: 'PeachPuff'
|
||||
},
|
||||
leftCorner: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
flipOnHover: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
svgStyle: {
|
||||
fill: this.cornerColor,
|
||||
right: (this.leftCorner ? 'auto' : '0'),
|
||||
left: (this.leftCorner ? '0' : 'auto'),
|
||||
transform: (this.leftCorner ? 'scale(-1, 1)' : 'none')
|
||||
},
|
||||
gitStyle: {
|
||||
fill: this.gitColor
|
||||
},
|
||||
flipped: false,
|
||||
svgPath1: 'M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z',
|
||||
svgPath2: 'M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 ' +
|
||||
'123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2',
|
||||
svgPath3: 'M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 ' +
|
||||
'C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 ' +
|
||||
'176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 ' +
|
||||
'216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 ' +
|
||||
'C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
flipColor: function() {
|
||||
if (this.flipOnHover) {
|
||||
let holdSvgFill = this.svgStyle.fill
|
||||
this.svgStyle.fill = this.gitStyle.fill
|
||||
this.gitStyle.fill = holdSvgFill
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeMount: function() {
|
||||
if (this.colorScheme != 'auto') {
|
||||
let sch = this.colorScheme
|
||||
this.gitStyle.fill = '#fff'
|
||||
|
||||
if (sch.toLowerCase() == 'black') {
|
||||
this.svgStyle.fill = '#151513'
|
||||
}
|
||||
if (sch.toLowerCase() == 'green') {
|
||||
this.svgStyle.fill = '#64CEAA'
|
||||
}
|
||||
if (sch.toLowerCase() == 'red') {
|
||||
this.svgStyle.fill = '#FD6C6C'
|
||||
}
|
||||
if (sch.toLowerCase() == 'blue') {
|
||||
this.svgStyle.fill = '#70B7FD'
|
||||
}
|
||||
if (sch.toLowerCase() == 'white') {
|
||||
this.svgStyle.fill = '#fff'
|
||||
this.gitStyle.fill = '#151513'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#github-corner .octo-arm {
|
||||
transform-origin: 130px 106px
|
||||
}
|
||||
#github-corner:hover .octo-arm {
|
||||
animation: octocat-wave 560ms ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes octocat-wave {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(-25deg); }
|
||||
40% { transform: rotate(10deg); }
|
||||
60% { transform: rotate(-25deg); }
|
||||
80% { transform: rotate(10deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
#github-corner-svg {
|
||||
color: #fff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#github-corner-svg, #github-corner-svg .octo-arm, #github-corner-svg .octo-body {
|
||||
transition: fill 1s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -1,48 +1,104 @@
|
||||
<template>
|
||||
<div ref="main" class="main">
|
||||
<div class="part">
|
||||
<span class="greeting bold-font">{{ title }}</span>
|
||||
<div ref="main" class="column no-wrap" style="min-height: 500px">
|
||||
<div v-if="mode != 'liberama'" class="relative-position">
|
||||
<GithubCorner url="https://github.com/bookpauk/liberama" corner-color="#1B695F" git-color="#EBE2C9"></GithubCorner>
|
||||
</div>
|
||||
<div class="col column justify-center items-center no-wrap overflow-hidden" style="min-height: 230px">
|
||||
<span class="greeting"><b>{{ title }}</b></span>
|
||||
<div class="q-my-sm"></div>
|
||||
<span class="greeting">Добро пожаловать!</span>
|
||||
<span class="greeting">Поддерживаются форматы: fb2, fb2.zip, html, txt</span>
|
||||
<span class="greeting">Поддерживаются форматы: <b>fb2, html, txt</b> и сжатие: <b>zip, bz2, gz<span v-if="isExternalConverter">, rar</span></b></span>
|
||||
<span v-if="isExternalConverter" class="greeting">...а также частично форматы: <b>epub, mobi, rtf, doc, docx, pdf, djvu</b></span>
|
||||
</div>
|
||||
<div class="part center">
|
||||
<el-input ref="input" placeholder="URL книги" v-model="bookUrl">
|
||||
<el-button slot="append" icon="el-icon-check" @click="submitUrl"></el-button>
|
||||
</el-input>
|
||||
<div class="space"></div>
|
||||
<input type="file" id="file" ref="file" @change="loadFile" style='display: none;'/>
|
||||
<el-button size="mini" @click="loadFileClick">
|
||||
|
||||
<div class="col-auto column justify-start items-center no-wrap overflow-hidden">
|
||||
<q-input
|
||||
ref="input" v-model="bookUrl" class="full-width q-px-sm" style="max-width: 700px"
|
||||
outlined dense bg-color="input" placeholder="Ссылка на книгу или веб-страницу" @keydown="onInputKeydown"
|
||||
>
|
||||
<template #append>
|
||||
<q-btn rounded flat style="width: 40px" icon="la la-check" @click="submitUrl" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<input
|
||||
id="file" ref="file" type="file"
|
||||
style="display: none;"
|
||||
:accept="acceptFileExt"
|
||||
@change="loadFile"
|
||||
/>
|
||||
|
||||
<div class="q-my-sm"></div>
|
||||
<q-btn no-caps dense class="q-px-sm" color="btn1" size="13px" @click="loadFileClick">
|
||||
<q-icon class="q-mr-xs" name="la la-caret-square-up" size="24px" />
|
||||
Загрузить файл с диска
|
||||
</el-button>
|
||||
<div class="space"></div>
|
||||
<span v-if="config.mode == 'omnireader'" class="bottom-span clickable" @click="openComments">Комментарии</span>
|
||||
</q-btn>
|
||||
|
||||
<div class="q-my-sm"></div>
|
||||
<q-btn no-caps dense class="q-px-sm" color="btn1" size="13px" @click="loadBufferClick">
|
||||
<q-icon class="q-mr-xs" name="la la-comment" size="24px" />
|
||||
Из буфера обмена
|
||||
</q-btn>
|
||||
|
||||
<div class="q-my-md"></div>
|
||||
<!--div v-if="mode == 'omnireader'">
|
||||
<div ref="yaShare2" class="ya-share2"
|
||||
data-services="collections,vkontakte,facebook,odnoklassniki,twitter,telegram"
|
||||
data-description="Чтение fb2-книг онлайн. Загрузка любой страницы интернета одним кликом, синхронизация между устройствами, удобное управление, регистрация не требуется."
|
||||
data-title="Omni Reader - браузерная онлайн-читалка"
|
||||
data-url="https://omnireader.ru">
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-my-sm"></div-->
|
||||
<span v-if="mode == 'omnireader'" class="bottom-span clickable" @click="openComments">Отзывы о читалке</span>
|
||||
<span v-if="mode == 'omnireader'" class="bottom-span clickable" @click="openOldVersion">Старая версия</span>
|
||||
</div>
|
||||
<div class="part bottom">
|
||||
|
||||
<div class="col column justify-end items-center no-wrap overflow-hidden">
|
||||
<span class="bottom-span clickable" @click="openHelp">Справка</span>
|
||||
<span class="bottom-span clickable" @click="openDonate">Помочь проекту</span>
|
||||
<span class="bottom-span">{{ version }}</span>
|
||||
|
||||
<span v-if="version == clientVersion" class="bottom-span">v{{ version }}</span>
|
||||
<span v-else class="bottom-span">Версия сервера {{ version }}, версия клиента {{ clientVersion }}, необходимо обновить страницу</span>
|
||||
</div>
|
||||
|
||||
<PasteTextPage v-if="pasteTextActive" ref="pasteTextPage" @paste-text-toggle="pasteTextToggle" @load-buffer="loadBuffer"></PasteTextPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import GithubCorner from './GithubCorner/GithubCorner.vue';
|
||||
|
||||
import Dialog from '../../share/Dialog.vue';
|
||||
import PasteTextPage from './PasteTextPage/PasteTextPage.vue';
|
||||
import {versionHistory} from '../versionHistory';
|
||||
import * as utils from '../../../share/utils';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
GithubCorner,
|
||||
Dialog,
|
||||
PasteTextPage,
|
||||
},
|
||||
};
|
||||
class LoaderPage {
|
||||
_options = componentOptions;
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class LoaderPage extends Vue {
|
||||
bookUrl = null;
|
||||
loadPercent = 0;
|
||||
pasteTextActive = false;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.config = this.$store.state.config;
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.progress = this.$refs.progress;
|
||||
/*if (this.mode == 'omnireader')
|
||||
Ya.share2(this.$refs.yaShare2);// eslint-disable-line no-undef*/
|
||||
}
|
||||
|
||||
activated() {
|
||||
@@ -50,19 +106,37 @@ class LoaderPage extends Vue {
|
||||
}
|
||||
|
||||
get title() {
|
||||
if (this.config.mode == 'omnireader')
|
||||
if (this.mode == 'omnireader')
|
||||
return 'Omni Reader - браузерная онлайн-читалка.';
|
||||
if (this.mode == 'liberama')
|
||||
return 'Liberama Reader - браузерная онлайн-читалка.';
|
||||
return 'Универсальная читалка книг и ресурсов интернета.';
|
||||
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
get version() {
|
||||
return `v${this.config.version}`;
|
||||
return this.$store.state.config.version;
|
||||
}
|
||||
|
||||
get acceptFileExt() {
|
||||
return this.$store.state.config.acceptFileExt;
|
||||
}
|
||||
|
||||
get isExternalConverter() {
|
||||
return this.$store.state.config.useExternalBookConverter;
|
||||
}
|
||||
|
||||
get clientVersion() {
|
||||
return versionHistory[0].version;
|
||||
}
|
||||
|
||||
submitUrl() {
|
||||
if (this.bookUrl) {
|
||||
this.$emit('load-book', {url: this.bookUrl});
|
||||
this.$emit('load-book', {url: this.bookUrl, force: true});
|
||||
this.bookUrl = '';
|
||||
}
|
||||
}
|
||||
@@ -72,89 +146,84 @@ class LoaderPage extends Vue {
|
||||
}
|
||||
|
||||
loadFile() {
|
||||
const file = this.$refs.file.files[0];
|
||||
const file = this.$refs.file.files[0];
|
||||
this.$refs.file.value = '';
|
||||
if (file)
|
||||
this.$emit('load-file', {file});
|
||||
}
|
||||
|
||||
openHelp() {
|
||||
this.$emit('help-toggle');
|
||||
loadBufferClick() {
|
||||
this.showPasteText();
|
||||
}
|
||||
|
||||
loadBuffer(opts) {
|
||||
if (opts.buffer.length) {
|
||||
const file = new File([opts.buffer], `paste_from_clipboard_#${utils.randomHexString(10)}`);
|
||||
this.$emit('load-file', {file});
|
||||
}
|
||||
}
|
||||
|
||||
showPasteText() {
|
||||
this.pasteTextActive = true;
|
||||
}
|
||||
|
||||
pasteTextToggle() {
|
||||
this.pasteTextActive = !this.pasteTextActive;
|
||||
}
|
||||
|
||||
openHelp(event) {
|
||||
this.$emit('do-action', {action: 'help', event});
|
||||
}
|
||||
|
||||
openDonate() {
|
||||
this.$emit('donate-toggle');
|
||||
this.$emit('do-action', {action: 'donate'});
|
||||
}
|
||||
|
||||
openComments() {
|
||||
window.open('http://samlib.ru/comment/b/bookpauk/bookpauk_reader', '_blank');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
//недостатки сторонних ui
|
||||
const input = this.$refs.input.$refs.input;
|
||||
if (document.activeElement === input && event.type == 'keydown' && event.code == 'Enter') {
|
||||
openOldVersion() {
|
||||
window.open('http://old.omnireader.ru', '_blank');
|
||||
}
|
||||
|
||||
async onInputKeydown(event) {
|
||||
if (event.key == 'Enter') {
|
||||
await utils.sleep(100);
|
||||
this.submitUrl();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type == 'keydown' && (event.code == 'F1' || (document.activeElement !== input && event.code == 'KeyH'))) {
|
||||
this.$emit('help-toggle');
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
keyHook(event) {
|
||||
if (this.pasteTextActive) {
|
||||
return this.$refs.pasteTextPage.keyHook(event);
|
||||
}
|
||||
|
||||
const input = this.$refs.input.getNativeElement();
|
||||
if (event.type == 'keydown' && (document.activeElement === input || event.code == 'Enter') && event.code != 'Escape')
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(LoaderPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
<style scoped>
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.part {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 130%;
|
||||
line-height: 170%;
|
||||
}
|
||||
|
||||
.bold-font {
|
||||
font-weight: bold;
|
||||
font-size: 120%;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
color: blue;
|
||||
color: var(--text-anchor-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: flex-start;
|
||||
padding: 0 10px 0 10px;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bottom-span {
|
||||
font-size: 70%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.space {
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<Window @close="close">
|
||||
<template #header>
|
||||
<span style="position: relative; top: -3px">
|
||||
Вставьте текст и нажмите
|
||||
<span class="clickable text-primary" style="font-size: 150%; position: relative; top: 1px" @click="loadBuffer">загрузить</span>
|
||||
или F2
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="fit column main">
|
||||
<q-input v-model="bookTitle" class="q-px-sm" dense borderless placeholder="Введите название текста" />
|
||||
<hr />
|
||||
<textarea ref="textArea" class="main text" @paste="calcTitle"></textarea>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
import Window from '../../../share/Window.vue';
|
||||
import _ from 'lodash';
|
||||
import * as utils from '../../../../share/utils';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
};
|
||||
class PasteTextPage {
|
||||
_options = componentOptions;
|
||||
|
||||
bookTitle = '';
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.$refs.textArea.focus();
|
||||
}
|
||||
|
||||
get dark() {
|
||||
return this.$store.state.reader.settings.nightMode;
|
||||
}
|
||||
|
||||
getNonEmptyLine3words(text, count) {
|
||||
let result = '';
|
||||
const lines = text.split("\n");
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
if (lines[i].trim() != '') {
|
||||
count--;
|
||||
if (count <= 0) {
|
||||
result = lines[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
result = result.trim().split(' ');
|
||||
return result.slice(0, 3).join(' ');
|
||||
}
|
||||
|
||||
calcTitle(event) {
|
||||
if (this.bookTitle == '') {
|
||||
this.bookTitle = `Из буфера обмена ${utils.dateFormat(new Date())}`;
|
||||
if (event) {
|
||||
let text = event.clipboardData.getData('text');
|
||||
this.bookTitle += ': ' + _.compact([
|
||||
this.getNonEmptyLine3words(text, 1),
|
||||
this.getNonEmptyLine3words(text, 2)
|
||||
]).join(' - ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadBuffer() {
|
||||
this.calcTitle();
|
||||
this.$emit('load-buffer', {buffer: `<buffer><fb2-title>${utils.escapeXml(this.bookTitle)}</fb2-title>${utils.escapeXml(this.$refs.textArea.value)}</buffer>`});
|
||||
this.close();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('paste-text-toggle');
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown') {
|
||||
switch (event.key) {
|
||||
case 'F2':
|
||||
this.loadBuffer();
|
||||
break;
|
||||
case 'Escape':
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(PasteTextPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 0 10px;
|
||||
position: relative;
|
||||
font-size: 120%;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.text:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
color: var(--text-app-color);
|
||||
background-color: var(--bg-app-color);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
color: blue;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,21 +1,39 @@
|
||||
<template>
|
||||
<div v-show="visible" class="main">
|
||||
<div class="center">
|
||||
<el-progress type="circle" :width="100" :stroke-width="6" color="#0F9900" :percentage="percentage"></el-progress>
|
||||
<p class="text">{{ text }}</p>
|
||||
<div v-show="visible" class="column justify-center items-center" style="background-color: rgba(0, 0, 0, 0.8); z-index: 100;">
|
||||
<div class="column justify-start items-center" style="height: 250px">
|
||||
<q-circular-progress
|
||||
show-value
|
||||
instant-feedback
|
||||
font-size="13px"
|
||||
:value="percentage"
|
||||
size="100px"
|
||||
:thickness="0.11"
|
||||
color="green-7"
|
||||
track-color="grey-4"
|
||||
class="q-ma-md"
|
||||
>
|
||||
<span class="text-yellow">{{ percentage }}%</span>
|
||||
</q-circular-progress>
|
||||
|
||||
<div>
|
||||
<span class="text-yellow">{{ text }}</span>
|
||||
<q-icon :style="iconStyle" color="yellow" name="la la-slash" size="20px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import * as utils from '../../../share/utils';
|
||||
|
||||
const ruMessage = {
|
||||
'start': ' ',
|
||||
'finish': ' ',
|
||||
'error': ' ',
|
||||
'queue': 'очередь',
|
||||
'download': 'скачивание',
|
||||
'decompress': 'распаковка',
|
||||
'convert': 'конвертирование',
|
||||
@@ -24,76 +42,59 @@ const ruMessage = {
|
||||
'upload': 'отправка',
|
||||
};
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class ProgressPage extends Vue {
|
||||
class ProgressPage {
|
||||
text = '';
|
||||
totalSteps = 1;
|
||||
step = 1;
|
||||
progress = 0;
|
||||
visible = false;
|
||||
iconStyle = '';
|
||||
|
||||
show() {
|
||||
this.$el.style.width = this.$parent.$el.offsetWidth + 'px';
|
||||
this.$el.style.height = this.$parent.$el.offsetHeight + 'px';
|
||||
this.text = '';
|
||||
this.totalSteps = 1;
|
||||
this.step = 1;
|
||||
this.progress = 0;
|
||||
this.iconAngle = 0;
|
||||
this.ani = false;
|
||||
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.visible = false;
|
||||
this.text = '';
|
||||
this.iconAngle = 0;
|
||||
}
|
||||
|
||||
setState(state) {
|
||||
if (state.state)
|
||||
this.text = (ruMessage[state.state] ? ruMessage[state.state] : state.state);
|
||||
if (state.state) {
|
||||
if (state.state == 'queue') {
|
||||
this.text = (state.place ? 'Номер в очереди: ' + state.place : '');
|
||||
} else {
|
||||
this.text = (ruMessage[state.state] ? ruMessage[state.state] : state.state);
|
||||
}
|
||||
}
|
||||
this.step = (state.step ? state.step : this.step);
|
||||
this.totalSteps = (state.totalSteps > this.totalSteps ? state.totalSteps : this.totalSteps);
|
||||
this.progress = state.progress || 0;
|
||||
|
||||
if (!this.ani) {
|
||||
(async() => {
|
||||
this.ani = true;
|
||||
this.iconAngle += 30;
|
||||
this.iconStyle = `transform: rotate(${this.iconAngle}deg); transition: 150ms linear`;
|
||||
await utils.sleep(150);
|
||||
this.ani = false;
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
get percentage() {
|
||||
let circle = document.querySelector('path[class="el-progress-circle__path"]');
|
||||
if (circle)
|
||||
circle.style.transition = '';
|
||||
return Math.round(((this.step - 1)/this.totalSteps + this.progress/(100*this.totalSteps))*100);
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ProgressPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
<style scoped>
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
z-index: 100;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
|
||||
position: absolute;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
color: white;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
</style>
|
||||
<style>
|
||||
.el-progress__text {
|
||||
color: lightgreen;
|
||||
}
|
||||
</style>
|
||||
|
||||
246
client/components/Reader/ReaderDialogs/ReaderDialogs.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div>
|
||||
<Dialog ref="dialog1" v-model="whatsNewVisible">
|
||||
<template #header>
|
||||
Что нового:
|
||||
</template>
|
||||
|
||||
<div style="line-height: 20px; min-width: 300px">
|
||||
<div v-html="whatsNewContent"></div>
|
||||
</div>
|
||||
|
||||
<span class="clickable" style="font-size: 13px" @click="openVersionHistory">Посмотреть историю версий</span>
|
||||
|
||||
<template #footer>
|
||||
<q-btn class="q-px-md" color="btn2" text-color="app" dense no-caps @click="whatsNewDisable">
|
||||
Больше не показывать
|
||||
</q-btn>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<q-dialog ref="dialog2" v-model="donationVisible" style="z-index: 100" no-route-dismiss no-esc-dismiss no-backdrop-dismiss>
|
||||
<div class="column bg-dialog no-wrap q-pa-md">
|
||||
<div class="row justify-center q-mb-md">
|
||||
Здравствуйте, дорогие читатели!
|
||||
</div>
|
||||
|
||||
<div class="q-mx-md column" style="font-size: 90%; word-break: normal">
|
||||
<div>
|
||||
Вот уже много лет мы все вместе пользуемся нашей любимой читалкой.<br><br>
|
||||
|
||||
Напоминаем вам, что проект является некоммерческим и обладает такими
|
||||
достоинствами, как:
|
||||
|
||||
<ul>
|
||||
<li>все функции читалки открыты и доступны совершенно бесплатно</li>
|
||||
<li>в проекте отсутствует какая-либо реклама или баннеры</li>
|
||||
<li>нет никакой регистрации и монетизации</li>
|
||||
<li>нет сбора персональных данных</li>
|
||||
<li>открытый исходный код</li>
|
||||
<li>проект постепенно улучшается, по мере возможности</li>
|
||||
</ul>
|
||||
|
||||
Однако на оплату хостинга читалки и сервера обновлений автор тратит свои
|
||||
собственные средства, а также тратит свое время и силы на улучшение проекта.
|
||||
<br><br>
|
||||
Давайте поддержим наш ресурс, чтобы и дальше спокойно существовать и развиваться:
|
||||
</div>
|
||||
|
||||
<q-btn style="margin: 10px 20px 10px 20px" color="green-8" no-caps @click="makeDonation">
|
||||
<q-icon class="q-mr-xs" name="la la-donate" size="24px" />
|
||||
Поддержать проект
|
||||
</q-btn>
|
||||
|
||||
<div class="row justify-center q-mt-sm">
|
||||
Напомнить снова через:
|
||||
</div>
|
||||
|
||||
<div class="row justify-between" style="margin: 0 20px 10px 20px">
|
||||
<q-btn style="width: 140px; margin-top: 5px" no-caps @click="donationDialogRemindLater(30)">
|
||||
1 месяц
|
||||
</q-btn>
|
||||
<q-btn style="width: 140px; margin-top: 5px" no-caps @click="donationDialogRemindLater(60)">
|
||||
2 месяца
|
||||
</q-btn>
|
||||
<q-btn style="width: 140px; margin-top: 5px" no-caps @click="donationDialogRemindLater(90)">
|
||||
3 месяца
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<div class="row justify-center q-mt-md">
|
||||
<div class="q-px-sm clickable" style="font-size: 80%" @click="openDonate">
|
||||
Помочь проекту можно в любое время
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-dialog>
|
||||
|
||||
<Dialog ref="dialog3" v-model="urlHelpVisible">
|
||||
<template #header>
|
||||
Обнаружена невалидная ссылка в поле "URL книги".
|
||||
<br>
|
||||
</template>
|
||||
|
||||
<div style="word-break: normal">
|
||||
Если вы пытаетесь вставить текст в читалку из буфера обмена, пожалуйста воспользуйтесь кнопкой
|
||||
<q-btn no-caps dense class="q-px-sm" color="btn1" size="13px" @click="loadBufferClick">
|
||||
<q-icon class="q-mr-xs" name="la la-comment" size="24px" />
|
||||
Из буфера обмена
|
||||
</q-btn>
|
||||
на странице загрузки.
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import Dialog from '../../share/Dialog.vue';
|
||||
import * as utils from '../../../share/utils';
|
||||
import {versionHistory} from '../versionHistory';
|
||||
import rstore from '../../../store/modules/reader';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Dialog
|
||||
},
|
||||
watch: {
|
||||
settings: function() {
|
||||
this.loadSettings();
|
||||
},
|
||||
},
|
||||
};
|
||||
class ReaderDialogs {
|
||||
_options = componentOptions;
|
||||
|
||||
whatsNewVisible = false;
|
||||
whatsNewContent = '';
|
||||
donationVisible = false;
|
||||
urlHelpVisible = false;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.showWhatsNew();
|
||||
//await this.showDonation();
|
||||
}
|
||||
|
||||
loadSettings() {
|
||||
const settings = this.settings;
|
||||
this.showWhatsNewDialog = settings.showWhatsNewDialog;
|
||||
this.showDonationDialog = settings.showDonationDialog;
|
||||
}
|
||||
|
||||
async showWhatsNew() {
|
||||
const whatsNew = versionHistory[0];
|
||||
if (this.showWhatsNewDialog &&
|
||||
whatsNew.showUntil >= utils.dateFormat(new Date(), 'YYYY-MM-DD') &&
|
||||
this.whatsNewHeader != this.whatsNewContentHash) {
|
||||
await utils.sleep(2000);
|
||||
this.whatsNewContent = 'Версия ' + this.whatsNewHeader + whatsNew.content;
|
||||
this.whatsNewVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
async showDonation() {
|
||||
if ((this.mode == 'omnireader' || this.mode == 'liberama') && this.showDonationDialog && this.donationNextPopup <= Date.now()) {
|
||||
await utils.sleep(3000);
|
||||
this.donationVisible = true;
|
||||
}
|
||||
}
|
||||
|
||||
async showUrlHelp() {
|
||||
this.urlHelpVisible = true;
|
||||
}
|
||||
|
||||
loadBufferClick() {
|
||||
this.$emit('load-buffer-toggle');
|
||||
this.urlHelpVisible = false;
|
||||
}
|
||||
|
||||
donationDialogRemindLater(remindAfter = 30) {
|
||||
this.donationVisible = false;
|
||||
|
||||
this.commit('reader/setDonationNextPopup', Date.now() + rstore.dayMs*remindAfter);
|
||||
}
|
||||
|
||||
makeDonation() {
|
||||
utils.makeDonation();
|
||||
this.donationDialogRemindLater();
|
||||
}
|
||||
|
||||
openDonate() {
|
||||
this.$emit('donate-toggle');
|
||||
}
|
||||
|
||||
async copyLink(link) {
|
||||
const result = await utils.copyTextToClipboard(link);
|
||||
if (result)
|
||||
this.$root.notify.success(`Ссылка ${link} успешно скопирована в буфер обмена`);
|
||||
else
|
||||
this.$root.notify.error('Копирование не удалось');
|
||||
}
|
||||
|
||||
openVersionHistory() {
|
||||
this.whatsNewVisible = false;
|
||||
this.$emit('version-history-toggle');
|
||||
}
|
||||
|
||||
whatsNewDisable() {
|
||||
this.whatsNewVisible = false;
|
||||
this.commit('reader/setWhatsNewContentHash', this.whatsNewHeader);
|
||||
}
|
||||
|
||||
get whatsNewHeader() {
|
||||
return `${versionHistory[0].version} (${versionHistory[0].releaseDate})`;
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.$store.state.reader.settings;
|
||||
}
|
||||
|
||||
get whatsNewContentHash() {
|
||||
return this.$store.state.reader.whatsNewContentHash;
|
||||
}
|
||||
|
||||
get donationNextPopup() {
|
||||
return this.$store.state.reader.donationNextPopup;
|
||||
}
|
||||
|
||||
keyHook() {
|
||||
if (this.$refs.dialog1.active || this.$refs.dialog2.active || this.$refs.dialog3.active)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ReaderDialogs);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.clickable {
|
||||
color: var(--text-anchor-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
cursor: pointer;
|
||||
font-size: 120%;
|
||||
color: blue;
|
||||
}
|
||||
</style>
|
||||
1009
client/components/Reader/RecentBooksPage/RecentBooksPage.vue
Normal file
@@ -1,39 +1,44 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
{{ header }}
|
||||
</template>
|
||||
<Window ref="window" height="125px" max-width="600px" :top-shift="-50" @close="close">
|
||||
<template #header>
|
||||
{{ header }}
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<span v-show="initStep">{{ initPercentage }}%</span>
|
||||
<div class="content">
|
||||
<span v-show="initStep">{{ initPercentage }}%</span>
|
||||
|
||||
<div v-show="!initStep" class="input">
|
||||
<input ref="input" class="el-input__inner"
|
||||
placeholder="что ищем"
|
||||
:value="needle" @input="needle = $event.target.value"/>
|
||||
<div style="position: absolute; right: 10px; margin-top: 10px; font-size: 16px;">{{ foundText }}</div>
|
||||
</div>
|
||||
<el-button-group v-show="!initStep" class="button-group">
|
||||
<el-button @click="showNext"><i class="el-icon-arrow-down"></i></el-button>
|
||||
<el-button @click="showPrev"><i class="el-icon-arrow-up"></i></el-button>
|
||||
</el-button-group>
|
||||
<div v-show="!initStep" class="input">
|
||||
<q-input
|
||||
ref="input" v-model="needle"
|
||||
class="col" outlined dense
|
||||
bg-color="input"
|
||||
placeholder="Найти"
|
||||
@keydown="inputKeyDown"
|
||||
/>
|
||||
<div style="position: absolute; right: 10px; margin-top: 10px; font-size: 16px;">
|
||||
{{ foundText }}
|
||||
</div>
|
||||
</Window>
|
||||
</div>
|
||||
<q-btn-group v-show="!initStep" class="button-group row no-wrap">
|
||||
<q-btn class="button" dense stretch @click="showNext">
|
||||
<q-icon style="top: -2px" name="la la-angle-down" dense size="22px" />
|
||||
</q-btn>
|
||||
<q-btn class="button" dense stretch @click="showPrev">
|
||||
<q-icon name="la la-angle-up" dense size="22px" />
|
||||
</q-btn>
|
||||
</q-btn-group>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
import {sleep} from '../../../share/utils';
|
||||
|
||||
export default @Component({
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
@@ -43,11 +48,16 @@ export default @Component({
|
||||
|
||||
},
|
||||
foundText: function(newValue) {
|
||||
this.$refs.input.style.paddingRight = (10 + newValue.length*12) + 'px';
|
||||
//недостатки сторонних ui
|
||||
const el = this.$refs.input.$el.querySelector('label div div div input');
|
||||
if (el)
|
||||
el.style.paddingRight = newValue.length*12 + 'px';
|
||||
},
|
||||
},
|
||||
})
|
||||
class SearchPage extends Vue {
|
||||
};
|
||||
class SearchPage {
|
||||
_options = componentOptions;
|
||||
|
||||
header = null;
|
||||
initStep = null;
|
||||
initPercentage = 0;
|
||||
@@ -61,6 +71,8 @@ class SearchPage extends Vue {
|
||||
}
|
||||
|
||||
async init(parsed) {
|
||||
this.$refs.window.init();
|
||||
|
||||
if (this.parsed != parsed) {
|
||||
this.initStep = true;
|
||||
this.stopInit = false;
|
||||
@@ -95,12 +107,17 @@ class SearchPage extends Vue {
|
||||
this.parsed = parsed;
|
||||
}
|
||||
|
||||
this.header = 'Найти';
|
||||
this.header = 'Поиск в тексте';
|
||||
await this.$nextTick();
|
||||
this.$refs.input.focus();
|
||||
this.focusInput();
|
||||
this.$refs.input.select();
|
||||
}
|
||||
|
||||
focusInput() {
|
||||
if (!this.$root.isMobileDevice)
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
|
||||
get foundText() {
|
||||
if (this.foundList.length && this.foundCur >= 0)
|
||||
return `${this.foundCur + 1}/${this.foundList.length}`;
|
||||
@@ -138,7 +155,8 @@ class SearchPage extends Vue {
|
||||
} else {
|
||||
this.$emit('stop-text-search');
|
||||
}
|
||||
this.$refs.input.focus();
|
||||
|
||||
this.focusInput();
|
||||
}
|
||||
|
||||
showPrev() {
|
||||
@@ -154,56 +172,41 @@ class SearchPage extends Vue {
|
||||
} else {
|
||||
this.$emit('stop-text-search');
|
||||
}
|
||||
this.$refs.input.focus();
|
||||
|
||||
this.focusInput();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.stopInit = true;
|
||||
this.$emit('search-toggle');
|
||||
this.$emit('do-action', {action: 'search'});
|
||||
}
|
||||
|
||||
inputKeyDown(event) {
|
||||
if (event.key == 'Enter') {
|
||||
this.showNext();
|
||||
}
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
//недостатки сторонних ui
|
||||
if (document.activeElement === this.$refs.input && event.type == 'keydown' && event.key == 'Enter') {
|
||||
this.showNext();
|
||||
}
|
||||
|
||||
if (event.type == 'keydown' && (event.code == 'Escape')) {
|
||||
if (event.type == 'keydown' && event.key == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(SearchPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
height: 125px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
top: -50px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
min-width: 430px;
|
||||
}
|
||||
|
||||
.input {
|
||||
@@ -215,16 +218,14 @@ class SearchPage extends Vue {
|
||||
}
|
||||
|
||||
.button-group {
|
||||
width: 150px;
|
||||
width: 100px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 37px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
.button {
|
||||
padding: 9px 17px 9px 17px;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
width: 50px;
|
||||
}
|
||||
</style>
|
||||
772
client/components/Reader/ServerStorage/ServerStorage.vue
Normal file
@@ -0,0 +1,772 @@
|
||||
<template>
|
||||
<div class="hidden"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
import bookManager from '../share/bookManager';
|
||||
import readerApi from '../../../api/reader';
|
||||
import * as utils from '../../../share/utils';
|
||||
import * as cryptoUtils from '../../../share/cryptoUtils';
|
||||
import LockQueue from '../../../share/LockQueue';
|
||||
|
||||
import localForage from 'localforage';
|
||||
const ssCacheStore = localForage.createInstance({
|
||||
name: 'ssCacheStore'
|
||||
});
|
||||
|
||||
const componentOptions = {
|
||||
watch: {
|
||||
serverSyncEnabled: function() {
|
||||
if (this.inited)
|
||||
this.serverSyncEnabledChanged();
|
||||
},
|
||||
serverStorageKey: function() {
|
||||
if (this.inited)
|
||||
this.serverStorageKeyChanged(true);
|
||||
},
|
||||
settings: function() {
|
||||
this.debouncedSaveSettings();
|
||||
},
|
||||
profiles: function() {
|
||||
this.saveProfiles();
|
||||
},
|
||||
currentProfile: function() {
|
||||
this.currentProfileChanged(true);
|
||||
},
|
||||
libs: function() {
|
||||
this.debouncedSaveLibs();
|
||||
},
|
||||
},
|
||||
};
|
||||
class ServerStorage {
|
||||
_options = componentOptions;
|
||||
|
||||
created() {
|
||||
this.inited = false;
|
||||
this.keyInited = false;
|
||||
this.commit = this.$store.commit;
|
||||
this.prevServerStorageKey = null;
|
||||
this.identity = utils.randomHexString(20);
|
||||
this.lock = new LockQueue(100);
|
||||
|
||||
this.$root.generateNewServerStorageKey = () => {this.generateNewServerStorageKey()};
|
||||
|
||||
this.debouncedSaveSettings = _.debounce(() => {
|
||||
this.saveSettings();
|
||||
}, 500);
|
||||
|
||||
this.debouncedSaveLibs = _.debounce(() => {
|
||||
this.saveLibs();
|
||||
}, 500);
|
||||
|
||||
this.debouncedNotifySuccess = _.debounce(() => {
|
||||
this.success('Данные синхронизированы с сервером');
|
||||
}, 1000);
|
||||
|
||||
this.oldProfiles = {};
|
||||
this.oldSettings = {};
|
||||
this.oldLibs = {};
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.cachedRecent = await ssCacheStore.getItem('recent');
|
||||
if (!this.cachedRecent)
|
||||
await this.cleanCachedRecent('cachedRecent');
|
||||
|
||||
this.cachedRecentPatch = await ssCacheStore.getItem('recent-patch');
|
||||
if (!this.cachedRecentPatch)
|
||||
await this.cleanCachedRecent('cachedRecentPatch');
|
||||
|
||||
this.cachedRecentMod = await ssCacheStore.getItem('recent-mod');
|
||||
if (!this.cachedRecentMod)
|
||||
await this.cleanCachedRecent('cachedRecentMod');
|
||||
|
||||
//подстраховка хранения ключа, восстановим из IndexedDB при проблемах в localStorage
|
||||
if (!this.serverStorageKey) {
|
||||
const key = await ssCacheStore.getItem('storageKey');
|
||||
if (key)
|
||||
this.commit('reader/setServerStorageKey', key);
|
||||
}
|
||||
|
||||
if (!this.serverStorageKey) {
|
||||
//генерируем новый ключ
|
||||
await this.generateNewServerStorageKey();
|
||||
} else {
|
||||
await this.serverStorageKeyChanged();
|
||||
}
|
||||
} finally {
|
||||
this.inited = true;
|
||||
}
|
||||
}
|
||||
|
||||
async setCachedRecent(value) {
|
||||
await ssCacheStore.setItem('recent', value);
|
||||
this.cachedRecent = value;
|
||||
}
|
||||
|
||||
async setCachedRecentPatch(value) {
|
||||
await ssCacheStore.setItem('recent-patch', value);
|
||||
this.cachedRecentPatch = value;
|
||||
}
|
||||
|
||||
async setCachedRecentMod(value) {
|
||||
await ssCacheStore.setItem('recent-mod', value);
|
||||
this.cachedRecentMod = value;
|
||||
}
|
||||
|
||||
async cleanCachedRecent(whatToClean) {
|
||||
if (whatToClean == 'cachedRecent' || whatToClean == 'all')
|
||||
await this.setCachedRecent({rev: 0, data: {}});
|
||||
if (whatToClean == 'cachedRecentPatch' || whatToClean == 'all')
|
||||
await this.setCachedRecentPatch({rev: 0, data: {}});
|
||||
if (whatToClean == 'cachedRecentMod' || whatToClean == 'all')
|
||||
await this.setCachedRecentMod({rev: 0, data: {}});
|
||||
}
|
||||
|
||||
async generateNewServerStorageKey() {
|
||||
const key = utils.toBase58(utils.randomArray(32));
|
||||
this.commit('reader/setServerStorageKey', key);
|
||||
//дождемся serverStorageKeyChanged, событие по watch не работает при this.inited == false
|
||||
await this.serverStorageKeyChanged(true);
|
||||
}
|
||||
|
||||
async serverSyncEnabledChanged() {
|
||||
if (this.serverSyncEnabled) {
|
||||
this.prevServerStorageKey = null;
|
||||
if (!this.serverStorageKey) {
|
||||
//генерируем новый ключ
|
||||
await this.generateNewServerStorageKey();
|
||||
} else {
|
||||
await this.serverStorageKeyChanged(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async serverStorageKeyChanged(force) {
|
||||
if (this.prevServerStorageKey != this.serverStorageKey) {
|
||||
this.prevServerStorageKey = this.serverStorageKey;
|
||||
|
||||
//сохраним ключ также в IndexedDB, чтобы была возможность восстановить при проблемах с localStorage
|
||||
await ssCacheStore.setItem('storageKey', this.serverStorageKey);
|
||||
|
||||
this.hashedStorageKey = utils.toBase58(cryptoUtils.sha256(this.serverStorageKey));
|
||||
this.keyInited = true;
|
||||
|
||||
await this.loadProfiles(force);
|
||||
this.checkCurrentProfile();
|
||||
await this.currentProfileChanged(force);
|
||||
await this.loadLibs(force);
|
||||
|
||||
if (force)
|
||||
await this.cleanCachedRecent('all');
|
||||
const loadSuccess = await this.loadRecent();
|
||||
if (loadSuccess && force) {
|
||||
await this.saveRecent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async currentProfileChanged(force) {
|
||||
if (!this.currentProfile)
|
||||
return;
|
||||
|
||||
await this.loadSettings(force);
|
||||
}
|
||||
|
||||
get serverSyncEnabled() {
|
||||
return this.$store.state.reader.serverSyncEnabled;
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.$store.state.reader.settings;
|
||||
}
|
||||
|
||||
get settingsRev() {
|
||||
return this.$store.state.reader.settingsRev;
|
||||
}
|
||||
|
||||
get serverStorageKey() {
|
||||
return this.$store.state.reader.serverStorageKey;
|
||||
}
|
||||
|
||||
get profiles() {
|
||||
return this.$store.state.reader.profiles;
|
||||
}
|
||||
|
||||
get profilesRev() {
|
||||
return this.$store.state.reader.profilesRev;
|
||||
}
|
||||
|
||||
get currentProfile() {
|
||||
return this.$store.state.reader.currentProfile;
|
||||
}
|
||||
|
||||
get showServerStorageMessages() {
|
||||
return this.settings.showServerStorageMessages;
|
||||
}
|
||||
|
||||
get libs() {
|
||||
return this.$store.state.reader.libs;
|
||||
}
|
||||
|
||||
get libsRev() {
|
||||
return this.$store.state.reader.libsRev;
|
||||
}
|
||||
|
||||
get offlineModeActive() {
|
||||
return this.$store.state.reader.offlineModeActive;
|
||||
}
|
||||
|
||||
checkCurrentProfile() {
|
||||
if (!this.profiles[this.currentProfile]) {
|
||||
this.commit('reader/setCurrentProfile', '');
|
||||
}
|
||||
}
|
||||
|
||||
success(message) {
|
||||
if (this.showServerStorageMessages)
|
||||
this.$root.notify.success(message);
|
||||
}
|
||||
|
||||
warning(message) {
|
||||
if (this.showServerStorageMessages && !this.offlineModeActive)
|
||||
this.$root.notify.warning(message);
|
||||
}
|
||||
|
||||
error(message) {
|
||||
if (this.showServerStorageMessages && !this.offlineModeActive) {
|
||||
this.errorMessageCounter = (this.errorMessageCounter ? this.errorMessageCounter + 1 : 1);
|
||||
const hint = (this.errorMessageCounter < 2 ? '' :
|
||||
'<div><br>Надоело это сообщение? Добавьте в настройках кнопку "Автономный режим" ' +
|
||||
'<i class="la la-unlink" style="font-size: 20px; color: white"></i> на панель инструментов и активируйте ее.</div>'
|
||||
);
|
||||
|
||||
this.$root.notify.error(message + hint);
|
||||
}
|
||||
}
|
||||
|
||||
async loadSettings(force = false, doNotifySuccess = true) {
|
||||
if (!this.keyInited || !this.serverSyncEnabled || !this.currentProfile)
|
||||
return;
|
||||
|
||||
const setsId = `settings-${this.currentProfile}`;
|
||||
const oldRev = this.settingsRev[setsId] || 0;
|
||||
//проверим ревизию на сервере
|
||||
if (!force) {
|
||||
try {
|
||||
const revs = await this.storageCheck({[setsId]: {}});
|
||||
if (revs.state == 'success' && revs.items[setsId].rev == oldRev) {
|
||||
return;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let sets = null;
|
||||
try {
|
||||
sets = await this.storageGet({[setsId]: {}});
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sets.state == 'success') {
|
||||
sets = sets.items[setsId];
|
||||
|
||||
if (sets.rev == 0)
|
||||
sets.data = {};
|
||||
|
||||
this.oldSettings = _.cloneDeep(sets.data);
|
||||
this.commit('reader/setSettings', sets.data);
|
||||
this.commit('reader/setSettingsRev', {[setsId]: sets.rev});
|
||||
|
||||
if (doNotifySuccess)
|
||||
this.debouncedNotifySuccess();
|
||||
} else {
|
||||
this.warning(`Неверный ответ сервера: ${sets.state}`);
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
if (!this.keyInited || !this.serverSyncEnabled || !this.currentProfile || this.savingSettings)
|
||||
return;
|
||||
|
||||
const diff = utils.getObjDiff(this.oldSettings, this.settings);
|
||||
if (utils.isEmptyObjDiff(diff))
|
||||
return;
|
||||
|
||||
this.savingSettings = true;
|
||||
try {
|
||||
const setsId = `settings-${this.currentProfile}`;
|
||||
let result = {state: ''};
|
||||
|
||||
const oldRev = this.settingsRev[setsId] || 0;
|
||||
try {
|
||||
result = await this.storageSet({[setsId]: {rev: oldRev + 1, data: this.settings}});
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
|
||||
}
|
||||
|
||||
if (result.state == 'reject') {
|
||||
await this.loadSettings(true, false);
|
||||
this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
|
||||
} else if (result.state == 'success') {
|
||||
this.oldSettings = _.cloneDeep(this.settings);
|
||||
this.commit('reader/setSettingsRev', {[setsId]: this.settingsRev[setsId] + 1});
|
||||
}
|
||||
} finally {
|
||||
this.savingSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadProfiles(force = false, doNotifySuccess = true) {
|
||||
if (!this.keyInited || !this.serverSyncEnabled)
|
||||
return;
|
||||
|
||||
const oldRev = this.profilesRev;
|
||||
//проверим ревизию на сервере
|
||||
if (!force) {
|
||||
try {
|
||||
const revs = await this.storageCheck({profiles: {}});
|
||||
if (revs.state == 'success' && revs.items.profiles.rev == oldRev) {
|
||||
return;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let prof = null;
|
||||
try {
|
||||
prof = await this.storageGet({profiles: {}});
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (prof.state == 'success') {
|
||||
prof = prof.items.profiles;
|
||||
|
||||
if (prof.rev == 0)
|
||||
prof.data = {};
|
||||
|
||||
this.oldProfiles = _.cloneDeep(prof.data);
|
||||
this.commit('reader/setProfiles', prof.data);
|
||||
this.commit('reader/setProfilesRev', prof.rev);
|
||||
this.checkCurrentProfile();
|
||||
|
||||
if (doNotifySuccess)
|
||||
this.debouncedNotifySuccess();
|
||||
} else {
|
||||
this.warning(`Неверный ответ сервера: ${prof.state}`);
|
||||
}
|
||||
}
|
||||
|
||||
async saveProfiles() {
|
||||
if (!this.keyInited || !this.serverSyncEnabled || this.savingProfiles)
|
||||
return;
|
||||
|
||||
const diff = utils.getObjDiff(this.oldProfiles, this.profiles);
|
||||
if (utils.isEmptyObjDiff(diff))
|
||||
return;
|
||||
|
||||
//обнуляются профили во время разработки при hotReload, подстраховка
|
||||
if (!this.$store.state.reader.allowProfilesSave) {
|
||||
console.error('Сохранение профилей не санкционировано');
|
||||
return;
|
||||
}
|
||||
|
||||
this.savingProfiles = true;
|
||||
try {
|
||||
let result = {state: ''};
|
||||
try {
|
||||
result = await this.storageSet({profiles: {rev: this.profilesRev + 1, data: this.profiles}});
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
|
||||
}
|
||||
|
||||
if (result.state == 'reject') {
|
||||
await this.loadProfiles(true, false);
|
||||
this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
|
||||
} else if (result.state == 'success') {
|
||||
this.oldProfiles = _.cloneDeep(this.profiles);
|
||||
this.commit('reader/setProfilesRev', this.profilesRev + 1);
|
||||
}
|
||||
} finally {
|
||||
this.savingProfiles = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadLibs(force = false, doNotifySuccess = true) {
|
||||
if (!this.keyInited || !this.serverSyncEnabled)
|
||||
return;
|
||||
|
||||
const oldRev = this.libsRev;
|
||||
//проверим ревизию на сервере
|
||||
if (!force) {
|
||||
try {
|
||||
const revs = await this.storageCheck({libs: {}});
|
||||
if (revs.state == 'success' && revs.items.libs.rev == oldRev) {
|
||||
return;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let libs = null;
|
||||
try {
|
||||
libs = await this.storageGet({libs: {}});
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (libs.state == 'success') {
|
||||
libs = libs.items.libs;
|
||||
|
||||
if (libs.rev == 0)
|
||||
libs.data = {};
|
||||
|
||||
this.oldLibs = _.cloneDeep(libs.data);
|
||||
this.commit('reader/setLibs', libs.data);
|
||||
this.commit('reader/setLibsRev', libs.rev);
|
||||
|
||||
if (doNotifySuccess)
|
||||
this.debouncedNotifySuccess();
|
||||
} else {
|
||||
this.warning(`Неверный ответ сервера: ${libs.state}`);
|
||||
}
|
||||
}
|
||||
|
||||
async saveLibs() {
|
||||
if (!this.keyInited || !this.serverSyncEnabled || this.savingLibs)
|
||||
return;
|
||||
|
||||
const diff = utils.getObjDiff(this.oldLibs, this.libs);
|
||||
if (utils.isEmptyObjDiff(diff))
|
||||
return;
|
||||
|
||||
this.savingLibs = true;
|
||||
try {
|
||||
let result = {state: ''};
|
||||
try {
|
||||
result = await this.storageSet({libs: {rev: this.libsRev + 1, data: this.libs}});
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
|
||||
}
|
||||
|
||||
if (result.state == 'reject') {
|
||||
await this.loadLibs(true, false);
|
||||
this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
|
||||
} else if (result.state == 'success') {
|
||||
this.oldLibs = _.cloneDeep(this.libs);
|
||||
this.commit('reader/setLibsRev', this.libsRev + 1);
|
||||
}
|
||||
} finally {
|
||||
this.savingLibs = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadRecent(skipRevCheck = false, doNotifySuccess = true) {
|
||||
if (!this.keyInited || !this.serverSyncEnabled || this.loadingRecent)
|
||||
return;
|
||||
this.loadingRecent = true;
|
||||
try {
|
||||
//проверим ревизию на сервере
|
||||
let query = {recent: {}, recentPatch: {}, recentMod: {}};
|
||||
let revs = null;
|
||||
if (!skipRevCheck) {
|
||||
try {
|
||||
revs = await this.storageCheck(query);
|
||||
if (revs.state == 'success') {
|
||||
if (revs.items.recent.rev != this.cachedRecent.rev) {
|
||||
//no changes
|
||||
} else if (revs.items.recentPatch.rev != this.cachedRecentPatch.rev) {
|
||||
query = {recentPatch: {}, recentMod: {}};
|
||||
} else if (revs.items.recentMod.rev != this.cachedRecentMod.rev) {
|
||||
query = {recentMod: {}};
|
||||
} else
|
||||
return true;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let recent = null;
|
||||
try {
|
||||
recent = await this.storageGet(query);
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (recent.state == 'success') {
|
||||
let newRecent = recent.items.recent;
|
||||
let newRecentPatch = recent.items.recentPatch;
|
||||
let newRecentMod = recent.items.recentMod;
|
||||
|
||||
if (!newRecent) {
|
||||
newRecent = _.cloneDeep(this.cachedRecent);
|
||||
}
|
||||
if (!newRecentPatch) {
|
||||
newRecentPatch = _.cloneDeep(this.cachedRecentPatch);
|
||||
}
|
||||
if (!newRecentMod) {
|
||||
newRecentMod = _.cloneDeep(this.cachedRecentMod);
|
||||
}
|
||||
|
||||
if (newRecent.rev == 0) newRecent.data = {};
|
||||
if (newRecentPatch.rev == 0) newRecentPatch.data = {};
|
||||
if (newRecentMod.rev == 0) newRecentMod.data = {};
|
||||
|
||||
let result = Object.assign({}, newRecent.data, newRecentPatch.data);
|
||||
|
||||
const md = newRecentMod.data;
|
||||
if (md.key && result[md.key])
|
||||
result[md.key] = utils.applyObjDiff(result[md.key], md.mod, {isAddChanged: true});
|
||||
|
||||
/*if (!bookManager.loaded) {
|
||||
this.warning('Ожидание загрузки списка книг перед синхронизацией');
|
||||
while (!bookManager.loaded) await utils.sleep(100);
|
||||
}*/
|
||||
|
||||
if (newRecent.rev != this.cachedRecent.rev)
|
||||
await this.setCachedRecent(newRecent);
|
||||
if (newRecentPatch.rev != this.cachedRecentPatch.rev)
|
||||
await this.setCachedRecentPatch(newRecentPatch);
|
||||
if (newRecentMod.rev != this.cachedRecentMod.rev)
|
||||
await this.setCachedRecentMod(newRecentMod);
|
||||
|
||||
await bookManager.setRecent(result);
|
||||
} else {
|
||||
this.warning(`Неверный ответ сервера: ${recent.state}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (doNotifySuccess)
|
||||
this.debouncedNotifySuccess();
|
||||
} finally {
|
||||
this.loadingRecent = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async saveRecent(itemKeys, recurse) {
|
||||
while (!this.inited)
|
||||
await utils.sleep(100);
|
||||
|
||||
if (!this.keyInited || !this.serverSyncEnabled)
|
||||
return;
|
||||
|
||||
let needRecurseCall = false;
|
||||
|
||||
await this.lock.get();
|
||||
try {
|
||||
const bm = bookManager;
|
||||
|
||||
let needSaveRecent = false;
|
||||
let needSaveRecentPatch = false;
|
||||
let needSaveRecentMod = false;
|
||||
|
||||
//newRecentMod
|
||||
let newRecentMod = {};
|
||||
let oneItemKey = null;
|
||||
if (itemKeys && itemKeys.length == 1)
|
||||
oneItemKey = itemKeys[0];
|
||||
|
||||
if (oneItemKey && this.cachedRecentPatch.data[oneItemKey] && this.prevItemKey == oneItemKey) {
|
||||
newRecentMod = _.cloneDeep(this.cachedRecentMod);
|
||||
newRecentMod.rev++;
|
||||
|
||||
newRecentMod.data.key = oneItemKey;
|
||||
newRecentMod.data.mod = utils.getObjDiff(this.cachedRecentPatch.data[oneItemKey], bm.recent[oneItemKey]);
|
||||
needSaveRecentMod = true;
|
||||
}
|
||||
this.prevItemKey = oneItemKey;
|
||||
|
||||
//newRecentPatch
|
||||
let newRecentPatch = {};
|
||||
if (itemKeys && !needSaveRecentMod) {
|
||||
newRecentPatch = _.cloneDeep(this.cachedRecentPatch);
|
||||
newRecentPatch.rev++;
|
||||
|
||||
for (const key of itemKeys) {
|
||||
newRecentPatch.data[key] = _.cloneDeep(bm.recent[key]);
|
||||
}
|
||||
|
||||
const applyMod = this.cachedRecentMod.data;
|
||||
if (applyMod && applyMod.key && newRecentPatch.data[applyMod.key])
|
||||
newRecentPatch.data[applyMod.key] = utils.applyObjDiff(newRecentPatch.data[applyMod.key], applyMod.mod, {isAddChanged: true});
|
||||
|
||||
newRecentMod = {rev: this.cachedRecentMod.rev + 1, data: {}};
|
||||
needSaveRecentPatch = true;
|
||||
needSaveRecentMod = true;
|
||||
}
|
||||
|
||||
//newRecent
|
||||
let newRecent = {};
|
||||
if (!itemKeys || (needSaveRecentPatch && Object.keys(newRecentPatch.data).length > 10)) {
|
||||
newRecent = {rev: this.cachedRecent.rev + 1, data: _.cloneDeep(bm.recent)};
|
||||
newRecentPatch = {rev: this.cachedRecentPatch.rev + 1, data: {}};
|
||||
newRecentMod = {rev: this.cachedRecentMod.rev + 1, data: {}};
|
||||
needSaveRecent = true;
|
||||
needSaveRecentPatch = true;
|
||||
needSaveRecentMod = true;
|
||||
}
|
||||
|
||||
//query
|
||||
let query = {};
|
||||
if (needSaveRecent) {
|
||||
query = {recent: newRecent, recentPatch: newRecentPatch, recentMod: newRecentMod};
|
||||
} else if (needSaveRecentPatch) {
|
||||
query = {recentPatch: newRecentPatch, recentMod: newRecentMod};
|
||||
} else {
|
||||
query = {recentMod: newRecentMod};
|
||||
}
|
||||
|
||||
//сохранение
|
||||
let result = {state: ''};
|
||||
|
||||
try {
|
||||
result = await this.storageSet(query);
|
||||
} catch(e) {
|
||||
this.error(`Ошибка соединения с сервером (${e.message}). Данные не сохранены и могут быть перезаписаны.`);
|
||||
}
|
||||
|
||||
if (result.state == 'reject') {
|
||||
|
||||
const res = await this.loadRecent(false, false);
|
||||
|
||||
if (res)
|
||||
this.warning(`Последние изменения отменены. Данные синхронизированы с сервером.`);
|
||||
if (!recurse && itemKeys) {
|
||||
needRecurseCall = true;
|
||||
}
|
||||
} else if (result.state == 'success') {
|
||||
if (needSaveRecent && newRecent.rev)
|
||||
await this.setCachedRecent(newRecent);
|
||||
if (needSaveRecentPatch && newRecentPatch.rev)
|
||||
await this.setCachedRecentPatch(newRecentPatch);
|
||||
if (needSaveRecentMod && newRecentMod.rev)
|
||||
await this.setCachedRecentMod(newRecentMod);
|
||||
} else {
|
||||
this.prevItemKey = null;
|
||||
}
|
||||
} finally {
|
||||
this.lock.ret();
|
||||
}
|
||||
|
||||
if (needRecurseCall)
|
||||
await this.saveRecent(itemKeys, true);
|
||||
}
|
||||
|
||||
async storageCheck(items) {
|
||||
return await this.storageApi('check', items);
|
||||
}
|
||||
|
||||
async storageGet(items) {
|
||||
return await this.storageApi('get', items);
|
||||
}
|
||||
|
||||
async storageSet(items, force) {
|
||||
return await this.storageApi('set', items, force);
|
||||
}
|
||||
|
||||
async storageApi(action, items, force) {
|
||||
const request = {action, identity: this.identity, items};
|
||||
if (force)
|
||||
request.force = true;
|
||||
const encodedRequest = await this.encodeStorageItems(request);
|
||||
return await this.decodeStorageItems(await readerApi.storage(encodedRequest));
|
||||
}
|
||||
|
||||
async encodeStorageItems(request) {
|
||||
if (!this.hashedStorageKey)
|
||||
throw new Error('hashedStorageKey is empty');
|
||||
|
||||
if (!_.isObject(request.items))
|
||||
throw new Error('items is not an object');
|
||||
|
||||
let result = Object.assign({}, request);
|
||||
let items = {};
|
||||
for (const id of Object.keys(request.items)) {
|
||||
const item = request.items[id];
|
||||
if (request.action == 'set' && !_.isObject(item.data))
|
||||
throw new Error('encodeStorageItems: data is not an object');
|
||||
|
||||
let encoded = Object.assign({}, item);
|
||||
|
||||
if (item.data) {
|
||||
const comp = utils.pako.deflate(JSON.stringify(item.data), {level: 1});
|
||||
let encrypted = null;
|
||||
try {
|
||||
encrypted = cryptoUtils.aesEncrypt(comp, this.serverStorageKey);
|
||||
} catch (e) {
|
||||
throw new Error('encrypt failed');
|
||||
}
|
||||
encoded.data = '1' + utils.toBase64(encrypted);
|
||||
}
|
||||
items[`${this.hashedStorageKey}.${utils.toBase58(id)}`] = encoded;
|
||||
}
|
||||
|
||||
result.items = items;
|
||||
return result;
|
||||
}
|
||||
|
||||
async decodeStorageItems(response) {
|
||||
if (!this.hashedStorageKey)
|
||||
throw new Error('hashedStorageKey is empty');
|
||||
|
||||
let result = Object.assign({}, response);
|
||||
let items = {};
|
||||
if (response.items) {
|
||||
if (!_.isObject(response.items))
|
||||
throw new Error('items is not an object');
|
||||
|
||||
for (const id of Object.keys(response.items)) {
|
||||
const item = response.items[id];
|
||||
let decoded = Object.assign({}, item);
|
||||
if (item.data) {
|
||||
if (!_.isString(item.data) || !item.data.length)
|
||||
throw new Error('decodeStorageItems: data is not a string');
|
||||
if (item.data[0] !== '1')
|
||||
throw new Error('decodeStorageItems: unknown data format');
|
||||
|
||||
const a = utils.fromBase64(item.data.substr(1));
|
||||
let decrypted = null;
|
||||
try {
|
||||
decrypted = cryptoUtils.aesDecrypt(a, this.serverStorageKey);
|
||||
} catch (e) {
|
||||
throw new Error('decrypt failed');
|
||||
}
|
||||
decoded.data = JSON.parse(utils.pako.inflate(decrypted, {to: 'string'}));
|
||||
}
|
||||
|
||||
const ids = id.split('.');
|
||||
if (!(ids.length == 2) || !(ids[0] == this.hashedStorageKey))
|
||||
throw new Error(`decodeStorageItems: bad id - ${id}`);
|
||||
items[utils.fromBase58(ids[1]).toString()] = decoded;
|
||||
}
|
||||
}
|
||||
|
||||
result.items = items;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ServerStorage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
@@ -1,51 +1,60 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Установить позицию
|
||||
</template>
|
||||
<Window ref="window" height="125px" max-width="600px" :top-shift="-50" @close="close">
|
||||
<template #header>
|
||||
Установить позицию
|
||||
</template>
|
||||
|
||||
<div class="slider">
|
||||
<el-slider v-model="sliderValue" :max="sliderMax" :format-tooltip="formatTooltip"></el-slider>
|
||||
</div>
|
||||
</Window>
|
||||
<div class="col column justify-center">
|
||||
<div id="set-position-slider" class="slider q-px-md column justify-center">
|
||||
<q-slider
|
||||
v-model="sliderValue"
|
||||
thumb-path="M 2, 10 a 8.5,8.5 0 1,0 17,0 a 8.5,8.5 0 1,0 -17,0"
|
||||
|
||||
:max="sliderMax"
|
||||
label
|
||||
:label-value="(sliderMax ? (sliderValue/sliderMax*100).toFixed(2) + '%' : 0)"
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import _ from 'lodash';
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
|
||||
import Window from '../../share/Window.vue';
|
||||
|
||||
export default @Component({
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
watch: {
|
||||
sliderValue: function(newValue) {
|
||||
this.$emit('book-pos-changed', {bookPos: newValue});
|
||||
if (this.initialized)
|
||||
this.$emit('book-pos-changed', {bookPos: newValue});
|
||||
},
|
||||
},
|
||||
})
|
||||
class SetPositionPage extends Vue {
|
||||
};
|
||||
class SetPositionPage {
|
||||
_options = componentOptions;
|
||||
|
||||
sliderValue = null;
|
||||
sliderMax = null;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.reader = this.$store.state.reader;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
formatTooltip(val) {
|
||||
if (this.sliderMax)
|
||||
return (val/this.sliderMax*100).toFixed(2) + '%';
|
||||
else
|
||||
return 0;
|
||||
init(sliderValue, sliderMax) {
|
||||
this.$refs.window.init();
|
||||
|
||||
this.sliderMax = sliderMax;
|
||||
this.sliderValue = sliderValue;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -53,44 +62,34 @@ class SetPositionPage extends Vue {
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && (event.code == 'Escape' || event.code == 'KeyP')) {
|
||||
this.close();
|
||||
if (event.type == 'keydown') {
|
||||
const action = this.$root.readerActionByKeyEvent(event);
|
||||
if (event.key == 'Escape' || action == 'setPosition') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(SetPositionPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
top: -50px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
margin: 20px;
|
||||
background-color: #efefef;
|
||||
margin: 0 20px 0 20px;
|
||||
height: 35px;
|
||||
background-color: var(--bg-input-color);
|
||||
border-radius: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.el-slider {
|
||||
margin-right: 20px;
|
||||
margin-left: 20px;
|
||||
<style>
|
||||
#set-position-slider .q-slider__thumb path {
|
||||
fill: white !important;
|
||||
stroke: blue !important;
|
||||
stroke-width: 2 !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
145
client/components/Reader/SettingsPage/ConvertTab/ConvertTab.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="fit sets-tab-panel">
|
||||
<!---------------------------------------------->
|
||||
<div class="q-mt-sm column items-center">
|
||||
<span>Настройки конвертирования применяются ко всем</span>
|
||||
<span>вновь загружаемым или обновляемым файлам</span>
|
||||
</div>
|
||||
|
||||
<!---------------------------------------------->
|
||||
<div class="sets-part-header">
|
||||
HTML, XML, TXT
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Текст
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.splitToPara" size="xs" label="Попытаться разбить текст на параграфы">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Опция принудительно включает эвристику разбиения текста на<br>
|
||||
параграфы в случае, если формат файла определен как html,<br>
|
||||
xml или txt. Возможна нечитабельная разметка текста.
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Сайты
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.enableSitesFilter" size="xs" label="Включить html-фильтр для сайтов">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Html-фильтр вырезает лишние элементы со<br>
|
||||
страницы для определенных сайтов, таких как:<br>
|
||||
samlib.ru<br>
|
||||
www.fanfiction.net<br>
|
||||
archiveofourown.org<br>
|
||||
и других
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!---------------------------------------------->
|
||||
<div v-if="isExternalConverter">
|
||||
<div class="sets-part-header">
|
||||
PDF
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Формат
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.pdfAsText" size="xs" label="Извлекать текст из PDF">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Пытается извлечь текст из pdf-файла и переразбить на параграфы.<br>
|
||||
Размер получаемого fb2-файла при этом относительно небольшой.<br>
|
||||
При отключении этой опции, pdf будет представлен как набор<br>
|
||||
изображений (аналогично ковертированию djvu).
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!form.pdfAsText" class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Качество
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.pdfQuality" bg-color="input" class="col-5" :min="10" :max="100">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Качество конвертирования Pdf в Fb2. Чем значение выше, тем больше<br>
|
||||
размер итогового файла. Если сервер отказывается конвертировать<br>
|
||||
слишком большой файл, то попробуйте понизить качество.
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!---------------------------------------------->
|
||||
<div v-if="isExternalConverter">
|
||||
<div class="sets-part-header">
|
||||
DJVU
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Качество
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.djvuQuality" bg-color="input" class="col-5" :min="10" :max="100">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Качество конвертирования Djvu в Fb2. Чем значение выше, тем больше<br>
|
||||
размер итогового файла. Если сервер отказывается конвертировать<br>
|
||||
слишком большой файл, то попробуйте понизить качество.
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
import NumInput from '../../../share/NumInput.vue';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
NumInput
|
||||
},
|
||||
};
|
||||
class ConvertTab {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
get isExternalConverter() {
|
||||
return this.$store.state.config.useExternalBookConverter;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ConvertTab);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
</style>
|
||||
78
client/components/Reader/SettingsPage/KeysTab/KeysTab.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="fit column">
|
||||
<div class="bg-menu-1 row">
|
||||
<q-tabs
|
||||
v-model="selectedTab"
|
||||
active-color="app"
|
||||
active-bg-color="app"
|
||||
indicator-color="bg-app"
|
||||
dense
|
||||
no-caps
|
||||
class="bg-menu-2 text-menu"
|
||||
>
|
||||
<q-tab name="mouse" label="Мышь/тачскрин" />
|
||||
<q-tab name="keyboard" label="Клавиатура" />
|
||||
</q-tabs>
|
||||
</div>
|
||||
|
||||
<div class="q-mb-sm" />
|
||||
|
||||
<div class="col sets-tab-panel">
|
||||
<div v-if="selectedTab == 'mouse'">
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.clickControl" size="xs" label="Включить управление кликом" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTab == 'keyboard'">
|
||||
<div class="sets-item row">
|
||||
<UserHotKeys v-model="form.userHotKeys" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
import UserHotKeys from './UserHotKeys/UserHotKeys.vue';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
UserHotKeys,
|
||||
},
|
||||
};
|
||||
class KeysTab {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
selectedTab = 'mouse';
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(KeysTab);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="table col column no-wrap">
|
||||
<!-- header -->
|
||||
<div class="table-row row">
|
||||
<div class="desc q-pa-sm bg-header-3">
|
||||
Команда
|
||||
</div>
|
||||
<div class="hotKeys col q-pa-sm bg-header-3 row no-wrap">
|
||||
<div style="width: 80px">
|
||||
Сочетание клавиш
|
||||
</div>
|
||||
<q-input
|
||||
ref="input"
|
||||
v-model="search"
|
||||
class="q-ml-sm col"
|
||||
outlined dense
|
||||
bg-color="input"
|
||||
placeholder="Найти"
|
||||
@click.stop
|
||||
/>
|
||||
<div v-show="!readonly" class="q-ml-sm column justify-center">
|
||||
<q-btn class="bg-grey-4 text-grey-6" style="height: 35px; width: 35px" rounded flat icon="la la-broom" @click="defaultHotKeyAll">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Установить все сочетания по умолчанию
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- body -->
|
||||
<div v-for="(action, index) in tableData" :key="index" class="table-row row">
|
||||
<div class="desc q-pa-sm">
|
||||
{{ rstore.readerActions[action] }}
|
||||
</div>
|
||||
<div class="hotKeys col q-pa-sm">
|
||||
<q-chip
|
||||
v-for="(code, index2) in modelValue[action]" :key="index2"
|
||||
:color="collisions[code] ? 'red' : 'grey-7'"
|
||||
:removable="!readonly" :clickable="collisions[code] ? true : false"
|
||||
text-color="white" @remove="removeCode(action, code)"
|
||||
@click="collisionWarning(code)"
|
||||
>
|
||||
{{ code }}
|
||||
</q-chip>
|
||||
</div>
|
||||
<div v-show="!readonly" class="column q-pa-xs">
|
||||
<q-icon
|
||||
v-ripple
|
||||
:disabled="(modelValue[action].length >= maxCodesLength) || null"
|
||||
name="la la-plus-circle"
|
||||
class="button bg-green-8 text-white"
|
||||
@click="addHotKey(action)"
|
||||
>
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Добавить сочетание клавиш
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon
|
||||
v-ripple
|
||||
name="la la-broom"
|
||||
class="button text-grey-5"
|
||||
@click="defaultHotKey(action)"
|
||||
>
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
По умолчанию
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../../vueComponent.js';
|
||||
|
||||
import rstore from '../../../../../store/modules/reader';
|
||||
|
||||
const componentOptions = {
|
||||
watch: {
|
||||
search: function() {
|
||||
this.updateTableData();
|
||||
},
|
||||
modelValue: function() {
|
||||
this.checkCollisions();
|
||||
this.updateTableData();
|
||||
}
|
||||
},
|
||||
};
|
||||
class UserHotKeys {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
modelValue: Object,
|
||||
readonly: Boolean,
|
||||
};
|
||||
|
||||
search = '';
|
||||
rstore = {};
|
||||
tableData = [];
|
||||
collisions = {};
|
||||
maxCodesLength = 10;
|
||||
|
||||
created() {
|
||||
this.rstore = rstore;
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.checkCollisions();
|
||||
this.updateTableData();
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
updateTableData() {
|
||||
let result = rstore.hotKeys.map(hk => hk.name);
|
||||
|
||||
const search = this.search.toLowerCase();
|
||||
const codesIncludeSearch = (action) => {
|
||||
for (const code of this.modelValue[action]) {
|
||||
if (code.toLowerCase().includes(search))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
result = result.filter(item => {
|
||||
return !search ||
|
||||
rstore.readerActions[item].toLowerCase().includes(search) ||
|
||||
codesIncludeSearch(item)
|
||||
});
|
||||
|
||||
this.tableData = result;
|
||||
}
|
||||
|
||||
checkCollisions() {
|
||||
const cols = {};
|
||||
for (const [action, codes] of Object.entries(this.modelValue)) {
|
||||
codes.forEach(code => {
|
||||
if (!cols[code])
|
||||
cols[code] = [];
|
||||
if (cols[code].indexOf(action) < 0)
|
||||
cols[code].push(action);
|
||||
});
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const [code, actions] of Object.entries(cols)) {
|
||||
if (actions.length > 1)
|
||||
result[code] = actions;
|
||||
}
|
||||
|
||||
this.collisions = result;
|
||||
}
|
||||
|
||||
collisionWarning(code) {
|
||||
if (this.collisions[code]) {
|
||||
const descs = this.collisions[code].map(action => `<b>${rstore.readerActions[action]}</b>`);
|
||||
this.$root.stdDialog.alert(`Сочетание '${code}' одновременно назначено<br>следующим командам:<br>${descs.join('<br>')}<br><br>
|
||||
Возможно неожиданное поведение.`, 'Предупреждение');
|
||||
}
|
||||
}
|
||||
|
||||
removeCode(action, code) {
|
||||
let codes = Array.from(this.modelValue[action]);
|
||||
const index = codes.indexOf(code);
|
||||
if (index >= 0) {
|
||||
codes.splice(index, 1);
|
||||
const newValue = Object.assign({}, this.modelValue, {[action]: codes});
|
||||
this.$emit('update:modelValue', newValue);
|
||||
}
|
||||
}
|
||||
|
||||
async addHotKey(action) {
|
||||
if (this.modelValue[action].length >= this.maxCodesLength)
|
||||
return;
|
||||
try {
|
||||
const result = await this.$root.stdDialog.getHotKey(`Добавить сочетание для:<br><b>${rstore.readerActions[action]}</b>`, '');
|
||||
if (result) {
|
||||
let codes = Array.from(this.modelValue[action]);
|
||||
if (codes.indexOf(result) < 0) {
|
||||
codes.push(result);
|
||||
const newValue = Object.assign({}, this.modelValue, {[action]: codes});
|
||||
this.$emit('update:modelValue', newValue);
|
||||
this.$nextTick(() => {
|
||||
this.collisionWarning(result);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
async defaultHotKey(action) {
|
||||
try {
|
||||
if (await this.$root.stdDialog.confirm(`Подтвердите сброс сочетаний клавиш<br>в значения по умолчанию для команды:<br><b>${rstore.readerActions[action]}</b>`, ' ')) {
|
||||
const codes = Array.from(rstore.settingDefaults.userHotKeys[action]);
|
||||
const newValue = Object.assign({}, this.modelValue, {[action]: codes});
|
||||
this.$emit('update:modelValue', newValue);
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
async defaultHotKeyAll() {
|
||||
try {
|
||||
if (await this.$root.stdDialog.confirm('Подтвердите сброс сочетаний клавиш<br>для ВСЕХ команд в значения по умолчанию:', ' ')) {
|
||||
const newValue = Object.assign({}, rstore.settingDefaults.userHotKeys);
|
||||
this.$emit('update:modelValue', newValue);
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(UserHotKeys);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table {
|
||||
border-left: 1px solid grey;
|
||||
border-top: 1px solid grey;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-right: 1px solid grey;
|
||||
border-bottom: 1px solid grey;
|
||||
}
|
||||
|
||||
.table-row:nth-child(even) {
|
||||
background-color: var(--bg-menu-color1);
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: var(--bg-menu-color2);
|
||||
}
|
||||
|
||||
.desc {
|
||||
width: 130px;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.hotKeys {
|
||||
border-left: 1px solid grey;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 25px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
134
client/components/Reader/SettingsPage/OthersTab/OthersTab.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="fit sets-tab-panel">
|
||||
<!---------------------------------------------->
|
||||
<div class="sets-part-header">
|
||||
Подсказки, уведомления
|
||||
</div>
|
||||
|
||||
<div class="sets-item row no-wrap">
|
||||
<div class="sets-label label">
|
||||
Подсказка
|
||||
</div>
|
||||
<q-checkbox v-model="form.showClickMapPage" size="xs" label="Показывать области управления кликом" :disable="!form.clickControl">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Показывать или нет подсказку при каждой загрузке книги
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Подсказка
|
||||
</div>
|
||||
<q-checkbox v-model="form.blinkCachedLoad" size="xs" label="Предупреждать о загрузке из кэша">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Мерцать сообщением в строке статуса и на кнопке<br>
|
||||
обновления при загрузке книги из кэша
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row no-wrap">
|
||||
<div class="sets-label label">
|
||||
Уведомление
|
||||
</div>
|
||||
<q-checkbox v-model="form.showServerStorageMessages" size="xs" label="Показывать сообщения синхронизации">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Показывать уведомления и ошибки от<br>
|
||||
синхронизатора данных с сервером
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Уведомление
|
||||
</div>
|
||||
<q-checkbox v-model="form.showWhatsNewDialog" size="xs">
|
||||
Показывать уведомление "Что нового"
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Показывать уведомления "Что нового"<br>
|
||||
при появлении новой версии читалки
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<!--div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Уведомление
|
||||
</div>
|
||||
<q-checkbox v-model="form.showDonationDialog" size="xs">
|
||||
Показывать форму доната
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Показывать диалог для сбора пожертвований
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div-->
|
||||
|
||||
<!---------------------------------------------->
|
||||
<div class="sets-part-header">
|
||||
Другое
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Парам. в URL
|
||||
</div>
|
||||
<q-checkbox v-model="form.allowUrlParamBookPos" size="xs">
|
||||
Добавлять параметр "__p"
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Добавление параметра "__p" в строке браузера<br>
|
||||
позволяет передавать ссылку на книгу в читалке<br>
|
||||
без потери текущей позиции. Однако в этом случае<br>
|
||||
при листании забивается история браузера, т.к. на<br>
|
||||
каждое изменение позиции происходит смена URL.
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Копирование
|
||||
</div>
|
||||
<q-checkbox v-model="form.copyFullText" size="xs" label="Загружать весь текст">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Загружать весь текст в окно<br>
|
||||
копирования текста со страницы
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
},
|
||||
};
|
||||
class OthersTab {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default vueComponent(OthersTab);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="fit sets-tab-panel">
|
||||
<!---------------------------------------------->
|
||||
<div class="sets-part-header">
|
||||
Анимация
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Тип
|
||||
</div>
|
||||
<q-select
|
||||
v-model="form.pageChangeAnimation" bg-color="input" class="col-left" :options="pageChangeAnimationOptions"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Скорость
|
||||
</div>
|
||||
<NumInput v-model="form.pageChangeAnimationSpeed" bg-color="input" class="col-left" :min="0" :max="100" :disable="form.pageChangeAnimation == ''" />
|
||||
</div>
|
||||
|
||||
<!---------------------------------------------->
|
||||
<div class="sets-part-header">
|
||||
Другое
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Страница
|
||||
</div>
|
||||
<q-checkbox v-model="form.keepLastToFirst" size="xs" label="Переносить последнюю строку">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Переносить последнюю строку страницы<br>
|
||||
в начало следующей при листании
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
import NumInput from '../../../share/NumInput.vue';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
NumInput,
|
||||
},
|
||||
};
|
||||
class PageMoveTab {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
get pageChangeAnimationOptions() {
|
||||
let result = [
|
||||
{label: 'Нет', value: ''},
|
||||
{label: 'Вверх-вниз', value: 'downShift'},
|
||||
(!this.form.dualPageMode ? {label: 'Вправо-влево', value: 'rightShift'} : null),
|
||||
{label: 'Протаивание', value: 'thaw'},
|
||||
{label: 'Мерцание', value: 'blink'},
|
||||
{label: 'Вращение', value: 'rotate'},
|
||||
(this.form.wallpaper == '' && !this.form.dualPageMode ? {label: 'Листание', value: 'flip'} : null),
|
||||
];
|
||||
|
||||
result = result.filter(v => v);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(PageMoveTab);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.col-left {
|
||||
width: 150px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="fit sets-tab-panel">
|
||||
<div class="sets-part-header">
|
||||
Управление синхронизацией данных
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<q-checkbox v-model="serverSyncEnabled" class="col" size="xs" label="Включить синхронизацию с сервером" />
|
||||
</div>
|
||||
|
||||
<div v-show="serverSyncEnabled">
|
||||
<!---------------------------------------------->
|
||||
<div class="sets-part-header">
|
||||
Профили устройств
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="text col">
|
||||
Выберите или добавьте профиль устройства, чтобы начать синхронизацию настроек с сервером.
|
||||
<br>При выборе "Нет" синхронизация настроек (но не книг) отключается.
|
||||
</div>
|
||||
</div>
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Устройство
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-select
|
||||
v-model="currentProfile" :options="currentProfileOptions"
|
||||
style="width: 275px"
|
||||
bg-color="input"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options display-value-sanitize options-sanitize
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<q-btn class="sets-button" color="btn2" text-color="app" dense no-caps @click="addProfile">
|
||||
Добавить
|
||||
</q-btn>
|
||||
<q-btn class="sets-button" color="btn2" text-color="app" dense no-caps @click="delProfile">
|
||||
Удалить
|
||||
</q-btn>
|
||||
<q-btn class="sets-button" color="btn2" text-color="app" dense no-caps @click="delAllProfiles">
|
||||
Удалить все
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<!---------------------------------------------->
|
||||
<div class="sets-part-header">
|
||||
Ключ доступа
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="text col">
|
||||
Ключ доступа позволяет восстановить профили с настройками и список читаемых книг.
|
||||
Для этого необходимо передать ключ на новое устройство через почту, мессенджер или другим способом.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<q-btn class="sets-button" color="btn2" text-color="app" style="width: 250px" dense no-caps @click="showServerStorageKey">
|
||||
<span v-show="serverStorageKeyVisible">Скрыть</span>
|
||||
<span v-show="!serverStorageKeyVisible">Показать</span>
|
||||
ключ доступа
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div v-if="!serverStorageKeyVisible" class="col">
|
||||
<hr />
|
||||
<b>{{ partialStorageKey }}</b> (часть вашего ключа)
|
||||
<hr />
|
||||
</div>
|
||||
<div v-else class="col" style="line-height: 100%">
|
||||
<hr />
|
||||
<div style="width: 300px; padding-top: 5px; overflow-wrap: break-word;">
|
||||
<b>{{ serverStorageKey }}</b>
|
||||
<q-icon class="copy-icon" name="la la-copy" @click="copyToClip(serverStorageKey, 'Ключ')">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
|
||||
Скопировать
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
<div v-if="mode == 'omnireader' || mode == 'liberama'">
|
||||
<br>Переход по ссылке позволит автоматически ввести ключ доступа:
|
||||
<br><div class="text-center" style="margin-top: 5px">
|
||||
<a :href="setStorageKeyLink" target="_blank">Ссылка для ввода ключа</a>
|
||||
<q-icon class="copy-icon" name="la la-copy" @click="copyToClip(setStorageKeyLink, 'Ссылка')">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="center middle" content-style="font-size: 80%">
|
||||
Скопировать
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<q-btn class="sets-button" color="btn2" text-color="app" style="width: 250px" dense no-caps @click="enterServerStorageKey">
|
||||
Ввести ключ доступа
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<q-btn class="sets-button" color="btn2" text-color="app" style="width: 250px" dense no-caps @click="generateServerStorageKey">
|
||||
Сгенерировать новый ключ
|
||||
</q-btn>
|
||||
</div>
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="text col">
|
||||
Рекомендуется сохранить ключ в надежном месте, чтобы всегда иметь возможность восстановить настройки,
|
||||
например, после переустановки ОС или чистки/смены браузера.<br>
|
||||
<b>ПРЕДУПРЕЖДЕНИЕ!</b> При утере ключа, НИКТО не сможет восстановить ваши данные, т.к. они сжимаются
|
||||
и шифруются ключом доступа перед отправкой на сервер.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
import * as utils from '../../../../share/utils';
|
||||
import rstore from '../../../../store/modules/reader';
|
||||
|
||||
const componentOptions = {
|
||||
watch: {
|
||||
},
|
||||
};
|
||||
class ProfilesTab {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
rstore = rstore;
|
||||
|
||||
serverStorageKeyVisible = false;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
|
||||
get serverSyncEnabled() {
|
||||
return this.$store.state.reader.serverSyncEnabled;
|
||||
}
|
||||
|
||||
set serverSyncEnabled(newValue) {
|
||||
this.commit('reader/setServerSyncEnabled', newValue);
|
||||
}
|
||||
|
||||
get currentProfile() {
|
||||
return this.$store.state.reader.currentProfile;
|
||||
}
|
||||
|
||||
set currentProfile(newValue) {
|
||||
this.commit('reader/setCurrentProfile', newValue);
|
||||
}
|
||||
|
||||
get profiles() {
|
||||
return this.$store.state.reader.profiles;
|
||||
}
|
||||
|
||||
get currentProfileOptions() {
|
||||
const profNames = Object.keys(this.profiles)
|
||||
profNames.sort();
|
||||
|
||||
let result = [{label: 'Нет', value: ''}];
|
||||
profNames.forEach(name => {
|
||||
result.push({label: name, value: name});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
get partialStorageKey() {
|
||||
return this.serverStorageKey.substr(0, 7) + '***';
|
||||
}
|
||||
|
||||
get serverStorageKey() {
|
||||
return this.$store.state.reader.serverStorageKey;
|
||||
}
|
||||
|
||||
get setStorageKeyLink() {
|
||||
return `https://${window.location.host}/#/reader?setStorageAccessKey=${utils.toBase58(this.serverStorageKey)}`;
|
||||
}
|
||||
|
||||
async addProfile() {
|
||||
try {
|
||||
if (Object.keys(this.profiles).length >= 100) {
|
||||
this.$root.stdDialog.alert('Достигнут предел количества профилей', 'Ошибка');
|
||||
return;
|
||||
}
|
||||
const result = await this.$root.stdDialog.prompt('Введите произвольное название для профиля устройства:', ' ', {
|
||||
inputValidator: (str) => { if (!str) return 'Название не должно быть пустым'; else if (str.length > 50) return 'Слишком длинное название'; else return true; },
|
||||
});
|
||||
if (result && result.value) {
|
||||
if (this.profiles[result.value]) {
|
||||
this.$root.stdDialog.alert('Такой профиль уже существует', 'Ошибка');
|
||||
} else {
|
||||
const newProfiles = Object.assign({}, this.profiles, {[result.value]: 1});
|
||||
this.commit('reader/setAllowProfilesSave', true);
|
||||
await this.$nextTick();//ждем обработчики watch
|
||||
this.commit('reader/setProfiles', newProfiles);
|
||||
await this.$nextTick();//ждем обработчики watch
|
||||
this.commit('reader/setAllowProfilesSave', false);
|
||||
this.currentProfile = result.value;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
async delProfile() {
|
||||
if (!this.currentProfile)
|
||||
return;
|
||||
|
||||
try {
|
||||
const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Удаление профиля '${this.$root.sanitize(this.currentProfile)}' необратимо.` +
|
||||
`<br>Все настройки профиля будут потеряны, однако список читаемых книг сохранится.` +
|
||||
`<br><br>Введите 'да' для подтверждения удаления:`, ' ', {
|
||||
inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
|
||||
});
|
||||
|
||||
if (result && result.value && result.value.toLowerCase() == 'да') {
|
||||
if (this.profiles[this.currentProfile]) {
|
||||
const newProfiles = Object.assign({}, this.profiles);
|
||||
delete newProfiles[this.currentProfile];
|
||||
this.commit('reader/setAllowProfilesSave', true);
|
||||
await this.$nextTick();//ждем обработчики watch
|
||||
this.commit('reader/setProfiles', newProfiles);
|
||||
await this.$nextTick();//ждем обработчики watch
|
||||
this.commit('reader/setAllowProfilesSave', false);
|
||||
this.currentProfile = '';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
async delAllProfiles() {
|
||||
if (!Object.keys(this.profiles).length)
|
||||
return;
|
||||
|
||||
try {
|
||||
const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Удаление ВСЕХ профилей с настройками необратимо.` +
|
||||
`<br><br>Введите 'да' для подтверждения удаления:`, ' ', {
|
||||
inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Удаление не подтверждено'; },
|
||||
});
|
||||
|
||||
if (result && result.value && result.value.toLowerCase() == 'да') {
|
||||
this.commit('reader/setAllowProfilesSave', true);
|
||||
await this.$nextTick();//ждем обработчики watch
|
||||
this.commit('reader/setProfiles', {});
|
||||
await this.$nextTick();//ждем обработчики watch
|
||||
this.commit('reader/setAllowProfilesSave', false);
|
||||
this.currentProfile = '';
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
async showServerStorageKey() {
|
||||
this.serverStorageKeyVisible = !this.serverStorageKeyVisible;
|
||||
}
|
||||
|
||||
async enterServerStorageKey(key) {
|
||||
try {
|
||||
const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Изменение ключа доступа приведет к замене всех профилей и читаемых книг в читалке.` +
|
||||
`<br><br>Введите новый ключ доступа:`, ' ', {
|
||||
inputValidator: (str) => {
|
||||
try {
|
||||
if (str && utils.fromBase58(str).length == 32) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return 'Неверный формат ключа';
|
||||
},
|
||||
inputValue: (key && _.isString(key) ? key : null),
|
||||
});
|
||||
|
||||
if (result && result.value && utils.fromBase58(result.value).length == 32) {
|
||||
this.commit('reader/setServerStorageKey', result.value);
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
async generateServerStorageKey() {
|
||||
try {
|
||||
const result = await this.$root.stdDialog.prompt(`<b>Предупреждение!</b> Генерация нового ключа доступа приведет к удалению всех профилей и читаемых книг в читалке.` +
|
||||
`<br><br>Введите 'да' для подтверждения генерации нового ключа:`, ' ', {
|
||||
inputValidator: (str) => { if (str && str.toLowerCase() === 'да') return true; else return 'Генерация не подтверждена'; },
|
||||
});
|
||||
|
||||
if (result && result.value && result.value.toLowerCase() == 'да') {
|
||||
if (this.$root.generateNewServerStorageKey)
|
||||
this.$root.generateNewServerStorageKey();
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async copyToClip(text, prefix) {
|
||||
const result = await utils.copyTextToClipboard(text);
|
||||
const suf = (prefix.substr(-1) == 'а' ? 'а' : '');
|
||||
const msg = (result ? `${prefix} успешно скопирован${suf} в буфер обмена` : 'Копирование не удалось');
|
||||
if (result)
|
||||
this.$root.notify.success(msg);
|
||||
else
|
||||
this.$root.notify.error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ProfilesTab);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 90%;
|
||||
line-height: 130%;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
margin-left: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 120%;
|
||||
color: var(--text-anchor-color);
|
||||
}
|
||||
</style>
|
||||
41
client/components/Reader/SettingsPage/ResetTab/ResetTab.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="fit sets-tab-panel">
|
||||
<div class="sets-item row">
|
||||
<q-btn class="col q-ma-sm" color="btn2" text-color="app" dense no-caps @click="setDefaults">
|
||||
Установить по умолчанию
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
},
|
||||
};
|
||||
class ResetTab {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
setDefaults() {
|
||||
this.$emit('tab-event', {action: 'set-defaults'});
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ResetTab);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -1,455 +1,238 @@
|
||||
<template>
|
||||
<div ref="main" class="main" @click="close">
|
||||
<div class="mainWindow" @click.stop>
|
||||
<Window @close="close">
|
||||
<template slot="header">
|
||||
Настройки
|
||||
</template>
|
||||
<Window ref="window" width="600px" @close="close">
|
||||
<template #header>
|
||||
Настройки
|
||||
</template>
|
||||
|
||||
<el-tabs type="border-card" tab-position="left" v-model="selectedTab">
|
||||
<!--------------------------------------------------------------------------->
|
||||
<el-tab-pane label="Вид">
|
||||
<div class="col row">
|
||||
<div class="full-height">
|
||||
<q-tabs
|
||||
ref="tabs"
|
||||
v-model="selectedTab"
|
||||
class="bg-menu-1 text-menu"
|
||||
style="max-width: 130px"
|
||||
|
||||
left-icon="la la-caret-up"
|
||||
right-icon="la la-caret-down"
|
||||
active-color="white"
|
||||
active-bg-color="primary"
|
||||
indicator-color="bg-app"
|
||||
vertical
|
||||
no-caps
|
||||
stretch
|
||||
inline-label
|
||||
>
|
||||
<q-tab v-for="item in tabs" :key="item.name" class="tab row items-center" :name="item.name">
|
||||
<q-icon :name="item.icon" :color="selectedTab == item.name ? 'yellow' : 'teal-7'" size="24px" />
|
||||
<div class="q-ml-xs" style="font-size: 90%">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" size="small" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Цвет</div>
|
||||
|
||||
<el-form-item label="Текст">
|
||||
<el-col :span="12">
|
||||
<el-color-picker v-model="textColor" color-format="hex" :predefine="predefineTextColors"></el-color-picker>
|
||||
<span class="color-picked"><b>{{ textColor }}</b></span>
|
||||
</el-col>
|
||||
<el-col :span="5">
|
||||
<span style="position: relative; top: 20px;">Обои:</span>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Фон">
|
||||
<el-col :span="12">
|
||||
<el-color-picker v-model="backgroundColor" color-format="hex" :predefine="predefineBackgroundColors" :disabled="wallpaper != ''"></el-color-picker>
|
||||
<span v-show="wallpaper == ''" class="color-picked"><b>{{ backgroundColor }}</b></span>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="11">
|
||||
<el-select v-model="wallpaper">
|
||||
<el-option label="Нет" value=""></el-option>
|
||||
<el-option label="1" value="paper1"></el-option>
|
||||
<el-option label="2" value="paper2"></el-option>
|
||||
<el-option label="3" value="paper3"></el-option>
|
||||
<el-option label="4" value="paper4"></el-option>
|
||||
<el-option label="5" value="paper5"></el-option>
|
||||
<el-option label="6" value="paper6"></el-option>
|
||||
<el-option label="7" value="paper7"></el-option>
|
||||
<el-option label="8" value="paper8"></el-option>
|
||||
<el-option label="9" value="paper9"></el-option>
|
||||
</el-select>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Шрифт</div>
|
||||
|
||||
<el-form-item label="Локальный/веб">
|
||||
<el-col :span="11">
|
||||
<el-select v-model="fontName" placeholder="Шрифт" :disabled="webFontName != ''">
|
||||
<el-option v-for="item in fonts"
|
||||
:key="item.name"
|
||||
:label="item.label"
|
||||
:value="item.name">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light" placement="top">
|
||||
<template slot="content">
|
||||
Веб шрифты дают большое разнообразие,<br>
|
||||
однако есть шанс, что шрифт будет загружаться<br>
|
||||
очень медленно или вовсе не загрузится
|
||||
</template>
|
||||
<el-select v-model="webFontName">
|
||||
<el-option label="Нет" value=""></el-option>
|
||||
<el-option v-for="item in webFonts"
|
||||
:key="item.name"
|
||||
:value="item.name">
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="Размер">
|
||||
<el-col :span="17">
|
||||
<el-input-number v-model="fontSize" :min="5" :max="100"></el-input-number>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
<a href="https://fonts.google.com/?subset=cyrillic" target="_blank">Примеры</a>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="Сдвиг">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Сдвиг шрифта по вертикали в процентах от размера.<br>
|
||||
Отрицательное значение сдвигает вверх, положительное -<br>
|
||||
вниз. Значение зависит от метрики шрифта.
|
||||
</template>
|
||||
<el-input-number v-model="vertShift" :min="-100" :max="100"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Стиль">
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="fontBold">Жирный</el-checkbox>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="fontItalic">Курсив</el-checkbox>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Текст</div>
|
||||
|
||||
<el-form-item label="Интервал">
|
||||
<el-input-number v-model="lineInterval" :min="0" :max="100"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="Параграф">
|
||||
<el-input-number v-model="p" :min="0" :max="200"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="Отступ">
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Слева/справа
|
||||
</template>
|
||||
<el-input-number v-model="indentLR" :min="0" :max="200"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Сверху/снизу
|
||||
</template>
|
||||
<el-input-number v-model="indentTB" :min="0" :max="200"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="Сдвиг">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Сдвиг текста по вертикали в процентах от размера шрифта.<br>
|
||||
Отрицательное значение сдвигает вверх, положительное -<br>
|
||||
вниз.
|
||||
</template>
|
||||
<el-input-number v-model="textVertShift" :min="-100" :max="100"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="Скроллинг">
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Замедление скроллинга в миллисекундах.<br>
|
||||
Определяет время, за которое текст<br>
|
||||
прокручивается на одну строку.
|
||||
</template>
|
||||
<el-input-number v-model="scrollingDelay" :min="1" :max="10000"></el-input-number>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="1">
|
||||
|
||||
</el-col>
|
||||
<el-col :span="11">
|
||||
<el-tooltip :open-delay="500" effect="light" placement="top">
|
||||
<template slot="content">
|
||||
Вид скроллинга: линейный,<br>
|
||||
ускорение-замедление и пр.
|
||||
</template>
|
||||
|
||||
<el-select v-model="scrollingType">
|
||||
<el-option value="linear"></el-option>
|
||||
<el-option value="ease"></el-option>
|
||||
<el-option value="ease-in"></el-option>
|
||||
<el-option value="ease-out"></el-option>
|
||||
<el-option value="ease-in-out"></el-option>
|
||||
</el-select>
|
||||
</el-tooltip>
|
||||
</el-col>
|
||||
|
||||
</el-form-item>
|
||||
<el-form-item label="Выравнивание">
|
||||
<el-checkbox v-model="textAlignJustify">По ширине</el-checkbox>
|
||||
<el-checkbox v-model="wordWrap">Перенос по слогам</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Строка статуса</div>
|
||||
|
||||
<el-form-item label="Статус">
|
||||
<el-checkbox v-model="showStatusBar">Показывать</el-checkbox>
|
||||
<el-checkbox v-model="statusBarTop" :disabled="!showStatusBar">Вверху/внизу</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="Высота">
|
||||
<el-input-number v-model="statusBarHeight" :min="5" :max="50" :disabled="!showStatusBar"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="Прозрачность">
|
||||
<el-input-number v-model="statusBarColorAlpha" :min="0" :max="1" :precision="2" :step="0.1" :disabled="!showStatusBar"></el-input-number>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!--------------------------------------------------------------------------->
|
||||
<el-tab-pane label="Листание">
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Анимация</div>
|
||||
|
||||
<el-form-item label="Вид">
|
||||
не готово
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Скорость">
|
||||
не готово
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<div class="partHeader">Другое</div>
|
||||
|
||||
<el-form-item label="Страница">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Переносить последнюю строку страницы<br>
|
||||
в начало следующей при листании
|
||||
</template>
|
||||
<el-checkbox v-model="keepLastToFirst">Переносить последнюю строку</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
</el-tab-pane>
|
||||
<!--------------------------------------------------------------------------->
|
||||
<el-tab-pane label="Прочее">
|
||||
<el-form :model="form" size="mini" label-width="120px" @submit.native.prevent>
|
||||
<el-form-item label="Подсказка">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Показывать или нет подсказку при каждой загрузке книги
|
||||
</template>
|
||||
<el-checkbox v-model="showClickMapPage">Показывать области управления кликом</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="URL">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Добавление параметра "__p" в строке браузера<br>
|
||||
позволяет передавать ссылку на книгу в читалке<br>
|
||||
без потери текущей позиции. Однако в этом случае<br>
|
||||
при листании забивается история браузера, т.к. на<br>
|
||||
каждое изменение позиции происходит смена URL.
|
||||
</template>
|
||||
<el-checkbox v-model="allowUrlParamBookPos">Добавлять параметр "__p"</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="Парсинг">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Включение этой опции позволяет делать предварительную<br>
|
||||
обработку текста в ленивом режиме сразу после загрузки<br>
|
||||
книги. Это может повысить отзывчивость читалки, но<br>
|
||||
нагружает процессор каждый раз при открытии книги.
|
||||
</template>
|
||||
<el-checkbox v-model="lazyParseEnabled">Предварительная обработка текста</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
<el-form-item label="Копирование">
|
||||
<el-tooltip :open-delay="500" effect="light">
|
||||
<template slot="content">
|
||||
Загружать весь текст в окно<br>
|
||||
копирования текста со страницы
|
||||
</template>
|
||||
<el-checkbox v-model="copyFullText">Загружать весь текст</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</Window>
|
||||
<div class="col fit">
|
||||
<!-- Профили --------------------------------------------------------------------->
|
||||
<ProfilesTab v-if="selectedTab == 'profiles'" :form="form" />
|
||||
<!-- Вид ------------------------------------------------------------------------->
|
||||
<ViewTab v-if="selectedTab == 'view'" :form="form" @tab-event="tabEvent" />
|
||||
<!-- Кнопки ---------------------------------------------------------------------->
|
||||
<ToolBarTab v-if="selectedTab == 'toolbar'" :form="form" />
|
||||
<!-- Управление ------------------------------------------------------------------>
|
||||
<KeysTab v-if="selectedTab == 'keys'" :form="form" />
|
||||
<!-- Листание -------------------------------------------------------------------->
|
||||
<PageMoveTab v-if="selectedTab == 'pagemove'" :form="form" />
|
||||
<!-- Конвертирование ------------------------------------------------------------->
|
||||
<ConvertTab v-if="selectedTab == 'convert'" :form="form" />
|
||||
<!-- Обновление ------------------------------------------------------------------>
|
||||
<UpdateTab v-if="selectedTab == 'update'" :form="form" />
|
||||
<!-- Прочее ---------------------------------------------------------------------->
|
||||
<OthersTab v-if="selectedTab == 'others'" :form="form" />
|
||||
<!-- Сброс ----------------------------------------------------------------------->
|
||||
<ResetTab v-if="selectedTab == 'reset'" :form="form" @tab-event="tabEvent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../../vueComponent.js';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
//stuff
|
||||
import Window from '../../share/Window.vue';
|
||||
|
||||
import rstore from '../../../store/modules/reader';
|
||||
|
||||
export default @Component({
|
||||
//pages
|
||||
import ProfilesTab from './ProfilesTab/ProfilesTab.vue';
|
||||
import ViewTab from './ViewTab/ViewTab.vue';
|
||||
import ToolBarTab from './ToolBarTab/ToolBarTab.vue';
|
||||
import KeysTab from './KeysTab/KeysTab.vue';
|
||||
import PageMoveTab from './PageMoveTab/PageMoveTab.vue';
|
||||
import ConvertTab from './ConvertTab/ConvertTab.vue';
|
||||
import UpdateTab from './UpdateTab/UpdateTab.vue';
|
||||
import OthersTab from './OthersTab/OthersTab.vue';
|
||||
import ResetTab from './ResetTab/ResetTab.vue';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Window,
|
||||
},
|
||||
data: function() {
|
||||
return Object.assign({}, rstore.settingDefaults);
|
||||
//pages
|
||||
ProfilesTab,
|
||||
ViewTab,
|
||||
ToolBarTab,
|
||||
KeysTab,
|
||||
PageMoveTab,
|
||||
ConvertTab,
|
||||
UpdateTab,
|
||||
OthersTab,
|
||||
ResetTab,
|
||||
},
|
||||
watch: {
|
||||
form: function(newValue) {
|
||||
this.commit('reader/setSettings', newValue);
|
||||
settings: function() {
|
||||
this.settingsChanged();//no await
|
||||
},
|
||||
fontBold: function(newValue) {
|
||||
this.fontWeight = (newValue ? 'bold' : '');
|
||||
},
|
||||
fontItalic: function(newValue) {
|
||||
this.fontStyle = (newValue ? 'italic' : '');
|
||||
},
|
||||
vertShift: function(newValue) {
|
||||
const font = (this.webFontName ? this.webFontName : this.fontName);
|
||||
this.fontShifts = Object.assign({}, this.fontShifts, {[font]: newValue});
|
||||
this.fontVertShift = newValue;
|
||||
},
|
||||
fontName: function(newValue) {
|
||||
const font = (this.webFontName ? this.webFontName : newValue);
|
||||
this.vertShift = this.fontShifts[font] || 0;
|
||||
},
|
||||
webFontName: function(newValue) {
|
||||
const font = (newValue ? newValue : this.fontName);
|
||||
this.vertShift = this.fontShifts[font] || 0;
|
||||
form: {
|
||||
handler() {
|
||||
if (this.inited && !this.isSetsChanged) {
|
||||
this.debouncedCommitSettings();
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
class SettingsPage extends Vue {
|
||||
selectedTab = null;
|
||||
form = {};
|
||||
fontBold = false;
|
||||
fontItalic = false;
|
||||
vertShift = 0;
|
||||
};
|
||||
class SettingsPage {
|
||||
_options = componentOptions;
|
||||
|
||||
webFonts = [];
|
||||
fonts = [];
|
||||
form = {};
|
||||
|
||||
tabs = [
|
||||
{ name: 'profiles', icon: 'la la-users', label: 'Профили' },
|
||||
{ name: 'view', icon: 'la la-eye', label: 'Вид'},
|
||||
{ name: 'toolbar', icon: 'la la-grip-horizontal', label: 'Панель'},
|
||||
{ name: 'keys', icon: 'la la-gamepad', label: 'Управление'},
|
||||
{ name: 'pagemove', icon: 'la la-school', label: 'Листание'},
|
||||
{ name: 'convert', icon: 'la la-magic', label: 'Конвертир.'},
|
||||
{ name: 'update', icon: 'la la-retweet', label: 'Обновление'},
|
||||
{ name: 'others', icon: 'la la-list-ul', label: 'Прочее'},
|
||||
{ name: 'reset', icon: 'la la-broom', label: 'Сброс'},
|
||||
];
|
||||
selectedTab = 'profiles';
|
||||
|
||||
isSetsChanged = false;
|
||||
|
||||
created() {
|
||||
this.commit = this.$store.commit;
|
||||
this.reader = this.$store.state.reader;
|
||||
|
||||
this.form = Object.assign({}, this.settings);
|
||||
for (let prop in rstore.settingDefaults) {
|
||||
this[prop] = this.form[prop];
|
||||
this.$watch(prop, (newValue) => {
|
||||
this.form = Object.assign({}, this.form, {[prop]: newValue});
|
||||
});
|
||||
this.debouncedCommitSettings = _.debounce(() => {
|
||||
this.commit('reader/setSettings', _.cloneDeep(this.form));
|
||||
}, 50);
|
||||
|
||||
this.settingsChanged();//no await
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.$refs.window.init();
|
||||
this.inited = true;
|
||||
}
|
||||
|
||||
async settingsChanged() {
|
||||
this.isSetsChanged = true;
|
||||
try {
|
||||
this.form = reactive(_.cloneDeep(this.settings));
|
||||
} finally {
|
||||
await this.$nextTick();
|
||||
this.isSetsChanged = false;
|
||||
}
|
||||
this.fontBold = (this.fontWeight == 'bold');
|
||||
this.fontItalic = (this.fontStyle == 'italic');
|
||||
|
||||
this.fonts = rstore.fonts;
|
||||
this.webFonts = rstore.webFonts;
|
||||
const font = (this.webFontName ? this.webFontName : this.fontName);
|
||||
this.vertShift = this.fontShifts[font] || 0;
|
||||
}
|
||||
|
||||
get settings() {
|
||||
return this.$store.state.reader.settings;
|
||||
}
|
||||
|
||||
get predefineTextColors() {
|
||||
return [
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
'#202020',
|
||||
'#323232',
|
||||
'#aaaaaa',
|
||||
'#00c0c0',
|
||||
];
|
||||
}
|
||||
|
||||
get predefineBackgroundColors() {
|
||||
return [
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
'#202020',
|
||||
'#ebe2c9',
|
||||
'#909080',
|
||||
'#808080',
|
||||
'#c8c8c8',
|
||||
'#478355',
|
||||
'#a6caf0',
|
||||
];
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$emit('settings-toggle');
|
||||
this.$emit('do-action', {action: 'settings'});
|
||||
}
|
||||
|
||||
async setDefaults() {
|
||||
try {
|
||||
if (await this.$root.stdDialog.confirm('Подтвердите установку настроек по умолчанию:', ' ')) {
|
||||
this.form = _.cloneDeep(rstore.settingDefaults);
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
tabEvent(event) {
|
||||
if (!event || !event.action)
|
||||
return;
|
||||
|
||||
switch (event.action) {
|
||||
case 'set-defaults': this.setDefaults(); break;
|
||||
case 'night-mode': this.$emit('do-action', {action: 'nightMode'}); break;
|
||||
}
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (event.type == 'keydown' && event.code == 'Escape') {
|
||||
if (!this.$root.stdDialog.active && event.type == 'keydown' && event.key == 'Escape') {
|
||||
this.close();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(SettingsPage);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 60;
|
||||
.tab {
|
||||
justify-content: initial;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.sets-tab-panel {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
font-size: 90%;
|
||||
padding: 0 10px 15px 10px;
|
||||
}
|
||||
|
||||
.sets-part-header {
|
||||
border-top: 2px solid #bbbbbb;
|
||||
font-weight: bold;
|
||||
font-size: 110%;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.sets-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: right;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mainWindow {
|
||||
height: 70%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.el-form {
|
||||
border-top: 2px solid #bbbbbb;
|
||||
.sets-item {
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-bottom: 5px;
|
||||
.sets-button {
|
||||
margin: 3px 15px 3px 0;
|
||||
padding: 0 5px 0 5px;
|
||||
}
|
||||
|
||||
.color-picked {
|
||||
margin-left: 10px;
|
||||
position: relative;
|
||||
top: -11px;
|
||||
}
|
||||
|
||||
.partHeader {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.el-tab-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 420px;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="fit sets-tab-panel">
|
||||
<div class="sets-part-header">
|
||||
Отображение
|
||||
</div>
|
||||
|
||||
<div class="item row no-wrap">
|
||||
<div class="sets-label label"></div>
|
||||
<q-checkbox v-model="form.toolBarMultiLine" size="xs" label="Многострочная панель">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Размещать кнопки на панели в несколько рядов, если они не помещаются в одну строку
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="item row no-wrap">
|
||||
<div class="sets-label label"></div>
|
||||
<q-checkbox v-model="form.toolBarHideOnScroll" size="xs" label="Скрывать/показывать панель при прокрутке">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Скрывать/показывть панель при прокрутке текста вперед/назад
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="sets-part-header">
|
||||
Показывать кнопки
|
||||
</div>
|
||||
|
||||
<div v-for="item in rstore.toolButtons" :key="item.name">
|
||||
<div class="sets-item row no-wrap">
|
||||
<div class="sets-label label"></div>
|
||||
<q-checkbox v-model="form.showToolButton[item.name]" size="xs" :label="rstore.readerActions[item.name]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
import rstore from '../../../../store/modules/reader';
|
||||
|
||||
const componentOptions = {
|
||||
watch: {
|
||||
},
|
||||
};
|
||||
class ToolBarTab {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
rstore = rstore;
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return this.$store.state.config.mode;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ToolBarTab);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
</style>
|
||||
122
client/components/Reader/SettingsPage/UpdateTab/UpdateTab.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="fit sets-tab-panel">
|
||||
<!---------------------------------------------->
|
||||
<div class="sets-part-header">
|
||||
Обновление читалки
|
||||
</div>
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<q-checkbox v-model="form.showNeedUpdateNotify" size="xs">
|
||||
Проверять наличие новой версии
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Напоминать о необходимости обновления страницы<br>
|
||||
при появлении новой версии читалки
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<!---------------------------------------------->
|
||||
<div class="sets-part-header">
|
||||
Обновление книг
|
||||
</div>
|
||||
<div v-show="!configBucEnabled" class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div>Сервер обновлений временно не работает</div>
|
||||
</div>
|
||||
|
||||
<div v-show="configBucEnabled" class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<q-checkbox v-model="form.bucEnabled" size="xs">
|
||||
Проверять обновления книг
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<div v-show="configBucEnabled && form.bucEnabled" class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col-4 column justify-center items-end q-pr-xs">
|
||||
Разница размеров
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.bucSizeDiff" bg-color="input" style="width: 200px" />
|
||||
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Уведомлять о наличии обновления книги в списке загруженных<br>
|
||||
при указанной разнице в размерах старого и нового файлов.<br>
|
||||
Разница указывается в байтах и может быть отрицательной.
|
||||
</q-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="configBucEnabled && form.bucEnabled" class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<q-checkbox v-model="form.bucSetOnNew" size="xs">
|
||||
Автопроверка для вновь загружаемых
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Автоматически устанавливать флаг проверки<br>
|
||||
обновлений для всех вновь загружаемых книг
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<div v-show="configBucEnabled && form.bucEnabled" class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<q-checkbox v-model="form.bucCancelEnabled" size="xs">
|
||||
Отменять проверку через {{ form.bucCancelDays }} дней{{ (form.bucCancelEnabled ? ':' : '') }}
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Снимать флаг проверки с книги, если не было<br>
|
||||
обновлений в течение {{ form.bucCancelDays }} дней
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
|
||||
<div v-show="configBucEnabled && form.bucEnabled && form.bucCancelEnabled" class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col-4"></div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.bucCancelDays" bg-color="input" :min="1" :max="10000" />
|
||||
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Снимать флаг проверки с книги, если не было<br>
|
||||
обновлений в течение {{ form.bucCancelDays }} дней
|
||||
</q-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
import NumInput from '../../../share/NumInput.vue';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
NumInput
|
||||
},
|
||||
};
|
||||
class UpdateTab {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
get configBucEnabled() {
|
||||
return this.$store.state.config.bucEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(UpdateTab);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 100px;
|
||||
}
|
||||
</style>
|
||||
332
client/components/Reader/SettingsPage/ViewTab/Color/Color.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div>
|
||||
<!---------------------------------------------->
|
||||
<div class="hidden sets-part-header">
|
||||
Цвет
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Текст
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-input
|
||||
v-model="textColorFiltered"
|
||||
class="col-left no-mp"
|
||||
bg-color="input"
|
||||
outlined dense
|
||||
|
||||
:rules="['hexColor']"
|
||||
style="max-width: 150px"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="helper.colorPanStyle(form.textColor)">
|
||||
<q-popup-proxy anchor="bottom middle" self="top middle">
|
||||
<div>
|
||||
<q-color
|
||||
v-model="form.textColor"
|
||||
no-header default-view="palette" :palette="defPalette.predefineTextColors"
|
||||
/>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-mt-md" />
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Фон
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-input
|
||||
v-model="bgColorFiltered"
|
||||
class="col-left no-mp"
|
||||
bg-color="input"
|
||||
outlined dense
|
||||
|
||||
:rules="['hexColor']"
|
||||
style="max-width: 150px"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="helper.colorPanStyle(form.backgroundColor)">
|
||||
<q-popup-proxy anchor="bottom middle" self="top middle">
|
||||
<div>
|
||||
<q-color v-model="form.backgroundColor" no-header default-view="palette" :palette="defPalette.predefineBackgroundColors" />
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-mt-md" />
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Обои
|
||||
</div>
|
||||
<div class="col row items-center">
|
||||
<q-select
|
||||
v-model="form.wallpaper"
|
||||
class="col-left no-mp"
|
||||
:options="wallpaperOptions"
|
||||
bg-color="input"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options
|
||||
>
|
||||
<template #selected-item="scope">
|
||||
<div>
|
||||
{{ scope.opt.label }}
|
||||
</div>
|
||||
<div v-show="scope.opt.value" class="q-ml-sm" :class="scope.opt.value" style="width: 40px; height: 28px;"></div>
|
||||
</template>
|
||||
|
||||
<template #option="scope">
|
||||
<q-item
|
||||
v-bind="scope.itemProps"
|
||||
>
|
||||
<q-item-section style="min-width: 50px;">
|
||||
<q-item-label>
|
||||
{{ scope.opt.label }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section v-show="scope.opt.value" :class="scope.opt.value" style="min-width: 70px; min-height: 50px;" />
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<div class="q-px-xs" />
|
||||
<q-btn class="q-ml-sm" round dense color="blue" icon="la la-plus" @click.stop="loadWallpaperFileClick">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Добавить файл обоев
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn v-show="form.wallpaper.indexOf('user-paper') === 0" class="q-ml-sm" round dense color="blue" icon="la la-minus" @click.stop="delWallpaper">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Удалить выбранные обои
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn v-show="form.wallpaper.indexOf('user-paper') === 0" class="q-ml-sm" round dense color="blue" icon="la la-file-download" @click.stop="downloadWallpaper">
|
||||
<q-tooltip :delay="1500" anchor="bottom middle" content-style="font-size: 80%">
|
||||
Скачать выбранные обои
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-mt-sm" />
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col row items-center">
|
||||
<q-checkbox v-model="form.wallpaperIgnoreStatusBar" size="xs" label="Не включать строку статуса в обои" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref="file" type="file" style="display: none;" @change="loadWallpaperFile" />
|
||||
<a ref="download" style="display: none;" target="_blank"></a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../../vueComponent.js';
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
import * as helper from '../helper';
|
||||
import defPalette from '../defPalette';
|
||||
|
||||
import * as utils from '../../../../../share/utils';
|
||||
import * as cryptoUtils from '../../../../../share/cryptoUtils';
|
||||
import wallpaperStorage from '../../../share/wallpaperStorage';
|
||||
import readerApi from '../../../../../api/reader';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
},
|
||||
watch: {
|
||||
form: {
|
||||
handler() {
|
||||
this.formChanged();//no await
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
textColorFiltered(newValue) {
|
||||
if (!this.isFormChanged && this.helper.isHexColor(newValue))
|
||||
this.form.textColor = newValue;
|
||||
},
|
||||
bgColorFiltered(newValue) {
|
||||
if (!this.isFormChanged && this.helper.isHexColor(newValue))
|
||||
this.form.backgroundColor = newValue;
|
||||
},
|
||||
},
|
||||
};
|
||||
class Color {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
helper = helper;
|
||||
defPalette = defPalette;
|
||||
|
||||
isFormChanged = false;
|
||||
textColorFiltered = '';
|
||||
bgColorFiltered = '';
|
||||
|
||||
created() {
|
||||
this.formChanged();//no await
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
async formChanged() {
|
||||
this.isFormChanged = true;
|
||||
try {
|
||||
this.textColorFiltered = this.form.textColor;
|
||||
this.bgColorFiltered = this.form.backgroundColor;
|
||||
|
||||
if (this.form.wallpaper != '' && this.form.pageChangeAnimation == 'flip')
|
||||
this.form.pageChangeAnimation = '';
|
||||
} finally {
|
||||
await this.$nextTick();
|
||||
this.isFormChanged = false;
|
||||
}
|
||||
}
|
||||
|
||||
get wallpaperOptions() {
|
||||
let result = [{label: 'Нет', value: ''}];
|
||||
|
||||
const userWallpapers = _.cloneDeep(this.form.userWallpapers);
|
||||
userWallpapers.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
for (const wp of userWallpapers) {
|
||||
if (wallpaperStorage.keyExists(wp.cssClass))
|
||||
result.push({label: wp.label, value: wp.cssClass});
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 17; i++) {
|
||||
result.push({label: i, value: `paper${i}`});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
loadWallpaperFileClick() {
|
||||
this.$refs.file.click();
|
||||
}
|
||||
|
||||
loadWallpaperFile() {
|
||||
const file = this.$refs.file.files[0];
|
||||
if (file.size > 10*1024*1024) {
|
||||
this.$root.stdDialog.alert('Файл обоев не должен превышать в размере 10Mb', 'Ошибка');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type != 'image/png' && file.type != 'image/jpeg') {
|
||||
this.$root.stdDialog.alert('Файл обоев должен иметь тип PNG или JPEG', 'Ошибка');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.form.userWallpapers.length >= 100) {
|
||||
this.$root.stdDialog.alert('Превышено максимальное количество пользовательских обоев.', 'Ошибка');
|
||||
return;
|
||||
}
|
||||
|
||||
this.$refs.file.value = '';
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
(async() => {
|
||||
const data = e.target.result;
|
||||
const key = utils.toHex(cryptoUtils.sha256(data));
|
||||
const label = `#${key.substring(0, 4)}`;
|
||||
const cssClass = `user-paper${key}`;
|
||||
|
||||
const newUserWallpapers = _.cloneDeep(this.form.userWallpapers);
|
||||
const index = _.findIndex(newUserWallpapers, (item) => (item.cssClass == cssClass));
|
||||
|
||||
if (index < 0)
|
||||
newUserWallpapers.push({label, cssClass});
|
||||
if (!wallpaperStorage.keyExists(cssClass)) {
|
||||
await wallpaperStorage.setData(cssClass, data);
|
||||
//отправим data на сервер в файл `/upload/${key}`
|
||||
try {
|
||||
//const res =
|
||||
await readerApi.uploadFileBuf(data);
|
||||
//console.log(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
this.form.userWallpapers = newUserWallpapers;
|
||||
this.form.wallpaper = cssClass;
|
||||
})();
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
async delWallpaper() {
|
||||
if (this.form.wallpaper.indexOf('user-paper') == 0) {
|
||||
const newUserWallpapers = [];
|
||||
for (const wp of this.form.userWallpapers) {
|
||||
if (wp.cssClass != this.form.wallpaper) {
|
||||
newUserWallpapers.push(wp);
|
||||
}
|
||||
}
|
||||
|
||||
await wallpaperStorage.removeData(this.form.wallpaper);
|
||||
|
||||
this.form.userWallpapers = newUserWallpapers;
|
||||
this.form.wallpaper = '';
|
||||
}
|
||||
}
|
||||
|
||||
async downloadWallpaper() {
|
||||
if (this.form.wallpaper.indexOf('user-paper') != 0)
|
||||
return;
|
||||
|
||||
try {
|
||||
const d = this.$refs.download;
|
||||
|
||||
const dataUrl = await wallpaperStorage.getData(this.form.wallpaper);
|
||||
|
||||
if (!dataUrl)
|
||||
throw new Error('Файл обоев не найден');
|
||||
|
||||
d.href = dataUrl;
|
||||
d.download = `wallpaper-#${this.form.wallpaper.replace('user-paper', '').substring(0, 4)}`;
|
||||
|
||||
d.click();
|
||||
} catch (e) {
|
||||
this.$root.stdDialog.alert(e.message, 'Ошибка', {color: 'negative'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(Color);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.col-left {
|
||||
width: 145px;
|
||||
}
|
||||
|
||||
.no-mp {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
176
client/components/Reader/SettingsPage/ViewTab/Font/Font.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<!---------------------------------------------->
|
||||
<div class="hidden sets-part-header">
|
||||
Шрифт
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Локальный/веб
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-select
|
||||
v-model="form.fontName" class="col-left" bg-color="input" :options="fontsOptions" :disable="form.webFontName != ''"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options
|
||||
/>
|
||||
|
||||
<div class="q-px-sm" />
|
||||
<q-select
|
||||
v-model="form.webFontName" class="col" bg-color="input" :options="webFontsOptions"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options
|
||||
>
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Веб шрифты дают большое разнообразие,<br>
|
||||
однако есть шанс, что шрифт будет загружаться<br>
|
||||
очень медленно или вовсе не загрузится
|
||||
</q-tooltip>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Размер
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.fontSize" bg-color="input" class="col-left" :min="5" :max="200" />
|
||||
|
||||
<div class="col q-pt-xs text-right">
|
||||
<a href="https://fonts.google.com/?subset=cyrillic" target="_blank">Примеры</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Сдвиг
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="vertShift" bg-color="input" class="col-left" :min="-100" :max="100">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Сдвиг шрифта по вертикали в процентах от размера.<br>
|
||||
Отрицательное значение сдвигает вверх, положительное -<br>
|
||||
вниз. Значение зависит от метрики шрифта.
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Стиль
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="fontBold" size="xs" label="Жирный" />
|
||||
<q-checkbox v-model="fontItalic" class="q-ml-sm" size="xs" label="Курсив" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../../vueComponent.js';
|
||||
|
||||
import NumInput from '../../../../share/NumInput.vue';
|
||||
import rstore from '../../../../../store/modules/reader';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
NumInput,
|
||||
},
|
||||
watch: {
|
||||
form: {
|
||||
handler() {
|
||||
this.formChanged();//no await
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
fontBold: function(newValue) {
|
||||
if (!this.isFormChanged)
|
||||
this.form.fontWeight = (newValue ? 'bold' : '');
|
||||
},
|
||||
fontItalic: function(newValue) {
|
||||
if (!this.isFormChanged)
|
||||
this.form.fontStyle = (newValue ? 'italic' : '');
|
||||
},
|
||||
vertShift: function(newValue) {
|
||||
if (!this.isFormChanged) {
|
||||
const font = (this.form.webFontName ? this.form.webFontName : this.form.fontName);
|
||||
if (this.form.fontShifts[font] != newValue || this.form.fontVertShift != newValue) {
|
||||
this.form.fontShifts = Object.assign({}, this.form.fontShifts, {[font]: newValue});
|
||||
this.form.fontVertShift = newValue;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
class Font {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
fontBold = false;
|
||||
fontItalic = false;
|
||||
vertShift = 0;
|
||||
webFonts = [];
|
||||
fonts = [];
|
||||
|
||||
created() {
|
||||
this.formChanged();//no await
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
async formChanged() {
|
||||
this.isFormChanged = true;
|
||||
try {
|
||||
this.fontBold = (this.form.fontWeight == 'bold');
|
||||
this.fontItalic = (this.form.fontStyle == 'italic');
|
||||
|
||||
this.fonts = rstore.fonts;
|
||||
this.webFonts = rstore.webFonts;
|
||||
const font = (this.form.webFontName ? this.form.webFontName : this.form.fontName);
|
||||
this.vertShift = this.form.fontShifts[font] || 0;
|
||||
} finally {
|
||||
await this.$nextTick();
|
||||
this.isFormChanged = false;
|
||||
}
|
||||
}
|
||||
|
||||
get fontsOptions() {
|
||||
let result = [];
|
||||
this.fonts.forEach(font => {
|
||||
result.push({label: (font.label ? font.label : font.name), value: font.name});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
get webFontsOptions() {
|
||||
let result = [{label: 'Нет', value: ''}];
|
||||
this.webFonts.forEach(font => {
|
||||
result.push({label: font.name, value: font.name});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default vueComponent(Font);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.col-left {
|
||||
width: 145px;
|
||||
}
|
||||
</style>
|
||||
244
client/components/Reader/SettingsPage/ViewTab/Mode/Mode.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div>
|
||||
<!---------------------------------------------->
|
||||
<div class="hidden sets-part-header">
|
||||
Режим
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="nightMode" size="xs" label="Ночной режим" @update:modelValue="nightModeToggle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.dualPageMode" size="xs" label="Двухстраничный режим" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-part-header">
|
||||
Страницы
|
||||
</div>
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Отступ границ
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.indentLR" bg-color="input" class="col-left" :min="0" :max="2000">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Слева/справа от края экрана
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
<div class="q-px-sm" />
|
||||
<NumInput v-model="form.indentTB" bg-color="input" class="col" :min="0" :max="2000">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Сверху/снизу от края экрана
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="form.dualPageMode" class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Отступ внутри
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.dualIndentLR" bg-color="input" class="col-left" :min="0" :max="2000">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Слева/справа внутри страницы
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="form.dualPageMode">
|
||||
<div class="sets-part-header">
|
||||
Разделитель
|
||||
</div>
|
||||
|
||||
<div class="sets-item row no-wrap">
|
||||
<div class="sets-label label">
|
||||
Цвет
|
||||
</div>
|
||||
<div class="col-left row">
|
||||
<q-input
|
||||
v-model="dualDivColorFiltered"
|
||||
class="col-left no-mp"
|
||||
bg-color="input"
|
||||
outlined dense
|
||||
:rules="['hexColor']"
|
||||
style="max-width: 150px"
|
||||
:disable="form.dualDivColorAsText"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="helper.colorPanStyle(form.dualDivColor)">
|
||||
<q-popup-proxy anchor="bottom middle" self="top middle">
|
||||
<div>
|
||||
<q-color
|
||||
v-model="form.dualDivColor"
|
||||
no-header default-view="palette" :palette="defPalette.predefineTextColors"
|
||||
/>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<div class="q-px-xs" />
|
||||
<q-checkbox v-model="form.dualDivColorAsText" size="xs" label="Как у текста" />
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Прозрачность
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.dualDivColorAlpha" bg-color="input" class="col-left" :min="0" :max="1" :digits="2" :step="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Ширина (px)
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.dualDivWidth" bg-color="input" class="col-left" :min="0" :max="100">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Ширина разделителя
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Высота (%)
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.dualDivHeight" bg-color="input" class="col-left" :min="0" :max="100">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Высота разделителя
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Пунктир
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.dualDivStrokeFill" bg-color="input" class="col-left" :min="0" :max="2000">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Заполнение пунктира
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
<div class="q-px-sm" />
|
||||
<NumInput v-model="form.dualDivStrokeGap" bg-color="input" class="col" :min="0" :max="2000">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Промежуток пунктира
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Ширина тени
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.dualDivShadowWidth" bg-color="input" class="col-left" :min="0" :max="100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../../vueComponent.js';
|
||||
import NumInput from '../../../../share/NumInput.vue';
|
||||
import * as helper from '../helper';
|
||||
import defPalette from '../defPalette';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
NumInput
|
||||
},
|
||||
watch: {
|
||||
form: {
|
||||
handler() {
|
||||
this.formChanged();//no await
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
dualDivColorFiltered(newValue) {
|
||||
if (!this.isFormChanged && this.helper.isHexColor(newValue))
|
||||
this.form.dualDivColor = newValue;
|
||||
},
|
||||
}
|
||||
};
|
||||
class Mode {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
helper = helper;
|
||||
defPalette = defPalette;
|
||||
|
||||
isFormChanged = false;
|
||||
dualDivColorFiltered = '';
|
||||
nightMode = false;
|
||||
|
||||
created() {
|
||||
this.formChanged();//no await
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
async formChanged() {
|
||||
this.isFormChanged = true;
|
||||
try {
|
||||
this.dualDivColorFiltered = this.form.dualDivColor;
|
||||
|
||||
if (this.form.dualPageMode
|
||||
&& (this.form.pageChangeAnimation == 'flip' || this.form.pageChangeAnimation == 'rightShift')
|
||||
)
|
||||
this.form.pageChangeAnimation = '';
|
||||
|
||||
this.nightMode = this.form.nightMode;
|
||||
} finally {
|
||||
await this.$nextTick();
|
||||
this.isFormChanged = false;
|
||||
}
|
||||
}
|
||||
|
||||
nightModeToggle() {
|
||||
this.$emit('tab-event', {action: 'night-mode'});
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(Mode);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.col-left {
|
||||
width: 145px;
|
||||
}
|
||||
|
||||
.no-mp {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
154
client/components/Reader/SettingsPage/ViewTab/Status/Status.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div>
|
||||
<!---------------------------------------------->
|
||||
<div class="hidden sets-part-header">
|
||||
Строка статуса
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Статус
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.showStatusBar" size="xs" label="Показывать" />
|
||||
<q-checkbox v-show="form.showStatusBar" v-model="form.statusBarTop" class="q-ml-sm" size="xs" label="Вверху/внизу" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="form.showStatusBar" class="sets-item row no-wrap">
|
||||
<div class="sets-label label">
|
||||
Цвет
|
||||
</div>
|
||||
<div class="col-left row">
|
||||
<q-input
|
||||
v-model="statusBarColorFiltered"
|
||||
class="col-left no-mp"
|
||||
bg-color="input"
|
||||
outlined dense
|
||||
:rules="['hexColor']"
|
||||
style="max-width: 150px"
|
||||
:disable="form.statusBarColorAsText"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="la la-angle-down la-xs" class="cursor-pointer text-white" :style="helper.colorPanStyle(form.statusBarColor)">
|
||||
<q-popup-proxy anchor="bottom middle" self="top middle">
|
||||
<div>
|
||||
<q-color
|
||||
v-model="form.statusBarColor"
|
||||
no-header default-view="palette" :palette="defPalette.predefineTextColors"
|
||||
/>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<div class="q-px-xs" />
|
||||
<q-checkbox v-model="form.statusBarColorAsText" size="xs" label="Как у текста" />
|
||||
</div>
|
||||
|
||||
<div v-show="form.showStatusBar" class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Прозрачность
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.statusBarColorAlpha" bg-color="input" class="col-left" :min="0" :max="1" :digits="2" :step="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="form.showStatusBar" class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Высота
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.statusBarHeight" bg-color="input" class="col-left" :min="5" :max="100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="form.showStatusBar" class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.statusBarClickOpen" size="xs" label="Открывать оригинал по клику">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
По клику на автора-название в строке статуса<br>
|
||||
открывать оригинал произведения в новой вкладке
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../../vueComponent.js';
|
||||
import NumInput from '../../../../share/NumInput.vue';
|
||||
import * as helper from '../helper';
|
||||
import defPalette from '../defPalette';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
NumInput,
|
||||
},
|
||||
watch: {
|
||||
form: {
|
||||
handler() {
|
||||
this.formChanged();//no await
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
statusBarColorFiltered(newValue) {
|
||||
if (!this.isFormChanged && this.helper.isHexColor(newValue))
|
||||
this.form.statusBarColor = newValue;
|
||||
},
|
||||
},
|
||||
};
|
||||
class Text {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
helper = helper;
|
||||
defPalette = defPalette;
|
||||
|
||||
statusBarColorFiltered = '';
|
||||
|
||||
created() {
|
||||
this.formChanged();//no await
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
async formChanged() {
|
||||
this.isFormChanged = true;
|
||||
try {
|
||||
this.statusBarColorFiltered = this.form.statusBarColor;
|
||||
} finally {
|
||||
await this.$nextTick();
|
||||
this.isFormChanged = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default vueComponent(Text);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.col-left {
|
||||
width: 145px;
|
||||
}
|
||||
|
||||
.no-mp {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
210
client/components/Reader/SettingsPage/ViewTab/Text/Text.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div>
|
||||
<!---------------------------------------------->
|
||||
<div class="hidden sets-part-header">
|
||||
Текст
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Интервал
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.lineInterval" bg-color="input" class="col-left" :min="0" :max="200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Параграф
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.p" bg-color="input" class="col-left" :min="0" :max="2000" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Сдвиг
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.textVertShift" bg-color="input" class="col-left" :min="-100" :max="100">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Сдвиг текста по вертикали в процентах от размера шрифта.<br>
|
||||
Отрицательное значение сдвигает вверх, положительное -<br>
|
||||
вниз.
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Скроллинг
|
||||
</div>
|
||||
<div class="col row">
|
||||
<NumInput v-model="form.scrollingDelay" bg-color="input" class="col-left" :min="1" :max="10000">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Замедление скроллинга в миллисекундах.<br>
|
||||
Определяет время, за которое текст<br>
|
||||
прокручивается на одну строку.
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
|
||||
<div class="q-px-sm" />
|
||||
<q-select
|
||||
v-model="form.scrollingType" bg-color="input" class="col" :options="['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out']"
|
||||
dropdown-icon="la la-angle-down la-sm"
|
||||
outlined dense emit-value map-options
|
||||
>
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Вид скроллинга: линейный,<br>
|
||||
ускорение-замедление и пр.
|
||||
</q-tooltip>
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Выравнивание
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.textAlignJustify" size="xs" label="По ширине" />
|
||||
<q-checkbox v-model="form.wordWrap" class="q-ml-sm" size="xs" label="Перенос по слогам" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col-left column justify-center text-right">
|
||||
Компактность
|
||||
</div>
|
||||
<div class="q-px-sm" />
|
||||
<NumInput v-model="form.compactTextPerc" bg-color="input" class="col" :min="0" :max="100">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Степень компактности текста в процентах.<br>
|
||||
Чем больше компактность, тем хуже выравнивание<br>
|
||||
по правому краю.
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Обработка
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.cutEmptyParagraphs" size="xs" label="Убирать пустые строки" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col-left column justify-center text-right">
|
||||
Добавлять пустые
|
||||
</div>
|
||||
<div class="q-px-sm" />
|
||||
<NumInput v-model="form.addEmptyParagraphs" bg-color="input" class="col" :min="0" :max="2" />
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label">
|
||||
Изображения
|
||||
</div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.showImages" size="xs" label="Показывать" />
|
||||
<q-checkbox v-model="form.showInlineImagesInCenter" class="q-ml-sm" :disable="!form.showImages" size="xs" label="Инлайн в центр" @update:modelValue="needReload">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Выносить все изображения в центр экрана
|
||||
</q-tooltip>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col row">
|
||||
<q-checkbox v-model="form.imageFitWidth" size="xs" label="Ширина не более размера страницы" :disable="!form.showImages || form.dualPageMode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sets-item row">
|
||||
<div class="sets-label label"></div>
|
||||
<div class="col-left column justify-center text-right">
|
||||
Высота не более
|
||||
</div>
|
||||
<div class="q-px-sm" />
|
||||
<NumInput v-model="form.imageHeightLines" bg-color="input" class="col" :min="1" :max="100" :disable="!form.showImages">
|
||||
<q-tooltip :delay="1000" anchor="top middle" self="bottom middle" content-style="font-size: 80%">
|
||||
Определяет высоту изображения количеством строк.<br>
|
||||
В случае превышения высоты, изображение будет<br>
|
||||
уменьшено с сохранением пропорций так, чтобы<br>
|
||||
помещаться в указанное количество строк.
|
||||
</q-tooltip>
|
||||
</NumInput>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../../vueComponent.js';
|
||||
import NumInput from '../../../../share/NumInput.vue';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
NumInput,
|
||||
},
|
||||
watch: {
|
||||
form: {
|
||||
handler() {
|
||||
this.formChanged();//no await
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
class Text {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
statusBarColorFiltered = '';
|
||||
|
||||
created() {
|
||||
this.formChanged();//no await
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
async formChanged() {
|
||||
this.isFormChanged = true;
|
||||
try {
|
||||
//
|
||||
} finally {
|
||||
await this.$nextTick();
|
||||
this.isFormChanged = false;
|
||||
}
|
||||
}
|
||||
|
||||
needReload() {
|
||||
this.$root.notify.warning('Необходимо обновить страницу (F5), чтобы изменения возымели эффект');
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(Text);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.col-left {
|
||||
width: 145px;
|
||||
}
|
||||
</style>
|
||||
83
client/components/Reader/SettingsPage/ViewTab/ViewTab.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="fit column">
|
||||
<q-tabs
|
||||
v-model="selectedTab"
|
||||
active-color="app"
|
||||
active-bg-color="app"
|
||||
indicator-color="bg-app"
|
||||
dense
|
||||
no-caps
|
||||
class="no-mp bg-menu-2 text-menu"
|
||||
>
|
||||
<q-tab name="mode" label="Режим" />
|
||||
<q-tab name="color" label="Цвет" />
|
||||
<q-tab name="font" label="Шрифт" />
|
||||
<q-tab name="text" label="Текст" />
|
||||
<q-tab name="status" label="Строка статуса" />
|
||||
</q-tabs>
|
||||
|
||||
<div class="q-mb-sm" />
|
||||
|
||||
<div class="col sets-tab-panel">
|
||||
<Mode v-if="selectedTab == 'mode'" :form="form" @tab-event="tabEvent" />
|
||||
<Color v-if="selectedTab == 'color'" :form="form" />
|
||||
<Font v-if="selectedTab == 'font'" :form="form" />
|
||||
<Text v-if="selectedTab == 'text'" :form="form" />
|
||||
<Status v-if="selectedTab == 'status'" :form="form" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../../../vueComponent.js';
|
||||
|
||||
import Mode from './Mode/Mode.vue';
|
||||
import Color from './Color/Color.vue';
|
||||
import Font from './Font/Font.vue';
|
||||
import Text from './Text/Text.vue';
|
||||
import Status from './Status/Status.vue';
|
||||
|
||||
const componentOptions = {
|
||||
components: {
|
||||
Mode,
|
||||
Color,
|
||||
Font,
|
||||
Text,
|
||||
Status,
|
||||
},
|
||||
};
|
||||
class ViewTab {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
form: Object,
|
||||
};
|
||||
|
||||
selectedTab = 'mode';
|
||||
|
||||
created() {
|
||||
}
|
||||
|
||||
mounted() {
|
||||
}
|
||||
|
||||
tabEvent(event) {
|
||||
if (!event || !event.action)
|
||||
return;
|
||||
|
||||
switch (event.action) {
|
||||
case 'night-mode': this.$emit('tab-event', {action: 'night-mode'}); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(ViewTab);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.label {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
</style>
|
||||
45
client/components/Reader/SettingsPage/ViewTab/defPalette.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const defPalette = [
|
||||
'rgb(255,204,204)', 'rgb(255,230,204)', 'rgb(255,255,204)', 'rgb(204,255,204)', 'rgb(204,255,230)',
|
||||
'rgb(204,255,255)', 'rgb(204,230,255)', 'rgb(204,204,255)', 'rgb(230,204,255)', 'rgb(255,204,255)',
|
||||
'rgb(255,153,153)', 'rgb(255,204,153)', 'rgb(255,255,153)', 'rgb(153,255,153)', 'rgb(153,255,204)',
|
||||
'rgb(153,255,255)', 'rgb(153,204,255)', 'rgb(153,153,255)', 'rgb(204,153,255)', 'rgb(255,153,255)',
|
||||
'rgb(255,102,102)', 'rgb(255,179,102)', 'rgb(255,255,102)', 'rgb(102,255,102)', 'rgb(102,255,179)',
|
||||
'rgb(102,255,255)', 'rgb(102,179,255)', 'rgb(102,102,255)', 'rgb(179,102,255)', 'rgb(255,102,255)',
|
||||
'rgb(255,51,51)', 'rgb(255,153,51)', 'rgb(255,255,51)', 'rgb(51,255,51)', 'rgb(51,255,153)', 'rgb(51,255,255)', 'rgb(51,153,255)', 'rgb(51,51,255)', 'rgb(153,51,255)', 'rgb(255,51,255)',
|
||||
'rgb(255,0,0)', 'rgb(255,128,0)', 'rgb(255,255,0)', 'rgb(0,255,0)', 'rgb(0,255,128)', 'rgb(0,255,255)', 'rgb(0,128,255)', 'rgb(0,0,255)', 'rgb(128,0,255)', 'rgb(255,0,255)',
|
||||
'rgb(245,0,0)', 'rgb(245,123,0)', 'rgb(245,245,0)', 'rgb(0,245,0)', 'rgb(0,245,123)', 'rgb(0,245,245)', 'rgb(0,123,245)', 'rgb(0,0,245)', 'rgb(123,0,245)', 'rgb(245,0,245)',
|
||||
'rgb(214,0,0)', 'rgb(214,108,0)', 'rgb(214,214,0)', 'rgb(0,214,0)', 'rgb(0,214,108)', 'rgb(0,214,214)', 'rgb(0,108,214)', 'rgb(0,0,214)', 'rgb(108,0,214)', 'rgb(214,0,214)',
|
||||
'rgb(163,0,0)', 'rgb(163,82,0)', 'rgb(163,163,0)', 'rgb(0,163,0)', 'rgb(0,163,82)', 'rgb(0,163,163)', 'rgb(0,82,163)', 'rgb(0,0,163)', 'rgb(82,0,163)', 'rgb(163,0,163)',
|
||||
'rgb(92,0,0)', 'rgb(92,46,0)', 'rgb(92,92,0)', 'rgb(0,92,0)', 'rgb(0,92,46)', 'rgb(0,92,92)', 'rgb(0,46,92)', 'rgb(0,0,92)', 'rgb(46,0,92)', 'rgb(92,0,92)',
|
||||
'rgb(255,255,255)', 'rgb(205,205,205)', 'rgb(178,178,178)', 'rgb(153,153,153)', 'rgb(127,127,127)', 'rgb(102,102,102)', 'rgb(76,76,76)', 'rgb(51,51,51)', 'rgb(25,25,25)', 'rgb(0,0,0)'
|
||||
];
|
||||
|
||||
export default {
|
||||
predefinePalette: defPalette,
|
||||
|
||||
predefineTextColors: defPalette.concat([
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
'#202020',
|
||||
'#323232',
|
||||
'#aaaaaa',
|
||||
'#00c0c0',
|
||||
'#ebe2c9',
|
||||
'#cfdc99',
|
||||
'#478355',
|
||||
'#909080',
|
||||
]),
|
||||
|
||||
predefineBackgroundColors: defPalette.concat([
|
||||
'#ffffff',
|
||||
'#000000',
|
||||
'#202020',
|
||||
'#ebe2c9',
|
||||
'#cfdc99',
|
||||
'#478355',
|
||||
'#a6caf0',
|
||||
'#909080',
|
||||
'#808080',
|
||||
'#c8c8c8',
|
||||
]),
|
||||
};
|
||||
9
client/components/Reader/SettingsPage/ViewTab/helper.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const hex = /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/;
|
||||
|
||||
export function colorPanStyle(bgColor) {
|
||||
return `width: 30px; height: 30px; border: 1px solid black; border-radius: 4px; background-color: ${bgColor}`;
|
||||
}
|
||||
|
||||
export function isHexColor(value) {
|
||||
return hex.test(value);
|
||||
}
|
||||
@@ -1,14 +1,226 @@
|
||||
import {sleep} from '../../../share/utils';
|
||||
|
||||
export default class DrawHelper {
|
||||
fontBySize(size) {
|
||||
return `${size}px ${this.fontName}`;
|
||||
return `${size}px '${this.fontName}'`;
|
||||
}
|
||||
|
||||
drawPercentBar(x, y, w, h, font, fontSize, bookPos, textLength) {
|
||||
fontByStyle(style) {
|
||||
return `${style.italic ? 'italic' : this.fontStyle} ${style.bold ? 'bold' : this.fontWeight} ${this.fontSize}px '${this.fontName}'`;
|
||||
}
|
||||
|
||||
measureText(text, style) {// eslint-disable-line no-unused-vars
|
||||
this.context.font = this.fontByStyle(style);
|
||||
return this.context.measureText(text).width;
|
||||
}
|
||||
|
||||
measureTextMetrics(text, style) {// eslint-disable-line no-unused-vars
|
||||
this.context.font = this.fontByStyle(style);
|
||||
return this.context.measureText(text);
|
||||
}
|
||||
|
||||
measureTextFont(text, font) {// eslint-disable-line no-unused-vars
|
||||
this.context.font = font;
|
||||
return this.context.measureText(text).width;
|
||||
}
|
||||
|
||||
drawLine(line, lineIndex, baseLineIndex, sel, imageDrawn) {
|
||||
/* line:
|
||||
{
|
||||
begin: Number,
|
||||
end: Number,
|
||||
first: Boolean,
|
||||
last: Boolean,
|
||||
parts: array of {
|
||||
style: {bold: Boolean, italic: Boolean, center: Boolean},
|
||||
image: {local: Boolean, inline: Boolean, id: String, imageLine: Number, lineCount: Number, paraIndex: Number},
|
||||
text: String,
|
||||
}
|
||||
}*/
|
||||
|
||||
let out = '<div>';
|
||||
|
||||
let lineText = '';
|
||||
let center = false;
|
||||
let space = 0;
|
||||
let j = 0;
|
||||
//формируем строку
|
||||
for (const part of line.parts) {
|
||||
let tOpen = '';
|
||||
tOpen += (part.style.bold ? '<b>' : '');
|
||||
tOpen += (part.style.italic ? '<i>' : '');
|
||||
tOpen += (part.style.sup ? '<span style="vertical-align: baseline; position: relative; line-height: 0; top: -0.3em">' : '');
|
||||
tOpen += (part.style.sub ? '<span style="vertical-align: baseline; position: relative; line-height: 0; top: 0.3em">' : '');
|
||||
if (part.style.note) {
|
||||
const t = part.text;
|
||||
const m = this.measureTextMetrics(t, part.style);
|
||||
const d = this.fontSize - 1.1*m.fontBoundingBoxAscent;
|
||||
const w = m.width;
|
||||
const size = (this.fontSize > 18 ? this.fontSize : 18);
|
||||
const pad = size/2;
|
||||
const btnW = (w >= size ? w : size) + pad*2;
|
||||
|
||||
tOpen += `<span style="position: relative;">` +
|
||||
`<span style="position: absolute; background-color: ${this.textColor}; opacity: 0.1; cursor: pointer; pointer-events: auto; ` +
|
||||
`height: ${this.fontSize + pad*2}px; padding: ${pad}px; left: -${(btnW - w)/2 - pad*0.05}px; top: -${pad + d}px; width: ${btnW}px; border-radius: ${size}px;" ` +
|
||||
`onclick="onNoteClickLiberama('${part.style.note.id}', ${part.style.note.orig ? 1 : 0})"><span style="visibility: hidden;" class="dborder">${t}</span></span>`;
|
||||
}
|
||||
let tClose = '';
|
||||
tClose += (part.style.note ? '</span>' : '');
|
||||
tClose += (part.style.sub ? '</span>' : '');
|
||||
tClose += (part.style.sup ? '</span>' : '');
|
||||
tClose += (part.style.italic ? '</i>' : '');
|
||||
tClose += (part.style.bold ? '</b>' : '');
|
||||
|
||||
let text = '';
|
||||
if (lineIndex == 0 && this.searching) {
|
||||
for (let k = 0; k < part.text.length; k++) {
|
||||
text += (sel.has(j) ? `<ins>${part.text[k]}</ins>` : part.text[k]);
|
||||
j++;
|
||||
}
|
||||
} else
|
||||
text = part.text;
|
||||
|
||||
if (text && text.trim() == '')
|
||||
text = `<span style="white-space: pre">${text}</span>`;
|
||||
|
||||
lineText += `${tOpen}${text}${tClose}`;
|
||||
|
||||
center = center || part.style.center;
|
||||
space = (part.style.space > space ? part.style.space : space);
|
||||
|
||||
//избражения
|
||||
//image: {local: Boolean, inline: Boolean, id: String, imageLine: Number, lineCount: Number, paraIndex: Number, w: Number, h: Number},
|
||||
const img = part.image;
|
||||
if (img && img.id && !img.inline && !imageDrawn.has(img.paraIndex)) {
|
||||
const bin = this.parsed.binary[img.id];
|
||||
if (bin) {
|
||||
let resize = '';
|
||||
if (bin.h > img.h) {
|
||||
resize = `height: ${img.h}px`;
|
||||
}
|
||||
|
||||
const left = (this.w - img.w)/2;
|
||||
const top = ((img.lineCount*this.lineHeight - img.h)/2) + (lineIndex - baseLineIndex - img.imageLine)*this.lineHeight;
|
||||
if (img.local) {
|
||||
lineText += `<img src="data:${bin.type};base64,${bin.data}" style="position: absolute; left: ${left}px; top: ${top}px; ${resize}"/>`;
|
||||
} else {
|
||||
lineText += `<img src="${img.id}" style="position: absolute; left: ${left}px; top: ${top}px; ${resize}"/>`;
|
||||
}
|
||||
}
|
||||
imageDrawn.add(img.paraIndex);
|
||||
}
|
||||
|
||||
if (img && img.id && img.inline) {
|
||||
if (img.local) {
|
||||
const bin = this.parsed.binary[img.id];
|
||||
if (bin) {
|
||||
let resize = '';
|
||||
if (bin.h > this.fontSize) {
|
||||
resize = `height: ${this.fontSize - 3}px`;
|
||||
}
|
||||
lineText += `<img src="data:${bin.type};base64,${bin.data}" style="${resize}"/>`;
|
||||
}
|
||||
} else {
|
||||
//
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const centerStyle = (center ? `text-align: center; text-align-last: center; width: ${this.w}px` : '')
|
||||
if ((line.first || space) && !center) {
|
||||
let p = (line.first ? this.p : 0);
|
||||
p = (space ? p + this.p*space : p);
|
||||
lineText = `<span style="display: inline-block; margin-left: ${p}px"></span>${lineText}`;
|
||||
}
|
||||
|
||||
if (line.last || center)
|
||||
lineText = `<span style="display: inline-block; ${centerStyle}">${lineText}</span>`;
|
||||
|
||||
out += lineText + '</div>';
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
drawPage(lines, isScrolling) {
|
||||
if (!this.lastBook || this.pageLineCount < 1 || !this.book || !lines || !this.parsed.textLength)
|
||||
return '';
|
||||
|
||||
const font = this.fontByStyle({});
|
||||
const justify = (this.textAlignJustify ? 'text-align: justify; text-align-last: justify;' : '');
|
||||
|
||||
const boxH = this.h + (isScrolling ? this.lineHeight : 0);
|
||||
let out = `<div class="row no-wrap" style="width: ${this.boxW}px; height: ${boxH}px;` +
|
||||
` position: absolute; top: ${this.fontSize*this.textShift}px; color: ${this.textColor}; font: ${font}; ${justify}` +
|
||||
` line-height: ${this.lineHeight}px; white-space: nowrap;">`;
|
||||
|
||||
let imageDrawn1 = new Set();
|
||||
let imageDrawn2 = new Set();
|
||||
let len = lines.length;
|
||||
const lineCount = this.pageLineCount + (isScrolling ? 1 : 0);
|
||||
len = (len > lineCount ? lineCount : len);
|
||||
|
||||
//поиск
|
||||
let sel = new Set();
|
||||
if (len > 0 && this.searching) {
|
||||
const line = lines[0];
|
||||
let pureText = '';
|
||||
for (const part of line.parts) {
|
||||
pureText += part.text;
|
||||
}
|
||||
|
||||
pureText = pureText.toLowerCase();
|
||||
let j = 0;
|
||||
while (1) {// eslint-disable-line no-constant-condition
|
||||
j = pureText.indexOf(this.needle, j);
|
||||
if (j >= 0) {
|
||||
for (let k = 0; k < this.needle.length; k++) {
|
||||
sel.add(j + k);
|
||||
}
|
||||
} else
|
||||
break;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
//отрисовка строк
|
||||
if (!this.dualPageMode) {
|
||||
out += `<div class="fit">`;
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += this.drawLine(lines[i], i, 0, sel, imageDrawn1);
|
||||
}
|
||||
out += `</div>`;
|
||||
} else {
|
||||
//левая страница
|
||||
out += `<div style="width: ${this.w}px; margin-left: ${this.dualIndentLR}px; position: relative;">`;
|
||||
const l2 = (this.pageRowsCount > len ? len : this.pageRowsCount);
|
||||
for (let i = 0; i < l2; i++) {
|
||||
out += this.drawLine(lines[i], i, 0, sel, imageDrawn1);
|
||||
}
|
||||
out += '</div>';
|
||||
|
||||
//разделитель
|
||||
out += `<div style="width: ${this.dualIndentLR*2}px;"></div>`;
|
||||
|
||||
//правая страница
|
||||
out += `<div style="width: ${this.w}px; margin-right: ${this.dualIndentLR}px; position: relative;">`;
|
||||
for (let i = l2; i < len; i++) {
|
||||
out += this.drawLine(lines[i], i, l2, sel, imageDrawn2);
|
||||
}
|
||||
out += '</div>';
|
||||
}
|
||||
|
||||
out += '</div>';
|
||||
return out;
|
||||
}
|
||||
|
||||
drawPercentBar(x, y, w, h, font, fontSize, bookPos, textLength, imageNum, imageLength) {
|
||||
const pad = 3;
|
||||
const fh = h - 2*pad;
|
||||
const fh2 = fh/2;
|
||||
|
||||
const t1 = `${Math.floor((bookPos + 1)/1000)}k/${Math.floor(textLength/1000)}k`;
|
||||
const tImg = (imageNum > 0 ? ` (${imageNum}/${imageLength})` : '');
|
||||
const t1 = `${Math.floor((bookPos + 1)/1000)}/${Math.floor(textLength/1000)}${tImg}`;
|
||||
const w1 = this.measureTextFont(t1, font) + fh2;
|
||||
const read = (bookPos + 1)/textLength;
|
||||
const t2 = `${(read*100).toFixed(2)}%`;
|
||||
@@ -21,8 +233,8 @@ export default class DrawHelper {
|
||||
|
||||
if (w1 + w2 + w3 <= w && w3 > (10 + fh2)) {
|
||||
const barWidth = w - w1 - w2 - fh2;
|
||||
out += this.strokeRect(x + w1, y + pad, barWidth, fh - 2, this.statusBarColor);
|
||||
out += this.fillRect(x + w1 + 2, y + pad + 2, (barWidth - 4)*read, fh - 6, this.statusBarColor);
|
||||
out += this.strokeRect(x + w1, y + pad, barWidth, fh - 2, this.statusBarRgbaColor);
|
||||
out += this.fillRect(x + w1 + 2, y + pad + 2, (barWidth - 4)*read, fh - 6, this.statusBarRgbaColor);
|
||||
}
|
||||
|
||||
if (w1 <= w)
|
||||
@@ -31,16 +243,16 @@ export default class DrawHelper {
|
||||
return out;
|
||||
}
|
||||
|
||||
drawStatusBar(statusBarTop, statusBarHeight, bookPos, textLength, title) {
|
||||
drawStatusBar(statusBarTop, statusBarHeight, bookPos, textLength, title, imageNum, imageLength) {
|
||||
|
||||
let out = `<div class="layout" style="` +
|
||||
`width: ${this.realWidth}px; height: ${statusBarHeight}px; ` +
|
||||
`color: ${this.statusBarColor}">`;
|
||||
`color: ${this.statusBarRgbaColor}">`;
|
||||
|
||||
const fontSize = statusBarHeight*0.75;
|
||||
const font = 'bold ' + this.fontBySize(fontSize);
|
||||
|
||||
out += this.fillRect(0, (statusBarTop ? statusBarHeight : 0), this.realWidth, 1, this.statusBarColor);
|
||||
out += this.fillRect(0, (statusBarTop ? statusBarHeight : 0), this.realWidth, 1, this.statusBarRgbaColor);
|
||||
|
||||
const date = new Date();
|
||||
const time = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
@@ -49,7 +261,7 @@ export default class DrawHelper {
|
||||
|
||||
out += this.fillTextShift(this.fittingString(title, this.realWidth/2 - fontSize - 3, font), fontSize, 2, font, fontSize);
|
||||
|
||||
out += this.drawPercentBar(this.realWidth/2, 2, this.realWidth/2 - timeW - 2*fontSize, statusBarHeight, font, fontSize, bookPos, textLength);
|
||||
out += this.drawPercentBar(this.realWidth/2 + fontSize, 2, this.realWidth/2 - timeW - 3*fontSize, statusBarHeight, font, fontSize, bookPos, textLength, imageNum, imageLength);
|
||||
|
||||
out += '</div>';
|
||||
return out;
|
||||
@@ -95,4 +307,127 @@ export default class DrawHelper {
|
||||
return `<div style="position: absolute; left: ${x}px; top: ${y}px; ` +
|
||||
`width: ${w}px; height: ${h}px; box-sizing: border-box; border: 1px solid ${color}"></div>`;
|
||||
}
|
||||
|
||||
async doPageAnimationThaw(page1, page2, duration, isDown, animation1Finish) {
|
||||
page1.style.animation = `page1-animation-thaw ${duration}ms ease-in 1`;
|
||||
page2.style.animation = `page2-animation-thaw ${duration}ms ease-in 1`;
|
||||
await animation1Finish(duration);
|
||||
}
|
||||
|
||||
async doPageAnimationBlink(page1, page2, duration, isDown, animation1Finish, animation2Finish) {
|
||||
page1.style.opacity = '0';
|
||||
page2.style.opacity = '0';
|
||||
page2.style.animation = `page2-animation-thaw ${duration/2}ms ease-out 1`;
|
||||
await animation2Finish(duration/2);
|
||||
|
||||
page1.style.opacity = '1';
|
||||
page1.style.animation = `page1-animation-thaw ${duration/2}ms ease-in 1`;
|
||||
await animation1Finish(duration/2);
|
||||
|
||||
page2.style.opacity = '1';
|
||||
}
|
||||
|
||||
async doPageAnimationRightShift(page1, page2, duration, isDown, animation1Finish) {
|
||||
const s = this.boxW + this.fontSize;
|
||||
|
||||
if (isDown) {
|
||||
page1.style.transform = `translateX(${s}px)`;
|
||||
await sleep(30);
|
||||
|
||||
page1.style.transition = `${duration}ms ease-in-out`;
|
||||
page1.style.transform = `translateX(0px)`;
|
||||
|
||||
page2.style.transition = `${duration}ms ease-in-out`;
|
||||
page2.style.transform = `translateX(-${s}px)`;
|
||||
await animation1Finish(duration);
|
||||
} else {
|
||||
page1.style.transform = `translateX(-${s}px)`;
|
||||
await sleep(30);
|
||||
|
||||
page1.style.transition = `${duration}ms ease-in-out`;
|
||||
page1.style.transform = `translateX(0px)`;
|
||||
|
||||
page2.style.transition = `${duration}ms ease-in-out`;
|
||||
page2.style.transform = `translateX(${s}px)`;
|
||||
await animation1Finish(duration);
|
||||
}
|
||||
}
|
||||
|
||||
async doPageAnimationDownShift(page1, page2, duration, isDown, animation1Finish) {
|
||||
const s = this.h + this.fontSize/2;
|
||||
|
||||
if (isDown) {
|
||||
page1.style.transform = `translateY(${s}px)`;
|
||||
await sleep(30);
|
||||
|
||||
page1.style.transition = `${duration}ms ease-in-out`;
|
||||
page1.style.transform = `translateY(0px)`;
|
||||
|
||||
page2.style.transition = `${duration}ms ease-in-out`;
|
||||
page2.style.transform = `translateY(-${s}px)`;
|
||||
await animation1Finish(duration);
|
||||
} else {
|
||||
page1.style.transform = `translateY(-${s}px)`;
|
||||
await sleep(30);
|
||||
|
||||
page1.style.transition = `${duration}ms ease-in-out`;
|
||||
page1.style.transform = `translateY(0px)`;
|
||||
|
||||
page2.style.transition = `${duration}ms ease-in-out`;
|
||||
page2.style.transform = `translateY(${s}px)`;
|
||||
await animation1Finish(duration);
|
||||
}
|
||||
}
|
||||
|
||||
async doPageAnimationRotate(page1, page2, duration, isDown, animation1Finish, animation2Finish) {
|
||||
if (isDown) {
|
||||
page1.style.transform = `rotateY(90deg)`;
|
||||
await sleep(30);
|
||||
|
||||
page2.style.transition = `${duration/2}ms ease-in`;
|
||||
page2.style.transform = `rotateY(-90deg)`;
|
||||
|
||||
await animation2Finish(duration/2);
|
||||
|
||||
page1.style.transition = `${duration/2}ms ease-out`;
|
||||
page1.style.transform = `rotateY(0deg)`;
|
||||
await animation1Finish(duration/2);
|
||||
} else {
|
||||
page1.style.transform = `rotateY(-90deg)`;
|
||||
await sleep(30);
|
||||
|
||||
page2.style.transition = `${duration/2}ms ease-in`;
|
||||
page2.style.transform = `rotateY(90deg)`;
|
||||
|
||||
await animation2Finish(duration/2);
|
||||
|
||||
page1.style.transition = `${duration/2}ms ease-out`;
|
||||
page1.style.transform = `rotateY(0deg)`;
|
||||
await animation1Finish(duration/2);
|
||||
}
|
||||
}
|
||||
|
||||
async doPageAnimationFlip(page1, page2, duration, isDown, animation1Finish, animation2Finish, backgroundColor) {
|
||||
page2.style.background = backgroundColor;
|
||||
|
||||
if (isDown) {
|
||||
page2.style.transformOrigin = '5%';
|
||||
await sleep(30);
|
||||
|
||||
page2.style.transition = `${duration}ms ease-in-out`;
|
||||
page2.style.transform = `rotateY(-120deg) translateX(${this.w/4}px)`;
|
||||
await animation2Finish(duration);
|
||||
} else {
|
||||
page2.style.transformOrigin = '95%';
|
||||
await sleep(30);
|
||||
|
||||
page2.style.transition = `${duration}ms ease-in-out`;
|
||||
page2.style.transform = `rotateY(120deg) translateX(-${this.w/4}px)`;
|
||||
await animation2Finish(duration);
|
||||
}
|
||||
|
||||
page2.style.transformOrigin = 'center';
|
||||
page2.style.background = '';
|
||||
}
|
||||
|
||||
}
|
||||
93
client/components/Reader/TextPage/TextPage.css
Normal file
@@ -0,0 +1,93 @@
|
||||
@keyframes page1-animation-thaw {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes page2-animation-thaw {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.paper1 {
|
||||
background: url("images/paper1.jpg") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper2 {
|
||||
background: url("images/paper2.jpg") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper3 {
|
||||
background: url("images/paper3.jpg") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper4 {
|
||||
background: url("images/paper4.jpg") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper5 {
|
||||
background: url("images/paper5.jpg") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper6 {
|
||||
background: url("images/paper6.jpg") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper7 {
|
||||
background: url("images/paper7.jpg") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper8 {
|
||||
background: url("images/paper8.jpg") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper9 {
|
||||
background: url("images/paper9.jpg");
|
||||
}
|
||||
|
||||
.paper10 {
|
||||
background: url("images/paper10.png") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper11 {
|
||||
background: url("images/paper11.png") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper12 {
|
||||
background: url("images/paper12.png") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper13 {
|
||||
background: url("images/paper13.png") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper14 {
|
||||
background: url("images/paper14.png") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper15 {
|
||||
background: url("images/paper15.png") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper16 {
|
||||
background: url("images/paper16.png") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.paper17 {
|
||||
background: url("images/paper17.png") center;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
BIN
client/components/Reader/TextPage/images/paper10.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
client/components/Reader/TextPage/images/paper11.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
client/components/Reader/TextPage/images/paper12.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
client/components/Reader/TextPage/images/paper13.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
client/components/Reader/TextPage/images/paper14.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
client/components/Reader/TextPage/images/paper15.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
client/components/Reader/TextPage/images/paper16.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
client/components/Reader/TextPage/images/paper17.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
@@ -1,48 +1,143 @@
|
||||
import localForage from 'localforage';
|
||||
import path from 'path-browserify';
|
||||
import _ from 'lodash';
|
||||
|
||||
import * as utils from '../../../share/utils';
|
||||
import BookParser from './BookParser';
|
||||
import readerApi from '../../../api/reader';
|
||||
import coversStorage from './coversStorage';
|
||||
import * as utils from '../../../share/utils';
|
||||
|
||||
const maxDataSize = 500*1024*1024;//chars, not bytes
|
||||
const maxDataSize = 500*1024*1024;//compressed bytes
|
||||
const maxRecentLength = 5000;
|
||||
|
||||
//локальный кэш метаданных книг, ограничение maxDataSize
|
||||
const bmMetaStore = localForage.createInstance({
|
||||
name: 'bmMetaStore'
|
||||
});
|
||||
|
||||
//локальный кэш самих книг, ограничение maxDataSize
|
||||
const bmDataStore = localForage.createInstance({
|
||||
name: 'bmDataStore'
|
||||
});
|
||||
|
||||
const bmRecentStore = localForage.createInstance({
|
||||
name: 'bmRecentStore'
|
||||
//список недавно открытых книг
|
||||
const bmRecentStoreNew = localForage.createInstance({
|
||||
name: 'bmRecentStoreNew'
|
||||
});
|
||||
|
||||
class BookManager {
|
||||
async init() {
|
||||
async init(settings) {
|
||||
this.loaded = false;
|
||||
this.settings = settings;
|
||||
|
||||
this.eventListeners = [];
|
||||
this.books = {};
|
||||
|
||||
this.recent = {};
|
||||
this.saveRecent = _.debounce(() => {
|
||||
bmRecentStoreNew.setItem('recent', this.recent);
|
||||
}, 300, {maxWait: 800});
|
||||
|
||||
this.saveRecentItem = _.debounce(() => {
|
||||
bmRecentStoreNew.setItem('recent-item', this.recentItem);
|
||||
this.recentRev = (this.recentRev < maxRecentLength ? this.recentRev + 1 : 1);
|
||||
bmRecentStoreNew.setItem('rev', this.recentRev);
|
||||
}, 200, {maxWait: 300});
|
||||
|
||||
//загрузка bmRecentStore
|
||||
this.recentRev = await bmRecentStoreNew.getItem('rev') || 0;
|
||||
if (this.recentRev) {
|
||||
this.recent = await bmRecentStoreNew.getItem('recent');
|
||||
if (!this.recent)
|
||||
this.recent = {};
|
||||
|
||||
this.recentItem = await bmRecentStoreNew.getItem('recent-item');
|
||||
if (this.recentItem)
|
||||
this.recent[this.recentItem.key] = this.recentItem;
|
||||
|
||||
//конвертируем в новые ключи
|
||||
await this.convertRecent();
|
||||
|
||||
this.recentLastKey = await bmRecentStoreNew.getItem('recent-last-key');
|
||||
if (this.recentLastKey) {
|
||||
const meta = await bmMetaStore.getItem(`bmMeta-${this.recentLastKey}`);
|
||||
if (_.isObject(meta)) {
|
||||
this.books[meta.key] = meta;
|
||||
}
|
||||
}
|
||||
|
||||
await this.cleanRecentBooks();
|
||||
}
|
||||
|
||||
this.recentChanged = true;
|
||||
|
||||
this.loadStored();//no await
|
||||
}
|
||||
|
||||
//TODO: убрать в 2025г
|
||||
async convertRecent() {
|
||||
const converted = await bmRecentStoreNew.getItem('recent-converted');
|
||||
|
||||
if (converted)
|
||||
return;
|
||||
|
||||
const newRecent = {};
|
||||
for (const book of Object.values(this.recent)) {
|
||||
|
||||
if (!book.path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newKey = this.keyFromPath(book.path);
|
||||
|
||||
newRecent[newKey] = _.cloneDeep(book);
|
||||
newRecent[newKey].key = newKey;
|
||||
if (!newRecent[newKey].loadTime)
|
||||
newRecent[newKey].loadTime = newRecent[newKey].addTime;
|
||||
}
|
||||
|
||||
this.recent = newRecent;
|
||||
|
||||
//console.log(converted);
|
||||
(async() => {
|
||||
await utils.sleep(3000);
|
||||
this.saveRecent();
|
||||
this.emit('recent-changed');
|
||||
this.emit('set-recent');
|
||||
await bmRecentStoreNew.setItem('recent-converted', true);
|
||||
})();
|
||||
}
|
||||
|
||||
//Ленивая асинхронная загрузка bmMetaStore
|
||||
async loadStored() {
|
||||
//даем время для загрузки последней читаемой книги, чтобы не блокировать приложение
|
||||
await utils.sleep(2000);
|
||||
|
||||
let len = await bmMetaStore.length();
|
||||
for (let i = 0; i < len; i++) {
|
||||
for (let i = len - 1; i >= 0; i--) {
|
||||
const key = await bmMetaStore.key(i);
|
||||
const keySplit = key.split('-');
|
||||
|
||||
if (keySplit.length == 2 && keySplit[0] == 'bmMeta') {
|
||||
let meta = await bmMetaStore.getItem(key);
|
||||
|
||||
this.books[meta.key] = meta;
|
||||
if (_.isObject(meta)) {
|
||||
//уже может быть распарсена книга
|
||||
const oldBook = this.books[meta.key];
|
||||
this.books[meta.key] = meta;
|
||||
|
||||
if (oldBook && oldBook.parsed) {
|
||||
this.books[meta.key].parsed = oldBook.parsed;
|
||||
}
|
||||
} else {
|
||||
await bmMetaStore.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
len = await bmRecentStore.length();
|
||||
for (let i = 0; i < len; i++) {
|
||||
const key = await bmRecentStore.key(i);
|
||||
let r = await bmRecentStore.getItem(key);
|
||||
this.recent[r.key] = r;
|
||||
}
|
||||
|
||||
await this.cleanBooks();
|
||||
this.loaded = true;
|
||||
this.emit('load-stored-finish');
|
||||
}
|
||||
|
||||
async cleanBooks() {
|
||||
@@ -52,7 +147,8 @@ class BookManager {
|
||||
let toDel = null;
|
||||
for (let key in this.books) {
|
||||
let book = this.books[key];
|
||||
size += (book.length ? book.length : 0);
|
||||
const bookLength = (book.length ? book.length : 0);
|
||||
size += (book.dataCompressedLength ? book.dataCompressedLength : bookLength);
|
||||
|
||||
if (book.addTime < min) {
|
||||
toDel = book;
|
||||
@@ -68,49 +164,176 @@ class BookManager {
|
||||
}
|
||||
}
|
||||
|
||||
async addBook(newBook, callback) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
let meta = {url: newBook.url, path: newBook.path};
|
||||
meta.key = this.keyFromUrl(meta.url);
|
||||
meta.addTime = Date.now();
|
||||
async deflateWithProgress(data, callback) {
|
||||
const chunkSize = 512*1024;
|
||||
const deflator = new utils.pako.Deflate({level: 5});
|
||||
|
||||
const result = await this.parseBook(meta, newBook.data, callback);
|
||||
let chunkTotal = 1 + Math.floor(data.length/chunkSize);
|
||||
let chunkNum = 0;
|
||||
let perc = 0;
|
||||
let prevPerc = 0;
|
||||
|
||||
for (var i = 0; i < data.length; i += chunkSize) {
|
||||
if ((i + chunkSize) >= data.length) {
|
||||
deflator.push(data.substring(i, i + chunkSize), true);
|
||||
} else {
|
||||
deflator.push(data.substring(i, i + chunkSize), false);
|
||||
}
|
||||
chunkNum++;
|
||||
|
||||
perc = Math.round(chunkNum/chunkTotal*100);
|
||||
if (perc != prevPerc) {
|
||||
callback(perc);
|
||||
await utils.sleep(1);
|
||||
prevPerc = perc;
|
||||
}
|
||||
}
|
||||
|
||||
if (deflator.err) {
|
||||
throw new Error(deflator.msg);
|
||||
}
|
||||
|
||||
callback(100);
|
||||
|
||||
return deflator.result;
|
||||
}
|
||||
|
||||
async inflateWithProgress(data, callback) {
|
||||
const chunkSize = 512*1024;
|
||||
const inflator = new utils.pako.Inflate({to: 'string'});
|
||||
|
||||
let chunkTotal = 1 + Math.floor(data.length/chunkSize);
|
||||
let chunkNum = 0;
|
||||
let perc = 0;
|
||||
let prevPerc = 0;
|
||||
|
||||
for (var i = 0; i < data.length; i += chunkSize) {
|
||||
if ((i + chunkSize) >= data.length) {
|
||||
inflator.push(data.subarray(i, i + chunkSize), true);
|
||||
} else {
|
||||
inflator.push(data.subarray(i, i + chunkSize), false);
|
||||
}
|
||||
chunkNum++;
|
||||
|
||||
perc = Math.round(chunkNum/chunkTotal*100);
|
||||
if (perc != prevPerc) {
|
||||
callback(perc);
|
||||
await utils.sleep(1);
|
||||
prevPerc = perc;
|
||||
}
|
||||
}
|
||||
|
||||
if (inflator.err) {
|
||||
throw new Error(inflator.msg);
|
||||
}
|
||||
|
||||
callback(100);
|
||||
|
||||
return inflator.result;
|
||||
}
|
||||
|
||||
async addBook(newBook, callback) {
|
||||
let meta = {url: newBook.url, path: newBook.path};
|
||||
|
||||
if (newBook.downloadSize !== undefined && newBook.downloadSize >= 0)
|
||||
meta.downloadSize = newBook.downloadSize;
|
||||
|
||||
meta.key = this.keyFromPath(meta.path);
|
||||
meta.addTime = Date.now();//время добавления в кеш
|
||||
|
||||
const cb = (perc) => {
|
||||
const p = Math.round(30*perc/100);
|
||||
callback(p);
|
||||
};
|
||||
|
||||
const cb2 = (perc) => {
|
||||
const p = Math.round(30 + 65*perc/100);
|
||||
callback(p);
|
||||
};
|
||||
|
||||
const result = await this.parseBook(meta, newBook.data, cb);
|
||||
result.dataCompressed = true;
|
||||
|
||||
let data = newBook.data;
|
||||
if (result.dataCompressed) {
|
||||
//data = utils.pako.deflate(data, {level: 5});
|
||||
data = await this.deflateWithProgress(data, cb2);
|
||||
result.dataCompressedLength = data.byteLength;
|
||||
}
|
||||
callback(95);
|
||||
|
||||
this.books[meta.key] = result;
|
||||
|
||||
await bmMetaStore.setItem(`bmMeta-${meta.key}`, this.metaOnly(result));
|
||||
await bmDataStore.setItem(`bmData-${meta.key}`, result.data);
|
||||
await bmDataStore.setItem(`bmData-${meta.key}`, data);
|
||||
|
||||
callback(100);
|
||||
return result;
|
||||
}
|
||||
|
||||
hasBookParsed(meta) {
|
||||
async hasBookParsed(meta) {
|
||||
if (!this.books)
|
||||
return false;
|
||||
if (!meta.url)
|
||||
if (!meta.path)
|
||||
return false;
|
||||
if (!meta.key)
|
||||
meta.key = this.keyFromUrl(meta.url);
|
||||
meta.key = this.keyFromPath(meta.path);
|
||||
|
||||
let book = this.books[meta.key];
|
||||
|
||||
if (!book && !this.loaded) {
|
||||
book = await bmMetaStore.getItem(`bmMeta-${meta.key}`);
|
||||
if (book)
|
||||
this.books[meta.key] = book;
|
||||
}
|
||||
|
||||
return !!(book && book.parsed);
|
||||
}
|
||||
|
||||
async getBook(meta, callback) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
let result = undefined;
|
||||
|
||||
if (!meta.path)
|
||||
return;
|
||||
|
||||
if (!meta.key)
|
||||
meta.key = this.keyFromUrl(meta.url);
|
||||
meta.key = this.keyFromPath(meta.path);
|
||||
|
||||
result = this.books[meta.key];
|
||||
|
||||
if (result && !result.data) {
|
||||
result.data = await bmDataStore.getItem(`bmData-${meta.key}`);
|
||||
this.books[meta.key] = result;
|
||||
if (!result) {
|
||||
result = await bmMetaStore.getItem(`bmMeta-${meta.key}`);
|
||||
if (result)
|
||||
this.books[meta.key] = result;
|
||||
}
|
||||
|
||||
if (result && !result.parsed) {
|
||||
result = await this.parseBook(result, result.data, callback);
|
||||
let data = await bmDataStore.getItem(`bmData-${meta.key}`);
|
||||
callback(5);
|
||||
await utils.sleep(10);
|
||||
|
||||
let cb = (perc) => {
|
||||
const p = 5 + Math.round(15*perc/100);
|
||||
callback(p);
|
||||
};
|
||||
|
||||
if (result.dataCompressed) {
|
||||
try {
|
||||
//data = utils.pako.inflate(data, {to: 'string'});
|
||||
data = await this.inflateWithProgress(data, cb);
|
||||
} catch (e) {
|
||||
this.delBook(meta);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
callback(20);
|
||||
|
||||
cb = (perc) => {
|
||||
const p = 20 + Math.round(80*perc/100);
|
||||
callback(p);
|
||||
};
|
||||
|
||||
result = await this.parseBook(result, data, cb);
|
||||
this.books[meta.key] = result;
|
||||
}
|
||||
|
||||
@@ -118,9 +341,6 @@ class BookManager {
|
||||
}
|
||||
|
||||
async delBook(meta) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
|
||||
await bmMetaStore.removeItem(`bmMeta-${meta.key}`);
|
||||
await bmDataStore.removeItem(`bmData-${meta.key}`);
|
||||
|
||||
@@ -128,15 +348,41 @@ class BookManager {
|
||||
}
|
||||
|
||||
async parseBook(meta, data, callback) {
|
||||
if (!this.books)
|
||||
await this.init();
|
||||
const parsed = new BookParser();
|
||||
const parsed = new BookParser(this.settings);
|
||||
|
||||
const parsedMeta = await parsed.parse(data, callback);
|
||||
|
||||
//cover page
|
||||
let coverPageUrl = '';
|
||||
if (parsed.coverPageId && parsed.binary[parsed.coverPageId]) {
|
||||
const bin = parsed.binary[parsed.coverPageId];
|
||||
let dataUrl = `data:${bin.type};base64,${bin.data}`;
|
||||
try {
|
||||
dataUrl = await utils.resizeImage(dataUrl, 160, 160, 0.94);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
coverPageUrl = readerApi.makeUrlFromBuf(dataUrl);
|
||||
|
||||
//далее асинхронно
|
||||
(async() => {
|
||||
//отправим dataUrl на сервер в /upload
|
||||
try {
|
||||
await readerApi.uploadFileBuf(dataUrl, coverPageUrl);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
//сохраним в storage
|
||||
await coversStorage.setData(coverPageUrl, dataUrl);
|
||||
})();
|
||||
}
|
||||
|
||||
const result = Object.assign({}, meta, parsedMeta, {
|
||||
length: data.length,
|
||||
textLength: parsed.textLength,
|
||||
data,
|
||||
coverPageUrl,
|
||||
parsed
|
||||
});
|
||||
|
||||
@@ -145,90 +391,282 @@ class BookManager {
|
||||
|
||||
metaOnly(book) {
|
||||
let result = Object.assign({}, book);
|
||||
delete result.data;
|
||||
delete result.parsed;
|
||||
return result;
|
||||
}
|
||||
|
||||
keyFromUrl(url) {
|
||||
/*keyFromUrl(url) {
|
||||
return utils.stringToHex(url);
|
||||
}*/
|
||||
|
||||
keyFromPath(bookPath) {
|
||||
return path.basename(bookPath);
|
||||
}
|
||||
|
||||
async setRecentBook(value, noTouch) {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
const result = Object.assign({}, value);
|
||||
if (!noTouch)
|
||||
Object.assign(result, {touchTime: Date.now()});
|
||||
keysEqual(bookPath1, bookPath2) {
|
||||
if (bookPath1 === undefined || bookPath2 === undefined)
|
||||
return false;
|
||||
|
||||
return (this.keyFromPath(bookPath1) === this.keyFromPath(bookPath2));
|
||||
}
|
||||
//-- recent --------------------------------------------------------------
|
||||
async recentSetItem(item = null, skipCheck = false) {
|
||||
const rev = await bmRecentStoreNew.getItem('rev');
|
||||
if (rev != this.recentRev && !skipCheck) {//если изменение произошло в другой вкладке барузера
|
||||
const newRecent = await bmRecentStoreNew.getItem('recent');
|
||||
Object.assign(this.recent, newRecent);
|
||||
this.recentItem = await bmRecentStoreNew.getItem('recent-item');
|
||||
this.recentRev = rev;
|
||||
}
|
||||
|
||||
if (result.textLength && !result.bookPos && result.bookPosPercent)
|
||||
result.bookPos = Math.round(result.bookPosPercent*result.textLength);
|
||||
const prevKey = (this.recentItem ? this.recentItem.key : '');
|
||||
if (item) {
|
||||
this.recent[item.key] = item;
|
||||
this.recentItem = item;
|
||||
} else {
|
||||
this.recentItem = null;
|
||||
}
|
||||
|
||||
this.recent[result.key] = result;
|
||||
this.saveRecentItem();
|
||||
|
||||
await bmRecentStore.setItem(result.key, result);
|
||||
await this.cleanRecentBooks();
|
||||
if (!item || prevKey != item.key) {
|
||||
this.saveRecent();
|
||||
}
|
||||
|
||||
this.recentChanged = true;
|
||||
|
||||
if (item) {
|
||||
this.emit('recent-changed', item.key);
|
||||
} else {
|
||||
this.emit('recent-changed');
|
||||
}
|
||||
}
|
||||
|
||||
async recentSetLastKey(key) {
|
||||
this.recentLastKey = key;
|
||||
await bmRecentStoreNew.setItem('recent-last-key', this.recentLastKey);
|
||||
}
|
||||
|
||||
async setRecentBook(value) {
|
||||
let result = this.metaOnly(value);
|
||||
result.touchTime = Date.now();//время последнего чтения
|
||||
if (!result.loadTime)
|
||||
result.loadTime = Date.now();//время загрузки файла
|
||||
|
||||
result.deleted = 0;
|
||||
|
||||
if (this.recent[result.key]) {
|
||||
result = Object.assign({}, this.recent[result.key], result);
|
||||
}
|
||||
|
||||
await this.recentSetLastKey(result.key);
|
||||
await this.recentSetItem(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getRecentBook(value) {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
return this.recent[value.key];
|
||||
}
|
||||
/*
|
||||
async delRecentBook(value, delFlag = 1) {
|
||||
const item = this.recent[value.key];
|
||||
item.deleted = delFlag;
|
||||
|
||||
async delRecentBook(value) {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
if (this.recentLastKey == value.key) {
|
||||
await this.recentSetLastKey(null);
|
||||
}
|
||||
|
||||
await bmRecentStore.removeItem(value.key);
|
||||
delete this.recent[value.key];
|
||||
this.recentChanged = true;
|
||||
await this.recentSetItem(item);
|
||||
this.emit('recent-deleted', value.key);
|
||||
}
|
||||
*/
|
||||
async delRecentBooks(values, delFlag = 1) {
|
||||
for (const value of values) {
|
||||
const item = this.recent[value.key];
|
||||
item.deleted = delFlag;
|
||||
|
||||
if (this.recentLastKey == value.key) {
|
||||
await this.recentSetLastKey(null);
|
||||
}
|
||||
|
||||
await this.recentSetItem(item);
|
||||
}
|
||||
|
||||
this.emit('recent-deleted');
|
||||
}
|
||||
/*
|
||||
async restoreRecentBook(value) {
|
||||
const item = this.recent[value.key];
|
||||
item.deleted = 0;
|
||||
|
||||
await this.recentSetItem(item);
|
||||
}
|
||||
*/
|
||||
async restoreRecentBooks(values) {
|
||||
for (const value of values) {
|
||||
const item = this.recent[value.key];
|
||||
item.deleted = 0;
|
||||
|
||||
await this.recentSetItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
async setCheckBuc(value, checkBuc) {
|
||||
const item = this.recent[value.key];
|
||||
|
||||
const updateItems = [];
|
||||
if (item) {
|
||||
if (item.sameBookKey !== undefined) {
|
||||
const sorted = this.getSortedRecent();
|
||||
for (const book of sorted) {
|
||||
if (!book.deleted && book.sameBookKey === item.sameBookKey)
|
||||
updateItems.push(book);
|
||||
}
|
||||
} else {
|
||||
updateItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const book of updateItems) {
|
||||
book.checkBuc = checkBuc;
|
||||
if (checkBuc)
|
||||
book.checkBucTime = now;
|
||||
await this.recentSetItem(book);
|
||||
}
|
||||
}
|
||||
|
||||
async cleanRecentBooks() {
|
||||
if (!this.recent)
|
||||
await this.init();
|
||||
const sorted = this.getSortedRecent();
|
||||
|
||||
if (Object.keys(this.recent).length > 100) {
|
||||
let min = Date.now();
|
||||
let found = null;
|
||||
for (let key in this.recent) {
|
||||
const book = this.recent[key];
|
||||
if (book.touchTime < min) {
|
||||
min = book.touchTime;
|
||||
found = book;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
await this.delRecentBook(found);
|
||||
await this.cleanRecentBooks();
|
||||
}
|
||||
let isDel = false;
|
||||
for (let i = maxRecentLength; i < sorted.length; i++) {
|
||||
delete this.recent[sorted[i].key];
|
||||
isDel = true;
|
||||
}
|
||||
|
||||
this.sortedRecentCached = null;
|
||||
|
||||
if (isDel)
|
||||
await this.recentSetItem();
|
||||
return isDel;
|
||||
}
|
||||
|
||||
mostRecentBook() {
|
||||
if (!this.recentChanged && this.mostRecentCached) {
|
||||
return this.mostRecentCached;
|
||||
if (this.recentLastKey) {
|
||||
return this.recent[this.recentLastKey];
|
||||
}
|
||||
const oldKey = this.recentLastKey;
|
||||
|
||||
let max = 0;
|
||||
let result = null;
|
||||
for (let key in this.recent) {
|
||||
for (const key in this.recent) {
|
||||
const book = this.recent[key];
|
||||
if (book.touchTime > max) {
|
||||
if (!book.deleted && book.touchTime > max) {
|
||||
max = book.touchTime;
|
||||
result = book;
|
||||
}
|
||||
}
|
||||
this.mostRecentCached = result;
|
||||
|
||||
const newRecentLastKey = (result ? result.key : null);
|
||||
this.recentSetLastKey(newRecentLastKey);//no await
|
||||
|
||||
if (newRecentLastKey !== oldKey)
|
||||
this.emit('recent-changed');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getSortedRecent() {
|
||||
if (!this.recentChanged && this.sortedRecentCached) {
|
||||
return this.sortedRecentCached;
|
||||
}
|
||||
|
||||
let result = Object.values(this.recent);
|
||||
|
||||
result.sort((a, b) => b.touchTime - a.touchTime);
|
||||
|
||||
this.sortedRecentCached = result;
|
||||
this.recentChanged = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
findRecentByUrlAndPath(url, bookPath) {
|
||||
if (bookPath) {
|
||||
const key = this.keyFromPath(bookPath);
|
||||
const book = this.recent[key];
|
||||
if (book && !book.deleted)
|
||||
return book;
|
||||
}
|
||||
|
||||
let max = 0;
|
||||
let result = null;
|
||||
|
||||
for (const key in this.recent) {
|
||||
const book = this.recent[key];
|
||||
if (!book.deleted && book.url == url && book.loadTime > max) {
|
||||
max = book.loadTime;
|
||||
result = book;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
findRecentBySameBookKey(sameKey) {
|
||||
let max = 0;
|
||||
let result = null;
|
||||
|
||||
for (const key in this.recent) {
|
||||
const book = this.recent[key];
|
||||
if (!book.deleted && book.sameBookKey == sameKey && book.loadTime > max) {
|
||||
max = book.loadTime;
|
||||
result = book;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async setRecent(value) {
|
||||
const mergedRecent = _.cloneDeep(this.recent);
|
||||
|
||||
Object.assign(mergedRecent, value);
|
||||
|
||||
//подстраховка от hotReload
|
||||
for (let i of Object.keys(mergedRecent)) {
|
||||
if (!mergedRecent[i].key || mergedRecent[i].key !== i)
|
||||
delete mergedRecent[i];
|
||||
}
|
||||
|
||||
this.recent = mergedRecent;
|
||||
|
||||
await this.recentSetLastKey(null);
|
||||
await this.recentSetItem(null, true);
|
||||
|
||||
this.emit('set-recent');
|
||||
}
|
||||
|
||||
addEventListener(listener) {
|
||||
if (this.eventListeners.indexOf(listener) < 0)
|
||||
this.eventListeners.push(listener);
|
||||
}
|
||||
|
||||
removeEventListener(listener) {
|
||||
const i = this.eventListeners.indexOf(listener);
|
||||
if (i >= 0)
|
||||
this.eventListeners.splice(i, 1);
|
||||
}
|
||||
|
||||
emit(eventName, value) {
|
||||
if (this.eventListeners) {
|
||||
for (const listener of this.eventListeners) {
|
||||
//console.log(eventName);
|
||||
listener(eventName, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new BookManager();
|
||||
61
client/components/Reader/share/coversStorage.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import localForage from 'localforage';
|
||||
//import _ from 'lodash';
|
||||
import * as utils from '../../../share/utils';
|
||||
|
||||
const maxDataSize = 100*1024*1024;
|
||||
|
||||
const coversStore = localForage.createInstance({
|
||||
name: 'coversStorage'
|
||||
});
|
||||
|
||||
class CoversStorage {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.cleanCovers(); //no await
|
||||
}
|
||||
|
||||
async setData(key, data) {
|
||||
await coversStore.setItem(key, {addTime: Date.now(), data});
|
||||
}
|
||||
|
||||
async getData(key) {
|
||||
const item = await coversStore.getItem(key);
|
||||
return (item ? item.data : undefined);
|
||||
}
|
||||
|
||||
async removeData(key) {
|
||||
await coversStore.removeItem(key);
|
||||
}
|
||||
|
||||
async cleanCovers() {
|
||||
await utils.sleep(10000);
|
||||
|
||||
while (1) {// eslint-disable-line no-constant-condition
|
||||
let size = 0;
|
||||
let min = Date.now();
|
||||
let toDel = null;
|
||||
for (const key of (await coversStore.keys())) {
|
||||
const item = await coversStore.getItem(key);
|
||||
|
||||
size += item.data.length;
|
||||
|
||||
if (item.addTime < min) {
|
||||
toDel = key;
|
||||
min = item.addTime;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (size > maxDataSize && toDel) {
|
||||
await this.removeData(toDel);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new CoversStorage();
|
||||
@@ -1,70 +0,0 @@
|
||||
export default async function restoreOldSettings(settings, bookManager, commit) {
|
||||
const oldSets = localStorage['colorSetting'];
|
||||
let isOld = false;
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
let key = unescape(localStorage.key(i));
|
||||
if (key.indexOf('bpr-book-') == 0)
|
||||
isOld = true;
|
||||
}
|
||||
|
||||
if (isOld || oldSets) {
|
||||
let newSettings = null;
|
||||
if (oldSets) {
|
||||
const [textColor, backgroundColor, lineStep, , , statusBarHeight, scInt] = unescape(oldSets).split('|');
|
||||
|
||||
const fontSize = Math.round(lineStep*0.8);
|
||||
const scrollingDelay = fontSize*scInt;
|
||||
|
||||
newSettings = Object.assign({}, settings, {
|
||||
textColor,
|
||||
backgroundColor,
|
||||
fontSize,
|
||||
statusBarHeight: statusBarHeight*1,
|
||||
scrollingDelay,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
let key = localStorage.key(i);
|
||||
if (key.indexOf('bpr-') == 0) {
|
||||
let v = unescape(localStorage[key]);
|
||||
key = unescape(key);
|
||||
|
||||
if (key.lastIndexOf('=timestamp') == key.length - 10) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.indexOf('bpr-book-') == 0) {
|
||||
const url = key.substr(9);
|
||||
const [scrollTop, scrollHeight, ] = v.split('|');
|
||||
|
||||
const bookPosPercent = scrollTop*1/(scrollHeight*1 + 1);
|
||||
const title = unescape(localStorage[`bpr-title-${escape(url)}`]);
|
||||
const author = unescape(localStorage[`bpr-author-${escape(url)}`]);
|
||||
const time = unescape(localStorage[`bpr-book-${escape(url)}=timestamp`]).split(';')[0];
|
||||
const touchTime = Date.parse(time);
|
||||
|
||||
const bookKey = bookManager.keyFromUrl(url);
|
||||
const recent = await bookManager.getRecentBook({key: bookKey});
|
||||
|
||||
if (!recent) {
|
||||
await bookManager.setRecentBook({
|
||||
key: bookKey,
|
||||
touchTime,
|
||||
bookPosPercent,
|
||||
url,
|
||||
fb2: {
|
||||
bookTitle: title,
|
||||
lastName: author,
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.clear();
|
||||
if (oldSets)
|
||||
commit('reader/setSettings', newSettings);
|
||||
}
|
||||
}
|
||||
44
client/components/Reader/share/wallpaperStorage.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import localForage from 'localforage';
|
||||
//import _ from 'lodash';
|
||||
|
||||
const wpStore = localForage.createInstance({
|
||||
name: 'wallpaperStorage'
|
||||
});
|
||||
|
||||
class WallpaperStorage {
|
||||
constructor() {
|
||||
this.cachedKeys = [];
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.cachedKeys = await wpStore.keys();
|
||||
}
|
||||
|
||||
async getLength() {
|
||||
return await wpStore.length();
|
||||
}
|
||||
|
||||
async setData(key, data) {
|
||||
await wpStore.setItem(key, data);
|
||||
this.cachedKeys = await wpStore.keys();
|
||||
}
|
||||
|
||||
async getData(key) {
|
||||
return await wpStore.getItem(key);
|
||||
}
|
||||
|
||||
async removeData(key) {
|
||||
await wpStore.removeItem(key);
|
||||
this.cachedKeys = await wpStore.keys();
|
||||
}
|
||||
|
||||
async getKeys() {
|
||||
return await wpStore.keys();
|
||||
}
|
||||
|
||||
keyExists(key) {//не асинхронная
|
||||
return this.cachedKeys.includes(key);
|
||||
}
|
||||
}
|
||||
|
||||
export default new WallpaperStorage();
|
||||
856
client/components/Reader/versionHistory.js
Normal file
@@ -0,0 +1,856 @@
|
||||
export const versionHistory = [
|
||||
{
|
||||
version: '1.2.8',
|
||||
releaseDate: '2025-06-04',
|
||||
showUntil: '2025-06-03',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправление багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.2.7',
|
||||
releaseDate: '2025-02-22',
|
||||
showUntil: '2025-02-21',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>отключена форма для сбора донатов</li>
|
||||
<li>мелкие оптимизации</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.2.6',
|
||||
releaseDate: '2024-10-03',
|
||||
showUntil: '2024-10-02',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления из-за нарушения авторских прав</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.2.4',
|
||||
releaseDate: '2024-08-27',
|
||||
showUntil: '2024-08-26',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправление багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.2.3',
|
||||
releaseDate: '2024-08-02',
|
||||
showUntil: '2024-08-01',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправление багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.2.2',
|
||||
releaseDate: '2024-07-28',
|
||||
showUntil: '2024-07-27',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлено отображение примечаний на месте, по клику на сноске (#50)</li>
|
||||
<li>исправление багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.2.0',
|
||||
releaseDate: '2024-03-25',
|
||||
showUntil: '2024-03-24',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>в списке загруженных, книга в архив (из архива) переносится теперь со всей группой своих версий</li>
|
||||
<li>добавлена возможность задавать в конфиге любую ссылку для кнопки "Сетевая библиотека", параметр networkLibraryLink (#47)</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.1.3',
|
||||
releaseDate: '2023-02-06',
|
||||
showUntil: '2023-02-05',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправление багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.1.2',
|
||||
releaseDate: '2023-01-22',
|
||||
showUntil: '2023-01-21',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправление багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.1.1',
|
||||
releaseDate: '2023-01-11',
|
||||
showUntil: '2023-01-15',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлена опция "Ночной режим" и кнопка на панель</li>
|
||||
<li>исправление багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '1.0.0',
|
||||
releaseDate: '2022-12-18',
|
||||
showUntil: '2022-12-25',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>на мобильных устройствах переход в полноэкранный режим теперь возможен через двойной тап по центру</li>
|
||||
<li>добавлено окно "Сетевая библиотека" для omnireader.ru</li>
|
||||
<li>улучшена работа синхронизации с сервером при плохом качестве связи</li>
|
||||
<li>добавлена сборка релизов читалки: <a href="https://github.com/bookpauk/liberama/releases" target="_blank">https://github.com/bookpauk/liberama/releases</a></li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.12.2',
|
||||
releaseDate: '2022-09-04',
|
||||
showUntil: '2022-09-11',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправлен баг с формой для доната, показывалась каждый день, а не каждый месяц</li>
|
||||
<li>автор приносит извинения за доставленные неудобства</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.12.1',
|
||||
releaseDate: '2022-09-01',
|
||||
showUntil: '2022-08-30',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлена форма для доната</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.12.0',
|
||||
releaseDate: '2022-07-27',
|
||||
showUntil: '2022-08-03',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>запущен сервер проверки обновлений книг:</li>
|
||||
<ul>
|
||||
<li>проверка обновления той или иной книги настраивается в списке загруженных (чекбокс)</li>
|
||||
<li>для того, чтобы чекбокс появился у ранее загруженной, необходимо принудительно обновить книгу</li>
|
||||
<li>в настройках можно указать разницу размеров, при которой требуется делать уведомление</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.11.8',
|
||||
releaseDate: '2022-07-14',
|
||||
showUntil: '2022-07-13',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлено отображение и синхронизация обложек в окне загруженных книг</li>
|
||||
<li>добавлена синхронизация обоев</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.11.7',
|
||||
releaseDate: '2022-07-12',
|
||||
showUntil: '2022-07-19',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлено автосокрытие панели управления при листании, отключается в настройках</li>
|
||||
<li>изменения в окне загруженных книг:</li>
|
||||
<ul>
|
||||
<li>добавлена группировка по версиям файла одной и той же книги</li>
|
||||
<li>группировка происходит по имени загружаемого файла, либо по URL книги</li>
|
||||
<li>добавлены различные методы сортировки списка загруженных книг</li>
|
||||
<li>нумерация всегда осуществляется по времени загрузки</li>
|
||||
</ul>
|
||||
<li>незначительные общие изменения интерфейса, приведение к единому стилю</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.11.6',
|
||||
releaseDate: '2022-07-02',
|
||||
showUntil: '2022-07-01',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>улучшено копирование текста прямо со страницы, для переводчиков</li>
|
||||
<li>актуализация используемых пакетов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.11.5',
|
||||
releaseDate: '2022-04-15',
|
||||
showUntil: '2022-04-14',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>небольшие дополнения интерфейса</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.11.1',
|
||||
releaseDate: '2021-12-03',
|
||||
showUntil: '2021-12-02',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>переход на JembaDb вместо SQLite</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.11.0',
|
||||
releaseDate: '2021-11-18',
|
||||
showUntil: '2021-11-17',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>переход на Vue 3</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.10.3',
|
||||
releaseDate: '2021-10-24',
|
||||
showUntil: '2021-10-23',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.10.2',
|
||||
releaseDate: '2021-10-19',
|
||||
showUntil: '2021-10-18',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>актуализация версий пакетов и стека используемых технологий</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.10.1',
|
||||
releaseDate: '2021-10-10',
|
||||
showUntil: '2021-10-09',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.10.0',
|
||||
releaseDate: '2021-02-09',
|
||||
showUntil: '2021-02-16',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлен двухстраничный режим</li>
|
||||
<li>в настройки добавлены все кириллические веб-шрифты от google</li>
|
||||
<li>в настройки добавлена возможность загрузки пользовательских обоев (пока без синхронизации)</li>
|
||||
<li>немного улучшен парсинг fb2</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.12',
|
||||
releaseDate: '2020-12-18',
|
||||
showUntil: '2020-12-17',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлена вкладка "Изображения" в окно оглавления</li>
|
||||
<li>настройки конвертирования вынесены в отдельную вкладку</li>
|
||||
<li>добавлена кнопка для быстрого доступа к настройкам конвертирования</li>
|
||||
<li>улучшения работы конвертеров</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.11',
|
||||
releaseDate: '2020-12-09',
|
||||
showUntil: '2020-12-08',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>оптимизации, улучшения работы конвертеров</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.10',
|
||||
releaseDate: '2020-12-03',
|
||||
showUntil: '2020-12-10',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлена частичная поддержка формата Djvu</li>
|
||||
<li>добавлена поддержка Rar-архивов</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.9',
|
||||
releaseDate: '2020-11-21',
|
||||
showUntil: '2020-11-20',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>оптимизации, исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.8',
|
||||
releaseDate: '2020-11-13',
|
||||
showUntil: '2020-11-12',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлено окно "Оглавление/закладки"</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.7',
|
||||
releaseDate: '2020-11-12',
|
||||
showUntil: '2020-11-11',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.6',
|
||||
releaseDate: '2020-11-06',
|
||||
showUntil: '2020-11-05',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>завершена работа над новым окном "Библиотека"</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.5',
|
||||
releaseDate: '2020-11-01',
|
||||
showUntil: '2020-10-31',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>на панель инструментов добавлена новая кнопка "Обновить с разбиением на параграфы"</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.4',
|
||||
releaseDate: '2020-10-29',
|
||||
showUntil: '2020-10-28',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>заработал новый сайт <a href="https://liberama.top">https://liberama.top</a>, где будет более свободный обмен книгами</li>
|
||||
<li>для liberama.top добавлено новое окно: "Библиотека"</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.3',
|
||||
releaseDate: '2020-05-21',
|
||||
showUntil: '2020-05-20',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.2',
|
||||
releaseDate: '2020-03-15',
|
||||
showUntil: '2020-04-25',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>в настройки добавлена возможность назначать сочетания клавиш на команды в читалке</li>
|
||||
<li>переход на Service Worker вместо AppCache для автономного режима работы</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.1',
|
||||
releaseDate: '2020-03-03',
|
||||
showUntil: '2020-03-02',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>улучшение работы серверной части</li>
|
||||
<li>незначительные изменения интерфейса</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.9.0',
|
||||
releaseDate: '2020-02-26',
|
||||
showUntil: '2020-02-25',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>переход на UI-фреймфорк Quasar</li>
|
||||
<li>незначительные изменения интерфейса</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.8.4',
|
||||
releaseDate: '2020-02-06',
|
||||
showUntil: '2020-02-05',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлен paypal-адрес для пожертвований</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.8.3',
|
||||
releaseDate: '2020-01-28',
|
||||
showUntil: '2020-01-27',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлено всплывающее окно с акцией "Оплатим хостинг вместе"</li>
|
||||
<li>внутренние оптимизации</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.8.2',
|
||||
releaseDate: '2020-01-20',
|
||||
showUntil: '2020-01-19',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>внутренние оптимизации</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.8.1',
|
||||
releaseDate: '2020-01-07',
|
||||
showUntil: '2020-01-06',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлена частичная поддержка формата FB3</li>
|
||||
<li>исправлен баг "Request path contains unescaped characters"</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.8.0',
|
||||
releaseDate: '2020-01-02',
|
||||
showUntil: '2020-01-05',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>окончательный переход на https</li>
|
||||
<li>код проекта теперь Open Source: <a href="https://github.com/bookpauk/liberama" target="_blank">https://github.com/bookpauk/liberama</a></li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.7.9',
|
||||
releaseDate: '2019-11-27',
|
||||
showUntil: '2019-11-26',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлен неубираемый баннер для http-версии о переходе на httpS</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.7.8',
|
||||
releaseDate: '2019-11-25',
|
||||
showUntil: '2019-11-24',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>улучшение html-фильтров для сайтов</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.7.7',
|
||||
releaseDate: '2019-11-06',
|
||||
showUntil: '2019-11-10',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлены следующие жесты для тачскрина (только при включенной опции "управление кликом"):</li>
|
||||
<ul>
|
||||
<li style="list-style-type: square">от центра вверх: на весь экран</li>
|
||||
<li style="list-style-type: square">от центра вниз: плавный скроллинг</li>
|
||||
<li style="list-style-type: square">от центра вправо: увеличить скорость скроллинга</li>
|
||||
<li style="list-style-type: square">от центра влево: уменьшить скорость скроллинга</li>
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.7.6',
|
||||
releaseDate: '2019-10-30',
|
||||
showUntil: '2019-10-29',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.7.5',
|
||||
releaseDate: '2019-10-22',
|
||||
showUntil: '2019-10-21',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.7.3',
|
||||
releaseDate: '2019-10-18',
|
||||
showUntil: '2019-10-17',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>внутренние переделки механизма синхронизации с сервером</li>
|
||||
<li>добавлен html-фильтр для сайтов www.fanfiction.net, archiveofourown.org</li>
|
||||
<li>добавлен параметр "Включить html-фильтр для сайтов" в раздел "Вид"->"Текст" в настройках</li>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.7.1',
|
||||
releaseDate: '2019-09-20',
|
||||
showUntil: '2019-09-19',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
<li>на панель управления добавлена кнопка "Автономный режим"</li>
|
||||
<li>актуализирована справка</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.7.0',
|
||||
releaseDate: '2019-09-07',
|
||||
showUntil: '2019-10-01',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>налажена работа https-версии сайта, рекомендуется плавный переход</li>
|
||||
<li>добавлена возможность загрузки и работы https-версии читалки в оффлайн-режиме (при отсутствии интернета)</li>
|
||||
<li>упрощение механизма серверной синхронизации с целью повышения надежности и избавления от багов</li>
|
||||
<li>окна теперь можно перемещать за заголовок</li>
|
||||
<li>немного улучшен внешний вид и управление на смартфонах</li>
|
||||
<li>добавлен параметр "Компактность" в раздел "Вид"->"Текст" в настройках</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.6.10',
|
||||
releaseDate: '2019-07-21',
|
||||
showUntil: '2019-07-20',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.6.9',
|
||||
releaseDate: '2019-06-23',
|
||||
showUntil: '2019-06-22',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправлен баг - падение сервера при распаковке битых архивов книг</li>
|
||||
<li>исправлен баг - не распознавались некоторые книги формата fb2 в кодировке utf8</li>
|
||||
<li>добавлены новые варианты анимации перелистывания</li>
|
||||
<li>на страницу загрузки добавлен блок "Поделиться"</li>
|
||||
<li>улучшены прогрессбары</li>
|
||||
<li>исправления недочетов, небольшие оптимизации</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.6.7',
|
||||
releaseDate: '2019-05-30',
|
||||
showUntil: '2019-06-05',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлен диалог "Что нового"</li>
|
||||
<li>в справку добавлена история версий проекта</li>
|
||||
<li>добавлена возможность настройки отображаемых кнопок на панели управления</li>
|
||||
<li>некоторые кнопки на панели управления были скрыты по умолчанию</li>
|
||||
<li>на страницу загрузки добавлена возможность загрузки книги из буфера обмена</li>
|
||||
<li>добавлен GET-параметр вида "/reader?__refresh=1&url=..." для принудительного обновления загружаемого текста</li>
|
||||
<li>добавлен GET-параметр вида "/reader?__pp=50.5&url=..." для указания позиции в книге в процентах</li>
|
||||
<li>исправления багов и недочетов</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.6.6',
|
||||
releaseDate: '2019-03-29',
|
||||
showUntil: '2019-03-29',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>в справку добавлено описание настройки браузеров для автономной работы читалки (без доступа к интернету)</li>
|
||||
<li>оптимизации процесса синхронизации, внутренние переделки</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.6.4',
|
||||
releaseDate: '2019-03-24',
|
||||
showUntil: '2019-03-24',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>исправления багов, оптимизации</li>
|
||||
<li>добавлена возможность синхронизации данных между устройствами</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.5.4',
|
||||
releaseDate: '2019-03-04',
|
||||
showUntil: '2019-03-04',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>добавлена поддержка форматов pdf, epub, mobi</li>
|
||||
<li>(0.5.2) добавлена поддержка форматов rtf, doc, docx</li>
|
||||
<li>(0.4.2) фильтр для СИ больше не вырезает изображения</li>
|
||||
<li>(0.4.0) добавлено отображение картинок в fb2</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.3.0',
|
||||
releaseDate: '2019-02-17',
|
||||
showUntil: '2019-02-17',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>поправки багов</li>
|
||||
<li>улучшено распознавание текста</li>
|
||||
<li>изменена верстка страницы - убрано позиционирование каждого слова</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.1.7',
|
||||
releaseDate: '2019-02-14',
|
||||
showUntil: '2019-02-14',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>увеличены верхние границы отступов и др.размеров</li>
|
||||
<li>добавлена настройка для удаления/вставки пустых параграфов</li>
|
||||
<li>добавлена настройка включения/отключения управления кликом</li>
|
||||
<li>добавлена возможность сброса настроек</li>
|
||||
<li>убран автоматический редирект на последнюю загруженную книгу, если не задан url в маршруте</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
{
|
||||
version: '0.1.0',
|
||||
releaseDate: '2019-02-12',
|
||||
showUntil: '2019-02-12',
|
||||
content:
|
||||
`
|
||||
<ul>
|
||||
<li>первый деплой проекта, длительность разработки - 2 месяца</li>
|
||||
</ul>
|
||||
|
||||
`
|
||||
},
|
||||
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Settings в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Settings extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<el-container>
|
||||
Раздел Sources в разработке
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Sources extends Vue {
|
||||
created() {
|
||||
}
|
||||
|
||||
}
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
80
client/components/share/Dialog.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<q-dialog v-model="active" no-route-dismiss @show="onShow" @hide="onHide">
|
||||
<div class="column bg-dialog no-wrap">
|
||||
<div class="header row">
|
||||
<div class="caption col row items-center q-ml-md">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<div class="close-icon column justify-center items-center">
|
||||
<q-btn v-close-popup flat round dense>
|
||||
<q-icon name="la la-times" size="18px"></q-icon>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col q-mx-md">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end q-pa-md">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../vueComponent.js';
|
||||
import * as utils from '../../share/utils';
|
||||
|
||||
class Dialog {
|
||||
_props = {
|
||||
modelValue: Boolean,
|
||||
};
|
||||
|
||||
shown = false;
|
||||
|
||||
get active() {
|
||||
return this.modelValue;
|
||||
}
|
||||
|
||||
set active(value) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
|
||||
onShow() {
|
||||
this.shown = true;
|
||||
}
|
||||
|
||||
onHide() {
|
||||
this.shown = false;
|
||||
}
|
||||
|
||||
async waitShown() {
|
||||
let i = 100;
|
||||
while (!this.shown && i > 0) {
|
||||
await utils.sleep(10);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(Dialog);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
font-size: 110%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 50px;
|
||||
}
|
||||
</style>
|
||||
58
client/components/share/Notify.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="hidden"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../vueComponent.js';
|
||||
|
||||
class Notify {
|
||||
notify(opts) {
|
||||
let {
|
||||
caption = null,
|
||||
captionColor = 'black',
|
||||
color = 'positive',
|
||||
icon = '',
|
||||
iconColor = 'white',
|
||||
message = '',
|
||||
messageColor = 'black',
|
||||
position = 'top-right',
|
||||
} = opts;
|
||||
|
||||
caption = (caption ? `<div style="font-size: 120%; color: ${captionColor}"><b>${caption}</b></div><br>` : '');
|
||||
return this.$q.notify({
|
||||
position,
|
||||
color,
|
||||
textColor: iconColor,
|
||||
icon,
|
||||
actions: [{icon: 'la la-times notify-button-icon', color: 'black'}],
|
||||
html: true,
|
||||
|
||||
message:
|
||||
`<div style="max-width: 350px">
|
||||
${caption}
|
||||
<div style="color: ${messageColor}; overflow-wrap: break-word; word-wrap: break-word;">${message}</div>
|
||||
</div>`
|
||||
});
|
||||
}
|
||||
|
||||
success(message, caption, options) {
|
||||
this.notify(Object.assign({color: 'positive', icon: 'la la-check-circle', message, caption}, options));
|
||||
}
|
||||
|
||||
warning(message, caption, options) {
|
||||
this.notify(Object.assign({color: 'warning', icon: 'la la-exclamation-circle', message, caption}, options));
|
||||
}
|
||||
|
||||
error(message, caption, options) {
|
||||
this.notify(Object.assign({color: 'negative', icon: 'la la-exclamation-circle', messageColor: 'yellow', captionColor: 'white', message, caption}, options));
|
||||
}
|
||||
|
||||
info(message, caption, options) {
|
||||
this.notify(Object.assign({color: 'info', icon: 'la la-bell', message, caption}, options));
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(Notify);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
251
client/components/share/NumInput.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<q-input
|
||||
v-model="filteredValue"
|
||||
outlined dense
|
||||
input-style="text-align: center"
|
||||
class="no-mp"
|
||||
:disable="disable"
|
||||
:mask="mask"
|
||||
:error="error"
|
||||
>
|
||||
<slot></slot>
|
||||
<template #prepend>
|
||||
<q-icon
|
||||
v-show="mmButtons"
|
||||
v-ripple="modelValue != min"
|
||||
style="font-size: 100%"
|
||||
:class="(modelValue != min ? '' : 'disable')"
|
||||
name="la la-angle-double-left"
|
||||
class="button"
|
||||
@click="toMin"
|
||||
/>
|
||||
|
||||
<q-icon
|
||||
v-ripple="validate(modelValue - step)"
|
||||
:class="(validate(modelValue - step) ? '' : 'disable')"
|
||||
:name="minusIcon"
|
||||
class="button"
|
||||
@click="onClick('minus')"
|
||||
@mousedown.prevent.stop="onMouseDown($event, 'minus')"
|
||||
@mouseup.prevent.stop="onMouseUp"
|
||||
@mouseout.prevent.stop="onMouseUp"
|
||||
@touchstart.stop="onTouchStart($event, 'minus')"
|
||||
@touchend.stop="onTouchEnd"
|
||||
@touchcancel.prevent.stop="onTouchEnd"
|
||||
/>
|
||||
</template>
|
||||
<template #append>
|
||||
<q-icon
|
||||
v-ripple="validate(modelValue + step)"
|
||||
:class="(validate(modelValue + step) ? '' : 'disable')"
|
||||
:name="plusIcon"
|
||||
class="button"
|
||||
@click="onClick('plus')"
|
||||
@mousedown.prevent.stop="onMouseDown($event, 'plus')"
|
||||
@mouseup.prevent.stop="onMouseUp"
|
||||
@mouseout.prevent.stop="onMouseUp"
|
||||
@touchstart.stop="onTouchStart($event, 'plus')"
|
||||
@touchend.stop="onTouchEnd"
|
||||
@touchcancel.prevent.stop="onTouchEnd"
|
||||
/>
|
||||
|
||||
<q-icon
|
||||
v-show="mmButtons"
|
||||
v-ripple="modelValue != max"
|
||||
style="font-size: 100%"
|
||||
:class="(modelValue != max ? '' : 'disable')"
|
||||
name="la la-angle-double-right"
|
||||
class="button"
|
||||
@click="toMax"
|
||||
/>
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../vueComponent.js';
|
||||
|
||||
import * as utils from '../../share/utils';
|
||||
|
||||
const componentOptions = {
|
||||
watch: {
|
||||
filteredValue() {
|
||||
this.checkErrorAndEmit(true);
|
||||
},
|
||||
modelValue(newValue) {
|
||||
this.filteredValue = newValue;
|
||||
},
|
||||
min() {
|
||||
this.checkErrorAndEmit();
|
||||
},
|
||||
max() {
|
||||
this.checkErrorAndEmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
class NumInput {
|
||||
_options = componentOptions;
|
||||
_props = {
|
||||
modelValue: Number,
|
||||
min: { type: Number, default: -Number.MAX_VALUE },
|
||||
max: { type: Number, default: Number.MAX_VALUE },
|
||||
step: { type: Number, default: 1 },
|
||||
digits: { type: Number, default: 0 },
|
||||
disable: Boolean,
|
||||
minusIcon: {type: String, default: 'la la-minus-circle'},
|
||||
plusIcon: {type: String, default: 'la la-plus-circle'},
|
||||
mmButtons: Boolean,
|
||||
mask: String,
|
||||
};
|
||||
|
||||
filteredValue = 0;
|
||||
error = false;
|
||||
|
||||
created() {
|
||||
this.filteredValue = this.modelValue;
|
||||
}
|
||||
|
||||
string2number(value) {
|
||||
return Number.parseFloat(Number.parseFloat(value).toFixed(this.digits));
|
||||
}
|
||||
|
||||
validate(value) {
|
||||
let n = this.string2number(value);
|
||||
if (isNaN(n))
|
||||
return false;
|
||||
if (n < this.min)
|
||||
return false;
|
||||
if (n > this.max)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
checkErrorAndEmit(emit = false) {
|
||||
if (this.validate(this.filteredValue)) {
|
||||
this.error = false;
|
||||
if (emit)
|
||||
this.$emit('update:modelValue', this.string2number(this.filteredValue));
|
||||
} else {
|
||||
this.error = true;
|
||||
}
|
||||
}
|
||||
|
||||
plus() {
|
||||
const newValue = this.modelValue + this.step;
|
||||
if (this.validate(newValue))
|
||||
this.filteredValue = newValue;
|
||||
}
|
||||
|
||||
minus() {
|
||||
const newValue = this.modelValue - this.step;
|
||||
if (this.validate(newValue))
|
||||
this.filteredValue = newValue;
|
||||
}
|
||||
|
||||
onClick(way) {
|
||||
if (this.clickRepeat)
|
||||
return;
|
||||
|
||||
if (way == 'plus') {
|
||||
this.plus();
|
||||
} else {
|
||||
this.minus();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseDown(event, way) {
|
||||
this.startClickRepeat = true;
|
||||
this.clickRepeat = false;
|
||||
|
||||
if (event.button == 0) {
|
||||
(async() => {
|
||||
if (this.inRepeatFunc)
|
||||
return;
|
||||
|
||||
this.inRepeatFunc = true;
|
||||
try {
|
||||
await utils.sleep(300);
|
||||
if (this.startClickRepeat) {
|
||||
this.clickRepeat = true;
|
||||
while (this.clickRepeat) {
|
||||
if (way == 'plus') {
|
||||
this.plus();
|
||||
} else {
|
||||
this.minus();
|
||||
}
|
||||
await utils.sleep(100);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.inRepeatFunc = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
if (this.inTouch)
|
||||
return;
|
||||
this.startClickRepeat = false;
|
||||
if (this.clickRepeat) {
|
||||
(async() => {
|
||||
await utils.sleep(50);
|
||||
this.clickRepeat = false;
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
onTouchStart(event, way) {
|
||||
if (!this.$root.isMobileDevice)
|
||||
return;
|
||||
if (event.touches.length == 1) {
|
||||
this.inTouch = true;
|
||||
this.onMouseDown({button: 0}, way);
|
||||
}
|
||||
}
|
||||
|
||||
onTouchEnd() {
|
||||
if (!this.$root.isMobileDevice)
|
||||
return;
|
||||
this.inTouch = false;
|
||||
this.onMouseUp();
|
||||
}
|
||||
|
||||
toMin() {
|
||||
this.filteredValue = this.min;
|
||||
}
|
||||
|
||||
toMax() {
|
||||
this.filteredValue = this.max;
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(NumInput);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-mp {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 130%;
|
||||
border-radius: 15px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
color: var(--text-ubtn-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
.disable, .disable:hover {
|
||||
cursor: not-allowed;
|
||||
filter: invert(0%);
|
||||
}
|
||||
</style>
|
||||
406
client/components/share/StdDialog.vue
Normal file
@@ -0,0 +1,406 @@
|
||||
<template>
|
||||
<q-dialog ref="dialog" v-model="active" no-route-dismiss @show="onShow" @hide="onHide">
|
||||
<slot></slot>
|
||||
|
||||
<!--------------------------------------------------->
|
||||
<div v-show="type == 'alert'" class="bg-dialog no-wrap">
|
||||
<div class="header row">
|
||||
<div class="caption col row items-center q-ml-md">
|
||||
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
|
||||
<div v-html="caption"></div>
|
||||
</div>
|
||||
<div class="close-icon column justify-center items-center">
|
||||
<q-btn v-close-popup flat round dense>
|
||||
<q-icon name="la la-times" size="18px"></q-icon>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-mx-md">
|
||||
<div v-html="message"></div>
|
||||
</div>
|
||||
|
||||
<div class="buttons row justify-end q-pa-md">
|
||||
<q-btn class="q-px-md" dense no-caps @click="okClick">
|
||||
OK
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--------------------------------------------------->
|
||||
<div v-show="type == 'confirm'" class="bg-dialog no-wrap">
|
||||
<div class="header row">
|
||||
<div class="caption col row items-center q-ml-md">
|
||||
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
|
||||
<div v-html="caption"></div>
|
||||
</div>
|
||||
<div class="close-icon column justify-center items-center">
|
||||
<q-btn v-close-popup flat round dense>
|
||||
<q-icon name="la la-times" size="18px"></q-icon>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-mx-md">
|
||||
<div v-html="message"></div>
|
||||
</div>
|
||||
|
||||
<div class="buttons row justify-end q-pa-md">
|
||||
<q-btn v-close-popup class="q-px-md q-ml-sm" dense no-caps>
|
||||
Отмена
|
||||
</q-btn>
|
||||
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
|
||||
OK
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--------------------------------------------------->
|
||||
<div v-show="type == 'askYesNo'" class="bg-dialog no-wrap">
|
||||
<div class="header row">
|
||||
<div class="caption col row items-center q-ml-md">
|
||||
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
|
||||
<div v-html="caption"></div>
|
||||
</div>
|
||||
<div class="close-icon column justify-center items-center">
|
||||
<q-btn v-close-popup flat round dense>
|
||||
<q-icon name="la la-times" size="18px"></q-icon>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-mx-md">
|
||||
<div v-html="message"></div>
|
||||
</div>
|
||||
|
||||
<div class="buttons row justify-end q-pa-md">
|
||||
<q-btn v-close-popup class="q-px-md q-ml-sm" dense no-caps>
|
||||
Нет
|
||||
</q-btn>
|
||||
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
|
||||
Да
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--------------------------------------------------->
|
||||
<div v-show="type == 'prompt'" class="bg-dialog no-wrap">
|
||||
<div class="header row">
|
||||
<div class="caption col row items-center q-ml-md">
|
||||
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
|
||||
<div v-html="caption"></div>
|
||||
</div>
|
||||
<div class="close-icon column justify-center items-center">
|
||||
<q-btn v-close-popup flat round dense>
|
||||
<q-icon name="la la-times" size="18px"></q-icon>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-mx-md">
|
||||
<div v-html="message"></div>
|
||||
<q-input ref="input" v-model="inputValue" class="q-mt-xs" outlined dense />
|
||||
<div class="error">
|
||||
<span v-show="error != ''">{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons row justify-end q-pa-md">
|
||||
<q-btn v-close-popup class="q-px-md q-ml-sm" dense no-caps>
|
||||
Отмена
|
||||
</q-btn>
|
||||
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps @click="okClick">
|
||||
OK
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--------------------------------------------------->
|
||||
<div v-show="type == 'hotKey'" class="bg-dialog no-wrap">
|
||||
<div class="header row">
|
||||
<div class="caption col row items-center q-ml-md">
|
||||
<q-icon v-show="caption" class="q-mr-sm" :class="iconColor" :name="iconName" size="28px"></q-icon>
|
||||
<div v-html="caption"></div>
|
||||
</div>
|
||||
<div class="close-icon column justify-center items-center">
|
||||
<q-btn v-close-popup flat round dense>
|
||||
<q-icon name="la la-times" size="18px"></q-icon>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-mx-md">
|
||||
<div v-html="message"></div>
|
||||
<div class="q-my-md text-center">
|
||||
<div v-show="hotKeyCode == ''" class="text-grey-5">
|
||||
Нет
|
||||
</div>
|
||||
<div>{{ hotKeyCode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons row justify-end q-pa-md">
|
||||
<q-btn v-close-popup class="q-px-md q-ml-sm" dense no-caps>
|
||||
Отмена
|
||||
</q-btn>
|
||||
<q-btn class="q-px-md q-ml-sm" color="primary" dense no-caps :disabled="hotKeyCode == ''" @click="okClick">
|
||||
OK
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import vueComponent from '../vueComponent.js';
|
||||
import * as utils from '../../share/utils';
|
||||
|
||||
const componentOptions = {
|
||||
watch: {
|
||||
inputValue: function(newValue) {
|
||||
this.validate(newValue);
|
||||
},
|
||||
}
|
||||
};
|
||||
class StdDialog {
|
||||
_options = componentOptions;
|
||||
caption = '';
|
||||
message = '';
|
||||
active = false;
|
||||
type = '';
|
||||
inputValue = '';
|
||||
error = '';
|
||||
iconColor = '';
|
||||
iconName = '';
|
||||
hotKeyCode = '';
|
||||
|
||||
created() {
|
||||
if (this.$root.addEventHook) {
|
||||
this.$root.addEventHook('key', this.keyHook);
|
||||
}
|
||||
}
|
||||
|
||||
init(message, caption, opts) {
|
||||
this.caption = caption;
|
||||
this.message = message;
|
||||
|
||||
this.ok = false;
|
||||
this.type = '';
|
||||
this.inputValidator = null;
|
||||
this.inputValue = '';
|
||||
this.error = '';
|
||||
this.showed = false;
|
||||
|
||||
this.iconColor = 'text-warning';
|
||||
if (opts && opts.color) {
|
||||
this.iconColor = `text-${opts.color}`;
|
||||
}
|
||||
|
||||
this.iconName = 'las la-exclamation-circle';
|
||||
if (opts && opts.iconName) {
|
||||
this.iconName = opts.iconName;
|
||||
}
|
||||
|
||||
this.hotKeyCode = '';
|
||||
if (opts && opts.hotKeyCode) {
|
||||
this.hotKeyCode = opts.hotKeyCode;
|
||||
}
|
||||
}
|
||||
|
||||
onHide() {
|
||||
if (this.hideTrigger) {
|
||||
this.hideTrigger();
|
||||
this.hideTrigger = null;
|
||||
}
|
||||
this.showed = false;
|
||||
}
|
||||
|
||||
onShow() {
|
||||
if (this.type == 'prompt') {
|
||||
this.enableValidator = true;
|
||||
if (this.inputValue)
|
||||
this.validate(this.inputValue);
|
||||
this.$refs.input.focus();
|
||||
}
|
||||
this.showed = true;
|
||||
}
|
||||
|
||||
validate(value) {
|
||||
if (!this.enableValidator)
|
||||
return false;
|
||||
|
||||
if (this.inputValidator) {
|
||||
const result = this.inputValidator(value);
|
||||
if (result !== true) {
|
||||
this.error = result;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.error = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
okClick() {
|
||||
if (this.type == 'prompt' && !this.validate(this.inputValue)) {
|
||||
this.$refs.dialog.shake();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.type == 'hotKey' && this.hotKeyCode == '') {
|
||||
this.$refs.dialog.shake();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ok = true;
|
||||
this.$refs.dialog.hide();
|
||||
}
|
||||
|
||||
alert(message, caption, opts) {
|
||||
return new Promise((resolve) => {
|
||||
this.init(message, caption, opts);
|
||||
|
||||
this.hideTrigger = () => {
|
||||
if (this.ok) {
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
this.type = 'alert';
|
||||
this.active = true;
|
||||
});
|
||||
}
|
||||
|
||||
confirm(message, caption, opts) {
|
||||
return new Promise((resolve) => {
|
||||
this.init(message, caption, opts);
|
||||
|
||||
this.hideTrigger = () => {
|
||||
if (this.ok) {
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
this.type = 'confirm';
|
||||
this.active = true;
|
||||
});
|
||||
}
|
||||
|
||||
askYesNo(message, caption, opts) {
|
||||
return new Promise((resolve) => {
|
||||
this.init(message, caption, opts);
|
||||
|
||||
this.hideTrigger = () => {
|
||||
if (this.ok) {
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
this.type = 'askYesNo';
|
||||
this.active = true;
|
||||
});
|
||||
}
|
||||
|
||||
prompt(message, caption, opts) {
|
||||
return new Promise((resolve) => {
|
||||
this.enableValidator = false;
|
||||
this.init(message, caption, opts);
|
||||
|
||||
this.hideTrigger = () => {
|
||||
if (this.ok) {
|
||||
resolve({value: this.inputValue});
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
this.type = 'prompt';
|
||||
if (opts) {
|
||||
this.inputValidator = opts.inputValidator || null;
|
||||
this.inputValue = opts.inputValue || '';
|
||||
}
|
||||
this.active = true;
|
||||
});
|
||||
}
|
||||
|
||||
getHotKey(message, caption, opts) {
|
||||
return new Promise((resolve) => {
|
||||
this.init(message, caption, opts);
|
||||
|
||||
this.hideTrigger = () => {
|
||||
if (this.ok) {
|
||||
resolve(this.hotKeyCode);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
this.type = 'hotKey';
|
||||
this.active = true;
|
||||
});
|
||||
}
|
||||
|
||||
keyHook(event) {
|
||||
if (this.active && this.showed) {
|
||||
let handled = false;
|
||||
if (this.type == 'hotKey') {
|
||||
if (event.type == 'keydown') {
|
||||
this.hotKeyCode = utils.keyEventToCode(event);
|
||||
handled = true;
|
||||
}
|
||||
} else {
|
||||
if (event.key == 'Enter') {
|
||||
this.okClick();
|
||||
handled = true;
|
||||
}
|
||||
|
||||
if (event.key == 'Escape') {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.dialog.hide();
|
||||
});
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(StdDialog);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
font-size: 110%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.error {
|
||||
height: 20px;
|
||||
font-size: 80%;
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +1,183 @@
|
||||
<template>
|
||||
<div class="window">
|
||||
<div class="header">
|
||||
<span class="header-text"><slot name="header"></slot></span>
|
||||
<span class="close-button" @click="close"><i class="el-icon-close"></i></span>
|
||||
<div ref="main" class="main xyfit absolute" @click="close" @mouseup="onMouseUp" @mousemove="onMouseMove">
|
||||
<div ref="windowBox" class="xyfit absolute flex no-wrap" @click.stop>
|
||||
<div ref="window" class="window flexfit column no-wrap">
|
||||
<div
|
||||
ref="header"
|
||||
class="header row justify-end"
|
||||
@mousedown.prevent.stop="onMouseDown"
|
||||
@touchstart.stop="onTouchStart"
|
||||
@touchend.stop="onTouchEnd"
|
||||
@touchmove.stop="onTouchMove"
|
||||
>
|
||||
<div class="header-text col" style="width: 0">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<slot name="buttons"></slot>
|
||||
<span class="close-button row justify-center items-center" @mousedown.stop @click="close"><q-icon name="la la-times" size="16px" /></span>
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
//-----------------------------------------------------------------------------
|
||||
import Vue from 'vue';
|
||||
import Component from 'vue-class-component';
|
||||
import vueComponent from '../vueComponent.js';
|
||||
|
||||
export default @Component({
|
||||
})
|
||||
class Window extends Vue {
|
||||
close() {
|
||||
this.$emit('close');
|
||||
class Window {
|
||||
_props = {
|
||||
height: { type: String, default: '100%' },
|
||||
width: { type: String, default: '100%' },
|
||||
maxWidth: { type: String, default: '' },
|
||||
topShift: { type: Number, default: 0 },
|
||||
margin: '',
|
||||
};
|
||||
|
||||
init() {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.main.style.top = 0;
|
||||
this.$refs.main.style.left = 0;
|
||||
|
||||
this.$refs.windowBox.style.height = this.height;
|
||||
this.$refs.windowBox.style.width = this.width;
|
||||
if (this.maxWidth)
|
||||
this.$refs.windowBox.style.maxWidth = this.maxWidth;
|
||||
|
||||
const left = (this.$refs.main.offsetWidth - this.$refs.windowBox.offsetWidth)/2;
|
||||
const top = (this.$refs.main.offsetHeight - this.$refs.windowBox.offsetHeight)/2 + this.topShift;
|
||||
this.$refs.windowBox.style.left = (left > 0 ? left : 0) + 'px';
|
||||
this.$refs.windowBox.style.top = (top > 0 ? top : 0) + 'px';
|
||||
|
||||
if (this.margin)
|
||||
this.$refs.window.style.margin = this.margin;
|
||||
});
|
||||
}
|
||||
|
||||
onMouseDown(event) {
|
||||
if (this.$root.isMobileDevice)
|
||||
return;
|
||||
if (event.button == 0) {
|
||||
this.$refs.header.style.cursor = 'move';
|
||||
this.startX = event.screenX;
|
||||
this.startY = event.screenY;
|
||||
this.moving = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp(event) {
|
||||
if (event.button == 0) {
|
||||
this.$refs.header.style.cursor = 'default';
|
||||
this.moving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove(event) {
|
||||
if (this.moving) {
|
||||
const deltaX = event.screenX - this.startX;
|
||||
const deltaY = event.screenY - this.startY;
|
||||
this.startX = event.screenX;
|
||||
this.startY = event.screenY;
|
||||
|
||||
this.$refs.windowBox.style.left = (this.$refs.windowBox.offsetLeft + deltaX) + 'px';
|
||||
this.$refs.windowBox.style.top = (this.$refs.windowBox.offsetTop + deltaY) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
onTouchStart(event) {
|
||||
if (!this.$root.isMobileDevice)
|
||||
return;
|
||||
if (event.touches.length == 1) {
|
||||
const touch = event.touches[0];
|
||||
this.$refs.header.style.cursor = 'move';
|
||||
this.startX = touch.screenX;
|
||||
this.startY = touch.screenY;
|
||||
this.moving = true;
|
||||
}
|
||||
}
|
||||
|
||||
onTouchMove(event) {
|
||||
if (!this.$root.isMobileDevice)
|
||||
return;
|
||||
if (event.touches.length == 1 && this.moving) {
|
||||
const touch = event.touches[0];
|
||||
const deltaX = touch.screenX - this.startX;
|
||||
const deltaY = touch.screenY - this.startY;
|
||||
this.startX = touch.screenX;
|
||||
this.startY = touch.screenY;
|
||||
|
||||
this.$refs.windowBox.style.left = (this.$refs.windowBox.offsetLeft + deltaX) + 'px';
|
||||
this.$refs.windowBox.style.top = (this.$refs.windowBox.offsetTop + deltaY) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
onTouchEnd() {
|
||||
if (!this.$root.isMobileDevice)
|
||||
return;
|
||||
this.$refs.header.style.cursor = 'default';
|
||||
this.moving = false;
|
||||
}
|
||||
|
||||
|
||||
close() {
|
||||
if (!this.moving)
|
||||
this.$emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
export default vueComponent(Window);
|
||||
//-----------------------------------------------------------------------------
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.window {
|
||||
.main {
|
||||
background-color: transparent !important;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.xyfit {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flexfit {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.window {
|
||||
margin: 10px;
|
||||
background-color: #ffffff;
|
||||
border: 3px double black;
|
||||
background-color: var(--bg-app-color);
|
||||
border: 3px double var(--text-app-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 3px 3px 5px black;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background-color: #e5e7ea;
|
||||
background: linear-gradient(to bottom right, var(--bg-header-color1), var(--bg-header-color2));
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
color: #FFFFA0;
|
||||
text-shadow: 2px 2px 5px #005000, 2px 1px 5px #005000;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
.close-button:hover {
|
||||
color: white;
|
||||
background-color: #FF3030;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
61
client/components/vueComponent.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import _ from 'lodash';
|
||||
|
||||
export default function(componentClass) {
|
||||
const comp = {};
|
||||
const obj = new componentClass();
|
||||
|
||||
//data, options, props
|
||||
const data = {};
|
||||
for (const prop of Object.getOwnPropertyNames(obj)) {
|
||||
if (['_options', '_props'].includes(prop)) {//meta props
|
||||
if (prop === '_options') {
|
||||
const options = obj[prop];
|
||||
for (const optName of ['components', 'watch', 'emits']) {
|
||||
if (options[optName]) {
|
||||
comp[optName] = options[optName];
|
||||
}
|
||||
}
|
||||
} else if (prop === '_props') {
|
||||
comp.props = obj[prop];
|
||||
}
|
||||
} else {//usual prop
|
||||
data[prop] = obj[prop];
|
||||
}
|
||||
}
|
||||
comp.data = () => _.cloneDeep(data);
|
||||
|
||||
//methods
|
||||
const methods = {};
|
||||
const computed = {};
|
||||
|
||||
let classProto = Object.getPrototypeOf(obj);
|
||||
while (classProto) {
|
||||
const classMethods = Object.getOwnPropertyNames(classProto);
|
||||
for (const method of classMethods) {
|
||||
const desc = Object.getOwnPropertyDescriptor(classProto, method);
|
||||
if (desc.get) {//has getter, computed
|
||||
if (!computed[method]) {
|
||||
computed[method] = {get: desc.get};
|
||||
if (desc.set)
|
||||
computed[method].set = desc.set;
|
||||
}
|
||||
} else if ( ['beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'activated',
|
||||
'deactivated', 'beforeUnmount', 'unmounted', 'errorCaptured', 'renderTracked', 'renderTriggered',
|
||||
'setup'].includes(method) ) {//life cycle hooks
|
||||
if (!comp[method])
|
||||
comp[method] = obj[method];
|
||||
} else if (method !== 'constructor') {//usual
|
||||
if (!methods[method])
|
||||
methods[method] = obj[method];
|
||||
}
|
||||
}
|
||||
|
||||
classProto = Object.getPrototypeOf(classProto);
|
||||
}
|
||||
comp.methods = methods;
|
||||
comp.computed = computed;
|
||||
|
||||
//console.log(comp);
|
||||
return defineComponent(comp);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
/*
|
||||
import ElementUI from 'element-ui';
|
||||
import './theme/index.css';
|
||||
import locale from 'element-ui/lib/locale/lang/ru-RU';
|
||||
|
||||
Vue.use(ElementUI, { locale });
|
||||
*/
|
||||
|
||||
//------------------------------------------------------
|
||||
//import './theme/index.css';
|
||||
|
||||
import './theme/icon.css';
|
||||
import './theme/tooltip.css';
|
||||
|
||||
import ElMenu from 'element-ui/lib/menu';
|
||||
import './theme/menu.css';
|
||||
|
||||
import ElMenuItem from 'element-ui/lib/menu-item';
|
||||
import './theme/menu-item.css';
|
||||
|
||||
import ElButton from 'element-ui/lib/button';
|
||||
import './theme/button.css';
|
||||
|
||||
import ElButtonGroup from 'element-ui/lib/button-group';
|
||||
import './theme/button-group.css';
|
||||
|
||||
import ElCheckbox from 'element-ui/lib/checkbox';
|
||||
import './theme/checkbox.css';
|
||||
|
||||
import ElTabs from 'element-ui/lib/tabs';
|
||||
import './theme/tabs.css';
|
||||
|
||||
import ElTabPane from 'element-ui/lib/tab-pane';
|
||||
import './theme/tab-pane.css';
|
||||
|
||||
import ElTooltip from 'element-ui/lib/tooltip';
|
||||
import './theme/tooltip.css';
|
||||
|
||||
import ElCol from 'element-ui/lib/col';
|
||||
import './theme/col.css';
|
||||
|
||||
import ElContainer from 'element-ui/lib/container';
|
||||
import './theme/container.css';
|
||||
|
||||
import ElAside from 'element-ui/lib/aside';
|
||||
import './theme/aside.css';
|
||||
|
||||
import ElHeader from 'element-ui/lib/header';
|
||||
import './theme/header.css';
|
||||
|
||||
import ElMain from 'element-ui/lib/main';
|
||||
import './theme/main.css';
|
||||
|
||||
import ElInput from 'element-ui/lib/input';
|
||||
import './theme/input.css';
|
||||
|
||||
import ElInputNumber from 'element-ui/lib/input-number';
|
||||
import './theme/input-number.css';
|
||||
|
||||
import ElSelect from 'element-ui/lib/select';
|
||||
import './theme/select.css';
|
||||
|
||||
import ElOption from 'element-ui/lib/option';
|
||||
import './theme/option.css';
|
||||
|
||||
import ElTable from 'element-ui/lib/table';
|
||||
import './theme/table.css';
|
||||
|
||||
import ElTableColumn from 'element-ui/lib/table-column';
|
||||
import './theme/table-column.css';
|
||||
|
||||
import ElProgress from 'element-ui/lib/progress';
|
||||
import './theme/progress.css';
|
||||
|
||||
import ElSlider from 'element-ui/lib/slider';
|
||||
import './theme/slider.css';
|
||||
|
||||
import ElForm from 'element-ui/lib/form';
|
||||
import './theme/form.css';
|
||||
|
||||
import ElFormItem from 'element-ui/lib/form-item';
|
||||
import './theme/form-item.css';
|
||||
|
||||
import ElColorPicker from 'element-ui/lib/color-picker';
|
||||
import './theme/color-picker.css';
|
||||
|
||||
import Notification from 'element-ui/lib/notification';
|
||||
import './theme/notification.css';
|
||||
|
||||
import Loading from 'element-ui/lib/loading';
|
||||
import './theme/loading.css';
|
||||
|
||||
import MessageBox from 'element-ui/lib/message-box';
|
||||
import './theme/message-box.css';
|
||||
|
||||
const components = {
|
||||
ElMenu, ElMenuItem, ElButton, ElButtonGroup, ElCheckbox, ElTabs, ElTabPane, ElTooltip,
|
||||
ElCol, ElContainer, ElAside, ElMain, ElHeader,
|
||||
ElInput, ElInputNumber, ElSelect, ElOption, ElTable, ElTableColumn,
|
||||
ElProgress, ElSlider, ElForm, ElFormItem,
|
||||
ElColorPicker,
|
||||
};
|
||||
|
||||
for (let name in components) {
|
||||
Vue.component(name, components[name]);
|
||||
}
|
||||
|
||||
//Vue.use(Loading.directive);
|
||||
|
||||
Vue.prototype.$loading = Loading.service;
|
||||
Vue.prototype.$msgbox = MessageBox;
|
||||
Vue.prototype.$alert = MessageBox.alert;
|
||||
Vue.prototype.$confirm = MessageBox.confirm;
|
||||
Vue.prototype.$prompt = MessageBox.prompt;
|
||||
Vue.prototype.$notify = Notification;
|
||||
//Vue.prototype.$message = Message;
|
||||
|
||||
import lang from 'element-ui/lib/locale/lang/ru-RU';
|
||||
import locale from 'element-ui/lib/locale';
|
||||
locale.use(lang);
|
||||
@@ -1,9 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title></title>
|
||||
<meta name="description" content="Браузерная онлайн-читалка книг. Поддерживаются форматы: fb2, html, txt, rtf, doc, docx, pdf, epub, mobi.">
|
||||
<meta name="keywords" content="онлайн,читалка,fb2,книги,читать,браузер,интернет">
|
||||
<script src="/sw-register.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||