mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-01-27 22:33:07 +00:00
Compare commits
838 Commits
v3.15.2ABC
...
pr/3820
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6c8f861d | ||
|
|
dc1d40512b | ||
|
|
2dafbf2080 | ||
|
|
033b7fa44f | ||
|
|
2c4f705072 | ||
|
|
ee811e94e1 | ||
|
|
fcae147723 | ||
|
|
b3793a3330 | ||
|
|
952b67aed3 | ||
|
|
27f14b042b | ||
|
|
49d30007d3 | ||
|
|
bd26f02ddb | ||
|
|
ccc37fc0d5 | ||
|
|
9973999e86 | ||
|
|
2aec54748a | ||
|
|
5585c27cb8 | ||
|
|
52ae343309 | ||
|
|
f8d60fc4da | ||
|
|
69e827a663 | ||
|
|
d1d73023a2 | ||
|
|
f4af19ed81 | ||
|
|
bc5a9c9039 | ||
|
|
f7dfedcd44 | ||
|
|
c6b0b6a0ea | ||
|
|
7c7326b42a | ||
|
|
d0cf6eebbb | ||
|
|
0ba838f5ae | ||
|
|
172a3eaadf | ||
|
|
b868ba9406 | ||
|
|
89a16956b9 | ||
|
|
b098d28407 | ||
|
|
1be1b3b747 | ||
|
|
f63d2de8f4 | ||
|
|
626b602a61 | ||
|
|
3d7d90104b | ||
|
|
422fd7bb57 | ||
|
|
c7c8dafb18 | ||
|
|
ea25ae625a | ||
|
|
ebf900ba24 | ||
|
|
0037c91cc5 | ||
|
|
8a8bf883e6 | ||
|
|
17539cb80b | ||
|
|
bb057ba057 | ||
|
|
99ad865311 | ||
|
|
48baaa33e2 | ||
|
|
6f07d25101 | ||
|
|
fec904b4c6 | ||
|
|
a29aca32e7 | ||
|
|
6fed42198d | ||
|
|
446406758f | ||
|
|
2d9da23c25 | ||
|
|
b24c9daa08 | ||
|
|
f5bc490380 | ||
|
|
4673303bc2 | ||
|
|
e550ab4046 | ||
|
|
be4ba06081 | ||
|
|
0fda0673b2 | ||
|
|
9edf65c252 | ||
|
|
3f8ec30f89 | ||
|
|
f1bebe3895 | ||
|
|
f5954b03f2 | ||
|
|
a265723c57 | ||
|
|
e7c0cdae3d | ||
|
|
56d024a2e6 | ||
|
|
f6ed348824 | ||
|
|
a4406d953a | ||
|
|
1a41252d70 | ||
|
|
97371be26a | ||
|
|
994fbd197a | ||
|
|
4995aebb93 | ||
|
|
782fa3a2a0 | ||
|
|
5f71d2902b | ||
|
|
391d0a0bfe | ||
|
|
782ee7a4ad | ||
|
|
987063422d | ||
|
|
26afb67f61 | ||
|
|
d2f6bc03db | ||
|
|
547bedb5f9 | ||
|
|
1b4d41fc19 | ||
|
|
6f2252635a | ||
|
|
46eac41021 | ||
|
|
43441f3185 | ||
|
|
f2765650f7 | ||
|
|
a017c28b02 | ||
|
|
ef1e0f1faa | ||
|
|
5dc961505b | ||
|
|
b129ec1469 | ||
|
|
adcbd7c4c8 | ||
|
|
843aa6d769 | ||
|
|
8ab9b842fc | ||
|
|
f8b5a8133e | ||
|
|
478a541d62 | ||
|
|
eb852b8045 | ||
|
|
07e7f3e70c | ||
|
|
fea8f157a7 | ||
|
|
898be28af3 | ||
|
|
63f6f6d3c6 | ||
|
|
ac2de613c5 | ||
|
|
948f03b5b8 | ||
|
|
28894adeab | ||
|
|
9770fea3fd | ||
|
|
422257743e | ||
|
|
e3619bb1fc | ||
|
|
db1fdca3ab | ||
|
|
c2f56833f3 | ||
|
|
d3cc5c890b | ||
|
|
8eac8eff4b | ||
|
|
8bede8f0ee | ||
|
|
c7894499b1 | ||
|
|
55a50ce261 | ||
|
|
5b675918ad | ||
|
|
70046e00f8 | ||
|
|
e129eb2f5d | ||
|
|
56c9413ab5 | ||
|
|
671044ca3a | ||
|
|
04baabc27d | ||
|
|
4add67086d | ||
|
|
66451a3b1e | ||
|
|
785041dcd1 | ||
|
|
40bb823efa | ||
|
|
0dd5b262f7 | ||
|
|
2ed6aad6f7 | ||
|
|
41cbb67155 | ||
|
|
1ab6ef078e | ||
|
|
25a7af5b70 | ||
|
|
5f41decdb6 | ||
|
|
3a1053ad70 | ||
|
|
d768a41746 | ||
|
|
f5f22fe0e9 | ||
|
|
27c5f86205 | ||
|
|
361d1c1aff | ||
|
|
88e5f26dd8 | ||
|
|
ddfccba822 | ||
|
|
069d054e30 | ||
|
|
1f7ff4386b | ||
|
|
6373c398bc | ||
|
|
7a78408415 | ||
|
|
52311f989d | ||
|
|
b9bf9c7e70 | ||
|
|
45ada35a11 | ||
|
|
9d2a305f03 | ||
|
|
9cc7799d5c | ||
|
|
c235695f04 | ||
|
|
999a96b5ce | ||
|
|
ef5e3ddf4c | ||
|
|
d5f498cbf9 | ||
|
|
5040b9528f | ||
|
|
83a48b8d0c | ||
|
|
9fbdd24d01 | ||
|
|
5b136f651c | ||
|
|
effeef0d67 | ||
|
|
cf3d5df582 | ||
|
|
71cdbf1079 | ||
|
|
712db7dfa7 | ||
|
|
ed48c6664b | ||
|
|
917f20cdd5 | ||
|
|
0c98f832bb | ||
|
|
41a60e6312 | ||
|
|
5994e0d0b3 | ||
|
|
c723f820f7 | ||
|
|
0e9021049c | ||
|
|
1d663ef38d | ||
|
|
d3e11ead54 | ||
|
|
79e1f4caf7 | ||
|
|
59e397a65a | ||
|
|
6b066c3fd3 | ||
|
|
b7413714be | ||
|
|
ec49d5e526 | ||
|
|
d53a271c9f | ||
|
|
9d81f50b60 | ||
|
|
ac766f3b37 | ||
|
|
cd09408aa8 | ||
|
|
631ef795b7 | ||
|
|
01c4e3ec1f | ||
|
|
5a75182aff | ||
|
|
8538ccabe6 | ||
|
|
8ef88a4799 | ||
|
|
189625c79b | ||
|
|
d872a496a7 | ||
|
|
a1d2b5314b | ||
|
|
9a4473526a | ||
|
|
5077fda3f6 | ||
|
|
397ae31f56 | ||
|
|
f2f06b23fd | ||
|
|
87915ef0ef | ||
|
|
c9240c7023 | ||
|
|
e54d237a19 | ||
|
|
ccfd5578cf | ||
|
|
618e865e52 | ||
|
|
4dd07a3c11 | ||
|
|
7888bfa878 | ||
|
|
17b77f5460 | ||
|
|
d348d1e689 | ||
|
|
2aa60f793d | ||
|
|
72739f28e8 | ||
|
|
f1aeea18d4 | ||
|
|
321bbba4b8 | ||
|
|
36af1cdb7f | ||
|
|
0813daf01f | ||
|
|
6d137d9ca8 | ||
|
|
5490cc9fe6 | ||
|
|
10283e6e45 | ||
|
|
154ee06fc4 | ||
|
|
810c2140c9 | ||
|
|
c4fbc8d827 | ||
|
|
96ae07a456 | ||
|
|
0d1291a713 | ||
|
|
3c66907a86 | ||
|
|
0663e82fa1 | ||
|
|
6e241c5bcd | ||
|
|
ebe5dca7a9 | ||
|
|
b33d9264d3 | ||
|
|
0855c5c181 | ||
|
|
1741abc3fe | ||
|
|
f1af87ee7e | ||
|
|
7dcceb983e | ||
|
|
63f4104f81 | ||
|
|
6bc865144a | ||
|
|
ccafee7a21 | ||
|
|
033a089fd8 | ||
|
|
27f471791d | ||
|
|
ae11da2bc7 | ||
|
|
b58b9ca8f0 | ||
|
|
ba0b3e7d93 | ||
|
|
6d4b1843ae | ||
|
|
6fca21b6ed | ||
|
|
76d6679002 | ||
|
|
4efa1b10f3 | ||
|
|
b9e15746c3 | ||
|
|
1fff75cc5e | ||
|
|
9037cf1750 | ||
|
|
dfe26280d2 | ||
|
|
7894d9fbec | ||
|
|
a3dbaf9e6a | ||
|
|
835305bcf6 | ||
|
|
c3173d2e14 | ||
|
|
4859756ef8 | ||
|
|
1c47d743d6 | ||
|
|
bfbbbe9e86 | ||
|
|
1aaa146412 | ||
|
|
086d85c08b | ||
|
|
134fe7d372 | ||
|
|
836dfbade2 | ||
|
|
52a7ce9866 | ||
|
|
395f2d16fa | ||
|
|
6cabdc0a67 | ||
|
|
f64e7d3fd7 | ||
|
|
cdbb2fa26a | ||
|
|
bec830c3b8 | ||
|
|
e80588b234 | ||
|
|
73504a3386 | ||
|
|
9c4b936ddd | ||
|
|
222d49bdca | ||
|
|
05f3f40e47 | ||
|
|
7cad7fd319 | ||
|
|
dca9099d00 | ||
|
|
f386be3494 | ||
|
|
c4074d67f5 | ||
|
|
fa78d04e89 | ||
|
|
5f9dfc9258 | ||
|
|
d534eddb29 | ||
|
|
9099db5ea1 | ||
|
|
a9a8b4b9bb | ||
|
|
5d29d40c97 | ||
|
|
9506be6b65 | ||
|
|
1aabb84731 | ||
|
|
4291284252 | ||
|
|
fcede5448e | ||
|
|
c47974cb49 | ||
|
|
4fde4600bc | ||
|
|
27f939201d | ||
|
|
6e2cde507d | ||
|
|
95d6e39a44 | ||
|
|
39b8cbae2a | ||
|
|
0162232053 | ||
|
|
00f1d4a27e | ||
|
|
db618fe2ad | ||
|
|
47f2703388 | ||
|
|
52e929ee68 | ||
|
|
f74e72a35f | ||
|
|
e3d256aaaf | ||
|
|
f65dee28cb | ||
|
|
58dd1b147d | ||
|
|
f84dcd9fce | ||
|
|
cc1ab35255 | ||
|
|
b2c3d620a4 | ||
|
|
2d8874acaf | ||
|
|
001bf4a605 | ||
|
|
803ca09ab6 | ||
|
|
8a60a4a5cc | ||
|
|
a345b67ffe | ||
|
|
456cefd535 | ||
|
|
2b6e166e86 | ||
|
|
f8c995e59e | ||
|
|
656c9399ef | ||
|
|
55b5c1e713 | ||
|
|
ab6861675d | ||
|
|
0deb9073cd | ||
|
|
d5cda45d4d | ||
|
|
36674f4cf2 | ||
|
|
618de544bf | ||
|
|
8f5b421531 | ||
|
|
8db12739d3 | ||
|
|
6456c22c61 | ||
|
|
4cb093c0c0 | ||
|
|
9f0f9a9169 | ||
|
|
a9aab5bb0c | ||
|
|
5ca970bdee | ||
|
|
9635e1a8eb | ||
|
|
23e3c98a0d | ||
|
|
346bb0086e | ||
|
|
e873dcf3a8 | ||
|
|
269dd6107c | ||
|
|
8281db8543 | ||
|
|
66db3ecdc1 | ||
|
|
1ef61b32d4 | ||
|
|
dc66d36b2d | ||
|
|
0bd3b53dd1 | ||
|
|
5651c66562 | ||
|
|
fb2d03f5a2 | ||
|
|
0b44e68a36 | ||
|
|
fe7ee78cae | ||
|
|
aa68762294 | ||
|
|
2a9402634f | ||
|
|
291e16b124 | ||
|
|
e75eb72d3f | ||
|
|
7bf95dd0ca | ||
|
|
80a21e3f27 | ||
|
|
a921d0a9bb | ||
|
|
9acecb63ed | ||
|
|
a6efaf0e8b | ||
|
|
c4b754e467 | ||
|
|
e82411d3d2 | ||
|
|
5080fd068a | ||
|
|
88d36bcf85 | ||
|
|
e2243efe82 | ||
|
|
337531a622 | ||
|
|
e396a51ad5 | ||
|
|
7e165c6e61 | ||
|
|
5d9ef3fa6c | ||
|
|
24bffacaeb | ||
|
|
1e38ed8d1f | ||
|
|
25ce1aa00c | ||
|
|
97f8493319 | ||
|
|
c9241e3091 | ||
|
|
70118022b8 | ||
|
|
68c75fbfd2 | ||
|
|
d6d6cc1e29 | ||
|
|
9fce94af63 | ||
|
|
41f390b305 | ||
|
|
226e714f32 | ||
|
|
f3332fb95b | ||
|
|
64dd71601c | ||
|
|
4968300e7a | ||
|
|
3acb25ce3a | ||
|
|
8115b1504e | ||
|
|
34fa724fdd | ||
|
|
24544e713e | ||
|
|
06a806e260 | ||
|
|
4259931b67 | ||
|
|
a4e0f1fc0f | ||
|
|
2ada6ce70d | ||
|
|
132878fd8c | ||
|
|
0146ab7ce0 | ||
|
|
a29addbfa3 | ||
|
|
796f8ac8b7 | ||
|
|
19d76bd077 | ||
|
|
f59a250bb1 | ||
|
|
5b64052c21 | ||
|
|
f00e76319c | ||
|
|
a844b29165 | ||
|
|
fcd1a2de5b | ||
|
|
5eb1456915 | ||
|
|
a82e9758b3 | ||
|
|
6adac74f76 | ||
|
|
3c3b4d8466 | ||
|
|
9cc4d2d7c5 | ||
|
|
d216216df7 | ||
|
|
9fd92e00a1 | ||
|
|
afeadb5417 | ||
|
|
e9286d4bb7 | ||
|
|
22dbe1ebf0 | ||
|
|
53761bc567 | ||
|
|
e6e9029bb7 | ||
|
|
84f49aebce | ||
|
|
8949248bc4 | ||
|
|
df06d8fcd3 | ||
|
|
d6ca6592a2 | ||
|
|
77f162f7a4 | ||
|
|
bc475b5ed9 | ||
|
|
0415af624a | ||
|
|
8a63859546 | ||
|
|
8d5bc9e37c | ||
|
|
313f18c74c | ||
|
|
0c6bc5d7ac | ||
|
|
db6c689914 | ||
|
|
d4970ed119 | ||
|
|
e45fddad60 | ||
|
|
a31f1da4dc | ||
|
|
3ec3cf8df8 | ||
|
|
4d6d8a5e5a | ||
|
|
96acd334a0 | ||
|
|
a0aa975d07 | ||
|
|
8a06257a50 | ||
|
|
aaa0acdfea | ||
|
|
570c850c4f | ||
|
|
17dfacd5c9 | ||
|
|
6d904111f7 | ||
|
|
f1f686d8c7 | ||
|
|
6e120c2d05 | ||
|
|
9b58db9f1e | ||
|
|
ae123a8310 | ||
|
|
1f047890ab | ||
|
|
58b0e12fcc | ||
|
|
51f4c83ec0 | ||
|
|
9decaf73f7 | ||
|
|
15fde76209 | ||
|
|
2ba160fe65 | ||
|
|
606af87e0f | ||
|
|
ab32695ac9 | ||
|
|
cd0bf9c947 | ||
|
|
c039a90624 | ||
|
|
0e8387ec0d | ||
|
|
1347374ff7 | ||
|
|
9419186e78 | ||
|
|
58c6e6a446 | ||
|
|
6e2bc1cabe | ||
|
|
971be6375e | ||
|
|
4353c01032 | ||
|
|
d09cecedd7 | ||
|
|
235e3f484f | ||
|
|
b53b279241 | ||
|
|
4fd358771a | ||
|
|
02147411e3 | ||
|
|
5d68cddd18 | ||
|
|
74a065e747 | ||
|
|
87c6343f30 | ||
|
|
544f4c6103 | ||
|
|
a6ac6b98c2 | ||
|
|
2336f8508b | ||
|
|
672b787cd5 | ||
|
|
931566636b | ||
|
|
ffaca4ec10 | ||
|
|
fabc0bea83 | ||
|
|
5c2ad7dfee | ||
|
|
3e7d4714a2 | ||
|
|
77c4ac6640 | ||
|
|
a7c892c1bb | ||
|
|
dca7086522 | ||
|
|
6c42a7e180 | ||
|
|
e8c2858154 | ||
|
|
84f84782f5 | ||
|
|
3caec793d8 | ||
|
|
9717f0cd66 | ||
|
|
0cdc1947c1 | ||
|
|
a8e5a96c98 | ||
|
|
f024bea493 | ||
|
|
61d77b4d2d | ||
|
|
2e5ebb861e | ||
|
|
1b577c4030 | ||
|
|
bbe4b5f978 | ||
|
|
14d2534542 | ||
|
|
3b49b5180e | ||
|
|
30e042635c | ||
|
|
3c04d491e6 | ||
|
|
41e08831c6 | ||
|
|
32c583ece8 | ||
|
|
a92b44427d | ||
|
|
5961e9042a | ||
|
|
2028f3dccd | ||
|
|
8e4fc01831 | ||
|
|
e92c169e71 | ||
|
|
e2ae6898fd | ||
|
|
471de9df9f | ||
|
|
398e6ef6f2 | ||
|
|
44262e2aae | ||
|
|
d8e174e143 | ||
|
|
bb59f0bbae | ||
|
|
c50ffe0723 | ||
|
|
0d2878a7e7 | ||
|
|
a0d043439c | ||
|
|
8126271ea3 | ||
|
|
9bb21ddd04 | ||
|
|
746cd34087 | ||
|
|
313727035b | ||
|
|
1a8611c528 | ||
|
|
f7aa9346e9 | ||
|
|
83a7636b6f | ||
|
|
53c05a3ef6 | ||
|
|
e20e681888 | ||
|
|
f8fef1187c | ||
|
|
a866b45c55 | ||
|
|
8ceb422156 | ||
|
|
8315df33ae | ||
|
|
59f6f40ace | ||
|
|
91f9a76af2 | ||
|
|
ae7404eb1f | ||
|
|
7a2fecf502 | ||
|
|
dfd3b99232 | ||
|
|
a953bf0555 | ||
|
|
88ff10d229 | ||
|
|
8d479b8cd1 | ||
|
|
63675a46e0 | ||
|
|
5cc5eec619 | ||
|
|
b5490e3a53 | ||
|
|
1645a5acf4 | ||
|
|
98c5b798a7 | ||
|
|
295d878c3d | ||
|
|
a283438b28 | ||
|
|
48bdc417fa | ||
|
|
d2117259eb | ||
|
|
67e265b23f | ||
|
|
25a40e31c5 | ||
|
|
a353425d07 | ||
|
|
c07c02f1d9 | ||
|
|
81ab9417d3 | ||
|
|
85401ba71b | ||
|
|
3ad0755c36 | ||
|
|
dc67c75130 | ||
|
|
3388fccad7 | ||
|
|
98cc79df92 | ||
|
|
189363ec76 | ||
|
|
dbe56abb24 | ||
|
|
e213eb0a78 | ||
|
|
422829cbd8 | ||
|
|
84c0242eee | ||
|
|
eddc81d051 | ||
|
|
2f392a7517 | ||
|
|
531e6efa5e | ||
|
|
72257dc71b | ||
|
|
b456bb955a | ||
|
|
181c6bf65a | ||
|
|
d4fa5d55d0 | ||
|
|
5a932b781b | ||
|
|
eebf24e1ba | ||
|
|
26a126859d | ||
|
|
41b9a570b5 | ||
|
|
76c9f2ee71 | ||
|
|
5c2acf3183 | ||
|
|
fa2874b18f | ||
|
|
7e776df4d4 | ||
|
|
ebc3b4ee66 | ||
|
|
7009ef4441 | ||
|
|
ff19e3875e | ||
|
|
7ec2558eef | ||
|
|
a7cf49557a | ||
|
|
c4c5ffff9b | ||
|
|
719cc0c485 | ||
|
|
d01548feb6 | ||
|
|
48eb42862a | ||
|
|
ace790739f | ||
|
|
c77d6e5fae | ||
|
|
b6bbed0e1b | ||
|
|
9bf28f1433 | ||
|
|
dbbfb0b628 | ||
|
|
4f2c2916d6 | ||
|
|
629b51a26c | ||
|
|
d947ff45e2 | ||
|
|
a2d260c297 | ||
|
|
c411691fd6 | ||
|
|
4a9fe1dbdb | ||
|
|
0ce0ae771b | ||
|
|
6334d191f8 | ||
|
|
75699874d0 | ||
|
|
f1633cf03c | ||
|
|
3ef91cb1ea | ||
|
|
f40c5e17ca | ||
|
|
5b8928685f | ||
|
|
1b0fd6bb33 | ||
|
|
84d237e792 | ||
|
|
7d298565f9 | ||
|
|
7c59f56fb2 | ||
|
|
091e7e0b65 | ||
|
|
bc6b4e3bfc | ||
|
|
5a2e071879 | ||
|
|
8fa5eeb0ef | ||
|
|
59f27197f6 | ||
|
|
1646ba7e25 | ||
|
|
29460edca9 | ||
|
|
f8d170be87 | ||
|
|
2ecdd962bd | ||
|
|
ed376f3154 | ||
|
|
930974f66d | ||
|
|
aba8946274 | ||
|
|
a2b5903bde | ||
|
|
a93133a9f3 | ||
|
|
ea1d0714b4 | ||
|
|
9f4cf60cda | ||
|
|
e5ab223571 | ||
|
|
45a9501459 | ||
|
|
ec74b994d7 | ||
|
|
b5155ed256 | ||
|
|
315296458a | ||
|
|
e601e19381 | ||
|
|
0fbb4879a9 | ||
|
|
51c8973a85 | ||
|
|
707b90e445 | ||
|
|
7f656bc408 | ||
|
|
5c906ee722 | ||
|
|
3629292ebb | ||
|
|
2cb3ca6880 | ||
|
|
90ee9afb54 | ||
|
|
2284f15876 | ||
|
|
bfcb904ab7 | ||
|
|
232d3c66a4 | ||
|
|
2b458d1265 | ||
|
|
58a2993fe1 | ||
|
|
0f8fcb9889 | ||
|
|
cbe3c79b6b | ||
|
|
c707db4aa5 | ||
|
|
87415d54d5 | ||
|
|
7525509887 | ||
|
|
e5a189939b | ||
|
|
f3bc8f91cc | ||
|
|
3b4dd7dd61 | ||
|
|
4bc957159d | ||
|
|
7881d4b4a2 | ||
|
|
1e9c7423c7 | ||
|
|
fa74fb4ada | ||
|
|
7a37bf47c5 | ||
|
|
be70b9e67d | ||
|
|
f7a5097dd8 | ||
|
|
758c2799a1 | ||
|
|
b0dffc6df1 | ||
|
|
6ea724bb16 | ||
|
|
b58688bd62 | ||
|
|
0f8461ced6 | ||
|
|
3b0028da69 | ||
|
|
049b64cd41 | ||
|
|
8709772f51 | ||
|
|
dcc7a22272 | ||
|
|
92f963d798 | ||
|
|
e5f6d28abd | ||
|
|
3360b4e829 | ||
|
|
9e1a532105 | ||
|
|
b124e55b3d | ||
|
|
6e1cf63ed9 | ||
|
|
bc35b5245b | ||
|
|
4033307473 | ||
|
|
cd30679aac | ||
|
|
9679e5b130 | ||
|
|
4d295f5f18 | ||
|
|
6ed6b6d66f | ||
|
|
87ba4ee264 | ||
|
|
5e9fad9b09 | ||
|
|
6693eebe64 | ||
|
|
f0a8bf379a | ||
|
|
fb843ef3c1 | ||
|
|
22678b15af | ||
|
|
d2cefa8bf7 | ||
|
|
e5d0051075 | ||
|
|
df8fd077ca | ||
|
|
88caa81baa | ||
|
|
4a1e4c1b80 | ||
|
|
cf4747553c | ||
|
|
a2497052b4 | ||
|
|
240dfa3954 | ||
|
|
d19aaf6c78 | ||
|
|
e777fb542a | ||
|
|
5c9c342b10 | ||
|
|
f3011eeef9 | ||
|
|
9fd581149b | ||
|
|
03c14e5847 | ||
|
|
03bc9a8189 | ||
|
|
421c88cc07 | ||
|
|
235969a485 | ||
|
|
2e459118aa | ||
|
|
ff60ca163f | ||
|
|
4dc5746c71 | ||
|
|
49e072f03f | ||
|
|
6f837980eb | ||
|
|
82f2d0254f | ||
|
|
5cf8715dea | ||
|
|
849e5d5d1a | ||
|
|
188090ee45 | ||
|
|
d352b76efe | ||
|
|
e88272c684 | ||
|
|
10ce696333 | ||
|
|
4488fe36db | ||
|
|
c79765396d | ||
|
|
36549f3224 | ||
|
|
2a366c3053 | ||
|
|
de8bd67e07 | ||
|
|
dcfc510ce8 | ||
|
|
e81a9dab1f | ||
|
|
65759e18bd | ||
|
|
f458b98dcf | ||
|
|
cc7fe99760 | ||
|
|
78642e514d | ||
|
|
4edbfa10b5 | ||
|
|
03f8fc83ee | ||
|
|
089dcb942b | ||
|
|
a4f30d687d | ||
|
|
5e8f74b9bc | ||
|
|
b39e8eea16 | ||
|
|
0c6c0c9fd6 | ||
|
|
51d3d11bff | ||
|
|
46882c4fb4 | ||
|
|
760c1a9e8c | ||
|
|
2b79583e8c | ||
|
|
609b40e84c | ||
|
|
f24e47785c | ||
|
|
e27e61aaca | ||
|
|
07c574fa42 | ||
|
|
960ecae861 | ||
|
|
49a4daa8f6 | ||
|
|
1f41745d2b | ||
|
|
1602f0af37 | ||
|
|
375c54016c | ||
|
|
695293333f | ||
|
|
5431d3ed9b | ||
|
|
fc9821a6c4 | ||
|
|
fa63f1d4d5 | ||
|
|
a6969a9ce2 | ||
|
|
78c4061199 | ||
|
|
1ad88c2fca | ||
|
|
de20311299 | ||
|
|
5fede97fa5 | ||
|
|
51f758bf47 | ||
|
|
7b18c3ea0a | ||
|
|
17b22b8afe | ||
|
|
cc76ff1478 | ||
|
|
bbe56bf443 | ||
|
|
f449132b4c | ||
|
|
1c0bbc9390 | ||
|
|
bf3c083e8c | ||
|
|
031ed751d1 | ||
|
|
2d781f02e3 | ||
|
|
baf201cc3a | ||
|
|
b99c0382f6 | ||
|
|
0057e2b57e | ||
|
|
e2ce1185b6 | ||
|
|
8983d74775 | ||
|
|
f084c11936 | ||
|
|
fa0d1d6bc1 | ||
|
|
a442817226 | ||
|
|
73e579703a | ||
|
|
f10ef2bdb3 | ||
|
|
e3586f0734 | ||
|
|
8d49422061 | ||
|
|
59790bd005 | ||
|
|
b34027699f | ||
|
|
dcdc8b4943 | ||
|
|
184462616f | ||
|
|
24c950227a | ||
|
|
df265ffc8a | ||
|
|
73a400b882 | ||
|
|
bcef4006dc | ||
|
|
b55db94822 | ||
|
|
19ee3d6dbb | ||
|
|
c17f976385 | ||
|
|
e83e6567af | ||
|
|
b638cca547 | ||
|
|
2fc5bcabb8 | ||
|
|
52658d6e44 | ||
|
|
9f3a4dc6bb | ||
|
|
acb10d7695 | ||
|
|
fa4ced0592 | ||
|
|
3f1d6a5459 | ||
|
|
d60d902e27 | ||
|
|
e1c1e32a4b | ||
|
|
5c2f603860 | ||
|
|
47b78510df | ||
|
|
7e3f2a3deb | ||
|
|
4680e7a5cc | ||
|
|
f07252d670 | ||
|
|
f15c831b70 | ||
|
|
fdbec6d789 | ||
|
|
8c09772605 | ||
|
|
510d8f410d | ||
|
|
ea9f9a8c36 | ||
|
|
4818f70aed | ||
|
|
cca79d4b17 | ||
|
|
a715c9e1e6 | ||
|
|
587831652c | ||
|
|
90b504d67d | ||
|
|
8efea112b4 | ||
|
|
acbdd1b801 | ||
|
|
d012a09346 | ||
|
|
3cca38302a | ||
|
|
f9352a94c6 | ||
|
|
1add97b1b2 | ||
|
|
6e0aff525f | ||
|
|
748c25aae4 | ||
|
|
e69132b40a | ||
|
|
77450ed334 | ||
|
|
835ca0de32 | ||
|
|
f675fd130f | ||
|
|
bacdd65025 | ||
|
|
07f2e8ba4f | ||
|
|
86887b536e | ||
|
|
b7dc47fe9e | ||
|
|
9343f11366 | ||
|
|
8ece54701d | ||
|
|
243038474e | ||
|
|
a69d251f53 | ||
|
|
7ed48f3e70 | ||
|
|
627b4ace0f | ||
|
|
f2d5a8df99 | ||
|
|
0d8026436c | ||
|
|
8656feba44 | ||
|
|
e9a76dd018 | ||
|
|
db0f75c852 | ||
|
|
0db6ffe340 | ||
|
|
1b855108bf | ||
|
|
ffe12ebee7 | ||
|
|
e211b0858d | ||
|
|
c8ac3f36fd | ||
|
|
8c0cf4ccd4 | ||
|
|
79eb4d8a9a | ||
|
|
52d5d17561 | ||
|
|
0fc3e03e95 | ||
|
|
28cadcad06 | ||
|
|
1fd8648602 | ||
|
|
66e10f3b4e | ||
|
|
da0372e44c | ||
|
|
a4e6b2358a | ||
|
|
24adbdc429 | ||
|
|
ccd5cacb0c | ||
|
|
5e2171ceb1 | ||
|
|
b00a962e77 | ||
|
|
c518fc2d23 | ||
|
|
ca2582fdbd | ||
|
|
04916d8931 | ||
|
|
f781c2bd56 | ||
|
|
8adf5ce463 | ||
|
|
94afbe5417 | ||
|
|
9e169aba91 | ||
|
|
f5c7761c61 | ||
|
|
ec040cc2bb | ||
|
|
42125f4041 | ||
|
|
a499bb3a54 | ||
|
|
35b4c354f2 | ||
|
|
b8fd8a7a86 | ||
|
|
620cb95ae8 | ||
|
|
f66664a3e2 | ||
|
|
d7ee004127 | ||
|
|
4a449c7895 |
@@ -10,7 +10,7 @@ orbs:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
docker:
|
docker:
|
||||||
- image: cimg/node:20.8.0
|
- image: cimg/node:20.17.0
|
||||||
- image: mongo:4.4
|
- image: mongo:4.4
|
||||||
|
|
||||||
working_directory: ~/homebrewery
|
working_directory: ~/homebrewery
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
# fallback to using the latest cache if no exact match is found
|
# fallback to using the latest cache if no exact match is found
|
||||||
- v1-dependencies-
|
- v1-dependencies-
|
||||||
|
|
||||||
- run: sudo npm install -g npm@10.2.0
|
- run: sudo npm install -g npm@10.8.2
|
||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
app-dir: ~/homebrewery
|
app-dir: ~/homebrewery
|
||||||
cache-path: node_modules
|
cache-path: node_modules
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
|
|
||||||
test:
|
test:
|
||||||
docker:
|
docker:
|
||||||
- image: cimg/node:20.8.0
|
- image: cimg/node:20.17.0
|
||||||
|
|
||||||
working_directory: ~/homebrewery
|
working_directory: ~/homebrewery
|
||||||
parallelism: 1
|
parallelism: 1
|
||||||
@@ -76,6 +76,9 @@ jobs:
|
|||||||
- run:
|
- run:
|
||||||
name: Test - Routes
|
name: Test - Routes
|
||||||
command: npm run test:route
|
command: npm run test:route
|
||||||
|
- run:
|
||||||
|
name: Test - HTML sanitization
|
||||||
|
command: npm run test:safehtml
|
||||||
- run:
|
- run:
|
||||||
name: Test - Coverage
|
name: Test - Coverage
|
||||||
command: npm run test:coverage
|
command: npm run test:coverage
|
||||||
|
|||||||
36
.github/pull_request_template.md
vendored
Normal file
36
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!--
|
||||||
|
Before submitting a Pull Request, please consider the following to speed up reviews:
|
||||||
|
- 👷♀️ Create small PRs. Large PRs can usually be broken down into incremental PRs.
|
||||||
|
- 🚩 Do you already have several open PRs? Consider finishing or asking for help with existing PRs first.
|
||||||
|
- 🔧 Does your PR reference a discussed and approved issue, especially for personal or edge-case requests?
|
||||||
|
- 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
|
||||||
|
## Related Issues or Discussions
|
||||||
|
|
||||||
|
- Closes #
|
||||||
|
|
||||||
|
## QA Instructions, Screenshots, Recordings
|
||||||
|
|
||||||
|
_Please replace this line with instructions on how to test or view your changes, as well as any before/after
|
||||||
|
images for UI changes._
|
||||||
|
|
||||||
|
### Reviewer Checklist
|
||||||
|
|
||||||
|
_Please replace the list below with specific features you want reviewers to look at._
|
||||||
|
|
||||||
|
*Reviewers, refer to this list when testing features, or suggest new items *
|
||||||
|
- [ ] Verify new features are functional
|
||||||
|
- [ ] Feature A does X
|
||||||
|
- [ ] Feature B does Y
|
||||||
|
- [ ] Verify old features have not broken
|
||||||
|
- [ ] Feature Z can still be used
|
||||||
|
- [ ] Test for edge cases / try to break things
|
||||||
|
- [ ] Feature A handles negative numbers
|
||||||
|
- [ ] Identify opportunities for simplification and refactoring
|
||||||
|
- [ ] Check for code legibility and appropriate comments
|
||||||
|
|
||||||
|
<details><summary>Copy this list</summary>
|
||||||
76
changelog.md
76
changelog.md
@@ -81,9 +81,85 @@ pre {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## changelog
|
## changelog
|
||||||
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
|
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
|
||||||
|
|
||||||
|
### Saturday 10/12/2024 - v3.16.0
|
||||||
|
|
||||||
|
{{taskList
|
||||||
|
##### 5e-Cleric
|
||||||
|
|
||||||
|
* [x] Added a new API endpoint `/metadata/:shareId` to fetch metadata about individual brews
|
||||||
|
|
||||||
|
Fixes issue [#2638](https://github.com/naturalcrit/homebrewery/issues/2638)
|
||||||
|
|
||||||
|
* [x] Added A3, A5, and Card page size snippets under {{openSans **:fas_paintbrush: STYLE TAB :fas_arrow_right: :fas_print: PRINT**}}
|
||||||
|
|
||||||
|
* [x] Adjust navbar styling for very long titles
|
||||||
|
|
||||||
|
Fixes issue [#2071](https://github.com/naturalcrit/homebrewery/issues/2071)
|
||||||
|
|
||||||
|
* [x] Added some sorting options to the {{openSans **VAULT** {{fas,fa-dungeon}}}} page
|
||||||
|
|
||||||
|
* [x] Fix `language` property not working in share page
|
||||||
|
|
||||||
|
Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776)
|
||||||
|
|
||||||
|
##### abquintic
|
||||||
|
|
||||||
|
* [x] New {{openSans **:fas_pencil: TEXT EDITOR :fas_arrow_right: :fas_bookmark: PAGE NUMBER :fas_arrow_right:**}}
|
||||||
|
{{openSans **:fas_xmark: SKIP PAGE NUMBER**}} and {{openSans **:fas_arrow_rotate_left: RESTART PAGE NUMBER**}} snippets for more control over automatic page numbering.
|
||||||
|
|
||||||
|
Fixes issue [#513](https://github.com/naturalcrit/homebrewery/issues/513)
|
||||||
|
|
||||||
|
* [x] New Table of Contents control options via {{openSans **:fas_pencil: TEXT EDITOR :fas_arrow_right: :fas_book: TABLE OF CONTENTS**}} submenus. By default, H1-H3 is included in the ToC generation, but the new options allow marking `{{blocks}}` to include or exclude specific or ranges of contained headers. Also, a global option to increase the default range of H1-H3 to H1-H4/5/6. After applying these markers, you must regenerate the Table of Contents to see the changes.
|
||||||
|
|
||||||
|
* [x] Added a ":fas_lock: SYNC VIEWS" button onto the divider bar. When locked, scrolling on either panel will sync the other panel to the same page.
|
||||||
|
|
||||||
|
Fixes issue [#241](https://github.com/naturalcrit/homebrewery/issues/241)
|
||||||
|
|
||||||
|
##### Gazook89
|
||||||
|
|
||||||
|
* [x] Added a :fas_glasses: HIDE button to the page navigation bar
|
||||||
|
|
||||||
|
##### G-Ambatte
|
||||||
|
|
||||||
|
* [x] Automatic local backups of your files, in case of accidental data loss. Stores up to 5 snapshots of each brew edited in your browser, incrementing from a few minutes old to a maximum of several days. Restore a backup by clicking an entry in the new {{openSans **:fas_clock_rotate_left: HISTORY**}} button in the snippet bar.
|
||||||
|
|
||||||
|
Fixes issue [#3070](https://github.com/naturalcrit/homebrewery/issues/3070)
|
||||||
|
|
||||||
|
* [x] Fix issue with legacy brews breaking on Share page
|
||||||
|
|
||||||
|
Fixes issue [#3764](https://github.com/naturalcrit/homebrewery/issues/3764)
|
||||||
|
|
||||||
|
* [x] Fix print size when printing a zoomed document
|
||||||
|
|
||||||
|
Fixes issue [#3744](https://github.com/naturalcrit/homebrewery/issues/3744)
|
||||||
|
|
||||||
|
##### All
|
||||||
|
|
||||||
|
* [x] Background code cleanup, security fixes, dev tool improvements, dependency updates, prep for upcoming features, etc.
|
||||||
|
}}
|
||||||
|
|
||||||
|
### Wednesday 9/25/2024 - v3.15.1
|
||||||
|
|
||||||
|
{{taskList
|
||||||
|
##### calculuschild
|
||||||
|
|
||||||
|
* [x] Background fixes to handle Google Drive issues
|
||||||
|
|
||||||
|
* [x] Remove duplicate error logging
|
||||||
|
|
||||||
|
##### calculuschild, 5e-Cleric
|
||||||
|
|
||||||
|
* [x] Fix links in {{openSans **RECENT BREWS :fas_clock_rotate_left:**}} and user {{openSans **BREWS :fas_beer_mug_empty:**}} pointing to trashed Google Drive files after transferring from Google to Homebrewery storage
|
||||||
|
|
||||||
|
Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776)
|
||||||
|
}}
|
||||||
|
|
||||||
|
\page
|
||||||
|
|
||||||
### Wednesday 9/04/2024 - v3.15.0
|
### Wednesday 9/04/2024 - v3.15.0
|
||||||
|
|
||||||
{{taskList
|
{{taskList
|
||||||
|
|||||||
@@ -2,35 +2,44 @@ require('./admin.less');
|
|||||||
const React = require('react');
|
const React = require('react');
|
||||||
const createClass = require('create-react-class');
|
const createClass = require('create-react-class');
|
||||||
|
|
||||||
|
const BrewUtils = require('./brewUtils/brewUtils.jsx');
|
||||||
|
const NotificationUtils = require('./notificationUtils/notificationUtils.jsx');
|
||||||
|
|
||||||
const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
|
const tabGroups = ['brew', 'notifications'];
|
||||||
const BrewLookup = require('./brewLookup/brewLookup.jsx');
|
|
||||||
const BrewCompress = require ('./brewCompress/brewCompress.jsx');
|
|
||||||
const Stats = require('./stats/stats.jsx');
|
|
||||||
|
|
||||||
const Admin = createClass({
|
const Admin = createClass({
|
||||||
getDefaultProps : function() {
|
getDefaultProps : function() {
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getInitialState : function(){
|
||||||
|
return ({
|
||||||
|
currentTab : 'brew'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
handleClick : function(newTab){
|
||||||
|
if(this.state.currentTab === newTab) return;
|
||||||
|
this.setState({
|
||||||
|
currentTab : newTab
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
render : function(){
|
render : function(){
|
||||||
return <div className='admin'>
|
return <div className='admin'>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div className='container'>
|
<div className='container'>
|
||||||
<i className='fas fa-rocket' />
|
<i className='fas fa-rocket' />
|
||||||
homebrewery admin
|
homebrewery admin
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className='container'>
|
<main className='container'>
|
||||||
<Stats />
|
<nav className='tabs'>
|
||||||
<hr />
|
{tabGroups.map((tab, idx)=>{ return <button className={tab===this.state.currentTab ? 'active' : ''} key={idx} onClick={()=>{ return this.handleClick(tab); }}>{tab.toUpperCase()}</button>; })}
|
||||||
<BrewLookup />
|
</nav>
|
||||||
<hr />
|
{this.state.currentTab==='brew' && <BrewUtils />}
|
||||||
<BrewCleanup />
|
{this.state.currentTab==='notifications' && <NotificationUtils />}
|
||||||
<hr />
|
</main>
|
||||||
<BrewCompress />
|
|
||||||
</div>
|
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,39 +6,95 @@
|
|||||||
|
|
||||||
@import 'font-awesome/css/font-awesome.css';
|
@import 'font-awesome/css/font-awesome.css';
|
||||||
|
|
||||||
html,body, #reactContainer, .naturalCrit{
|
html,body, #reactContainer, .naturalCrit { min-height : 100%; }
|
||||||
min-height : 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@sidebarWidth : 250px;
|
@sidebarWidth : 250px;
|
||||||
|
|
||||||
body{
|
body {
|
||||||
background-color : #eee;
|
|
||||||
font-family : 'Open Sans', sans-serif;
|
|
||||||
color : #4b5055;
|
|
||||||
font-weight : 100;
|
|
||||||
text-rendering : optimizeLegibility;
|
|
||||||
margin : 0;
|
|
||||||
padding : 0;
|
|
||||||
height : 100%;
|
height : 100%;
|
||||||
|
padding : 0;
|
||||||
|
margin : 0;
|
||||||
|
font-family : 'Open Sans', sans-serif;
|
||||||
|
font-weight : 100;
|
||||||
|
color : #4B5055;
|
||||||
|
background-color : #EEEEEE;
|
||||||
|
text-rendering : optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin{
|
:where(.admin) {
|
||||||
|
|
||||||
header{
|
header {
|
||||||
background-color : @red;
|
|
||||||
font-size: 2em;
|
|
||||||
padding : 20px 0px;
|
padding : 20px 0px;
|
||||||
|
margin-bottom : 30px;
|
||||||
|
font-size : 2em;
|
||||||
color : white;
|
color : white;
|
||||||
margin-bottom: 30px;
|
background-color : @red;
|
||||||
i{
|
i { margin-right : 30px; }
|
||||||
margin-right: 30px;
|
}
|
||||||
|
|
||||||
|
hr { margin : 30px 0px; }
|
||||||
|
|
||||||
|
:where(.container) {
|
||||||
|
input {
|
||||||
|
height : 33px;
|
||||||
|
padding : 0px 10px;
|
||||||
|
margin-bottom : 20px;
|
||||||
|
font-family : monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
height : 37px;
|
||||||
|
vertical-align : middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
@maxItemWidth : 132px;
|
||||||
|
dt {
|
||||||
|
float : left;
|
||||||
|
width : @maxItemWidth;
|
||||||
|
clear : left;
|
||||||
|
text-align : right;
|
||||||
|
&::after { content : ' : '; }
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
height : 1em;
|
||||||
|
padding : 0 0 0.5em 0;
|
||||||
|
margin-left : @maxItemWidth + 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hr{
|
.tabs button {
|
||||||
margin : 30px 0px;
|
margin-right : 3px;
|
||||||
|
margin-left : 3px;
|
||||||
|
color : black;
|
||||||
|
background-color : #EEEEEE;
|
||||||
|
border : 1px solid #444444;
|
||||||
|
border-radius : 5px;
|
||||||
|
&:hover {
|
||||||
|
color : #EEEEEE;
|
||||||
|
background-color : #444444;
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
margin-right : 2px;
|
||||||
|
margin-left : 2px;
|
||||||
|
text-decoration : underline;
|
||||||
|
background-color : #CCCCCC;
|
||||||
|
border : 2px solid #444444;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notificationUtils {
|
||||||
|
display : flex;
|
||||||
|
gap : 50px;
|
||||||
|
justify-content : space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgb(178, 54, 54);
|
||||||
|
color:white;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-block:10px;
|
||||||
|
padding:10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
.BrewCleanup{
|
|
||||||
.removeBox{
|
|
||||||
margin-top: 20px;
|
|
||||||
button{
|
|
||||||
background-color: @red;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.BrewCompress{
|
|
||||||
.removeBox{
|
|
||||||
margin-top: 20px;
|
|
||||||
button{
|
|
||||||
background-color: @red;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
|
|
||||||
.brewLookup{
|
|
||||||
input{
|
|
||||||
height : 33px;
|
|
||||||
margin-bottom : 20px;
|
|
||||||
padding : 0px 10px;
|
|
||||||
font-family : monospace;
|
|
||||||
}
|
|
||||||
button{
|
|
||||||
vertical-align : middle;
|
|
||||||
height : 37px;
|
|
||||||
}
|
|
||||||
dl{
|
|
||||||
@maxItemWidth : 132px;
|
|
||||||
dt{
|
|
||||||
float : left;
|
|
||||||
clear : left;
|
|
||||||
width : @maxItemWidth;
|
|
||||||
text-align : right;
|
|
||||||
&::after {
|
|
||||||
content: " : ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dd{
|
|
||||||
height : 1em;
|
|
||||||
margin-left : @maxItemWidth + 6px;
|
|
||||||
padding : 0 0 0.5em 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
9
client/admin/brewUtils/brewCleanup/brewCleanup.less
Normal file
9
client/admin/brewUtils/brewCleanup/brewCleanup.less
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.BrewCleanup {
|
||||||
|
.removeBox {
|
||||||
|
margin-top : 20px;
|
||||||
|
button {
|
||||||
|
margin-right : 10px;
|
||||||
|
background-color : @red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
client/admin/brewUtils/brewCompress/brewCompress.less
Normal file
9
client/admin/brewUtils/brewCompress/brewCompress.less
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.BrewCompress {
|
||||||
|
.removeBox {
|
||||||
|
margin-top : 20px;
|
||||||
|
button {
|
||||||
|
margin-right : 10px;
|
||||||
|
background-color : @red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
require('./brewLookup.less');
|
require('./brewLookup.less');
|
||||||
|
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const createClass = require('create-react-class');
|
const createClass = require('create-react-class');
|
||||||
const cx = require('classnames');
|
const cx = require('classnames');
|
||||||
@@ -16,19 +17,40 @@ const BrewLookup = createClass({
|
|||||||
query : '',
|
query : '',
|
||||||
foundBrew : null,
|
foundBrew : null,
|
||||||
searching : false,
|
searching : false,
|
||||||
error : null
|
error : null,
|
||||||
|
scriptCount : 0
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
handleChange(e){
|
handleChange(e){
|
||||||
this.setState({ query: e.target.value });
|
this.setState({ query: e.target.value });
|
||||||
},
|
},
|
||||||
lookup(){
|
lookup(){
|
||||||
this.setState({ searching: true, error: null });
|
this.setState({ searching: true, error: null, scriptCount: 0 });
|
||||||
|
|
||||||
request.get(`/admin/lookup/${this.state.query}`)
|
request.get(`/admin/lookup/${this.state.query}`)
|
||||||
.then((res)=>this.setState({ foundBrew: res.body }))
|
.then((res)=>{
|
||||||
|
const foundBrew = res.body;
|
||||||
|
const scriptCheck = foundBrew?.text.match(/(<\/?s)cript/g);
|
||||||
|
this.setState({
|
||||||
|
foundBrew : foundBrew,
|
||||||
|
scriptCount : scriptCheck?.length || 0,
|
||||||
|
});
|
||||||
|
})
|
||||||
.catch((err)=>this.setState({ error: err }))
|
.catch((err)=>this.setState({ error: err }))
|
||||||
.finally(()=>this.setState({ searching: false }));
|
.finally(()=>{
|
||||||
|
this.setState({
|
||||||
|
searching : false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async cleanScript(){
|
||||||
|
if(!this.state.foundBrew?.shareId) return;
|
||||||
|
|
||||||
|
await request.put(`/admin/clean/script/${this.state.foundBrew.shareId}`)
|
||||||
|
.catch((err)=>{ this.setState({ error: err }); return; });
|
||||||
|
|
||||||
|
this.lookup();
|
||||||
},
|
},
|
||||||
|
|
||||||
renderFoundBrew(){
|
renderFoundBrew(){
|
||||||
@@ -47,12 +69,23 @@ const BrewLookup = createClass({
|
|||||||
<dt>Share Link</dt>
|
<dt>Share Link</dt>
|
||||||
<dd><a href={`/share/${brew.shareId}`} target='_blank' rel='noopener noreferrer'>/share/{brew.shareId}</a></dd>
|
<dd><a href={`/share/${brew.shareId}`} target='_blank' rel='noopener noreferrer'>/share/{brew.shareId}</a></dd>
|
||||||
|
|
||||||
|
<dt>Created Time</dt>
|
||||||
|
<dd>{brew.createdAt ? Moment(brew.createdAt).toLocaleString() : 'No creation date'}</dd>
|
||||||
|
|
||||||
<dt>Last Updated</dt>
|
<dt>Last Updated</dt>
|
||||||
<dd>{Moment(brew.updatedAt).fromNow()}</dd>
|
<dd>{Moment(brew.updatedAt).fromNow()}</dd>
|
||||||
|
|
||||||
<dt>Num of Views</dt>
|
<dt>Num of Views</dt>
|
||||||
<dd>{brew.views}</dd>
|
<dd>{brew.views}</dd>
|
||||||
|
|
||||||
|
<dt>Number of SCRIPT tags detected</dt>
|
||||||
|
<dd>{this.state.scriptCount}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
{this.state.scriptCount > 0 &&
|
||||||
|
<div className='cleanButton'>
|
||||||
|
<button onClick={this.cleanScript}>CLEAN BREW</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>;
|
</div>;
|
||||||
},
|
},
|
||||||
|
|
||||||
6
client/admin/brewUtils/brewLookup/brewLookup.less
Normal file
6
client/admin/brewUtils/brewLookup/brewLookup.less
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.brewLookup {
|
||||||
|
.cleanButton {
|
||||||
|
display : inline-block;
|
||||||
|
width : 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
client/admin/brewUtils/brewUtils.jsx
Normal file
24
client/admin/brewUtils/brewUtils.jsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const React = require('react');
|
||||||
|
const createClass = require('create-react-class');
|
||||||
|
|
||||||
|
|
||||||
|
const BrewCleanup = require('./brewCleanup/brewCleanup.jsx');
|
||||||
|
const BrewLookup = require('./brewLookup/brewLookup.jsx');
|
||||||
|
const BrewCompress = require ('./brewCompress/brewCompress.jsx');
|
||||||
|
const Stats = require('./stats/stats.jsx');
|
||||||
|
|
||||||
|
const BrewUtils = createClass({
|
||||||
|
render : function(){
|
||||||
|
return <>
|
||||||
|
<Stats />
|
||||||
|
<hr />
|
||||||
|
<BrewLookup />
|
||||||
|
<hr />
|
||||||
|
<BrewCleanup />
|
||||||
|
<hr />
|
||||||
|
<BrewCompress />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = BrewUtils;
|
||||||
13
client/admin/brewUtils/stats/stats.less
Normal file
13
client/admin/brewUtils/stats/stats.less
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
.Stats {
|
||||||
|
position : relative;
|
||||||
|
|
||||||
|
.pending {
|
||||||
|
position : absolute;
|
||||||
|
top : 0px;
|
||||||
|
left : 0px;
|
||||||
|
width : 100%;
|
||||||
|
height : 100%;
|
||||||
|
background-color : rgba(238,238,238, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
require('./notificationAdd.less');
|
||||||
|
const React = require('react');
|
||||||
|
const { useState, useRef } = require('react');
|
||||||
|
const request = require('superagent');
|
||||||
|
|
||||||
|
const NotificationAdd = ()=>{
|
||||||
|
const [notificationResult, setNotificationResult] = useState(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const dismissKeyRef = useRef(null);
|
||||||
|
const titleRef = useRef(null);
|
||||||
|
const textRef = useRef(null);
|
||||||
|
const startAtRef = useRef(null);
|
||||||
|
const stopAtRef = useRef(null);
|
||||||
|
|
||||||
|
const saveNotification = async ()=>{
|
||||||
|
const dismissKey = dismissKeyRef.current.value;
|
||||||
|
const title = titleRef.current.value;
|
||||||
|
const text = textRef.current.value;
|
||||||
|
const startAt = new Date(startAtRef.current.value);
|
||||||
|
const stopAt = new Date(stopAtRef.current.value);
|
||||||
|
|
||||||
|
// Basic validation
|
||||||
|
if(!dismissKey || !title || !text || isNaN(startAt.getTime()) || isNaN(stopAt.getTime())) {
|
||||||
|
setError('All fields are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(startAt >= stopAt) {
|
||||||
|
setError('End date must be after the start date!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
dismissKey,
|
||||||
|
title,
|
||||||
|
text,
|
||||||
|
startAt : startAt?.toISOString() ?? '',
|
||||||
|
stopAt : stopAt?.toISOString() ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSearching(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await request.post('/admin/notification/add').send(data);
|
||||||
|
console.log(response.body);
|
||||||
|
|
||||||
|
// Reset form fields
|
||||||
|
dismissKeyRef.current.value = '';
|
||||||
|
titleRef.current.value = '';
|
||||||
|
textRef.current.value = '';
|
||||||
|
|
||||||
|
setNotificationResult('Notification successfully created.');
|
||||||
|
setSearching(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err.response.body.message);
|
||||||
|
setError(`Error saving notification: ${err.response.body.message}`);
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='notificationAdd'>
|
||||||
|
<h2>Add Notification</h2>
|
||||||
|
|
||||||
|
<label className='field'>
|
||||||
|
Dismiss Key:
|
||||||
|
<input className='fieldInput' type='text' ref={dismissKeyRef} required
|
||||||
|
placeholder='dismiss_notif_drive'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className='field'>
|
||||||
|
Title:
|
||||||
|
<input className='fieldInput' type='text' ref={titleRef} required
|
||||||
|
placeholder='Stop using Google Drive as image host'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className='field'>
|
||||||
|
Text:
|
||||||
|
<textarea className='fieldInput' type='text' ref={textRef} required
|
||||||
|
placeholder='Google Drive is not an image hosting site, you should not use it as such.'
|
||||||
|
>
|
||||||
|
</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className='field'>
|
||||||
|
Start Date:
|
||||||
|
<input type='date' className='fieldInput' ref={startAtRef} required/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className='field'>
|
||||||
|
End Date:
|
||||||
|
<input type='date' className='fieldInput' ref={stopAtRef} required/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className='notificationResult'>{notificationResult}</div>
|
||||||
|
|
||||||
|
<button className='notificationSave' onClick={saveNotification} disabled={searching}>
|
||||||
|
<i className={`fas ${searching ? 'fa-spin fa-spinner' : 'fa-save'}`}/>
|
||||||
|
Save Notification
|
||||||
|
</button>
|
||||||
|
{error && <div className='error'>{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = NotificationAdd;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.notificationAdd {
|
||||||
|
position : relative;
|
||||||
|
display : flex;
|
||||||
|
flex-direction : column;
|
||||||
|
width : 500px;
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display : grid;
|
||||||
|
grid-template-columns : 120px 150px;
|
||||||
|
align-items : center;
|
||||||
|
justify-items : stretch;
|
||||||
|
width : 100%;
|
||||||
|
margin-bottom : 20px;
|
||||||
|
|
||||||
|
|
||||||
|
input {
|
||||||
|
height : 33px;
|
||||||
|
padding : 0px 10px;
|
||||||
|
margin-bottom : unset;
|
||||||
|
font-family : monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width : 50ch;
|
||||||
|
min-height : 7em;
|
||||||
|
max-height : 20em;
|
||||||
|
resize : vertical;
|
||||||
|
padding : 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 200px;
|
||||||
|
|
||||||
|
i { margin-right : 10px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
require('./notificationLookup.less');
|
||||||
|
|
||||||
|
const React = require('react');
|
||||||
|
const { useState } = require('react');
|
||||||
|
const request = require('superagent');
|
||||||
|
const Moment = require('moment');
|
||||||
|
|
||||||
|
const NotificationDetail = ({ notification, onDelete })=>(
|
||||||
|
<>
|
||||||
|
<dl>
|
||||||
|
<dt>Key</dt>
|
||||||
|
<dd>{notification.dismissKey}</dd>
|
||||||
|
|
||||||
|
<dt>Title</dt>
|
||||||
|
<dd>{notification.title || 'No Title'}</dd>
|
||||||
|
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>{Moment(notification.createdAt).format('LLLL')}</dd>
|
||||||
|
|
||||||
|
<dt>Start</dt>
|
||||||
|
<dd>{Moment(notification.startAt).format('LLLL') || 'No Start Time'}</dd>
|
||||||
|
|
||||||
|
<dt>Stop</dt>
|
||||||
|
<dd>{Moment(notification.stopAt).format('LLLL') || 'No End Time'}</dd>
|
||||||
|
|
||||||
|
<dt>Text</dt>
|
||||||
|
<dd>{notification.text || 'No Text'}</dd>
|
||||||
|
</dl>
|
||||||
|
<button onClick={()=>onDelete(notification.dismissKey)}>DELETE</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const NotificationLookup = ()=>{
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
|
||||||
|
const lookupAll = async ()=>{
|
||||||
|
setSearching(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await request.get('/admin/notification/all');
|
||||||
|
setNotifications(res.body || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
setError(`Error looking up notifications: ${err.response.body.message}`);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNotification = async (dismissKey)=>{
|
||||||
|
if(!dismissKey) return;
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Really delete notification ${dismissKey}?`
|
||||||
|
);
|
||||||
|
if(!confirmed) {
|
||||||
|
console.log('Delete notification cancelled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Delete notification confirm');
|
||||||
|
try {
|
||||||
|
await request.delete(`/admin/notification/delete/${dismissKey}`);
|
||||||
|
lookupAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
setError(`Error deleting notification: ${err.response.body.message}`);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNotificationsList = ()=>{
|
||||||
|
if(error)
|
||||||
|
return <div className='error'>{error}</div>;
|
||||||
|
|
||||||
|
if(notifications.length === 0)
|
||||||
|
return <div className='noNotification'>No notifications available.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className='notificationList'>
|
||||||
|
{notifications.map((notification)=>(
|
||||||
|
<li key={notification.dismissKey} >
|
||||||
|
<details>
|
||||||
|
<summary>{notification.title || 'No Title'}</summary>
|
||||||
|
<NotificationDetail notification={notification} onDelete={deleteNotification} />
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='notificationLookup'>
|
||||||
|
<h2>Check all Notifications</h2>
|
||||||
|
<button onClick={lookupAll}>
|
||||||
|
<i className={`fas ${searching ? 'fa-spin fa-spinner' : 'fa-search'}`} />
|
||||||
|
</button>
|
||||||
|
{renderNotificationsList()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = NotificationLookup;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
.notificationLookup {
|
||||||
|
width : 450px;
|
||||||
|
height : fit-content;
|
||||||
|
|
||||||
|
.notificationList {
|
||||||
|
display : flex;
|
||||||
|
flex-direction : column;
|
||||||
|
max-height : 500px;
|
||||||
|
margin-block : 20px;
|
||||||
|
overflow : auto;
|
||||||
|
border : 1px solid;
|
||||||
|
border-radius : 5px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding : 10px;
|
||||||
|
background : #CCCCCC;
|
||||||
|
|
||||||
|
&:nth-child(even) { background : #DDDDDD; }
|
||||||
|
&:first-child {
|
||||||
|
border-top-left-radius : 5px;
|
||||||
|
border-top-right-radius : 5px;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
border-bottom-right-radius : 5px;
|
||||||
|
border-bottom-left-radius : 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
font-size : 20px;
|
||||||
|
font-weight : 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl dt{
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.noNotification { margin-block : 20px; }
|
||||||
|
}
|
||||||
15
client/admin/notificationUtils/notificationUtils.jsx
Normal file
15
client/admin/notificationUtils/notificationUtils.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const React = require('react');
|
||||||
|
|
||||||
|
const NotificationLookup = require('./notificationLookup/notificationLookup.jsx');
|
||||||
|
const NotificationAdd = require('./notificationAdd/notificationAdd.jsx');
|
||||||
|
|
||||||
|
const NotificationUtils = ()=>{
|
||||||
|
return (
|
||||||
|
<section className='notificationUtils'>
|
||||||
|
<NotificationAdd />
|
||||||
|
<NotificationLookup />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = NotificationUtils;
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
|
|
||||||
.Stats{
|
|
||||||
position : relative;
|
|
||||||
.pending{
|
|
||||||
position : absolute;
|
|
||||||
top : 0px;
|
|
||||||
left : 0px;
|
|
||||||
height : 100%;
|
|
||||||
width : 100%;
|
|
||||||
background-color : rgba(238,238,238, 0.5);
|
|
||||||
}
|
|
||||||
dl{
|
|
||||||
@maxItemWidth : 132px;
|
|
||||||
dt{
|
|
||||||
float : left;
|
|
||||||
clear : left;
|
|
||||||
width : @maxItemWidth;
|
|
||||||
text-align : right;
|
|
||||||
&::after {
|
|
||||||
content: " : ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dd{
|
|
||||||
margin : 0 0 0 @maxItemWidth + 10px;
|
|
||||||
padding : 0 0 0.5em 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
91
client/components/Anchored.jsx
Normal file
91
client/components/Anchored.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState, useRef, forwardRef, useEffect, cloneElement, Children } from 'react';
|
||||||
|
import './Anchored.less';
|
||||||
|
|
||||||
|
// Anchored is a wrapper component that must have as children an <AnchoredTrigger> and a <AnchoredBox> component.
|
||||||
|
// AnchoredTrigger must have a unique `id` prop, which is passed up to Anchored, saved in state on mount, and
|
||||||
|
// then passed down through props into AnchoredBox. The `id` is used for the CSS Anchor Positioning properties.
|
||||||
|
// **The Anchor Positioning API is not available in Firefox yet**
|
||||||
|
// So in Firefox the positioning isn't perfect but is likely sufficient, and FF team seems to be working on the API quickly.
|
||||||
|
|
||||||
|
|
||||||
|
const Anchored = ({ children })=>{
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [anchorId, setAnchorId] = useState(null);
|
||||||
|
const boxRef = useRef(null);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
|
||||||
|
// promote trigger id to Anchored id (to pass it back down to the box as "anchorId")
|
||||||
|
useEffect(()=>{
|
||||||
|
if(triggerRef.current){
|
||||||
|
setAnchorId(triggerRef.current.id);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// close box on outside click or Escape key
|
||||||
|
useEffect(()=>{
|
||||||
|
const handleClickOutside = (evt)=>{
|
||||||
|
if(
|
||||||
|
boxRef.current &&
|
||||||
|
!boxRef.current.contains(evt.target) &&
|
||||||
|
triggerRef.current &&
|
||||||
|
!triggerRef.current.contains(evt.target)
|
||||||
|
) {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscapeKey = (evt)=>{
|
||||||
|
if(evt.key === 'Escape') setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('click', handleClickOutside);
|
||||||
|
window.addEventListener('keydown', handleEscapeKey);
|
||||||
|
|
||||||
|
return ()=>{
|
||||||
|
window.removeEventListener('click', handleClickOutside);
|
||||||
|
window.removeEventListener('keydown', handleEscapeKey);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleVisibility = ()=>setVisible((prev)=>!prev);
|
||||||
|
|
||||||
|
// Map children to inject necessary props
|
||||||
|
const mappedChildren = Children.map(children, (child)=>{
|
||||||
|
if(child.type === AnchoredTrigger) {
|
||||||
|
return cloneElement(child, { ref: triggerRef, toggleVisibility, visible });
|
||||||
|
}
|
||||||
|
if(child.type === AnchoredBox) {
|
||||||
|
return cloneElement(child, { ref: boxRef, visible, anchorId });
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>{mappedChildren}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// forward ref for AnchoredTrigger
|
||||||
|
const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={`anchored-trigger${visible ? ' active' : ''} ${className}`}
|
||||||
|
onClick={toggleVisibility}
|
||||||
|
style={{ anchorName: `--${props.id}` }} // setting anchor properties here allows greater recyclability.
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
));
|
||||||
|
|
||||||
|
// forward ref for AnchoredBox
|
||||||
|
const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`anchored-box${visible ? ' active' : ''} ${className}`}
|
||||||
|
style={{ positionAnchor: `--${anchorId}` }} // setting anchor properties here allows greater recyclability.
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
export { Anchored, AnchoredTrigger, AnchoredBox };
|
||||||
13
client/components/Anchored.less
Normal file
13
client/components/Anchored.less
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
|
||||||
|
.anchored-box {
|
||||||
|
position:absolute;
|
||||||
|
@supports (inset-block-start: anchor(bottom)){
|
||||||
|
inset-block-start: anchor(bottom);
|
||||||
|
}
|
||||||
|
justify-self: anchor-center;
|
||||||
|
visibility: hidden;
|
||||||
|
&.active {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
// Dialog box, for popups and modal blocking messages
|
// Dialog box, for popups and modal blocking messages
|
||||||
const React = require('react');
|
import React from 'react';
|
||||||
const { useRef, useEffect } = React;
|
const { useRef, useEffect } = React;
|
||||||
|
|
||||||
function Dialog({ dismissKey, closeText = 'Close', blocking = false, ...rest }) {
|
function Dialog({ dismisskeys = [], closeText = 'Close', blocking = false, ...rest }) {
|
||||||
const dialogRef = useRef(null);
|
const dialogRef = useRef(null);
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
if(!dismissKey || !localStorage.getItem(dismissKey)) {
|
if(dismisskeys.length !== 0) {
|
||||||
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
|
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [dialogRef.current, dismisskeys]);
|
||||||
|
|
||||||
const dismiss = ()=>{
|
const dismiss = ()=>{
|
||||||
dismissKey && localStorage.setItem(dismissKey, true);
|
dismisskeys.forEach((key)=>{
|
||||||
|
if(key) {
|
||||||
|
localStorage.setItem(key, 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
dialogRef.current?.close();
|
dialogRef.current?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||||
require('./brewRenderer.less');
|
require('./brewRenderer.less');
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const { useState, useRef, useEffect } = React;
|
const { useState, useRef, useCallback, useMemo } = React;
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
|
|
||||||
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
|
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
|
||||||
@@ -16,8 +16,7 @@ const Frame = require('react-frame-component').default;
|
|||||||
const dedent = require('dedent-tabs').default;
|
const dedent = require('dedent-tabs').default;
|
||||||
const { printCurrentBrew } = require('../../../shared/helpers.js');
|
const { printCurrentBrew } = require('../../../shared/helpers.js');
|
||||||
|
|
||||||
const DOMPurify = require('dompurify');
|
import { safeHTML } from './safeHTML.js';
|
||||||
const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false };
|
|
||||||
|
|
||||||
const PAGE_HEIGHT = 1056;
|
const PAGE_HEIGHT = 1056;
|
||||||
|
|
||||||
@@ -29,6 +28,7 @@ const INITIAL_CONTENT = dedent`
|
|||||||
<base target=_blank>
|
<base target=_blank>
|
||||||
</head><body style='overflow: hidden'><div></div></body></html>`;
|
</head><body style='overflow: hidden'><div></div></body></html>`;
|
||||||
|
|
||||||
|
|
||||||
//v=====----------------------< Brew Page Component >---------------------=====v//
|
//v=====----------------------< Brew Page Component >---------------------=====v//
|
||||||
const BrewPage = (props)=>{
|
const BrewPage = (props)=>{
|
||||||
props = {
|
props = {
|
||||||
@@ -36,15 +36,15 @@ const BrewPage = (props)=>{
|
|||||||
index : 0,
|
index : 0,
|
||||||
...props
|
...props
|
||||||
};
|
};
|
||||||
const cleanText = props.contents; //DOMPurify.sanitize(props.contents, purifyConfig);
|
const cleanText = safeHTML(props.contents);
|
||||||
return <div className={props.className} id={`p${props.index + 1}`} >
|
return <div className={props.className} id={`p${props.index + 1}`} style={props.style}>
|
||||||
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
|
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
//v=====--------------------< Brew Renderer Component >-------------------=====v//
|
//v=====--------------------< Brew Renderer Component >-------------------=====v//
|
||||||
const renderedPages = [];
|
let renderedPages = [];
|
||||||
let rawPages = [];
|
let rawPages = [];
|
||||||
|
|
||||||
const BrewRenderer = (props)=>{
|
const BrewRenderer = (props)=>{
|
||||||
@@ -55,17 +55,24 @@ const BrewRenderer = (props)=>{
|
|||||||
theme : '5ePHB',
|
theme : '5ePHB',
|
||||||
lang : '',
|
lang : '',
|
||||||
errors : [],
|
errors : [],
|
||||||
currentEditorPage : 0,
|
currentEditorCursorPageNum : 1,
|
||||||
|
currentEditorViewPageNum : 1,
|
||||||
|
currentBrewRendererPageNum : 1,
|
||||||
themeBundle : {},
|
themeBundle : {},
|
||||||
|
onPageChange : ()=>{},
|
||||||
...props
|
...props
|
||||||
};
|
};
|
||||||
|
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
height : PAGE_HEIGHT,
|
|
||||||
isMounted : false,
|
isMounted : false,
|
||||||
visibility : 'hidden',
|
visibility : 'hidden'
|
||||||
zoom : 100,
|
});
|
||||||
currentPageNumber : 1,
|
|
||||||
|
const [displayOptions, setDisplayOptions] = useState({
|
||||||
|
zoomLevel : 100,
|
||||||
|
spread : 'single',
|
||||||
|
startOnRight : true,
|
||||||
|
pageShadows : true
|
||||||
});
|
});
|
||||||
|
|
||||||
const mainRef = useRef(null);
|
const mainRef = useRef(null);
|
||||||
@@ -76,36 +83,42 @@ const BrewRenderer = (props)=>{
|
|||||||
rawPages = props.text.split(/^\\page$/gm);
|
rawPages = props.text.split(/^\\page$/gm);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(()=>{ // Unmounting steps
|
const scrollToHash = (hash)=>{
|
||||||
return ()=>{window.removeEventListener('resize', updateSize);};
|
if(!hash) return;
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateSize = ()=>{
|
const iframeDoc = document.getElementById('BrewRenderer').contentDocument;
|
||||||
setState((prevState)=>({
|
let anchor = iframeDoc.querySelector(hash);
|
||||||
...prevState,
|
|
||||||
height : mainRef.current.parentNode.clientHeight,
|
if(anchor) {
|
||||||
}));
|
anchor.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
// Use MutationObserver to wait for the element if it's not immediately available
|
||||||
|
new MutationObserver((mutations, obs)=>{
|
||||||
|
anchor = iframeDoc.querySelector(hash);
|
||||||
|
if(anchor) {
|
||||||
|
anchor.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
obs.disconnect();
|
||||||
|
}
|
||||||
|
}).observe(iframeDoc, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentPage = (e)=>{
|
const updateCurrentPage = useCallback(_.throttle((e)=>{
|
||||||
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
||||||
const totalScrollableHeight = scrollHeight - clientHeight;
|
const totalScrollableHeight = scrollHeight - clientHeight;
|
||||||
const currentPageNumber = Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length);
|
const currentPageNumber = Math.max(Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length), 1);
|
||||||
|
|
||||||
setState((prevState)=>({
|
props.onPageChange(currentPageNumber);
|
||||||
...prevState,
|
}, 200), []);
|
||||||
currentPageNumber : currentPageNumber || 1
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isInView = (index)=>{
|
const isInView = (index)=>{
|
||||||
if(!state.isMounted)
|
if(!state.isMounted)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if(index == props.currentEditorPage) //Already rendered before this step
|
if(index == props.currentEditorCursorPageNum - 1) //Already rendered before this step
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if(Math.abs(index - state.currentPageNumber) <= 3)
|
if(Math.abs(index - props.currentBrewRendererPageNum - 1) <= 3)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -118,9 +131,9 @@ const BrewRenderer = (props)=>{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderStyle = ()=>{
|
const renderStyle = ()=>{
|
||||||
const cleanStyle = props.style; //DOMPurify.sanitize(props.style, purifyConfig);
|
|
||||||
const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>';
|
const themeStyles = props.themeBundle?.joinedStyles ?? '<style>@import url("/themes/V3/Blank/style.css");</style>';
|
||||||
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: `${themeStyles} \n\n <style> ${cleanStyle} </style>` }} />;
|
const cleanStyle = safeHTML(`${themeStyles} \n\n <style> ${props.style} </style>`);
|
||||||
|
return <div style={{ display: 'none' }} dangerouslySetInnerHTML={{ __html: cleanStyle }} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPage = (pageText, index)=>{
|
const renderPage = (pageText, index)=>{
|
||||||
@@ -130,7 +143,13 @@ const BrewRenderer = (props)=>{
|
|||||||
} else {
|
} else {
|
||||||
pageText += `\n\n \n\\column\n `; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
|
pageText += `\n\n \n\\column\n `; //Artificial column break at page end to emulate column-fill:auto (until `wide` is used, when column-fill:balance will reappear)
|
||||||
const html = Markdown.render(pageText, index);
|
const html = Markdown.render(pageText, index);
|
||||||
return <BrewPage className='page' index={index} key={index} contents={html} />;
|
|
||||||
|
const styles = {
|
||||||
|
...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {})
|
||||||
|
// Add more conditions as needed
|
||||||
|
};
|
||||||
|
|
||||||
|
return <BrewPage className='page' index={index} key={index} contents={html} style={styles} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,7 +161,7 @@ const BrewRenderer = (props)=>{
|
|||||||
renderedPages.length = 0;
|
renderedPages.length = 0;
|
||||||
|
|
||||||
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
|
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
|
||||||
renderedPages[props.currentEditorPage] = renderPage(rawPages[props.currentEditorPage], props.currentEditorPage);
|
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
|
||||||
|
|
||||||
_.forEach(rawPages, (page, index)=>{
|
_.forEach(rawPages, (page, index)=>{
|
||||||
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
|
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
|
||||||
@@ -163,9 +182,9 @@ const BrewRenderer = (props)=>{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
|
const frameDidMount = ()=>{ //This triggers when iFrame finishes internal "componentDidMount"
|
||||||
|
scrollToHash(window.location.hash);
|
||||||
|
|
||||||
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
|
setTimeout(()=>{ //We still see a flicker where the style isn't applied yet, so wait 100ms before showing iFrame
|
||||||
updateSize();
|
|
||||||
window.addEventListener('resize', updateSize);
|
|
||||||
renderPages(); //Make sure page is renderable before showing
|
renderPages(); //Make sure page is renderable before showing
|
||||||
setState((prevState)=>({
|
setState((prevState)=>({
|
||||||
...prevState,
|
...prevState,
|
||||||
@@ -180,19 +199,30 @@ const BrewRenderer = (props)=>{
|
|||||||
document.dispatchEvent(new MouseEvent('click'));
|
document.dispatchEvent(new MouseEvent('click'));
|
||||||
};
|
};
|
||||||
|
|
||||||
//Toolbar settings:
|
const handleDisplayOptionsChange = (newDisplayOptions)=>{
|
||||||
const handleZoom = (newZoom)=>{
|
setDisplayOptions(newDisplayOptions);
|
||||||
setState((prevState)=>({
|
|
||||||
...prevState,
|
|
||||||
zoom : newZoom
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pagesStyle = {
|
||||||
|
zoom : `${displayOptions.zoomLevel}%`,
|
||||||
|
columnGap : `${displayOptions.columnGap}px`,
|
||||||
|
rowGap : `${displayOptions.rowGap}px`
|
||||||
|
};
|
||||||
|
|
||||||
|
const styleObject = {};
|
||||||
|
|
||||||
|
if(global.config.deployment) {
|
||||||
|
styleObject.backgroundImage = `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' height='40px' width='200px'><text x='0' y='15' fill='%23fff7' font-size='20'>${global.config.deployment}</text></svg>")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderedStyle = useMemo(()=>renderStyle(), [props.style, props.themeBundle]);
|
||||||
|
renderedPages = useMemo(()=>renderPages(), [props.text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/*render dummy page while iFrame is mounting.*/}
|
{/*render dummy page while iFrame is mounting.*/}
|
||||||
{!state.isMounted
|
{!state.isMounted
|
||||||
? <div className='brewRenderer' onScroll={getCurrentPage}>
|
? <div className='brewRenderer' onScroll={updateCurrentPage}>
|
||||||
<div className='pages'>
|
<div className='pages'>
|
||||||
{renderDummyPage(1)}
|
{renderDummyPage(1)}
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +235,7 @@ const BrewRenderer = (props)=>{
|
|||||||
<NotificationPopup />
|
<NotificationPopup />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToolBar onZoomChange={handleZoom} currentPage={state.currentPageNumber} totalPages={rawPages.length}/>
|
<ToolBar displayOptions={displayOptions} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length} onDisplayOptionsChange={handleDisplayOptionsChange} />
|
||||||
|
|
||||||
{/*render in iFrame so broken code doesn't crash the site.*/}
|
{/*render in iFrame so broken code doesn't crash the site.*/}
|
||||||
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
|
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
|
||||||
@@ -213,19 +243,20 @@ const BrewRenderer = (props)=>{
|
|||||||
contentDidMount={frameDidMount}
|
contentDidMount={frameDidMount}
|
||||||
onClick={()=>{emitClick();}}
|
onClick={()=>{emitClick();}}
|
||||||
>
|
>
|
||||||
<div className={'brewRenderer'}
|
<div className={`brewRenderer ${global.config.deployment && 'deployment'}`}
|
||||||
onScroll={getCurrentPage}
|
onScroll={updateCurrentPage}
|
||||||
onKeyDown={handleControlKeys}
|
onKeyDown={handleControlKeys}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{ height: state.height }}>
|
style={ styleObject }>
|
||||||
|
|
||||||
{/* Apply CSS from Style tab and render pages from Markdown tab */}
|
{/* Apply CSS from Style tab and render pages from Markdown tab */}
|
||||||
{state.isMounted
|
{state.isMounted
|
||||||
&&
|
&&
|
||||||
<>
|
<>
|
||||||
{renderStyle()}
|
{renderedStyle}
|
||||||
<div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
|
<div lang={`${props.lang || 'en'}`} style={pagesStyle} className={
|
||||||
{renderPages()}
|
`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}` } >
|
||||||
|
{renderedPages}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,45 @@
|
|||||||
.brewRenderer {
|
.brewRenderer {
|
||||||
overflow-y : scroll;
|
overflow-y : scroll;
|
||||||
will-change : transform;
|
will-change : transform;
|
||||||
padding-top : 30px;
|
padding-top : 60px;
|
||||||
|
height : 100vh;
|
||||||
|
&:has(.facing, .flow) {
|
||||||
|
padding : 60px 30px;
|
||||||
|
}
|
||||||
|
&.deployment {
|
||||||
|
background-color: darkred;
|
||||||
|
}
|
||||||
:where(.pages) {
|
:where(.pages) {
|
||||||
margin : 30px 0px;
|
&.facing {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, auto);
|
||||||
|
grid-template-rows: repeat(3, auto);
|
||||||
|
gap: 10px 10px;
|
||||||
|
justify-content: center;
|
||||||
|
&.recto .page:first-child {
|
||||||
|
// sets first page on 'right' ('recto') of the preview, as if for a Cover page.
|
||||||
|
// todo: add a checkbox to toggle this setting
|
||||||
|
grid-column-start: 2;
|
||||||
|
}
|
||||||
|
& :where(.page) {
|
||||||
|
margin-left: unset !important;
|
||||||
|
margin-right: unset !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.flow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
& :where(.page) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: unset !important;
|
||||||
|
margin-right: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
& > :where(.page) {
|
& > :where(.page) {
|
||||||
width : 215.9mm;
|
width : 215.9mm;
|
||||||
height : 279.4mm;
|
height : 279.4mm;
|
||||||
@@ -14,6 +50,9 @@
|
|||||||
margin-left : auto;
|
margin-left : auto;
|
||||||
box-shadow : 1px 4px 14px #000000;
|
box-shadow : 1px 4px 14px #000000;
|
||||||
}
|
}
|
||||||
|
*[id] {
|
||||||
|
scroll-margin-top:100px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width : 20px;
|
width : 20px;
|
||||||
@@ -39,6 +78,7 @@
|
|||||||
overflow-y : unset;
|
overflow-y : unset;
|
||||||
.pages {
|
.pages {
|
||||||
margin : 0px;
|
margin : 0px;
|
||||||
|
zoom: 100% !important;
|
||||||
& > .page { box-shadow : unset; }
|
& > .page { box-shadow : unset; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,62 @@
|
|||||||
require('./notificationPopup.less');
|
require('./notificationPopup.less');
|
||||||
const React = require('react');
|
import React, { useEffect, useState } from 'react';
|
||||||
const _ = require('lodash');
|
const request = require('../../utils/request-middleware.js');
|
||||||
|
|
||||||
import Dialog from '../../../components/dialog.jsx';
|
import Dialog from '../../../components/dialog.jsx';
|
||||||
|
|
||||||
const DISMISS_KEY = 'dismiss_notification01-10-24';
|
|
||||||
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
|
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
|
||||||
|
|
||||||
const NotificationPopup = ()=>{
|
const NotificationPopup = ()=>{
|
||||||
return <Dialog className='notificationPopup' dismissKey={DISMISS_KEY} closeText={DISMISS_BUTTON} >
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
const [dissmissKeyList, setDismissKeyList] = useState([]);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
getNotifications();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getNotifications = async ()=>{
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await request.get('/admin/notification/all');
|
||||||
|
pickActiveNotifications(res.body || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
setError(`Error looking up notifications: ${err?.response?.body?.message || err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickActiveNotifications = (notifs)=>{
|
||||||
|
const now = new Date();
|
||||||
|
const filteredNotifications = notifs.filter((notification)=>{
|
||||||
|
const startDate = new Date(notification.startAt);
|
||||||
|
const stopDate = new Date(notification.stopAt);
|
||||||
|
const dismissed = localStorage.getItem(notification.dismissKey) ? true : false;
|
||||||
|
return now >= startDate && now <= stopDate && !dismissed;
|
||||||
|
});
|
||||||
|
setNotifications(filteredNotifications);
|
||||||
|
setDismissKeyList(filteredNotifications.map((notif)=>notif.dismissKey));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNotificationsList = ()=>{
|
||||||
|
if(error) return <div className='error'>{error}</div>;
|
||||||
|
|
||||||
|
return notifications.map((notification)=>(
|
||||||
|
<li key={notification.dismissKey} >
|
||||||
|
<em>{notification.title}</em><br />
|
||||||
|
<p dangerouslySetInnerHTML={{ __html: notification.text }}></p>
|
||||||
|
</li>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Dialog className='notificationPopup' dismisskeys={dissmissKeyList} closeText={DISMISS_BUTTON} >
|
||||||
<div className='header'>
|
<div className='header'>
|
||||||
<i className='fas fa-info-circle info'></i>
|
<i className='fas fa-info-circle info'></i>
|
||||||
<h3>Notice</h3>
|
<h3>Notice</h3>
|
||||||
<small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
|
<small>This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:</small>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li key='ThrottlingError' style={{
|
{renderNotificationsList()}
|
||||||
backgroundColor: '#910000',
|
|
||||||
margin: '-10px -10px -10px -20px',
|
|
||||||
padding: '10px 10px 10px 20px',
|
|
||||||
fontSize: '1.0em'
|
|
||||||
}}>
|
|
||||||
<em>Known issue with saving/creating Google Drive files</em><br />
|
|
||||||
Dear users. The <a href="https://github.com/naturalcrit/homebrewery/issues/3770">
|
|
||||||
issue with saving to Google Drive</a> has resurfaced as of Oct 1, 2024 22:00 UTC.
|
|
||||||
<br></br><br></br>
|
|
||||||
Earlier we submitted a bug report to Google and have all but confirmed the issue
|
|
||||||
lies on Google's end and the disruption has been affecting multiple other
|
|
||||||
organizations besides us. Unfortunately, it means reliable interaction with
|
|
||||||
Google remains out of our control until they can resolve their issue.
|
|
||||||
<br></br><br></br>
|
|
||||||
Brews saved to Google Drive are <em>not lost</em> and can still be viewed, just not updated.
|
|
||||||
You can also access them via your Google Drive interface in the <code>/Hombrewery</code> folder.
|
|
||||||
<br></br><br></br>
|
|
||||||
If you need to urgently edit documents, you can detatch them from your Google Drive
|
|
||||||
by transferring them to our Homebrewery storage. To do this, click the colored Google Drive
|
|
||||||
icon next to the save button when on an edit page; you can transfer them back later,
|
|
||||||
but this should allow you to edit while this issue is ongoing.
|
|
||||||
<br></br><br></br>
|
|
||||||
If you are experiencing errors creating new documents, you can similarly change your
|
|
||||||
account settings to create new brews by default in the Homebrewery storage. Click
|
|
||||||
your username and then "account", then change the "default save location".
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li key='Vault'>
|
|
||||||
<em>Search brews with our new page!</em><br />
|
|
||||||
We have been working very hard in making this possible, now you can share your work and look at it in the new <a href="/vault">Vault</a> page!
|
|
||||||
All PUBLISHED brews will be available to anyone searching there, by title or author, and filtering by renderer.
|
|
||||||
|
|
||||||
More features will be coming.
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li key='googleDriveFolder'>
|
|
||||||
<em>Don't delete your Homebrewery folder on Google Drive!</em> <br />
|
|
||||||
We have had several reports of users losing their brews, not realizing
|
|
||||||
that they had deleted the files on their Google Drive. If you have a Homebrewery folder
|
|
||||||
on your Google Drive with *.txt files inside, <em>do not delete it</em>!
|
|
||||||
We cannot help you recover files that you have deleted from your own
|
|
||||||
Google Drive.
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li key='faq'>
|
|
||||||
<em>Protect your work! </em> <br />
|
|
||||||
If you opt not to use your Google Drive, keep in mind that we do not save a history of your projects. Please make frequent backups of your brews!
|
|
||||||
<a target='_blank' href='https://www.reddit.com/r/homebrewery/comments/adh6lh/faqs_psas_announcements/'>
|
|
||||||
See the FAQ
|
|
||||||
</a> to learn how to avoid losing your work!
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,7 +55,10 @@
|
|||||||
margin-top : 1.4em;
|
margin-top : 1.4em;
|
||||||
font-size : 0.8em;
|
font-size : 0.8em;
|
||||||
line-height : 1.4em;
|
line-height : 1.4em;
|
||||||
em { font-weight : 800; }
|
em {
|
||||||
|
text-transform:capitalize;
|
||||||
|
font-weight : 800;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
client/homebrew/brewRenderer/safeHTML.js
Normal file
46
client/homebrew/brewRenderer/safeHTML.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Derived from the vue-html-secure package, customized for Homebrewery
|
||||||
|
|
||||||
|
let doc = null;
|
||||||
|
let div = null;
|
||||||
|
|
||||||
|
function safeHTML(htmlString) {
|
||||||
|
// If the Document interface doesn't exist, exit
|
||||||
|
if(typeof document == 'undefined') return null;
|
||||||
|
// If the test document and div don't exist, create them
|
||||||
|
if(!doc) doc = document.implementation.createHTMLDocument('');
|
||||||
|
if(!div) div = doc.createElement('div');
|
||||||
|
|
||||||
|
// Set the test div contents to the evaluation string
|
||||||
|
div.innerHTML = htmlString;
|
||||||
|
// Grab all nodes from the test div
|
||||||
|
const elements = div.querySelectorAll('*');
|
||||||
|
|
||||||
|
// Blacklisted tags
|
||||||
|
const blacklistTags = ['script', 'noscript', 'noembed'];
|
||||||
|
// Tests to remove attributes
|
||||||
|
const blacklistAttrs = [
|
||||||
|
(test)=>{return test.localName.indexOf('on') == 0;},
|
||||||
|
(test)=>{return test.localName.indexOf('type') == 0 && test.value.match(/submit/i);},
|
||||||
|
(test)=>{return test.value.replace(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g, '').toLowerCase().trim().indexOf('javascript:') == 0;}
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
elements.forEach((element)=>{
|
||||||
|
// Check each element for blacklisted type
|
||||||
|
if(blacklistTags.includes(element?.localName?.toLowerCase())) {
|
||||||
|
element.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check remaining elements for blacklisted attributes
|
||||||
|
for (const attribute of element.attributes){
|
||||||
|
if(blacklistAttrs.some((test)=>{return test(attribute);})) {
|
||||||
|
element.removeAttribute(attribute.localName);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return div.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.safeHTML = safeHTML;
|
||||||
@@ -3,25 +3,28 @@ const React = require('react');
|
|||||||
const { useState, useEffect } = React;
|
const { useState, useEffect } = React;
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
|
|
||||||
|
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
|
||||||
|
// import * as ZoomIcons from '../../../icons/icon-components/zoomIcons.jsx';
|
||||||
|
|
||||||
const MAX_ZOOM = 300;
|
const MAX_ZOOM = 300;
|
||||||
const MIN_ZOOM = 10;
|
const MIN_ZOOM = 10;
|
||||||
|
|
||||||
const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
const ToolBar = ({ displayOptions, currentPage, totalPages, onDisplayOptionsChange })=>{
|
||||||
|
|
||||||
const [zoomLevel, setZoomLevel] = useState(100);
|
|
||||||
const [pageNum, setPageNum] = useState(currentPage);
|
const [pageNum, setPageNum] = useState(currentPage);
|
||||||
|
const [toolsVisible, setToolsVisible] = useState(true);
|
||||||
useEffect(()=>{
|
|
||||||
onZoomChange(zoomLevel);
|
|
||||||
}, [zoomLevel]);
|
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
setPageNum(currentPage);
|
setPageNum(currentPage);
|
||||||
}, [currentPage]);
|
}, [currentPage]);
|
||||||
|
|
||||||
const handleZoomButton = (zoom)=>{
|
const handleZoomButton = (zoom)=>{
|
||||||
setZoomLevel(_.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
|
handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOptionChange = (optionKey, newValue)=>{
|
||||||
|
//setDisplayOptions(prevOptions => ({ ...prevOptions, [optionKey]: newValue }));
|
||||||
|
onDisplayOptionsChange({ ...displayOptions, [optionKey]: newValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageInput = (pageInput)=>{
|
const handlePageInput = (pageInput)=>{
|
||||||
@@ -55,53 +58,58 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
|||||||
|
|
||||||
} else if(mode == 'fit'){
|
} else if(mode == 'fit'){
|
||||||
// find the page with the largest single dim (height or width) so that zoom can be adapted to fit it.
|
// find the page with the largest single dim (height or width) so that zoom can be adapted to fit it.
|
||||||
const minDimRatio = [...pages].reduce((minRatio, page) => Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity);
|
const minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity);
|
||||||
|
|
||||||
desiredZoom = minDimRatio * 100;
|
desiredZoom = minDimRatio * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const margin = 5; // extra space so page isn't edge to edge (not truly "to fill")
|
const margin = 5; // extra space so page isn't edge to edge (not truly "to fill")
|
||||||
|
|
||||||
const deltaZoom = (desiredZoom - zoomLevel) - margin;
|
const deltaZoom = (desiredZoom - displayOptions.zoomLevel) - margin;
|
||||||
return deltaZoom;
|
return deltaZoom;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='toolBar'>
|
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
|
||||||
|
<button className='toggleButton' title={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{setToolsVisible(!toolsVisible);}}><i className='fas fa-glasses' /></button>
|
||||||
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
|
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
|
||||||
<div className='group'>
|
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
|
||||||
<button
|
<button
|
||||||
id='fill-width'
|
id='fill-width'
|
||||||
className='tool'
|
className='tool'
|
||||||
onClick={()=>handleZoomButton(zoomLevel + calculateChange('fill'))}
|
title='Set zoom to fill preview with one page'
|
||||||
|
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
|
||||||
>
|
>
|
||||||
<i className='fac fit-width' />
|
<i className='fac fit-width' />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id='zoom-to-fit'
|
id='zoom-to-fit'
|
||||||
className='tool'
|
className='tool'
|
||||||
onClick={()=>handleZoomButton(zoomLevel + calculateChange('fit'))}
|
title='Set zoom to fit entire page in preview'
|
||||||
|
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
|
||||||
>
|
>
|
||||||
<i className='fac zoom-to-fit' />
|
<i className='fac zoom-to-fit' />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id='zoom-out'
|
id='zoom-out'
|
||||||
className='tool'
|
className='tool'
|
||||||
onClick={()=>handleZoomButton(zoomLevel - 20)}
|
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
|
||||||
disabled={zoomLevel <= MIN_ZOOM}
|
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
|
||||||
|
title='Zoom Out'
|
||||||
>
|
>
|
||||||
<i className='fas fa-magnifying-glass-minus' />
|
<i className='fas fa-magnifying-glass-minus' />
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
id='zoom-slider'
|
id='zoom-slider'
|
||||||
className='range-input tool'
|
className='range-input tool hover-tooltip'
|
||||||
type='range'
|
type='range'
|
||||||
name='zoom'
|
name='zoom'
|
||||||
|
title='Set Zoom'
|
||||||
list='zoomLevels'
|
list='zoomLevels'
|
||||||
min={MIN_ZOOM}
|
min={MIN_ZOOM}
|
||||||
max={MAX_ZOOM}
|
max={MAX_ZOOM}
|
||||||
step='1'
|
step='1'
|
||||||
value={zoomLevel}
|
value={displayOptions.zoomLevel}
|
||||||
onChange={(e)=>handleZoomButton(parseInt(e.target.value))}
|
onChange={(e)=>handleZoomButton(parseInt(e.target.value))}
|
||||||
/>
|
/>
|
||||||
<datalist id='zoomLevels'>
|
<datalist id='zoomLevels'>
|
||||||
@@ -111,18 +119,72 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
|||||||
<button
|
<button
|
||||||
id='zoom-in'
|
id='zoom-in'
|
||||||
className='tool'
|
className='tool'
|
||||||
onClick={()=>handleZoomButton(zoomLevel + 20)}
|
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
|
||||||
disabled={zoomLevel >= MAX_ZOOM}
|
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
|
||||||
|
title='Zoom In'
|
||||||
>
|
>
|
||||||
<i className='fas fa-magnifying-glass-plus' />
|
<i className='fas fa-magnifying-glass-plus' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/*v=====----------------------< Spread Controls >---------------------=====v*/}
|
||||||
|
<div className='group' role='group' aria-label='Spread' aria-hidden={!toolsVisible}>
|
||||||
|
<div className='radio-group' role='radiogroup'>
|
||||||
|
<button role='radio'
|
||||||
|
id='single-spread'
|
||||||
|
className='tool'
|
||||||
|
title='Single Page'
|
||||||
|
onClick={()=>{handleOptionChange('spread', 'active');}}
|
||||||
|
aria-checked={displayOptions.spread === 'single'}
|
||||||
|
><i className='fac single-spread' /></button>
|
||||||
|
<button role='radio'
|
||||||
|
id='facing-spread'
|
||||||
|
className='tool'
|
||||||
|
title='Facing Pages'
|
||||||
|
onClick={()=>{handleOptionChange('spread', 'facing');}}
|
||||||
|
aria-checked={displayOptions.spread === 'facing'}
|
||||||
|
><i className='fac facing-spread' /></button>
|
||||||
|
<button role='radio'
|
||||||
|
id='flow-spread'
|
||||||
|
className='tool'
|
||||||
|
title='Flow Pages'
|
||||||
|
onClick={()=>{handleOptionChange('spread', 'flow');}}
|
||||||
|
aria-checked={displayOptions.spread === 'flow'}
|
||||||
|
><i className='fac flow-spread' /></button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<Anchored>
|
||||||
|
<AnchoredTrigger id='spread-settings' className='tool' title='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
|
||||||
|
<AnchoredBox title='Options'>
|
||||||
|
<h1>Options</h1>
|
||||||
|
<label title='Modify the horizontal space between pages.'>
|
||||||
|
Column gap
|
||||||
|
<input type='range' min={0} max={200} defaultValue={10} className='range-input' onChange={(evt)=>handleOptionChange('columnGap', evt.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label title='Modify the vertical space between rows of pages.'>
|
||||||
|
Row gap
|
||||||
|
<input type='range' min={0} max={200} defaultValue={10} className='range-input' onChange={(evt)=>handleOptionChange('rowGap', evt.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label title='Start 1st page on the right side, such as if you have cover page.'>
|
||||||
|
Start on right
|
||||||
|
<input type='checkbox' checked={displayOptions.startOnRight} onChange={()=>{handleOptionChange('startOnRight', !displayOptions.startOnRight);}}
|
||||||
|
title={displayOptions.spread !== 'facing' ? 'Switch to Facing to enable toggle.' : null} />
|
||||||
|
</label>
|
||||||
|
<label title='Toggle the page shadow on every page.'>
|
||||||
|
Page shadows
|
||||||
|
<input type='checkbox' checked={displayOptions.pageShadows} onChange={()=>{handleOptionChange('pageShadows', !displayOptions.pageShadows);}} />
|
||||||
|
</label>
|
||||||
|
</AnchoredBox>
|
||||||
|
</Anchored>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/*v=====----------------------< Page Controls >---------------------=====v*/}
|
{/*v=====----------------------< Page Controls >---------------------=====v*/}
|
||||||
<div className='group'>
|
<div className='group' role='group' aria-label='Pages' aria-hidden={!toolsVisible}>
|
||||||
<button
|
<button
|
||||||
id='previous-page'
|
id='previous-page'
|
||||||
className='previousPage tool'
|
className='previousPage tool'
|
||||||
|
type='button'
|
||||||
|
title='Previous Page(s)'
|
||||||
onClick={()=>scrollToPage(pageNum - 1)}
|
onClick={()=>scrollToPage(pageNum - 1)}
|
||||||
disabled={pageNum <= 1}
|
disabled={pageNum <= 1}
|
||||||
>
|
>
|
||||||
@@ -135,6 +197,7 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
|||||||
className='text-input'
|
className='text-input'
|
||||||
type='text'
|
type='text'
|
||||||
name='page'
|
name='page'
|
||||||
|
title='Current page(s) in view'
|
||||||
inputMode='numeric'
|
inputMode='numeric'
|
||||||
pattern='[0-9]'
|
pattern='[0-9]'
|
||||||
value={pageNum}
|
value={pageNum}
|
||||||
@@ -143,12 +206,14 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
|||||||
onBlur={()=>scrollToPage(pageNum)}
|
onBlur={()=>scrollToPage(pageNum)}
|
||||||
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
|
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
|
||||||
/>
|
/>
|
||||||
<span id='page-count'>/ {totalPages}</span>
|
<span id='page-count' title='Total Page Count'>/ {totalPages}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id='next-page'
|
id='next-page'
|
||||||
className='tool'
|
className='tool'
|
||||||
|
type='button'
|
||||||
|
title='Next Page(s)'
|
||||||
onClick={()=>scrollToPage(pageNum + 1)}
|
onClick={()=>scrollToPage(pageNum + 1)}
|
||||||
disabled={pageNum >= totalPages}
|
disabled={pageNum >= totalPages}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
font-family : 'Open Sans', sans-serif;
|
font-family : 'Open Sans', sans-serif;
|
||||||
color : #CCCCCC;
|
color : #CCCCCC;
|
||||||
background-color : #555555;
|
background-color : #555555;
|
||||||
|
& > *:not(.toggleButton) {
|
||||||
|
opacity : 1;
|
||||||
|
transition : all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
box-sizing : border-box;
|
box-sizing : border-box;
|
||||||
@@ -30,6 +34,70 @@
|
|||||||
align-items : center;
|
align-items : center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active, [aria-checked='true'] { background-color : #444444; }
|
||||||
|
|
||||||
|
.anchored-trigger {
|
||||||
|
&.active { background-color : #444444; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.anchored-box {
|
||||||
|
--box-color : #555555;
|
||||||
|
top : 30px;
|
||||||
|
display : flex;
|
||||||
|
flex-direction : column;
|
||||||
|
gap : 5px;
|
||||||
|
padding : 15px;
|
||||||
|
margin-top : 10px;
|
||||||
|
font-size : 0.8em;
|
||||||
|
color : #CCCCCC;
|
||||||
|
background-color : var(--box-color);
|
||||||
|
border-radius : 5px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding-bottom : 0.3em;
|
||||||
|
margin-bottom : 0.5em;
|
||||||
|
border-bottom : 1px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
padding-bottom : 0.3em;
|
||||||
|
margin : 1em 0 0.5em 0;
|
||||||
|
color : lightgray;
|
||||||
|
border-bottom : 1px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display : flex;
|
||||||
|
gap : 6px;
|
||||||
|
align-items : center;
|
||||||
|
justify-content : space-between;
|
||||||
|
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
height : unset;
|
||||||
|
&[type='range'] { padding : 0; }
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
position : absolute;
|
||||||
|
top : -20px;
|
||||||
|
left : 50%;
|
||||||
|
width : 0px;
|
||||||
|
height : 0px;
|
||||||
|
pointer-events : none;
|
||||||
|
content : '';
|
||||||
|
border : 10px solid transparent;
|
||||||
|
border-bottom : 10px solid var(--box-color);
|
||||||
|
transform : translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group:has(button[role='radio']) {
|
||||||
|
display : flex;
|
||||||
|
height : 100%;
|
||||||
|
border : 1px solid #333333;
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
position : relative;
|
position : relative;
|
||||||
height : 1.5em;
|
height : 1.5em;
|
||||||
@@ -53,7 +121,7 @@
|
|||||||
outline : none;
|
outline : none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover::after {
|
&.hover-tooltip[value]:hover::after {
|
||||||
position : absolute;
|
position : absolute;
|
||||||
bottom : -30px;
|
bottom : -30px;
|
||||||
left : 50%;
|
left : 50%;
|
||||||
@@ -79,7 +147,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
box-sizing : content-box;
|
box-sizing : border-box;
|
||||||
display : flex;
|
display : flex;
|
||||||
align-items : center;
|
align-items : center;
|
||||||
justify-content : center;
|
justify-content : center;
|
||||||
@@ -90,14 +158,36 @@
|
|||||||
font-weight : unset;
|
font-weight : unset;
|
||||||
color : inherit;
|
color : inherit;
|
||||||
background-color : unset;
|
background-color : unset;
|
||||||
|
|
||||||
|
&:not(button:has(i, svg)) { padding : 0 8px; }
|
||||||
|
|
||||||
&:hover { background-color : #444444; }
|
&:hover { background-color : #444444; }
|
||||||
&:focus { outline : 1px solid #D3D3D3; }
|
&:focus { border : 1px solid #D3D3D3;outline : none;}
|
||||||
&:disabled {
|
&:disabled {
|
||||||
color : #777777;
|
color : #777777;
|
||||||
background-color : unset !important;
|
background-color : unset !important;
|
||||||
}
|
}
|
||||||
i {
|
i { font-size : 1.2em; }
|
||||||
font-size:1.2em;
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
flex-wrap : nowrap;
|
||||||
|
width : 32px;
|
||||||
|
overflow : hidden;
|
||||||
|
background-color : unset;
|
||||||
|
opacity : 0.5;
|
||||||
|
transition : all 0.3s ease;
|
||||||
|
& > *:not(.toggleButton) {
|
||||||
|
opacity : 0;
|
||||||
|
transition : all 0.2s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.toggleButton {
|
||||||
|
position : absolute;
|
||||||
|
left : 0;
|
||||||
|
z-index : 5;
|
||||||
|
width : 32px;
|
||||||
|
min-width : unset;
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
|
||||||
require('./editor.less');
|
require('./editor.less');
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const createClass = require('create-react-class');
|
const createClass = require('create-react-class');
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
const cx = require('classnames');
|
|
||||||
const dedent = require('dedent-tabs').default;
|
const dedent = require('dedent-tabs').default;
|
||||||
const Markdown = require('../../../shared/naturalcrit/markdown.js');
|
const Markdown = require('../../../shared/naturalcrit/markdown.js');
|
||||||
|
|
||||||
@@ -22,6 +21,7 @@ const DEFAULT_STYLE_TEXT = dedent`
|
|||||||
color: black;
|
color: black;
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
let isJumping = false;
|
||||||
|
|
||||||
const Editor = createClass({
|
const Editor = createClass({
|
||||||
displayName : 'Editor',
|
displayName : 'Editor',
|
||||||
@@ -37,8 +37,15 @@ const Editor = createClass({
|
|||||||
onMetaChange : ()=>{},
|
onMetaChange : ()=>{},
|
||||||
reportError : ()=>{},
|
reportError : ()=>{},
|
||||||
|
|
||||||
|
onCursorPageChange : ()=>{},
|
||||||
|
onViewPageChange : ()=>{},
|
||||||
|
|
||||||
editorTheme : 'default',
|
editorTheme : 'default',
|
||||||
renderer : 'legacy'
|
renderer : 'legacy',
|
||||||
|
|
||||||
|
currentEditorCursorPageNum : 1,
|
||||||
|
currentEditorViewPageNum : 1,
|
||||||
|
currentBrewRendererPageNum : 1,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getInitialState : function() {
|
getInitialState : function() {
|
||||||
@@ -56,12 +63,16 @@ const Editor = createClass({
|
|||||||
isMeta : function() {return this.state.view == 'meta';},
|
isMeta : function() {return this.state.view == 'meta';},
|
||||||
|
|
||||||
componentDidMount : function() {
|
componentDidMount : function() {
|
||||||
|
|
||||||
this.updateEditorSize();
|
this.updateEditorSize();
|
||||||
this.highlightCustomMarkdown();
|
this.highlightCustomMarkdown();
|
||||||
window.addEventListener('resize', this.updateEditorSize);
|
window.addEventListener('resize', this.updateEditorSize);
|
||||||
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys);
|
document.getElementById('BrewRenderer').addEventListener('keydown', this.handleControlKeys);
|
||||||
document.addEventListener('keydown', this.handleControlKeys);
|
document.addEventListener('keydown', this.handleControlKeys);
|
||||||
|
|
||||||
|
this.codeEditor.current.codeMirror.on('cursorActivity', (cm)=>{this.updateCurrentCursorPage(cm.getCursor());});
|
||||||
|
this.codeEditor.current.codeMirror.on('scroll', _.throttle(()=>{this.updateCurrentViewPage(this.codeEditor.current.getTopVisibleLine());}, 200));
|
||||||
|
|
||||||
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
|
const editorTheme = window.localStorage.getItem(EDITOR_THEME_KEY);
|
||||||
if(editorTheme) {
|
if(editorTheme) {
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -75,28 +86,37 @@ const Editor = createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
componentDidUpdate : function(prevProps, prevState, snapshot) {
|
componentDidUpdate : function(prevProps, prevState, snapshot) {
|
||||||
|
|
||||||
this.highlightCustomMarkdown();
|
this.highlightCustomMarkdown();
|
||||||
if(prevProps.moveBrew !== this.props.moveBrew) {
|
if(prevProps.moveBrew !== this.props.moveBrew)
|
||||||
this.brewJump();
|
this.brewJump();
|
||||||
};
|
|
||||||
if(prevProps.moveSource !== this.props.moveSource) {
|
if(prevProps.moveSource !== this.props.moveSource)
|
||||||
this.sourceJump();
|
this.sourceJump();
|
||||||
};
|
|
||||||
|
if(this.props.liveScroll) {
|
||||||
|
if(prevProps.currentBrewRendererPageNum !== this.props.currentBrewRendererPageNum) {
|
||||||
|
this.sourceJump(this.props.currentBrewRendererPageNum, false);
|
||||||
|
} else if(prevProps.currentEditorViewPageNum !== this.props.currentEditorViewPageNum) {
|
||||||
|
this.brewJump(this.props.currentEditorViewPageNum, false);
|
||||||
|
} else if(prevProps.currentEditorCursorPageNum !== this.props.currentEditorCursorPageNum) {
|
||||||
|
this.brewJump(this.props.currentEditorCursorPageNum, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleControlKeys : function(e){
|
handleControlKeys : function(e){
|
||||||
if(!(e.ctrlKey && e.metaKey)) return;
|
if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return;
|
||||||
const LEFTARROW_KEY = 37;
|
const LEFTARROW_KEY = 37;
|
||||||
const RIGHTARROW_KEY = 39;
|
const RIGHTARROW_KEY = 39;
|
||||||
if (e.shiftKey && (e.keyCode == RIGHTARROW_KEY)) this.brewJump();
|
if(e.keyCode == RIGHTARROW_KEY) this.brewJump();
|
||||||
if (e.shiftKey && (e.keyCode == LEFTARROW_KEY)) this.sourceJump();
|
if(e.keyCode == LEFTARROW_KEY) this.sourceJump();
|
||||||
if ((e.keyCode == LEFTARROW_KEY) || (e.keyCode == RIGHTARROW_KEY)) {
|
if(e.keyCode == LEFTARROW_KEY || e.keyCode == RIGHTARROW_KEY) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
updateEditorSize : function() {
|
updateEditorSize : function() {
|
||||||
if(this.codeEditor.current) {
|
if(this.codeEditor.current) {
|
||||||
let paneHeight = this.editor.current.parentNode.clientHeight;
|
let paneHeight = this.editor.current.parentNode.clientHeight;
|
||||||
@@ -105,6 +125,20 @@ const Editor = createClass({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateCurrentCursorPage : function(cursor) {
|
||||||
|
const lines = this.props.brew.text.split('\n').slice(0, cursor.line + 1);
|
||||||
|
const pageRegex = this.props.brew.renderer == 'V3' ? /^\\page$/ : /\\page/;
|
||||||
|
const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1);
|
||||||
|
this.props.onCursorPageChange(currentPage);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCurrentViewPage : function(topScrollLine) {
|
||||||
|
const lines = this.props.brew.text.split('\n').slice(0, topScrollLine + 1);
|
||||||
|
const pageRegex = this.props.brew.renderer == 'V3' ? /^\\page$/ : /\\page/;
|
||||||
|
const currentPage = lines.reduce((count, line)=>count + (pageRegex.test(line) ? 1 : 0), 1);
|
||||||
|
this.props.onViewPageChange(currentPage);
|
||||||
|
},
|
||||||
|
|
||||||
handleInject : function(injectText){
|
handleInject : function(injectText){
|
||||||
this.codeEditor.current?.injectText(injectText, false);
|
this.codeEditor.current?.injectText(injectText, false);
|
||||||
},
|
},
|
||||||
@@ -119,18 +153,6 @@ const Editor = createClass({
|
|||||||
}); //TODO: not sure if updateeditorsize needed
|
}); //TODO: not sure if updateeditorsize needed
|
||||||
},
|
},
|
||||||
|
|
||||||
getCurrentPage : function(){
|
|
||||||
const lines = this.props.brew.text.split('\n').slice(0, this.codeEditor.current.getCursorPosition().line + 1);
|
|
||||||
return _.reduce(lines, (r, line)=>{
|
|
||||||
if(
|
|
||||||
(this.props.renderer == 'legacy' && line.indexOf('\\page') !== -1)
|
|
||||||
||
|
|
||||||
(this.props.renderer == 'V3' && line.match(/^\\page$/))
|
|
||||||
) r++;
|
|
||||||
return r;
|
|
||||||
}, 1);
|
|
||||||
},
|
|
||||||
|
|
||||||
highlightCustomMarkdown : function(){
|
highlightCustomMarkdown : function(){
|
||||||
if(!this.codeEditor.current) return;
|
if(!this.codeEditor.current) return;
|
||||||
if(this.state.view === 'text') {
|
if(this.state.view === 'text') {
|
||||||
@@ -145,7 +167,7 @@ const Editor = createClass({
|
|||||||
// Record details of folded sections
|
// Record details of folded sections
|
||||||
if(mark.__isFold) {
|
if(mark.__isFold) {
|
||||||
const fold = mark.find();
|
const fold = mark.find();
|
||||||
foldLines.push({from: fold.from?.line, to: fold.to?.line});
|
foldLines.push({ from: fold.from?.line, to: fold.to?.line });
|
||||||
}
|
}
|
||||||
return !mark.__isFold;
|
return !mark.__isFold;
|
||||||
}); //Don't undo code folding
|
}); //Don't undo code folding
|
||||||
@@ -163,7 +185,7 @@ const Editor = createClass({
|
|||||||
|
|
||||||
// Don't process lines inside folded text
|
// Don't process lines inside folded text
|
||||||
// If the current lineNumber is inside any folded marks, skip line styling
|
// If the current lineNumber is inside any folded marks, skip line styling
|
||||||
if (foldLines.some(fold => lineNumber >= fold.from && lineNumber <= fold.to))
|
if(foldLines.some((fold)=>lineNumber >= fold.from && lineNumber <= fold.to))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Styling for \page breaks
|
// Styling for \page breaks
|
||||||
@@ -189,7 +211,7 @@ const Editor = createClass({
|
|||||||
|
|
||||||
// definition lists
|
// definition lists
|
||||||
if(line.includes('::')){
|
if(line.includes('::')){
|
||||||
if(/^:*$/.test(line) == true){ return };
|
if(/^:*$/.test(line) == true){ return; };
|
||||||
const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
|
const regex = /^([^\n]*?:?\s?)(::[^\n]*)(?:\n|$)/ymd; // the `d` flag, for match indices, throws an ESLint error.
|
||||||
let match;
|
let match;
|
||||||
while ((match = regex.exec(line)) != null){
|
while ((match = regex.exec(line)) != null){
|
||||||
@@ -197,10 +219,10 @@ const Editor = createClass({
|
|||||||
codeMirror.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
|
codeMirror.markText({ line: lineNumber, ch: match.indices[1][0] }, { line: lineNumber, ch: match.indices[1][1] }, { className: 'dt-highlight' });
|
||||||
codeMirror.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
|
codeMirror.markText({ line: lineNumber, ch: match.indices[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
|
||||||
const ddIndex = match.indices[2][0];
|
const ddIndex = match.indices[2][0];
|
||||||
let colons = /::/g;
|
const colons = /::/g;
|
||||||
let colonMatches = colons.exec(match[2]);
|
const colonMatches = colons.exec(match[2]);
|
||||||
if(colonMatches !== null){
|
if(colonMatches !== null){
|
||||||
codeMirror.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight'} )
|
codeMirror.markText({ line: lineNumber, ch: colonMatches.index + ddIndex }, { line: lineNumber, ch: colonMatches.index + colonMatches[0].length + ddIndex }, { className: 'dl-colon-highlight' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,8 +236,8 @@ const Editor = createClass({
|
|||||||
while (startIndex >= 0) {
|
while (startIndex >= 0) {
|
||||||
superRegex.lastIndex = subRegex.lastIndex = startIndex;
|
superRegex.lastIndex = subRegex.lastIndex = startIndex;
|
||||||
let isSuper = false;
|
let isSuper = false;
|
||||||
let match = subRegex.exec(line) || superRegex.exec(line);
|
const match = subRegex.exec(line) || superRegex.exec(line);
|
||||||
if (match) {
|
if(match) {
|
||||||
isSuper = !subRegex.lastIndex;
|
isSuper = !subRegex.lastIndex;
|
||||||
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
|
codeMirror.markText({ line: lineNumber, ch: match.index }, { line: lineNumber, ch: match.index + match[0].length }, { className: isSuper ? 'superscript' : 'subscript' });
|
||||||
}
|
}
|
||||||
@@ -265,18 +287,18 @@ const Editor = createClass({
|
|||||||
|
|
||||||
while (startIndex >= 0) {
|
while (startIndex >= 0) {
|
||||||
emojiRegex.lastIndex = startIndex;
|
emojiRegex.lastIndex = startIndex;
|
||||||
let match = emojiRegex.exec(line);
|
const match = emojiRegex.exec(line);
|
||||||
if (match) {
|
if(match) {
|
||||||
let tokens = Markdown.marked.lexer(match[0]);
|
let tokens = Markdown.marked.lexer(match[0]);
|
||||||
tokens = tokens[0].tokens.filter(t => t.type == 'emoji')
|
tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji');
|
||||||
if (!tokens.length)
|
if(!tokens.length)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let startPos = { line: lineNumber, ch: match.index };
|
const startPos = { line: lineNumber, ch: match.index };
|
||||||
let endPos = { line: lineNumber, ch: match.index + match[0].length };
|
const endPos = { line: lineNumber, ch: match.index + match[0].length };
|
||||||
|
|
||||||
// Iterate over conflicting marks and clear them
|
// Iterate over conflicting marks and clear them
|
||||||
var marks = codeMirror.findMarks(startPos, endPos);
|
const marks = codeMirror.findMarks(startPos, endPos);
|
||||||
marks.forEach(function(marker) {
|
marks.forEach(function(marker) {
|
||||||
if(!marker.__isFold) marker.clear();
|
if(!marker.__isFold) marker.clear();
|
||||||
});
|
});
|
||||||
@@ -291,57 +313,72 @@ const Editor = createClass({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
brewJump : function(targetPage=this.getCurrentPage()){
|
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
|
||||||
if(!window) return;
|
if(!window || !this.isText() || isJumping)
|
||||||
// console.log(`Scroll to: p${targetPage}`);
|
return;
|
||||||
|
|
||||||
|
// Get current brewRenderer scroll position and calculate target position
|
||||||
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
|
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
|
||||||
const currentPos = brewRenderer.scrollTop;
|
const currentPos = brewRenderer.scrollTop;
|
||||||
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
|
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
|
||||||
const interimPos = targetPos >= 0 ? -30 : 30;
|
|
||||||
|
|
||||||
|
const checkIfScrollComplete = ()=>{
|
||||||
|
let scrollingTimeout;
|
||||||
|
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
|
||||||
|
scrollingTimeout = setTimeout(()=>{
|
||||||
|
isJumping = false;
|
||||||
|
brewRenderer.removeEventListener('scroll', checkIfScrollComplete);
|
||||||
|
}, 150); // If 150 ms pass without a brewRenderer scroll event, assume scrolling is done
|
||||||
|
};
|
||||||
|
|
||||||
|
isJumping = true;
|
||||||
|
checkIfScrollComplete();
|
||||||
|
brewRenderer.addEventListener('scroll', checkIfScrollComplete);
|
||||||
|
|
||||||
|
if(smooth) {
|
||||||
|
const bouncePos = targetPos >= 0 ? -30 : 30; //Do a little bounce before scrolling
|
||||||
const bounceDelay = 100;
|
const bounceDelay = 100;
|
||||||
const scrollDelay = 500;
|
const scrollDelay = 500;
|
||||||
|
|
||||||
if(!this.throttleBrewMove) {
|
if(!this.throttleBrewMove) {
|
||||||
this.throttleBrewMove = _.throttle((currentPos, interimPos, targetPos)=>{
|
this.throttleBrewMove = _.throttle((currentPos, bouncePos, targetPos)=>{
|
||||||
brewRenderer.scrollTo({ top: currentPos + interimPos, behavior: 'smooth' });
|
brewRenderer.scrollTo({ top: currentPos + bouncePos, behavior: 'smooth' });
|
||||||
setTimeout(()=>{
|
setTimeout(()=>{
|
||||||
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
|
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
|
||||||
}, bounceDelay);
|
}, bounceDelay);
|
||||||
}, scrollDelay, { leading: true, trailing: false });
|
}, scrollDelay, { leading: true, trailing: false });
|
||||||
};
|
};
|
||||||
this.throttleBrewMove(currentPos, interimPos, targetPos);
|
this.throttleBrewMove(currentPos, bouncePos, targetPos);
|
||||||
|
} else {
|
||||||
// const hashPage = (page != 1) ? `p${page}` : '';
|
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'instant', block: 'start' });
|
||||||
// window.location.hash = hashPage;
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sourceJump : function(targetLine=null){
|
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
|
||||||
if(this.isText()) {
|
if(!this.isText() || isJumping)
|
||||||
if(targetLine == null) {
|
return;
|
||||||
targetLine = 0;
|
|
||||||
|
|
||||||
const pageCollection = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('page');
|
|
||||||
const brewRendererHeight = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0).getBoundingClientRect().height;
|
|
||||||
|
|
||||||
let currentPage = 1;
|
|
||||||
for (const page of pageCollection) {
|
|
||||||
if(page.getBoundingClientRect().bottom > (brewRendererHeight / 2)) {
|
|
||||||
currentPage = parseInt(page.id.slice(1)) || 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;
|
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;
|
||||||
const textString = this.props.brew.text.split(textSplit).slice(0, currentPage-1).join(textSplit);
|
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit);
|
||||||
const textPosition = textString.length;
|
const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1;
|
||||||
const lineCount = textString.match('\n') ? textString.slice(0, textPosition).split('\n').length : 0;
|
|
||||||
|
|
||||||
targetLine = lineCount - 1; //Scroll to `\page`, which is one line back.
|
|
||||||
|
|
||||||
let currentY = this.codeEditor.current.codeMirror.getScrollInfo().top;
|
let currentY = this.codeEditor.current.codeMirror.getScrollInfo().top;
|
||||||
let targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
|
let targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
|
||||||
|
|
||||||
|
const checkIfScrollComplete = ()=>{
|
||||||
|
let scrollingTimeout;
|
||||||
|
clearTimeout(scrollingTimeout); // Reset the timer every time a scroll event occurs
|
||||||
|
scrollingTimeout = setTimeout(()=>{
|
||||||
|
isJumping = false;
|
||||||
|
this.codeEditor.current.codeMirror.off('scroll', checkIfScrollComplete);
|
||||||
|
}, 150); // If 150 ms pass without a scroll event, assume scrolling is done
|
||||||
|
};
|
||||||
|
|
||||||
|
isJumping = true;
|
||||||
|
checkIfScrollComplete();
|
||||||
|
this.codeEditor.current.codeMirror.on('scroll', checkIfScrollComplete);
|
||||||
|
|
||||||
|
if(smooth) {
|
||||||
//Scroll 1/10 of the way every 10ms until 1px off.
|
//Scroll 1/10 of the way every 10ms until 1px off.
|
||||||
const incrementalScroll = setInterval(()=>{
|
const incrementalScroll = setInterval(()=>{
|
||||||
currentY += (targetY - currentY) / 10;
|
currentY += (targetY - currentY) / 10;
|
||||||
@@ -359,7 +396,10 @@ const Editor = createClass({
|
|||||||
clearInterval(incrementalScroll);
|
clearInterval(incrementalScroll);
|
||||||
}
|
}
|
||||||
}, 10);
|
}, 10);
|
||||||
}
|
} else {
|
||||||
|
this.codeEditor.current.codeMirror.scrollTo(null, targetY); // Scroll any remaining difference
|
||||||
|
this.codeEditor.current.setCursorPosition({ line: targetLine + 1, ch: 0 });
|
||||||
|
this.codeEditor.current.codeMirror.addLineClass(targetLine + 1, 'wrap', 'sourceMoveFlash');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -460,7 +500,9 @@ const Editor = createClass({
|
|||||||
currentEditorTheme={this.state.editorTheme}
|
currentEditorTheme={this.state.editorTheme}
|
||||||
updateEditorTheme={this.updateEditorTheme}
|
updateEditorTheme={this.updateEditorTheme}
|
||||||
snippetBundle={this.props.snippetBundle}
|
snippetBundle={this.props.snippetBundle}
|
||||||
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} />
|
cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
|
||||||
|
updateBrew={this.props.updateBrew}
|
||||||
|
/>
|
||||||
|
|
||||||
{this.renderEditor()}
|
{this.renderEditor()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
.editor {
|
.editor {
|
||||||
position : relative;
|
position : relative;
|
||||||
width : 100%;
|
width : 100%;
|
||||||
|
container: editor / inline-size;
|
||||||
|
|
||||||
.codeEditor {
|
.codeEditor {
|
||||||
height : 100%;
|
height : 100%;
|
||||||
|
|||||||
@@ -304,17 +304,14 @@ const MetadataEditor = createClass({
|
|||||||
onChange={(e)=>this.handleRenderer('V3', e)} />
|
onChange={(e)=>this.handleRenderer('V3', e)} />
|
||||||
V3
|
V3
|
||||||
</label>
|
</label>
|
||||||
|
<small><a href='/legacy' target='_blank' rel='noopener noreferrer'>Click here to see the demo page for the old Legacy renderer!</a></small>
|
||||||
<a href='/legacy' target='_blank' rel='noopener noreferrer'>
|
|
||||||
Click here to see the demo page for the old Legacy renderer!
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
},
|
},
|
||||||
|
|
||||||
render : function(){
|
render : function(){
|
||||||
return <div className='metadataEditor'>
|
return <div className='metadataEditor'>
|
||||||
<h1 className='sectionHead'>Brew</h1>
|
<h1>Properties Editor</h1>
|
||||||
|
|
||||||
<div className='field title'>
|
<div className='field title'>
|
||||||
<label>title</label>
|
<label>title</label>
|
||||||
@@ -362,9 +359,7 @@ const MetadataEditor = createClass({
|
|||||||
|
|
||||||
{this.renderRenderOptions()}
|
{this.renderRenderOptions()}
|
||||||
|
|
||||||
<hr/>
|
<h2>Authors</h2>
|
||||||
|
|
||||||
<h1 className='sectionHead'>Authors</h1>
|
|
||||||
|
|
||||||
{this.renderAuthors()}
|
{this.renderAuthors()}
|
||||||
|
|
||||||
@@ -375,15 +370,13 @@ const MetadataEditor = createClass({
|
|||||||
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
|
notes={['Invited author usernames are case sensitive.', 'After adding an invited author, send them the edit link. There, they can choose to accept or decline the invitation.']}
|
||||||
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}/>
|
onChange={(e)=>this.handleFieldChange('invitedAuthors', e)}/>
|
||||||
|
|
||||||
<hr/>
|
<h2>Privacy</h2>
|
||||||
|
|
||||||
<h1 className='sectionHead'>Privacy</h1>
|
|
||||||
|
|
||||||
<div className='field publish'>
|
<div className='field publish'>
|
||||||
<label>publish</label>
|
<label>publish</label>
|
||||||
<div className='value'>
|
<div className='value'>
|
||||||
{this.renderPublish()}
|
{this.renderPublish()}
|
||||||
<small>Published homebrews will be publicly viewable and searchable (eventually...)</small>
|
<small>Published brews are searchable in <a href='/vault'>the Vault</a> and visible on your user page. Unpublished brews are not indexed in the Vault or visible on your user page, but can still be shared and indexed by search engines. You can unpublish a brew any time.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import 'naturalcrit/styles/colors.less';
|
@import 'naturalcrit/styles/colors.less';
|
||||||
|
|
||||||
|
|
||||||
.metadataEditor {
|
.metadataEditor {
|
||||||
position : absolute;
|
position : absolute;
|
||||||
z-index : 5;
|
z-index : 5;
|
||||||
@@ -9,12 +10,19 @@
|
|||||||
padding : 25px;
|
padding : 25px;
|
||||||
overflow-y : auto;
|
overflow-y : auto;
|
||||||
background-color : #999999;
|
background-color : #999999;
|
||||||
|
font-size : 13px;
|
||||||
|
|
||||||
.sectionHead {
|
h1 {
|
||||||
|
margin: 0 0 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
margin : 20px 0;
|
margin : 20px 0;
|
||||||
font-weight : 1000;
|
font-weight : bold;
|
||||||
|
border-bottom: 2px solid gray;
|
||||||
&:first-of-type { margin-top : 0; }
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > div { margin-bottom : 10px; }
|
& > div { margin-bottom : 10px; }
|
||||||
@@ -43,15 +51,21 @@
|
|||||||
min-width : 200px;
|
min-width : 200px;
|
||||||
& > label {
|
& > label {
|
||||||
width : 80px;
|
width : 80px;
|
||||||
font-size : 11px;
|
|
||||||
font-weight : 800;
|
font-weight : 800;
|
||||||
line-height : 1.8em;
|
line-height : 1.8em;
|
||||||
text-transform : uppercase;
|
text-transform : uppercase;
|
||||||
|
font-size: .9em;
|
||||||
}
|
}
|
||||||
& > .value {
|
& > .value {
|
||||||
flex : 1 1 auto;
|
flex : 1 1 auto;
|
||||||
width : 50px;
|
width : 50px;
|
||||||
&:invalid { background : #FFB9B9; }
|
&:invalid { background : #FFB9B9; }
|
||||||
|
small {
|
||||||
|
display : block;
|
||||||
|
font-size : 0.9em;
|
||||||
|
font-style : italic;
|
||||||
|
line-height : 1.4em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
input[type='text'], textarea {
|
input[type='text'], textarea {
|
||||||
border : 1px solid gray;
|
border : 1px solid gray;
|
||||||
@@ -78,7 +92,6 @@
|
|||||||
textarea.value {
|
textarea.value {
|
||||||
height : auto;
|
height : auto;
|
||||||
font-family : 'Open Sans', sans-serif;
|
font-family : 'Open Sans', sans-serif;
|
||||||
font-size : 0.8em;
|
|
||||||
resize : none;
|
resize : none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,12 +100,6 @@
|
|||||||
z-index : 200;
|
z-index : 200;
|
||||||
max-width : 150px;
|
max-width : 150px;
|
||||||
}
|
}
|
||||||
small {
|
|
||||||
display : inline-block;
|
|
||||||
font-size : 0.6em;
|
|
||||||
font-style : italic;
|
|
||||||
line-height : 1.4em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -113,18 +120,13 @@
|
|||||||
display : inline-flex;
|
display : inline-flex;
|
||||||
align-items : center;
|
align-items : center;
|
||||||
margin-right : 15px;
|
margin-right : 15px;
|
||||||
font-size : 0.7em;
|
font-size : 0.9em;
|
||||||
font-weight : 800;
|
font-weight : 800;
|
||||||
white-space : nowrap;
|
white-space : nowrap;
|
||||||
vertical-align : middle;
|
vertical-align : middle;
|
||||||
cursor : pointer;
|
cursor : pointer;
|
||||||
user-select : none;
|
user-select : none;
|
||||||
}
|
}
|
||||||
a {
|
|
||||||
display : inline-flex;
|
|
||||||
font-size : 0.7em;
|
|
||||||
font-weight : 800;
|
|
||||||
}
|
|
||||||
input {
|
input {
|
||||||
margin : 3px;
|
margin : 3px;
|
||||||
vertical-align : middle;
|
vertical-align : middle;
|
||||||
@@ -149,12 +151,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.authors.field .value {
|
.authors.field .value {
|
||||||
font-size : 0.8em;
|
|
||||||
line-height : 1.5em;
|
line-height : 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.themes.field {
|
.themes.field {
|
||||||
font-size : 13.33px;
|
|
||||||
.navDropdownContainer {
|
.navDropdownContainer {
|
||||||
position : relative;
|
position : relative;
|
||||||
z-index : 100;
|
z-index : 100;
|
||||||
@@ -165,9 +165,9 @@
|
|||||||
background-color : darkgray;
|
background-color : darkgray;
|
||||||
}
|
}
|
||||||
& > div:first-child {
|
& > div:first-child {
|
||||||
padding : 6px 3px;
|
padding : 3px 3px;
|
||||||
background-color : inherit;
|
background-color : inherit;
|
||||||
border : 2px solid rgb(118,118,118);
|
border : 1px solid gray;
|
||||||
i { float : right; }
|
i { float : right; }
|
||||||
&:hover {
|
&:hover {
|
||||||
color : white;
|
color : white;
|
||||||
@@ -240,6 +240,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field .list {
|
.field .list {
|
||||||
display : flex;
|
display : flex;
|
||||||
flex : 1 0;
|
flex : 1 0;
|
||||||
@@ -277,8 +278,20 @@
|
|||||||
background-color : #DDDDDD;
|
background-color : #DDDDDD;
|
||||||
border-radius : 0.5em;
|
border-radius : 0.5em;
|
||||||
|
|
||||||
.icon {
|
.icon { #groupedIcon; }
|
||||||
#groupedIcon; }
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
height : ~'calc(.9em + 4px + .6em)';
|
||||||
|
|
||||||
|
input { border-radius : 0.5em 0 0 0.5em; }
|
||||||
|
|
||||||
|
input:last-child { border-radius : 0.5em; }
|
||||||
|
|
||||||
|
.value {
|
||||||
|
width : 7.5vw;
|
||||||
|
min-width : 75px;
|
||||||
|
height : 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group {
|
.input-group {
|
||||||
@@ -301,10 +314,10 @@
|
|||||||
top : -0.54em;
|
top : -0.54em;
|
||||||
right : 1px;
|
right : 1px;
|
||||||
height : 97%;
|
height : 97%;
|
||||||
font-size : 0.8em;
|
|
||||||
|
|
||||||
i { font-size : 1.125em; }
|
i { font-size : 1.125em; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
/*eslint max-lines: ["warn", {"max": 250, "skipBlankLines": true, "skipComments": true}]*/
|
/*eslint max-lines: ["warn", {"max": 350, "skipBlankLines": true, "skipComments": true}]*/
|
||||||
require('./snippetbar.less');
|
require('./snippetbar.less');
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const createClass = require('create-react-class');
|
const createClass = require('create-react-class');
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
const cx = require('classnames');
|
const cx = require('classnames');
|
||||||
|
|
||||||
|
import { loadHistory } from '../../utils/versionHistory.js';
|
||||||
|
|
||||||
//Import all themes
|
//Import all themes
|
||||||
const ThemeSnippets = {};
|
const ThemeSnippets = {};
|
||||||
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
|
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
|
||||||
@@ -38,7 +40,8 @@ const Snippetbar = createClass({
|
|||||||
unfoldCode : ()=>{},
|
unfoldCode : ()=>{},
|
||||||
updateEditorTheme : ()=>{},
|
updateEditorTheme : ()=>{},
|
||||||
cursorPos : {},
|
cursorPos : {},
|
||||||
snippetBundle : []
|
snippetBundle : [],
|
||||||
|
updateBrew : ()=>{}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -46,31 +49,54 @@ const Snippetbar = createClass({
|
|||||||
return {
|
return {
|
||||||
renderer : this.props.renderer,
|
renderer : this.props.renderer,
|
||||||
themeSelector : false,
|
themeSelector : false,
|
||||||
snippets : []
|
snippets : [],
|
||||||
|
showHistory : false,
|
||||||
|
historyExists : false,
|
||||||
|
historyItems : []
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
componentDidMount : async function() {
|
componentDidMount : async function(prevState) {
|
||||||
const snippets = this.compileSnippets();
|
const snippets = this.compileSnippets();
|
||||||
this.setState({
|
this.setState({
|
||||||
snippets : snippets
|
snippets : snippets
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
componentDidUpdate : async function(prevProps) {
|
componentDidUpdate : async function(prevProps, prevState) {
|
||||||
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
|
if(prevProps.renderer != this.props.renderer || prevProps.theme != this.props.theme || prevProps.snippetBundle != this.props.snippetBundle) {
|
||||||
const snippets = this.compileSnippets();
|
|
||||||
this.setState({
|
this.setState({
|
||||||
snippets : snippets
|
snippets : this.compileSnippets()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update history list if it has changed
|
||||||
|
const checkHistoryItems = await loadHistory(this.props.brew);
|
||||||
|
|
||||||
|
// If all items have the noData property, there is no saved data
|
||||||
|
const checkHistoryExists = !checkHistoryItems.every((historyItem)=>{
|
||||||
|
return historyItem?.noData;
|
||||||
|
});
|
||||||
|
if(prevState.historyExists != checkHistoryExists){
|
||||||
|
this.setState({
|
||||||
|
historyExists : checkHistoryExists
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If any history items have changed, update the list
|
||||||
|
if(checkHistoryExists && checkHistoryItems.some((historyItem, index)=>{
|
||||||
|
return index >= prevState.historyItems.length || !_.isEqual(historyItem, prevState.historyItems[index]);
|
||||||
|
})){
|
||||||
|
this.setState({
|
||||||
|
historyItems : checkHistoryItems
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
mergeCustomizer : function(oldValue, newValue, key) {
|
mergeCustomizer : function(oldValue, newValue, key) {
|
||||||
if(key == 'snippets') {
|
if(key == 'snippets') {
|
||||||
const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme
|
const result = _.reverse(_.unionBy(_.reverse(newValue), _.reverse(oldValue), 'name')); // Join snippets together, with preference for the child theme over the parent theme
|
||||||
return _.filter(result, 'gen'); //Only keep snippets with a 'gen' property.
|
return result.filter((snip)=>snip.gen || snip.subsnippets);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -124,8 +150,10 @@ const Snippetbar = createClass({
|
|||||||
|
|
||||||
renderSnippetGroups : function(){
|
renderSnippetGroups : function(){
|
||||||
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
|
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
|
||||||
|
if(snippets.length === 0) return null;
|
||||||
|
|
||||||
return _.map(snippets, (snippetGroup)=>{
|
return <div className='snippets'>
|
||||||
|
{_.map(snippets, (snippetGroup)=>{
|
||||||
return <SnippetGroup
|
return <SnippetGroup
|
||||||
brew={this.props.brew}
|
brew={this.props.brew}
|
||||||
groupName={snippetGroup.groupName}
|
groupName={snippetGroup.groupName}
|
||||||
@@ -135,29 +163,68 @@ const Snippetbar = createClass({
|
|||||||
onSnippetClick={this.handleSnippetClick}
|
onSnippetClick={this.handleSnippetClick}
|
||||||
cursorPos={this.props.cursorPos}
|
cursorPos={this.props.cursorPos}
|
||||||
/>;
|
/>;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>;
|
||||||
|
},
|
||||||
|
|
||||||
|
replaceContent : function(item){
|
||||||
|
return this.props.updateBrew(item);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleHistoryMenu : function(){
|
||||||
|
this.setState({
|
||||||
|
showHistory : !this.state.showHistory
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
renderHistoryItems : function() {
|
||||||
|
if(!this.state.historyExists) return;
|
||||||
|
|
||||||
|
return <div className='dropdown'>
|
||||||
|
{_.map(this.state.historyItems, (item, index)=>{
|
||||||
|
if(item.noData || !item.savedAt) return;
|
||||||
|
|
||||||
|
const saveTime = new Date(item.savedAt);
|
||||||
|
const diffMs = new Date() - saveTime;
|
||||||
|
const diffSecs = Math.floor(diffMs / 1000);
|
||||||
|
|
||||||
|
let diffString = `about ${diffSecs} seconds ago`;
|
||||||
|
|
||||||
|
if(diffSecs > 60) diffString = `about ${Math.floor(diffSecs / 60)} minutes ago`;
|
||||||
|
if(diffSecs > (60 * 60)) diffString = `about ${Math.floor(diffSecs / (60 * 60))} hours ago`;
|
||||||
|
if(diffSecs > (24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (24 * 60 * 60))} days ago`;
|
||||||
|
if(diffSecs > (7 * 24 * 60 * 60)) diffString = `about ${Math.floor(diffSecs / (7 * 24 * 60 * 60))} weeks ago`;
|
||||||
|
|
||||||
|
return <div className='snippet' key={index} onClick={()=>{this.replaceContent(item);}} >
|
||||||
|
<i className={`fas fa-${index+1}`} />
|
||||||
|
<span className='name' title={saveTime.toISOString()}>v{item.version} : {diffString}</span>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>;
|
||||||
|
},
|
||||||
|
|
||||||
renderEditorButtons : function(){
|
renderEditorButtons : function(){
|
||||||
if(!this.props.showEditButtons) return;
|
if(!this.props.showEditButtons) return;
|
||||||
|
|
||||||
let foldButtons;
|
const foldButtons = <>
|
||||||
if(this.props.view == 'text'){
|
<div className={`editorTool foldAll ${this.props.view !== 'meta' && this.props.foldCode ? 'active' : ''}`}
|
||||||
foldButtons =
|
|
||||||
<>
|
|
||||||
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
|
|
||||||
onClick={this.props.foldCode} >
|
onClick={this.props.foldCode} >
|
||||||
<i className='fas fa-compress-alt' />
|
<i className='fas fa-compress-alt' />
|
||||||
</div>
|
</div>
|
||||||
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
|
<div className={`editorTool unfoldAll ${this.props.view !== 'meta' && this.props.unfoldCode ? 'active' : ''}`}
|
||||||
onClick={this.props.unfoldCode} >
|
onClick={this.props.unfoldCode} >
|
||||||
<i className='fas fa-expand-alt' />
|
<i className='fas fa-expand-alt' />
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className='editors'>
|
return <div className='editors'>
|
||||||
|
<div className='historyTools'>
|
||||||
|
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`}
|
||||||
|
onClick={this.toggleHistoryMenu} >
|
||||||
|
<i className='fas fa-clock-rotate-left' />
|
||||||
|
{ this.state.showHistory && this.renderHistoryItems() }
|
||||||
|
</div>
|
||||||
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
|
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
|
||||||
onClick={this.props.undo} >
|
onClick={this.props.undo} >
|
||||||
<i className='fas fa-undo' />
|
<i className='fas fa-undo' />
|
||||||
@@ -166,15 +233,18 @@ const Snippetbar = createClass({
|
|||||||
onClick={this.props.redo} >
|
onClick={this.props.redo} >
|
||||||
<i className='fas fa-redo' />
|
<i className='fas fa-redo' />
|
||||||
</div>
|
</div>
|
||||||
<div className='divider'></div>
|
</div>
|
||||||
|
<div className='codeTools'>
|
||||||
{foldButtons}
|
{foldButtons}
|
||||||
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
||||||
onClick={this.toggleThemeSelector} >
|
onClick={this.toggleThemeSelector} >
|
||||||
<i className='fas fa-palette' />
|
<i className='fas fa-palette' />
|
||||||
{this.state.themeSelector && this.renderThemeSelector()}
|
{this.state.themeSelector && this.renderThemeSelector()}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='divider'></div>
|
|
||||||
|
<div className='tabs'>
|
||||||
<div className={cx('text', { selected: this.props.view === 'text' })}
|
<div className={cx('text', { selected: this.props.view === 'text' })}
|
||||||
onClick={()=>this.props.onViewChange('text')}>
|
onClick={()=>this.props.onViewChange('text')}>
|
||||||
<i className='fa fa-beer' />
|
<i className='fa fa-beer' />
|
||||||
@@ -187,6 +257,8 @@ const Snippetbar = createClass({
|
|||||||
onClick={()=>this.props.onViewChange('meta')}>
|
onClick={()=>this.props.onViewChange('meta')}>
|
||||||
<i className='fas fa-info-circle' />
|
<i className='fas fa-info-circle' />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>;
|
</div>;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -224,8 +296,9 @@ const SnippetGroup = createClass({
|
|||||||
return _.map(snippets, (snippet)=>{
|
return _.map(snippets, (snippet)=>{
|
||||||
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
|
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
|
||||||
<i className={snippet.icon} />
|
<i className={snippet.icon} />
|
||||||
<span className='name'title={snippet.name}>{snippet.name}</span>
|
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
|
||||||
{snippet.experimental && <span className='beta'>beta</span>}
|
{snippet.experimental && <span className='beta'>beta</span>}
|
||||||
|
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
|
||||||
{snippet.subsnippets && <>
|
{snippet.subsnippets && <>
|
||||||
<i className='fas fa-caret-right'></i>
|
<i className='fas fa-caret-right'></i>
|
||||||
<div className='dropdown side'>
|
<div className='dropdown side'>
|
||||||
|
|||||||
@@ -4,18 +4,35 @@
|
|||||||
.snippetBar {
|
.snippetBar {
|
||||||
@menuHeight : 25px;
|
@menuHeight : 25px;
|
||||||
position : relative;
|
position : relative;
|
||||||
height : @menuHeight;
|
display : flex;
|
||||||
|
flex-wrap : wrap-reverse;
|
||||||
|
justify-content : space-between;
|
||||||
|
height : auto;
|
||||||
color : black;
|
color : black;
|
||||||
background-color : #DDDDDD;
|
background-color : #DDDDDD;
|
||||||
|
|
||||||
.editors {
|
.snippets {
|
||||||
position : absolute;
|
|
||||||
top : 0px;
|
|
||||||
right : 0px;
|
|
||||||
display : flex;
|
display : flex;
|
||||||
justify-content : space-between;
|
justify-content : flex-start;
|
||||||
height : @menuHeight;
|
min-width : 327.58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editors {
|
||||||
|
display : flex;
|
||||||
|
justify-content : flex-end;
|
||||||
|
min-width : 225px;
|
||||||
|
|
||||||
|
&:only-child { margin-left : auto; }
|
||||||
|
|
||||||
|
>div {
|
||||||
|
display : flex;
|
||||||
|
flex : 1;
|
||||||
|
justify-content : space-around;
|
||||||
|
|
||||||
|
&:first-child { border-left : none; }
|
||||||
|
|
||||||
& > div {
|
& > div {
|
||||||
|
position : relative;
|
||||||
width : @menuHeight;
|
width : @menuHeight;
|
||||||
height : @menuHeight;
|
height : @menuHeight;
|
||||||
line-height : @menuHeight;
|
line-height : @menuHeight;
|
||||||
@@ -46,12 +63,26 @@
|
|||||||
&.foldAll {
|
&.foldAll {
|
||||||
.tooltipLeft('Fold All');
|
.tooltipLeft('Fold All');
|
||||||
font-size : 0.75em;
|
font-size : 0.75em;
|
||||||
color : inherit;
|
color : grey;
|
||||||
|
&.active { color : inherit; }
|
||||||
}
|
}
|
||||||
&.unfoldAll {
|
&.unfoldAll {
|
||||||
.tooltipLeft('Unfold All');
|
.tooltipLeft('Unfold All');
|
||||||
font-size : 0.75em;
|
font-size : 0.75em;
|
||||||
color : inherit;
|
color : grey;
|
||||||
|
&.active { color : inherit; }
|
||||||
|
}
|
||||||
|
&.history {
|
||||||
|
.tooltipLeft('History');
|
||||||
|
position : relative;
|
||||||
|
font-size : 0.75em;
|
||||||
|
color : grey;
|
||||||
|
border : none;
|
||||||
|
&.active { color : inherit; }
|
||||||
|
& > .dropdown {
|
||||||
|
right : -1px;
|
||||||
|
& > .snippet { padding-right : 10px; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&.editorTheme {
|
&.editorTheme {
|
||||||
.tooltipLeft('Editor Themes');
|
.tooltipLeft('Editor Themes');
|
||||||
@@ -81,6 +112,7 @@
|
|||||||
background-color : inherit;
|
background-color : inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.snippetBarButton {
|
.snippetBarButton {
|
||||||
display : inline-block;
|
display : inline-block;
|
||||||
height : @menuHeight;
|
height : @menuHeight;
|
||||||
@@ -89,6 +121,7 @@
|
|||||||
font-weight : 800;
|
font-weight : 800;
|
||||||
line-height : @menuHeight;
|
line-height : @menuHeight;
|
||||||
text-transform : uppercase;
|
text-transform : uppercase;
|
||||||
|
text-wrap : nowrap;
|
||||||
cursor : pointer;
|
cursor : pointer;
|
||||||
&:hover, &.selected { background-color : #999999; }
|
&:hover, &.selected { background-color : #999999; }
|
||||||
i {
|
i {
|
||||||
@@ -105,7 +138,7 @@
|
|||||||
.tooltipLeft('Edit Brew Properties');
|
.tooltipLeft('Edit Brew Properties');
|
||||||
}
|
}
|
||||||
.snippetGroup {
|
.snippetGroup {
|
||||||
border-right : 1px solid currentColor;
|
|
||||||
&:hover {
|
&:hover {
|
||||||
& > .dropdown { visibility : visible; }
|
& > .dropdown { visibility : visible; }
|
||||||
}
|
}
|
||||||
@@ -127,11 +160,11 @@
|
|||||||
cursor : pointer;
|
cursor : pointer;
|
||||||
.animate(background-color);
|
.animate(background-color);
|
||||||
i {
|
i {
|
||||||
|
min-width : 25px;
|
||||||
height : 1.2em;
|
height : 1.2em;
|
||||||
margin-right : 8px;
|
margin-right : 8px;
|
||||||
font-size : 1.2em;
|
font-size : 1.2em;
|
||||||
min-width: 25px;
|
text-align : center;
|
||||||
text-align: center;
|
|
||||||
& ~ i {
|
& ~ i {
|
||||||
margin-right : 0;
|
margin-right : 0;
|
||||||
margin-left : 5px;
|
margin-left : 5px;
|
||||||
@@ -164,6 +197,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.name { margin-right : auto; }
|
.name { margin-right : auto; }
|
||||||
|
.disabled { text-decoration : line-through; }
|
||||||
.beta {
|
.beta {
|
||||||
align-self : center;
|
align-self : center;
|
||||||
padding : 4px 6px;
|
padding : 4px 6px;
|
||||||
@@ -190,3 +224,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@container editor (width < 553px) {
|
||||||
|
.snippetBar {
|
||||||
|
.editors {
|
||||||
|
flex : 1;
|
||||||
|
justify-content : space-between;
|
||||||
|
border-bottom : 1px solid;
|
||||||
|
}
|
||||||
|
.snippets {
|
||||||
|
flex : 1;
|
||||||
|
justify-content : space-evenly;
|
||||||
|
}
|
||||||
|
.editors > div.history > .dropdown { right : unset; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const StringArrayEditor = createClass({
|
|||||||
|
|
||||||
return <div className='field'>
|
return <div className='field'>
|
||||||
<label>{this.props.label}</label>
|
<label>{this.props.label}</label>
|
||||||
<div style={{ flex: '1 0' }}>
|
<div style={{ flex: '1 0' }} className='value'>
|
||||||
<div className='list'>
|
<div className='list'>
|
||||||
{valueElements}
|
{valueElements}
|
||||||
<div className='input-group'>
|
<div className='input-group'>
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
const React = require('react');
|
|
||||||
const createClass = require('create-react-class');
|
|
||||||
const cx = require('classnames');
|
|
||||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
|
||||||
|
|
||||||
const MAX_TITLE_LENGTH = 50;
|
|
||||||
|
|
||||||
|
|
||||||
const EditTitle = createClass({
|
|
||||||
displayName : 'EditTitleNavItem',
|
|
||||||
getDefaultProps : function() {
|
|
||||||
return {
|
|
||||||
title : '',
|
|
||||||
onChange : function(){}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
handleChange : function(e){
|
|
||||||
if(e.target.value.length > MAX_TITLE_LENGTH) return;
|
|
||||||
this.props.onChange(e.target.value);
|
|
||||||
},
|
|
||||||
render : function(){
|
|
||||||
return <Nav.item className='editTitle'>
|
|
||||||
<input placeholder='Brew Title' type='text' value={this.props.title} onChange={this.handleChange} />
|
|
||||||
|
|
||||||
<div className={cx('charCount', { 'max': this.props.title.length >= MAX_TITLE_LENGTH })}>
|
|
||||||
{this.props.title.length}/{MAX_TITLE_LENGTH}
|
|
||||||
</div>
|
|
||||||
</Nav.item>;
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = EditTitle;
|
|
||||||
@@ -116,17 +116,6 @@ const ErrorNavItem = createClass({
|
|||||||
</Nav.item>;
|
</Nav.item>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(HBErrorCode === '55') {
|
|
||||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
|
||||||
Oops!
|
|
||||||
<div className='errorContainer' onClick={clearError}>
|
|
||||||
Looks like there are too many requests
|
|
||||||
from this IP address in a short time.
|
|
||||||
Please try again after a few minutes.
|
|
||||||
</div>
|
|
||||||
</Nav.item>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
return <Nav.item className='save error' icon='fas fa-exclamation-triangle'>
|
||||||
Oops!
|
Oops!
|
||||||
<div className='errorContainer'>
|
<div className='errorContainer'>
|
||||||
|
|||||||
@@ -25,16 +25,20 @@
|
|||||||
|
|
||||||
.homebrew nav {
|
.homebrew nav {
|
||||||
background-color : #333333;
|
background-color : #333333;
|
||||||
.navContent {
|
|
||||||
position : relative;
|
position : relative;
|
||||||
z-index : 2;
|
z-index : 2;
|
||||||
display : flex;
|
display : flex;
|
||||||
justify-content : space-between;
|
justify-content : space-between;
|
||||||
}
|
|
||||||
.navSection {
|
.navSection {
|
||||||
display : flex;
|
display : flex;
|
||||||
align-items : center;
|
align-items : center;
|
||||||
&:last-child .navItem { border-left : 1px solid #666666; }
|
&:last-child .navItem { border-left : 1px solid #666666; }
|
||||||
|
|
||||||
|
&:has(.brewTitle) {
|
||||||
|
flex-grow : 1;
|
||||||
|
min-width : 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// "NaturalCrit" logo
|
// "NaturalCrit" logo
|
||||||
.navLogo {
|
.navLogo {
|
||||||
@@ -69,6 +73,10 @@
|
|||||||
.navItem {
|
.navItem {
|
||||||
#backgroundColorsHover;
|
#backgroundColorsHover;
|
||||||
.animate(background-color);
|
.animate(background-color);
|
||||||
|
display : flex;
|
||||||
|
align-items : center;
|
||||||
|
justify-content : center;
|
||||||
|
height : 100%;
|
||||||
padding : 8px 12px;
|
padding : 8px 12px;
|
||||||
font-size : 10px;
|
font-size : 10px;
|
||||||
font-weight : 800;
|
font-weight : 800;
|
||||||
@@ -94,39 +102,20 @@
|
|||||||
animation-duration : 2s;
|
animation-duration : 2s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.editTitle { // this is not needed at all currently - you used to be able to edit the title via the navbar.
|
|
||||||
padding : 2px 12px;
|
|
||||||
input {
|
|
||||||
width : 250px;
|
|
||||||
padding : 2px;
|
|
||||||
margin : 0;
|
|
||||||
font-family : 'Open Sans', sans-serif;
|
|
||||||
font-size : 12px;
|
|
||||||
font-weight : 800;
|
|
||||||
color : white;
|
|
||||||
text-align : center;
|
|
||||||
background-color : transparent;
|
|
||||||
border : 1px solid @blue;
|
|
||||||
outline : none;
|
|
||||||
}
|
|
||||||
.charCount {
|
|
||||||
display : inline-block;
|
|
||||||
margin-left : 8px;
|
|
||||||
color : #666666;
|
|
||||||
text-align : right;
|
|
||||||
vertical-align : bottom;
|
|
||||||
&.max { color : @red; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&.brewTitle {
|
&.brewTitle {
|
||||||
flex-grow : 1;
|
display : block;
|
||||||
|
width : 100%;
|
||||||
|
overflow : hidden;
|
||||||
font-size : 12px;
|
font-size : 12px;
|
||||||
font-weight : 800;
|
font-weight : 800;
|
||||||
color : white;
|
color : white;
|
||||||
text-align : center;
|
text-align : center;
|
||||||
|
text-overflow : ellipsis;
|
||||||
text-transform : initial;
|
text-transform : initial;
|
||||||
|
white-space : nowrap;
|
||||||
background-color : transparent;
|
background-color : transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "The Homebrewery" logo
|
// "The Homebrewery" logo
|
||||||
&.homebrewLogo {
|
&.homebrewLogo {
|
||||||
.animate(color);
|
.animate(color);
|
||||||
@@ -240,23 +229,25 @@
|
|||||||
}
|
}
|
||||||
.navDropdownContainer {
|
.navDropdownContainer {
|
||||||
position : relative;
|
position : relative;
|
||||||
|
height : 100%;
|
||||||
|
|
||||||
.navDropdown {
|
.navDropdown {
|
||||||
position: absolute;
|
position : absolute;
|
||||||
top: 28px;
|
//top: 28px;
|
||||||
right: 0px;
|
right : 0px;
|
||||||
z-index: 10000;
|
z-index : 10000;
|
||||||
width: max-content;
|
display : flex;
|
||||||
min-width:100%;
|
flex-direction : column;
|
||||||
max-height: calc(100vh - 28px);
|
align-items : flex-end;
|
||||||
overflow: hidden auto;
|
width : max-content;
|
||||||
display: flex;
|
min-width : 100%;
|
||||||
flex-direction: column;
|
max-height : calc(100vh - 28px);
|
||||||
align-items: flex-end;
|
overflow : hidden auto;
|
||||||
.navItem {
|
.navItem {
|
||||||
position : relative;
|
position : relative;
|
||||||
display : flex;
|
display : flex;
|
||||||
justify-content : space-between;
|
|
||||||
align-items : center;
|
align-items : center;
|
||||||
|
justify-content : space-between;
|
||||||
width : 100%;
|
width : 100%;
|
||||||
border : 1px solid #888888;
|
border : 1px solid #888888;
|
||||||
border-bottom : 0;
|
border-bottom : 0;
|
||||||
@@ -278,10 +269,10 @@
|
|||||||
overflow : hidden auto;
|
overflow : hidden auto;
|
||||||
color : white;
|
color : white;
|
||||||
text-decoration : none;
|
text-decoration : none;
|
||||||
background-color : #333333;
|
|
||||||
border-top : 1px solid #888888;
|
|
||||||
scrollbar-color : #666666 #333333;
|
scrollbar-color : #666666 #333333;
|
||||||
scrollbar-width : thin;
|
scrollbar-width : thin;
|
||||||
|
background-color : #333333;
|
||||||
|
border-top : 1px solid #888888;
|
||||||
.clear {
|
.clear {
|
||||||
position : absolute;
|
position : absolute;
|
||||||
top : 50%;
|
top : 50%;
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
const React = require('react');
|
|
||||||
const createClass = require('create-react-class');
|
|
||||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
|
||||||
|
|
||||||
const MAIN_URL = 'https://www.reddit.com/r/UnearthedArcana/submit?selftext=true';
|
|
||||||
|
|
||||||
|
|
||||||
const RedditShare = createClass({
|
|
||||||
displayName : 'RedditShareNavItem',
|
|
||||||
getDefaultProps : function() {
|
|
||||||
return {
|
|
||||||
brew : {
|
|
||||||
title : '',
|
|
||||||
sharedId : '',
|
|
||||||
text : ''
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
getText : function(){
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
handleClick : function(){
|
|
||||||
const url = [
|
|
||||||
MAIN_URL,
|
|
||||||
`title=${encodeURIComponent(this.props.brew.title ? this.props.brew.title : 'Check out my brew!')}`,
|
|
||||||
`text=${encodeURIComponent(this.props.brew.text)}`
|
|
||||||
].join('&');
|
|
||||||
|
|
||||||
window.open(url, '_blank');
|
|
||||||
},
|
|
||||||
|
|
||||||
|
|
||||||
render : function(){
|
|
||||||
return <Nav.item icon='fa-reddit-alien' color='red' onClick={this.handleClick}>
|
|
||||||
share on reddit
|
|
||||||
</Nav.item>;
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = RedditShare;
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
/* eslint-disable max-lines */
|
/* eslint-disable max-lines */
|
||||||
require('./editPage.less');
|
require('./editPage.less');
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const createClass = require('create-react-class');
|
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
|
const createClass = require('create-react-class');
|
||||||
|
|
||||||
const request = require('../../utils/request-middleware.js');
|
const request = require('../../utils/request-middleware.js');
|
||||||
const { Meta } = require('vitreum/headtags');
|
const { Meta } = require('vitreum/headtags');
|
||||||
|
|
||||||
@@ -27,9 +28,11 @@ const Markdown = require('naturalcrit/markdown.js');
|
|||||||
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
|
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
|
||||||
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
|
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
|
||||||
|
|
||||||
|
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
|
||||||
|
|
||||||
const googleDriveIcon = require('../../googleDrive.svg');
|
const googleDriveIcon = require('../../googleDrive.svg');
|
||||||
|
|
||||||
const SAVE_TIMEOUT = 16000;
|
const SAVE_TIMEOUT = 10000;
|
||||||
|
|
||||||
const EditPage = createClass({
|
const EditPage = createClass({
|
||||||
displayName : 'EditPage',
|
displayName : 'EditPage',
|
||||||
@@ -54,7 +57,9 @@ const EditPage = createClass({
|
|||||||
autoSave : true,
|
autoSave : true,
|
||||||
autoSaveWarning : false,
|
autoSaveWarning : false,
|
||||||
unsavedTime : new Date(),
|
unsavedTime : new Date(),
|
||||||
currentEditorPage : 0,
|
currentEditorViewPageNum : 1,
|
||||||
|
currentEditorCursorPageNum : 1,
|
||||||
|
currentBrewRendererPageNum : 1,
|
||||||
displayLockMessage : this.props.brew.lock || false,
|
displayLockMessage : this.props.brew.lock || false,
|
||||||
themeBundle : {}
|
themeBundle : {}
|
||||||
};
|
};
|
||||||
@@ -113,6 +118,18 @@ const EditPage = createClass({
|
|||||||
this.editor.current.update();
|
this.editor.current.update();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleEditorViewPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentEditorViewPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleEditorCursorPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentEditorCursorPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBrewRendererPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentBrewRendererPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
handleTextChange : function(text){
|
handleTextChange : function(text){
|
||||||
//If there are errors, run the validator on every change to give quick feedback
|
//If there are errors, run the validator on every change to give quick feedback
|
||||||
let htmlErrors = this.state.htmlErrors;
|
let htmlErrors = this.state.htmlErrors;
|
||||||
@@ -122,7 +139,6 @@ const EditPage = createClass({
|
|||||||
brew : { ...prevState.brew, text: text },
|
brew : { ...prevState.brew, text: text },
|
||||||
isPending : true,
|
isPending : true,
|
||||||
htmlErrors : htmlErrors,
|
htmlErrors : htmlErrors,
|
||||||
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
|
|
||||||
}), ()=>{if(this.state.autoSave) this.trySave();});
|
}), ()=>{if(this.state.autoSave) this.trySave();});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -150,6 +166,16 @@ const EditPage = createClass({
|
|||||||
return !_.isEqual(this.state.brew, this.savedBrew);
|
return !_.isEqual(this.state.brew, this.savedBrew);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateBrew : function(newData){
|
||||||
|
this.setState((prevState)=>({
|
||||||
|
brew : {
|
||||||
|
...prevState.brew,
|
||||||
|
style : newData.style,
|
||||||
|
text : newData.text
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
trySave : function(immediate=false){
|
trySave : function(immediate=false){
|
||||||
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
|
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
|
||||||
if(this.hasChanges()){
|
if(this.hasChanges()){
|
||||||
@@ -202,6 +228,9 @@ const EditPage = createClass({
|
|||||||
htmlErrors : Markdown.validate(prevState.brew.text)
|
htmlErrors : Markdown.validate(prevState.brew.text)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
await updateHistory(this.state.brew).catch(console.error);
|
||||||
|
await versionHistoryGarbageCollection().catch(console.error);
|
||||||
|
|
||||||
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
|
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
|
||||||
|
|
||||||
const brew = this.state.brew;
|
const brew = this.state.brew;
|
||||||
@@ -400,8 +429,8 @@ const EditPage = createClass({
|
|||||||
<Meta name='robots' content='noindex, nofollow' />
|
<Meta name='robots' content='noindex, nofollow' />
|
||||||
{this.renderNavbar()}
|
{this.renderNavbar()}
|
||||||
|
|
||||||
<div className='content'>
|
|
||||||
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
|
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
|
||||||
|
<div className="content">
|
||||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||||
<Editor
|
<Editor
|
||||||
ref={this.editor}
|
ref={this.editor}
|
||||||
@@ -413,6 +442,12 @@ const EditPage = createClass({
|
|||||||
renderer={this.state.brew.renderer}
|
renderer={this.state.brew.renderer}
|
||||||
userThemes={this.props.userThemes}
|
userThemes={this.props.userThemes}
|
||||||
snippetBundle={this.state.themeBundle.snippets}
|
snippetBundle={this.state.themeBundle.snippets}
|
||||||
|
updateBrew={this.updateBrew}
|
||||||
|
onCursorPageChange={this.handleEditorCursorPageChange}
|
||||||
|
onViewPageChange={this.handleEditorViewPageChange}
|
||||||
|
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||||
|
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||||
|
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||||
/>
|
/>
|
||||||
<BrewRenderer
|
<BrewRenderer
|
||||||
text={this.state.brew.text}
|
text={this.state.brew.text}
|
||||||
@@ -422,7 +457,10 @@ const EditPage = createClass({
|
|||||||
themeBundle={this.state.themeBundle}
|
themeBundle={this.state.themeBundle}
|
||||||
errors={this.state.htmlErrors}
|
errors={this.state.htmlErrors}
|
||||||
lang={this.state.brew.lang}
|
lang={this.state.brew.lang}
|
||||||
currentEditorPage={this.state.currentEditorPage}
|
onPageChange={this.handleBrewRendererPageChange}
|
||||||
|
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||||
|
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||||
|
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||||
allowPrint={true}
|
allowPrint={true}
|
||||||
/>
|
/>
|
||||||
</SplitPane>
|
</SplitPane>
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ const errorIndex = (props)=>{
|
|||||||
|
|
||||||
**Brew Title:** ${props.brew.brewTitle}`,
|
**Brew Title:** ${props.brew.brewTitle}`,
|
||||||
|
|
||||||
|
// ####### Admin page error #######
|
||||||
|
'52': dedent`
|
||||||
|
## Access Denied
|
||||||
|
You need to provide correct administrator credentials to access this page.`,
|
||||||
|
|
||||||
'90' : dedent` An unexpected error occurred while looking for these brews.
|
'90' : dedent` An unexpected error occurred while looking for these brews.
|
||||||
Try again in a few minutes.`,
|
Try again in a few minutes.`,
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
require('./homePage.less');
|
require('./homePage.less');
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const createClass = require('create-react-class');
|
const createClass = require('create-react-class');
|
||||||
const _ = require('lodash');
|
|
||||||
const cx = require('classnames');
|
const cx = require('classnames');
|
||||||
const request = require('../../utils/request-middleware.js');
|
const request = require('../../utils/request-middleware.js');
|
||||||
const { Meta } = require('vitreum/headtags');
|
const { Meta } = require('vitreum/headtags');
|
||||||
@@ -35,7 +34,9 @@ const HomePage = createClass({
|
|||||||
brew : this.props.brew,
|
brew : this.props.brew,
|
||||||
welcomeText : this.props.brew.text,
|
welcomeText : this.props.brew.text,
|
||||||
error : undefined,
|
error : undefined,
|
||||||
currentEditorPage : 0,
|
currentEditorViewPageNum : 1,
|
||||||
|
currentEditorCursorPageNum : 1,
|
||||||
|
currentBrewRendererPageNum : 1,
|
||||||
themeBundle : {}
|
themeBundle : {}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -61,10 +62,22 @@ const HomePage = createClass({
|
|||||||
handleSplitMove : function(){
|
handleSplitMove : function(){
|
||||||
this.editor.current.update();
|
this.editor.current.update();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleEditorViewPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentEditorViewPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleEditorCursorPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentEditorCursorPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBrewRendererPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentBrewRendererPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
handleTextChange : function(text){
|
handleTextChange : function(text){
|
||||||
this.setState((prevState)=>({
|
this.setState((prevState)=>({
|
||||||
brew : { ...prevState.brew, text: text },
|
brew : { ...prevState.brew, text: text },
|
||||||
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
|
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
renderNavbar : function(){
|
renderNavbar : function(){
|
||||||
@@ -87,8 +100,7 @@ const HomePage = createClass({
|
|||||||
return <div className='homePage sitePage'>
|
return <div className='homePage sitePage'>
|
||||||
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
|
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
|
||||||
{this.renderNavbar()}
|
{this.renderNavbar()}
|
||||||
|
<div className="content">
|
||||||
<div className='content'>
|
|
||||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||||
<Editor
|
<Editor
|
||||||
ref={this.editor}
|
ref={this.editor}
|
||||||
@@ -97,17 +109,24 @@ const HomePage = createClass({
|
|||||||
renderer={this.state.brew.renderer}
|
renderer={this.state.brew.renderer}
|
||||||
showEditButtons={false}
|
showEditButtons={false}
|
||||||
snippetBundle={this.state.themeBundle.snippets}
|
snippetBundle={this.state.themeBundle.snippets}
|
||||||
|
onCursorPageChange={this.handleEditorCursorPageChange}
|
||||||
|
onViewPageChange={this.handleEditorViewPageChange}
|
||||||
|
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||||
|
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||||
|
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||||
/>
|
/>
|
||||||
<BrewRenderer
|
<BrewRenderer
|
||||||
text={this.state.brew.text}
|
text={this.state.brew.text}
|
||||||
style={this.state.brew.style}
|
style={this.state.brew.style}
|
||||||
renderer={this.state.brew.renderer}
|
renderer={this.state.brew.renderer}
|
||||||
currentEditorPage={this.state.currentEditorPage}
|
onPageChange={this.handleBrewRendererPageChange}
|
||||||
|
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||||
|
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||||
|
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||||
themeBundle={this.state.themeBundle}
|
themeBundle={this.state.themeBundle}
|
||||||
/>
|
/>
|
||||||
</SplitPane>
|
</SplitPane>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}>
|
<div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}>
|
||||||
Save current <i className='fas fa-save' />
|
Save current <i className='fas fa-save' />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,13 +91,6 @@ If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](
|
|||||||
|
|
||||||
\page
|
\page
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Markdown+
|
## Markdown+
|
||||||
The Homebrewery aims to make homebrewing as simple as possible, providing a live editor with Markdown syntax that is more human-readable and faster to write with than raw HTML.
|
The Homebrewery aims to make homebrewing as simple as possible, providing a live editor with Markdown syntax that is more human-readable and faster to write with than raw HTML.
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ const NewPage = createClass({
|
|||||||
saveGoogle : (global.account && global.account.googleId ? true : false),
|
saveGoogle : (global.account && global.account.googleId ? true : false),
|
||||||
error : null,
|
error : null,
|
||||||
htmlErrors : Markdown.validate(brew.text),
|
htmlErrors : Markdown.validate(brew.text),
|
||||||
currentEditorPage : 0,
|
currentEditorViewPageNum : 1,
|
||||||
|
currentEditorCursorPageNum : 1,
|
||||||
|
currentBrewRendererPageNum : 1,
|
||||||
themeBundle : {}
|
themeBundle : {}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -108,6 +110,18 @@ const NewPage = createClass({
|
|||||||
this.editor.current.update();
|
this.editor.current.update();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleEditorViewPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentEditorViewPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleEditorCursorPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentEditorCursorPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBrewRendererPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentBrewRendererPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
handleTextChange : function(text){
|
handleTextChange : function(text){
|
||||||
//If there are errors, run the validator on every change to give quick feedback
|
//If there are errors, run the validator on every change to give quick feedback
|
||||||
let htmlErrors = this.state.htmlErrors;
|
let htmlErrors = this.state.htmlErrors;
|
||||||
@@ -116,7 +130,6 @@ const NewPage = createClass({
|
|||||||
this.setState((prevState)=>({
|
this.setState((prevState)=>({
|
||||||
brew : { ...prevState.brew, text: text },
|
brew : { ...prevState.brew, text: text },
|
||||||
htmlErrors : htmlErrors,
|
htmlErrors : htmlErrors,
|
||||||
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
|
|
||||||
}));
|
}));
|
||||||
localStorage.setItem(BREWKEY, text);
|
localStorage.setItem(BREWKEY, text);
|
||||||
},
|
},
|
||||||
@@ -210,7 +223,7 @@ const NewPage = createClass({
|
|||||||
render : function(){
|
render : function(){
|
||||||
return <div className='newPage sitePage'>
|
return <div className='newPage sitePage'>
|
||||||
{this.renderNavbar()}
|
{this.renderNavbar()}
|
||||||
<div className='content'>
|
<div className="content">
|
||||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||||
<Editor
|
<Editor
|
||||||
ref={this.editor}
|
ref={this.editor}
|
||||||
@@ -221,6 +234,11 @@ const NewPage = createClass({
|
|||||||
renderer={this.state.brew.renderer}
|
renderer={this.state.brew.renderer}
|
||||||
userThemes={this.props.userThemes}
|
userThemes={this.props.userThemes}
|
||||||
snippetBundle={this.state.themeBundle.snippets}
|
snippetBundle={this.state.themeBundle.snippets}
|
||||||
|
onCursorPageChange={this.handleEditorCursorPageChange}
|
||||||
|
onViewPageChange={this.handleEditorViewPageChange}
|
||||||
|
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||||
|
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||||
|
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||||
/>
|
/>
|
||||||
<BrewRenderer
|
<BrewRenderer
|
||||||
text={this.state.brew.text}
|
text={this.state.brew.text}
|
||||||
@@ -230,7 +248,10 @@ const NewPage = createClass({
|
|||||||
themeBundle={this.state.themeBundle}
|
themeBundle={this.state.themeBundle}
|
||||||
errors={this.state.htmlErrors}
|
errors={this.state.htmlErrors}
|
||||||
lang={this.state.brew.lang}
|
lang={this.state.brew.lang}
|
||||||
currentEditorPage={this.state.currentEditorPage}
|
onPageChange={this.handleBrewRendererPageChange}
|
||||||
|
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||||
|
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||||
|
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||||
allowPrint={true}
|
allowPrint={true}
|
||||||
/>
|
/>
|
||||||
</SplitPane>
|
</SplitPane>
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ const SharePage = createClass({
|
|||||||
|
|
||||||
getInitialState : function() {
|
getInitialState : function() {
|
||||||
return {
|
return {
|
||||||
themeBundle : {}
|
themeBundle : {},
|
||||||
|
currentBrewRendererPageNum : 1
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ const SharePage = createClass({
|
|||||||
document.removeEventListener('keydown', this.handleControlKeys);
|
document.removeEventListener('keydown', this.handleControlKeys);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleBrewRendererPageChange : function(pageNumber){
|
||||||
|
this.setState({ currentBrewRendererPageNum: pageNumber });
|
||||||
|
},
|
||||||
|
|
||||||
handleControlKeys : function(e){
|
handleControlKeys : function(e){
|
||||||
if(!(e.ctrlKey || e.metaKey)) return;
|
if(!(e.ctrlKey || e.metaKey)) return;
|
||||||
const P_KEY = 80;
|
const P_KEY = 80;
|
||||||
@@ -114,9 +119,12 @@ const SharePage = createClass({
|
|||||||
<BrewRenderer
|
<BrewRenderer
|
||||||
text={this.props.brew.text}
|
text={this.props.brew.text}
|
||||||
style={this.props.brew.style}
|
style={this.props.brew.style}
|
||||||
|
lang={this.props.brew.lang}
|
||||||
renderer={this.props.brew.renderer}
|
renderer={this.props.brew.renderer}
|
||||||
theme={this.props.brew.theme}
|
theme={this.props.brew.theme}
|
||||||
themeBundle={this.state.themeBundle}
|
themeBundle={this.state.themeBundle}
|
||||||
|
onPageChange={this.handleBrewRendererPageChange}
|
||||||
|
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||||
allowPrint={true}
|
allowPrint={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.sharePage{
|
.sharePage{
|
||||||
.navContent .navSection.titleSection {
|
nav .navSection.titleSection {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
const React = require('react');
|
const React = require('react');
|
||||||
const createClass = require('create-react-class');
|
const { useState } = React;
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
|
|
||||||
const ListPage = require('../basePages/listPage/listPage.jsx');
|
const ListPage = require('../basePages/listPage/listPage.jsx');
|
||||||
|
|
||||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||||
const Navbar = require('../../navbar/navbar.jsx');
|
const Navbar = require('../../navbar/navbar.jsx');
|
||||||
|
|
||||||
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
|
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
|
||||||
const Account = require('../../navbar/account.navitem.jsx');
|
const Account = require('../../navbar/account.navitem.jsx');
|
||||||
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
|
const NewBrew = require('../../navbar/newbrew.navitem.jsx');
|
||||||
@@ -14,69 +13,48 @@ const HelpNavItem = require('../../navbar/help.navitem.jsx');
|
|||||||
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
|
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
|
||||||
const VaultNavitem = require('../../navbar/vault.navitem.jsx');
|
const VaultNavitem = require('../../navbar/vault.navitem.jsx');
|
||||||
|
|
||||||
const UserPage = createClass({
|
const UserPage = (props)=>{
|
||||||
displayName : 'UserPage',
|
props = {
|
||||||
getDefaultProps : function() {
|
|
||||||
return {
|
|
||||||
username : '',
|
username : '',
|
||||||
brews : [],
|
brews : [],
|
||||||
query : '',
|
query : '',
|
||||||
error : null
|
...props
|
||||||
};
|
};
|
||||||
},
|
|
||||||
getInitialState : function() {
|
|
||||||
const usernameWithS = this.props.username + (this.props.username.endsWith('s') ? `’` : `’s`);
|
|
||||||
|
|
||||||
const brews = _.groupBy(this.props.brews, (brew)=>{
|
const [error, setError] = useState(null);
|
||||||
return (brew.published ? 'published' : 'private');
|
|
||||||
});
|
const usernameWithS = props.username + (props.username.endsWith('s') ? `’` : `’s`);
|
||||||
|
const groupedBrews = _.groupBy(props.brews, (brew)=>brew.published ? 'published' : 'private');
|
||||||
|
|
||||||
const brewCollection = [
|
const brewCollection = [
|
||||||
{
|
{
|
||||||
title : `${usernameWithS} published brews`,
|
title : `${usernameWithS} published brews`,
|
||||||
class : 'published',
|
class : 'published',
|
||||||
brews : brews.published
|
brews : groupedBrews.published || []
|
||||||
}
|
},
|
||||||
];
|
...(props.username === global.account?.username ? [{
|
||||||
if(this.props.username == global.account?.username){
|
|
||||||
brewCollection.push(
|
|
||||||
{
|
|
||||||
title : `${usernameWithS} unpublished brews`,
|
title : `${usernameWithS} unpublished brews`,
|
||||||
class : 'unpublished',
|
class : 'unpublished',
|
||||||
brews : brews.private
|
brews : groupedBrews.private || []
|
||||||
}
|
}] : [])
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const navItems = (
|
||||||
brewCollection : brewCollection
|
<Navbar>
|
||||||
};
|
|
||||||
},
|
|
||||||
errorReported : function(error) {
|
|
||||||
this.setState({
|
|
||||||
error
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
navItems : function() {
|
|
||||||
return <Navbar>
|
|
||||||
<Nav.section>
|
<Nav.section>
|
||||||
{this.state.error ?
|
{error && (<ErrorNavItem error={error} parent={null}></ErrorNavItem>)}
|
||||||
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
<NewBrew />
|
<NewBrew />
|
||||||
<HelpNavItem />
|
<HelpNavItem />
|
||||||
<VaultNavitem/>
|
<VaultNavitem />
|
||||||
<RecentNavItem />
|
<RecentNavItem />
|
||||||
<Account />
|
<Account />
|
||||||
</Nav.section>
|
</Nav.section>
|
||||||
</Navbar>;
|
</Navbar>
|
||||||
},
|
);
|
||||||
|
|
||||||
render : function(){
|
return (
|
||||||
return <ListPage brewCollection={this.state.brewCollection} navItems={this.navItems()} query={this.props.query} reportError={this.errorReported}></ListPage>;
|
<ListPage brewCollection={brewCollection} navItems={navItems} query={props.query} reportError={(err)=>setError(err)} />
|
||||||
}
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
module.exports = UserPage;
|
module.exports = UserPage;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/*eslint max-lines: ["warn", {"max": 400, "skipBlankLines": true, "skipComments": true}]*/
|
||||||
|
/*eslint max-params:["warn", { max: 10 }], */
|
||||||
require('./vaultPage.less');
|
require('./vaultPage.less');
|
||||||
|
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
@@ -18,13 +20,15 @@ const request = require('../../utils/request-middleware.js');
|
|||||||
const VaultPage = (props)=>{
|
const VaultPage = (props)=>{
|
||||||
const [pageState, setPageState] = useState(parseInt(props.query.page) || 1);
|
const [pageState, setPageState] = useState(parseInt(props.query.page) || 1);
|
||||||
|
|
||||||
|
const [sortState, setSort] = useState(props.query.sort || 'title');
|
||||||
|
const [dirState, setdir] = useState(props.query.dir || 'asc');
|
||||||
|
|
||||||
//Response state
|
//Response state
|
||||||
const [brewCollection, setBrewCollection] = useState(null);
|
const [brewCollection, setBrewCollection] = useState(null);
|
||||||
const [totalBrews, setTotalBrews] = useState(null);
|
const [totalBrews, setTotalBrews] = useState(null);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
|
||||||
const titleRef = useRef(null);
|
const titleRef = useRef(null);
|
||||||
const authorRef = useRef(null);
|
const authorRef = useRef(null);
|
||||||
const countRef = useRef(null);
|
const countRef = useRef(null);
|
||||||
@@ -34,7 +38,7 @@ const VaultPage = (props)=>{
|
|||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
disableSubmitIfFormInvalid();
|
disableSubmitIfFormInvalid();
|
||||||
loadPage(pageState, true);
|
loadPage(pageState, true, props.query.sort, props.query.dir);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateStateWithBrews = (brews, page)=>{
|
const updateStateWithBrews = (brews, page)=>{
|
||||||
@@ -43,7 +47,7 @@ const VaultPage = (props)=>{
|
|||||||
setSearching(false);
|
setSearching(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUrl = (titleValue, authorValue, countValue, v3Value, legacyValue, page)=>{
|
const updateUrl = (titleValue, authorValue, countValue, v3Value, legacyValue, page, sort, dir)=>{
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const urlParams = new URLSearchParams(url.search);
|
const urlParams = new URLSearchParams(url.search);
|
||||||
|
|
||||||
@@ -53,17 +57,19 @@ const VaultPage = (props)=>{
|
|||||||
urlParams.set('v3', v3Value);
|
urlParams.set('v3', v3Value);
|
||||||
urlParams.set('legacy', legacyValue);
|
urlParams.set('legacy', legacyValue);
|
||||||
urlParams.set('page', page);
|
urlParams.set('page', page);
|
||||||
|
urlParams.set('sort', sort);
|
||||||
|
urlParams.set('dir', dir);
|
||||||
|
|
||||||
url.search = urlParams.toString();
|
url.search = urlParams.toString();
|
||||||
window.history.replaceState(null, '', url.toString());
|
window.history.replaceState(null, '', url.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
const performSearch = async (title, author, count, v3, legacy, page)=>{
|
const performSearch = async (title, author, count, v3, legacy, page, sort, dir)=>{
|
||||||
updateUrl(title, author, count, v3, legacy, page);
|
updateUrl(title, author, count, v3, legacy, page, sort, dir);
|
||||||
|
|
||||||
const response = await request.get(
|
const response = await request
|
||||||
`/api/vault?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}&count=${count}&page=${page}`
|
.get(`/api/vault?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}&count=${count}&page=${page}&sort=${sort}&dir=${dir}`)
|
||||||
).catch((error)=>{
|
.catch((error)=>{
|
||||||
console.log('error at loadPage: ', error);
|
console.log('error at loadPage: ', error);
|
||||||
setError(error);
|
setError(error);
|
||||||
updateStateWithBrews([], 1);
|
updateStateWithBrews([], 1);
|
||||||
@@ -76,9 +82,8 @@ const VaultPage = (props)=>{
|
|||||||
const loadTotal = async (title, author, v3, legacy)=>{
|
const loadTotal = async (title, author, v3, legacy)=>{
|
||||||
setTotalBrews(null);
|
setTotalBrews(null);
|
||||||
|
|
||||||
const response = await request.get(
|
const response = await request.get(`/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}`)
|
||||||
`/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}`
|
.catch((error)=>{
|
||||||
).catch((error)=>{
|
|
||||||
console.log('error at loadTotal: ', error);
|
console.log('error at loadTotal: ', error);
|
||||||
setError(error);
|
setError(error);
|
||||||
updateStateWithBrews([], 1);
|
updateStateWithBrews([], 1);
|
||||||
@@ -88,9 +93,8 @@ const VaultPage = (props)=>{
|
|||||||
setTotalBrews(response.body.totalBrews);
|
setTotalBrews(response.body.totalBrews);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPage = async (page, updateTotal)=>{
|
const loadPage = async (page, updateTotal, sort, dir)=>{
|
||||||
if(!validateForm())
|
if(!validateForm()) return;
|
||||||
return;
|
|
||||||
|
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -100,8 +104,14 @@ const VaultPage = (props)=>{
|
|||||||
const count = countRef.current.value || 10;
|
const count = countRef.current.value || 10;
|
||||||
const v3 = v3Ref.current.checked != false;
|
const v3 = v3Ref.current.checked != false;
|
||||||
const legacy = legacyRef.current.checked != false;
|
const legacy = legacyRef.current.checked != false;
|
||||||
|
const sortOption = sort || 'title';
|
||||||
|
const dirOption = dir || 'asc';
|
||||||
|
const pageProp = page || 1;
|
||||||
|
|
||||||
performSearch(title, author, count, v3, legacy, page);
|
setSort(sortOption);
|
||||||
|
setdir(dirOption);
|
||||||
|
|
||||||
|
performSearch(title, author, count, v3, legacy, pageProp, sortOption, dirOption);
|
||||||
|
|
||||||
if(updateTotal)
|
if(updateTotal)
|
||||||
loadTotal(title, author, v3, legacy);
|
loadTotal(title, author, v3, legacy);
|
||||||
@@ -248,6 +258,33 @@ const VaultPage = (props)=>{
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderSortOption = (optionTitle, optionValue)=>{
|
||||||
|
const oppositeDir = dirState === 'asc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`sort-option ${sortState === optionValue ? `active` : ''}`}>
|
||||||
|
<button onClick={()=>loadPage(1, false, optionValue, oppositeDir)}>
|
||||||
|
{optionTitle}
|
||||||
|
</button>
|
||||||
|
{sortState === optionValue && (
|
||||||
|
<i className={`sortDir fas ${dirState === 'asc' ? 'fa-sort-up' : 'fa-sort-down'}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSortBar = ()=>{
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='sort-container'>
|
||||||
|
{renderSortOption('Title', 'title', props.query.dir)}
|
||||||
|
{renderSortOption('Created Date', 'createdAt', props.query.dir)}
|
||||||
|
{renderSortOption('Updated Date', 'updatedAt', props.query.dir)}
|
||||||
|
{renderSortOption('Views', 'views', props.query.dir)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderPaginationControls = ()=>{
|
const renderPaginationControls = ()=>{
|
||||||
if(!totalBrews) return null;
|
if(!totalBrews) return null;
|
||||||
|
|
||||||
@@ -271,10 +308,8 @@ const VaultPage = (props)=>{
|
|||||||
.map((_, index)=>(
|
.map((_, index)=>(
|
||||||
<a
|
<a
|
||||||
key={startPage + index}
|
key={startPage + index}
|
||||||
className={`pageNumber ${
|
className={`pageNumber ${pageState === startPage + index ? 'currentPage' : ''}`}
|
||||||
pageState === startPage + index ? 'currentPage' : ''
|
onClick={()=>loadPage(startPage + index, false, sortState, dirState)}
|
||||||
}`}
|
|
||||||
onClick={()=>loadPage(startPage + index, false)}
|
|
||||||
>
|
>
|
||||||
{startPage + index}
|
{startPage + index}
|
||||||
</a>
|
</a>
|
||||||
@@ -284,7 +319,7 @@ const VaultPage = (props)=>{
|
|||||||
<div className='paginationControls'>
|
<div className='paginationControls'>
|
||||||
<button
|
<button
|
||||||
className='previousPage'
|
className='previousPage'
|
||||||
onClick={()=>loadPage(pageState - 1, false)}
|
onClick={()=>loadPage(pageState - 1, false, sortState, dirState)}
|
||||||
disabled={pageState === startPage}
|
disabled={pageState === startPage}
|
||||||
>
|
>
|
||||||
<i className='fa-solid fa-chevron-left'></i>
|
<i className='fa-solid fa-chevron-left'></i>
|
||||||
@@ -293,7 +328,7 @@ const VaultPage = (props)=>{
|
|||||||
{startPage > 1 && (
|
{startPage > 1 && (
|
||||||
<a
|
<a
|
||||||
className='pageNumber firstPage'
|
className='pageNumber firstPage'
|
||||||
onClick={()=>loadPage(1, false)}
|
onClick={()=>loadPage(1, false, sortState, dirState)}
|
||||||
>
|
>
|
||||||
1 ...
|
1 ...
|
||||||
</a>
|
</a>
|
||||||
@@ -302,7 +337,7 @@ const VaultPage = (props)=>{
|
|||||||
{endPage < totalPages && (
|
{endPage < totalPages && (
|
||||||
<a
|
<a
|
||||||
className='pageNumber lastPage'
|
className='pageNumber lastPage'
|
||||||
onClick={()=>loadPage(totalPages, false)}
|
onClick={()=>loadPage(totalPages, false, sortState, dirState)}
|
||||||
>
|
>
|
||||||
... {totalPages}
|
... {totalPages}
|
||||||
</a>
|
</a>
|
||||||
@@ -310,7 +345,7 @@ const VaultPage = (props)=>{
|
|||||||
</ol>
|
</ol>
|
||||||
<button
|
<button
|
||||||
className='nextPage'
|
className='nextPage'
|
||||||
onClick={()=>loadPage(pageState + 1, false)}
|
onClick={()=>loadPage(pageState + 1, false, sortState, dirState)}
|
||||||
disabled={pageState === totalPages}
|
disabled={pageState === totalPages}
|
||||||
>
|
>
|
||||||
<i className='fa-solid fa-chevron-right'></i>
|
<i className='fa-solid fa-chevron-right'></i>
|
||||||
@@ -376,15 +411,15 @@ const VaultPage = (props)=>{
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='vaultPage'>
|
<div className='sitePage vaultPage'>
|
||||||
<link href='/themes/V3/Blank/style.css' rel='stylesheet' />
|
<link href='/themes/V3/Blank/style.css' rel='stylesheet' />
|
||||||
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
|
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
|
||||||
{renderNavItems()}
|
{renderNavItems()}
|
||||||
<div className='content'>
|
<div className="content">
|
||||||
<SplitPane showDividerButtons={false}>
|
<SplitPane showDividerButtons={false}>
|
||||||
<div className='form dataGroup'>{renderForm()}</div>
|
<div className='form dataGroup'>{renderForm()}</div>
|
||||||
|
|
||||||
<div className='resultsContainer dataGroup'>
|
<div className='resultsContainer dataGroup'>
|
||||||
|
{renderSortBar()}
|
||||||
{renderFoundBrews()}
|
{renderFoundBrews()}
|
||||||
</div>
|
</div>
|
||||||
</SplitPane>
|
</SplitPane>
|
||||||
|
|||||||
@@ -5,11 +5,7 @@
|
|||||||
|
|
||||||
*:not(input) { user-select : none; }
|
*:not(input) { user-select : none; }
|
||||||
|
|
||||||
.content {
|
.content .dataGroup {
|
||||||
background : #2C3E50;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.dataGroup {
|
|
||||||
width : 100%;
|
width : 100%;
|
||||||
height : 100%;
|
height : 100%;
|
||||||
background : white;
|
background : white;
|
||||||
@@ -27,9 +23,9 @@
|
|||||||
|
|
||||||
code {
|
code {
|
||||||
padding-inline : 5px;
|
padding-inline : 5px;
|
||||||
|
font-family : monospace;
|
||||||
background : lightgrey;
|
background : lightgrey;
|
||||||
border-radius : 5px;
|
border-radius : 5px;
|
||||||
font-family : monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 {
|
||||||
@@ -165,6 +161,48 @@
|
|||||||
color : white;
|
color : white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-container {
|
||||||
|
display : flex;
|
||||||
|
flex-wrap : wrap;
|
||||||
|
column-gap : 15px;
|
||||||
|
justify-content : center;
|
||||||
|
height : 30px;
|
||||||
|
color : white;
|
||||||
|
background-color : #555555;
|
||||||
|
border-top : 1px solid #666666;
|
||||||
|
border-bottom : 1px solid #666666;
|
||||||
|
|
||||||
|
.sort-option {
|
||||||
|
display : flex;
|
||||||
|
align-items : center;
|
||||||
|
padding : 0 8px;
|
||||||
|
|
||||||
|
&:hover { background-color : #444444; }
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color : #333333;
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-weight : 800;
|
||||||
|
color : white;
|
||||||
|
|
||||||
|
& + .sortDir { padding-left : 5px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding : 0;
|
||||||
|
font-size : 11px;
|
||||||
|
font-weight : normal;
|
||||||
|
color : #CCCCCC;
|
||||||
|
text-transform : uppercase;
|
||||||
|
background-color : transparent;
|
||||||
|
|
||||||
|
&:hover { background : none; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.foundBrews {
|
.foundBrews {
|
||||||
position : relative;
|
position : relative;
|
||||||
width : 100%;
|
width : 100%;
|
||||||
@@ -236,15 +274,15 @@
|
|||||||
width : 47%;
|
width : 47%;
|
||||||
margin-right : 40px;
|
margin-right : 40px;
|
||||||
color : black;
|
color : black;
|
||||||
isolation:isolate;
|
isolation : isolate;
|
||||||
|
|
||||||
&:after {
|
&::after {
|
||||||
position:absolute;
|
position : absolute;
|
||||||
inset:0;
|
inset : 0;
|
||||||
display:block;
|
z-index : -2;
|
||||||
content:'';
|
display : block;
|
||||||
|
content : '';
|
||||||
background-image : url('/assets/parchmentBackground.jpg');
|
background-image : url('/assets/parchmentBackground.jpg');
|
||||||
z-index:-1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:nth-child(even of .brewItem) { margin-right : 0; }
|
&:nth-child(even of .brewItem) { margin-right : 0; }
|
||||||
@@ -257,28 +295,24 @@
|
|||||||
color : var(--HB_Color_HeaderText);
|
color : var(--HB_Color_HeaderText);
|
||||||
}
|
}
|
||||||
.info {
|
.info {
|
||||||
|
position : relative;
|
||||||
|
z-index : 2;
|
||||||
font-family : 'ScalySansRemake';
|
font-family : 'ScalySansRemake';
|
||||||
font-size : 1.2em;
|
font-size : 1.2em;
|
||||||
position:relative;
|
|
||||||
z-index:2;
|
|
||||||
|
|
||||||
>span {
|
>span {
|
||||||
margin-right : 12px;
|
margin-right : 12px;
|
||||||
line-height : 1.5em;
|
line-height : 1.5em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.links {
|
.links { z-index : 2; }
|
||||||
z-index:2;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
hr {
|
||||||
margin: 0px;
|
margin : 0px;
|
||||||
visibility: hidden;
|
visibility : hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail {
|
.thumbnail { z-index : -1; }
|
||||||
z-index:1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.paginationControls {
|
.paginationControls {
|
||||||
@@ -333,7 +367,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes trailingDots {
|
@keyframes trailingDots {
|
||||||
@@ -350,7 +384,7 @@
|
|||||||
|
|
||||||
// media query for when the page is smaller than 1079 px in width
|
// media query for when the page is smaller than 1079 px in width
|
||||||
@media screen and (max-width : 1079px) {
|
@media screen and (max-width : 1079px) {
|
||||||
.vaultPage .content {
|
.vaultPage {
|
||||||
|
|
||||||
.dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; }
|
.dataGroup.form .brewLookup { padding : 1px 20px 20px 10px; }
|
||||||
|
|
||||||
|
|||||||
19
client/homebrew/utils/customIDBStore.js
Normal file
19
client/homebrew/utils/customIDBStore.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as IDB from 'idb-keyval/dist/index.js';
|
||||||
|
|
||||||
|
export function initCustomStore(db, store){
|
||||||
|
const createCustomStore = async ()=>IDB.createStore(db, store);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries : async ()=>IDB.entries(await createCustomStore()),
|
||||||
|
keys : async ()=>IDB.keys(await createCustomStore()),
|
||||||
|
values : async ()=>IDB.values(await createCustomStore()),
|
||||||
|
clear : async ()=>IDB.clear(await createCustomStore),
|
||||||
|
get : async (key)=>IDB.get(key, await createCustomStore()),
|
||||||
|
getMany : async (keys)=>IDB.getMany(keys, await createCustomStore()),
|
||||||
|
set : async (key, value)=>IDB.set(key, value, await createCustomStore()),
|
||||||
|
setMany : async (entries)=>IDB.setMany(entries, await createCustomStore()),
|
||||||
|
update : async (key, updateFn)=>IDB.update(key, updateFn, await createCustomStore()),
|
||||||
|
del : async (key)=>IDB.del(key, await createCustomStore()),
|
||||||
|
delMany : async (keys)=>IDB.delMany(keys, await createCustomStore())
|
||||||
|
};
|
||||||
|
};
|
||||||
119
client/homebrew/utils/versionHistory.js
Normal file
119
client/homebrew/utils/versionHistory.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { initCustomStore } from './customIDBStore.js';
|
||||||
|
|
||||||
|
export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY';
|
||||||
|
export const HISTORY_SLOTS = 5;
|
||||||
|
|
||||||
|
// History values in minutes
|
||||||
|
const HISTORY_SAVE_DELAYS = {
|
||||||
|
'0' : 0,
|
||||||
|
'1' : 2,
|
||||||
|
'2' : 10,
|
||||||
|
'3' : 60,
|
||||||
|
'4' : 12 * 60,
|
||||||
|
'5' : 2 * 24 * 60
|
||||||
|
};
|
||||||
|
// const HISTORY_SAVE_DELAYS = {
|
||||||
|
// '0' : 0,
|
||||||
|
// '1' : 1,
|
||||||
|
// '2' : 2,
|
||||||
|
// '3' : 3,
|
||||||
|
// '4' : 4,
|
||||||
|
// '5' : 5
|
||||||
|
// };
|
||||||
|
|
||||||
|
const GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
|
||||||
|
// const GARBAGE_COLLECT_DELAY = 10;
|
||||||
|
|
||||||
|
|
||||||
|
const HB_DB = 'HOMEBREWERY-DB';
|
||||||
|
const HB_STORE = 'HISTORY';
|
||||||
|
|
||||||
|
const IDB = initCustomStore(HB_DB, HB_STORE);
|
||||||
|
|
||||||
|
function getKeyBySlot(brew, slot){
|
||||||
|
// Return a string representing the key for this brew and history slot
|
||||||
|
return `${HISTORY_PREFIX}-${brew.shareId}-${slot}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseBrewForStorage(brew, slot = 0) {
|
||||||
|
// Strip out unneeded object properties
|
||||||
|
// Returns an array of [ key, brew ]
|
||||||
|
const archiveBrew = {
|
||||||
|
title : brew.title,
|
||||||
|
text : brew.text,
|
||||||
|
style : brew.style,
|
||||||
|
version : brew.version,
|
||||||
|
shareId : brew.shareId,
|
||||||
|
savedAt : brew?.savedAt || new Date(),
|
||||||
|
expireAt : new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
archiveBrew.expireAt.setMinutes(archiveBrew.expireAt.getMinutes() + HISTORY_SAVE_DELAYS[slot]);
|
||||||
|
|
||||||
|
const key = getKeyBySlot(brew, slot);
|
||||||
|
|
||||||
|
return [key, archiveBrew];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadHistory(brew){
|
||||||
|
const DEFAULT_HISTORY_ITEM = { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
|
||||||
|
|
||||||
|
const historyKeys = [];
|
||||||
|
|
||||||
|
// Create array of all history keys
|
||||||
|
for (let i = 1; i <= HISTORY_SLOTS; i++){
|
||||||
|
historyKeys.push(getKeyBySlot(brew, i));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load all keys from IDB at once
|
||||||
|
const dataArray = await IDB.getMany(historyKeys);
|
||||||
|
return dataArray.map((data)=>{ return data ?? DEFAULT_HISTORY_ITEM; });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHistory(brew) {
|
||||||
|
const history = await loadHistory(brew);
|
||||||
|
|
||||||
|
// Walk each version position
|
||||||
|
for (let slot = HISTORY_SLOTS - 1; slot >= 0; slot--){
|
||||||
|
const storedVersion = history[slot];
|
||||||
|
|
||||||
|
// If slot has expired, update all lower slots and break
|
||||||
|
if(new Date() >= new Date(storedVersion.expireAt)){
|
||||||
|
|
||||||
|
// Create array of arrays : [ [key1, value1], [key2, value2], ..., [keyN, valueN] ]
|
||||||
|
// to pass to IDB.setMany
|
||||||
|
const historyUpdate = [];
|
||||||
|
|
||||||
|
for (let updateSlot = slot; updateSlot > 0; updateSlot--){
|
||||||
|
// Move data from updateSlot to updateSlot + 1
|
||||||
|
if(!history[updateSlot - 1]?.noData) {
|
||||||
|
historyUpdate.push(parseBrewForStorage(history[updateSlot - 1], updateSlot + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the most recent brew
|
||||||
|
historyUpdate.push(parseBrewForStorage(brew, 1));
|
||||||
|
|
||||||
|
await IDB.setMany(historyUpdate);
|
||||||
|
|
||||||
|
// Break out of data checks because we found an expired value
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function versionHistoryGarbageCollection(){
|
||||||
|
const entries = await IDB.entries();
|
||||||
|
|
||||||
|
const expiredKeys = [];
|
||||||
|
for (const [key, value] of entries){
|
||||||
|
const expireAt = new Date(value.savedAt);
|
||||||
|
expireAt.setMinutes(expireAt.getMinutes() + GARBAGE_COLLECT_DELAY);
|
||||||
|
if(new Date() > expireAt){
|
||||||
|
expiredKeys.push(key);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if(expiredKeys.length > 0){
|
||||||
|
await IDB.delMany(expiredKeys);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -73,3 +73,12 @@
|
|||||||
.fit-width {
|
.fit-width {
|
||||||
mask-image: url('../icons/fit-width.svg');
|
mask-image: url('../icons/fit-width.svg');
|
||||||
}
|
}
|
||||||
|
.single-spread {
|
||||||
|
mask-image: url('../icons/single-spread.svg');
|
||||||
|
}
|
||||||
|
.facing-spread {
|
||||||
|
mask-image: url('../icons/facing-spread.svg');
|
||||||
|
}
|
||||||
|
.flow-spread {
|
||||||
|
mask-image: url('../icons/flow-spread.svg');
|
||||||
|
}
|
||||||
|
|||||||
10
client/icons/facing-spread.svg
Normal file
10
client/icons/facing-spread.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(0.979101,0,0,0.919064,-29.0748,1.98095)">
|
||||||
|
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.979101,0,0,0.919064,23.058,1.98095)">
|
||||||
|
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
24
client/icons/flow-spread.svg
Normal file
24
client/icons/flow-spread.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1.0781,0,0,1.0781,-3.90545,-3.90502)">
|
||||||
|
<g transform="matrix(0.590052,0,0,0.553871,-13.8993,-2.19227)">
|
||||||
|
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.590052,0,0,0.553871,-13.8993,44.3152)">
|
||||||
|
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.590052,0,0,0.553871,17.5184,-2.19227)">
|
||||||
|
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.590052,0,0,0.553871,50.0095,-2.19227)">
|
||||||
|
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.590052,0,0,0.553871,17.5184,44.3152)">
|
||||||
|
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.590052,0,0,0.553871,50.0095,44.3152)">
|
||||||
|
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
7
client/icons/single-spread.svg
Normal file
7
client/icons/single-spread.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1.41826,0,0,1.3313,-26.7845,-19.5573)">
|
||||||
|
<path d="M78.584,16.13C78.584,15.335 78.164,14.69 77.647,14.69L30.632,14.69C30.115,14.69 29.695,15.335 29.695,16.13L29.695,88.365C29.695,89.16 30.115,89.805 30.632,89.805L77.647,89.805C78.164,89.805 78.584,89.16 78.584,88.365L78.584,16.13Z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 777 B |
@@ -6,5 +6,7 @@
|
|||||||
"enable_v3" : true,
|
"enable_v3" : true,
|
||||||
"enable_themes" : true,
|
"enable_themes" : true,
|
||||||
"local_environments" : ["docker", "local"],
|
"local_environments" : ["docker", "local"],
|
||||||
"publicUrl" : "https://homebrewery.naturalcrit.com"
|
"publicUrl" : "https://homebrewery.naturalcrit.com",
|
||||||
|
"hb_images" : null,
|
||||||
|
"hb_fonts" : null
|
||||||
}
|
}
|
||||||
|
|||||||
2480
package-lock.json
generated
2480
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "homebrewery",
|
"name": "homebrewery",
|
||||||
"description": "Create authentic looking D&D homebrews using only markdown",
|
"description": "Create authentic looking D&D homebrews using only markdown",
|
||||||
"version": "3.15.0",
|
"version": "3.16.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"npm": "^10.2.x",
|
"npm": "^10.2.x",
|
||||||
"node": "^20.8.x"
|
"node": "^20.18.x"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git://github.com/naturalcrit/homebrewery.git"
|
"url": "git://github.com/naturalcrit/homebrewery.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev.js",
|
"dev": "node --experimental-require-module scripts/dev.js",
|
||||||
"quick": "node scripts/quick.js",
|
"quick": "node --experimental-require-module scripts/quick.js",
|
||||||
"build": "node scripts/buildHomebrew.js && node scripts/buildAdmin.js",
|
"build": "node --experimental-require-module scripts/buildHomebrew.js && node --experimental-require-module scripts/buildAdmin.js",
|
||||||
"builddev": "node scripts/buildHomebrew.js --dev",
|
"builddev": "node --experimental-require-module scripts/buildHomebrew.js --dev",
|
||||||
"lint": "eslint --fix",
|
"lint": "eslint --fix",
|
||||||
"lint:dry": "eslint",
|
"lint:dry": "eslint",
|
||||||
"stylelint": "stylelint --fix **/*.{less}",
|
"stylelint": "stylelint --fix **/*.{less}",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"test:api-unit": "jest \"server/.*.spec.js\" --verbose",
|
"test:api-unit": "jest \"server/.*.spec.js\" --verbose",
|
||||||
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
|
"test:api-unit:themes": "jest \"server/.*.spec.js\" -t \"theme bundle\" --verbose",
|
||||||
"test:api-unit:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose",
|
"test:api-unit:css": "jest \"server/.*.spec.js\" -t \"Get CSS\" --verbose",
|
||||||
|
"test:api-unit:notifications": "jest \"server/.*.spec.js\" -t \"Notifications\" --verbose",
|
||||||
"test:coverage": "jest --coverage --silent --runInBand",
|
"test:coverage": "jest --coverage --silent --runInBand",
|
||||||
"test:dev": "jest --verbose --watch",
|
"test:dev": "jest --verbose --watch",
|
||||||
"test:basic": "jest tests/markdown/basic.test.js --verbose",
|
"test:basic": "jest tests/markdown/basic.test.js --verbose",
|
||||||
@@ -37,10 +38,11 @@
|
|||||||
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
|
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
|
||||||
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
|
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
|
||||||
"test:route": "jest tests/routes/static-pages.test.js --verbose",
|
"test:route": "jest tests/routes/static-pages.test.js --verbose",
|
||||||
"phb": "node scripts/phb.js",
|
"test:safehtml": "jest tests/html/safeHTML.test.js --verbose",
|
||||||
|
"phb": "node --experimental-require-module scripts/phb.js",
|
||||||
"prod": "set NODE_ENV=production && npm run build",
|
"prod": "set NODE_ENV=production && npm run build",
|
||||||
"postinstall": "npm run build",
|
"postinstall": "npm run build",
|
||||||
"start": "node server.js"
|
"start": "node --experimental-require-module server.js"
|
||||||
},
|
},
|
||||||
"author": "stolksdorf",
|
"author": "stolksdorf",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -85,24 +87,24 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.26.0",
|
||||||
"@babel/plugin-transform-runtime": "^7.25.4",
|
"@babel/plugin-transform-runtime": "^7.25.9",
|
||||||
"@babel/preset-env": "^7.25.4",
|
"@babel/preset-env": "^7.26.0",
|
||||||
"@babel/preset-react": "^7.24.7",
|
"@babel/preset-react": "^7.25.9",
|
||||||
"@googleapis/drive": "^8.14.0",
|
"@googleapis/drive": "^8.14.0",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"codemirror": "^5.65.6",
|
"codemirror": "^5.65.6",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.7",
|
||||||
"create-react-class": "^15.7.0",
|
"create-react-class": "^15.7.0",
|
||||||
"dedent-tabs": "^0.10.3",
|
"dedent-tabs": "^0.10.3",
|
||||||
"dompurify": "^3.1.6",
|
"dompurify": "^3.1.7",
|
||||||
"expr-eval": "^2.0.2",
|
"expr-eval": "^2.0.2",
|
||||||
"express": "^4.19.2",
|
"express": "^4.21.1",
|
||||||
"express-async-handler": "^1.2.0",
|
"express-async-handler": "^1.2.0",
|
||||||
"express-rate-limit": "^7.4.0",
|
"express-static-gzip": "2.1.8",
|
||||||
"express-static-gzip": "2.1.7",
|
|
||||||
"fs-extra": "11.2.0",
|
"fs-extra": "11.2.0",
|
||||||
|
"idb-keyval": "^6.2.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jwt-simple": "^0.5.6",
|
"jwt-simple": "^0.5.6",
|
||||||
"less": "^3.13.1",
|
"less": "^3.13.1",
|
||||||
@@ -114,28 +116,29 @@
|
|||||||
"marked-smartypants-lite": "^1.0.2",
|
"marked-smartypants-lite": "^1.0.2",
|
||||||
"markedLegacy": "npm:marked@^0.3.19",
|
"markedLegacy": "npm:marked@^0.3.19",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mongoose": "^8.6.1",
|
"mongoose": "^8.7.3",
|
||||||
"nanoid": "3.3.4",
|
"nanoid": "3.3.4",
|
||||||
"nconf": "^0.12.1",
|
"nconf": "^0.12.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-frame-component": "^4.1.3",
|
"react-frame-component": "^4.1.3",
|
||||||
"react-router-dom": "6.26.1",
|
"react-router-dom": "6.28.0",
|
||||||
"sanitize-filename": "1.6.3",
|
"sanitize-filename": "1.6.3",
|
||||||
"superagent": "^10.1.0",
|
"superagent": "^10.1.1",
|
||||||
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
|
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@stylistic/stylelint-plugin": "^3.0.1",
|
"@stylistic/stylelint-plugin": "^3.1.1",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.14.0",
|
||||||
"eslint-plugin-jest": "^28.8.3",
|
"eslint-plugin-jest": "^28.9.0",
|
||||||
"eslint-plugin-react": "^7.35.2",
|
"eslint-plugin-react": "^7.37.2",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.12.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-expect-message": "^1.1.3",
|
"jest-expect-message": "^1.1.3",
|
||||||
|
"jsdom-global": "^3.0.2",
|
||||||
"postcss-less": "^6.0.0",
|
"postcss-less": "^6.0.0",
|
||||||
"stylelint": "^16.9.0",
|
"stylelint": "^16.10.0",
|
||||||
"stylelint-config-recess-order": "^5.1.0",
|
"stylelint-config-recess-order": "^5.1.1",
|
||||||
"stylelint-config-recommended": "^14.0.1",
|
"stylelint-config-recommended": "^14.0.1",
|
||||||
"supertest": "^7.0.0"
|
"supertest": "^7.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
const HomebrewModel = require('./homebrew.model.js').model;
|
const HomebrewModel = require('./homebrew.model.js').model;
|
||||||
|
const NotificationModel = require('./notifications.model.js').model;
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const Moment = require('moment');
|
const Moment = require('moment');
|
||||||
//const render = require('vitreum/steps/render');
|
|
||||||
const templateFn = require('../client/template.js');
|
const templateFn = require('../client/template.js');
|
||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
const HomebrewAPI = require('./homebrew.api.js');
|
||||||
|
const asyncHandler = require('express-async-handler');
|
||||||
|
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
|
||||||
|
|
||||||
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
||||||
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
|
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
|
||||||
|
|
||||||
@@ -22,7 +26,7 @@ const mw = {
|
|||||||
if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){
|
if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
return res.status(401).send('Access denied');
|
throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,23 +70,8 @@ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* Searches for matching edit or share id, also attempts to partial match */
|
/* Searches for matching edit or share id, also attempts to partial match */
|
||||||
router.get('/admin/lookup/:id', mw.adminOnly, async (req, res, next)=>{
|
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
|
||||||
HomebrewModel.findOne({
|
return res.json(req.brew);
|
||||||
$or : [
|
|
||||||
{ editId: { $regex: req.params.id, $options: 'i' } },
|
|
||||||
{ shareId: { $regex: req.params.id, $options: 'i' } },
|
|
||||||
]
|
|
||||||
}).exec()
|
|
||||||
.then((brew)=>{
|
|
||||||
if(!brew) // No document found
|
|
||||||
return res.status(404).json({ error: 'Document not found' });
|
|
||||||
else
|
|
||||||
return res.json(brew);
|
|
||||||
})
|
|
||||||
.catch((err)=>{
|
|
||||||
console.error(err);
|
|
||||||
return res.status(500).json({ error: 'Internal Server Error' });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Find 50 brews that aren't compressed yet */
|
/* Find 50 brews that aren't compressed yet */
|
||||||
@@ -100,6 +89,25 @@ router.get('/admin/finduncompressed', mw.adminOnly, (req, res)=>{
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Cleans `<script` and `</script>` from the "text" field of a brew */
|
||||||
|
router.put('/admin/clean/script/:id', asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res)=>{
|
||||||
|
console.log(`[ADMIN] Cleaning script tags from ShareID ${req.params.id}`);
|
||||||
|
|
||||||
|
function cleanText(text){return text.replaceAll(/(<\/?s)cript/gi, '');};
|
||||||
|
|
||||||
|
const brew = req.brew;
|
||||||
|
|
||||||
|
const properties = ['text', 'description', 'title'];
|
||||||
|
properties.forEach((property)=>{
|
||||||
|
brew[property] = cleanText(brew[property]);
|
||||||
|
});
|
||||||
|
|
||||||
|
splitTextStyleAndMetadata(brew);
|
||||||
|
|
||||||
|
req.body = brew;
|
||||||
|
|
||||||
|
return await HomebrewAPI.updateBrew(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
/* Compresses the "text" field of a brew to binary */
|
/* Compresses the "text" field of a brew to binary */
|
||||||
router.put('/admin/compress/:id', (req, res)=>{
|
router.put('/admin/compress/:id', (req, res)=>{
|
||||||
@@ -138,12 +146,48 @@ router.get('/admin/stats', mw.adminOnly, async (req, res)=>{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ####################### NOTIFICATIONS
|
||||||
|
|
||||||
|
router.get('/admin/notification/all', async (req, res, next)=>{
|
||||||
|
try {
|
||||||
|
const notifications = await NotificationModel.getAll();
|
||||||
|
return res.json(notifications);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error getting all notifications: ', error.message);
|
||||||
|
return res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/admin/notification/add', mw.adminOnly, async (req, res, next)=>{
|
||||||
|
try {
|
||||||
|
const notification = await NotificationModel.addNotification(req.body);
|
||||||
|
return res.status(201).json(notification);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error adding notification: ', error.message);
|
||||||
|
return res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/admin/notification/delete/:id', mw.adminOnly, async (req, res, next)=>{
|
||||||
|
try {
|
||||||
|
const notification = await NotificationModel.deleteNotification(req.params.id);
|
||||||
|
return res.json(notification);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting notification: { key: ', req.params.id, ' error: ', error.message, ' }');
|
||||||
|
return res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/admin', mw.adminOnly, (req, res)=>{
|
router.get('/admin', mw.adminOnly, (req, res)=>{
|
||||||
templateFn('admin', {
|
templateFn('admin', {
|
||||||
url : req.originalUrl
|
url : req.originalUrl
|
||||||
})
|
})
|
||||||
.then((page)=>res.send(page))
|
.then((page)=>res.send(page))
|
||||||
.catch((err)=>res.sendStatus(500));
|
.catch((err)=>{
|
||||||
|
console.log(err);
|
||||||
|
res.sendStatus(500);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
116
server/admin.api.spec.js
Normal file
116
server/admin.api.spec.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
const supertest = require('supertest');
|
||||||
|
|
||||||
|
const app = supertest.agent(require('app.js').app)
|
||||||
|
.set('X-Forwarded-Proto', 'https');
|
||||||
|
|
||||||
|
const NotificationModel = require('./notifications.model.js').model;
|
||||||
|
|
||||||
|
describe('Tests for admin api', ()=>{
|
||||||
|
afterEach(()=>{
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notifications', ()=>{
|
||||||
|
it('should return list of all notifications', async ()=>{
|
||||||
|
const testNotifications = ['a', 'b'];
|
||||||
|
|
||||||
|
jest.spyOn(NotificationModel, 'find')
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
return { exec: jest.fn().mockResolvedValue(testNotifications) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app
|
||||||
|
.get('/admin/notification/all')
|
||||||
|
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual(testNotifications);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a new notification', async ()=>{
|
||||||
|
const inputNotification = {
|
||||||
|
title : 'Test Notification',
|
||||||
|
text : 'This is a test notification',
|
||||||
|
startAt : new Date().toISOString(),
|
||||||
|
stopAt : new Date().toISOString(),
|
||||||
|
dismissKey : 'testKey'
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedNotification = {
|
||||||
|
...inputNotification,
|
||||||
|
_id : expect.any(String),
|
||||||
|
createdAt : expect.any(String),
|
||||||
|
startAt : inputNotification.startAt,
|
||||||
|
stopAt : inputNotification.stopAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(NotificationModel.prototype, 'save')
|
||||||
|
.mockImplementationOnce(function() {
|
||||||
|
return Promise.resolve(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app
|
||||||
|
.post('/admin/notification/add')
|
||||||
|
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`)
|
||||||
|
.send(inputNotification);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body).toEqual(savedNotification);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error adding a notification without dismissKey', async () => {
|
||||||
|
const inputNotification = {
|
||||||
|
title : 'Test Notification',
|
||||||
|
text : 'This is a test notification',
|
||||||
|
startAt : new Date().toISOString(),
|
||||||
|
stopAt : new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
//Change 'save' function to just return itself instead of actually interacting with the database
|
||||||
|
jest.spyOn(NotificationModel.prototype, 'save')
|
||||||
|
.mockImplementationOnce(function() {
|
||||||
|
return Promise.resolve(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app
|
||||||
|
.post('/admin/notification/add')
|
||||||
|
.set('Authorization', 'Basic ' + Buffer.from('admin:password3').toString('base64'))
|
||||||
|
.send(inputNotification);
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ message: 'Dismiss key is required!' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a notification based on its dismiss key', async ()=>{
|
||||||
|
const dismissKey = 'testKey';
|
||||||
|
|
||||||
|
jest.spyOn(NotificationModel, 'findOneAndDelete')
|
||||||
|
.mockImplementationOnce((key) => {
|
||||||
|
return { exec: jest.fn().mockResolvedValue(key) };
|
||||||
|
});
|
||||||
|
const response = await app
|
||||||
|
.delete(`/admin/notification/delete/${dismissKey}`)
|
||||||
|
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
|
||||||
|
|
||||||
|
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({'dismissKey': 'testKey'});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ dismissKey: 'testKey' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error deleting a notification that doesnt exist', async ()=>{
|
||||||
|
const dismissKey = 'testKey';
|
||||||
|
|
||||||
|
jest.spyOn(NotificationModel, 'findOneAndDelete')
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
return { exec: jest.fn().mockResolvedValue() };
|
||||||
|
});
|
||||||
|
const response = await app
|
||||||
|
.delete(`/admin/notification/delete/${dismissKey}`)
|
||||||
|
.set('Authorization', `Basic ${Buffer.from('admin:password3').toString('base64')}`);
|
||||||
|
|
||||||
|
expect(NotificationModel.findOneAndDelete).toHaveBeenCalledWith({'dismissKey': 'testKey'});
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ message: 'Notification not found' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ const express = require('express');
|
|||||||
const yaml = require('js-yaml');
|
const yaml = require('js-yaml');
|
||||||
const app = express();
|
const app = express();
|
||||||
const config = require('./config.js');
|
const config = require('./config.js');
|
||||||
|
const fs = require('fs-extra');
|
||||||
|
|
||||||
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
|
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
|
||||||
const GoogleActions = require('./googleActions.js');
|
const GoogleActions = require('./googleActions.js');
|
||||||
@@ -31,8 +32,6 @@ const sanitizeBrew = (brew, accessType)=>{
|
|||||||
};
|
};
|
||||||
|
|
||||||
app.set('trust proxy', 1 /* number of proxies between user and server */)
|
app.set('trust proxy', 1 /* number of proxies between user and server */)
|
||||||
app.get('/ip', (request, response) => response.send(request.ip))
|
|
||||||
|
|
||||||
|
|
||||||
app.use('/', serveCompressedStaticAssets(`build`));
|
app.use('/', serveCompressedStaticAssets(`build`));
|
||||||
app.use(require('./middleware/content-negotiation.js'));
|
app.use(require('./middleware/content-negotiation.js'));
|
||||||
@@ -206,6 +205,23 @@ app.get('/download/:id', asyncHandler(getBrew('share')), (req, res)=>{
|
|||||||
res.status(200).send(brew.text);
|
res.status(200).send(brew.text);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Serve brew metadata
|
||||||
|
app.get('/metadata/:id', asyncHandler(getBrew('share')), (req, res)=>{
|
||||||
|
const { brew } = req;
|
||||||
|
sanitizeBrew(brew, 'share');
|
||||||
|
|
||||||
|
const fields = ['title', 'pageCount', 'description', 'authors', 'lang',
|
||||||
|
'published', 'views', 'shareId', 'createdAt', 'updatedAt',
|
||||||
|
'lastViewed', 'thumbnail', 'tags'
|
||||||
|
];
|
||||||
|
|
||||||
|
const metadata = fields.reduce((acc, field)=>{
|
||||||
|
if(brew[field] !== undefined) acc[field] = brew[field];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
res.status(200).json(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
//Serve brew styling
|
//Serve brew styling
|
||||||
app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
|
app.get('/css/:id', asyncHandler(getBrew('share')), (req, res)=>{getCSS(req, res);});
|
||||||
|
|
||||||
@@ -431,8 +447,16 @@ if(isLocalEnvironment){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Static Local Paths
|
||||||
|
app.use('/staticImages', express.static(config.get('hb_images') && fs.existsSync(config.get('hb_images')) ? config.get('hb_images') :'staticImages'));
|
||||||
|
app.use('/staticFonts', express.static(config.get('hb_fonts') && fs.existsSync(config.get('hb_fonts')) ? config.get('hb_fonts'):'staticFonts'));
|
||||||
|
|
||||||
//Vault Page
|
//Vault Page
|
||||||
app.get('/vault', asyncHandler(async(req, res, next)=>{
|
app.get('/vault', asyncHandler(async(req, res, next)=>{
|
||||||
|
req.ogMeta = { ...defaultMetaTags,
|
||||||
|
title : 'The Vault',
|
||||||
|
description : 'Search for Brews'
|
||||||
|
};
|
||||||
return next();
|
return next();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -451,7 +475,8 @@ const renderPage = async (req, res)=>{
|
|||||||
const configuration = {
|
const configuration = {
|
||||||
local : isLocalEnvironment,
|
local : isLocalEnvironment,
|
||||||
publicUrl : config.get('publicUrl') ?? '',
|
publicUrl : config.get('publicUrl') ?? '',
|
||||||
environment : nodeEnv
|
environment : nodeEnv,
|
||||||
|
deployment : config.get('heroku_app_name') ?? ''
|
||||||
};
|
};
|
||||||
const props = {
|
const props = {
|
||||||
version : require('./../package.json').version,
|
version : require('./../package.json').version,
|
||||||
|
|||||||
@@ -154,9 +154,8 @@ const GoogleActions = {
|
|||||||
return brews;
|
return brews;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateGoogleBrew : async (brew, auth = defaultAuth, userIp)=>{
|
updateGoogleBrew : async (brew, userIp)=>{
|
||||||
const drive = googleDrive.drive({ version: 'v3', auth: auth });
|
const drive = googleDrive.drive({ version: 'v3', auth: defaultAuth });
|
||||||
console.log(auth == defaultAuth ? 'UPDATE w SERVICEACC' : 'UPDATE w USERACC')
|
|
||||||
|
|
||||||
await drive.files.update({
|
await drive.files.update({
|
||||||
fileId : brew.googleId,
|
fileId : brew.googleId,
|
||||||
@@ -222,7 +221,6 @@ const GoogleActions = {
|
|||||||
})
|
})
|
||||||
.catch((err)=>{
|
.catch((err)=>{
|
||||||
console.log('Error while creating new Google brew');
|
console.log('Error while creating new Google brew');
|
||||||
console.error(err);
|
|
||||||
throw (err);
|
throw (err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const yaml = require('js-yaml');
|
|||||||
const asyncHandler = require('express-async-handler');
|
const asyncHandler = require('express-async-handler');
|
||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
|
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
|
||||||
const rateLimit = require('express-rate-limit');
|
|
||||||
|
|
||||||
const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
|
const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
|
||||||
|
|
||||||
@@ -88,8 +87,18 @@ const api = {
|
|||||||
// Get relevant IDs for the brew
|
// Get relevant IDs for the brew
|
||||||
const { id, googleId } = api.getId(req);
|
const { id, googleId } = api.getId(req);
|
||||||
|
|
||||||
|
const accessMap = {
|
||||||
|
edit : { editId: id },
|
||||||
|
share : { shareId: id },
|
||||||
|
admin : {
|
||||||
|
$or : [
|
||||||
|
{ editId: id },
|
||||||
|
{ shareId: id },
|
||||||
|
] }
|
||||||
|
};
|
||||||
|
|
||||||
// Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine.
|
// Try to find the document in the Homebrewery database -- if it doesn't exist, that's fine.
|
||||||
let stub = await HomebrewModel.get(accessType === 'edit' ? { editId: id } : { shareId: id })
|
let stub = await HomebrewModel.get(accessMap[accessType])
|
||||||
.catch((err)=>{
|
.catch((err)=>{
|
||||||
if(googleId) {
|
if(googleId) {
|
||||||
console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
|
console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
|
||||||
@@ -296,9 +305,8 @@ const api = {
|
|||||||
|
|
||||||
req.params.id = currentTheme.theme;
|
req.params.id = currentTheme.theme;
|
||||||
req.params.renderer = currentTheme.renderer;
|
req.params.renderer = currentTheme.renderer;
|
||||||
}
|
} else {
|
||||||
//=== Static Themes ===//
|
//=== Static Themes ===//
|
||||||
else {
|
|
||||||
const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client
|
const localSnippets = `${req.params.renderer}_${req.params.id}`; // Just log the name for loading on client
|
||||||
const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`;
|
const localStyle = `@import url(\"/themes/${req.params.renderer}/${req.params.id}/style.css\");`;
|
||||||
completeSnippets.push(localSnippets);
|
completeSnippets.push(localSnippets);
|
||||||
@@ -354,7 +362,7 @@ const api = {
|
|||||||
if(!brew.googleId) return;
|
if(!brew.googleId) return;
|
||||||
} else if(brew.googleId) {
|
} else if(brew.googleId) {
|
||||||
// If the google id exists and no other actions are being performed, update the google brew
|
// If the google id exists and no other actions are being performed, update the google brew
|
||||||
const updated = await api.updateGoogleBrew(req.account, api.excludeGoogleProps(brew), res, req);
|
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew), req.ip);
|
||||||
|
|
||||||
if(!updated) return;
|
if(!updated) return;
|
||||||
}
|
}
|
||||||
@@ -398,15 +406,6 @@ const api = {
|
|||||||
|
|
||||||
res.status(200).send(saved);
|
res.status(200).send(saved);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateGoogleBrew : async (account, brew, res, req)=>{
|
|
||||||
//let oAuth2Client;
|
|
||||||
//if(account.googleId)
|
|
||||||
// oAuth2Client = GoogleActions.authCheck(account, res);
|
|
||||||
|
|
||||||
return await GoogleActions.updateGoogleBrew(brew, undefined, req.ip);
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteGoogleBrew : async (account, id, editId, res)=>{
|
deleteGoogleBrew : async (account, id, editId, res)=>{
|
||||||
const auth = await GoogleActions.authCheck(account, res);
|
const auth = await GoogleActions.authCheck(account, res);
|
||||||
await GoogleActions.deleteGoogleBrew(auth, id, editId);
|
await GoogleActions.deleteGoogleBrew(auth, id, editId);
|
||||||
|
|||||||
@@ -924,7 +924,7 @@ brew`);
|
|||||||
expect(req.brew).toEqual(testBrew);
|
expect(req.brew).toEqual(testBrew);
|
||||||
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
|
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
|
||||||
expect(res.status).toHaveBeenCalledWith(200);
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
expect(res.send).toHaveBeenCalledWith("\nI Have a style!\n");
|
expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n');
|
||||||
expect(res.set).toHaveBeenCalledWith({
|
expect(res.set).toHaveBeenCalledWith({
|
||||||
'Cache-Control' : 'no-cache',
|
'Cache-Control' : 'no-cache',
|
||||||
'Content-Type' : 'text/css'
|
'Content-Type' : 'text/css'
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
const config = require('../config.js');
|
||||||
|
const nodeEnv = config.get('node_env');
|
||||||
|
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
|
||||||
|
|
||||||
module.exports = (req, res, next)=>{
|
module.exports = (req, res, next)=>{
|
||||||
const isImageRequest = req.get('Accept')?.split(',')
|
const isImageRequest = req.get('Accept')?.split(',')
|
||||||
?.filter((h)=>!h.includes('q='))
|
?.filter((h)=>!h.includes('q='))
|
||||||
?.every((h)=>/image\/.*/.test(h));
|
?.every((h)=>/image\/.*/.test(h));
|
||||||
if(isImageRequest) {
|
if(isImageRequest && !isLocalEnvironment && !req.url?.startsWith('/staticImages')) {
|
||||||
return res.status(406).send({
|
return res.status(406).send({
|
||||||
message : 'Request for image at this URL is not supported'
|
message : 'Request for image at this URL is not supported'
|
||||||
});
|
});
|
||||||
|
|||||||
62
server/notifications.model.js
Normal file
62
server/notifications.model.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const _ = require('lodash');
|
||||||
|
|
||||||
|
const NotificationSchema = new mongoose.Schema({
|
||||||
|
dismissKey : { type: String, unique: true, required: true },
|
||||||
|
title : { type: String, default: '' },
|
||||||
|
text : { type: String, default: '' },
|
||||||
|
createdAt : { type: Date, default: Date.now },
|
||||||
|
startAt : { type: Date, default: Date.now },
|
||||||
|
stopAt : { type: Date, default: Date.now },
|
||||||
|
}, { versionKey: false });
|
||||||
|
|
||||||
|
NotificationSchema.statics.addNotification = async function(data) {
|
||||||
|
if(!data.dismissKey) throw { message: 'Dismiss key is required!' };
|
||||||
|
|
||||||
|
const defaults = {
|
||||||
|
title : '',
|
||||||
|
text : '',
|
||||||
|
startAt : new Date(),
|
||||||
|
stopAt : new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationData = _.defaults(data, defaults);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newNotification = new this(notificationData);
|
||||||
|
const savedNotification = await newNotification.save();
|
||||||
|
return savedNotification;
|
||||||
|
} catch (err) {
|
||||||
|
throw { message: err.message || 'Error saving notification' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
NotificationSchema.statics.deleteNotification = async function(dismissKey) {
|
||||||
|
if(!dismissKey) throw { message: 'Dismiss key is required!' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletedNotification = await this.findOneAndDelete({ dismissKey }).exec();
|
||||||
|
if(!deletedNotification) {
|
||||||
|
throw { message: 'Notification not found' };
|
||||||
|
}
|
||||||
|
return deletedNotification;
|
||||||
|
} catch (err) {
|
||||||
|
throw { message: err.message || 'Error deleting notification' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
NotificationSchema.statics.getAll = async function() {
|
||||||
|
try {
|
||||||
|
const notifications = await this.find().exec();
|
||||||
|
return notifications;
|
||||||
|
} catch (err) {
|
||||||
|
throw { message: err.message || 'Error retrieving notifications' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Notification = mongoose.model('Notification', NotificationSchema);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
schema : NotificationSchema,
|
||||||
|
model : Notification,
|
||||||
|
};
|
||||||
@@ -29,12 +29,18 @@ const rendererConditions = (legacy, v3)=>{
|
|||||||
return {}; // If all renderers selected, renderer field not needed in query for speed
|
return {}; // If all renderers selected, renderer field not needed in query for speed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sortConditions = (sort, dir) => {
|
||||||
|
return { [sort]: dir === 'asc' ? 1 : -1 };
|
||||||
|
};
|
||||||
|
|
||||||
const findBrews = async (req, res)=>{
|
const findBrews = async (req, res)=>{
|
||||||
const title = req.query.title || '';
|
const title = req.query.title || '';
|
||||||
const author = req.query.author || '';
|
const author = req.query.author || '';
|
||||||
const page = Math.max(parseInt(req.query.page) || 1, 1);
|
const page = Math.max(parseInt(req.query.page) || 1, 1);
|
||||||
const count = Math.max(parseInt(req.query.count) || 20, 10);
|
const count = Math.max(parseInt(req.query.count) || 20, 10);
|
||||||
const skip = (page - 1) * count;
|
const skip = (page - 1) * count;
|
||||||
|
const sort = req.query.sort || 'title';
|
||||||
|
const dir = req.query.dir || 'asc';
|
||||||
|
|
||||||
const combinedQuery = {
|
const combinedQuery = {
|
||||||
$and : [
|
$and : [
|
||||||
@@ -54,6 +60,7 @@ const findBrews = async (req, res)=>{
|
|||||||
};
|
};
|
||||||
|
|
||||||
await HomebrewModel.find(combinedQuery, projection)
|
await HomebrewModel.find(combinedQuery, projection)
|
||||||
|
.sort(sortConditions(sort, dir))
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(count)
|
.limit(count)
|
||||||
.maxTimeMS(5000)
|
.maxTimeMS(5000)
|
||||||
|
|||||||
@@ -397,6 +397,11 @@ const CodeEditor = createClass({
|
|||||||
getCursorPosition : function(){
|
getCursorPosition : function(){
|
||||||
return this.codeMirror.getCursor();
|
return this.codeMirror.getCursor();
|
||||||
},
|
},
|
||||||
|
getTopVisibleLine : function(){
|
||||||
|
const rect = this.codeMirror.getWrapperElement().getBoundingClientRect();
|
||||||
|
const topVisibleLine = this.codeMirror.lineAtHeight(rect.top, 'window');
|
||||||
|
return topVisibleLine;
|
||||||
|
},
|
||||||
updateSize : function(){
|
updateSize : function(){
|
||||||
this.codeMirror.refresh();
|
this.codeMirror.refresh();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,16 +105,16 @@ renderer.link = function (href, title, text) {
|
|||||||
// Expose `src` attribute as `--HB_src` to make the URL accessible via CSS
|
// Expose `src` attribute as `--HB_src` to make the URL accessible via CSS
|
||||||
renderer.image = function (href, title, text) {
|
renderer.image = function (href, title, text) {
|
||||||
href = cleanUrl(href);
|
href = cleanUrl(href);
|
||||||
if (href === null)
|
if(href === null)
|
||||||
return text;
|
return text;
|
||||||
|
|
||||||
let out = `<img src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
|
let out = `<img src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
|
||||||
if (title)
|
if(title)
|
||||||
out += ` title="${title}"`;
|
out += ` title="${title}"`;
|
||||||
|
|
||||||
out += '>';
|
out += '>';
|
||||||
return out;
|
return out;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Disable default reflink behavior, as it steps on our variables extension
|
// Disable default reflink behavior, as it steps on our variables extension
|
||||||
tokenizer.def = function () {
|
tokenizer.def = function () {
|
||||||
@@ -745,7 +745,7 @@ const tableTerminators = [
|
|||||||
`:+\\n`, // hardBreak
|
`:+\\n`, // hardBreak
|
||||||
` *{[^\n]+}`, // blockInjector
|
` *{[^\n]+}`, // blockInjector
|
||||||
` *{{[^{\n]*\n.*?\n}}` // mustacheDiv
|
` *{{[^{\n]*\n.*?\n}}` // mustacheDiv
|
||||||
]
|
];
|
||||||
|
|
||||||
Marked.use(MarkedVariables());
|
Marked.use(MarkedVariables());
|
||||||
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
|
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ const Nav = {
|
|||||||
displayName : 'Nav.base',
|
displayName : 'Nav.base',
|
||||||
render : function(){
|
render : function(){
|
||||||
return <nav>
|
return <nav>
|
||||||
<div className='navContent'>
|
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</div>
|
|
||||||
</nav>;
|
</nav>;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,185 +1,110 @@
|
|||||||
require('./splitPane.less');
|
require('./splitPane.less');
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const createClass = require('create-react-class');
|
const { useState, useEffect } = React;
|
||||||
const cx = require('classnames');
|
|
||||||
|
|
||||||
const SplitPane = createClass({
|
const storageKey = 'naturalcrit-pane-split';
|
||||||
displayName : 'SplitPane',
|
|
||||||
getDefaultProps : function() {
|
const SplitPane = (props)=>{
|
||||||
return {
|
const {
|
||||||
storageKey : 'naturalcrit-pane-split',
|
onDragFinish = ()=>{},
|
||||||
onDragFinish : function(){}, //fires when dragging
|
showDividerButtons = true
|
||||||
showDividerButtons : true
|
} = props;
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dividerPos, setDividerPos] = useState(null);
|
||||||
|
const [moveSource, setMoveSource] = useState(false);
|
||||||
|
const [moveBrew, setMoveBrew] = useState(false);
|
||||||
|
const [showMoveArrows, setShowMoveArrows] = useState(true);
|
||||||
|
const [liveScroll, setLiveScroll] = useState(false);
|
||||||
|
|
||||||
|
useEffect(()=>{
|
||||||
|
const savedPos = window.localStorage.getItem(storageKey);
|
||||||
|
setDividerPos(savedPos ? limitPosition(savedPos, 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)) : window.innerWidth / 2);
|
||||||
|
setLiveScroll(window.localStorage.getItem('liveScroll') === 'true');
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return ()=>window.removeEventListener('resize', handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
|
||||||
|
|
||||||
|
//when resizing, the divider should grow smaller if less space is given, then grow back if the space is restored, to the original position
|
||||||
|
const handleResize = () =>setDividerPos(limitPosition(window.localStorage.getItem(storageKey), 0.1 * (window.innerWidth - 13), 0.9 * (window.innerWidth - 13)));
|
||||||
|
|
||||||
|
const handleUp =(e)=>{
|
||||||
|
e.preventDefault();
|
||||||
|
if(isDragging) {
|
||||||
|
onDragFinish(dividerPos);
|
||||||
|
window.localStorage.setItem(storageKey, dividerPos);
|
||||||
|
}
|
||||||
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
},
|
|
||||||
|
|
||||||
getInitialState : function() {
|
const handleDown = (e)=>{
|
||||||
return {
|
e.preventDefault();
|
||||||
currentDividerPos : null,
|
setIsDragging(true);
|
||||||
windowWidth : 0,
|
|
||||||
isDragging : false,
|
|
||||||
moveSource : false,
|
|
||||||
moveBrew : false,
|
|
||||||
showMoveArrows : true
|
|
||||||
};
|
};
|
||||||
},
|
|
||||||
|
|
||||||
pane1 : React.createRef(null),
|
const handleMove = (e)=>{
|
||||||
pane2 : React.createRef(null),
|
if(!isDragging) return;
|
||||||
|
|
||||||
componentDidMount : function() {
|
|
||||||
const dividerPos = window.localStorage.getItem(this.props.storageKey);
|
|
||||||
if(dividerPos){
|
|
||||||
this.setState({
|
|
||||||
currentDividerPos : this.limitPosition(dividerPos, 0.1*(window.innerWidth-13), 0.9*(window.innerWidth-13)),
|
|
||||||
userSetDividerPos : dividerPos,
|
|
||||||
windowWidth : window.innerWidth
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.setState({
|
|
||||||
currentDividerPos : window.innerWidth / 2,
|
|
||||||
userSetDividerPos : window.innerWidth / 2
|
|
||||||
});
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', this.handleWindowResize);
|
|
||||||
},
|
|
||||||
|
|
||||||
componentWillUnmount : function() {
|
|
||||||
window.removeEventListener('resize', this.handleWindowResize);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleWindowResize : function() {
|
|
||||||
// Allow divider to increase in size to last user-set position
|
|
||||||
// Limit current position to between 10% and 90% of visible space
|
|
||||||
const newLoc = this.limitPosition(this.state.userSetDividerPos, 0.1*(window.innerWidth-13), 0.9*(window.innerWidth-13));
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
currentDividerPos : newLoc,
|
|
||||||
windowWidth : window.innerWidth
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
limitPosition : function(x, min = 1, max = window.innerWidth - 13) {
|
|
||||||
const result = Math.round(Math.min(max, Math.max(min, x)));
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
|
|
||||||
handleUp : function(e){
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if(this.state.isDragging){
|
setDividerPos(limitPosition(e.pageX));
|
||||||
this.props.onDragFinish(this.state.currentDividerPos);
|
};
|
||||||
window.localStorage.setItem(this.props.storageKey, this.state.currentDividerPos);
|
|
||||||
}
|
|
||||||
this.setState({ isDragging: false });
|
|
||||||
},
|
|
||||||
|
|
||||||
handleDown : function(e){
|
const liveScrollToggle = ()=>{
|
||||||
e.preventDefault();
|
window.localStorage.setItem('liveScroll', String(!liveScroll));
|
||||||
this.setState({ isDragging: true });
|
setLiveScroll(!liveScroll);
|
||||||
//this.unFocus()
|
};
|
||||||
},
|
|
||||||
|
|
||||||
handleMove : function(e){
|
const renderMoveArrows = (showMoveArrows &&
|
||||||
if(!this.state.isDragging) return;
|
<>
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
const newSize = this.limitPosition(e.pageX);
|
|
||||||
this.setState({
|
|
||||||
currentDividerPos : newSize,
|
|
||||||
userSetDividerPos : newSize
|
|
||||||
});
|
|
||||||
},
|
|
||||||
/*
|
|
||||||
unFocus : function() {
|
|
||||||
if(document.selection){
|
|
||||||
document.selection.empty();
|
|
||||||
}else{
|
|
||||||
window.getSelection().removeAllRanges();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
*/
|
|
||||||
|
|
||||||
setMoveArrows : function(newState) {
|
|
||||||
if(this.state.showMoveArrows != newState){
|
|
||||||
this.setState({
|
|
||||||
showMoveArrows : newState
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
renderMoveArrows : function(){
|
|
||||||
if(this.state.showMoveArrows) {
|
|
||||||
return <>
|
|
||||||
<div className='arrow left'
|
<div className='arrow left'
|
||||||
style={{ left: this.state.currentDividerPos-4 }}
|
onClick={()=>setMoveSource(!moveSource)} >
|
||||||
onClick={()=>this.setState({ moveSource: !this.state.moveSource })} >
|
|
||||||
<i className='fas fa-arrow-left' />
|
<i className='fas fa-arrow-left' />
|
||||||
</div>
|
</div>
|
||||||
<div className='arrow right'
|
<div className='arrow right'
|
||||||
style={{ left: this.state.currentDividerPos-4 }}
|
onClick={()=>setMoveBrew(!moveBrew)} >
|
||||||
onClick={()=>this.setState({ moveBrew: !this.state.moveBrew })} >
|
|
||||||
<i className='fas fa-arrow-right' />
|
<i className='fas fa-arrow-right' />
|
||||||
</div>
|
</div>
|
||||||
</>;
|
<div id='scrollToggleDiv' className={liveScroll ? 'arrow lock' : 'arrow unlock'}
|
||||||
}
|
onClick={liveScrollToggle} >
|
||||||
},
|
<i id='scrollToggle' className={liveScroll ? 'fas fa-lock' : 'fas fa-unlock'} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
renderDivider : function(){
|
const renderDivider = (
|
||||||
return <>
|
<div className={`divider ${isDragging && 'dragging'}`} onPointerDown={handleDown}>
|
||||||
{this.renderMoveArrows()}
|
{showDividerButtons && renderMoveArrows}
|
||||||
<div className='divider' onPointerDown={this.handleDown} >
|
|
||||||
<div className='dots'>
|
<div className='dots'>
|
||||||
<i className='fas fa-circle' />
|
<i className='fas fa-circle' />
|
||||||
<i className='fas fa-circle' />
|
<i className='fas fa-circle' />
|
||||||
<i className='fas fa-circle' />
|
<i className='fas fa-circle' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>;
|
);
|
||||||
},
|
|
||||||
|
|
||||||
render : function(){
|
return (
|
||||||
return <div className='splitPane' onPointerMove={this.handleMove} onPointerUp={this.handleUp}>
|
<div className='splitPane' onPointerMove={handleMove} onPointerUp={handleUp}>
|
||||||
<Pane
|
<Pane width={dividerPos} moveBrew={moveBrew} moveSource={moveSource} liveScroll={liveScroll} setMoveArrows={setShowMoveArrows}>
|
||||||
width={this.state.currentDividerPos}
|
{props.children[0]}
|
||||||
>
|
|
||||||
{React.cloneElement(this.props.children[0], {
|
|
||||||
...(this.props.showDividerButtons && {
|
|
||||||
moveBrew: this.state.moveBrew,
|
|
||||||
moveSource: this.state.moveSource,
|
|
||||||
setMoveArrows: this.setMoveArrows,
|
|
||||||
}),
|
|
||||||
})}
|
|
||||||
</Pane>
|
</Pane>
|
||||||
{this.renderDivider()}
|
{renderDivider}
|
||||||
<Pane isDragging={this.state.isDragging}>{this.props.children[1]}</Pane>
|
<Pane isDragging={isDragging}>{props.children[1]}</Pane>
|
||||||
</div>;
|
</div>
|
||||||
}
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
const Pane = createClass({
|
const Pane = ({ width, children, isDragging, moveBrew, moveSource, liveScroll, setMoveArrows })=>{
|
||||||
displayName : 'Pane',
|
const styles = width
|
||||||
getDefaultProps : function() {
|
? { flex: 'none', width: `${width}px` }
|
||||||
return {
|
: { pointerEvents: isDragging ? 'none' : 'auto' }; //Disable mouse capture in the right pane; else dragging into the iframe drops the divider
|
||||||
width : null
|
|
||||||
};
|
|
||||||
},
|
|
||||||
render : function(){
|
|
||||||
let styles = {};
|
|
||||||
if(this.props.width){
|
|
||||||
styles = {
|
|
||||||
flex : 'none',
|
|
||||||
width : `${this.props.width}px`
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
styles = {
|
|
||||||
pointerEvents : this.props.isDragging ? 'none' : 'auto' //Disable mouse capture in the rightmost pane; dragging into the iframe drops the divider otherwise
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={cx('pane', this.props.className)} style={styles}>
|
return (
|
||||||
{this.props.children}
|
<div className='pane' style={styles}>
|
||||||
</div>;
|
{React.cloneElement(children, { moveBrew, moveSource, liveScroll, setMoveArrows })}
|
||||||
}
|
</div>
|
||||||
});
|
);
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = SplitPane;
|
module.exports = SplitPane;
|
||||||
|
|||||||
@@ -1,60 +1,68 @@
|
|||||||
|
|
||||||
.splitPane{
|
.splitPane {
|
||||||
position : relative;
|
position : relative;
|
||||||
display : flex;
|
display : flex;
|
||||||
|
flex-direction : row;
|
||||||
height : 100%;
|
height : 100%;
|
||||||
outline : none;
|
outline : none;
|
||||||
flex-direction : row;
|
.pane {
|
||||||
.pane{
|
flex : 1;
|
||||||
overflow-x : hidden;
|
overflow-x : hidden;
|
||||||
overflow-y : hidden;
|
overflow-y : hidden;
|
||||||
flex : 1;
|
|
||||||
}
|
}
|
||||||
.divider{
|
.divider {
|
||||||
touch-action : none;
|
position : relative;
|
||||||
display : table;
|
display : table;
|
||||||
height : 100%;
|
|
||||||
width : 15px;
|
width : 15px;
|
||||||
|
height : 100%;
|
||||||
|
text-align : center;
|
||||||
|
touch-action : none;
|
||||||
cursor : ew-resize;
|
cursor : ew-resize;
|
||||||
background-color : #bbb;
|
background-color : #BBBBBB;
|
||||||
text-align : center;
|
.dots {
|
||||||
.dots{
|
|
||||||
display : table-cell;
|
display : table-cell;
|
||||||
vertical-align : middle;
|
|
||||||
text-align : center;
|
text-align : center;
|
||||||
i{
|
vertical-align : middle;
|
||||||
|
i {
|
||||||
display : block !important;
|
display : block !important;
|
||||||
margin : 10px 0px;
|
margin : 10px 0px;
|
||||||
font-size : 6px;
|
font-size : 6px;
|
||||||
color : #666;
|
color : #666666;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&:hover{
|
&:hover,&.dragging { background-color : #999999; }
|
||||||
background-color: #999;
|
|
||||||
}
|
}
|
||||||
}
|
.arrow {
|
||||||
.arrow{
|
|
||||||
position : absolute;
|
position : absolute;
|
||||||
|
left : 50%;
|
||||||
|
z-index : 999;
|
||||||
width : 25px;
|
width : 25px;
|
||||||
height : 25px;
|
height : 25px;
|
||||||
border : 2px solid #bbb;
|
|
||||||
border-radius : 15px;
|
|
||||||
text-align : center;
|
|
||||||
font-size : 1.2em;
|
font-size : 1.2em;
|
||||||
|
text-align : center;
|
||||||
cursor : pointer;
|
cursor : pointer;
|
||||||
background-color : #ddd;
|
background-color : #DDDDDD;
|
||||||
z-index : 999;
|
border : 2px solid #BBBBBB;
|
||||||
box-shadow : 0 4px 5px #0000007f;
|
border-radius : 15px;
|
||||||
&.left{
|
box-shadow : 0 4px 5px #0000007F;
|
||||||
|
translate : -50%;
|
||||||
|
&.left {
|
||||||
.tooltipLeft('Jump to location in Editor');
|
.tooltipLeft('Jump to location in Editor');
|
||||||
top : 30px;
|
top : 30px;
|
||||||
}
|
}
|
||||||
&.right{
|
&.right {
|
||||||
.tooltipRight('Jump to location in Preview');
|
.tooltipRight('Jump to location in Preview');
|
||||||
top : 60px;
|
top : 60px;
|
||||||
}
|
}
|
||||||
&:hover{
|
&.lock {
|
||||||
background-color: #666;
|
.tooltipRight('De-sync Editor and Preview locations.');
|
||||||
|
top : 90px;
|
||||||
|
background : #666666;
|
||||||
}
|
}
|
||||||
|
&.unlock {
|
||||||
|
.tooltipRight('Sync Editor and Preview locations');
|
||||||
|
top : 90px;
|
||||||
|
}
|
||||||
|
&:hover { background-color : #666666; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
tests/html/safeHTML.test.js
Normal file
50
tests/html/safeHTML.test.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
|
||||||
|
require('jsdom-global')();
|
||||||
|
|
||||||
|
import { safeHTML } from '../../client/homebrew/brewRenderer/safeHTML';
|
||||||
|
|
||||||
|
test('Javascript via href', function() {
|
||||||
|
const source = `<a href="javascript:alert('This is a JavaScript injection via href attribute')">Click me</a>`;
|
||||||
|
const rendered = safeHTML(source);
|
||||||
|
expect(rendered).toBe('<a>Click me</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Javascript via src', function() {
|
||||||
|
const source = `<img src="javascript:alert('This is a JavaScript injection via src attribute')">`;
|
||||||
|
const rendered = safeHTML(source);
|
||||||
|
expect(rendered).toBe('<img>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Javascript via form submit action', function() {
|
||||||
|
const source = `<form action="javascript:alert('This is a JavaScript injection via action attribute')">\n<input type="submit" value="Submit">\n</form>`;
|
||||||
|
const rendered = safeHTML(source);
|
||||||
|
expect(rendered).toBe('<form>\n<input value=\"Submit\">\n</form>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Javascript via inline event handler - onClick', function() {
|
||||||
|
const source = `<div style="background-color: red; color: white; width: 100px; height: 100px;" onclick="alert('This is a JavaScript injection via inline event handler')">\nClick me\n</div>`;
|
||||||
|
const rendered = safeHTML(source);
|
||||||
|
expect(rendered).toBe('<div style=\"background-color: red; color: white; width: 100px; height: 100px;\">\nClick me\n</div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Javascript via inline event handler - onMouseOver', function() {
|
||||||
|
const source = `<div onmouseover="alert('This is a JavaScript injection via inline event handler')">Hover over me</div>`;
|
||||||
|
const rendered = safeHTML(source);
|
||||||
|
expect(rendered).toBe('<div>Hover over me</div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Javascript via data attribute', function() {
|
||||||
|
const source = `<div data-code="javascript:alert('This is a JavaScript injection via data attribute')">Test</div>`;
|
||||||
|
const rendered = safeHTML(source);
|
||||||
|
expect(rendered).toBe('<div>Test</div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Javascript via event delegation', function() {
|
||||||
|
const source = `<div id="parent"><button id="child">Click me</button></div><script>document.getElementById('parent').addEventListener('click', function(event) {if (event.target.id === 'child') {console.log('This is JavaScript executed via event delegation');}});</script>`;
|
||||||
|
const rendered = safeHTML(source);
|
||||||
|
expect(rendered).toBe('<div id="parent"><button id="child">Click me</button></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -27,11 +27,61 @@ module.exports = [
|
|||||||
experimental : true,
|
experimental : true,
|
||||||
subsnippets : [
|
subsnippets : [
|
||||||
{
|
{
|
||||||
name : 'Table of Contents',
|
name : 'Generate Table of Contents',
|
||||||
icon : 'fas fa-book',
|
icon : 'fas fa-book',
|
||||||
gen : TableOfContentsGen,
|
gen : TableOfContentsGen,
|
||||||
experimental : true
|
experimental : true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name : 'Table of Contents Individual Inclusion',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocInclude# CHANGE # to your header level
|
||||||
|
}}\n`,
|
||||||
|
subsnippets : [
|
||||||
|
{
|
||||||
|
name : 'Individual Inclusion H1',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocIncludeH1 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Inclusion H2',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocIncludeH2 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Inclusion H3',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocIncludeH3 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Inclusion H4',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocIncludeH4 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Inclusion H5',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocIncludeH5 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Inclusion H6',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocIncludeH6 \n
|
||||||
|
}}\n`,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Table of Contents Range Inclusion',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocDepthH3
|
||||||
|
}}\n`,
|
||||||
|
subsnippets : [
|
||||||
{
|
{
|
||||||
name : 'Include in ToC up to H3',
|
name : 'Include in ToC up to H3',
|
||||||
icon : 'fas fa-dice-three',
|
icon : 'fas fa-dice-three',
|
||||||
@@ -56,7 +106,54 @@ module.exports = [
|
|||||||
icon : 'fas fa-dice-six',
|
icon : 'fas fa-dice-six',
|
||||||
gen : dedent `\n{{tocDepthH6
|
gen : dedent `\n{{tocDepthH6
|
||||||
}}\n`,
|
}}\n`,
|
||||||
}
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Table of Contents Individual Exclusion',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocExcludeH1 \n
|
||||||
|
}}\n`,
|
||||||
|
subsnippets : [
|
||||||
|
{
|
||||||
|
name : 'Individual Exclusion H1',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocExcludeH1 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Exclusion H2',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocExcludeH2 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Exclusion H3',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocExcludeH3 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Exclusion H4',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocExcludeH4 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Exclusion H5',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocExcludeH5 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Individual Exclusion H6',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
gen : dedent `\n{{tocExcludeH6 \n
|
||||||
|
}}\n`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -94,6 +191,27 @@ module.exports = [
|
|||||||
background-image: linear-gradient(-45deg, #322814, #998250, #322814);
|
background-image: linear-gradient(-45deg, #322814, #998250, #322814);
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
}\n\n`
|
}\n\n`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Table of Contents Toggles',
|
||||||
|
icon : 'fas fa-book',
|
||||||
|
subsnippets : [
|
||||||
|
{
|
||||||
|
name : 'Enable H1-H4 all pages',
|
||||||
|
icon : 'fas fa-dice-four',
|
||||||
|
gen : `.page {\n\th4 {--TOC: include; }\n}\n\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Enable H1-H5 all pages',
|
||||||
|
icon : 'fas fa-dice-five',
|
||||||
|
gen : `.page {\n\th4, h5 {--TOC: include; }\n}\n\n`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Enable H1-H6 all pages',
|
||||||
|
icon : 'fas fa-dice-six',
|
||||||
|
gen : `.page {\n\th4, h5, h6 {--TOC: include; }\n}\n\n`,
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,77 +1,78 @@
|
|||||||
const _ = require('lodash');
|
|
||||||
const dedent = require('dedent-tabs').default;
|
const dedent = require('dedent-tabs').default;
|
||||||
|
|
||||||
const getTOC = (pages)=>{
|
// Map each actual page to its footer label, accounting for skips or numbering resets
|
||||||
|
const mapPages = (pages)=>{
|
||||||
|
let actualPage = 0;
|
||||||
|
let mappedPage = 0; // Number displayed in footer
|
||||||
|
const pageMap = [];
|
||||||
|
|
||||||
const recursiveAdd = (title, page, targetDepth, child, curDepth=0)=>{
|
pages.forEach((page)=>{
|
||||||
if(curDepth > 5) return; // Something went wrong.
|
actualPage++;
|
||||||
if(curDepth == targetDepth) {
|
const doSkip = page.querySelector('.skipCounting');
|
||||||
child.push({
|
const doReset = page.querySelector('.resetCounting');
|
||||||
title : title,
|
|
||||||
page : page,
|
if(doReset)
|
||||||
children : []
|
mappedPage = 1;
|
||||||
});
|
if(!doSkip && !doReset)
|
||||||
} else {
|
mappedPage++;
|
||||||
if(child.length == 0) {
|
|
||||||
child.push({
|
pageMap[actualPage] = {
|
||||||
title : null,
|
mappedPage : mappedPage,
|
||||||
page : page,
|
showPage : !doSkip
|
||||||
children : []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
recursiveAdd(title, page, targetDepth, _.last(child).children, curDepth+1,);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
return pageMap;
|
||||||
|
};
|
||||||
|
|
||||||
const res = [];
|
const getMarkdown = (headings, pageMap)=>{
|
||||||
|
const levelPad = ['- ###', ' - ####', ' -', ' -', ' -', ' -'];
|
||||||
|
|
||||||
|
const allMarkdown = [];
|
||||||
|
const depthChain = [0];
|
||||||
|
|
||||||
|
headings.forEach((heading)=>{
|
||||||
|
const page = parseInt(heading.closest('.page').id?.replace(/^p/, ''));
|
||||||
|
const mappedPage = pageMap[page].mappedPage;
|
||||||
|
const showPage = pageMap[page].showPage;
|
||||||
|
const title = heading.textContent.trim();
|
||||||
|
const ToCExclude = getComputedStyle(heading).getPropertyValue('--TOC');
|
||||||
|
const depth = parseInt(heading.tagName.substring(1));
|
||||||
|
|
||||||
|
if(!title || !showPage || ToCExclude == 'exclude')
|
||||||
|
return;
|
||||||
|
|
||||||
|
//If different header depth than last, remove indents until nearest higher-level header, then indent once
|
||||||
|
if(depth !== depthChain[depthChain.length -1]) {
|
||||||
|
while (depth <= depthChain[depthChain.length - 1]) {
|
||||||
|
depthChain.pop();
|
||||||
|
}
|
||||||
|
depthChain.push(depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = `${levelPad[depthChain.length - 2]} [{{ ${title}}}{{ ${mappedPage}}}](#p${page})`;
|
||||||
|
allMarkdown.push(markdown);
|
||||||
|
});
|
||||||
|
return allMarkdown.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTOC = ()=>{
|
||||||
const iframe = document.getElementById('BrewRenderer');
|
const iframe = document.getElementById('BrewRenderer');
|
||||||
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
|
const iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
const headings = iframeDocument.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
const headings = iframeDocument.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
const headerDepth = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'];
|
const pages = iframeDocument.querySelectorAll('.page');
|
||||||
|
|
||||||
_.each(headings, (heading)=>{
|
const pageMap = mapPages(pages);
|
||||||
const onPage = parseInt(heading.closest('.page').id?.replace(/^p/, ''));
|
return getMarkdown(headings, pageMap);
|
||||||
const ToCExclude = getComputedStyle(heading).getPropertyValue('--TOC');
|
|
||||||
|
|
||||||
if(ToCExclude != 'exclude') {
|
|
||||||
recursiveAdd(heading.textContent.trim(), onPage, headerDepth.indexOf(heading.tagName), res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const ToCIterate = (entries, curDepth=0)=>{
|
|
||||||
const levelPad = ['- ###', ' - ####', ' - ', ' - ', ' - ', ' - '];
|
|
||||||
const toc = [];
|
|
||||||
if(entries.title !== null){
|
|
||||||
toc.push(`${levelPad[curDepth]} [{{ ${entries.title}}}{{ ${entries.page}}}](#p${entries.page})`);
|
|
||||||
}
|
|
||||||
if(entries.children.length) {
|
|
||||||
_.each(entries.children, (entry, idx)=>{
|
|
||||||
const children = ToCIterate(entry, entry.title == null ? curDepth : curDepth+1);
|
|
||||||
if(children.length) {
|
|
||||||
toc.push(...children);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return toc;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = function(props){
|
module.exports = function(props){
|
||||||
const pages = props.brew.text.split('\\page');
|
const TOC = getTOC();
|
||||||
const TOC = getTOC(pages);
|
|
||||||
const markdown = _.reduce(TOC, (r, g1, idx1)=>{
|
|
||||||
r.push(ToCIterate(g1).join('\n'));
|
|
||||||
return r;
|
|
||||||
}, []).join('\n');
|
|
||||||
|
|
||||||
return dedent`
|
return dedent`
|
||||||
{{toc,wide
|
{{toc,wide
|
||||||
# Contents
|
# Contents
|
||||||
|
|
||||||
${markdown}
|
${TOC}
|
||||||
}}
|
}}
|
||||||
\n`;
|
\n`;
|
||||||
};
|
};
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
--HB_Color_CaptionText : #766649; // Brown
|
--HB_Color_CaptionText : #766649; // Brown
|
||||||
--HB_Color_WatercolorStain : #BBAD82; // Light brown
|
--HB_Color_WatercolorStain : #BBAD82; // Light brown
|
||||||
--HB_Color_Footnotes : #C9AD6A; // Gold
|
--HB_Color_Footnotes : #C9AD6A; // Gold
|
||||||
|
--TOC : 'include';
|
||||||
}
|
}
|
||||||
|
|
||||||
.useSansSerif() {
|
.useSansSerif() {
|
||||||
@@ -797,7 +798,7 @@
|
|||||||
// *****************************/
|
// *****************************/
|
||||||
|
|
||||||
// Default Exclusions
|
// Default Exclusions
|
||||||
// Anything not exlcuded is included, default Headers are H1, H2, and H3.
|
// Anything not excluded is included, default Headers are H1, H2, and H3.
|
||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
h6,
|
h6,
|
||||||
@@ -808,12 +809,14 @@ h6,
|
|||||||
.noToC,
|
.noToC,
|
||||||
.toc { --TOC: exclude; }
|
.toc { --TOC: exclude; }
|
||||||
|
|
||||||
.tocDepthH2 :is(h1, h2) {--TOC: include; }
|
|
||||||
.tocDepthH3 :is(h1, h2, h3) {--TOC: include; }
|
|
||||||
.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; }
|
|
||||||
.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; }
|
|
||||||
.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; }
|
|
||||||
|
|
||||||
|
// Brew level default inclusion changes.
|
||||||
|
// These add Headers 'back' to inclusion.
|
||||||
|
|
||||||
|
//NOTE: DO NOT USE :HAS WITH .PAGES!!! EXTREMELY SLOW TO RENDER ON LARGE DOCS!
|
||||||
|
|
||||||
|
// Block level inclusion changes
|
||||||
|
// These include either a single (include) or a range (depth)
|
||||||
.tocIncludeH1 h1 {--TOC: include; }
|
.tocIncludeH1 h1 {--TOC: include; }
|
||||||
.tocIncludeH2 h2 {--TOC: include; }
|
.tocIncludeH2 h2 {--TOC: include; }
|
||||||
.tocIncludeH3 h3 {--TOC: include; }
|
.tocIncludeH3 h3 {--TOC: include; }
|
||||||
@@ -821,6 +824,21 @@ h6,
|
|||||||
.tocIncludeH5 h5 {--TOC: include; }
|
.tocIncludeH5 h5 {--TOC: include; }
|
||||||
.tocIncludeH6 h6 {--TOC: include; }
|
.tocIncludeH6 h6 {--TOC: include; }
|
||||||
|
|
||||||
|
.tocDepthH2 :is(h1, h2) {--TOC: include; }
|
||||||
|
.tocDepthH3 :is(h1, h2, h3) {--TOC: include; }
|
||||||
|
.tocDepthH4 :is(h1, h2, h3, h4) {--TOC: include; }
|
||||||
|
.tocDepthH5 :is(h1, h2, h3, h4, h5) {--TOC: include; }
|
||||||
|
.tocDepthH6 :is(h1, h2, h3, h4, h5, h6) {--TOC: include; }
|
||||||
|
|
||||||
|
// Block level exclusion changes
|
||||||
|
// These exclude a single block level
|
||||||
|
.tocExcludeH1 h1 {--TOC: exclude; }
|
||||||
|
.tocExcludeH2 h2 {--TOC: exclude; }
|
||||||
|
.tocExcludeH3 h3 {--TOC: exclude; }
|
||||||
|
.tocExcludeH4 h4 {--TOC: exclude; }
|
||||||
|
.tocExcludeH5 h5 {--TOC: exclude; }
|
||||||
|
.tocExcludeH6 h6 {--TOC: exclude; }
|
||||||
|
|
||||||
.page:has(.partCover) {
|
.page:has(.partCover) {
|
||||||
--TOC: exclude;
|
--TOC: exclude;
|
||||||
& h1 {
|
& h1 {
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ module.exports = [
|
|||||||
icon : 'fas fa-file-alt',
|
icon : 'fas fa-file-alt',
|
||||||
gen : '\n\\page\n'
|
gen : '\n\\page\n'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name : 'Page Numbering',
|
||||||
|
icon : 'fas fa-bookmark',
|
||||||
|
subsnippets : [
|
||||||
{
|
{
|
||||||
name : 'Page Number',
|
name : 'Page Number',
|
||||||
icon : 'fas fa-bookmark',
|
icon : 'fas fa-bookmark',
|
||||||
@@ -32,6 +36,18 @@ module.exports = [
|
|||||||
icon : 'fas fa-sort-numeric-down',
|
icon : 'fas fa-sort-numeric-down',
|
||||||
gen : '{{pageNumber,auto}}\n'
|
gen : '{{pageNumber,auto}}\n'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name : 'Skip Page Number Increment this Page',
|
||||||
|
icon : 'fas fa-xmark',
|
||||||
|
gen : '{{skipCounting}}\n'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name : 'Restart Numbering',
|
||||||
|
icon : 'fas fa-arrow-rotate-left',
|
||||||
|
gen : '{{resetCounting}}\n'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name : 'Footer',
|
name : 'Footer',
|
||||||
icon : 'fas fa-shoe-prints',
|
icon : 'fas fa-shoe-prints',
|
||||||
@@ -352,6 +368,11 @@ module.exports = [
|
|||||||
icon : 'font MrEavesRemake',
|
icon : 'font MrEavesRemake',
|
||||||
gen : dedent`{{font-family:MrEavesRemake Dummy Text}}`
|
gen : dedent`{{font-family:MrEavesRemake Dummy Text}}`
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name : 'Pagella',
|
||||||
|
icon : 'font Pagella',
|
||||||
|
gen : dedent`{{font-family:Pagella Dummy Text}}`
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name : 'Solbera Imitation',
|
name : 'Solbera Imitation',
|
||||||
icon : 'font SolberaImitationRemake',
|
icon : 'font SolberaImitationRemake',
|
||||||
@@ -410,22 +431,40 @@ module.exports = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
/**************** PAGE *************/
|
/**************** LAYOUT *************/
|
||||||
|
|
||||||
{
|
{
|
||||||
groupName : 'Print',
|
groupName : 'Print',
|
||||||
icon : 'fas fa-print',
|
icon : 'fas fa-print',
|
||||||
view : 'style',
|
view : 'style',
|
||||||
snippets : [
|
snippets : [
|
||||||
|
{
|
||||||
|
name : 'A3 Page Size',
|
||||||
|
icon : 'far fa-file',
|
||||||
|
gen : dedent`/* A3 Page Size */
|
||||||
|
.page {
|
||||||
|
width : 297mm;
|
||||||
|
height : 420mm;
|
||||||
|
}\n\n`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name : 'A4 Page Size',
|
name : 'A4 Page Size',
|
||||||
icon : 'far fa-file',
|
icon : 'far fa-file',
|
||||||
gen : dedent`/* A4 Page Size */
|
gen : dedent`/* A4 Page Size */
|
||||||
.page{
|
.page {
|
||||||
width : 210mm;
|
width : 210mm;
|
||||||
height : 296.8mm;
|
height : 296.8mm;
|
||||||
}\n\n`
|
}\n\n`
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name : 'A5 Page Size',
|
||||||
|
icon : 'far fa-file',
|
||||||
|
gen : dedent`/* A5 Page Size */
|
||||||
|
.page {
|
||||||
|
width : 148mm;
|
||||||
|
height : 210mm;
|
||||||
|
}\n\n`,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name : 'Square Page Size',
|
name : 'Square Page Size',
|
||||||
icon : 'far fa-file',
|
icon : 'far fa-file',
|
||||||
@@ -437,6 +476,17 @@ module.exports = [
|
|||||||
columns : unset;
|
columns : unset;
|
||||||
}\n\n`
|
}\n\n`
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name : 'Card Page Size',
|
||||||
|
icon : 'far fa-file',
|
||||||
|
gen : dedent`/* Card Size */
|
||||||
|
.page {
|
||||||
|
width : 63.5mm;
|
||||||
|
height : 88.9mm;
|
||||||
|
padding : 5mm;
|
||||||
|
columns : unset;
|
||||||
|
}\n\n`
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name : 'Ink Friendly',
|
name : 'Ink Friendly',
|
||||||
icon : 'fas fa-tint',
|
icon : 'fas fa-tint',
|
||||||
@@ -452,5 +502,5 @@ module.exports = [
|
|||||||
}\n\n`
|
}\n\n`
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import (less) './themes/fonts/Blank/fonts.less';
|
||||||
@import (less) './themes/fonts/5e/fonts.less';
|
@import (less) './themes/fonts/5e/fonts.less';
|
||||||
@import (less) './themes/assets/assets.less';
|
@import (less) './themes/assets/assets.less';
|
||||||
@import (less) './themes/fonts/iconFonts/elderberryInn.less';
|
@import (less) './themes/fonts/iconFonts/elderberryInn.less';
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@page { margin : 0; }
|
@page { margin : 0; }
|
||||||
body { counter-reset : page-numbers; }
|
body { counter-reset : page-numbers 0; }
|
||||||
* { -webkit-print-color-adjust : exact; }
|
* { -webkit-print-color-adjust : exact; }
|
||||||
|
|
||||||
//*****************************
|
//*****************************
|
||||||
@@ -51,7 +52,6 @@ body { counter-reset : page-numbers; }
|
|||||||
height : 279.4mm;
|
height : 279.4mm;
|
||||||
padding : 1.4cm 1.9cm 1.7cm;
|
padding : 1.4cm 1.9cm 1.7cm;
|
||||||
overflow : hidden;
|
overflow : hidden;
|
||||||
counter-increment : page-numbers;
|
|
||||||
background-color : var(--HB_Color_Background);
|
background-color : var(--HB_Color_Background);
|
||||||
text-rendering : optimizeLegibility;
|
text-rendering : optimizeLegibility;
|
||||||
contain : size;
|
contain : size;
|
||||||
@@ -494,4 +494,13 @@ body { counter-reset : page-numbers; }
|
|||||||
&:nth-child(even) {
|
&:nth-child(even) {
|
||||||
.pageNumber { left : 30px; }
|
.pageNumber { left : 30px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resetCounting {
|
||||||
|
counter-set : page-numbers 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:has(.skipCounting)) {
|
||||||
|
counter-increment : page-numbers;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
46
themes/fonts/Blank/fonts.less
Normal file
46
themes/fonts/Blank/fonts.less
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
TeX Gyre Pagella
|
||||||
|
License:
|
||||||
|
% Copyright 2007--2018 for TeX Gyre extensions by B. Jackowski,
|
||||||
|
% J.M. Nowacki et al. (on behalf of TeX Users Groups). Vietnamese
|
||||||
|
% characters were added by Han The Thanh.
|
||||||
|
%
|
||||||
|
% This work can be freely used and distributed under
|
||||||
|
% the GUST Font License (GFL -- see GUST-FONT-LICENSE.txt)
|
||||||
|
% which is actually an instance of the LaTeX Project Public License
|
||||||
|
% (LPPL -- see http://www.latex-project.org/lppl.txt ).
|
||||||
|
%
|
||||||
|
% This work has the maintenance status "maintained". The Current Maintainer
|
||||||
|
% of this work is Bogus\l{}aw Jackowski and Janusz M. Nowacki.
|
||||||
|
%
|
||||||
|
% This work consists of the files listed
|
||||||
|
% in the MANIFEST-TeX-Gyre-Pagella.txt file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Pagella;
|
||||||
|
src: url('../../../fonts/Blank/texgyrepagella-regular.woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Pagella;
|
||||||
|
src: url('../../../fonts/Blank/texgyrepagella-bold.woff2');
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Pagella;
|
||||||
|
src: url('../../../fonts/Blank/texgyrepagella-italic.woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: Pagella;
|
||||||
|
src: url('../../../fonts/Blank/texgyrepagella-bolditalic.woff2');
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
BIN
themes/fonts/Blank/texgyrepagella-bold.woff2
Normal file
BIN
themes/fonts/Blank/texgyrepagella-bold.woff2
Normal file
Binary file not shown.
BIN
themes/fonts/Blank/texgyrepagella-bolditalic.woff2
Normal file
BIN
themes/fonts/Blank/texgyrepagella-bolditalic.woff2
Normal file
Binary file not shown.
BIN
themes/fonts/Blank/texgyrepagella-italic.woff2
Normal file
BIN
themes/fonts/Blank/texgyrepagella-italic.woff2
Normal file
Binary file not shown.
BIN
themes/fonts/Blank/texgyrepagella-regular.woff2
Normal file
BIN
themes/fonts/Blank/texgyrepagella-regular.woff2
Normal file
Binary file not shown.
Reference in New Issue
Block a user