mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-01-23 23:03:01 +00:00
Compare commits
943 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add088c2a9 | ||
|
|
6d8415bfeb | ||
|
|
decb334808 | ||
|
|
66f71972eb | ||
|
|
ebe8e1067c | ||
|
|
9807cf762b | ||
|
|
b58563cb42 | ||
|
|
7c3f3b87af | ||
|
|
e7daad592c | ||
|
|
992359e239 | ||
|
|
b2546f3458 | ||
|
|
6f016bf5b6 | ||
|
|
7cd3c69fbd | ||
|
|
9b1507d4f5 | ||
|
|
2e49bf4fa8 | ||
|
|
e5624434d6 | ||
|
|
1850173f87 | ||
|
|
fb9148ada5 | ||
|
|
b857a91ab8 | ||
|
|
b7c49218ae | ||
|
|
f4d4334a75 | ||
|
|
38b4c285a3 | ||
|
|
cf46a975aa | ||
|
|
9f693547f7 | ||
|
|
a69dd998f5 | ||
|
|
f141515446 | ||
|
|
f749706cb3 | ||
|
|
b22f3d041c | ||
|
|
dd8692d82b | ||
|
|
0d2dfe66bc | ||
|
|
0437635861 | ||
|
|
a5f12ca0b4 | ||
|
|
07e0a7c1b5 | ||
|
|
2e9c7b1d9b | ||
|
|
0ddc3ae5b9 | ||
|
|
8c6c8f861d | ||
|
|
107aa34ee4 | ||
|
|
e006826e3e | ||
|
|
4e4463fe4d | ||
|
|
1a56c393ab | ||
|
|
9bc4b1fb56 | ||
|
|
234d484a74 | ||
|
|
dc1d40512b | ||
|
|
2dafbf2080 | ||
|
|
033b7fa44f | ||
|
|
2c4f705072 | ||
|
|
ee811e94e1 | ||
|
|
fcae147723 | ||
|
|
b3793a3330 | ||
|
|
952b67aed3 | ||
|
|
27f14b042b | ||
|
|
49d30007d3 | ||
|
|
bd26f02ddb | ||
|
|
ccc37fc0d5 | ||
|
|
9973999e86 | ||
|
|
2aec54748a | ||
|
|
5585c27cb8 | ||
|
|
52ae343309 | ||
|
|
f8d60fc4da | ||
|
|
69e827a663 | ||
|
|
d1d73023a2 | ||
|
|
f4af19ed81 | ||
|
|
bc5a9c9039 | ||
|
|
f7dfedcd44 | ||
|
|
b7b1981bde | ||
|
|
2e8368d08c | ||
|
|
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 | ||
|
|
ca90e1804a | ||
|
|
e7c0cdae3d | ||
|
|
db75e0dd66 | ||
|
|
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 | ||
|
|
10d4cd4ab3 | ||
|
|
2a523c4955 | ||
|
|
64dd71601c | ||
|
|
4968300e7a | ||
|
|
3acb25ce3a | ||
|
|
487a574f50 | ||
|
|
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 | ||
|
|
1a325fb3c5 | ||
|
|
84f49aebce | ||
|
|
8949248bc4 | ||
|
|
df06d8fcd3 | ||
|
|
d6ca6592a2 | ||
|
|
0110c6afed | ||
|
|
0e29620710 | ||
|
|
c33b44855a | ||
|
|
77f162f7a4 | ||
|
|
bc475b5ed9 | ||
|
|
0415af624a | ||
|
|
8a63859546 | ||
|
|
8d5bc9e37c | ||
|
|
313f18c74c | ||
|
|
0c6bc5d7ac | ||
|
|
db6c689914 | ||
|
|
d4970ed119 | ||
|
|
e7e35294c6 | ||
|
|
e9c45b216c | ||
|
|
40925253bd | ||
|
|
66433d9e77 | ||
|
|
3a6750613b | ||
|
|
e45fddad60 | ||
|
|
a31f1da4dc | ||
|
|
3ec3cf8df8 | ||
|
|
a7361f8450 | ||
|
|
32fa272947 | ||
|
|
68895bdca2 | ||
|
|
4d6d8a5e5a | ||
|
|
96acd334a0 | ||
|
|
8ab6a8599d | ||
|
|
a0aa975d07 | ||
|
|
8a06257a50 | ||
|
|
ea656e5119 | ||
|
|
aaa0acdfea | ||
|
|
570c850c4f | ||
|
|
17dfacd5c9 | ||
|
|
6d904111f7 | ||
|
|
f1f686d8c7 | ||
|
|
6e120c2d05 | ||
|
|
9b58db9f1e | ||
|
|
ae123a8310 | ||
|
|
1f047890ab | ||
|
|
58b0e12fcc | ||
|
|
51f4c83ec0 | ||
|
|
9decaf73f7 | ||
|
|
15fde76209 | ||
|
|
2ba160fe65 | ||
|
|
606af87e0f | ||
|
|
05ad8e17a7 | ||
|
|
bf87225415 | ||
|
|
8b61e69b77 | ||
|
|
e260eb0911 | ||
|
|
c8424e0b10 | ||
|
|
ff9a75f6b6 | ||
|
|
ab32695ac9 | ||
|
|
a96ff6ecb3 | ||
|
|
5af45f16b0 | ||
|
|
a9b6d5ff38 | ||
|
|
cd0bf9c947 | ||
|
|
c039a90624 | ||
|
|
0e8387ec0d | ||
|
|
1347374ff7 | ||
|
|
9419186e78 | ||
|
|
58c6e6a446 | ||
|
|
6e2bc1cabe | ||
|
|
971be6375e | ||
|
|
4353c01032 | ||
|
|
d09cecedd7 | ||
|
|
235e3f484f | ||
|
|
b53b279241 | ||
|
|
4fd358771a | ||
|
|
02147411e3 | ||
|
|
5d68cddd18 | ||
|
|
74a065e747 | ||
|
|
433f016c25 | ||
|
|
10a9bc2906 | ||
|
|
87c6343f30 | ||
|
|
b585e85f0f | ||
|
|
544f4c6103 | ||
|
|
8a67e1eccd | ||
|
|
a6ac6b98c2 | ||
|
|
2336f8508b | ||
|
|
7ea1696065 | ||
|
|
5b4a7c168f | ||
|
|
a54adc1e4b | ||
|
|
c1288ce4bb | ||
|
|
c65210b3ed | ||
|
|
70a3cb9ef9 | ||
|
|
d1686c4c8f | ||
|
|
c5033db336 | ||
|
|
672b787cd5 | ||
|
|
931566636b | ||
|
|
ffaca4ec10 | ||
|
|
fabc0bea83 | ||
|
|
5c2ad7dfee | ||
|
|
3e7d4714a2 | ||
|
|
77c4ac6640 | ||
|
|
a7c892c1bb | ||
|
|
dca7086522 | ||
|
|
6c42a7e180 | ||
|
|
e8c2858154 | ||
|
|
84f84782f5 | ||
|
|
3caec793d8 | ||
|
|
9717f0cd66 | ||
|
|
0cdc1947c1 | ||
|
|
36aa4ea508 | ||
|
|
d5c5b4315b | ||
|
|
d505e4e24c | ||
|
|
ea7f18e3b0 | ||
|
|
e8e16f4d66 | ||
|
|
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 | ||
|
|
84736980c9 | ||
|
|
03c14e5847 | ||
|
|
e0be7a5db3 | ||
|
|
2e332d7699 | ||
|
|
03bc9a8189 | ||
|
|
421c88cc07 | ||
|
|
235969a485 | ||
|
|
2e459118aa | ||
|
|
ff60ca163f | ||
|
|
53979f2266 | ||
|
|
4dc5746c71 | ||
|
|
780c92cb9b | ||
|
|
20c54ef79e | ||
|
|
5ce69041fc | ||
|
|
20db8c6720 | ||
|
|
ed35eb2680 | ||
|
|
2a7b7cd50c | ||
|
|
d37fa03ec7 | ||
|
|
2c7d39147d | ||
|
|
6535e94ccd | ||
|
|
49e072f03f | ||
|
|
dd5d551c73 | ||
|
|
aa90513825 | ||
|
|
8f18601c2e | ||
|
|
db02f88287 | ||
|
|
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:
|
||||
build:
|
||||
docker:
|
||||
- image: cimg/node:20.8.0
|
||||
- image: cimg/node:20.17.0
|
||||
- image: mongo:4.4
|
||||
|
||||
working_directory: ~/homebrewery
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# fallback to using the latest cache if no exact match is found
|
||||
- v1-dependencies-
|
||||
|
||||
- run: sudo npm install -g npm@10.2.0
|
||||
- run: sudo npm install -g npm@10.8.2
|
||||
- node/install-packages:
|
||||
app-dir: ~/homebrewery
|
||||
cache-path: node_modules
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
test:
|
||||
docker:
|
||||
- image: cimg/node:20.8.0
|
||||
- image: cimg/node:20.17.0
|
||||
|
||||
working_directory: ~/homebrewery
|
||||
parallelism: 1
|
||||
@@ -76,6 +76,9 @@ jobs:
|
||||
- run:
|
||||
name: Test - Routes
|
||||
command: npm run test:route
|
||||
- run:
|
||||
name: Test - HTML sanitization
|
||||
command: npm run test:safehtml
|
||||
- run:
|
||||
name: Test - Coverage
|
||||
command: npm run test:coverage
|
||||
|
||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1 +1,3 @@
|
||||
package-lock.json binary
|
||||
package-lock.json binary
|
||||
|
||||
*.json text eol=lf
|
||||
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>
|
||||
10
babel.config.json
Normal file
10
babel.config.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-runtime",
|
||||
"babel-plugin-transform-import-meta"
|
||||
]
|
||||
}
|
||||
122
changelog.md
122
changelog.md
@@ -81,9 +81,131 @@ pre {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## changelog
|
||||
For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery).
|
||||
|
||||
### Wednesday 11/27/2024 - v3.16.1
|
||||
|
||||
{{taskList
|
||||
##### 5e-Cleric
|
||||
|
||||
* [x] Allow linking to specific HTML IDs via `#ID` at the end of the URL, e.g.: `homebrewery.naturalcrit.com/share/share/a6RCXwaDS58i#p4` to link to Page 4 directly
|
||||
|
||||
Fixes issues [#2820](https://github.com/naturalcrit/homebrewery/issues/2820), [#3505](https://github.com/naturalcrit/homebrewery/issues/3505)
|
||||
|
||||
* [x] Fix generation of link to certain Google Drive brews
|
||||
|
||||
Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776)
|
||||
|
||||
##### abquintic
|
||||
|
||||
* [x] Fix blank pages appearing when pasting text
|
||||
|
||||
Fixes issue [#3718](https://github.com/naturalcrit/homebrewery/issues/3718)
|
||||
|
||||
##### Gazook89
|
||||
|
||||
* [x] Add new brew viewing options to the view toolbar
|
||||
- {{fac,single-spread}} {{openSans **SINGLE PAGE**}}
|
||||
- {{fac,facing-spread}} {{openSans **TWO PAGE**}}
|
||||
- {{fac,flow-spread}} {{openSans **GRID**}}
|
||||
|
||||
Fixes issue [#1379](https://github.com/naturalcrit/homebrewery/issues/1379)
|
||||
|
||||
* [x] Updates to tag input boxes
|
||||
|
||||
##### G-Ambatte
|
||||
|
||||
* [x] Admin tools to fix certain corrupted documents
|
||||
|
||||
Fixes issue [#3801](https://github.com/naturalcrit/homebrewery/issues/3801)
|
||||
|
||||
* [x] Fix print window being affected by document zoom
|
||||
|
||||
Fixes issue [#3744](https://github.com/naturalcrit/homebrewery/issues/3744)
|
||||
|
||||
|
||||
##### calculuschild, 5e-Cleric, G-Ambatte, Gazook89, abquintic
|
||||
|
||||
* [x] Multiple code refactors, cleanups, and security fixes
|
||||
}}
|
||||
|
||||
### 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
|
||||
|
||||
{{taskList
|
||||
|
||||
@@ -2,35 +2,44 @@ require('./admin.less');
|
||||
const React = require('react');
|
||||
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 BrewLookup = require('./brewLookup/brewLookup.jsx');
|
||||
const BrewCompress = require ('./brewCompress/brewCompress.jsx');
|
||||
const Stats = require('./stats/stats.jsx');
|
||||
const tabGroups = ['brew', 'notifications'];
|
||||
|
||||
const Admin = createClass({
|
||||
getDefaultProps : function() {
|
||||
return {};
|
||||
},
|
||||
|
||||
getInitialState : function(){
|
||||
return ({
|
||||
currentTab : 'brew'
|
||||
});
|
||||
},
|
||||
|
||||
handleClick : function(newTab){
|
||||
if(this.state.currentTab === newTab) return;
|
||||
this.setState({
|
||||
currentTab : newTab
|
||||
});
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return <div className='admin'>
|
||||
|
||||
<header>
|
||||
<div className='container'>
|
||||
<i className='fas fa-rocket' />
|
||||
homebrewery admin
|
||||
</div>
|
||||
</header>
|
||||
<div className='container'>
|
||||
<Stats />
|
||||
<hr />
|
||||
<BrewLookup />
|
||||
<hr />
|
||||
<BrewCleanup />
|
||||
<hr />
|
||||
<BrewCompress />
|
||||
</div>
|
||||
<main className='container'>
|
||||
<nav className='tabs'>
|
||||
{tabGroups.map((tab, idx)=>{ return <button className={tab===this.state.currentTab ? 'active' : ''} key={idx} onClick={()=>{ return this.handleClick(tab); }}>{tab.toUpperCase()}</button>; })}
|
||||
</nav>
|
||||
{this.state.currentTab==='brew' && <BrewUtils />}
|
||||
{this.state.currentTab==='notifications' && <NotificationUtils />}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,39 +6,95 @@
|
||||
|
||||
@import 'font-awesome/css/font-awesome.css';
|
||||
|
||||
html,body, #reactContainer, .naturalCrit{
|
||||
min-height : 100%;
|
||||
}
|
||||
html,body, #reactContainer, .naturalCrit { min-height : 100%; }
|
||||
|
||||
@sidebarWidth : 250px;
|
||||
|
||||
body{
|
||||
background-color : #eee;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
color : #4b5055;
|
||||
font-weight : 100;
|
||||
text-rendering : optimizeLegibility;
|
||||
margin : 0;
|
||||
body {
|
||||
height : 100%;
|
||||
padding : 0;
|
||||
height : 100%;
|
||||
margin : 0;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-weight : 100;
|
||||
color : #4B5055;
|
||||
background-color : #EEEEEE;
|
||||
text-rendering : optimizeLegibility;
|
||||
}
|
||||
|
||||
.admin{
|
||||
:where(.admin) {
|
||||
|
||||
header{
|
||||
header {
|
||||
padding : 20px 0px;
|
||||
margin-bottom : 30px;
|
||||
font-size : 2em;
|
||||
color : white;
|
||||
background-color : @red;
|
||||
font-size: 2em;
|
||||
padding : 20px 0px;
|
||||
color : white;
|
||||
margin-bottom: 30px;
|
||||
i{
|
||||
margin-right: 30px;
|
||||
i { 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;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
hr{
|
||||
margin : 30px 0px;
|
||||
.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');
|
||||
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const cx = require('classnames');
|
||||
@@ -13,22 +14,43 @@ const BrewLookup = createClass({
|
||||
},
|
||||
getInitialState() {
|
||||
return {
|
||||
query : '',
|
||||
foundBrew : null,
|
||||
searching : false,
|
||||
error : null
|
||||
query : '',
|
||||
foundBrew : null,
|
||||
searching : false,
|
||||
error : null,
|
||||
scriptCount : 0
|
||||
};
|
||||
},
|
||||
handleChange(e){
|
||||
this.setState({ query: e.target.value });
|
||||
},
|
||||
lookup(){
|
||||
this.setState({ searching: true, error: null });
|
||||
this.setState({ searching: true, error: null, scriptCount: 0 });
|
||||
|
||||
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 }))
|
||||
.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(){
|
||||
@@ -47,12 +69,23 @@ const BrewLookup = createClass({
|
||||
<dt>Share Link</dt>
|
||||
<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>
|
||||
<dd>{Moment(brew.updatedAt).fromNow()}</dd>
|
||||
|
||||
<dt>Num of Views</dt>
|
||||
<dd>{brew.views}</dd>
|
||||
|
||||
<dt>SCRIPT tags detected</dt>
|
||||
<dd>{this.state.scriptCount}</dd>
|
||||
</dl>
|
||||
{this.state.scriptCount > 0 &&
|
||||
<div className='cleanButton'>
|
||||
<button onClick={this.cleanScript}>CLEAN BREW</button>
|
||||
</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,22 +1,26 @@
|
||||
// Dialog box, for popups and modal blocking messages
|
||||
const React = require('react');
|
||||
import React from '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);
|
||||
|
||||
useEffect(()=>{
|
||||
if(!dismissKey || !localStorage.getItem(dismissKey)) {
|
||||
if(dismisskeys.length !== 0) {
|
||||
blocking ? dialogRef.current?.showModal() : dialogRef.current?.show();
|
||||
}
|
||||
}, []);
|
||||
}, [dialogRef.current, dismisskeys]);
|
||||
|
||||
const dismiss = ()=>{
|
||||
dismissKey && localStorage.setItem(dismissKey, true);
|
||||
dismisskeys.forEach((key)=>{
|
||||
if(key) {
|
||||
localStorage.setItem(key, 'true');
|
||||
}
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
return (
|
||||
<dialog ref={dialogRef} onCancel={dismiss} {...rest}>
|
||||
{rest.children}
|
||||
<button className='dismiss' onClick={dismiss}>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||
require('./brewRenderer.less');
|
||||
const React = require('react');
|
||||
const { useState, useRef, useEffect } = React;
|
||||
const { useState, useRef, useCallback, useMemo } = React;
|
||||
const _ = require('lodash');
|
||||
|
||||
const MarkdownLegacy = require('naturalcrit/markdownLegacy.js');
|
||||
const Markdown = require('naturalcrit/markdown.js');
|
||||
import Markdown from 'naturalcrit/markdown.js';
|
||||
const ErrorBar = require('./errorBar/errorBar.jsx');
|
||||
const ToolBar = require('./toolBar/toolBar.jsx');
|
||||
|
||||
@@ -16,8 +16,7 @@ const Frame = require('react-frame-component').default;
|
||||
const dedent = require('dedent-tabs').default;
|
||||
const { printCurrentBrew } = require('../../../shared/helpers.js');
|
||||
|
||||
const DOMPurify = require('dompurify');
|
||||
const purifyConfig = { FORCE_BODY: true, SANITIZE_DOM: false };
|
||||
import { safeHTML } from './safeHTML.js';
|
||||
|
||||
const PAGE_HEIGHT = 1056;
|
||||
|
||||
@@ -29,6 +28,7 @@ const INITIAL_CONTENT = dedent`
|
||||
<base target=_blank>
|
||||
</head><body style='overflow: hidden'><div></div></body></html>`;
|
||||
|
||||
|
||||
//v=====----------------------< Brew Page Component >---------------------=====v//
|
||||
const BrewPage = (props)=>{
|
||||
props = {
|
||||
@@ -36,36 +36,43 @@ const BrewPage = (props)=>{
|
||||
index : 0,
|
||||
...props
|
||||
};
|
||||
const cleanText = props.contents; //DOMPurify.sanitize(props.contents, purifyConfig);
|
||||
return <div className={props.className} id={`p${props.index + 1}`} >
|
||||
const cleanText = safeHTML(props.contents);
|
||||
return <div className={props.className} id={`p${props.index + 1}`} style={props.style}>
|
||||
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
||||
//v=====--------------------< Brew Renderer Component >-------------------=====v//
|
||||
const renderedPages = [];
|
||||
let renderedPages = [];
|
||||
let rawPages = [];
|
||||
|
||||
const BrewRenderer = (props)=>{
|
||||
props = {
|
||||
text : '',
|
||||
style : '',
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB',
|
||||
lang : '',
|
||||
errors : [],
|
||||
currentEditorPage : 0,
|
||||
themeBundle : {},
|
||||
text : '',
|
||||
style : '',
|
||||
renderer : 'legacy',
|
||||
theme : '5ePHB',
|
||||
lang : '',
|
||||
errors : [],
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
themeBundle : {},
|
||||
onPageChange : ()=>{},
|
||||
...props
|
||||
};
|
||||
|
||||
const [state, setState] = useState({
|
||||
height : PAGE_HEIGHT,
|
||||
isMounted : false,
|
||||
visibility : 'hidden',
|
||||
zoom : 100,
|
||||
currentPageNumber : 1,
|
||||
isMounted : false,
|
||||
visibility : 'hidden'
|
||||
});
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useState({
|
||||
zoomLevel : 100,
|
||||
spread : 'single',
|
||||
startOnRight : true,
|
||||
pageShadows : true
|
||||
});
|
||||
|
||||
const mainRef = useRef(null);
|
||||
@@ -76,36 +83,42 @@ const BrewRenderer = (props)=>{
|
||||
rawPages = props.text.split(/^\\page$/gm);
|
||||
}
|
||||
|
||||
useEffect(()=>{ // Unmounting steps
|
||||
return ()=>{window.removeEventListener('resize', updateSize);};
|
||||
}, []);
|
||||
const scrollToHash = (hash)=>{
|
||||
if(!hash) return;
|
||||
|
||||
const updateSize = ()=>{
|
||||
setState((prevState)=>({
|
||||
...prevState,
|
||||
height : mainRef.current.parentNode.clientHeight,
|
||||
}));
|
||||
const iframeDoc = document.getElementById('BrewRenderer').contentDocument;
|
||||
let anchor = iframeDoc.querySelector(hash);
|
||||
|
||||
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 totalScrollableHeight = scrollHeight - clientHeight;
|
||||
const currentPageNumber = Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length);
|
||||
const currentPageNumber = Math.max(Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length), 1);
|
||||
|
||||
setState((prevState)=>({
|
||||
...prevState,
|
||||
currentPageNumber : currentPageNumber || 1
|
||||
}));
|
||||
};
|
||||
props.onPageChange(currentPageNumber);
|
||||
}, 200), []);
|
||||
|
||||
const isInView = (index)=>{
|
||||
if(!state.isMounted)
|
||||
return false;
|
||||
|
||||
if(index == props.currentEditorPage) //Already rendered before this step
|
||||
if(index == props.currentEditorCursorPageNum - 1) //Already rendered before this step
|
||||
return false;
|
||||
|
||||
if(Math.abs(index - state.currentPageNumber) <= 3)
|
||||
if(Math.abs(index - props.currentBrewRendererPageNum - 1) <= 3)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
@@ -118,9 +131,9 @@ const BrewRenderer = (props)=>{
|
||||
};
|
||||
|
||||
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>';
|
||||
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)=>{
|
||||
@@ -130,7 +143,13 @@ const BrewRenderer = (props)=>{
|
||||
} 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)
|
||||
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,8 @@ const BrewRenderer = (props)=>{
|
||||
renderedPages.length = 0;
|
||||
|
||||
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
|
||||
renderedPages[props.currentEditorPage] = renderPage(rawPages[props.currentEditorPage], props.currentEditorPage);
|
||||
if(rawPages.length > props.currentEditorCursorPageNum -1)
|
||||
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
|
||||
|
||||
_.forEach(rawPages, (page, index)=>{
|
||||
if((isInView(index) || !renderedPages[index]) && typeof window !== 'undefined'){
|
||||
@@ -163,9 +183,9 @@ const BrewRenderer = (props)=>{
|
||||
};
|
||||
|
||||
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
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
renderPages(); //Make sure page is renderable before showing
|
||||
setState((prevState)=>({
|
||||
...prevState,
|
||||
@@ -180,19 +200,30 @@ const BrewRenderer = (props)=>{
|
||||
document.dispatchEvent(new MouseEvent('click'));
|
||||
};
|
||||
|
||||
//Toolbar settings:
|
||||
const handleZoom = (newZoom)=>{
|
||||
setState((prevState)=>({
|
||||
...prevState,
|
||||
zoom : newZoom
|
||||
}));
|
||||
const handleDisplayOptionsChange = (newDisplayOptions)=>{
|
||||
setDisplayOptions(newDisplayOptions);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
{/*render dummy page while iFrame is mounting.*/}
|
||||
{!state.isMounted
|
||||
? <div className='brewRenderer' onScroll={getCurrentPage}>
|
||||
? <div className='brewRenderer' onScroll={updateCurrentPage}>
|
||||
<div className='pages'>
|
||||
{renderDummyPage(1)}
|
||||
</div>
|
||||
@@ -205,7 +236,7 @@ const BrewRenderer = (props)=>{
|
||||
<NotificationPopup />
|
||||
</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.*/}
|
||||
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
|
||||
@@ -213,19 +244,20 @@ const BrewRenderer = (props)=>{
|
||||
contentDidMount={frameDidMount}
|
||||
onClick={()=>{emitClick();}}
|
||||
>
|
||||
<div className={'brewRenderer'}
|
||||
onScroll={getCurrentPage}
|
||||
<div className={`brewRenderer ${global.config.deployment && 'deployment'}`}
|
||||
onScroll={updateCurrentPage}
|
||||
onKeyDown={handleControlKeys}
|
||||
tabIndex={-1}
|
||||
style={{ height: state.height }}>
|
||||
style={ styleObject }>
|
||||
|
||||
{/* Apply CSS from Style tab and render pages from Markdown tab */}
|
||||
{state.isMounted
|
||||
&&
|
||||
<>
|
||||
{renderStyle()}
|
||||
<div className='pages' lang={`${props.lang || 'en'}`} style={{ zoom: `${state.zoom}%` }}>
|
||||
{renderPages()}
|
||||
{renderedStyle}
|
||||
<div lang={`${props.lang || 'en'}`} style={pagesStyle} className={
|
||||
`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}` } >
|
||||
{renderedPages}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -3,9 +3,45 @@
|
||||
.brewRenderer {
|
||||
overflow-y : scroll;
|
||||
will-change : transform;
|
||||
padding-top : 30px;
|
||||
padding-top : 60px;
|
||||
height : 100vh;
|
||||
&:has(.facing, .flow) {
|
||||
padding : 60px 30px;
|
||||
}
|
||||
&.deployment {
|
||||
background-color: darkred;
|
||||
}
|
||||
: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) {
|
||||
width : 215.9mm;
|
||||
height : 279.4mm;
|
||||
@@ -14,6 +50,9 @@
|
||||
margin-left : auto;
|
||||
box-shadow : 1px 4px 14px #000000;
|
||||
}
|
||||
*[id] {
|
||||
scroll-margin-top:100px;
|
||||
}
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width : 20px;
|
||||
@@ -39,6 +78,7 @@
|
||||
overflow-y : unset;
|
||||
.pages {
|
||||
margin : 0px;
|
||||
zoom: 100% !important;
|
||||
& > .page { box-shadow : unset; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,62 @@
|
||||
require('./notificationPopup.less');
|
||||
const React = require('react');
|
||||
const _ = require('lodash');
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import request from '../../utils/request-middleware.js';
|
||||
|
||||
import Dialog from '../../../components/dialog.jsx';
|
||||
|
||||
const DISMISS_KEY = 'dismiss_notification04-09-24';
|
||||
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
|
||||
|
||||
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'>
|
||||
<i className='fas fa-info-circle info'></i>
|
||||
<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>
|
||||
</div>
|
||||
<ul>
|
||||
<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>
|
||||
{renderNotificationsList()}
|
||||
</ul>
|
||||
</Dialog>;
|
||||
};
|
||||
|
||||
@@ -55,7 +55,10 @@
|
||||
margin-top : 1.4em;
|
||||
font-size : 0.8em;
|
||||
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 _ = require('lodash');
|
||||
|
||||
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
|
||||
// import * as ZoomIcons from '../../../icons/icon-components/zoomIcons.jsx';
|
||||
|
||||
const MAX_ZOOM = 300;
|
||||
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);
|
||||
|
||||
useEffect(()=>{
|
||||
onZoomChange(zoomLevel);
|
||||
}, [zoomLevel]);
|
||||
const [pageNum, setPageNum] = useState(currentPage);
|
||||
const [toolsVisible, setToolsVisible] = useState(true);
|
||||
|
||||
useEffect(()=>{
|
||||
setPageNum(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
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)=>{
|
||||
@@ -55,53 +58,58 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
||||
|
||||
} else if(mode == 'fit'){
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 (
|
||||
<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*/}
|
||||
<div className='group'>
|
||||
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
|
||||
<button
|
||||
id='fill-width'
|
||||
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' />
|
||||
</button>
|
||||
<button
|
||||
id='zoom-to-fit'
|
||||
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' />
|
||||
</button>
|
||||
<button
|
||||
id='zoom-out'
|
||||
className='tool'
|
||||
onClick={()=>handleZoomButton(zoomLevel - 20)}
|
||||
disabled={zoomLevel <= MIN_ZOOM}
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
|
||||
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
|
||||
title='Zoom Out'
|
||||
>
|
||||
<i className='fas fa-magnifying-glass-minus' />
|
||||
</button>
|
||||
<input
|
||||
id='zoom-slider'
|
||||
className='range-input tool'
|
||||
className='range-input tool hover-tooltip'
|
||||
type='range'
|
||||
name='zoom'
|
||||
title='Set Zoom'
|
||||
list='zoomLevels'
|
||||
min={MIN_ZOOM}
|
||||
max={MAX_ZOOM}
|
||||
step='1'
|
||||
value={zoomLevel}
|
||||
value={displayOptions.zoomLevel}
|
||||
onChange={(e)=>handleZoomButton(parseInt(e.target.value))}
|
||||
/>
|
||||
<datalist id='zoomLevels'>
|
||||
@@ -111,18 +119,72 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
||||
<button
|
||||
id='zoom-in'
|
||||
className='tool'
|
||||
onClick={()=>handleZoomButton(zoomLevel + 20)}
|
||||
disabled={zoomLevel >= MAX_ZOOM}
|
||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
|
||||
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
|
||||
title='Zoom In'
|
||||
>
|
||||
<i className='fas fa-magnifying-glass-plus' />
|
||||
</button>
|
||||
</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*/}
|
||||
<div className='group'>
|
||||
<div className='group' role='group' aria-label='Pages' aria-hidden={!toolsVisible}>
|
||||
<button
|
||||
id='previous-page'
|
||||
className='previousPage tool'
|
||||
type='button'
|
||||
title='Previous Page(s)'
|
||||
onClick={()=>scrollToPage(pageNum - 1)}
|
||||
disabled={pageNum <= 1}
|
||||
>
|
||||
@@ -135,6 +197,7 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
||||
className='text-input'
|
||||
type='text'
|
||||
name='page'
|
||||
title='Current page(s) in view'
|
||||
inputMode='numeric'
|
||||
pattern='[0-9]'
|
||||
value={pageNum}
|
||||
@@ -143,12 +206,14 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
||||
onBlur={()=>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>
|
||||
|
||||
<button
|
||||
id='next-page'
|
||||
className='tool'
|
||||
type='button'
|
||||
title='Next Page(s)'
|
||||
onClick={()=>scrollToPage(pageNum + 1)}
|
||||
disabled={pageNum >= totalPages}
|
||||
>
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
height : auto;
|
||||
padding : 2px 0;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 13px;
|
||||
color : #CCCCCC;
|
||||
background-color : #555555;
|
||||
& > *:not(.toggleButton) {
|
||||
opacity : 1;
|
||||
transition : all 0.2s ease;
|
||||
}
|
||||
|
||||
.group {
|
||||
box-sizing : border-box;
|
||||
@@ -30,6 +35,70 @@
|
||||
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 {
|
||||
position : relative;
|
||||
height : 1.5em;
|
||||
@@ -53,7 +122,7 @@
|
||||
outline : none;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
&.hover-tooltip[value]:hover::after {
|
||||
position : absolute;
|
||||
bottom : -30px;
|
||||
left : 50%;
|
||||
@@ -79,25 +148,40 @@
|
||||
}
|
||||
|
||||
button {
|
||||
box-sizing : content-box;
|
||||
box-sizing : border-box;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
width : auto;
|
||||
min-width : 46px;
|
||||
height : 100%;
|
||||
padding : 0 0px;
|
||||
font-weight : unset;
|
||||
color : inherit;
|
||||
background-color : unset;
|
||||
&:hover { background-color : #444444; }
|
||||
&:focus { outline : 1px solid #D3D3D3; }
|
||||
&:focus { border : 1px solid #D3D3D3;outline : none;}
|
||||
&:disabled {
|
||||
color : #777777;
|
||||
background-color : unset !important;
|
||||
}
|
||||
i {
|
||||
font-size:1.2em;
|
||||
i { 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,11 +1,10 @@
|
||||
/*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/
|
||||
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
|
||||
require('./editor.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const _ = require('lodash');
|
||||
const cx = require('classnames');
|
||||
const dedent = require('dedent-tabs').default;
|
||||
const Markdown = require('../../../shared/naturalcrit/markdown.js');
|
||||
import Markdown from '../../../shared/naturalcrit/markdown.js';
|
||||
|
||||
const CodeEditor = require('naturalcrit/codeEditor/codeEditor.jsx');
|
||||
const SnippetBar = require('./snippetbar/snippetbar.jsx');
|
||||
@@ -22,6 +21,7 @@ const DEFAULT_STYLE_TEXT = dedent`
|
||||
color: black;
|
||||
}`;
|
||||
|
||||
let isJumping = false;
|
||||
|
||||
const Editor = createClass({
|
||||
displayName : 'Editor',
|
||||
@@ -37,8 +37,15 @@ const Editor = createClass({
|
||||
onMetaChange : ()=>{},
|
||||
reportError : ()=>{},
|
||||
|
||||
onCursorPageChange : ()=>{},
|
||||
onViewPageChange : ()=>{},
|
||||
|
||||
editorTheme : 'default',
|
||||
renderer : 'legacy'
|
||||
renderer : 'legacy',
|
||||
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
@@ -56,12 +63,16 @@ const Editor = createClass({
|
||||
isMeta : function() {return this.state.view == 'meta';},
|
||||
|
||||
componentDidMount : function() {
|
||||
|
||||
this.updateEditorSize();
|
||||
this.highlightCustomMarkdown();
|
||||
window.addEventListener('resize', this.updateEditorSize);
|
||||
document.getElementById('BrewRenderer').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);
|
||||
if(editorTheme) {
|
||||
this.setState({
|
||||
@@ -75,28 +86,37 @@ const Editor = createClass({
|
||||
},
|
||||
|
||||
componentDidUpdate : function(prevProps, prevState, snapshot) {
|
||||
|
||||
this.highlightCustomMarkdown();
|
||||
if(prevProps.moveBrew !== this.props.moveBrew) {
|
||||
if(prevProps.moveBrew !== this.props.moveBrew)
|
||||
this.brewJump();
|
||||
};
|
||||
if(prevProps.moveSource !== this.props.moveSource) {
|
||||
|
||||
if(prevProps.moveSource !== this.props.moveSource)
|
||||
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){
|
||||
if(!(e.ctrlKey && e.metaKey)) return;
|
||||
if(!(e.ctrlKey && e.metaKey && e.shiftKey)) return;
|
||||
const LEFTARROW_KEY = 37;
|
||||
const RIGHTARROW_KEY = 39;
|
||||
if (e.shiftKey && (e.keyCode == RIGHTARROW_KEY)) this.brewJump();
|
||||
if (e.shiftKey && (e.keyCode == LEFTARROW_KEY)) this.sourceJump();
|
||||
if ((e.keyCode == LEFTARROW_KEY) || (e.keyCode == RIGHTARROW_KEY)) {
|
||||
if(e.keyCode == RIGHTARROW_KEY) this.brewJump();
|
||||
if(e.keyCode == LEFTARROW_KEY) this.sourceJump();
|
||||
if(e.keyCode == LEFTARROW_KEY || e.keyCode == RIGHTARROW_KEY) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
updateEditorSize : function() {
|
||||
if(this.codeEditor.current) {
|
||||
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){
|
||||
this.codeEditor.current?.injectText(injectText, false);
|
||||
},
|
||||
@@ -119,18 +153,6 @@ const Editor = createClass({
|
||||
}); //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(){
|
||||
if(!this.codeEditor.current) return;
|
||||
if(this.state.view === 'text') {
|
||||
@@ -139,13 +161,13 @@ const Editor = createClass({
|
||||
codeMirror.operation(()=>{ // Batch CodeMirror styling
|
||||
|
||||
const foldLines = [];
|
||||
|
||||
|
||||
//reset custom text styles
|
||||
const customHighlights = codeMirror.getAllMarks().filter((mark)=>{
|
||||
// Record details of folded sections
|
||||
if(mark.__isFold) {
|
||||
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;
|
||||
}); //Don't undo code folding
|
||||
@@ -163,7 +185,7 @@ const Editor = createClass({
|
||||
|
||||
// Don't process lines inside folded text
|
||||
// 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;
|
||||
|
||||
// Styling for \page breaks
|
||||
@@ -189,7 +211,7 @@ const Editor = createClass({
|
||||
|
||||
// definition lists
|
||||
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.
|
||||
let match;
|
||||
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[2][0] }, { line: lineNumber, ch: match.indices[2][1] }, { className: 'dd-highlight' });
|
||||
const ddIndex = match.indices[2][0];
|
||||
let colons = /::/g;
|
||||
let colonMatches = colons.exec(match[2]);
|
||||
const colons = /::/g;
|
||||
const colonMatches = colons.exec(match[2]);
|
||||
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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,12 +232,12 @@ const Editor = createClass({
|
||||
let startIndex = line.indexOf('^');
|
||||
const superRegex = /\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^/gy;
|
||||
const subRegex = /\^\^(?!\s)(?=([^\n\^]*[^\s\^]))\1\^\^/gy;
|
||||
|
||||
|
||||
while (startIndex >= 0) {
|
||||
superRegex.lastIndex = subRegex.lastIndex = startIndex;
|
||||
let isSuper = false;
|
||||
let match = subRegex.exec(line) || superRegex.exec(line);
|
||||
if (match) {
|
||||
const match = subRegex.exec(line) || superRegex.exec(line);
|
||||
if(match) {
|
||||
isSuper = !subRegex.lastIndex;
|
||||
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) {
|
||||
emojiRegex.lastIndex = startIndex;
|
||||
let match = emojiRegex.exec(line);
|
||||
if (match) {
|
||||
const match = emojiRegex.exec(line);
|
||||
if(match) {
|
||||
let tokens = Markdown.marked.lexer(match[0]);
|
||||
tokens = tokens[0].tokens.filter(t => t.type == 'emoji')
|
||||
if (!tokens.length)
|
||||
tokens = tokens[0].tokens.filter((t)=>t.type == 'emoji');
|
||||
if(!tokens.length)
|
||||
return;
|
||||
|
||||
let startPos = { line: lineNumber, ch: match.index };
|
||||
let endPos = { line: lineNumber, ch: match.index + match[0].length };
|
||||
const startPos = { line: lineNumber, ch: match.index };
|
||||
const endPos = { line: lineNumber, ch: match.index + match[0].length };
|
||||
|
||||
// Iterate over conflicting marks and clear them
|
||||
var marks = codeMirror.findMarks(startPos, endPos);
|
||||
const marks = codeMirror.findMarks(startPos, endPos);
|
||||
marks.forEach(function(marker) {
|
||||
if(!marker.__isFold) marker.clear();
|
||||
});
|
||||
@@ -291,75 +313,93 @@ const Editor = createClass({
|
||||
}
|
||||
},
|
||||
|
||||
brewJump : function(targetPage=this.getCurrentPage()){
|
||||
if(!window) return;
|
||||
// console.log(`Scroll to: p${targetPage}`);
|
||||
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
|
||||
if(!window || !this.isText() || isJumping)
|
||||
return;
|
||||
|
||||
// Get current brewRenderer scroll position and calculate target position
|
||||
const brewRenderer = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer')[0];
|
||||
const currentPos = brewRenderer.scrollTop;
|
||||
const targetPos = window.frames['BrewRenderer'].contentDocument.getElementById(`p${targetPage}`).getBoundingClientRect().top;
|
||||
const interimPos = targetPos >= 0 ? -30 : 30;
|
||||
|
||||
const bounceDelay = 100;
|
||||
const scrollDelay = 500;
|
||||
|
||||
if(!this.throttleBrewMove) {
|
||||
this.throttleBrewMove = _.throttle((currentPos, interimPos, targetPos)=>{
|
||||
brewRenderer.scrollTo({ top: currentPos + interimPos, behavior: 'smooth' });
|
||||
setTimeout(()=>{
|
||||
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
|
||||
}, bounceDelay);
|
||||
}, scrollDelay, { leading: true, trailing: false });
|
||||
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
|
||||
};
|
||||
this.throttleBrewMove(currentPos, interimPos, targetPos);
|
||||
|
||||
// const hashPage = (page != 1) ? `p${page}` : '';
|
||||
// window.location.hash = hashPage;
|
||||
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 scrollDelay = 500;
|
||||
|
||||
if(!this.throttleBrewMove) {
|
||||
this.throttleBrewMove = _.throttle((currentPos, bouncePos, targetPos)=>{
|
||||
brewRenderer.scrollTo({ top: currentPos + bouncePos, behavior: 'smooth' });
|
||||
setTimeout(()=>{
|
||||
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'smooth', block: 'start' });
|
||||
}, bounceDelay);
|
||||
}, scrollDelay, { leading: true, trailing: false });
|
||||
};
|
||||
this.throttleBrewMove(currentPos, bouncePos, targetPos);
|
||||
} else {
|
||||
brewRenderer.scrollTo({ top: currentPos + targetPos, behavior: 'instant', block: 'start' });
|
||||
}
|
||||
},
|
||||
|
||||
sourceJump : function(targetLine=null){
|
||||
if(this.isText()) {
|
||||
if(targetLine == null) {
|
||||
targetLine = 0;
|
||||
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
|
||||
if(!this.isText() || isJumping)
|
||||
return;
|
||||
|
||||
const pageCollection = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('page');
|
||||
const brewRendererHeight = window.frames['BrewRenderer'].contentDocument.getElementsByClassName('brewRenderer').item(0).getBoundingClientRect().height;
|
||||
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;
|
||||
const textString = this.props.brew.text.split(textSplit).slice(0, targetPage-1).join(textSplit);
|
||||
const targetLine = textString.match('\n') ? textString.split('\n').length - 1 : -1;
|
||||
|
||||
let currentPage = 1;
|
||||
for (const page of pageCollection) {
|
||||
if(page.getBoundingClientRect().bottom > (brewRendererHeight / 2)) {
|
||||
currentPage = parseInt(page.id.slice(1)) || 1;
|
||||
break;
|
||||
}
|
||||
let currentY = this.codeEditor.current.codeMirror.getScrollInfo().top;
|
||||
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.
|
||||
const incrementalScroll = setInterval(()=>{
|
||||
currentY += (targetY - currentY) / 10;
|
||||
this.codeEditor.current.codeMirror.scrollTo(null, currentY);
|
||||
|
||||
// Update target: target height is not accurate until within +-10 lines of the visible window
|
||||
if(Math.abs(targetY - currentY > 100))
|
||||
targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
|
||||
|
||||
// End when close enough
|
||||
if(Math.abs(targetY - currentY) < 1) {
|
||||
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');
|
||||
clearInterval(incrementalScroll);
|
||||
}
|
||||
|
||||
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;
|
||||
const textString = this.props.brew.text.split(textSplit).slice(0, currentPage-1).join(textSplit);
|
||||
const textPosition = textString.length;
|
||||
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 targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
|
||||
|
||||
//Scroll 1/10 of the way every 10ms until 1px off.
|
||||
const incrementalScroll = setInterval(()=>{
|
||||
currentY += (targetY - currentY) / 10;
|
||||
this.codeEditor.current.codeMirror.scrollTo(null, currentY);
|
||||
|
||||
// Update target: target height is not accurate until within +-10 lines of the visible window
|
||||
if(Math.abs(targetY - currentY > 100))
|
||||
targetY = this.codeEditor.current.codeMirror.heightAtLine(targetLine, 'local', true);
|
||||
|
||||
// End when close enough
|
||||
if(Math.abs(targetY - currentY) < 1) {
|
||||
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');
|
||||
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}
|
||||
updateEditorTheme={this.updateEditorTheme}
|
||||
snippetBundle={this.props.snippetBundle}
|
||||
cursorPos={this.codeEditor.current?.getCursorPosition() || {}} />
|
||||
cursorPos={this.codeEditor.current?.getCursorPosition() || {}}
|
||||
updateBrew={this.props.updateBrew}
|
||||
/>
|
||||
|
||||
{this.renderEditor()}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.editor {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
container: editor / inline-size;
|
||||
|
||||
.codeEditor {
|
||||
height : 100%;
|
||||
|
||||
@@ -3,10 +3,10 @@ require('./metadataEditor.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const _ = require('lodash');
|
||||
const request = require('../../utils/request-middleware.js');
|
||||
import request from '../../utils/request-middleware.js';
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
const Combobox = require('client/components/combobox.jsx');
|
||||
const StringArrayEditor = require('../stringArrayEditor/stringArrayEditor.jsx');
|
||||
const TagInput = require('../tagInput/tagInput.jsx');
|
||||
|
||||
|
||||
const Themes = require('themes/themes.json');
|
||||
@@ -304,17 +304,14 @@ const MetadataEditor = createClass({
|
||||
onChange={(e)=>this.handleRenderer('V3', e)} />
|
||||
V3
|
||||
</label>
|
||||
|
||||
<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></small>
|
||||
</div>
|
||||
</div>;
|
||||
},
|
||||
|
||||
render : function(){
|
||||
return <div className='metadataEditor'>
|
||||
<h1 className='sectionHead'>Brew</h1>
|
||||
<h1>Properties Editor</h1>
|
||||
|
||||
<div className='field title'>
|
||||
<label>title</label>
|
||||
@@ -344,10 +341,11 @@ const MetadataEditor = createClass({
|
||||
{this.renderThumbnail()}
|
||||
</div>
|
||||
|
||||
<StringArrayEditor label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
|
||||
<TagInput label='tags' valuePatterns={[/^(?:(?:group|meta|system|type):)?[A-Za-z0-9][A-Za-z0-9 \/.\-]{0,40}$/]}
|
||||
placeholder='add tag' unique={true}
|
||||
values={this.props.metadata.tags}
|
||||
onChange={(e)=>this.handleFieldChange('tags', e)}/>
|
||||
onChange={(e)=>this.handleFieldChange('tags', e)}
|
||||
/>
|
||||
|
||||
<div className='field systems'>
|
||||
<label>systems</label>
|
||||
@@ -362,28 +360,25 @@ const MetadataEditor = createClass({
|
||||
|
||||
{this.renderRenderOptions()}
|
||||
|
||||
<hr/>
|
||||
|
||||
<h1 className='sectionHead'>Authors</h1>
|
||||
<h2>Authors</h2>
|
||||
|
||||
{this.renderAuthors()}
|
||||
|
||||
<StringArrayEditor label='invited authors' valuePatterns={[/.+/]}
|
||||
<TagInput label='invited authors' valuePatterns={[/.+/]}
|
||||
validators={[(v)=>!this.props.metadata.authors?.includes(v)]}
|
||||
placeholder='invite author' unique={true}
|
||||
values={this.props.metadata.invitedAuthors}
|
||||
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/>
|
||||
|
||||
<h1 className='sectionHead'>Privacy</h1>
|
||||
<h2>Privacy</h2>
|
||||
|
||||
<div className='field publish'>
|
||||
<label>publish</label>
|
||||
<div className='value'>
|
||||
{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>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import 'naturalcrit/styles/colors.less';
|
||||
|
||||
|
||||
.metadataEditor {
|
||||
position : absolute;
|
||||
z-index : 5;
|
||||
@@ -9,12 +10,19 @@
|
||||
padding : 25px;
|
||||
overflow-y : auto;
|
||||
background-color : #999999;
|
||||
font-size : 13px;
|
||||
|
||||
.sectionHead {
|
||||
h1 {
|
||||
margin: 0 0 40px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin : 20px 0;
|
||||
font-weight : 1000;
|
||||
|
||||
&:first-of-type { margin-top : 0; }
|
||||
font-weight : bold;
|
||||
border-bottom: 2px solid gray;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
& > div { margin-bottom : 10px; }
|
||||
@@ -43,15 +51,21 @@
|
||||
min-width : 200px;
|
||||
& > label {
|
||||
width : 80px;
|
||||
font-size : 11px;
|
||||
font-weight : 800;
|
||||
line-height : 1.8em;
|
||||
text-transform : uppercase;
|
||||
font-size: .9em;
|
||||
}
|
||||
& > .value {
|
||||
flex : 1 1 auto;
|
||||
width : 50px;
|
||||
&:invalid { background : #FFB9B9; }
|
||||
small {
|
||||
display : block;
|
||||
font-size : 0.9em;
|
||||
font-style : italic;
|
||||
line-height : 1.4em;
|
||||
}
|
||||
}
|
||||
input[type='text'], textarea {
|
||||
border : 1px solid gray;
|
||||
@@ -65,6 +79,7 @@
|
||||
text-overflow : ellipsis;
|
||||
}
|
||||
button {
|
||||
.colorButton();
|
||||
padding : 0px 5px;
|
||||
color : white;
|
||||
background-color : black;
|
||||
@@ -78,7 +93,6 @@
|
||||
textarea.value {
|
||||
height : auto;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
font-size : 0.8em;
|
||||
resize : none;
|
||||
}
|
||||
}
|
||||
@@ -87,12 +101,6 @@
|
||||
z-index : 200;
|
||||
max-width : 150px;
|
||||
}
|
||||
small {
|
||||
display : inline-block;
|
||||
font-size : 0.6em;
|
||||
font-style : italic;
|
||||
line-height : 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,18 +121,13 @@
|
||||
display : inline-flex;
|
||||
align-items : center;
|
||||
margin-right : 15px;
|
||||
font-size : 0.7em;
|
||||
font-size : 0.9em;
|
||||
font-weight : 800;
|
||||
white-space : nowrap;
|
||||
vertical-align : middle;
|
||||
cursor : pointer;
|
||||
user-select : none;
|
||||
}
|
||||
a {
|
||||
display : inline-flex;
|
||||
font-size : 0.7em;
|
||||
font-weight : 800;
|
||||
}
|
||||
input {
|
||||
margin : 3px;
|
||||
vertical-align : middle;
|
||||
@@ -136,25 +139,23 @@
|
||||
margin-bottom : 15px;
|
||||
button { width : 100%; }
|
||||
button.publish {
|
||||
.button(@blueLight);
|
||||
.colorButton(@blueLight);
|
||||
}
|
||||
button.unpublish {
|
||||
.button(@silver);
|
||||
.colorButton(@silver);
|
||||
}
|
||||
}
|
||||
|
||||
.delete.field .value {
|
||||
button {
|
||||
.button(@red);
|
||||
.colorButton(@red);
|
||||
}
|
||||
}
|
||||
.authors.field .value {
|
||||
font-size : 0.8em;
|
||||
line-height : 1.5em;
|
||||
}
|
||||
|
||||
.themes.field {
|
||||
font-size : 13.33px;
|
||||
.navDropdownContainer {
|
||||
position : relative;
|
||||
z-index : 100;
|
||||
@@ -165,9 +166,9 @@
|
||||
background-color : darkgray;
|
||||
}
|
||||
& > div:first-child {
|
||||
padding : 6px 3px;
|
||||
padding : 3px 3px;
|
||||
background-color : inherit;
|
||||
border : 2px solid rgb(118,118,118);
|
||||
border : 1px solid gray;
|
||||
i { float : right; }
|
||||
&:hover {
|
||||
color : white;
|
||||
@@ -240,6 +241,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field .list {
|
||||
display : flex;
|
||||
flex : 1 0;
|
||||
@@ -258,27 +260,26 @@
|
||||
color : white;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
|
||||
|
||||
i {
|
||||
position : relative;
|
||||
top : 50%;
|
||||
transform : translateY(-50%);
|
||||
}
|
||||
|
||||
|
||||
&:not(:last-child) { border-right : 1px solid black; }
|
||||
|
||||
|
||||
&:last-child { border-radius : 0 0.5em 0.5em 0; }
|
||||
}
|
||||
|
||||
.badge {
|
||||
.tag {
|
||||
padding : 0.3em;
|
||||
margin : 2px;
|
||||
font-size : 0.9em;
|
||||
background-color : #DDDDDD;
|
||||
border-radius : 0.5em;
|
||||
|
||||
.icon {
|
||||
#groupedIcon; }
|
||||
.icon { #groupedIcon; }
|
||||
}
|
||||
|
||||
.input-group {
|
||||
@@ -294,17 +295,30 @@
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.invalid:focus { background-color : pink; }
|
||||
.input-group {
|
||||
height : ~'calc(.9em + 4px + .6em)';
|
||||
|
||||
.icon {
|
||||
#groupedIcon;
|
||||
top : -0.54em;
|
||||
right : 1px;
|
||||
height : 97%;
|
||||
font-size : 0.8em;
|
||||
input { border-radius : 0.5em 0 0 0.5em; }
|
||||
|
||||
i { font-size : 1.125em; }
|
||||
input:last-child { border-radius : 0.5em; }
|
||||
|
||||
.value {
|
||||
width : 7.5vw;
|
||||
min-width : 75px;
|
||||
height : 100%;
|
||||
}
|
||||
|
||||
.invalid:focus { background-color : pink; }
|
||||
|
||||
.icon {
|
||||
#groupedIcon;
|
||||
top : -0.54em;
|
||||
right : 1px;
|
||||
height : 97%;
|
||||
|
||||
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');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const _ = require('lodash');
|
||||
const cx = require('classnames');
|
||||
|
||||
import { loadHistory } from '../../utils/versionHistory.js';
|
||||
|
||||
//Import all themes
|
||||
const ThemeSnippets = {};
|
||||
ThemeSnippets['Legacy_5ePHB'] = require('themes/Legacy/5ePHB/snippets.js');
|
||||
@@ -38,7 +40,8 @@ const Snippetbar = createClass({
|
||||
unfoldCode : ()=>{},
|
||||
updateEditorTheme : ()=>{},
|
||||
cursorPos : {},
|
||||
snippetBundle : []
|
||||
snippetBundle : [],
|
||||
updateBrew : ()=>{}
|
||||
};
|
||||
},
|
||||
|
||||
@@ -46,31 +49,54 @@ const Snippetbar = createClass({
|
||||
return {
|
||||
renderer : this.props.renderer,
|
||||
themeSelector : false,
|
||||
snippets : []
|
||||
snippets : [],
|
||||
showHistory : false,
|
||||
historyExists : false,
|
||||
historyItems : []
|
||||
};
|
||||
},
|
||||
|
||||
componentDidMount : async function() {
|
||||
componentDidMount : async function(prevState) {
|
||||
const snippets = this.compileSnippets();
|
||||
this.setState({
|
||||
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) {
|
||||
const snippets = this.compileSnippets();
|
||||
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) {
|
||||
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
|
||||
return _.filter(result, 'gen'); //Only keep snippets with a 'gen' property.
|
||||
return result.filter((snip)=>snip.gen || snip.subsnippets);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -124,69 +150,115 @@ const Snippetbar = createClass({
|
||||
|
||||
renderSnippetGroups : function(){
|
||||
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
|
||||
if(snippets.length === 0) return null;
|
||||
|
||||
return _.map(snippets, (snippetGroup)=>{
|
||||
return <SnippetGroup
|
||||
brew={this.props.brew}
|
||||
groupName={snippetGroup.groupName}
|
||||
icon={snippetGroup.icon}
|
||||
snippets={snippetGroup.snippets}
|
||||
key={snippetGroup.groupName}
|
||||
onSnippetClick={this.handleSnippetClick}
|
||||
cursorPos={this.props.cursorPos}
|
||||
/>;
|
||||
return <div className='snippets'>
|
||||
{_.map(snippets, (snippetGroup)=>{
|
||||
return <SnippetGroup
|
||||
brew={this.props.brew}
|
||||
groupName={snippetGroup.groupName}
|
||||
icon={snippetGroup.icon}
|
||||
snippets={snippetGroup.snippets}
|
||||
key={snippetGroup.groupName}
|
||||
onSnippetClick={this.handleSnippetClick}
|
||||
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(){
|
||||
if(!this.props.showEditButtons) return;
|
||||
|
||||
let foldButtons;
|
||||
if(this.props.view == 'text'){
|
||||
foldButtons =
|
||||
<>
|
||||
<div className={`editorTool foldAll ${this.props.foldCode ? 'active' : ''}`}
|
||||
onClick={this.props.foldCode} >
|
||||
<i className='fas fa-compress-alt' />
|
||||
</div>
|
||||
<div className={`editorTool unfoldAll ${this.props.unfoldCode ? 'active' : ''}`}
|
||||
onClick={this.props.unfoldCode} >
|
||||
<i className='fas fa-expand-alt' />
|
||||
</div>
|
||||
</>;
|
||||
|
||||
}
|
||||
const foldButtons = <>
|
||||
<div className={`editorTool foldAll ${this.props.view !== 'meta' && this.props.foldCode ? 'active' : ''}`}
|
||||
onClick={this.props.foldCode} >
|
||||
<i className='fas fa-compress-alt' />
|
||||
</div>
|
||||
<div className={`editorTool unfoldAll ${this.props.view !== 'meta' && this.props.unfoldCode ? 'active' : ''}`}
|
||||
onClick={this.props.unfoldCode} >
|
||||
<i className='fas fa-expand-alt' />
|
||||
</div>
|
||||
</>;
|
||||
|
||||
return <div className='editors'>
|
||||
<div className={`editorTool undo ${this.props.historySize.undo ? 'active' : ''}`}
|
||||
onClick={this.props.undo} >
|
||||
<i className='fas fa-undo' />
|
||||
<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' : ''}`}
|
||||
onClick={this.props.undo} >
|
||||
<i className='fas fa-undo' />
|
||||
</div>
|
||||
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
|
||||
onClick={this.props.redo} >
|
||||
<i className='fas fa-redo' />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`editorTool redo ${this.props.historySize.redo ? 'active' : ''}`}
|
||||
onClick={this.props.redo} >
|
||||
<i className='fas fa-redo' />
|
||||
</div>
|
||||
<div className='divider'></div>
|
||||
{foldButtons}
|
||||
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
||||
onClick={this.toggleThemeSelector} >
|
||||
<i className='fas fa-palette' />
|
||||
{this.state.themeSelector && this.renderThemeSelector()}
|
||||
<div className='codeTools'>
|
||||
{foldButtons}
|
||||
<div className={`editorTool editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
||||
onClick={this.toggleThemeSelector} >
|
||||
<i className='fas fa-palette' />
|
||||
{this.state.themeSelector && this.renderThemeSelector()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='divider'></div>
|
||||
<div className={cx('text', { selected: this.props.view === 'text' })}
|
||||
onClick={()=>this.props.onViewChange('text')}>
|
||||
<i className='fa fa-beer' />
|
||||
</div>
|
||||
<div className={cx('style', { selected: this.props.view === 'style' })}
|
||||
onClick={()=>this.props.onViewChange('style')}>
|
||||
<i className='fa fa-paint-brush' />
|
||||
</div>
|
||||
<div className={cx('meta', { selected: this.props.view === 'meta' })}
|
||||
onClick={()=>this.props.onViewChange('meta')}>
|
||||
<i className='fas fa-info-circle' />
|
||||
|
||||
<div className='tabs'>
|
||||
<div className={cx('text', { selected: this.props.view === 'text' })}
|
||||
onClick={()=>this.props.onViewChange('text')}>
|
||||
<i className='fa fa-beer' />
|
||||
</div>
|
||||
<div className={cx('style', { selected: this.props.view === 'style' })}
|
||||
onClick={()=>this.props.onViewChange('style')}>
|
||||
<i className='fa fa-paint-brush' />
|
||||
</div>
|
||||
<div className={cx('meta', { selected: this.props.view === 'meta' })}
|
||||
onClick={()=>this.props.onViewChange('meta')}>
|
||||
<i className='fas fa-info-circle' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>;
|
||||
},
|
||||
|
||||
@@ -224,8 +296,9 @@ const SnippetGroup = createClass({
|
||||
return _.map(snippets, (snippet)=>{
|
||||
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}>
|
||||
<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.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
|
||||
{snippet.subsnippets && <>
|
||||
<i className='fas fa-caret-right'></i>
|
||||
<div className='dropdown side'>
|
||||
|
||||
@@ -4,82 +4,114 @@
|
||||
.snippetBar {
|
||||
@menuHeight : 25px;
|
||||
position : relative;
|
||||
height : @menuHeight;
|
||||
display : flex;
|
||||
flex-wrap : wrap-reverse;
|
||||
justify-content : space-between;
|
||||
height : auto;
|
||||
color : black;
|
||||
background-color : #DDDDDD;
|
||||
|
||||
.editors {
|
||||
position : absolute;
|
||||
top : 0px;
|
||||
right : 0px;
|
||||
.snippets {
|
||||
display : flex;
|
||||
justify-content : space-between;
|
||||
height : @menuHeight;
|
||||
& > div {
|
||||
width : @menuHeight;
|
||||
height : @menuHeight;
|
||||
line-height : @menuHeight;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
&:hover,&.selected { background-color : #999999; }
|
||||
&.text {
|
||||
.tooltipLeft('Brew Editor');
|
||||
}
|
||||
&.style {
|
||||
.tooltipLeft('Style Editor');
|
||||
}
|
||||
&.meta {
|
||||
.tooltipLeft('Properties');
|
||||
}
|
||||
&.undo {
|
||||
.tooltipLeft('Undo');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.redo {
|
||||
.tooltipLeft('Redo');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.foldAll {
|
||||
.tooltipLeft('Fold All');
|
||||
font-size : 0.75em;
|
||||
color : inherit;
|
||||
}
|
||||
&.unfoldAll {
|
||||
.tooltipLeft('Unfold All');
|
||||
font-size : 0.75em;
|
||||
color : inherit;
|
||||
}
|
||||
&.editorTheme {
|
||||
.tooltipLeft('Editor Themes');
|
||||
font-size : 0.75em;
|
||||
color : black;
|
||||
&.active {
|
||||
position : relative;
|
||||
background-color : #999999;
|
||||
justify-content : flex-start;
|
||||
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 {
|
||||
position : relative;
|
||||
width : @menuHeight;
|
||||
height : @menuHeight;
|
||||
line-height : @menuHeight;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
&:hover,&.selected { background-color : #999999; }
|
||||
&.text {
|
||||
.tooltipLeft('Brew Editor');
|
||||
}
|
||||
&.style {
|
||||
.tooltipLeft('Style Editor');
|
||||
}
|
||||
&.meta {
|
||||
.tooltipLeft('Properties');
|
||||
}
|
||||
&.undo {
|
||||
.tooltipLeft('Undo');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.redo {
|
||||
.tooltipLeft('Redo');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.foldAll {
|
||||
.tooltipLeft('Fold All');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
&.active { color : inherit; }
|
||||
}
|
||||
&.unfoldAll {
|
||||
.tooltipLeft('Unfold All');
|
||||
font-size : 0.75em;
|
||||
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 {
|
||||
.tooltipLeft('Editor Themes');
|
||||
font-size : 0.75em;
|
||||
color : black;
|
||||
&.active {
|
||||
position : relative;
|
||||
background-color : #999999;
|
||||
}
|
||||
}
|
||||
&.divider {
|
||||
width : 5px;
|
||||
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
|
||||
&:hover { background-color : inherit; }
|
||||
}
|
||||
}
|
||||
&.divider {
|
||||
width : 5px;
|
||||
background : linear-gradient(currentColor, currentColor) no-repeat center/1px 100%;
|
||||
&:hover { background-color : inherit; }
|
||||
.themeSelector {
|
||||
position : absolute;
|
||||
top : 25px;
|
||||
right : 0;
|
||||
z-index : 10;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
width : 170px;
|
||||
height : inherit;
|
||||
background-color : inherit;
|
||||
}
|
||||
}
|
||||
.themeSelector {
|
||||
position : absolute;
|
||||
top : 25px;
|
||||
right : 0;
|
||||
z-index : 10;
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
width : 170px;
|
||||
height : inherit;
|
||||
background-color : inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
.snippetBarButton {
|
||||
display : inline-block;
|
||||
@@ -89,6 +121,7 @@
|
||||
font-weight : 800;
|
||||
line-height : @menuHeight;
|
||||
text-transform : uppercase;
|
||||
text-wrap : nowrap;
|
||||
cursor : pointer;
|
||||
&:hover, &.selected { background-color : #999999; }
|
||||
i {
|
||||
@@ -105,7 +138,7 @@
|
||||
.tooltipLeft('Edit Brew Properties');
|
||||
}
|
||||
.snippetGroup {
|
||||
border-right : 1px solid currentColor;
|
||||
|
||||
&:hover {
|
||||
& > .dropdown { visibility : visible; }
|
||||
}
|
||||
@@ -127,11 +160,11 @@
|
||||
cursor : pointer;
|
||||
.animate(background-color);
|
||||
i {
|
||||
min-width : 25px;
|
||||
height : 1.2em;
|
||||
margin-right : 8px;
|
||||
font-size : 1.2em;
|
||||
min-width: 25px;
|
||||
text-align: center;
|
||||
text-align : center;
|
||||
& ~ i {
|
||||
margin-right : 0;
|
||||
margin-left : 5px;
|
||||
@@ -164,6 +197,7 @@
|
||||
}
|
||||
}
|
||||
.name { margin-right : auto; }
|
||||
.disabled { text-decoration : line-through; }
|
||||
.beta {
|
||||
align-self : center;
|
||||
padding : 4px 6px;
|
||||
@@ -189,4 +223,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const _ = require('lodash');
|
||||
|
||||
const StringArrayEditor = createClass({
|
||||
displayName : 'StringArrayEditor',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
label : '',
|
||||
values : [],
|
||||
valuePatterns : null,
|
||||
validators : [],
|
||||
placeholder : '',
|
||||
notes : [],
|
||||
unique : false,
|
||||
cannotEdit : [],
|
||||
onChange : ()=>{}
|
||||
};
|
||||
},
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
valueContext : !!this.props.values ? this.props.values.map((value)=>({
|
||||
value,
|
||||
editing : false
|
||||
})) : [],
|
||||
temporaryValue : '',
|
||||
updateValue : ''
|
||||
};
|
||||
},
|
||||
|
||||
componentDidUpdate : function(prevProps) {
|
||||
if(!_.eq(this.props.values, prevProps.values)) {
|
||||
this.setState({
|
||||
valueContext : this.props.values ? this.props.values.map((newValue)=>({
|
||||
value : newValue,
|
||||
editing : this.state.valueContext.find(({ value })=>value === newValue)?.editing || false
|
||||
})) : []
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleChange : function(value) {
|
||||
this.props.onChange({
|
||||
target : {
|
||||
value
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
addValue : function(value){
|
||||
this.handleChange(_.uniq([...this.props.values, value]));
|
||||
this.setState({
|
||||
temporaryValue : ''
|
||||
});
|
||||
},
|
||||
|
||||
removeValue : function(index){
|
||||
this.handleChange(this.props.values.filter((_, i)=>i !== index));
|
||||
},
|
||||
|
||||
updateValue : function(value, index){
|
||||
const valueContext = this.state.valueContext;
|
||||
valueContext[index].value = value;
|
||||
valueContext[index].editing = false;
|
||||
this.handleChange(valueContext.map((context)=>context.value));
|
||||
this.setState({ valueContext, updateValue: '' });
|
||||
},
|
||||
|
||||
editValue : function(index){
|
||||
if(!!this.props.cannotEdit && this.props.cannotEdit.includes(this.props.values[index])) {
|
||||
return;
|
||||
}
|
||||
const valueContext = this.state.valueContext.map((context, i)=>{
|
||||
context.editing = index === i;
|
||||
return context;
|
||||
});
|
||||
this.setState({ valueContext, updateValue: this.props.values[index] });
|
||||
},
|
||||
|
||||
valueIsValid : function(value, index) {
|
||||
const values = _.clone(this.props.values);
|
||||
if(index !== undefined) {
|
||||
values.splice(index, 1);
|
||||
}
|
||||
const matchesPatterns = !this.props.valuePatterns || this.props.valuePatterns.some((pattern)=>!!(value || '').match(pattern));
|
||||
const uniqueIfSet = !this.props.unique || !values.includes(value);
|
||||
const passesValidators = !this.props.validators || this.props.validators.every((validator)=>validator(value));
|
||||
return matchesPatterns && uniqueIfSet && passesValidators;
|
||||
},
|
||||
|
||||
handleValueInputKeyDown : function(event, index) {
|
||||
if(event.key === 'Enter') {
|
||||
if(this.valueIsValid(event.target.value, index)) {
|
||||
if(index !== undefined) {
|
||||
this.updateValue(event.target.value, index);
|
||||
} else {
|
||||
this.addValue(event.target.value);
|
||||
}
|
||||
}
|
||||
} else if(event.key === 'Escape') {
|
||||
this.closeEditInput(index);
|
||||
}
|
||||
},
|
||||
|
||||
closeEditInput : function(index) {
|
||||
const valueContext = this.state.valueContext;
|
||||
valueContext[index].editing = false;
|
||||
this.setState({ valueContext, updateValue: '' });
|
||||
},
|
||||
|
||||
render : function() {
|
||||
const valueElements = Object.values(this.state.valueContext).map((context, i)=>context.editing
|
||||
? <React.Fragment key={i}>
|
||||
<div className='input-group'>
|
||||
<input type='text' className={`value ${this.valueIsValid(this.state.updateValue, i) ? '' : 'invalid'}`} autoFocus placeholder={this.props.placeholder}
|
||||
value={this.state.updateValue}
|
||||
onKeyDown={(e)=>this.handleValueInputKeyDown(e, i)}
|
||||
onChange={(e)=>this.setState({ updateValue: e.target.value })}/>
|
||||
{<div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.closeEditInput(i); }}><i className='fa fa-undo fa-fw'/></div>}
|
||||
{this.valueIsValid(this.state.updateValue, i) ? <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.updateValue(this.state.updateValue, i); }}><i className='fa fa-check fa-fw'/></div> : null}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
: <div className='badge' key={i} onClick={()=>this.editValue(i)}>{context.value}
|
||||
{!!this.props.cannotEdit && this.props.cannotEdit.includes(context.value) ? null : <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.removeValue(i); }}><i className='fa fa-times fa-fw'/></div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <div className='field'>
|
||||
<label>{this.props.label}</label>
|
||||
<div style={{ flex: '1 0' }}>
|
||||
<div className='list'>
|
||||
{valueElements}
|
||||
<div className='input-group'>
|
||||
<input type='text' className={`value ${this.valueIsValid(this.state.temporaryValue) ? '' : 'invalid'}`} placeholder={this.props.placeholder}
|
||||
value={this.state.temporaryValue}
|
||||
onKeyDown={(e)=>this.handleValueInputKeyDown(e)}
|
||||
onChange={(e)=>this.setState({ temporaryValue: e.target.value })}/>
|
||||
{this.valueIsValid(this.state.temporaryValue) ? <div className='icon steel' onClick={(e)=>{ e.stopPropagation(); this.addValue(this.state.temporaryValue); }}><i className='fa fa-check fa-fw'/></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.props.notes ? this.props.notes.map((n, index)=><p key={index}><small>{n}</small></p>) : null}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = StringArrayEditor;
|
||||
105
client/homebrew/editor/tagInput/tagInput.jsx
Normal file
105
client/homebrew/editor/tagInput/tagInput.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
require('./tagInput.less');
|
||||
const React = require('react');
|
||||
const { useState, useEffect } = React;
|
||||
const _ = require('lodash');
|
||||
|
||||
const TagInput = ({ unique = true, values = [], ...props }) => {
|
||||
const [tempInputText, setTempInputText] = useState('');
|
||||
const [tagList, setTagList] = useState(values.map((value) => ({ value, editing: false })));
|
||||
|
||||
useEffect(()=>{
|
||||
handleChange(tagList.map((context)=>context.value))
|
||||
}, [tagList])
|
||||
|
||||
const handleChange = (value)=>{
|
||||
props.onChange({
|
||||
target : { value }
|
||||
})
|
||||
};
|
||||
|
||||
const handleInputKeyDown = ({ evt, value, index, options = {} }) => {
|
||||
if (_.includes(['Enter', ','], evt.key)) {
|
||||
evt.preventDefault();
|
||||
submitTag(evt.target.value, value, index);
|
||||
if (options.clear) {
|
||||
setTempInputText('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const submitTag = (newValue, originalValue, index) => {
|
||||
setTagList((prevContext) => {
|
||||
// remove existing tag
|
||||
if(newValue === null){
|
||||
return [...prevContext].filter((context, i)=>i !== index);
|
||||
}
|
||||
// add new tag
|
||||
if(originalValue === null){
|
||||
return [...prevContext, { value: newValue, editing: false }]
|
||||
}
|
||||
// update existing tag
|
||||
return prevContext.map((context, i) => {
|
||||
if (i === index) {
|
||||
return { ...context, value: newValue, editing: false };
|
||||
}
|
||||
return context;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const editTag = (index) => {
|
||||
setTagList((prevContext) => {
|
||||
return prevContext.map((context, i) => {
|
||||
if (i === index) {
|
||||
return { ...context, editing: true };
|
||||
}
|
||||
return { ...context, editing: false };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderReadTag = (context, index) => {
|
||||
return (
|
||||
<li key={index}
|
||||
data-value={context.value}
|
||||
className='tag'
|
||||
onClick={() => editTag(index)}>
|
||||
{context.value}
|
||||
<button onClick={(evt)=>{evt.stopPropagation(); submitTag(null, context.value, index)}}><i className='fa fa-times fa-fw'/></button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWriteTag = (context, index) => {
|
||||
return (
|
||||
<input type='text'
|
||||
key={index}
|
||||
defaultValue={context.value}
|
||||
onKeyDown={(evt) => handleInputKeyDown({evt, value: context.value, index: index})}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='field'>
|
||||
<label>{props.label}</label>
|
||||
<div className='value'>
|
||||
<ul className='list'>
|
||||
{tagList.map((context, index) => { return context.editing ? renderWriteTag(context, index) : renderReadTag(context, index); })}
|
||||
</ul>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className='value'
|
||||
placeholder={props.placeholder}
|
||||
value={tempInputText}
|
||||
onChange={(e) => setTempInputText(e.target.value)}
|
||||
onKeyDown={(evt) => handleInputKeyDown({ evt, value: null, options: { clear: true } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = TagInput;
|
||||
0
client/homebrew/editor/tagInput/tagInput.less
Normal file
0
client/homebrew/editor/tagInput/tagInput.less
Normal file
@@ -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;
|
||||
@@ -111,7 +111,7 @@ const ErrorNavItem = createClass({
|
||||
Looks like there was a problem retreiving
|
||||
the theme, or a theme that it inherits,
|
||||
for this brew. Verify that brew <a className='lowercase' target='_blank' rel='noopener noreferrer' href={`/share/${response.body.brewId}`}>
|
||||
{response.body.brewId}</a> still exists!
|
||||
{response.body.brewId}</a> still exists!
|
||||
</div>
|
||||
</Nav.item>;
|
||||
}
|
||||
|
||||
@@ -25,16 +25,20 @@
|
||||
|
||||
.homebrew nav {
|
||||
background-color : #333333;
|
||||
.navContent {
|
||||
position : relative;
|
||||
z-index : 2;
|
||||
display : flex;
|
||||
justify-content : space-between;
|
||||
}
|
||||
position : relative;
|
||||
z-index : 2;
|
||||
display : flex;
|
||||
justify-content : space-between;
|
||||
|
||||
.navSection {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
&:last-child .navItem { border-left : 1px solid #666666; }
|
||||
|
||||
&:has(.brewTitle) {
|
||||
flex-grow : 1;
|
||||
min-width : 300px;
|
||||
}
|
||||
}
|
||||
// "NaturalCrit" logo
|
||||
.navLogo {
|
||||
@@ -69,6 +73,10 @@
|
||||
.navItem {
|
||||
#backgroundColorsHover;
|
||||
.animate(background-color);
|
||||
display : flex;
|
||||
align-items : center;
|
||||
justify-content : center;
|
||||
height : 100%;
|
||||
padding : 8px 12px;
|
||||
font-size : 10px;
|
||||
font-weight : 800;
|
||||
@@ -94,39 +102,20 @@
|
||||
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 {
|
||||
flex-grow : 1;
|
||||
display : block;
|
||||
width : 100%;
|
||||
overflow : hidden;
|
||||
font-size : 12px;
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
text-align : center;
|
||||
text-transform : initial;
|
||||
background-color : transparent;
|
||||
text-overflow : ellipsis;
|
||||
text-transform : initial;
|
||||
white-space : nowrap;
|
||||
background-color : transparent;
|
||||
}
|
||||
|
||||
// "The Homebrewery" logo
|
||||
&.homebrewLogo {
|
||||
.animate(color);
|
||||
@@ -240,23 +229,25 @@
|
||||
}
|
||||
.navDropdownContainer {
|
||||
position : relative;
|
||||
height : 100%;
|
||||
|
||||
.navDropdown {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 0px;
|
||||
z-index: 10000;
|
||||
width: max-content;
|
||||
min-width:100%;
|
||||
max-height: calc(100vh - 28px);
|
||||
overflow: hidden auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
position : absolute;
|
||||
//top: 28px;
|
||||
right : 0px;
|
||||
z-index : 10000;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
align-items : flex-end;
|
||||
width : max-content;
|
||||
min-width : 100%;
|
||||
max-height : calc(100vh - 28px);
|
||||
overflow : hidden auto;
|
||||
.navItem {
|
||||
position : relative;
|
||||
display : flex;
|
||||
justify-content : space-between;
|
||||
align-items : center;
|
||||
justify-content : space-between;
|
||||
width : 100%;
|
||||
border : 1px solid #888888;
|
||||
border-bottom : 0;
|
||||
@@ -278,10 +269,10 @@
|
||||
overflow : hidden auto;
|
||||
color : white;
|
||||
text-decoration : none;
|
||||
background-color : #333333;
|
||||
border-top : 1px solid #888888;
|
||||
scrollbar-color : #666666 #333333;
|
||||
scrollbar-width : thin;
|
||||
background-color : #333333;
|
||||
border-top : 1px solid #888888;
|
||||
.clear {
|
||||
position : absolute;
|
||||
top : 50%;
|
||||
|
||||
@@ -36,7 +36,7 @@ const RecentItems = createClass({
|
||||
//== Add current brew to appropriate recent items list (depending on storageKey) ==//
|
||||
if(this.props.storageKey == 'edit'){
|
||||
let editId = this.props.brew.editId;
|
||||
if(this.props.brew.googleId){
|
||||
if(this.props.brew.googleId && !this.props.brew.stubbed){
|
||||
editId = `${this.props.brew.googleId}${this.props.brew.editId}`;
|
||||
}
|
||||
edited = _.filter(edited, (brew)=>{
|
||||
@@ -51,7 +51,7 @@ const RecentItems = createClass({
|
||||
}
|
||||
if(this.props.storageKey == 'view'){
|
||||
let shareId = this.props.brew.shareId;
|
||||
if(this.props.brew.googleId){
|
||||
if(this.props.brew.googleId && !this.props.brew.stubbed){
|
||||
shareId = `${this.props.brew.googleId}${this.props.brew.shareId}`;
|
||||
}
|
||||
viewed = _.filter(viewed, (brew)=>{
|
||||
@@ -83,7 +83,7 @@ const RecentItems = createClass({
|
||||
let edited = JSON.parse(localStorage.getItem(EDIT_KEY) || '[]');
|
||||
if(this.props.storageKey == 'edit') {
|
||||
let prevEditId = prevProps.brew.editId;
|
||||
if(prevProps.brew.googleId){
|
||||
if(prevProps.brew.googleId && !this.props.brew.stubbed){
|
||||
prevEditId = `${prevProps.brew.googleId}${prevProps.brew.editId}`;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ const RecentItems = createClass({
|
||||
return brew.id !== prevEditId;
|
||||
});
|
||||
let editId = this.props.brew.editId;
|
||||
if(this.props.brew.googleId){
|
||||
if(this.props.brew.googleId && !this.props.brew.stubbed){
|
||||
editId = `${this.props.brew.googleId}${this.props.brew.editId}`;
|
||||
}
|
||||
edited.unshift({
|
||||
|
||||
@@ -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;
|
||||
@@ -2,7 +2,7 @@ require('./brewItem.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const moment = require('moment');
|
||||
const request = require('../../../../utils/request-middleware.js');
|
||||
import request from '../../../../utils/request-middleware.js';
|
||||
|
||||
const googleDriveIcon = require('../../../../googleDrive.svg');
|
||||
const homebreweryIcon = require('../../../../thumbnail.png');
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable max-lines */
|
||||
require('./editPage.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const _ = require('lodash');
|
||||
const request = require('../../utils/request-middleware.js');
|
||||
const createClass = require('create-react-class');
|
||||
|
||||
import request from '../../utils/request-middleware.js';
|
||||
const { Meta } = require('vitreum/headtags');
|
||||
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
@@ -22,14 +23,16 @@ const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
|
||||
|
||||
const LockNotification = require('./lockNotification/lockNotification.jsx');
|
||||
|
||||
const Markdown = require('naturalcrit/markdown.js');
|
||||
import Markdown from 'naturalcrit/markdown.js';
|
||||
|
||||
const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js');
|
||||
const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js');
|
||||
|
||||
import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js';
|
||||
|
||||
const googleDriveIcon = require('../../googleDrive.svg');
|
||||
|
||||
const SAVE_TIMEOUT = 3000;
|
||||
const SAVE_TIMEOUT = 10000;
|
||||
|
||||
const EditPage = createClass({
|
||||
displayName : 'EditPage',
|
||||
@@ -41,22 +44,24 @@ const EditPage = createClass({
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
brew : this.props.brew,
|
||||
isSaving : false,
|
||||
isPending : false,
|
||||
alertTrashedGoogleBrew : this.props.brew.trashed,
|
||||
alertLoginToTransfer : false,
|
||||
saveGoogle : this.props.brew.googleId ? true : false,
|
||||
confirmGoogleTransfer : false,
|
||||
error : null,
|
||||
htmlErrors : Markdown.validate(this.props.brew.text),
|
||||
url : '',
|
||||
autoSave : true,
|
||||
autoSaveWarning : false,
|
||||
unsavedTime : new Date(),
|
||||
currentEditorPage : 0,
|
||||
displayLockMessage : this.props.brew.lock || false,
|
||||
themeBundle : {}
|
||||
brew : this.props.brew,
|
||||
isSaving : false,
|
||||
isPending : false,
|
||||
alertTrashedGoogleBrew : this.props.brew.trashed,
|
||||
alertLoginToTransfer : false,
|
||||
saveGoogle : this.props.brew.googleId ? true : false,
|
||||
confirmGoogleTransfer : false,
|
||||
error : null,
|
||||
htmlErrors : Markdown.validate(this.props.brew.text),
|
||||
url : '',
|
||||
autoSave : true,
|
||||
autoSaveWarning : false,
|
||||
unsavedTime : new Date(),
|
||||
currentEditorViewPageNum : 1,
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
displayLockMessage : this.props.brew.lock || false,
|
||||
themeBundle : {}
|
||||
};
|
||||
},
|
||||
|
||||
@@ -113,16 +118,27 @@ const EditPage = createClass({
|
||||
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){
|
||||
//If there are errors, run the validator on every change to give quick feedback
|
||||
let htmlErrors = this.state.htmlErrors;
|
||||
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
|
||||
|
||||
this.setState((prevState)=>({
|
||||
brew : { ...prevState.brew, text: text },
|
||||
isPending : true,
|
||||
htmlErrors : htmlErrors,
|
||||
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
|
||||
brew : { ...prevState.brew, text: text },
|
||||
isPending : true,
|
||||
htmlErrors : htmlErrors,
|
||||
}), ()=>{if(this.state.autoSave) this.trySave();});
|
||||
},
|
||||
|
||||
@@ -150,6 +166,16 @@ const EditPage = createClass({
|
||||
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){
|
||||
if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT);
|
||||
if(this.hasChanges()){
|
||||
@@ -202,6 +228,9 @@ const EditPage = createClass({
|
||||
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 brew = this.state.brew;
|
||||
@@ -400,32 +429,41 @@ const EditPage = createClass({
|
||||
<Meta name='robots' content='noindex, nofollow' />
|
||||
{this.renderNavbar()}
|
||||
|
||||
<div className='content'>
|
||||
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
|
||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
brew={this.state.brew}
|
||||
onTextChange={this.handleTextChange}
|
||||
onStyleChange={this.handleStyleChange}
|
||||
onMetaChange={this.handleMetaChange}
|
||||
reportError={this.errorReported}
|
||||
renderer={this.state.brew.renderer}
|
||||
userThemes={this.props.userThemes}
|
||||
snippetBundle={this.state.themeBundle.snippets}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={this.state.brew.text}
|
||||
style={this.state.brew.style}
|
||||
renderer={this.state.brew.renderer}
|
||||
theme={this.state.brew.theme}
|
||||
themeBundle={this.state.themeBundle}
|
||||
errors={this.state.htmlErrors}
|
||||
lang={this.state.brew.lang}
|
||||
currentEditorPage={this.state.currentEditorPage}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
{this.props.brew.lock && <LockNotification shareId={this.props.brew.shareId} message={this.props.brew.lock.editMessage} />}
|
||||
<div className="content">
|
||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
brew={this.state.brew}
|
||||
onTextChange={this.handleTextChange}
|
||||
onStyleChange={this.handleStyleChange}
|
||||
onMetaChange={this.handleMetaChange}
|
||||
reportError={this.errorReported}
|
||||
renderer={this.state.brew.renderer}
|
||||
userThemes={this.props.userThemes}
|
||||
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
|
||||
text={this.state.brew.text}
|
||||
style={this.state.brew.style}
|
||||
renderer={this.state.brew.renderer}
|
||||
theme={this.state.brew.theme}
|
||||
themeBundle={this.state.themeBundle}
|
||||
errors={this.state.htmlErrors}
|
||||
lang={this.state.brew.lang}
|
||||
onPageChange={this.handleBrewRendererPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require('./errorPage.less');
|
||||
const React = require('react');
|
||||
const UIPage = require('../basePages/uiPage/uiPage.jsx');
|
||||
const Markdown = require('../../../../shared/naturalcrit/markdown.js');
|
||||
import Markdown from '../../../../shared/naturalcrit/markdown.js';
|
||||
const ErrorIndex = require('./errors/errorIndex.js');
|
||||
|
||||
const ErrorPage = ({ brew })=>{
|
||||
|
||||
@@ -172,6 +172,11 @@ const errorIndex = (props)=>{
|
||||
|
||||
**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.
|
||||
Try again in a few minutes.`,
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
require('./homePage.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const _ = require('lodash');
|
||||
const cx = require('classnames');
|
||||
const request = require('../../utils/request-middleware.js');
|
||||
import request from '../../utils/request-middleware.js';
|
||||
const { Meta } = require('vitreum/headtags');
|
||||
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
@@ -32,11 +31,13 @@ const HomePage = createClass({
|
||||
},
|
||||
getInitialState : function() {
|
||||
return {
|
||||
brew : this.props.brew,
|
||||
welcomeText : this.props.brew.text,
|
||||
error : undefined,
|
||||
currentEditorPage : 0,
|
||||
themeBundle : {}
|
||||
brew : this.props.brew,
|
||||
welcomeText : this.props.brew.text,
|
||||
error : undefined,
|
||||
currentEditorViewPageNum : 1,
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
themeBundle : {}
|
||||
};
|
||||
},
|
||||
|
||||
@@ -61,10 +62,22 @@ const HomePage = createClass({
|
||||
handleSplitMove : function(){
|
||||
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){
|
||||
this.setState((prevState)=>({
|
||||
brew : { ...prevState.brew, text: text },
|
||||
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
|
||||
brew : { ...prevState.brew, text: text },
|
||||
}));
|
||||
},
|
||||
renderNavbar : function(){
|
||||
@@ -87,27 +100,33 @@ const HomePage = createClass({
|
||||
return <div className='homePage sitePage'>
|
||||
<Meta name='google-site-verification' content='NwnAQSSJZzAT7N-p5MY6ydQ7Njm67dtbu73ZSyE5Fy4' />
|
||||
{this.renderNavbar()}
|
||||
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
brew={this.state.brew}
|
||||
onTextChange={this.handleTextChange}
|
||||
renderer={this.state.brew.renderer}
|
||||
showEditButtons={false}
|
||||
snippetBundle={this.state.themeBundle.snippets}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={this.state.brew.text}
|
||||
style={this.state.brew.style}
|
||||
renderer={this.state.brew.renderer}
|
||||
currentEditorPage={this.state.currentEditorPage}
|
||||
themeBundle={this.state.themeBundle}
|
||||
/>
|
||||
</SplitPane>
|
||||
<div className="content">
|
||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
brew={this.state.brew}
|
||||
onTextChange={this.handleTextChange}
|
||||
renderer={this.state.brew.renderer}
|
||||
showEditButtons={false}
|
||||
snippetBundle={this.state.themeBundle.snippets}
|
||||
onCursorPageChange={this.handleEditorCursorPageChange}
|
||||
onViewPageChange={this.handleEditorViewPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={this.state.brew.text}
|
||||
style={this.state.brew.style}
|
||||
renderer={this.state.brew.renderer}
|
||||
onPageChange={this.handleBrewRendererPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
themeBundle={this.state.themeBundle}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
|
||||
<div className={cx('floatingSaveButton', { show: this.state.welcomeText != this.state.brew.text })} onClick={this.handleSave}>
|
||||
Save current <i className='fas fa-save' />
|
||||
</div>
|
||||
|
||||
@@ -91,13 +91,6 @@ If you are looking for more 5e Homebrew resources check out [r/UnearthedArcana](
|
||||
|
||||
\page
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
require('./newPage.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const request = require('../../utils/request-middleware.js');
|
||||
import request from '../../utils/request-middleware.js';
|
||||
|
||||
const Markdown = require('naturalcrit/markdown.js');
|
||||
import Markdown from 'naturalcrit/markdown.js';
|
||||
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
const PrintNavItem = require('../../navbar/print.navitem.jsx');
|
||||
@@ -39,13 +39,15 @@ const NewPage = createClass({
|
||||
const brew = this.props.brew;
|
||||
|
||||
return {
|
||||
brew : brew,
|
||||
isSaving : false,
|
||||
saveGoogle : (global.account && global.account.googleId ? true : false),
|
||||
error : null,
|
||||
htmlErrors : Markdown.validate(brew.text),
|
||||
currentEditorPage : 0,
|
||||
themeBundle : {}
|
||||
brew : brew,
|
||||
isSaving : false,
|
||||
saveGoogle : (global.account && global.account.googleId ? true : false),
|
||||
error : null,
|
||||
htmlErrors : Markdown.validate(brew.text),
|
||||
currentEditorViewPageNum : 1,
|
||||
currentEditorCursorPageNum : 1,
|
||||
currentBrewRendererPageNum : 1,
|
||||
themeBundle : {}
|
||||
};
|
||||
},
|
||||
|
||||
@@ -108,15 +110,26 @@ const NewPage = createClass({
|
||||
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){
|
||||
//If there are errors, run the validator on every change to give quick feedback
|
||||
let htmlErrors = this.state.htmlErrors;
|
||||
if(htmlErrors.length) htmlErrors = Markdown.validate(text);
|
||||
|
||||
this.setState((prevState)=>({
|
||||
brew : { ...prevState.brew, text: text },
|
||||
htmlErrors : htmlErrors,
|
||||
currentEditorPage : this.editor.current.getCurrentPage() - 1 //Offset index since Marked starts pages at 0
|
||||
brew : { ...prevState.brew, text: text },
|
||||
htmlErrors : htmlErrors,
|
||||
}));
|
||||
localStorage.setItem(BREWKEY, text);
|
||||
},
|
||||
@@ -210,30 +223,38 @@ const NewPage = createClass({
|
||||
render : function(){
|
||||
return <div className='newPage sitePage'>
|
||||
{this.renderNavbar()}
|
||||
<div className='content'>
|
||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
brew={this.state.brew}
|
||||
onTextChange={this.handleTextChange}
|
||||
onStyleChange={this.handleStyleChange}
|
||||
onMetaChange={this.handleMetaChange}
|
||||
renderer={this.state.brew.renderer}
|
||||
userThemes={this.props.userThemes}
|
||||
snippetBundle={this.state.themeBundle.snippets}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={this.state.brew.text}
|
||||
style={this.state.brew.style}
|
||||
renderer={this.state.brew.renderer}
|
||||
theme={this.state.brew.theme}
|
||||
themeBundle={this.state.themeBundle}
|
||||
errors={this.state.htmlErrors}
|
||||
lang={this.state.brew.lang}
|
||||
currentEditorPage={this.state.currentEditorPage}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
<div className="content">
|
||||
<SplitPane onDragFinish={this.handleSplitMove}>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
brew={this.state.brew}
|
||||
onTextChange={this.handleTextChange}
|
||||
onStyleChange={this.handleStyleChange}
|
||||
onMetaChange={this.handleMetaChange}
|
||||
renderer={this.state.brew.renderer}
|
||||
userThemes={this.props.userThemes}
|
||||
snippetBundle={this.state.themeBundle.snippets}
|
||||
onCursorPageChange={this.handleEditorCursorPageChange}
|
||||
onViewPageChange={this.handleEditorViewPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
/>
|
||||
<BrewRenderer
|
||||
text={this.state.brew.text}
|
||||
style={this.state.brew.style}
|
||||
renderer={this.state.brew.renderer}
|
||||
theme={this.state.brew.theme}
|
||||
themeBundle={this.state.themeBundle}
|
||||
errors={this.state.htmlErrors}
|
||||
lang={this.state.brew.lang}
|
||||
onPageChange={this.handleBrewRendererPageChange}
|
||||
currentEditorViewPageNum={this.state.currentEditorViewPageNum}
|
||||
currentEditorCursorPageNum={this.state.currentEditorCursorPageNum}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ const SharePage = createClass({
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
themeBundle : {}
|
||||
themeBundle : {},
|
||||
currentBrewRendererPageNum : 1
|
||||
};
|
||||
},
|
||||
|
||||
@@ -39,6 +40,10 @@ const SharePage = createClass({
|
||||
document.removeEventListener('keydown', this.handleControlKeys);
|
||||
},
|
||||
|
||||
handleBrewRendererPageChange : function(pageNumber){
|
||||
this.setState({ currentBrewRendererPageNum: pageNumber });
|
||||
},
|
||||
|
||||
handleControlKeys : function(e){
|
||||
if(!(e.ctrlKey || e.metaKey)) return;
|
||||
const P_KEY = 80;
|
||||
@@ -114,9 +119,12 @@ const SharePage = createClass({
|
||||
<BrewRenderer
|
||||
text={this.props.brew.text}
|
||||
style={this.props.brew.style}
|
||||
lang={this.props.brew.lang}
|
||||
renderer={this.props.brew.renderer}
|
||||
theme={this.props.brew.theme}
|
||||
themeBundle={this.state.themeBundle}
|
||||
onPageChange={this.handleBrewRendererPageChange}
|
||||
currentBrewRendererPageNum={this.state.currentBrewRendererPageNum}
|
||||
allowPrint={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.sharePage{
|
||||
.navContent .navSection.titleSection {
|
||||
nav .navSection.titleSection {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const _ = require('lodash');
|
||||
const { useState } = React;
|
||||
const _ = require('lodash');
|
||||
|
||||
const ListPage = require('../basePages/listPage/listPage.jsx');
|
||||
|
||||
const Nav = require('naturalcrit/nav/nav.jsx');
|
||||
const Navbar = require('../../navbar/navbar.jsx');
|
||||
|
||||
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
|
||||
const Account = require('../../navbar/account.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 VaultNavitem = require('../../navbar/vault.navitem.jsx');
|
||||
|
||||
const UserPage = createClass({
|
||||
displayName : 'UserPage',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
username : '',
|
||||
brews : [],
|
||||
query : '',
|
||||
error : null
|
||||
};
|
||||
},
|
||||
getInitialState : function() {
|
||||
const usernameWithS = this.props.username + (this.props.username.endsWith('s') ? `’` : `’s`);
|
||||
const UserPage = (props)=>{
|
||||
props = {
|
||||
username : '',
|
||||
brews : [],
|
||||
query : '',
|
||||
...props
|
||||
};
|
||||
|
||||
const brews = _.groupBy(this.props.brews, (brew)=>{
|
||||
return (brew.published ? 'published' : 'private');
|
||||
});
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const brewCollection = [
|
||||
{
|
||||
title : `${usernameWithS} published brews`,
|
||||
class : 'published',
|
||||
brews : brews.published
|
||||
}
|
||||
];
|
||||
if(this.props.username == global.account?.username){
|
||||
brewCollection.push(
|
||||
{
|
||||
title : `${usernameWithS} unpublished brews`,
|
||||
class : 'unpublished',
|
||||
brews : brews.private
|
||||
}
|
||||
);
|
||||
}
|
||||
const usernameWithS = props.username + (props.username.endsWith('s') ? `’` : `’s`);
|
||||
const groupedBrews = _.groupBy(props.brews, (brew)=>brew.published ? 'published' : 'private');
|
||||
|
||||
return {
|
||||
brewCollection : brewCollection
|
||||
};
|
||||
},
|
||||
errorReported : function(error) {
|
||||
this.setState({
|
||||
error
|
||||
});
|
||||
},
|
||||
const brewCollection = [
|
||||
{
|
||||
title : `${usernameWithS} published brews`,
|
||||
class : 'published',
|
||||
brews : groupedBrews.published || []
|
||||
},
|
||||
...(props.username === global.account?.username ? [{
|
||||
title : `${usernameWithS} unpublished brews`,
|
||||
class : 'unpublished',
|
||||
brews : groupedBrews.private || []
|
||||
}] : [])
|
||||
];
|
||||
|
||||
navItems : function() {
|
||||
return <Navbar>
|
||||
const navItems = (
|
||||
<Navbar>
|
||||
<Nav.section>
|
||||
{this.state.error ?
|
||||
<ErrorNavItem error={this.state.error} parent={this}></ErrorNavItem> :
|
||||
null
|
||||
}
|
||||
{error && (<ErrorNavItem error={error} parent={null}></ErrorNavItem>)}
|
||||
<NewBrew />
|
||||
<HelpNavItem />
|
||||
<VaultNavitem/>
|
||||
<VaultNavitem />
|
||||
<RecentNavItem />
|
||||
<Account />
|
||||
</Nav.section>
|
||||
</Navbar>;
|
||||
},
|
||||
</Navbar>
|
||||
);
|
||||
|
||||
render : function(){
|
||||
return <ListPage brewCollection={this.state.brewCollection} navItems={this.navItems()} query={this.props.query} reportError={this.errorReported}></ListPage>;
|
||||
}
|
||||
});
|
||||
return (
|
||||
<ListPage brewCollection={brewCollection} navItems={navItems} query={props.query} reportError={(err)=>setError(err)} />
|
||||
);
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
const React = require('react');
|
||||
@@ -13,18 +15,20 @@ const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx');
|
||||
const SplitPane = require('../../../../shared/naturalcrit/splitPane/splitPane.jsx');
|
||||
const ErrorIndex = require('../errorPage/errors/errorIndex.js');
|
||||
|
||||
const request = require('../../utils/request-middleware.js');
|
||||
import request from '../../utils/request-middleware.js';
|
||||
|
||||
const VaultPage = (props)=>{
|
||||
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
|
||||
const [brewCollection, setBrewCollection] = useState(null);
|
||||
const [totalBrews, setTotalBrews] = useState(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
|
||||
const titleRef = useRef(null);
|
||||
const authorRef = useRef(null);
|
||||
const countRef = useRef(null);
|
||||
@@ -34,7 +38,7 @@ const VaultPage = (props)=>{
|
||||
|
||||
useEffect(()=>{
|
||||
disableSubmitIfFormInvalid();
|
||||
loadPage(pageState, true);
|
||||
loadPage(pageState, true, props.query.sort, props.query.dir);
|
||||
}, []);
|
||||
|
||||
const updateStateWithBrews = (brews, page)=>{
|
||||
@@ -43,7 +47,7 @@ const VaultPage = (props)=>{
|
||||
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 urlParams = new URLSearchParams(url.search);
|
||||
|
||||
@@ -53,21 +57,23 @@ const VaultPage = (props)=>{
|
||||
urlParams.set('v3', v3Value);
|
||||
urlParams.set('legacy', legacyValue);
|
||||
urlParams.set('page', page);
|
||||
urlParams.set('sort', sort);
|
||||
urlParams.set('dir', dir);
|
||||
|
||||
url.search = urlParams.toString();
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
};
|
||||
|
||||
const performSearch = async (title, author, count, v3, legacy, page)=>{
|
||||
updateUrl(title, author, count, v3, legacy, page);
|
||||
const performSearch = async (title, author, count, v3, legacy, page, sort, dir)=>{
|
||||
updateUrl(title, author, count, v3, legacy, page, sort, dir);
|
||||
|
||||
const response = await request.get(
|
||||
`/api/vault?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}&count=${count}&page=${page}`
|
||||
).catch((error)=>{
|
||||
console.log('error at loadPage: ', error);
|
||||
setError(error);
|
||||
updateStateWithBrews([], 1);
|
||||
});
|
||||
const response = await request
|
||||
.get(`/api/vault?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}&count=${count}&page=${page}&sort=${sort}&dir=${dir}`)
|
||||
.catch((error)=>{
|
||||
console.log('error at loadPage: ', error);
|
||||
setError(error);
|
||||
updateStateWithBrews([], 1);
|
||||
});
|
||||
|
||||
if(response.ok)
|
||||
updateStateWithBrews(response.body.brews, page);
|
||||
@@ -76,9 +82,8 @@ const VaultPage = (props)=>{
|
||||
const loadTotal = async (title, author, v3, legacy)=>{
|
||||
setTotalBrews(null);
|
||||
|
||||
const response = await request.get(
|
||||
`/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}`
|
||||
).catch((error)=>{
|
||||
const response = await request.get(`/api/vault/total?title=${title}&author=${author}&v3=${v3}&legacy=${legacy}`)
|
||||
.catch((error)=>{
|
||||
console.log('error at loadTotal: ', error);
|
||||
setError(error);
|
||||
updateStateWithBrews([], 1);
|
||||
@@ -88,9 +93,8 @@ const VaultPage = (props)=>{
|
||||
setTotalBrews(response.body.totalBrews);
|
||||
};
|
||||
|
||||
const loadPage = async (page, updateTotal)=>{
|
||||
if(!validateForm())
|
||||
return;
|
||||
const loadPage = async (page, updateTotal, sort, dir)=>{
|
||||
if(!validateForm()) return;
|
||||
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
@@ -100,8 +104,14 @@ const VaultPage = (props)=>{
|
||||
const count = countRef.current.value || 10;
|
||||
const v3 = v3Ref.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)
|
||||
loadTotal(title, author, v3, legacy);
|
||||
@@ -248,6 +258,33 @@ const VaultPage = (props)=>{
|
||||
</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 = ()=>{
|
||||
if(!totalBrews) return null;
|
||||
|
||||
@@ -271,10 +308,8 @@ const VaultPage = (props)=>{
|
||||
.map((_, index)=>(
|
||||
<a
|
||||
key={startPage + index}
|
||||
className={`pageNumber ${
|
||||
pageState === startPage + index ? 'currentPage' : ''
|
||||
}`}
|
||||
onClick={()=>loadPage(startPage + index, false)}
|
||||
className={`pageNumber ${pageState === startPage + index ? 'currentPage' : ''}`}
|
||||
onClick={()=>loadPage(startPage + index, false, sortState, dirState)}
|
||||
>
|
||||
{startPage + index}
|
||||
</a>
|
||||
@@ -284,7 +319,7 @@ const VaultPage = (props)=>{
|
||||
<div className='paginationControls'>
|
||||
<button
|
||||
className='previousPage'
|
||||
onClick={()=>loadPage(pageState - 1, false)}
|
||||
onClick={()=>loadPage(pageState - 1, false, sortState, dirState)}
|
||||
disabled={pageState === startPage}
|
||||
>
|
||||
<i className='fa-solid fa-chevron-left'></i>
|
||||
@@ -293,7 +328,7 @@ const VaultPage = (props)=>{
|
||||
{startPage > 1 && (
|
||||
<a
|
||||
className='pageNumber firstPage'
|
||||
onClick={()=>loadPage(1, false)}
|
||||
onClick={()=>loadPage(1, false, sortState, dirState)}
|
||||
>
|
||||
1 ...
|
||||
</a>
|
||||
@@ -302,7 +337,7 @@ const VaultPage = (props)=>{
|
||||
{endPage < totalPages && (
|
||||
<a
|
||||
className='pageNumber lastPage'
|
||||
onClick={()=>loadPage(totalPages, false)}
|
||||
onClick={()=>loadPage(totalPages, false, sortState, dirState)}
|
||||
>
|
||||
... {totalPages}
|
||||
</a>
|
||||
@@ -310,7 +345,7 @@ const VaultPage = (props)=>{
|
||||
</ol>
|
||||
<button
|
||||
className='nextPage'
|
||||
onClick={()=>loadPage(pageState + 1, false)}
|
||||
onClick={()=>loadPage(pageState + 1, false, sortState, dirState)}
|
||||
disabled={pageState === totalPages}
|
||||
>
|
||||
<i className='fa-solid fa-chevron-right'></i>
|
||||
@@ -330,7 +365,7 @@ const VaultPage = (props)=>{
|
||||
|
||||
if(error) {
|
||||
const errorText = ErrorIndex()[error.HBErrorCode.toString()] || '';
|
||||
|
||||
|
||||
return (
|
||||
<div className='foundBrews noBrews'>
|
||||
<h3>Error: {errorText}</h3>
|
||||
@@ -376,18 +411,18 @@ const VaultPage = (props)=>{
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='vaultPage'>
|
||||
<div className='sitePage vaultPage'>
|
||||
<link href='/themes/V3/Blank/style.css' rel='stylesheet' />
|
||||
<link href='/themes/V3/5ePHB/style.css' rel='stylesheet' />
|
||||
{renderNavItems()}
|
||||
<div className='content'>
|
||||
<SplitPane showDividerButtons={false}>
|
||||
<div className='form dataGroup'>{renderForm()}</div>
|
||||
|
||||
<div className='resultsContainer dataGroup'>
|
||||
{renderFoundBrews()}
|
||||
</div>
|
||||
</SplitPane>
|
||||
<div className="content">
|
||||
<SplitPane showDividerButtons={false}>
|
||||
<div className='form dataGroup'>{renderForm()}</div>
|
||||
<div className='resultsContainer dataGroup'>
|
||||
{renderSortBar()}
|
||||
{renderFoundBrews()}
|
||||
</div>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,348 +1,373 @@
|
||||
body {
|
||||
height : 100vh;
|
||||
|
||||
.content { height : 100%; }
|
||||
|
||||
small {
|
||||
font-size : 10pt;
|
||||
color : #555555;
|
||||
|
||||
a { color : #333333; }
|
||||
}
|
||||
|
||||
code {
|
||||
padding-inline : 5px;
|
||||
background : lightgrey;
|
||||
border-radius : 5px;
|
||||
}
|
||||
|
||||
*:not(input) { user-select : none; }
|
||||
}
|
||||
|
||||
.vaultPage {
|
||||
height : 100%;
|
||||
overflow-y : hidden;
|
||||
background-color : #2C3E50;
|
||||
|
||||
|
||||
.content {
|
||||
background : #2C3E50;
|
||||
*:not(input) { user-select : none; }
|
||||
|
||||
.dataGroup {
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
background : white;
|
||||
.content .dataGroup {
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
background : white;
|
||||
|
||||
&.form .brewLookup {
|
||||
position : relative;
|
||||
padding : 50px clamp(20px, 4vw, 50px);
|
||||
&.form .brewLookup {
|
||||
position : relative;
|
||||
padding : 50px clamp(20px, 4vw, 50px);
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family : 'CodeBold';
|
||||
letter-spacing : 2px;
|
||||
}
|
||||
small {
|
||||
font-size : 10pt;
|
||||
color : #555555;
|
||||
|
||||
a { color : #333333; }
|
||||
}
|
||||
|
||||
code {
|
||||
padding-inline : 5px;
|
||||
font-family : monospace;
|
||||
background : lightgrey;
|
||||
border-radius : 5px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family : 'CodeBold';
|
||||
letter-spacing : 2px;
|
||||
}
|
||||
|
||||
legend {
|
||||
h3 {
|
||||
margin-block : 30px 20px;
|
||||
font-size : 20px;
|
||||
text-align : center;
|
||||
border-bottom : 2px solid;
|
||||
}
|
||||
ul {
|
||||
padding-inline : 30px 10px;
|
||||
li {
|
||||
margin-block : 5px;
|
||||
line-height : calc(1em + 5px);
|
||||
list-style : disc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
left : 0;
|
||||
display : block;
|
||||
padding : 10px;
|
||||
font-weight : 900;
|
||||
color : white;
|
||||
white-space : pre-wrap;
|
||||
content : 'Error:\A At least one renderer should be enabled to make a search';
|
||||
background : rgb(255, 60, 60);
|
||||
opacity : 0;
|
||||
transition : opacity 0.5s;
|
||||
}
|
||||
&:not(:has(input[type='checkbox']:checked))::after { opacity : 1; }
|
||||
|
||||
.formTitle {
|
||||
margin : 20px 0;
|
||||
font-size : 30px;
|
||||
color : black;
|
||||
legend {
|
||||
h3 {
|
||||
margin-block : 30px 20px;
|
||||
font-size : 20px;
|
||||
text-align : center;
|
||||
border-bottom : 2px solid;
|
||||
}
|
||||
|
||||
.formContents {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
|
||||
label {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
margin : 10px 0;
|
||||
ul {
|
||||
padding-inline : 30px 10px;
|
||||
li {
|
||||
margin-block : 5px;
|
||||
line-height : calc(1em + 5px);
|
||||
list-style : disc;
|
||||
}
|
||||
select { margin : 0 10px; }
|
||||
|
||||
input {
|
||||
margin : 0 10px;
|
||||
|
||||
&:invalid { background : rgb(255, 188, 181); }
|
||||
|
||||
&[type='checkbox'] {
|
||||
position : relative;
|
||||
display : inline-block;
|
||||
width : 50px;
|
||||
height : 30px;
|
||||
font-family : 'WalterTurncoat';
|
||||
font-size : 20px;
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
letter-spacing : 2px;
|
||||
appearance : none;
|
||||
background : red;
|
||||
isolation : isolate;
|
||||
border-radius : 5px;
|
||||
|
||||
&::before,&::after {
|
||||
position : absolute;
|
||||
inset : 0;
|
||||
z-index : 5;
|
||||
padding-top : 2px;
|
||||
text-align : center;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display : block;
|
||||
content : 'No';
|
||||
}
|
||||
|
||||
&::after {
|
||||
display : none;
|
||||
content : 'Yes';
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background : green;
|
||||
|
||||
&::before { display : none; }
|
||||
&::after { display : block; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#searchButton {
|
||||
position : absolute;
|
||||
right : 20px;
|
||||
bottom : 0;
|
||||
|
||||
i {
|
||||
margin-left : 10px;
|
||||
animation-duration : 1000s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.resultsContainer {
|
||||
&::after {
|
||||
position : absolute;
|
||||
top : 0;
|
||||
right : 0;
|
||||
left : 0;
|
||||
display : block;
|
||||
padding : 10px;
|
||||
font-weight : 900;
|
||||
color : white;
|
||||
white-space : pre-wrap;
|
||||
content : 'Error:\A At least one renderer should be enabled to make a search';
|
||||
background : rgb(255, 60, 60);
|
||||
opacity : 0;
|
||||
transition : opacity 0.5s;
|
||||
}
|
||||
&:not(:has(input[type='checkbox']:checked))::after { opacity : 1; }
|
||||
|
||||
.formTitle {
|
||||
margin : 20px 0;
|
||||
font-size : 30px;
|
||||
color : black;
|
||||
text-align : center;
|
||||
border-bottom : 2px solid;
|
||||
}
|
||||
|
||||
.formContents {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
height : 100%;
|
||||
overflow-y : auto;
|
||||
font-family : 'BookInsanityRemake';
|
||||
font-size : 0.34cm;
|
||||
|
||||
|
||||
h3 {
|
||||
font-family : 'Open Sans';
|
||||
font-weight : 900;
|
||||
color : white;
|
||||
label {
|
||||
display : flex;
|
||||
align-items : center;
|
||||
margin : 10px 0;
|
||||
}
|
||||
select { margin : 0 10px; }
|
||||
|
||||
input {
|
||||
margin : 0 10px;
|
||||
|
||||
&:invalid { background : rgb(255, 188, 181); }
|
||||
|
||||
&[type='checkbox'] {
|
||||
position : relative;
|
||||
display : inline-block;
|
||||
width : 50px;
|
||||
height : 30px;
|
||||
font-family : 'WalterTurncoat';
|
||||
font-size : 20px;
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
letter-spacing : 2px;
|
||||
appearance : none;
|
||||
background : red;
|
||||
isolation : isolate;
|
||||
border-radius : 5px;
|
||||
|
||||
&::before,&::after {
|
||||
position : absolute;
|
||||
inset : 0;
|
||||
z-index : 5;
|
||||
padding-top : 2px;
|
||||
text-align : center;
|
||||
}
|
||||
|
||||
&::before {
|
||||
display : block;
|
||||
content : 'No';
|
||||
}
|
||||
|
||||
&::after {
|
||||
display : none;
|
||||
content : 'Yes';
|
||||
}
|
||||
|
||||
&:checked {
|
||||
background : green;
|
||||
|
||||
&::before { display : none; }
|
||||
&::after { display : block; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.foundBrews {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
max-height : 100%;
|
||||
padding : 50px 50px 70px 50px;
|
||||
overflow-y : scroll;
|
||||
background-color : #2C3E50;
|
||||
|
||||
h3 { font-size : 25px; }
|
||||
|
||||
&.noBrews {
|
||||
display : grid;
|
||||
place-items : center;
|
||||
color : white;
|
||||
}
|
||||
|
||||
&.searching {
|
||||
display : grid;
|
||||
place-items : center;
|
||||
color : white;
|
||||
|
||||
h3 { position : relative; }
|
||||
|
||||
h3.searchAnim::after {
|
||||
position : absolute;
|
||||
top : 50%;
|
||||
right : 0;
|
||||
width : max-content;
|
||||
height : 1em;
|
||||
content : '';
|
||||
translate : calc(100% + 5px) -50%;
|
||||
animation : trailingDots 2s ease infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.totalBrews {
|
||||
position : fixed;
|
||||
right : 0;
|
||||
bottom : 0;
|
||||
z-index : 1000;
|
||||
padding : 8px 10px;
|
||||
font-family : 'Open Sans';
|
||||
font-size : 11px;
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
background-color : #333333;
|
||||
|
||||
.searchAnim {
|
||||
position : relative;
|
||||
display : inline-block;
|
||||
width : 3ch;
|
||||
height : 1em;
|
||||
}
|
||||
|
||||
.searchAnim::after {
|
||||
position : absolute;
|
||||
top : 50%;
|
||||
right : 0;
|
||||
width : max-content;
|
||||
height : 1em;
|
||||
content : '';
|
||||
translate : -50% -50%;
|
||||
animation : trailingDots 2s ease infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.brewItem {
|
||||
width : 47%;
|
||||
margin-right : 40px;
|
||||
color : black;
|
||||
isolation:isolate;
|
||||
|
||||
&:after {
|
||||
position:absolute;
|
||||
inset:0;
|
||||
display:block;
|
||||
content:'';
|
||||
background-image : url('/assets/parchmentBackground.jpg');
|
||||
z-index:-1;
|
||||
}
|
||||
|
||||
&:nth-child(even of .brewItem) { margin-right : 0; }
|
||||
|
||||
h2 {
|
||||
font-family : 'MrEavesRemake';
|
||||
font-size : 0.75cm;
|
||||
font-weight : 800;
|
||||
line-height : 0.988em;
|
||||
color : var(--HB_Color_HeaderText);
|
||||
}
|
||||
.info {
|
||||
font-family : 'ScalySansRemake';
|
||||
font-size : 1.2em;
|
||||
position:relative;
|
||||
z-index:2;
|
||||
|
||||
>span {
|
||||
margin-right : 12px;
|
||||
line-height : 1.5em;
|
||||
}
|
||||
}
|
||||
.links {
|
||||
z-index:2;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
z-index:1;
|
||||
}
|
||||
}
|
||||
|
||||
.paginationControls {
|
||||
position : absolute;
|
||||
left : 50%;
|
||||
display : grid;
|
||||
grid-template-areas : 'previousPage currentPage nextPage';
|
||||
grid-template-columns : 50px 1fr 50px;
|
||||
place-items : center;
|
||||
width : auto;
|
||||
translate : -50%;
|
||||
|
||||
.pages {
|
||||
display : flex;
|
||||
grid-area : currentPage;
|
||||
justify-content : space-evenly;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
padding : 5px 8px;
|
||||
text-align : center;
|
||||
|
||||
.pageNumber {
|
||||
margin-inline : 1vw;
|
||||
font-family : 'Open Sans';
|
||||
font-weight : 900;
|
||||
color : white;
|
||||
text-underline-position : under;
|
||||
text-wrap : nowrap;
|
||||
cursor : pointer;
|
||||
|
||||
&.currentPage {
|
||||
color : gold;
|
||||
text-decoration : underline;
|
||||
pointer-events : none;
|
||||
}
|
||||
|
||||
&.firstPage { margin-right : -5px; }
|
||||
|
||||
&.lastPage { margin-left : -5px; }
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
width : max-content;
|
||||
|
||||
&.previousPage { grid-area : previousPage; }
|
||||
|
||||
&.nextPage { grid-area : nextPage; }
|
||||
}
|
||||
#searchButton {
|
||||
position : absolute;
|
||||
right : 20px;
|
||||
bottom : 0;
|
||||
|
||||
i {
|
||||
margin-left : 10px;
|
||||
animation-duration : 1000s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.resultsContainer {
|
||||
display : flex;
|
||||
flex-direction : column;
|
||||
height : 100%;
|
||||
overflow-y : auto;
|
||||
font-family : 'BookInsanityRemake';
|
||||
font-size : 0.34cm;
|
||||
|
||||
h3 {
|
||||
font-family : 'Open Sans';
|
||||
font-weight : 900;
|
||||
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 {
|
||||
position : relative;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
max-height : 100%;
|
||||
padding : 50px 50px 70px 50px;
|
||||
overflow-y : scroll;
|
||||
background-color : #2C3E50;
|
||||
|
||||
h3 { font-size : 25px; }
|
||||
|
||||
&.noBrews {
|
||||
display : grid;
|
||||
place-items : center;
|
||||
color : white;
|
||||
}
|
||||
|
||||
&.searching {
|
||||
display : grid;
|
||||
place-items : center;
|
||||
color : white;
|
||||
|
||||
h3 { position : relative; }
|
||||
|
||||
h3.searchAnim::after {
|
||||
position : absolute;
|
||||
top : 50%;
|
||||
right : 0;
|
||||
width : max-content;
|
||||
height : 1em;
|
||||
content : '';
|
||||
translate : calc(100% + 5px) -50%;
|
||||
animation : trailingDots 2s ease infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.totalBrews {
|
||||
position : fixed;
|
||||
right : 0;
|
||||
bottom : 0;
|
||||
z-index : 1000;
|
||||
padding : 8px 10px;
|
||||
font-family : 'Open Sans';
|
||||
font-size : 11px;
|
||||
font-weight : 800;
|
||||
color : white;
|
||||
background-color : #333333;
|
||||
|
||||
.searchAnim {
|
||||
position : relative;
|
||||
display : inline-block;
|
||||
width : 3ch;
|
||||
height : 1em;
|
||||
}
|
||||
|
||||
.searchAnim::after {
|
||||
position : absolute;
|
||||
top : 50%;
|
||||
right : 0;
|
||||
width : max-content;
|
||||
height : 1em;
|
||||
content : '';
|
||||
translate : -50% -50%;
|
||||
animation : trailingDots 2s ease infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.brewItem {
|
||||
width : 47%;
|
||||
margin-right : 40px;
|
||||
color : black;
|
||||
isolation : isolate;
|
||||
|
||||
&::after {
|
||||
position : absolute;
|
||||
inset : 0;
|
||||
z-index : -2;
|
||||
display : block;
|
||||
content : '';
|
||||
background-image : url('/assets/parchmentBackground.jpg');
|
||||
}
|
||||
|
||||
&:nth-child(even of .brewItem) { margin-right : 0; }
|
||||
|
||||
h2 {
|
||||
font-family : 'MrEavesRemake';
|
||||
font-size : 0.75cm;
|
||||
font-weight : 800;
|
||||
line-height : 0.988em;
|
||||
color : var(--HB_Color_HeaderText);
|
||||
}
|
||||
.info {
|
||||
position : relative;
|
||||
z-index : 2;
|
||||
font-family : 'ScalySansRemake';
|
||||
font-size : 1.2em;
|
||||
|
||||
>span {
|
||||
margin-right : 12px;
|
||||
line-height : 1.5em;
|
||||
}
|
||||
}
|
||||
.links { z-index : 2; }
|
||||
|
||||
hr {
|
||||
margin : 0px;
|
||||
visibility : hidden;
|
||||
}
|
||||
|
||||
.thumbnail { z-index : -1; }
|
||||
}
|
||||
|
||||
.paginationControls {
|
||||
position : absolute;
|
||||
left : 50%;
|
||||
display : grid;
|
||||
grid-template-areas : 'previousPage currentPage nextPage';
|
||||
grid-template-columns : 50px 1fr 50px;
|
||||
place-items : center;
|
||||
width : auto;
|
||||
translate : -50%;
|
||||
|
||||
.pages {
|
||||
display : flex;
|
||||
grid-area : currentPage;
|
||||
justify-content : space-evenly;
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
padding : 5px 8px;
|
||||
text-align : center;
|
||||
|
||||
.pageNumber {
|
||||
margin-inline : 1vw;
|
||||
font-family : 'Open Sans';
|
||||
font-weight : 900;
|
||||
color : white;
|
||||
text-underline-position : under;
|
||||
text-wrap : nowrap;
|
||||
cursor : pointer;
|
||||
|
||||
&.currentPage {
|
||||
color : gold;
|
||||
text-decoration : underline;
|
||||
pointer-events : none;
|
||||
}
|
||||
|
||||
&.firstPage { margin-right : -5px; }
|
||||
|
||||
&.lastPage { margin-left : -5px; }
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
width : max-content;
|
||||
|
||||
&.previousPage { grid-area : previousPage; }
|
||||
|
||||
&.nextPage { grid-area : nextPage; }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@keyframes trailingDots {
|
||||
@@ -359,7 +384,7 @@ body {
|
||||
|
||||
// media query for when the page is smaller than 1079 px in width
|
||||
@media screen and (max-width : 1079px) {
|
||||
.vaultPage .content {
|
||||
.vaultPage {
|
||||
|
||||
.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())
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
const request = require('superagent');
|
||||
import request from 'superagent';
|
||||
|
||||
const addHeader = (request)=>request.set('Homebrewery-Version', global.version);
|
||||
|
||||
@@ -9,4 +9,4 @@ const requestMiddleware = {
|
||||
delete : (path)=>addHeader(request.delete(path)),
|
||||
};
|
||||
|
||||
module.exports = requestMiddleware;
|
||||
export default requestMiddleware;
|
||||
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 {
|
||||
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 |
@@ -8,6 +8,8 @@ const template = async function(name, title='', props = {}){
|
||||
});
|
||||
const ogMetaTags = ogTags.join('\n');
|
||||
|
||||
const ssrModule = await import(`../build/${name}/ssr.cjs`);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -21,7 +23,7 @@ const template = async function(name, title='', props = {}){
|
||||
<title>${title.length ? `${title} - The Homebrewery`: 'The Homebrewery - NaturalCrit'}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="reactRoot">${require(`../build/${name}/ssr.js`)(props)}</main>
|
||||
<main id="reactRoot">${ssrModule.default(props)}</main>
|
||||
<script src=${`/${name}/bundle.js`}></script>
|
||||
<script>start_app(${JSON.stringify(props)})</script>
|
||||
</body>
|
||||
@@ -29,4 +31,4 @@ const template = async function(name, title='', props = {}){
|
||||
`;
|
||||
};
|
||||
|
||||
module.exports = template;
|
||||
export default template;
|
||||
@@ -6,5 +6,7 @@
|
||||
"enable_v3" : true,
|
||||
"enable_themes" : true,
|
||||
"local_environments" : ["docker", "local"],
|
||||
"publicUrl" : "https://homebrewery.naturalcrit.com"
|
||||
"publicUrl" : "https://homebrewery.naturalcrit.com",
|
||||
"hb_images" : null,
|
||||
"hb_fonts" : null
|
||||
}
|
||||
|
||||
2581
package-lock.json
generated
2581
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
74
package.json
74
package.json
@@ -1,20 +1,21 @@
|
||||
{
|
||||
"name": "homebrewery",
|
||||
"description": "Create authentic looking D&D homebrews using only markdown",
|
||||
"version": "3.15.0",
|
||||
"version": "3.16.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": "^10.2.x",
|
||||
"node": "^20.8.x"
|
||||
"node": "^20.18.x"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/naturalcrit/homebrewery.git"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.js",
|
||||
"quick": "node scripts/quick.js",
|
||||
"build": "node scripts/buildHomebrew.js && node scripts/buildAdmin.js",
|
||||
"builddev": "node scripts/buildHomebrew.js --dev",
|
||||
"dev": "node --experimental-require-module scripts/dev.js",
|
||||
"quick": "node --experimental-require-module scripts/quick.js",
|
||||
"build": "node --experimental-require-module scripts/buildHomebrew.js && node --experimental-require-module scripts/buildAdmin.js",
|
||||
"builddev": "node --experimental-require-module scripts/buildHomebrew.js --dev",
|
||||
"lint": "eslint --fix",
|
||||
"lint:dry": "eslint",
|
||||
"stylelint": "stylelint --fix **/*.{less}",
|
||||
@@ -25,6 +26,7 @@
|
||||
"test:api-unit": "jest \"server/.*.spec.js\" --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:notifications": "jest \"server/.*.spec.js\" -t \"Notifications\" --verbose",
|
||||
"test:coverage": "jest --coverage --silent --runInBand",
|
||||
"test:dev": "jest --verbose --watch",
|
||||
"test:basic": "jest tests/markdown/basic.test.js --verbose",
|
||||
@@ -37,10 +39,11 @@
|
||||
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
|
||||
"test:emojis": "jest tests/markdown/emojis.test.js --verbose --noStackTrace",
|
||||
"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",
|
||||
"postinstall": "npm run build",
|
||||
"start": "node server.js"
|
||||
"start": "node --experimental-require-module server.js"
|
||||
},
|
||||
"author": "stolksdorf",
|
||||
"license": "MIT",
|
||||
@@ -54,6 +57,9 @@
|
||||
"shared",
|
||||
"server"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!nanoid/).*"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"build/*"
|
||||
],
|
||||
@@ -75,66 +81,60 @@
|
||||
"jest-expect-message"
|
||||
]
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-runtime"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-transform-runtime": "^7.25.4",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/plugin-transform-runtime": "^7.25.9",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/preset-react": "^7.25.9",
|
||||
"@googleapis/drive": "^8.14.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"classnames": "^2.5.1",
|
||||
"codemirror": "^5.65.6",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"create-react-class": "^15.7.0",
|
||||
"dedent-tabs": "^0.10.3",
|
||||
"dompurify": "^3.1.6",
|
||||
"dompurify": "^3.2.1",
|
||||
"expr-eval": "^2.0.2",
|
||||
"express": "^4.19.2",
|
||||
"express": "^4.21.1",
|
||||
"express-async-handler": "^1.2.0",
|
||||
"express-static-gzip": "2.1.7",
|
||||
"express-static-gzip": "2.2.0",
|
||||
"fs-extra": "11.2.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jwt-simple": "^0.5.6",
|
||||
"less": "^3.13.1",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "11.2.0",
|
||||
"marked-emoji": "^1.4.2",
|
||||
"marked-emoji": "^1.4.3",
|
||||
"marked-extended-tables": "^1.0.10",
|
||||
"marked-gfm-heading-id": "^3.2.0",
|
||||
"marked-smartypants-lite": "^1.0.2",
|
||||
"markedLegacy": "npm:marked@^0.3.19",
|
||||
"moment": "^2.30.1",
|
||||
"mongoose": "^8.6.0",
|
||||
"nanoid": "3.3.4",
|
||||
"mongoose": "^8.8.2",
|
||||
"nanoid": "5.0.8",
|
||||
"nconf": "^0.12.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-frame-component": "^4.1.3",
|
||||
"react-router-dom": "6.26.1",
|
||||
"react-router-dom": "6.28.0",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"superagent": "^10.1.0",
|
||||
"superagent": "^10.1.1",
|
||||
"vitreum": "git+https://git@github.com/calculuschild/vitreum.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stylistic/stylelint-plugin": "^3.0.1",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-jest": "^28.8.2",
|
||||
"eslint-plugin-react": "^7.35.1",
|
||||
"globals": "^15.9.0",
|
||||
"@stylistic/stylelint-plugin": "^3.1.1",
|
||||
"babel-plugin-transform-import-meta": "^2.2.1",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-jest": "^28.9.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"globals": "^15.12.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"jsdom-global": "^3.0.2",
|
||||
"postcss-less": "^6.0.0",
|
||||
"stylelint": "^16.9.0",
|
||||
"stylelint-config-recess-order": "^5.1.0",
|
||||
"stylelint": "^16.10.0",
|
||||
"stylelint-config-recess-order": "^5.1.1",
|
||||
"stylelint-config-recommended": "^14.0.1",
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
const fs = require('fs-extra');
|
||||
const Proj = require('./project.json');
|
||||
|
||||
const { pack } = require('vitreum');
|
||||
import fs from 'fs-extra';
|
||||
import Proj from './project.json' with { type: 'json' };
|
||||
import vitreum from 'vitreum';
|
||||
const { pack } = vitreum;
|
||||
|
||||
import lessTransform from 'vitreum/transforms/less.js';
|
||||
import assetTransform from 'vitreum/transforms/asset.js';
|
||||
|
||||
const isDev = !!process.argv.find((arg)=>arg=='--dev');
|
||||
|
||||
const lessTransform = require('vitreum/transforms/less.js');
|
||||
const assetTransform = require('vitreum/transforms/asset.js');
|
||||
//const Meta = require('vitreum/headtags');
|
||||
|
||||
const transforms = {
|
||||
'.less' : lessTransform,
|
||||
'*' : assetTransform('./build')
|
||||
@@ -17,7 +18,7 @@ const build = async ({ bundle, render, ssr })=>{
|
||||
const css = await lessTransform.generate({ paths: './shared' });
|
||||
await fs.outputFile('./build/admin/bundle.css', css);
|
||||
await fs.outputFile('./build/admin/bundle.js', bundle);
|
||||
await fs.outputFile('./build/admin/ssr.js', ssr);
|
||||
await fs.outputFile('./build/admin/ssr.cjs', ssr);
|
||||
};
|
||||
|
||||
fs.emptyDirSync('./build/admin');
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
const fs = require('fs-extra');
|
||||
const zlib = require('zlib');
|
||||
const Proj = require('./project.json');
|
||||
import fs from 'fs-extra';
|
||||
import zlib from 'zlib';
|
||||
import Proj from './project.json' with { type: 'json' };
|
||||
import vitreum from 'vitreum';
|
||||
const { pack, watchFile, livereload } = vitreum;
|
||||
|
||||
const { pack, watchFile, livereload } = require('vitreum');
|
||||
const isDev = !!process.argv.find((arg)=>arg=='--dev');
|
||||
import lessTransform from 'vitreum/transforms/less.js';
|
||||
import assetTransform from 'vitreum/transforms/asset.js';
|
||||
import babel from '@babel/core';
|
||||
import less from 'less';
|
||||
|
||||
const lessTransform = require('vitreum/transforms/less.js');
|
||||
const assetTransform = require('vitreum/transforms/asset.js');
|
||||
const babel = require('@babel/core');
|
||||
const less = require('less');
|
||||
const isDev = !!process.argv.find((arg) => arg === '--dev');
|
||||
|
||||
const babelify = async (code)=>(await babel.transformAsync(code, { presets: [['@babel/preset-env', { 'exclude': ['proposal-dynamic-import'] }], '@babel/preset-react'], plugins: ['@babel/plugin-transform-runtime'] })).code;
|
||||
|
||||
@@ -24,7 +25,7 @@ const build = async ({ bundle, render, ssr })=>{
|
||||
//css = `@layer bundle {\n${css}\n}`;
|
||||
await fs.outputFile('./build/homebrew/bundle.css', css);
|
||||
await fs.outputFile('./build/homebrew/bundle.js', bundle);
|
||||
await fs.outputFile('./build/homebrew/ssr.js', ssr);
|
||||
await fs.outputFile('./build/homebrew/ssr.cjs', ssr);
|
||||
|
||||
await fs.copy('./client/homebrew/favicon.ico', './build/assets/favicon.ico');
|
||||
|
||||
@@ -51,7 +52,7 @@ fs.emptyDirSync('./build');
|
||||
const themes = { Legacy: {}, V3: {} };
|
||||
|
||||
let themeFiles = fs.readdirSync('./themes/Legacy');
|
||||
for (dir of themeFiles) {
|
||||
for (let dir of themeFiles) {
|
||||
const themeData = JSON.parse(fs.readFileSync(`./themes/Legacy/${dir}/settings.json`).toString());
|
||||
themeData.path = dir;
|
||||
themes.Legacy[dir] = (themeData);
|
||||
@@ -68,7 +69,7 @@ fs.emptyDirSync('./build');
|
||||
}
|
||||
|
||||
themeFiles = fs.readdirSync('./themes/V3');
|
||||
for (dir of themeFiles) {
|
||||
for (let dir of themeFiles) {
|
||||
const themeData = JSON.parse(fs.readFileSync(`./themes/V3/${dir}/settings.json`).toString());
|
||||
themeData.path = dir;
|
||||
themes.V3[dir] = (themeData);
|
||||
@@ -104,14 +105,14 @@ fs.emptyDirSync('./build');
|
||||
const editorThemesBuildDir = './build/homebrew/cm-themes';
|
||||
await fs.copy('./node_modules/codemirror/theme', editorThemesBuildDir);
|
||||
await fs.copy('./themes/codeMirror/customThemes', editorThemesBuildDir);
|
||||
editorThemeFiles = fs.readdirSync(editorThemesBuildDir);
|
||||
const editorThemeFiles = fs.readdirSync(editorThemesBuildDir);
|
||||
|
||||
const editorThemeFile = './themes/codeMirror/editorThemes.json';
|
||||
if(fs.existsSync(editorThemeFile)) fs.rmSync(editorThemeFile);
|
||||
const stream = fs.createWriteStream(editorThemeFile, { flags: 'a' });
|
||||
stream.write('[\n"default"');
|
||||
|
||||
for (themeFile of editorThemeFiles) {
|
||||
for (let themeFile of editorThemeFiles) {
|
||||
stream.write(`,\n"${themeFile.slice(0, -4)}"`);
|
||||
}
|
||||
stream.write('\n]\n');
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
const DB = require('./server/db.js');
|
||||
const server = require('./server/app.js');
|
||||
const config = require('./server/config.js');
|
||||
import DB from './server/db.js';
|
||||
import server from './server/app.js';
|
||||
import config from './server/config.js';
|
||||
|
||||
DB.connect(config).then(()=>{
|
||||
// Ensure that we have successfully connected to the database
|
||||
// before launching server
|
||||
const PORT = process.env.PORT || config.get('web_port') || 8000;
|
||||
server.app.listen(PORT, ()=>{
|
||||
server.listen(PORT, ()=>{
|
||||
const reset = '\x1b[0m'; // Reset to default style
|
||||
const bright = '\x1b[1m'; // Bright (bold) style
|
||||
const cyan = '\x1b[36m'; // Cyan color
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
const HomebrewModel = require('./homebrew.model.js').model;
|
||||
const router = require('express').Router();
|
||||
const Moment = require('moment');
|
||||
//const render = require('vitreum/steps/render');
|
||||
const templateFn = require('../client/template.js');
|
||||
const zlib = require('zlib');
|
||||
import {model as HomebrewModel } from './homebrew.model.js';
|
||||
import {model as NotificationModel } from './notifications.model.js';
|
||||
import express from 'express';
|
||||
import Moment from 'moment';
|
||||
import zlib from 'zlib';
|
||||
import templateFn from '../client/template.js';
|
||||
|
||||
import HomebrewAPI from './homebrew.api.js';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
process.env.ADMIN_USER = process.env.ADMIN_USER || 'admin';
|
||||
process.env.ADMIN_PASS = process.env.ADMIN_PASS || 'password3';
|
||||
@@ -22,7 +28,7 @@ const mw = {
|
||||
if(process.env.ADMIN_USER === username && process.env.ADMIN_PASS === password){
|
||||
return next();
|
||||
}
|
||||
return res.status(401).send('Access denied');
|
||||
throw { HBErrorCode: '52', code: 401, message: 'Access denied' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,23 +72,8 @@ router.post('/admin/cleanup', mw.adminOnly, (req, res)=>{
|
||||
});
|
||||
|
||||
/* Searches for matching edit or share id, also attempts to partial match */
|
||||
router.get('/admin/lookup/:id', mw.adminOnly, async (req, res, next)=>{
|
||||
HomebrewModel.findOne({
|
||||
$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' });
|
||||
});
|
||||
router.get('/admin/lookup/:id', mw.adminOnly, asyncHandler(HomebrewAPI.getBrew('admin', false)), async (req, res, next)=>{
|
||||
return res.json(req.brew);
|
||||
});
|
||||
|
||||
/* Find 50 brews that aren't compressed yet */
|
||||
@@ -100,6 +91,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 */
|
||||
router.put('/admin/compress/:id', (req, res)=>{
|
||||
@@ -138,12 +148,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)=>{
|
||||
templateFn('admin', {
|
||||
url : req.originalUrl
|
||||
})
|
||||
.then((page)=>res.send(page))
|
||||
.catch((err)=>res.sendStatus(500));
|
||||
.catch((err)=>{
|
||||
console.log(err);
|
||||
res.sendStatus(500);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
117
server/admin.api.spec.js
Normal file
117
server/admin.api.spec.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import supertest from 'supertest';
|
||||
import HBApp from './app.js';
|
||||
import {model as NotificationModel } from './notifications.model.js';
|
||||
|
||||
|
||||
// Mimic https responses to avoid being redirected all the time
|
||||
const app = supertest.agent(HBApp).set('X-Forwarded-Proto', 'https');
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
1113
server/app.js
1113
server/app.js
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
const _ = require('lodash');
|
||||
import _ from 'lodash';
|
||||
|
||||
// Default properties for newly-created brews
|
||||
const DEFAULT_BREW = {
|
||||
@@ -32,7 +32,7 @@ const DEFAULT_BREW_LOAD = _.defaults(
|
||||
},
|
||||
DEFAULT_BREW);
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
DEFAULT_BREW,
|
||||
DEFAULT_BREW_LOAD
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
module.exports = require('nconf')
|
||||
.argv()
|
||||
.env({ lowerCase: true })
|
||||
.file('environment', { file: `config/${process.env.NODE_ENV}.json` })
|
||||
.file('defaults', { file: 'config/default.json' });
|
||||
import nconf from 'nconf';
|
||||
|
||||
export default nconf
|
||||
.argv()
|
||||
.env({ lowerCase: true })
|
||||
.file('environment', { file: `config/${process.env.NODE_ENV}.json` })
|
||||
.file('defaults', { file: 'config/default.json' });
|
||||
@@ -5,7 +5,7 @@
|
||||
// reused by both the main application and all tests which require database
|
||||
// connection.
|
||||
|
||||
const Mongoose = require('mongoose');
|
||||
import Mongoose from 'mongoose';
|
||||
|
||||
const getMongoDBURL = (config)=>{
|
||||
return config.get('mongodb_uri') ||
|
||||
@@ -31,7 +31,7 @@ const connect = async (config)=>{
|
||||
.catch((error)=>handleConnectionError(error));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
connect : connect,
|
||||
disconnect : disconnect
|
||||
export default {
|
||||
connect,
|
||||
disconnect
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = (req, res, next)=>{
|
||||
export default (req, res, next)=>{
|
||||
if(process.env.NODE_ENV === 'local' || process.env.NODE_ENV === 'docker') return next();
|
||||
if(req.header('x-forwarded-proto') !== 'https') {
|
||||
return res.redirect(302, `https://${req.get('Host')}${req.url}`);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable max-lines */
|
||||
const googleDrive = require('@googleapis/drive');
|
||||
const { nanoid } = require('nanoid');
|
||||
const token = require('./token.js');
|
||||
const config = require('./config.js');
|
||||
import googleDrive from '@googleapis/drive';
|
||||
import { nanoid } from 'nanoid';
|
||||
import token from './token.js';
|
||||
import config from './config.js';
|
||||
|
||||
|
||||
let serviceAuth;
|
||||
if(!config.get('service_account')){
|
||||
@@ -25,6 +26,15 @@ if(!config.get('service_account')){
|
||||
|
||||
const defaultAuth = serviceAuth || config.get('google_api_key');
|
||||
|
||||
const retryConfig = {
|
||||
retry: 3, // Number of retry attempts
|
||||
retryDelay: 100, // Initial delay in milliseconds
|
||||
retryDelayMultiplier: 2, // Multiplier for exponential backoff
|
||||
maxRetryDelay: 32000, // Maximum delay in milliseconds
|
||||
httpMethodsToRetry: ['PATCH'], // Only retry PATCH requests
|
||||
statusCodesToRetry: [[429, 429]], // Only retry on 429 status code
|
||||
};
|
||||
|
||||
const GoogleActions = {
|
||||
|
||||
authCheck : (account, res, updateTokens=true)=>{
|
||||
@@ -50,7 +60,7 @@ const GoogleActions = {
|
||||
account.googleRefreshToken = tokens.refresh_token;
|
||||
}
|
||||
account.googleAccessToken = tokens.access_token;
|
||||
const JWTToken = token.generateAccessToken(account);
|
||||
const JWTToken = token(account);
|
||||
|
||||
//Save updated token to cookie
|
||||
//res.cookie('nc_session', JWTToken, { maxAge: 1000*60*60*24*365, path: '/', sameSite: 'lax' });
|
||||
@@ -63,7 +73,7 @@ const GoogleActions = {
|
||||
getGoogleFolder : async (auth)=>{
|
||||
const drive = googleDrive.drive({ version: 'v3', auth });
|
||||
|
||||
fileMetadata = {
|
||||
const fileMetadata = {
|
||||
'name' : 'Homebrewery',
|
||||
'mimeType' : 'application/vnd.google-apps.folder'
|
||||
};
|
||||
@@ -112,9 +122,7 @@ const GoogleActions = {
|
||||
})
|
||||
.catch((err)=>{
|
||||
console.log(`Error Listing Google Brews`);
|
||||
console.error(err);
|
||||
throw (err);
|
||||
//TODO: Should break out here, but continues on for some reason.
|
||||
});
|
||||
fileList.push(...obj.data.files);
|
||||
NextPageToken = obj.data.nextPageToken;
|
||||
@@ -147,7 +155,7 @@ const GoogleActions = {
|
||||
return brews;
|
||||
},
|
||||
|
||||
updateGoogleBrew : async (brew)=>{
|
||||
updateGoogleBrew : async (brew, userIp)=>{
|
||||
const drive = googleDrive.drive({ version: 'v3', auth: defaultAuth });
|
||||
|
||||
await drive.files.update({
|
||||
@@ -168,11 +176,14 @@ const GoogleActions = {
|
||||
media : {
|
||||
mimeType : 'text/plain',
|
||||
body : brew.text
|
||||
}
|
||||
},
|
||||
headers: {
|
||||
'X-Forwarded-For': userIp, // Set the X-Forwarded-For header
|
||||
},
|
||||
retryConfig
|
||||
})
|
||||
.catch((err)=>{
|
||||
console.log('Error saving to google');
|
||||
console.error(err);
|
||||
throw (err);
|
||||
});
|
||||
|
||||
@@ -211,7 +222,6 @@ const GoogleActions = {
|
||||
})
|
||||
.catch((err)=>{
|
||||
console.log('Error while creating new Google brew');
|
||||
console.error(err);
|
||||
throw (err);
|
||||
});
|
||||
|
||||
@@ -335,4 +345,4 @@ const GoogleActions = {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = GoogleActions;
|
||||
export default GoogleActions;
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
/* eslint-disable max-lines */
|
||||
const _ = require('lodash');
|
||||
const HomebrewModel = require('./homebrew.model.js').model;
|
||||
const router = require('express').Router();
|
||||
const zlib = require('zlib');
|
||||
const GoogleActions = require('./googleActions.js');
|
||||
const Markdown = require('../shared/naturalcrit/markdown.js');
|
||||
const yaml = require('js-yaml');
|
||||
const asyncHandler = require('express-async-handler');
|
||||
const { nanoid } = require('nanoid');
|
||||
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
|
||||
import _ from 'lodash';
|
||||
import {model as HomebrewModel} from './homebrew.model.js';
|
||||
import express from 'express';
|
||||
import zlib from 'zlib';
|
||||
import GoogleActions from './googleActions.js';
|
||||
import Markdown from '../shared/naturalcrit/markdown.js';
|
||||
import yaml from 'js-yaml';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
|
||||
import checkClientVersion from './middleware/check-client-version.js';
|
||||
|
||||
const { DEFAULT_BREW, DEFAULT_BREW_LOAD } = require('./brewDefaults.js');
|
||||
const router = express.Router();
|
||||
|
||||
const Themes = require('../themes/themes.json');
|
||||
import { DEFAULT_BREW, DEFAULT_BREW_LOAD } from './brewDefaults.js';
|
||||
import Themes from '../themes/themes.json' with { type: 'json' };
|
||||
|
||||
const isStaticTheme = (renderer, themeName)=>{
|
||||
return Themes[renderer]?.[themeName] !== undefined;
|
||||
@@ -87,8 +89,18 @@ const api = {
|
||||
// Get relevant IDs for the brew
|
||||
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.
|
||||
let stub = await HomebrewModel.get(accessType === 'edit' ? { editId: id } : { shareId: id })
|
||||
let stub = await HomebrewModel.get(accessMap[accessType])
|
||||
.catch((err)=>{
|
||||
if(googleId) {
|
||||
console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
|
||||
@@ -242,11 +254,8 @@ const api = {
|
||||
|
||||
let googleId, saved;
|
||||
if(saveToGoogle) {
|
||||
googleId = await api.newGoogleBrew(req.account, newHomebrew, res)
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
res.status(err?.status || err?.response?.status || 500).send(err?.message || err);
|
||||
});
|
||||
googleId = await api.newGoogleBrew(req.account, newHomebrew, res);
|
||||
|
||||
if(!googleId) return;
|
||||
api.excludeStubProps(newHomebrew);
|
||||
newHomebrew.googleId = googleId;
|
||||
@@ -298,9 +307,8 @@ const api = {
|
||||
|
||||
req.params.id = currentTheme.theme;
|
||||
req.params.renderer = currentTheme.renderer;
|
||||
}
|
||||
} else {
|
||||
//=== Static Themes ===//
|
||||
else {
|
||||
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\");`;
|
||||
completeSnippets.push(localSnippets);
|
||||
@@ -351,19 +359,13 @@ const api = {
|
||||
brew.googleId = undefined;
|
||||
} else if(!brew.googleId && saveToGoogle) {
|
||||
// If we don't have a google id and the user wants to save to google, create the google brew and set the google id on the brew
|
||||
brew.googleId = await api.newGoogleBrew(req.account, api.excludeGoogleProps(brew), res)
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
res.status(err.status || err.response.status).send(err.message || err);
|
||||
});
|
||||
brew.googleId = await api.newGoogleBrew(req.account, api.excludeGoogleProps(brew), res);
|
||||
|
||||
if(!brew.googleId) return;
|
||||
} else if(brew.googleId) {
|
||||
// If the google id exists and no other actions are being performed, update the google brew
|
||||
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew))
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
res.status(err?.response?.status || 500).send(err);
|
||||
});
|
||||
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew), req.ip);
|
||||
|
||||
if(!updated) return;
|
||||
}
|
||||
|
||||
@@ -473,7 +475,7 @@ const api = {
|
||||
}
|
||||
};
|
||||
|
||||
router.use('/api', require('./middleware/check-client-version.js'));
|
||||
router.use('/api', checkClientVersion);
|
||||
router.post('/api', asyncHandler(api.newBrew));
|
||||
router.put('/api/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
|
||||
router.put('/api/update/:id', asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
|
||||
@@ -481,4 +483,4 @@ router.delete('/api/:id', asyncHandler(api.deleteBrew));
|
||||
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
|
||||
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
|
||||
|
||||
module.exports = api;
|
||||
export default api;
|
||||
@@ -36,8 +36,9 @@ describe('Tests for api', ()=>{
|
||||
}
|
||||
});
|
||||
|
||||
google = require('./googleActions.js');
|
||||
model = require('./homebrew.model.js').model;
|
||||
google = require('./googleActions.js').default;
|
||||
model = require('./homebrew.model.js').model;
|
||||
api = require('./homebrew.api').default;
|
||||
|
||||
jest.mock('./googleActions.js');
|
||||
google.authCheck = jest.fn(()=>'client');
|
||||
@@ -54,8 +55,6 @@ describe('Tests for api', ()=>{
|
||||
setHeader : jest.fn(()=>{})
|
||||
};
|
||||
|
||||
api = require('./homebrew.api');
|
||||
|
||||
hbBrew = {
|
||||
text : `brew text`,
|
||||
style : 'hello yes i am css',
|
||||
@@ -560,16 +559,6 @@ brew`);
|
||||
views : 0
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle google error', async()=>{
|
||||
google.newGoogleBrew = jest.fn(()=>{
|
||||
throw 'err';
|
||||
});
|
||||
await api.newBrew({ body: { text: 'asdf', title: '' }, query: { saveToGoogle: true }, account: { username: 'test user' } }, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.send).toHaveBeenCalledWith('err');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteGoogleBrew', ()=>{
|
||||
@@ -934,7 +923,7 @@ brew`);
|
||||
expect(req.brew).toEqual(testBrew);
|
||||
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
|
||||
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({
|
||||
'Cache-Control' : 'no-cache',
|
||||
'Content-Type' : 'text/css'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { nanoid } = require('nanoid');
|
||||
const _ = require('lodash');
|
||||
const zlib = require('zlib');
|
||||
import mongoose from 'mongoose';
|
||||
import { nanoid } from 'nanoid';
|
||||
import _ from 'lodash';
|
||||
import zlib from 'zlib';
|
||||
|
||||
|
||||
const HomebrewSchema = mongoose.Schema({
|
||||
shareId : { type: String, default: ()=>{return nanoid(12);}, index: { unique: true } },
|
||||
@@ -44,7 +45,7 @@ HomebrewSchema.statics.get = async function(query, fields=null){
|
||||
const brew = await Homebrew.findOne(query, fields).orFail()
|
||||
.catch((error)=>{throw 'Can not find brew';});
|
||||
if(!_.isNil(brew.textBin)) { // Uncompress zipped text field
|
||||
unzipped = zlib.inflateRawSync(brew.textBin);
|
||||
const unzipped = zlib.inflateRawSync(brew.textBin);
|
||||
brew.text = unzipped.toString();
|
||||
}
|
||||
return brew;
|
||||
@@ -62,7 +63,7 @@ HomebrewSchema.statics.getByUser = async function(username, allowAccess=false, f
|
||||
|
||||
const Homebrew = mongoose.model('Homebrew', HomebrewSchema);
|
||||
|
||||
module.exports = {
|
||||
schema : HomebrewSchema,
|
||||
model : Homebrew,
|
||||
export {
|
||||
HomebrewSchema as schema,
|
||||
Homebrew as model
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
module.exports = (req, res, next)=>{
|
||||
import packageJSON from '../../package.json' with { type: "json" };
|
||||
const version = packageJSON.version;
|
||||
|
||||
export default (req, res, next)=>{
|
||||
const userVersion = req.get('Homebrewery-Version');
|
||||
const version = require('../../package.json').version;
|
||||
|
||||
if(userVersion != version) {
|
||||
return res.status(412).send({
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
module.exports = (req, res, next)=>{
|
||||
import config from '../config.js';
|
||||
const nodeEnv = config.get('node_env');
|
||||
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
|
||||
|
||||
export default (req, res, next)=>{
|
||||
const isImageRequest = req.get('Accept')?.split(',')
|
||||
?.filter((h)=>!h.includes('q='))
|
||||
?.every((h)=>/image\/.*/.test(h));
|
||||
if(isImageRequest) {
|
||||
if(isImageRequest && !isLocalEnvironment && !req.url?.startsWith('/staticImages')) {
|
||||
return res.status(406).send({
|
||||
message : 'Request for image at this URL is not supported'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
const contentNegotiationMiddleware = require('./content-negotiation.js');
|
||||
|
||||
describe('content-negotiation-middleware', ()=>{
|
||||
let request;
|
||||
let response;
|
||||
let next;
|
||||
|
||||
beforeEach(()=>{
|
||||
request = {
|
||||
get : function(key) {
|
||||
return this[key];
|
||||
}
|
||||
};
|
||||
response = {
|
||||
status : jest.fn(()=>response),
|
||||
send : jest.fn(()=>{})
|
||||
};
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
it('should return 406 on image request', ()=>{
|
||||
contentNegotiationMiddleware({
|
||||
Accept : 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
...request
|
||||
}, response);
|
||||
|
||||
expect(response.status).toHaveBeenLastCalledWith(406);
|
||||
expect(response.send).toHaveBeenCalledWith({
|
||||
message : 'Request for image at this URL is not supported'
|
||||
});
|
||||
});
|
||||
|
||||
it('should call next on non-image request', ()=>{
|
||||
contentNegotiationMiddleware({
|
||||
Accept : 'text,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
...request
|
||||
}, response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
62
server/notifications.model.js
Normal file
62
server/notifications.model.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import mongoose from 'mongoose';
|
||||
import _ from '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);
|
||||
|
||||
export {
|
||||
NotificationSchema as schema,
|
||||
Notification as model
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
const expressStaticGzip = require('express-static-gzip');
|
||||
import expressStaticGzip from 'express-static-gzip';
|
||||
|
||||
// Serve brotli-compressed static files if available
|
||||
const customCacheControlHandler=(response, path)=>{
|
||||
@@ -28,4 +28,4 @@ const init=(pathToAssets)=>{
|
||||
} });
|
||||
};
|
||||
|
||||
module.exports = init;
|
||||
export default init;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
const jwt = require('jwt-simple');
|
||||
|
||||
// Load configuration values
|
||||
const config = require('./config.js');
|
||||
import jwt from 'jwt-simple';
|
||||
import config from './config.js';
|
||||
|
||||
// Generate an Access Token for the given User ID
|
||||
const generateAccessToken = (account)=>{
|
||||
@@ -24,6 +22,4 @@ const generateAccessToken = (account)=>{
|
||||
return token;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
generateAccessToken : generateAccessToken
|
||||
};
|
||||
export default generateAccessToken;
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const asyncHandler = require('express-async-handler');
|
||||
const HomebrewModel = require('./homebrew.model.js').model;
|
||||
import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import {model as HomebrewModel } from './homebrew.model.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -29,12 +29,18 @@ const rendererConditions = (legacy, v3)=>{
|
||||
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 title = req.query.title || '';
|
||||
const author = req.query.author || '';
|
||||
const page = Math.max(parseInt(req.query.page) || 1, 1);
|
||||
const count = Math.max(parseInt(req.query.count) || 20, 10);
|
||||
const skip = (page - 1) * count;
|
||||
const sort = req.query.sort || 'title';
|
||||
const dir = req.query.dir || 'asc';
|
||||
|
||||
const combinedQuery = {
|
||||
$and : [
|
||||
@@ -54,6 +60,7 @@ const findBrews = async (req, res)=>{
|
||||
};
|
||||
|
||||
await HomebrewModel.find(combinedQuery, projection)
|
||||
.sort(sortConditions(sort, dir))
|
||||
.skip(skip)
|
||||
.limit(count)
|
||||
.maxTimeMS(5000)
|
||||
@@ -99,4 +106,4 @@ const findTotal = async (req, res)=>{
|
||||
router.get('/api/vault/total', asyncHandler(findTotal));
|
||||
router.get('/api/vault', asyncHandler(findBrews));
|
||||
|
||||
module.exports = router;
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const _ = require('lodash');
|
||||
const yaml = require('js-yaml');
|
||||
const request = require('../client/homebrew/utils/request-middleware.js');
|
||||
import _ from 'lodash';
|
||||
import yaml from 'js-yaml';
|
||||
import request from '../client/homebrew/utils/request-middleware.js';
|
||||
|
||||
const splitTextStyleAndMetadata = (brew)=>{
|
||||
brew.text = brew.text.replaceAll('\r\n', '\n');
|
||||
@@ -51,7 +51,7 @@ const fetchThemeBundle = async (obj, renderer, theme)=>{
|
||||
}));
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
splitTextStyleAndMetadata,
|
||||
printCurrentBrew,
|
||||
fetchThemeBundle,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const diceFont = require('../../../themes/fonts/iconFonts/diceFont.js');
|
||||
const elderberryInn = require('../../../themes/fonts/iconFonts/elderberryInn.js');
|
||||
const fontAwesome = require('../../../themes/fonts/iconFonts/fontAwesome.js');
|
||||
const gameIcons = require('../../../themes/fonts/iconFonts/gameIcons.js');
|
||||
import diceFont from '../../../themes/fonts/iconFonts/diceFont.js';
|
||||
import elderberryInn from '../../../themes/fonts/iconFonts/elderberryInn.js';
|
||||
import fontAwesome from '../../../themes/fonts/iconFonts/fontAwesome.js';
|
||||
import gameIcons from '../../../themes/fonts/iconFonts/gameIcons.js';
|
||||
|
||||
const emojis = {
|
||||
...diceFont,
|
||||
|
||||
@@ -397,6 +397,11 @@ const CodeEditor = createClass({
|
||||
getCursorPosition : function(){
|
||||
return this.codeMirror.getCursor();
|
||||
},
|
||||
getTopVisibleLine : function(){
|
||||
const rect = this.codeMirror.getWrapperElement().getBoundingClientRect();
|
||||
const topVisibleLine = this.codeMirror.lineAtHeight(rect.top, 'window');
|
||||
return topVisibleLine;
|
||||
},
|
||||
updateSize : function(){
|
||||
this.codeMirror.refresh();
|
||||
},
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/* eslint-disable max-lines */
|
||||
const _ = require('lodash');
|
||||
const Marked = require('marked');
|
||||
const MarkedExtendedTables = require('marked-extended-tables');
|
||||
const { markedSmartypantsLite: MarkedSmartypantsLite } = require('marked-smartypants-lite');
|
||||
const { gfmHeadingId: MarkedGFMHeadingId, resetHeadings: MarkedGFMResetHeadingIDs } = require('marked-gfm-heading-id');
|
||||
const { markedEmoji: MarkedEmojis } = require('marked-emoji');
|
||||
import _ from 'lodash';
|
||||
import { Parser as MathParser } from 'expr-eval';
|
||||
import { marked as Marked } from 'marked';
|
||||
import MarkedExtendedTables from 'marked-extended-tables';
|
||||
import { markedSmartypantsLite as MarkedSmartypantsLite } from 'marked-smartypants-lite';
|
||||
import { gfmHeadingId as MarkedGFMHeadingId, resetHeadings as MarkedGFMResetHeadingIDs } from 'marked-gfm-heading-id';
|
||||
import { markedEmoji as MarkedEmojis } from 'marked-emoji';
|
||||
|
||||
//Icon fonts included so they can appear in emoji autosuggest dropdown
|
||||
const diceFont = require('../../themes/fonts/iconFonts/diceFont.js');
|
||||
const elderberryInn = require('../../themes/fonts/iconFonts/elderberryInn.js');
|
||||
const fontAwesome = require('../../themes/fonts/iconFonts/fontAwesome.js');
|
||||
const gameIcons = require('../../themes/fonts/iconFonts/gameIcons.js');
|
||||
import diceFont from '../../themes/fonts/iconFonts/diceFont.js';
|
||||
import elderberryInn from '../../themes/fonts/iconFonts/elderberryInn.js';
|
||||
import gameIcons from '../../themes/fonts/iconFonts/gameIcons.js';
|
||||
import fontAwesome from '../../themes/fonts/iconFonts/fontAwesome.js';
|
||||
|
||||
const MathParser = require('expr-eval').Parser;
|
||||
const renderer = new Marked.Renderer();
|
||||
const renderer = new Marked.Renderer();
|
||||
const tokenizer = new Marked.Tokenizer();
|
||||
|
||||
//Limit math features to simple items
|
||||
@@ -105,16 +105,16 @@ renderer.link = function (href, title, text) {
|
||||
// Expose `src` attribute as `--HB_src` to make the URL accessible via CSS
|
||||
renderer.image = function (href, title, text) {
|
||||
href = cleanUrl(href);
|
||||
if (href === null)
|
||||
if(href === null)
|
||||
return text;
|
||||
|
||||
let out = `<img src="${href}" alt="${text}" style="--HB_src:url(${href});"`;
|
||||
if (title)
|
||||
if(title)
|
||||
out += ` title="${title}"`;
|
||||
|
||||
out += '>';
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
// Disable default reflink behavior, as it steps on our variables extension
|
||||
tokenizer.def = function () {
|
||||
@@ -745,7 +745,7 @@ const tableTerminators = [
|
||||
`:+\\n`, // hardBreak
|
||||
` *{[^\n]+}`, // blockInjector
|
||||
` *{{[^{\n]*\n.*?\n}}` // mustacheDiv
|
||||
]
|
||||
];
|
||||
|
||||
Marked.use(MarkedVariables());
|
||||
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
|
||||
@@ -755,12 +755,12 @@ Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
|
||||
Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
|
||||
|
||||
function cleanUrl(href) {
|
||||
try {
|
||||
href = encodeURI(href).replace(/%25/g, '%');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return href;
|
||||
try {
|
||||
href = encodeURI(href).replace(/%25/g, '%');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
const escapeTest = /[&<>"']/;
|
||||
@@ -854,7 +854,7 @@ const globalVarsList = {};
|
||||
let varsQueue = [];
|
||||
let globalPageNumber = 0;
|
||||
|
||||
module.exports = {
|
||||
const Markdown = {
|
||||
marked : Marked,
|
||||
render : (rawBrewText, pageNumber=0)=>{
|
||||
globalVarsList[pageNumber] = {}; //Reset global links for current page, to ensure values are parsed in order
|
||||
@@ -865,6 +865,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
rawBrewText = rawBrewText.replace(/^\\column$/gm, `\n<div class='columnSplit'></div>\n`);
|
||||
|
||||
const opts = Marked.defaults;
|
||||
|
||||
rawBrewText = opts.hooks.preprocess(rawBrewText);
|
||||
@@ -935,3 +936,6 @@ module.exports = {
|
||||
return errors;
|
||||
},
|
||||
};
|
||||
|
||||
export default Markdown;
|
||||
|
||||
|
||||
@@ -12,10 +12,8 @@ const Nav = {
|
||||
displayName : 'Nav.base',
|
||||
render : function(){
|
||||
return <nav>
|
||||
<div className='navContent'>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</nav>;
|
||||
</nav>;
|
||||
}
|
||||
}),
|
||||
logo : function(){
|
||||
|
||||
@@ -1,184 +1,110 @@
|
||||
require('./splitPane.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const cx = require('classnames');
|
||||
const { useState, useEffect } = React;
|
||||
|
||||
const SplitPane = createClass({
|
||||
displayName : 'SplitPane',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
storageKey : 'naturalcrit-pane-split',
|
||||
onDragFinish : function(){}, //fires when dragging
|
||||
showDividerButtons : true
|
||||
};
|
||||
},
|
||||
const storageKey = 'naturalcrit-pane-split';
|
||||
|
||||
getInitialState : function() {
|
||||
return {
|
||||
currentDividerPos : null,
|
||||
windowWidth : 0,
|
||||
isDragging : false,
|
||||
moveSource : false,
|
||||
moveBrew : false
|
||||
};
|
||||
},
|
||||
const SplitPane = (props)=>{
|
||||
const {
|
||||
onDragFinish = ()=>{},
|
||||
showDividerButtons = true
|
||||
} = props;
|
||||
|
||||
pane1 : React.createRef(null),
|
||||
pane2 : React.createRef(null),
|
||||
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);
|
||||
|
||||
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);
|
||||
},
|
||||
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');
|
||||
|
||||
componentWillUnmount : function() {
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
},
|
||||
window.addEventListener('resize', handleResize);
|
||||
return ()=>window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
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));
|
||||
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
|
||||
|
||||
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){
|
||||
//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(this.state.isDragging){
|
||||
this.props.onDragFinish(this.state.currentDividerPos);
|
||||
window.localStorage.setItem(this.props.storageKey, this.state.currentDividerPos);
|
||||
if(isDragging) {
|
||||
onDragFinish(dividerPos);
|
||||
window.localStorage.setItem(storageKey, dividerPos);
|
||||
}
|
||||
this.setState({ isDragging: false });
|
||||
},
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
handleDown : function(e){
|
||||
const handleDown = (e)=>{
|
||||
e.preventDefault();
|
||||
this.setState({ isDragging: true });
|
||||
//this.unFocus()
|
||||
},
|
||||
|
||||
handleMove : function(e){
|
||||
if(!this.state.isDragging) return;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleMove = (e)=>{
|
||||
if(!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();
|
||||
}
|
||||
},
|
||||
*/
|
||||
setDividerPos(limitPosition(e.pageX));
|
||||
};
|
||||
|
||||
setMoveArrows : function(newState) {
|
||||
if(this.state.showMoveArrows != newState){
|
||||
this.setState({
|
||||
showMoveArrows : newState
|
||||
});
|
||||
}
|
||||
},
|
||||
const liveScrollToggle = ()=>{
|
||||
window.localStorage.setItem('liveScroll', String(!liveScroll));
|
||||
setLiveScroll(!liveScroll);
|
||||
};
|
||||
|
||||
renderMoveArrows : function(){
|
||||
if(this.state.showMoveArrows) {
|
||||
return <>
|
||||
<div className='arrow left'
|
||||
style={{ left: this.state.currentDividerPos-4 }}
|
||||
onClick={()=>this.setState({ moveSource: !this.state.moveSource })} >
|
||||
<i className='fas fa-arrow-left' />
|
||||
</div>
|
||||
<div className='arrow right'
|
||||
style={{ left: this.state.currentDividerPos-4 }}
|
||||
onClick={()=>this.setState({ moveBrew: !this.state.moveBrew })} >
|
||||
<i className='fas fa-arrow-right' />
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
},
|
||||
|
||||
renderDivider : function(){
|
||||
return <>
|
||||
{this.renderMoveArrows()}
|
||||
<div className='divider' onPointerDown={this.handleDown} >
|
||||
<div className='dots'>
|
||||
<i className='fas fa-circle' />
|
||||
<i className='fas fa-circle' />
|
||||
<i className='fas fa-circle' />
|
||||
</div>
|
||||
const renderMoveArrows = (showMoveArrows &&
|
||||
<>
|
||||
<div className='arrow left'
|
||||
onClick={()=>setMoveSource(!moveSource)} >
|
||||
<i className='fas fa-arrow-left' />
|
||||
</div>
|
||||
</>;
|
||||
},
|
||||
<div className='arrow right'
|
||||
onClick={()=>setMoveBrew(!moveBrew)} >
|
||||
<i className='fas fa-arrow-right' />
|
||||
</div>
|
||||
<div id='scrollToggleDiv' className={liveScroll ? 'arrow lock' : 'arrow unlock'}
|
||||
onClick={liveScrollToggle} >
|
||||
<i id='scrollToggle' className={liveScroll ? 'fas fa-lock' : 'fas fa-unlock'} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
render : function(){
|
||||
return <div className='splitPane' onPointerMove={this.handleMove} onPointerUp={this.handleUp}>
|
||||
<Pane
|
||||
width={this.state.currentDividerPos}
|
||||
>
|
||||
{React.cloneElement(this.props.children[0], {
|
||||
...(this.props.showDividerButtons && {
|
||||
moveBrew: this.state.moveBrew,
|
||||
moveSource: this.state.moveSource,
|
||||
setMoveArrows: this.setMoveArrows,
|
||||
}),
|
||||
})}
|
||||
const renderDivider = (
|
||||
<div className={`divider ${isDragging && 'dragging'}`} onPointerDown={handleDown}>
|
||||
{showDividerButtons && renderMoveArrows}
|
||||
<div className='dots'>
|
||||
<i className='fas fa-circle' />
|
||||
<i className='fas fa-circle' />
|
||||
<i className='fas fa-circle' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='splitPane' onPointerMove={handleMove} onPointerUp={handleUp}>
|
||||
<Pane width={dividerPos} moveBrew={moveBrew} moveSource={moveSource} liveScroll={liveScroll} setMoveArrows={setShowMoveArrows}>
|
||||
{props.children[0]}
|
||||
</Pane>
|
||||
{this.renderDivider()}
|
||||
<Pane isDragging={this.state.isDragging}>{this.props.children[1]}</Pane>
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
{renderDivider}
|
||||
<Pane isDragging={isDragging}>{props.children[1]}</Pane>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Pane = createClass({
|
||||
displayName : 'Pane',
|
||||
getDefaultProps : function() {
|
||||
return {
|
||||
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
|
||||
};
|
||||
}
|
||||
const Pane = ({ width, children, isDragging, moveBrew, moveSource, liveScroll, setMoveArrows })=>{
|
||||
const styles = width
|
||||
? { flex: 'none', width: `${width}px` }
|
||||
: { pointerEvents: isDragging ? 'none' : 'auto' }; //Disable mouse capture in the right pane; else dragging into the iframe drops the divider
|
||||
|
||||
return <div className={cx('pane', this.props.className)} style={styles}>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
}
|
||||
});
|
||||
return (
|
||||
<div className='pane' style={styles}>
|
||||
{React.cloneElement(children, { moveBrew, moveSource, liveScroll, setMoveArrows })}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = SplitPane;
|
||||
|
||||
@@ -1,60 +1,68 @@
|
||||
|
||||
.splitPane{
|
||||
.splitPane {
|
||||
position : relative;
|
||||
display : flex;
|
||||
flex-direction : row;
|
||||
height : 100%;
|
||||
outline : none;
|
||||
flex-direction : row;
|
||||
.pane{
|
||||
.pane {
|
||||
flex : 1;
|
||||
overflow-x : hidden;
|
||||
overflow-y : hidden;
|
||||
flex : 1;
|
||||
}
|
||||
.divider{
|
||||
touch-action : none;
|
||||
.divider {
|
||||
position : relative;
|
||||
display : table;
|
||||
height : 100%;
|
||||
width : 15px;
|
||||
cursor : ew-resize;
|
||||
background-color : #bbb;
|
||||
height : 100%;
|
||||
text-align : center;
|
||||
.dots{
|
||||
touch-action : none;
|
||||
cursor : ew-resize;
|
||||
background-color : #BBBBBB;
|
||||
.dots {
|
||||
display : table-cell;
|
||||
vertical-align : middle;
|
||||
text-align : center;
|
||||
i{
|
||||
vertical-align : middle;
|
||||
i {
|
||||
display : block !important;
|
||||
margin : 10px 0px;
|
||||
font-size : 6px;
|
||||
color : #666;
|
||||
color : #666666;
|
||||
}
|
||||
}
|
||||
&:hover{
|
||||
background-color: #999;
|
||||
}
|
||||
&:hover,&.dragging { background-color : #999999; }
|
||||
}
|
||||
.arrow{
|
||||
.arrow {
|
||||
position : absolute;
|
||||
left : 50%;
|
||||
z-index : 999;
|
||||
width : 25px;
|
||||
height : 25px;
|
||||
border : 2px solid #bbb;
|
||||
border-radius : 15px;
|
||||
text-align : center;
|
||||
font-size : 1.2em;
|
||||
text-align : center;
|
||||
cursor : pointer;
|
||||
background-color : #ddd;
|
||||
z-index : 999;
|
||||
box-shadow : 0 4px 5px #0000007f;
|
||||
&.left{
|
||||
background-color : #DDDDDD;
|
||||
border : 2px solid #BBBBBB;
|
||||
border-radius : 15px;
|
||||
box-shadow : 0 4px 5px #0000007F;
|
||||
translate : -50%;
|
||||
&.left {
|
||||
.tooltipLeft('Jump to location in Editor');
|
||||
top : 30px;
|
||||
}
|
||||
&.right{
|
||||
&.right {
|
||||
.tooltipRight('Jump to location in Preview');
|
||||
top : 60px;
|
||||
}
|
||||
&:hover{
|
||||
background-color: #666;
|
||||
&.lock {
|
||||
.tooltipRight('De-sync Editor and Preview locations.');
|
||||
top : 90px;
|
||||
background : #666666;
|
||||
}
|
||||
&.unlock {
|
||||
.tooltipRight('Sync Editor and Preview locations');
|
||||
top : 90px;
|
||||
}
|
||||
&:hover { background-color : #666666; }
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user