mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-01-24 03:23:02 +00:00
Compare commits
857 Commits
v3.15.1
...
ImplementC
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6898425435 | ||
|
|
be2557611e | ||
|
|
1a9a726263 | ||
|
|
dbf82f69f1 | ||
|
|
107e54688b | ||
|
|
b99282a5a7 | ||
|
|
1c0eb720ad | ||
|
|
93482f9022 | ||
|
|
8159c408c8 | ||
|
|
0632d78f71 | ||
|
|
c0155052ea | ||
|
|
628b2542a0 | ||
|
|
85f1da942f | ||
|
|
3909d5aef9 | ||
|
|
f0e047e7cc | ||
|
|
a1237305d7 | ||
|
|
d588a92147 | ||
|
|
2b7a1e1cb2 | ||
|
|
c8efca3120 | ||
|
|
a53eacf055 | ||
|
|
1b10a4001a | ||
|
|
75e71dd6f5 | ||
|
|
3f87b9f7d3 | ||
|
|
32561cf368 | ||
|
|
bf94cdcb6f | ||
|
|
e8eedcf6d6 | ||
|
|
92d1238a46 | ||
|
|
fcfd3171bd | ||
|
|
9a6cf8c5d2 | ||
|
|
91d928fd8a | ||
|
|
bca653bc4d | ||
|
|
dceb5e516b | ||
|
|
adb1db1d3c | ||
|
|
e8d1e632b4 | ||
|
|
50fcffb253 | ||
|
|
aae5367ad2 | ||
|
|
40b0c1ce3a | ||
|
|
ba83dfacd9 | ||
|
|
2717e6a9a4 | ||
|
|
d576bddd32 | ||
|
|
fde21868cd | ||
|
|
ed8c4d0eef | ||
|
|
6e9d293bbe | ||
|
|
7e1312805f | ||
|
|
d629fa1731 | ||
|
|
6301a66fd3 | ||
|
|
980a7bd57e | ||
|
|
6b0022ad00 | ||
|
|
0f33973e58 | ||
|
|
7a41a140fd | ||
|
|
57467701d0 | ||
|
|
9dbfb26e6c | ||
|
|
7a169cbd9e | ||
|
|
2dc8a8fbe9 | ||
|
|
5f14f656ef | ||
|
|
6e8a0d7314 | ||
|
|
e61144beb8 | ||
|
|
64b792c645 | ||
|
|
aee5b7a8cc | ||
|
|
912f9f0cf6 | ||
|
|
c63b6ffaf0 | ||
|
|
0c90d1a14d | ||
|
|
0148eafce0 | ||
|
|
a3ec5b8d3b | ||
|
|
4ded48df1e | ||
|
|
bc14246fe7 | ||
|
|
fcf985a115 | ||
|
|
a060fd123c | ||
|
|
7c7e143365 | ||
|
|
efa8f3fedf | ||
|
|
972a93d292 | ||
|
|
35be1e9b94 | ||
|
|
1a91c390f8 | ||
|
|
206e4fbda8 | ||
|
|
af98cb3867 | ||
|
|
f8fc6f7aa4 | ||
|
|
eb0fa28a03 | ||
|
|
4ab1a22eb3 | ||
|
|
962a46a670 | ||
|
|
cb16b32016 | ||
|
|
56f348f7ed | ||
|
|
b7c99b2d52 | ||
|
|
889f80f537 | ||
|
|
c270a69bb9 | ||
|
|
db0df82202 | ||
|
|
1346361f80 | ||
|
|
fdaf9d4808 | ||
|
|
3cdfae4270 | ||
|
|
a9275698fa | ||
|
|
99f2972079 | ||
|
|
afc92c4545 | ||
|
|
b26526a2f1 | ||
|
|
4f57f006ce | ||
|
|
666a94cd65 | ||
|
|
f0c094e9d8 | ||
|
|
a1c228b1d1 | ||
|
|
5e5c637c79 | ||
|
|
d573129f31 | ||
|
|
abd52f93d8 | ||
|
|
57cb334c15 | ||
|
|
c29e1905bf | ||
|
|
52d00b17a4 | ||
|
|
35364c400a | ||
|
|
77f0c1bf56 | ||
|
|
2d281072fa | ||
|
|
870a4c3363 | ||
|
|
aa951ff96c | ||
|
|
83b8f9c3b7 | ||
|
|
3a20452214 | ||
|
|
bae9fe939d | ||
|
|
3e4ba89ed9 | ||
|
|
2c5c3d40df | ||
|
|
213240327d | ||
|
|
eca0f59b40 | ||
|
|
51936a1b99 | ||
|
|
6136b78395 | ||
|
|
81f56ec91d | ||
|
|
c7d94b0779 | ||
|
|
9758797e2b | ||
|
|
74a7983757 | ||
|
|
4eb8abf1e7 | ||
|
|
23910cc94c | ||
|
|
ef0ee78758 | ||
|
|
1b20c00842 | ||
|
|
db9212bd12 | ||
|
|
7348ecbb3d | ||
|
|
31a22703c1 | ||
|
|
33f8f6bf38 | ||
|
|
a62d2bd457 | ||
|
|
ffa9666bb9 | ||
|
|
406f5d4e14 | ||
|
|
ed404d3906 | ||
|
|
3178c8722e | ||
|
|
596c4ad68d | ||
|
|
14a0f79ac8 | ||
|
|
e7f4611a00 | ||
|
|
136a6d4024 | ||
|
|
737e27f062 | ||
|
|
ee9143fa35 | ||
|
|
c62bb53660 | ||
|
|
4e5a971f0a | ||
|
|
16184f1b8d | ||
|
|
23bd0309b9 | ||
|
|
20dba6b7b3 | ||
|
|
5177c9a64e | ||
|
|
d179c18c35 | ||
|
|
6e4e35c7ad | ||
|
|
6c2721d49f | ||
|
|
029d61b6ad | ||
|
|
f58040e9a4 | ||
|
|
9f9948f531 | ||
|
|
2743ab869a | ||
|
|
4b21538e3e | ||
|
|
e17db0788c | ||
|
|
bea74c3b46 | ||
|
|
e252a39bd2 | ||
|
|
7ef259ddbe | ||
|
|
d18005fad4 | ||
|
|
86402cdbc8 | ||
|
|
e28b4e8c20 | ||
|
|
7c09680939 | ||
|
|
3f0a6a577f | ||
|
|
6f4cc0d91b | ||
|
|
e711a1c207 | ||
|
|
add088c2a9 | ||
|
|
6d8415bfeb | ||
|
|
decb334808 | ||
|
|
7a76c67038 | ||
|
|
b45686eb3b | ||
|
|
66f71972eb | ||
|
|
ebe8e1067c | ||
|
|
9807cf762b | ||
|
|
b58563cb42 | ||
|
|
7c3f3b87af | ||
|
|
e7daad592c | ||
|
|
992359e239 | ||
|
|
b2546f3458 | ||
|
|
6f016bf5b6 | ||
|
|
7cd3c69fbd | ||
|
|
9b1507d4f5 | ||
|
|
2e49bf4fa8 | ||
|
|
108d368d45 | ||
|
|
bd413cfc55 | ||
|
|
1af13b4e94 | ||
|
|
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 | ||
|
|
4bad047f93 | ||
|
|
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 | ||
|
|
28a7f24989 | ||
|
|
28855d02a6 | ||
|
|
650ec04417 | ||
|
|
9ef11bca99 | ||
|
|
88b34a7ba3 | ||
|
|
c6b0b6a0ea | ||
|
|
9d86384032 | ||
|
|
a6bc87bcea | ||
|
|
63add047b6 | ||
|
|
a0e88bb24f | ||
|
|
5b14e0e9b5 | ||
|
|
274e734135 | ||
|
|
7c7326b42a | ||
|
|
3818424251 | ||
|
|
2222550669 | ||
|
|
d0cf6eebbb | ||
|
|
93b9f1d1da | ||
|
|
0ba838f5ae | ||
|
|
172a3eaadf | ||
|
|
b868ba9406 | ||
|
|
89a16956b9 | ||
|
|
b098d28407 | ||
|
|
1be1b3b747 | ||
|
|
f63d2de8f4 | ||
|
|
626b602a61 | ||
|
|
3d7d90104b | ||
|
|
2abc2b13f0 | ||
|
|
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 | ||
|
|
5ab867f21e | ||
|
|
999a96b5ce | ||
|
|
ef5e3ddf4c | ||
|
|
d5f498cbf9 | ||
|
|
5040b9528f | ||
|
|
83a48b8d0c | ||
|
|
9fbdd24d01 | ||
|
|
5b136f651c | ||
|
|
4126188df1 | ||
|
|
26050e2134 | ||
|
|
5c0d6e6012 | ||
|
|
de7b13bc15 | ||
|
|
b6bd7ccf67 | ||
|
|
822d0c7738 | ||
|
|
183dd63021 | ||
|
|
0afc2ab2e6 | ||
|
|
effeef0d67 | ||
|
|
cf3d5df582 | ||
|
|
71cdbf1079 | ||
|
|
712db7dfa7 | ||
|
|
ed48c6664b | ||
|
|
119755e23a | ||
|
|
41fdf48ad3 | ||
|
|
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 | ||
|
|
35856ad01e | ||
|
|
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 | ||
|
|
766fd40b72 | ||
|
|
3e6884b506 | ||
|
|
2118142faa | ||
|
|
2b270ccdb7 | ||
|
|
08eabf8102 | ||
|
|
c1d85bc216 | ||
|
|
4291284252 | ||
|
|
3a2c213cf8 | ||
|
|
99dc0deb08 | ||
|
|
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 | ||
|
|
a7361f8450 | ||
|
|
32fa272947 | ||
|
|
68895bdca2 | ||
|
|
8ab6a8599d | ||
|
|
ea656e5119 | ||
|
|
570c850c4f | ||
|
|
17dfacd5c9 | ||
|
|
ab32695ac9 | ||
|
|
a96ff6ecb3 | ||
|
|
5af45f16b0 | ||
|
|
a9b6d5ff38 | ||
|
|
433f016c25 | ||
|
|
10a9bc2906 | ||
|
|
b585e85f0f | ||
|
|
8a67e1eccd | ||
|
|
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 | ||
|
|
f024bea493 | ||
|
|
2e5ebb861e | ||
|
|
1b577c4030 | ||
|
|
bbe4b5f978 | ||
|
|
14d2534542 | ||
|
|
3b49b5180e | ||
|
|
2028f3dccd | ||
|
|
8e4fc01831 | ||
|
|
e92c169e71 | ||
|
|
e2ae6898fd | ||
|
|
44262e2aae | ||
|
|
d8e174e143 | ||
|
|
0d2878a7e7 | ||
|
|
a0d043439c | ||
|
|
8126271ea3 | ||
|
|
67e265b23f | ||
|
|
dc67c75130 | ||
|
|
e213eb0a78 | ||
|
|
422829cbd8 | ||
|
|
ebc3b4ee66 | ||
|
|
9bf28f1433 | ||
|
|
dbbfb0b628 | ||
|
|
4f2c2916d6 | ||
|
|
629b51a26c | ||
|
|
d947ff45e2 | ||
|
|
a2d260c297 | ||
|
|
c411691fd6 | ||
|
|
f40c5e17ca | ||
|
|
3360b4e829 | ||
|
|
9e1a532105 | ||
|
|
a2497052b4 | ||
|
|
240dfa3954 | ||
|
|
d19aaf6c78 | ||
|
|
235969a485 | ||
|
|
2e459118aa | ||
|
|
ff60ca163f | ||
|
|
4dc5746c71 | ||
|
|
5cf8715dea | ||
|
|
849e5d5d1a | ||
|
|
188090ee45 | ||
|
|
d352b76efe | ||
|
|
e88272c684 | ||
|
|
10ce696333 | ||
|
|
4488fe36db | ||
|
|
c79765396d | ||
|
|
36549f3224 | ||
|
|
e81a9dab1f | ||
|
|
65759e18bd | ||
|
|
f458b98dcf | ||
|
|
cc7fe99760 | ||
|
|
78642e514d | ||
|
|
4edbfa10b5 | ||
|
|
5e8f74b9bc | ||
|
|
b39e8eea16 | ||
|
|
0c6c0c9fd6 | ||
|
|
51d3d11bff | ||
|
|
46882c4fb4 | ||
|
|
760c1a9e8c | ||
|
|
2b79583e8c | ||
|
|
609b40e84c | ||
|
|
07c574fa42 | ||
|
|
960ecae861 | ||
|
|
49a4daa8f6 | ||
|
|
1f41745d2b | ||
|
|
1602f0af37 | ||
|
|
375c54016c | ||
|
|
de20311299 | ||
|
|
5fede97fa5 | ||
|
|
cc76ff1478 | ||
|
|
bbe56bf443 | ||
|
|
f449132b4c | ||
|
|
baf201cc3a | ||
|
|
b99c0382f6 | ||
|
|
e2ce1185b6 | ||
|
|
8983d74775 | ||
|
|
f084c11936 | ||
|
|
a442817226 | ||
|
|
73e579703a | ||
|
|
e3586f0734 | ||
|
|
8d49422061 | ||
|
|
59790bd005 | ||
|
|
24c950227a | ||
|
|
b55db94822 | ||
|
|
c17f976385 | ||
|
|
e83e6567af | ||
|
|
b638cca547 | ||
|
|
2fc5bcabb8 | ||
|
|
52658d6e44 | ||
|
|
9f3a4dc6bb | ||
|
|
acb10d7695 | ||
|
|
fa4ced0592 | ||
|
|
3f1d6a5459 | ||
|
|
d60d902e27 | ||
|
|
e1c1e32a4b | ||
|
|
5c2f603860 | ||
|
|
47b78510df | ||
|
|
4680e7a5cc | ||
|
|
f07252d670 | ||
|
|
f15c831b70 | ||
|
|
fdbec6d789 | ||
|
|
587831652c | ||
|
|
90b504d67d | ||
|
|
8efea112b4 | ||
|
|
acbdd1b801 | ||
|
|
d012a09346 | ||
|
|
3cca38302a | ||
|
|
f9352a94c6 | ||
|
|
1add97b1b2 | ||
|
|
6e0aff525f | ||
|
|
748c25aae4 | ||
|
|
f675fd130f | ||
|
|
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
|
||||
@@ -70,15 +70,27 @@ jobs:
|
||||
- run:
|
||||
name: Test - Hard Breaks
|
||||
command: npm run test:hard-breaks
|
||||
- run:
|
||||
name: Test - Non-Breaking Spaces
|
||||
command: npm run test:non-breaking-spaces
|
||||
- run:
|
||||
name: Test - Variables
|
||||
command: npm run test:variables
|
||||
- run:
|
||||
name: Test - Emojis
|
||||
command: npm run test:emojis
|
||||
- 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
|
||||
- run:
|
||||
name: Test - Content Negotiation
|
||||
command: npm run test:content-negotiation
|
||||
|
||||
workflows:
|
||||
build_and_test:
|
||||
|
||||
25
.github/pull_request_template.md
vendored
25
.github/pull_request_template.md
vendored
@@ -1,26 +1,29 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
> [!TIP]
|
||||
> 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
|
||||
|
||||
_Describe what your PR accomplishes. Consider walking through the main changes to aid reviewers in following your code, especially if it covers multiple files._
|
||||
|
||||
## Related Issues or Discussions
|
||||
|
||||
> [!CAUTION]
|
||||
> If no issue exists yet, create it, and get agreement on the approach (or paste in a previous agreement from chat, etc.) before moving forward. (Experimental PRs are OK without prior discussion, but do not expect to get merged.)
|
||||
|
||||
- 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._
|
||||
_Replace this line with instructions on how to test or view your changes, as well as any before/after
|
||||
screenshots or recordings for UI changes._
|
||||
|
||||
### Reviewer Checklist
|
||||
|
||||
_Please replace the list below with specific features you want reviewers to look at._
|
||||
_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
|
||||
@@ -32,5 +35,3 @@ _Please replace the list below with specific features you want reviewers to look
|
||||
- [ ] 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
96
client/components/Anchored.jsx
Normal file
96
client/components/Anchored.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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.
|
||||
|
||||
// When Anchor Positioning is added to Firefox, this can also be rewritten using the Popover API-- add the `popover` attribute
|
||||
// to the container div, which will render the container in the *top level* and give it better interactions like
|
||||
// click outside to dismiss. **Do not** add without Anchor, though, because positioning is very limited with the `popover`
|
||||
// attribute.
|
||||
|
||||
|
||||
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, useCallback } = React;
|
||||
const { useState, useRef, useMemo, useEffect } = 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,15 +36,53 @@ 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 pageRef = useRef(null);
|
||||
const cleanText = safeHTML(props.contents);
|
||||
|
||||
useEffect(()=>{
|
||||
if(!pageRef.current) return;
|
||||
|
||||
// Observer for tracking pages within the `.pages` div
|
||||
const visibleObserver = new IntersectionObserver(
|
||||
(entries)=>{
|
||||
entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(props.index + 1, true, false); // add page to array of visible pages.
|
||||
else
|
||||
props.onVisibilityChange(props.index + 1, false, false);
|
||||
}
|
||||
)},
|
||||
{ threshold: .3, rootMargin: '0px 0px 0px 0px' } // detect when >30% of page is within bounds.
|
||||
);
|
||||
|
||||
// Observer for tracking the page at the center of the iframe.
|
||||
const centerObserver = new IntersectionObserver(
|
||||
(entries)=>{
|
||||
entries.forEach((entry)=>{
|
||||
if(entry.isIntersecting)
|
||||
props.onVisibilityChange(props.index + 1, true, true); // Set this page as the center page
|
||||
}
|
||||
)},
|
||||
{ threshold: 0, rootMargin: '-50% 0px -50% 0px' } // Detect when the page is at the center
|
||||
);
|
||||
|
||||
// attach observers to each `.page`
|
||||
visibleObserver.observe(pageRef.current);
|
||||
centerObserver.observe(pageRef.current);
|
||||
return ()=>{
|
||||
visibleObserver.disconnect();
|
||||
centerObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className={props.className} id={`p${props.index + 1}`} data-index={props.index} ref={pageRef} style={props.style}>
|
||||
<div className='columnWrapper' dangerouslySetInnerHTML={{ __html: cleanText }} />
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
||||
//v=====--------------------< Brew Renderer Component >-------------------=====v//
|
||||
const renderedPages = [];
|
||||
let renderedPages = [];
|
||||
let rawPages = [];
|
||||
|
||||
const BrewRenderer = (props)=>{
|
||||
@@ -64,10 +102,17 @@ const BrewRenderer = (props)=>{
|
||||
};
|
||||
|
||||
const [state, setState] = useState({
|
||||
height : PAGE_HEIGHT,
|
||||
isMounted : false,
|
||||
visibility : 'hidden',
|
||||
zoom : 100
|
||||
isMounted : false,
|
||||
visibility : 'hidden',
|
||||
visiblePages : [],
|
||||
centerPage : 1
|
||||
});
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useState({
|
||||
zoomLevel : 100,
|
||||
spread : 'single',
|
||||
startOnRight : true,
|
||||
pageShadows : true
|
||||
});
|
||||
|
||||
const mainRef = useRef(null);
|
||||
@@ -78,25 +123,23 @@ const BrewRenderer = (props)=>{
|
||||
rawPages = props.text.split(/^\\page$/gm);
|
||||
}
|
||||
|
||||
useEffect(()=>{ // Unmounting steps
|
||||
return ()=>{window.removeEventListener('resize', updateSize);};
|
||||
}, []);
|
||||
const handlePageVisibilityChange = (pageNum, isVisible, isCenter)=>{
|
||||
setState((prevState)=>{
|
||||
const updatedVisiblePages = new Set(prevState.visiblePages);
|
||||
if(!isCenter)
|
||||
isVisible ? updatedVisiblePages.add(pageNum) : updatedVisiblePages.delete(pageNum);
|
||||
|
||||
const updateSize = ()=>{
|
||||
setState((prevState)=>({
|
||||
...prevState,
|
||||
height : mainRef.current.parentNode.clientHeight,
|
||||
}));
|
||||
return {
|
||||
...prevState,
|
||||
visiblePages : [...updatedVisiblePages].sort((a, b)=>a - b),
|
||||
centerPage : isCenter ? pageNum : prevState.centerPage
|
||||
};
|
||||
});
|
||||
|
||||
if(isCenter)
|
||||
props.onPageChange(pageNum);
|
||||
};
|
||||
|
||||
const updateCurrentPage = useCallback(_.throttle((e)=>{
|
||||
const { scrollTop, clientHeight, scrollHeight } = e.target;
|
||||
const totalScrollableHeight = scrollHeight - clientHeight;
|
||||
const currentPageNumber = Math.max(Math.ceil((scrollTop / totalScrollableHeight) * rawPages.length), 1);
|
||||
|
||||
props.onPageChange(currentPageNumber);
|
||||
}, 200), []);
|
||||
|
||||
const isInView = (index)=>{
|
||||
if(!state.isMounted)
|
||||
return false;
|
||||
@@ -117,19 +160,27 @@ 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)=>{
|
||||
|
||||
const styles = {
|
||||
...(!displayOptions.pageShadows ? { boxShadow: 'none' } : {})
|
||||
// Add more conditions as needed
|
||||
};
|
||||
|
||||
if(props.renderer == 'legacy') {
|
||||
const html = MarkdownLegacy.render(pageText);
|
||||
return <BrewPage className='page phb' index={index} key={index} contents={html} />;
|
||||
|
||||
return <BrewPage className='page phb' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
} 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} />;
|
||||
|
||||
return <BrewPage className='page' index={index} key={index} contents={html} style={styles} onVisibilityChange={handlePageVisibilityChange} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,7 +192,8 @@ const BrewRenderer = (props)=>{
|
||||
renderedPages.length = 0;
|
||||
|
||||
// Render currently-edited page first so cross-page effects (variables, links) can propagate out first
|
||||
renderedPages[props.currentEditorCursorPageNum - 1] = renderPage(rawPages[props.currentEditorCursorPageNum - 1], props.currentEditorCursorPageNum - 1);
|
||||
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'){
|
||||
@@ -161,10 +213,30 @@ const BrewRenderer = (props)=>{
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToHash = (hash)=>{
|
||||
if(!hash) return;
|
||||
|
||||
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 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,
|
||||
@@ -179,19 +251,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, displayOptions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*render dummy page while iFrame is mounting.*/}
|
||||
{!state.isMounted
|
||||
? <div className='brewRenderer' onScroll={updateCurrentPage}>
|
||||
? <div className='brewRenderer'>
|
||||
<div className='pages'>
|
||||
{renderDummyPage(1)}
|
||||
</div>
|
||||
@@ -204,7 +287,7 @@ const BrewRenderer = (props)=>{
|
||||
<NotificationPopup />
|
||||
</div>
|
||||
|
||||
<ToolBar onZoomChange={handleZoom} currentPage={props.currentBrewRendererPageNum} totalPages={rawPages.length}/>
|
||||
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length}/>
|
||||
|
||||
{/*render in iFrame so broken code doesn't crash the site.*/}
|
||||
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
|
||||
@@ -212,19 +295,19 @@ const BrewRenderer = (props)=>{
|
||||
contentDidMount={frameDidMount}
|
||||
onClick={()=>{emitClick();}}
|
||||
>
|
||||
<div className={'brewRenderer'}
|
||||
onScroll={updateCurrentPage}
|
||||
<div className={`brewRenderer ${global.config.deployment && 'deployment'}`}
|
||||
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 className={`pages ${displayOptions.startOnRight ? 'recto' : 'verso'} ${displayOptions.spread}`} lang={`${props.lang || 'en'}`} style={pagesStyle}>
|
||||
{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: safe 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: safe center;
|
||||
& :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;
|
||||
@@ -1,28 +1,31 @@
|
||||
/* eslint-disable max-lines */
|
||||
require('./toolBar.less');
|
||||
const React = require('react');
|
||||
const { useState, useEffect } = React;
|
||||
const _ = require('lodash');
|
||||
|
||||
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx';
|
||||
|
||||
const MAX_ZOOM = 300;
|
||||
const MIN_ZOOM = 10;
|
||||
|
||||
const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
||||
const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages })=>{
|
||||
|
||||
const [zoomLevel, setZoomLevel] = useState(100);
|
||||
const [pageNum, setPageNum] = useState(currentPage);
|
||||
const [pageNum, setPageNum] = useState(1);
|
||||
const [toolsVisible, setToolsVisible] = useState(true);
|
||||
|
||||
useEffect(()=>{
|
||||
onZoomChange(zoomLevel);
|
||||
}, [zoomLevel]);
|
||||
|
||||
useEffect(()=>{
|
||||
setPageNum(currentPage);
|
||||
}, [currentPage]);
|
||||
// format multiple visible pages as a range (e.g. "150-153")
|
||||
const pageRange = visiblePages.length === 1 ? `${visiblePages[0]}` : `${visiblePages[0]} - ${visiblePages.at(-1)}`;
|
||||
setPageNum(pageRange);
|
||||
}, [visiblePages]);
|
||||
|
||||
const handleZoomButton = (zoom)=>{
|
||||
setZoomLevel(_.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
|
||||
handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM)));
|
||||
};
|
||||
|
||||
const handleOptionChange = (optionKey, newValue)=>{
|
||||
onDisplayOptionsChange({ ...displayOptions, [optionKey]: newValue });
|
||||
};
|
||||
|
||||
const handlePageInput = (pageInput)=>{
|
||||
@@ -30,16 +33,16 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
||||
setPageNum(parseInt(pageInput)); // input type is 'text', so `page` comes in as a string, not number.
|
||||
};
|
||||
|
||||
// scroll to a page, used in the Prev/Next Page buttons.
|
||||
const scrollToPage = (pageNumber)=>{
|
||||
if(typeof pageNumber !== 'number') return;
|
||||
pageNumber = _.clamp(pageNumber, 1, totalPages);
|
||||
const iframe = document.getElementById('BrewRenderer');
|
||||
const brewRenderer = iframe?.contentWindow?.document.querySelector('.brewRenderer');
|
||||
const page = brewRenderer?.querySelector(`#p${pageNumber}`);
|
||||
page?.scrollIntoView({ block: 'start' });
|
||||
setPageNum(pageNumber);
|
||||
};
|
||||
|
||||
|
||||
const calculateChange = (mode)=>{
|
||||
const iframe = document.getElementById('BrewRenderer');
|
||||
const iframeWidth = iframe.getBoundingClientRect().width;
|
||||
@@ -55,55 +58,63 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
||||
desiredZoom = (iframeWidth / widestPage) * 100;
|
||||
|
||||
} else if(mode == 'fit'){
|
||||
let minDimRatio;
|
||||
// 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);
|
||||
if(displayOptions.spread === 'facing')
|
||||
minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth / 2), Infinity); // if 'facing' spread, fit two pages in view
|
||||
else
|
||||
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 ${toolsVisible ? 'visible' : 'hidden'}`}>
|
||||
<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'>
|
||||
@@ -113,20 +124,74 @@ 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'
|
||||
onClick={()=>scrollToPage(pageNum - 1)}
|
||||
disabled={pageNum <= 1}
|
||||
type='button'
|
||||
title='Previous Page(s)'
|
||||
onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)}
|
||||
disabled={visiblePages.includes(1)}
|
||||
>
|
||||
<i className='fas fa-arrow-left'></i>
|
||||
</button>
|
||||
@@ -137,6 +202,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}
|
||||
@@ -144,15 +210,18 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{
|
||||
onChange={(e)=>handlePageInput(e.target.value)}
|
||||
onBlur={()=>scrollToPage(pageNum)}
|
||||
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
|
||||
style={{ width: `${pageNum.length}ch` }}
|
||||
/>
|
||||
<span id='page-count'>/ {totalPages}</span>
|
||||
<span id='page-count' title='Total Page Count'>/ {totalPages}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id='next-page'
|
||||
className='tool'
|
||||
onClick={()=>scrollToPage(pageNum + 1)}
|
||||
disabled={pageNum >= totalPages}
|
||||
type='button'
|
||||
title='Next Page(s)'
|
||||
onClick={()=>scrollToPage(_.max(visiblePages) + 1)}
|
||||
disabled={visiblePages.includes(totalPages)}
|
||||
>
|
||||
<i className='fas fa-arrow-right'></i>
|
||||
</button>
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
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 .2s ease;
|
||||
opacity : 1;
|
||||
transition : all 0.2s ease;
|
||||
}
|
||||
|
||||
.group {
|
||||
@@ -34,14 +35,78 @@
|
||||
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;
|
||||
padding : 2px 5px;
|
||||
font-family : 'Open Sans', sans-serif;
|
||||
color : #000000;
|
||||
background : #EEEEEE;
|
||||
border : 1px solid gray;
|
||||
color : inherit;
|
||||
background : #3B3B3B;
|
||||
border : none;
|
||||
&:focus { outline : 1px solid #D3D3D3; }
|
||||
|
||||
// `.range-input` if generic to all range inputs, or `#zoom-slider` if only for zoom slider
|
||||
@@ -50,14 +115,14 @@
|
||||
color : #D3D3D3;
|
||||
accent-color : #D3D3D3;
|
||||
|
||||
&::-webkit-slider-thumb, &::-moz-slider-thumb {
|
||||
&::-webkit-slider-thumb, &::-moz-range-thumb {
|
||||
width : 5px;
|
||||
height : 5px;
|
||||
cursor : pointer;
|
||||
cursor : ew-resize;
|
||||
outline : none;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
&.hover-tooltip[value]:hover::after {
|
||||
position : absolute;
|
||||
bottom : -30px;
|
||||
left : 50%;
|
||||
@@ -76,53 +141,47 @@
|
||||
|
||||
// `.text-input` if generic to all range inputs, or `#page-input` if only for current page input
|
||||
&#page-input {
|
||||
width : 4ch;
|
||||
min-width : 5ch;
|
||||
margin-right : 1ch;
|
||||
text-align : center;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
width: 32px;
|
||||
transition: all .3s ease;
|
||||
flex-wrap:nowrap;
|
||||
overflow: hidden;
|
||||
background-color: unset;
|
||||
opacity: .5;
|
||||
flex-wrap : nowrap;
|
||||
width : 32px;
|
||||
overflow : hidden;
|
||||
background-color : unset;
|
||||
opacity : 0.5;
|
||||
transition : all 0.3s ease;
|
||||
& > *:not(.toggleButton) {
|
||||
opacity: 0;
|
||||
transition: all .2s ease;
|
||||
opacity : 0;
|
||||
transition : all 0.2s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.toggleButton {
|
||||
z-index : 5;
|
||||
position:absolute;
|
||||
left: 0;
|
||||
width: 32px;
|
||||
min-width: unset;
|
||||
position : absolute;
|
||||
left : 0;
|
||||
z-index : 5;
|
||||
width : 32px;
|
||||
min-width : unset;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const _ = require('lodash');
|
||||
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');
|
||||
@@ -314,7 +314,7 @@ const Editor = createClass({
|
||||
},
|
||||
|
||||
brewJump : function(targetPage=this.props.currentEditorCursorPageNum, smooth=true){
|
||||
if(!window || isJumping)
|
||||
if(!window || !this.isText() || isJumping)
|
||||
return;
|
||||
|
||||
// Get current brewRenderer scroll position and calculate target position
|
||||
@@ -355,7 +355,7 @@ const Editor = createClass({
|
||||
},
|
||||
|
||||
sourceJump : function(targetPage=this.props.currentBrewRendererPageNum, smooth=true){
|
||||
if(!this.isText || isJumping)
|
||||
if(!this.isText() || isJumping)
|
||||
return;
|
||||
|
||||
const textSplit = this.props.renderer == 'V3' ? /^\\page$/gm : /\\page/;
|
||||
|
||||
@@ -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');
|
||||
@@ -341,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>
|
||||
@@ -363,12 +364,13 @@ const MetadataEditor = createClass({
|
||||
|
||||
{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)}
|
||||
/>
|
||||
|
||||
<h2>Privacy</h2>
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
text-overflow : ellipsis;
|
||||
}
|
||||
button {
|
||||
.colorButton();
|
||||
padding : 0px 5px;
|
||||
color : white;
|
||||
background-color : black;
|
||||
@@ -138,16 +139,16 @@
|
||||
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 {
|
||||
@@ -271,7 +272,7 @@
|
||||
&:last-child { border-radius : 0 0.5em 0.5em 0; }
|
||||
}
|
||||
|
||||
.badge {
|
||||
.tag {
|
||||
padding : 0.3em;
|
||||
margin : 2px;
|
||||
font-size : 0.9em;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/*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 { getHistoryItems, historyExists } from '../../utils/versionHistory.js';
|
||||
import { loadHistory } from '../../utils/versionHistory.js';
|
||||
|
||||
//Import all themes
|
||||
const ThemeSnippets = {};
|
||||
@@ -50,30 +50,47 @@ const Snippetbar = createClass({
|
||||
renderer : this.props.renderer,
|
||||
themeSelector : false,
|
||||
snippets : [],
|
||||
historyExists : false
|
||||
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) {
|
||||
this.setState({
|
||||
snippets : this.compileSnippets()
|
||||
});
|
||||
};
|
||||
|
||||
if(historyExists(this.props.brew) != this.state.historyExists){
|
||||
// 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 : !this.state.historyExists
|
||||
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) {
|
||||
@@ -133,30 +150,40 @@ 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() {
|
||||
const historyItems = getHistoryItems(this.props.brew);
|
||||
if(!this.state.historyExists) return;
|
||||
|
||||
return <div className='dropdown'>
|
||||
{_.map(historyItems, (item, index)=>{
|
||||
if(!item.savedAt) return;
|
||||
{_.map(this.state.historyItems, (item, index)=>{
|
||||
if(item.noData || !item.savedAt) return;
|
||||
|
||||
const saveTime = new Date(item.savedAt);
|
||||
const diffMs = new Date() - saveTime;
|
||||
@@ -180,57 +207,59 @@ const Snippetbar = createClass({
|
||||
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>
|
||||
</>;
|
||||
|
||||
|
||||
}
|
||||
return (
|
||||
<div className='editors'>
|
||||
{this.props.view !== 'meta' && <><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='codeTools'>
|
||||
<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>
|
||||
<div className={`editorTheme ${this.state.themeSelector ? 'active' : ''}`}
|
||||
onClick={this.toggleThemeSelector} >
|
||||
<i className='fas fa-palette' />
|
||||
{this.state.themeSelector && this.renderThemeSelector()}
|
||||
</div>
|
||||
</div></>}
|
||||
|
||||
|
||||
return <div className='editors'>
|
||||
<div className={`editorTool snippetGroup history ${this.state.historyExists ? 'active' : ''}`} >
|
||||
<i className='fas fa-clock-rotate-left' />
|
||||
{this.state.historyExists && 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 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='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 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>
|
||||
</div>;
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
render : function(){
|
||||
@@ -267,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,97 +4,119 @@
|
||||
.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;
|
||||
}
|
||||
&.history {
|
||||
.tooltipLeft('History');
|
||||
font-size : 0.75em;
|
||||
color : grey;
|
||||
position : relative;
|
||||
&.active {
|
||||
color : inherit;
|
||||
justify-content : flex-start;
|
||||
min-width : 327.58px;
|
||||
}
|
||||
|
||||
.editors {
|
||||
display : flex;
|
||||
justify-content : flex-end;
|
||||
min-width : 225px;
|
||||
|
||||
&:only-child { margin-left : auto;min-width:unset;}
|
||||
|
||||
>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;
|
||||
|
||||
&.editorTool:not(.active) {
|
||||
cursor:not-allowed;
|
||||
}
|
||||
&>.dropdown{
|
||||
right : -1px;
|
||||
&>.snippet{
|
||||
padding-right : 10px;
|
||||
|
||||
&: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;
|
||||
&.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;
|
||||
@@ -104,6 +126,7 @@
|
||||
font-weight : 800;
|
||||
line-height : @menuHeight;
|
||||
text-transform : uppercase;
|
||||
text-wrap : nowrap;
|
||||
cursor : pointer;
|
||||
&:hover, &.selected { background-color : #999999; }
|
||||
i {
|
||||
@@ -120,7 +143,7 @@
|
||||
.tooltipLeft('Edit Brew Properties');
|
||||
}
|
||||
.snippetGroup {
|
||||
border-right : 1px solid currentColor;
|
||||
|
||||
&:hover {
|
||||
& > .dropdown { visibility : visible; }
|
||||
}
|
||||
@@ -142,11 +165,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;
|
||||
@@ -179,6 +202,7 @@
|
||||
}
|
||||
}
|
||||
.name { margin-right : auto; }
|
||||
.disabled { text-decoration : line-through; }
|
||||
.beta {
|
||||
align-self : center;
|
||||
padding : 4px 6px;
|
||||
@@ -204,4 +228,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' }} className='value'>
|
||||
<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,8 +1,12 @@
|
||||
//╔===--------------- Polyfills --------------===╗//
|
||||
import 'core-js/es/string/to-well-formed.js';
|
||||
//╚===--------------- ---------------===╝//
|
||||
|
||||
require('./homebrew.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
const { StaticRouter:Router } = require('react-router-dom/server');
|
||||
const { Route, Routes, useParams, useSearchParams } = require('react-router-dom');
|
||||
const { StaticRouter:Router } = require('react-router');
|
||||
const { Route, Routes, useParams, useSearchParams } = require('react-router');
|
||||
|
||||
const HomePage = require('./pages/homePage/homePage.jsx');
|
||||
const EditPage = require('./pages/editPage/editPage.jsx');
|
||||
|
||||
@@ -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;
|
||||
@@ -25,12 +25,11 @@
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -4,7 +4,7 @@ const React = require('react');
|
||||
const _ = require('lodash');
|
||||
const createClass = require('create-react-class');
|
||||
|
||||
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');
|
||||
@@ -16,6 +16,7 @@ const PrintNavItem = require('../../navbar/print.navitem.jsx');
|
||||
const ErrorNavItem = require('../../navbar/error-navitem.jsx');
|
||||
const Account = require('../../navbar/account.navitem.jsx');
|
||||
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
|
||||
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
|
||||
|
||||
const SplitPane = require('naturalcrit/splitPane/splitPane.jsx');
|
||||
const Editor = require('../../editor/editor.jsx');
|
||||
@@ -23,7 +24,7 @@ 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');
|
||||
@@ -228,8 +229,8 @@ const EditPage = createClass({
|
||||
htmlErrors : Markdown.validate(prevState.brew.text)
|
||||
}));
|
||||
|
||||
updateHistory(this.state.brew);
|
||||
versionHistoryGarbageCollection();
|
||||
await updateHistory(this.state.brew).catch(console.error);
|
||||
await versionHistoryGarbageCollection().catch(console.error);
|
||||
|
||||
const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId);
|
||||
|
||||
@@ -380,7 +381,7 @@ const EditPage = createClass({
|
||||
|
||||
**[Homebrewery Link](${global.config.publicUrl}/share/${shareLink})**`;
|
||||
|
||||
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title)}&text=${encodeURIComponent(text)}`;
|
||||
return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title.toWellFormed())}&text=${encodeURIComponent(text)}`;
|
||||
},
|
||||
|
||||
renderNavbar : function(){
|
||||
@@ -417,6 +418,7 @@ const EditPage = createClass({
|
||||
</Nav.item>
|
||||
</Nav.dropdown>
|
||||
<PrintNavItem />
|
||||
<VaultNavItem />
|
||||
<RecentNavItem brew={this.state.brew} storageKey='edit' />
|
||||
<Account />
|
||||
</Nav.section>
|
||||
@@ -429,41 +431,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}
|
||||
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>
|
||||
{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 })=>{
|
||||
|
||||
@@ -18,7 +18,18 @@ const errorIndex = (props)=>{
|
||||
'01' : dedent`
|
||||
## An error occurred while retrieving this brew from Google Drive!
|
||||
|
||||
Google reported an error while attempting to retrieve a brew from this link.`,
|
||||
Google is able to see the brew at this link, but reported an error while attempting to retrieve it.
|
||||
|
||||
### Refreshing your Google Credentials
|
||||
|
||||
This issue is likely caused by an issue with your Google credentials; if you are the owner of this file, the following steps may resolve the issue:
|
||||
|
||||
- Go to https://www.naturalcrit.com/login and click logout if present (in small text at the bottom of the page).
|
||||
- Click "Sign In with Google", which will refresh your Google credentials.
|
||||
- After completing the sign in process, return to Homebrewery and refresh/reload the page so that it can pick up the updated credentials.
|
||||
- If this was the source of the issue, it should now be resolved.
|
||||
|
||||
If following these steps does not resolve the issue, please let us know!`,
|
||||
|
||||
// Google Drive - 404 : brew deleted or access denied
|
||||
'02' : dedent`
|
||||
@@ -50,7 +61,7 @@ const errorIndex = (props)=>{
|
||||
- **The Google Account may be closed.** Google may have removed the account
|
||||
due to inactivity or violating a Google policy. Make sure the owner can
|
||||
still access Google Drive normally and upload/download files to it.
|
||||
:
|
||||
|
||||
If the file isn't found, Google Drive usually puts your file in your Trash folder for
|
||||
30 days. Assuming the trash hasn't been emptied yet, it might be worth checking.
|
||||
You can also find the Activity tab on the right side of the Google Drive page, which
|
||||
@@ -172,6 +183,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.`,
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ require('./homePage.less');
|
||||
const React = require('react');
|
||||
const createClass = require('create-react-class');
|
||||
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');
|
||||
@@ -100,35 +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}
|
||||
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 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');
|
||||
@@ -223,38 +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}
|
||||
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 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>;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const Navbar = require('../../navbar/navbar.jsx');
|
||||
const MetadataNav = require('../../navbar/metadata.navitem.jsx');
|
||||
const PrintNavItem = require('../../navbar/print.navitem.jsx');
|
||||
const RecentNavItem = require('../../navbar/recent.navitem.jsx').both;
|
||||
const VaultNavItem = require('../../navbar/vault.navitem.jsx');
|
||||
const Account = require('../../navbar/account.navitem.jsx');
|
||||
const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx');
|
||||
|
||||
@@ -110,6 +111,7 @@ const SharePage = createClass({
|
||||
</Nav.item>
|
||||
</Nav.dropdown>
|
||||
</>}
|
||||
<VaultNavItem/>
|
||||
<RecentNavItem brew={this.props.brew} storageKey='view' />
|
||||
<Account />
|
||||
</Nav.section>
|
||||
@@ -119,6 +121,7 @@ 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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,7 @@ 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);
|
||||
@@ -411,19 +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'>
|
||||
{renderSortBar()}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -5,373 +5,331 @@
|
||||
|
||||
*:not(input) { user-select : none; }
|
||||
|
||||
.content {
|
||||
.content .dataGroup {
|
||||
width : 100%;
|
||||
height : 100%;
|
||||
background : #2C3E50;
|
||||
background : white;
|
||||
|
||||
.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);
|
||||
|
||||
small {
|
||||
font-size : 10pt;
|
||||
color : #555555;
|
||||
small {
|
||||
font-size : 10pt;
|
||||
color : #555555;
|
||||
|
||||
a { color : #333333; }
|
||||
}
|
||||
a { color : #333333; }
|
||||
}
|
||||
|
||||
code {
|
||||
padding-inline : 5px;
|
||||
font-family : monospace;
|
||||
background : lightgrey;
|
||||
border-radius : 5px;
|
||||
}
|
||||
code {
|
||||
padding-inline : 5px;
|
||||
font-family : monospace;
|
||||
background : lightgrey;
|
||||
border-radius : 5px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family : 'CodeBold';
|
||||
letter-spacing : 2px;
|
||||
}
|
||||
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); }
|
||||
|
||||
|
||||
}
|
||||
|
||||
.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; }
|
||||
}
|
||||
#searchButton {
|
||||
.colorButton(@green);
|
||||
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-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 {
|
||||
.colorButton(@green);
|
||||
width : max-content;
|
||||
|
||||
&.previousPage { grid-area : previousPage; }
|
||||
|
||||
&.nextPage { grid-area : nextPage; }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@keyframes trailingDots {
|
||||
@@ -388,7 +346,7 @@
|
||||
|
||||
// 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;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { initCustomStore } from './customIDBStore.js';
|
||||
|
||||
export const HISTORY_PREFIX = 'HOMEBREWERY-HISTORY';
|
||||
export const HISTORY_SLOTS = 5;
|
||||
|
||||
// History values in minutes
|
||||
const DEFAULT_HISTORY_SAVE_DELAYS = {
|
||||
const HISTORY_SAVE_DELAYS = {
|
||||
'0' : 0,
|
||||
'1' : 2,
|
||||
'2' : 10,
|
||||
@@ -10,29 +12,32 @@ const DEFAULT_HISTORY_SAVE_DELAYS = {
|
||||
'4' : 12 * 60,
|
||||
'5' : 2 * 24 * 60
|
||||
};
|
||||
// const HISTORY_SAVE_DELAYS = {
|
||||
// '0' : 0,
|
||||
// '1' : 1,
|
||||
// '2' : 2,
|
||||
// '3' : 3,
|
||||
// '4' : 4,
|
||||
// '5' : 5
|
||||
// };
|
||||
|
||||
const DEFAULT_GARBAGE_COLLECT_DELAY = 28 * 24 * 60;
|
||||
|
||||
const HISTORY_SAVE_DELAYS = global.config?.historyData?.HISTORY_SAVE_DELAYS ?? DEFAULT_HISTORY_SAVE_DELAYS;
|
||||
const GARBAGE_COLLECT_DELAY = global.config?.historyData?.GARBAGE_COLLECT_DELAY ?? DEFAULT_GARBAGE_COLLECT_DELAY;
|
||||
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 getVersionBySlot(brew, slot){
|
||||
// Read stored brew data
|
||||
// - If it exists, parse data to object
|
||||
// - If it doesn't exist, pass default object
|
||||
const key = getKeyBySlot(brew, slot);
|
||||
const storedVersion = localStorage.getItem(key);
|
||||
const output = storedVersion ? JSON.parse(storedVersion) : { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
|
||||
return output;
|
||||
};
|
||||
|
||||
function updateStoredBrew(brew, slot = 0) {
|
||||
function parseBrewForStorage(brew, slot = 0) {
|
||||
// Strip out unneeded object properties
|
||||
// Returns an array of [ key, brew ]
|
||||
const archiveBrew = {
|
||||
title : brew.title,
|
||||
text : brew.text,
|
||||
@@ -46,44 +51,50 @@ function updateStoredBrew(brew, slot = 0) {
|
||||
archiveBrew.expireAt.setMinutes(archiveBrew.expireAt.getMinutes() + HISTORY_SAVE_DELAYS[slot]);
|
||||
|
||||
const key = getKeyBySlot(brew, slot);
|
||||
localStorage.setItem(key, JSON.stringify(archiveBrew));
|
||||
|
||||
return [key, archiveBrew];
|
||||
}
|
||||
|
||||
export async function loadHistory(brew){
|
||||
const DEFAULT_HISTORY_ITEM = { expireAt: '2000-01-01T00:00:00.000Z', shareId: brew.shareId, noData: true };
|
||||
|
||||
export function historyExists(brew){
|
||||
return Object.keys(localStorage)
|
||||
.some((key)=>{
|
||||
return key.startsWith(`${HISTORY_PREFIX}-${brew.shareId}`);
|
||||
});
|
||||
}
|
||||
const historyKeys = [];
|
||||
|
||||
export function loadHistory(brew){
|
||||
const history = {};
|
||||
|
||||
// Load data from local storage to History object
|
||||
// Create array of all history keys
|
||||
for (let i = 1; i <= HISTORY_SLOTS; i++){
|
||||
history[i] = getVersionBySlot(brew, i);
|
||||
historyKeys.push(getKeyBySlot(brew, i));
|
||||
};
|
||||
|
||||
return history;
|
||||
// Load all keys from IDB at once
|
||||
const dataArray = await IDB.getMany(historyKeys);
|
||||
return dataArray.map((data)=>{ return data ?? DEFAULT_HISTORY_ITEM; });
|
||||
}
|
||||
|
||||
export function updateHistory(brew) {
|
||||
const history = loadHistory(brew);
|
||||
export async function updateHistory(brew) {
|
||||
const history = await loadHistory(brew);
|
||||
|
||||
// Walk each version position
|
||||
for (let slot = HISTORY_SLOTS; slot > 0; slot--){
|
||||
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)){
|
||||
for (let updateSlot = slot - 1; updateSlot>0; updateSlot--){
|
||||
|
||||
// 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
|
||||
!history[updateSlot]?.noData && updateStoredBrew(history[updateSlot], updateSlot + 1);
|
||||
if(!history[updateSlot - 1]?.noData) {
|
||||
historyUpdate.push(parseBrewForStorage(history[updateSlot - 1], updateSlot + 1));
|
||||
}
|
||||
};
|
||||
|
||||
// Update the most recent brew
|
||||
updateStoredBrew(brew, 1);
|
||||
historyUpdate.push(parseBrewForStorage(brew, 1));
|
||||
|
||||
await IDB.setMany(historyUpdate);
|
||||
|
||||
// Break out of data checks because we found an expired value
|
||||
break;
|
||||
@@ -91,26 +102,18 @@ export function updateHistory(brew) {
|
||||
};
|
||||
};
|
||||
|
||||
export function getHistoryItems(brew){
|
||||
const historyArray = [];
|
||||
export async function versionHistoryGarbageCollection(){
|
||||
const entries = await IDB.entries();
|
||||
|
||||
for (let i = 1; i <= HISTORY_SLOTS; i++){
|
||||
historyArray.push(getVersionBySlot(brew, i));
|
||||
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);
|
||||
}
|
||||
|
||||
return historyArray;
|
||||
};
|
||||
|
||||
export function versionHistoryGarbageCollection(){
|
||||
Object.keys(localStorage)
|
||||
.filter((key)=>{
|
||||
return key.startsWith(HISTORY_PREFIX);
|
||||
})
|
||||
.forEach((key)=>{
|
||||
const collectAt = new Date(JSON.parse(localStorage.getItem(key)).savedAt);
|
||||
collectAt.setMinutes(collectAt.getMinutes() + GARBAGE_COLLECT_DELAY);
|
||||
if(new Date() > collectAt){
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
|
||||
19
faq.md
19
faq.md
@@ -69,7 +69,6 @@ pre {
|
||||
|
||||
You can check the site status here: [Everyone or Just Me](https://downforeveryoneorjustme.com/homebrewery.naturalcrit.com)
|
||||
|
||||
|
||||
### Why am I getting an error when trying to save, and my account is linked to Google?
|
||||
|
||||
A sign-in with Google only lasts a year until the authentication expires. You must go [here](https://www.naturalcrit.com/login), click the *Log-out* button, and then sign back in using your Google account.
|
||||
@@ -82,12 +81,17 @@ If you have linked your account with a Google account, you would change your pas
|
||||
|
||||
### Is there a way to restore a previous version of my brew?
|
||||
|
||||
Currently, there is no way to do this through the site yourself. This would take too much of a toll on the amount of storage the homebrewery requires. However, we do have daily backups of our database that we keep for 8 days, and you can contact the moderators on [the subreddit](https://www.reddit.com/r/homebrewery) with your Homebrewery username, the name of the lost brew, and the last known time it was working properly. We can manually look through our backups and restore it if it exists.
|
||||
In your brew, there is an icon, :fas_clock_rotate_left:, that button opens up a menu with versions of your brew, stored in order from newer to older, up to a week old. Because of the amount of duplicates this function creates, this information is stored in **your browser**, so if you were to uninstall it or clear your cookies and site data, or change computers, the info will not be there.
|
||||
|
||||
Also, we do have daily backups of our database that we keep for 8 days, and you can contact the moderators on [the subreddit](https://www.reddit.com/r/homebrewery) with your Homebrewery username, the name of the lost brew, and the last known time it was working properly. We can manually look through our backups and restore it if it exists.
|
||||
|
||||
|
||||
### I worked on a brew for X hours, and suddenly all the text disappeared!
|
||||
|
||||
This usually happens if you accidentally drag-select all of your text and then start typing which overwrites the selection. Do not panic, and do not refresh the page or reload your brew quite yet as it is probably auto-saved in this state already. Simply press CTRL+Z as many times as needed to undo your last few changes and you will be back to where you were, then make sure to save your brew in the "good" state.
|
||||
|
||||
You can also load a history version old enough to have all the text, using the :fas_clock_rotate_left: history versions button.
|
||||
|
||||
\column
|
||||
|
||||
### Why is only Chrome supported?
|
||||
@@ -112,10 +116,7 @@ Once you have an image you would like to use, it is recommended to host it somew
|
||||
\page
|
||||
|
||||
### A particular font does not work for my language, what do I do?
|
||||
The fonts used were originally created for use with the English language, though revisions since then have added more support for other languages. They are still not complete sets and may be missing a glyph/character you need. Unfortunately, the volunteer group as it stands at the time of this writing does not have a font guru, so it would be difficult to add more glyphs (especially complicated glyphs). Let us know which glyph is missing on the subreddit, but you may need to search [Google Fonts](https://fonts.google.com) for an alternative font if you need something fast.
|
||||
|
||||
### Whenever I click on the "Get PDF" button, instead of getting a download, it opens Print Preview in another tab.
|
||||
Yes, this is by design. In the print preview, select "Save as PDF" as the Destination, and then click "Save". There will be a normal download dialog where you can save your brew as a PDF.
|
||||
The fonts used were originally created for use with the English language, though revisions since then have added more support for other languages. They are still not complete sets and may be missing a glyph/character you need. Unfortunately, the volunteer group as it stands at the time of this writing does not have a font guru, so it would be difficult to add more glyphs (especially complicated glyphs). Let us know which glyph is missing on the subreddit, but you may need to search [Google Fonts](https://fonts.google.com) for an alternative font if you need something fast.
|
||||
|
||||
### I have white borders on the bottom/sides of the print preview.
|
||||
|
||||
@@ -126,4 +127,8 @@ The Homebrewery defaults to creating US Letter page sizes. If you are printing
|
||||
|
||||
### Typing `#### Adhesion` in the text editor doesn't show the header at all in the completed page?
|
||||
|
||||
Your ad-blocking software is mistakenly assuming your text to be an ad. Whitelist homebrewery.naturalcrit.com in your ad-blocking software.
|
||||
Your ad-blocking software is mistakenly assuming your text to be an ad. We recommend whitelisting homebrewery.naturalcrit.com in your ad-blocking software, as we have no ads.
|
||||
|
||||
### My username appears as _hidden_ when checking my brews in the Vault, why is that?
|
||||
|
||||
Your username is most likely your e-mail adress, and our code is picking that up and protecting your identity. This will remain as is, but you can ask for a name change by contacting the moderators on [the subreddit](https://www.reddit.com/r/homebrewery) with your Homebrewery username, and your desired new name. You will also be asked to provide details about some of your unpublished brews, to verify your identity. No information will be leaked or shared.
|
||||
|
||||
4362
package-lock.json
generated
4362
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
78
package.json
78
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,8 @@
|
||||
"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:content-negotiation": "jest \"server/middleware/.*.spec.js\" --verbose",
|
||||
"test:coverage": "jest --coverage --silent --runInBand",
|
||||
"test:dev": "jest --verbose --watch",
|
||||
"test:basic": "jest tests/markdown/basic.test.js --verbose",
|
||||
@@ -35,12 +38,14 @@
|
||||
"test:mustache-syntax:injection": "jest \".*(mustache-syntax).*\" -t '^Injection:.*' --verbose --noStackTrace",
|
||||
"test:definition-lists": "jest tests/markdown/definition-lists.test.js --verbose --noStackTrace",
|
||||
"test:hard-breaks": "jest tests/markdown/hard-breaks.test.js --verbose --noStackTrace",
|
||||
"test:non-breaking-spaces": "jest tests/markdown/non-breaking-spaces.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 +59,9 @@
|
||||
"shared",
|
||||
"server"
|
||||
],
|
||||
"transformIgnorePatterns": [
|
||||
"node_modules/(?!nanoid/).*"
|
||||
],
|
||||
"coveragePathIgnorePatterns": [
|
||||
"build/*"
|
||||
],
|
||||
@@ -75,67 +83,63 @@
|
||||
"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.26.3",
|
||||
"@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",
|
||||
"core-js": "^3.39.0",
|
||||
"cors": "^2.8.5",
|
||||
"create-react-class": "^15.7.0",
|
||||
"dedent-tabs": "^0.10.3",
|
||||
"dompurify": "^3.1.7",
|
||||
"dompurify": "^3.2.3",
|
||||
"expr-eval": "^2.0.2",
|
||||
"express": "^4.21.0",
|
||||
"express": "^4.21.2",
|
||||
"express-async-handler": "^1.2.0",
|
||||
"express-static-gzip": "2.1.8",
|
||||
"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.7.0",
|
||||
"nanoid": "3.3.4",
|
||||
"mongoose": "^8.9.2",
|
||||
"nanoid": "5.0.9",
|
||||
"nconf": "^0.12.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-frame-component": "^4.1.3",
|
||||
"react-router-dom": "6.26.2",
|
||||
"react-router": "^7.1.1",
|
||||
"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.1.0",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-jest": "^28.8.3",
|
||||
"eslint-plugin-react": "^7.36.1",
|
||||
"globals": "^15.9.0",
|
||||
"@stylistic/stylelint-plugin": "^3.1.1",
|
||||
"babel-plugin-transform-import-meta": "^2.2.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-jest": "^28.10.0",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"globals": "^15.14.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": "^16.12.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,16 +1,18 @@
|
||||
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 babelConfig from '../babel.config.json' with { type : 'json' };
|
||||
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;
|
||||
const babelify = async (code)=>(await babel.transformAsync(code, babelConfig)).code;
|
||||
|
||||
const transforms = {
|
||||
'.js' : (code, filename, opts)=>babelify(code),
|
||||
@@ -24,7 +26,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 +53,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 +70,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 +106,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,28 @@ 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;
|
||||
|
||||
// Remove Account from request to prevent Admin user from being added to brew as an Author
|
||||
req.account = undefined;
|
||||
|
||||
return await HomebrewAPI.updateBrew(req, res);
|
||||
});
|
||||
|
||||
/* Compresses the "text" field of a brew to binary */
|
||||
router.put('/admin/compress/:id', (req, res)=>{
|
||||
@@ -138,12 +151,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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
177
server/app.js
177
server/app.js
@@ -1,24 +1,41 @@
|
||||
/*eslint max-lines: ["warn", {"max": 500, "skipBlankLines": true, "skipComments": true}]*/
|
||||
// Set working directory to project root
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import packageJSON from './../package.json' with { type: 'json' };
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
process.chdir(`${__dirname}/..`);
|
||||
const version = packageJSON.version;
|
||||
|
||||
import _ from 'lodash';
|
||||
import jwt from 'jwt-simple';
|
||||
import express from 'express';
|
||||
import yaml from 'js-yaml';
|
||||
import config from './config.js';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const _ = require('lodash');
|
||||
const jwt = require('jwt-simple');
|
||||
const express = require('express');
|
||||
const yaml = require('js-yaml');
|
||||
const app = express();
|
||||
const config = require('./config.js');
|
||||
|
||||
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = require('./homebrew.api.js');
|
||||
const GoogleActions = require('./googleActions.js');
|
||||
const serveCompressedStaticAssets = require('./static-assets.mv.js');
|
||||
const sanitizeFilename = require('sanitize-filename');
|
||||
const asyncHandler = require('express-async-handler');
|
||||
const templateFn = require('./../client/template.js');
|
||||
import api from './homebrew.api.js';
|
||||
const { homebrewApi, getBrew, getUsersBrewThemes, getCSS } = api;
|
||||
import adminApi from './admin.api.js';
|
||||
import vaultApi from './vault.api.js';
|
||||
import GoogleActions from './googleActions.js';
|
||||
import serveCompressedStaticAssets from './static-assets.mv.js';
|
||||
import sanitizeFilename from 'sanitize-filename';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import templateFn from '../client/template.js';
|
||||
import { model as HomebrewModel } from './homebrew.model.js';
|
||||
|
||||
const { DEFAULT_BREW } = require('./brewDefaults.js');
|
||||
import { DEFAULT_BREW } from './brewDefaults.js';
|
||||
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
|
||||
|
||||
const { splitTextStyleAndMetadata } = require('../shared/helpers.js');
|
||||
//==== Middleware Imports ====//
|
||||
import contentNegotiation from './middleware/content-negotiation.js';
|
||||
import bodyParser from 'body-parser';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import forceSSL from './forcessl.mw.js';
|
||||
|
||||
|
||||
const sanitizeBrew = (brew, accessType)=>{
|
||||
@@ -30,11 +47,47 @@ const sanitizeBrew = (brew, accessType)=>{
|
||||
return brew;
|
||||
};
|
||||
|
||||
app.set('trust proxy', 1 /* number of proxies between user and server */);
|
||||
|
||||
app.use('/', serveCompressedStaticAssets(`build`));
|
||||
app.use(require('./middleware/content-negotiation.js'));
|
||||
app.use(require('body-parser').json({ limit: '25mb' }));
|
||||
app.use(require('cookie-parser')());
|
||||
app.use(require('./forcessl.mw.js'));
|
||||
app.use(contentNegotiation);
|
||||
app.use(bodyParser.json({ limit: '25mb' }));
|
||||
app.use(cookieParser());
|
||||
app.use(forceSSL);
|
||||
|
||||
import cors from 'cors';
|
||||
|
||||
const nodeEnv = config.get('node_env');
|
||||
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
|
||||
|
||||
const corsOptions = {
|
||||
origin : (origin, callback)=>{
|
||||
|
||||
const allowedOrigins = [
|
||||
'https://homebrewery.naturalcrit.com',
|
||||
'https://www.naturalcrit.com',
|
||||
'https://naturalcrit-stage.herokuapp.com',
|
||||
'https://homebrewery-stage.herokuapp.com',
|
||||
];
|
||||
|
||||
if(isLocalEnvironment) {
|
||||
allowedOrigins.push('http://localhost:8000', 'http://localhost:8010');
|
||||
}
|
||||
|
||||
const herokuRegex = /^https:\/\/(?:homebrewery-pr-\d+\.herokuapp\.com|naturalcrit-pr-\d+\.herokuapp\.com)$/; // Matches any Heroku app
|
||||
|
||||
if(!origin || allowedOrigins.includes(origin) || herokuRegex.test(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
console.log(origin, 'not allowed');
|
||||
callback(new Error('Not allowed by CORS, if you think this is an error, please contact us'));
|
||||
}
|
||||
},
|
||||
methods : ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials : true,
|
||||
};
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
//Account Middleware
|
||||
app.use((req, res, next)=>{
|
||||
@@ -43,7 +96,9 @@ app.use((req, res, next)=>{
|
||||
req.account = jwt.decode(req.cookies.nc_session, config.get('secret'));
|
||||
//console.log("Just loaded up JWT from cookie:");
|
||||
//console.log(req.account);
|
||||
} catch (e){}
|
||||
} catch (e){
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
req.config = {
|
||||
@@ -54,15 +109,14 @@ app.use((req, res, next)=>{
|
||||
});
|
||||
|
||||
app.use(homebrewApi);
|
||||
app.use(require('./admin.api.js'));
|
||||
app.use(require('./vault.api.js'));
|
||||
app.use(adminApi);
|
||||
app.use(vaultApi);
|
||||
|
||||
const HomebrewModel = require('./homebrew.model.js').model;
|
||||
const welcomeText = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
|
||||
const welcomeTextLegacy = require('fs').readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
|
||||
const migrateText = require('fs').readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8');
|
||||
const changelogText = require('fs').readFileSync('changelog.md', 'utf8');
|
||||
const faqText = require('fs').readFileSync('faq.md', 'utf8');
|
||||
const welcomeText = fs.readFileSync('client/homebrew/pages/homePage/welcome_msg.md', 'utf8');
|
||||
const welcomeTextLegacy = fs.readFileSync('client/homebrew/pages/homePage/welcome_msg_legacy.md', 'utf8');
|
||||
const migrateText = fs.readFileSync('client/homebrew/pages/homePage/migrate.md', 'utf8');
|
||||
const changelogText = fs.readFileSync('changelog.md', 'utf8');
|
||||
const faqText = fs.readFileSync('faq.md', 'utf8');
|
||||
|
||||
String.prototype.replaceAll = function(s, r){return this.split(s).join(r);};
|
||||
|
||||
@@ -255,6 +309,8 @@ app.get('/user/:username', async (req, res, next)=>{
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
brews.forEach((brew)=>brew.stubbed = true); //All brews from MongoDB are "stubbed"
|
||||
|
||||
if(ownAccount && req?.account?.googleId){
|
||||
const auth = await GoogleActions.authCheck(req.account, res);
|
||||
let googleBrews = await GoogleActions.listGoogleBrews(auth)
|
||||
@@ -262,12 +318,12 @@ app.get('/user/:username', async (req, res, next)=>{
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
// If stub matches file from Google, use Google metadata over stub metadata
|
||||
if(googleBrews && googleBrews.length > 0) {
|
||||
for (const brew of brews.filter((brew)=>brew.googleId)) {
|
||||
const match = googleBrews.findIndex((b)=>b.editId === brew.editId);
|
||||
if(match !== -1) {
|
||||
brew.googleId = googleBrews[match].googleId;
|
||||
brew.stubbed = true;
|
||||
brew.pageCount = googleBrews[match].pageCount;
|
||||
brew.renderer = googleBrews[match].renderer;
|
||||
brew.version = googleBrews[match].version;
|
||||
@@ -276,6 +332,7 @@ app.get('/user/:username', async (req, res, next)=>{
|
||||
}
|
||||
}
|
||||
|
||||
//Remaining unstubbed google brews display current user as author
|
||||
googleBrews = googleBrews.map((brew)=>({ ...brew, authors: [req.account.username] }));
|
||||
brews = _.concat(brews, googleBrews);
|
||||
}
|
||||
@@ -291,6 +348,34 @@ app.get('/user/:username', async (req, res, next)=>{
|
||||
return next();
|
||||
});
|
||||
|
||||
//Change author name on brews
|
||||
app.put('/api/user/rename', async (req, res)=>{
|
||||
const { username, newUsername } = req.body;
|
||||
const ownAccount = req.account && (req.account.username == newUsername);
|
||||
|
||||
if(!username || !newUsername)
|
||||
return res.status(400).json({ error: 'Username and newUsername are required.' });
|
||||
if(!ownAccount)
|
||||
return res.status(403).json({ error: 'Must be logged in to change your username' });
|
||||
try {
|
||||
const brews = await HomebrewModel.getByUser(username, true, ['authors']);
|
||||
const renamePromises = brews.map(async (brew)=>{
|
||||
const updatedAuthors = brew.authors.map((author)=>author === username ? newUsername : author
|
||||
);
|
||||
return HomebrewModel.updateOne(
|
||||
{ _id: brew._id },
|
||||
{ $set: { authors: updatedAuthors } }
|
||||
);
|
||||
});
|
||||
await Promise.all(renamePromises);
|
||||
|
||||
return res.json({ success: true, message: `Brews for ${username} renamed to ${newUsername}.` });
|
||||
} catch (error) {
|
||||
console.error('Error renaming brews:', error);
|
||||
return res.status(500).json({ error: 'Failed to rename brews.' });
|
||||
}
|
||||
});
|
||||
|
||||
//Edit Page
|
||||
app.get('/edit/:id', asyncHandler(getBrew('edit')), asyncHandler(async(req, res, next)=>{
|
||||
req.brew = req.brew.toObject ? req.brew.toObject() : req.brew;
|
||||
@@ -392,22 +477,12 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
|
||||
let googleCount = [];
|
||||
if(req.account) {
|
||||
if(req.account.googleId) {
|
||||
try {
|
||||
auth = await GoogleActions.authCheck(req.account, res, false);
|
||||
} catch (e) {
|
||||
auth = undefined;
|
||||
console.log('Google auth check failed!');
|
||||
console.log(e);
|
||||
}
|
||||
if(auth.credentials.access_token) {
|
||||
try {
|
||||
googleCount = await GoogleActions.listGoogleBrews(auth);
|
||||
} catch (e) {
|
||||
googleCount = undefined;
|
||||
console.log('List Google files failed!');
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
auth = await GoogleActions.authCheck(req.account, res, false);
|
||||
|
||||
googleCount = await GoogleActions.listGoogleBrews(auth)
|
||||
.catch((err)=>{
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
const query = { authors: req.account.username, googleId: { $exists: false } };
|
||||
@@ -421,7 +496,7 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
|
||||
username : req.account.username,
|
||||
issued : req.account.issued,
|
||||
googleId : Boolean(req.account.googleId),
|
||||
authCheck : Boolean(req.account.googleId && auth.credentials.access_token),
|
||||
authCheck : Boolean(req.account.googleId && auth?.credentials.access_token),
|
||||
mongoCount : mongoCount,
|
||||
googleCount : googleCount?.length
|
||||
};
|
||||
@@ -437,8 +512,6 @@ app.get('/account', asyncHandler(async (req, res, next)=>{
|
||||
return next();
|
||||
}));
|
||||
|
||||
const nodeEnv = config.get('node_env');
|
||||
const isLocalEnvironment = config.get('local_environments').includes(nodeEnv);
|
||||
// Local only
|
||||
if(isLocalEnvironment){
|
||||
// Login
|
||||
@@ -451,6 +524,10 @@ if(isLocalEnvironment){
|
||||
});
|
||||
}
|
||||
|
||||
// Add Static Local Paths
|
||||
app.use('/staticImages', express.static(config.get('hb_images') && fs.existsSync(config.get('hb_images')) ? config.get('hb_images') :'staticImages'));
|
||||
app.use('/staticFonts', express.static(config.get('hb_fonts') && fs.existsSync(config.get('hb_fonts')) ? config.get('hb_fonts'):'staticFonts'));
|
||||
|
||||
//Vault Page
|
||||
app.get('/vault', asyncHandler(async(req, res, next)=>{
|
||||
req.ogMeta = { ...defaultMetaTags,
|
||||
@@ -476,10 +553,10 @@ const renderPage = async (req, res)=>{
|
||||
local : isLocalEnvironment,
|
||||
publicUrl : config.get('publicUrl') ?? '',
|
||||
environment : nodeEnv,
|
||||
history : config.get('historyConfig') ?? {}
|
||||
deployment : config.get('heroku_app_name') ?? ''
|
||||
};
|
||||
const props = {
|
||||
version : require('./../package.json').version,
|
||||
version : version,
|
||||
url : req.customUrl || req.originalUrl,
|
||||
brew : req.brew,
|
||||
brews : req.brews,
|
||||
@@ -556,6 +633,4 @@ app.use((req, res)=>{
|
||||
});
|
||||
//^=====--------------------------------------=====^//
|
||||
|
||||
module.exports = {
|
||||
app : app
|
||||
};
|
||||
export default app;
|
||||
|
||||
@@ -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,7 +176,11 @@ 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');
|
||||
@@ -229,8 +241,8 @@ const GoogleActions = {
|
||||
return obj.data.id;
|
||||
},
|
||||
|
||||
getGoogleBrew : async (id, accessId, accessType)=>{
|
||||
const drive = googleDrive.drive({ version: 'v3', auth: defaultAuth });
|
||||
getGoogleBrew : async (auth = defaultAuth, id, accessId, accessType)=>{
|
||||
const drive = googleDrive.drive({ version: 'v3', auth: auth });
|
||||
|
||||
const obj = await drive.files.get({
|
||||
fileId : id,
|
||||
@@ -333,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;
|
||||
@@ -85,66 +87,68 @@ const api = {
|
||||
// Create middleware with the accessType passed in as part of the scope
|
||||
return async (req, res, next)=>{
|
||||
// Get relevant IDs for the brew
|
||||
const { id, googleId } = api.getId(req);
|
||||
let { 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) {
|
||||
if(googleId)
|
||||
console.warn(`Unable to find document stub for ${accessType}Id ${id}`);
|
||||
} else {
|
||||
else
|
||||
console.warn(err);
|
||||
}
|
||||
});
|
||||
stub = stub?.toObject();
|
||||
googleId ??= stub?.googleId;
|
||||
|
||||
if(stub?.lock?.locked && accessType != 'edit') {
|
||||
throw { HBErrorCode: '51', code: stub.lock.code, message: stub.lock.shareMessage, brewId: stub.shareId, brewTitle: stub.title };
|
||||
const isOwner = (accessType == 'edit' && (!stub || stub?.authors?.length === 0)) || stub?.authors?.[0] === req.account?.username;
|
||||
const isAuthor = stub?.authors?.includes(req.account?.username);
|
||||
const isInvited = stub?.invitedAuthors?.includes(req.account?.username);
|
||||
|
||||
if(accessType === 'edit' && !(isOwner || isAuthor || isInvited)) {
|
||||
const accessError = { name: 'Access Error', status: 401, authors: stub?.authors, brewTitle: stub?.title, shareId: stub?.shareId };
|
||||
if(req.account)
|
||||
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03' };
|
||||
else
|
||||
throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04' };
|
||||
}
|
||||
|
||||
// If there is a google id, try to find the google brew
|
||||
if(!stubOnly && (googleId || stub?.googleId)) {
|
||||
let googleError;
|
||||
const googleBrew = await GoogleActions.getGoogleBrew(googleId || stub?.googleId, id, accessType)
|
||||
.catch((err)=>{
|
||||
googleError = err;
|
||||
if(stub?.lock?.locked && accessType != 'edit') {
|
||||
throw { HBErrorCode: '51', code: stub?.lock.code, message: stub?.lock.shareMessage, brewId: stub?.shareId, brewTitle: stub?.title };
|
||||
}
|
||||
|
||||
// If there's a google id, get it if requesting the full brew or if no stub found yet
|
||||
if(googleId && (!stubOnly || !stub)) {
|
||||
const oAuth2Client = isOwner ? GoogleActions.authCheck(req.account, res) : undefined;
|
||||
|
||||
const googleBrew = await GoogleActions.getGoogleBrew(oAuth2Client, googleId, id, accessType)
|
||||
.catch((googleError)=>{
|
||||
const reason = googleError.errors?.[0].reason;
|
||||
if(reason == 'notFound')
|
||||
throw { ...googleError, HBErrorCode: '02', authors: stub?.authors, account: req.account?.username };
|
||||
else
|
||||
throw { ...googleError, HBErrorCode: '01' };
|
||||
});
|
||||
// Throw any error caught while attempting to retrieve Google brew.
|
||||
if(googleError) {
|
||||
const reason = googleError.errors?.[0].reason;
|
||||
if(reason == 'notFound') {
|
||||
throw { ...googleError, HBErrorCode: '02', authors: stub?.authors, account: req.account?.username };
|
||||
} else {
|
||||
throw { ...googleError, HBErrorCode: '01' };
|
||||
}
|
||||
}
|
||||
|
||||
// Combine the Homebrewery stub with the google brew, or if the stub doesn't exist just use the google brew
|
||||
stub = stub ? _.assign({ ...api.excludeStubProps(stub), stubbed: true }, api.excludeGoogleProps(googleBrew)) : googleBrew;
|
||||
}
|
||||
const authorsExist = stub?.authors?.length > 0;
|
||||
const isAuthor = stub?.authors?.includes(req.account?.username);
|
||||
const isInvited = stub?.invitedAuthors?.includes(req.account?.username);
|
||||
if(accessType === 'edit' && (authorsExist && !(isAuthor || isInvited))) {
|
||||
const accessError = { name: 'Access Error', status: 401 };
|
||||
if(req.account){
|
||||
throw { ...accessError, message: 'User is not an Author', HBErrorCode: '03', authors: stub.authors, brewTitle: stub.title, shareId: stub.shareId };
|
||||
}
|
||||
throw { ...accessError, message: 'User is not logged in', HBErrorCode: '04', authors: stub.authors, brewTitle: stub.title, shareId: stub.shareId };
|
||||
}
|
||||
|
||||
// If after all of that we still don't have a brew, throw an exception
|
||||
if(!stub && !stubOnly) {
|
||||
if(!stub)
|
||||
throw { name: 'BrewLoad Error', message: 'Brew not found', status: 404, HBErrorCode: '05', accessType: accessType, brewId: id };
|
||||
}
|
||||
|
||||
// Clean up brew: fill in missing fields with defaults / fix old invalid values
|
||||
if(stub) {
|
||||
stub.tags = stub.tags || undefined; // Clear empty strings
|
||||
stub.renderer = stub.renderer || undefined; // Clear empty strings
|
||||
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
|
||||
}
|
||||
stub.tags = stub.tags || undefined; // Clear empty strings
|
||||
stub.renderer = stub.renderer || undefined; // Clear empty strings
|
||||
stub = _.defaults(stub, DEFAULT_BREW_LOAD); // Fill in blank fields
|
||||
|
||||
req.brew = stub ?? {};
|
||||
req.brew = stub;
|
||||
next();
|
||||
};
|
||||
},
|
||||
@@ -295,9 +299,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);
|
||||
@@ -353,7 +356,7 @@ const api = {
|
||||
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));
|
||||
const updated = await GoogleActions.updateGoogleBrew(api.excludeGoogleProps(brew), req.ip);
|
||||
|
||||
if(!updated) return;
|
||||
}
|
||||
@@ -464,12 +467,11 @@ const api = {
|
||||
}
|
||||
};
|
||||
|
||||
router.use('/api', require('./middleware/check-client-version.js'));
|
||||
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));
|
||||
router.delete('/api/:id', asyncHandler(api.deleteBrew));
|
||||
router.get('/api/remove/:id', asyncHandler(api.deleteBrew));
|
||||
router.post('/api', checkClientVersion, asyncHandler(api.newBrew));
|
||||
router.put('/api/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
|
||||
router.put('/api/update/:id', checkClientVersion, asyncHandler(api.getBrew('edit', true)), asyncHandler(api.updateBrew));
|
||||
router.delete('/api/:id', checkClientVersion, asyncHandler(api.deleteBrew));
|
||||
router.get('/api/remove/:id', checkClientVersion, asyncHandler(api.deleteBrew));
|
||||
router.get('/api/theme/:renderer/:id', asyncHandler(api.getThemeBundle));
|
||||
|
||||
module.exports = api;
|
||||
export default api;
|
||||
@@ -1,5 +1,7 @@
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import { splitTextStyleAndMetadata } from '../shared/helpers.js';
|
||||
|
||||
describe('Tests for api', ()=>{
|
||||
let api;
|
||||
let google;
|
||||
@@ -36,8 +38,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 +57,6 @@ describe('Tests for api', ()=>{
|
||||
setHeader : jest.fn(()=>{})
|
||||
};
|
||||
|
||||
api = require('./homebrew.api');
|
||||
|
||||
hbBrew = {
|
||||
text : `brew text`,
|
||||
style : 'hello yes i am css',
|
||||
@@ -297,7 +298,7 @@ describe('Tests for api', ()=>{
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(api.getId).toHaveBeenCalledWith(req);
|
||||
expect(model.get).toHaveBeenCalledWith({ shareId: '1' });
|
||||
expect(google.getGoogleBrew).toHaveBeenCalledWith('2', '1', 'share');
|
||||
expect(google.getGoogleBrew).toHaveBeenCalledWith(undefined, '2', '1', 'share');
|
||||
});
|
||||
|
||||
it('access is denied to a locked brew', async()=>{
|
||||
@@ -969,4 +970,57 @@ brew`);
|
||||
expect(res.send).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
describe('Split Text, Style, and Metadata', ()=>{
|
||||
|
||||
it('basic splitting', async ()=>{
|
||||
const testBrew = {
|
||||
text : '```metadata\n' +
|
||||
'title: title\n' +
|
||||
'description: description\n' +
|
||||
'tags: [ \'tag a\' , \'tag b\' ]\n' +
|
||||
'systems: [ test system ]\n' +
|
||||
'renderer: legacy\n' +
|
||||
'theme: 5ePHB\n' +
|
||||
'lang: en\n' +
|
||||
'\n' +
|
||||
'```\n' +
|
||||
'\n' +
|
||||
'```css\n' +
|
||||
'style\n' +
|
||||
'style\n' +
|
||||
'style\n' +
|
||||
'```\n' +
|
||||
'\n' +
|
||||
'text\n'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(testBrew);
|
||||
|
||||
// Metadata
|
||||
expect(testBrew.title).toEqual('title');
|
||||
expect(testBrew.description).toEqual('description');
|
||||
expect(testBrew.tags).toEqual(['tag a', 'tag b']);
|
||||
expect(testBrew.systems).toEqual(['test system']);
|
||||
expect(testBrew.renderer).toEqual('legacy');
|
||||
expect(testBrew.theme).toEqual('5ePHB');
|
||||
expect(testBrew.lang).toEqual('en');
|
||||
// Style
|
||||
expect(testBrew.style).toEqual('style\nstyle\nstyle');
|
||||
// Text
|
||||
expect(testBrew.text).toEqual('text\n');
|
||||
});
|
||||
|
||||
it('convert tags string to array', async ()=>{
|
||||
const testBrew = {
|
||||
text : '```metadata\n' +
|
||||
'tags: tag a\n' +
|
||||
'```\n\n'
|
||||
};
|
||||
|
||||
splitTextStyleAndMetadata(testBrew);
|
||||
|
||||
// Metadata
|
||||
expect(testBrew.tags).toEqual(['tag a']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,8 +1,10 @@
|
||||
module.exports = (req, res, next)=>{
|
||||
const userVersion = req.get('Homebrewery-Version');
|
||||
const version = require('../../package.json').version;
|
||||
import packageJSON from '../../package.json' with { type: 'json' };
|
||||
|
||||
if(userVersion != version) {
|
||||
export default (req, res, next)=>{
|
||||
const userVersion = req.get('Homebrewery-Version');
|
||||
const version = packageJSON.version;
|
||||
|
||||
if(userVersion !== version) {
|
||||
return res.status(412).send({
|
||||
message : `Client version ${userVersion} is out of date. Please save your changes elsewhere and refresh to pick up client version ${version}.`
|
||||
});
|
||||
@@ -10,3 +12,4 @@ module.exports = (req, res, next)=>{
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
|
||||
@@ -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 +1,41 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
import contentNegotiationMiddleware from './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();
|
||||
|
||||
@@ -106,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');
|
||||
@@ -21,6 +21,9 @@ const splitTextStyleAndMetadata = (brew)=>{
|
||||
brew.snippets = brew.text.slice(11, index - 1);
|
||||
brew.text = brew.text.slice(index + 5);
|
||||
}
|
||||
|
||||
// Handle old brews that still have empty strings in the tags metadata
|
||||
if(typeof brew.tags === 'string') brew.tags = brew.tags ? [brew.tags] : [];
|
||||
};
|
||||
|
||||
const printCurrentBrew = ()=>{
|
||||
@@ -51,7 +54,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,
|
||||
|
||||
@@ -11,49 +11,54 @@
|
||||
@import (less) './themes/fonts/iconFonts/fontAwesome.less';
|
||||
|
||||
@keyframes sourceMoveAnimation {
|
||||
50% {background-color: red; color: white;}
|
||||
100% {background-color: unset; color: unset;}
|
||||
50% { color : white;background-color : red;}
|
||||
100% { color : unset;background-color : unset;}
|
||||
}
|
||||
|
||||
.codeEditor{
|
||||
@media screen and (pointer : coarse) {
|
||||
font-size : 16px;
|
||||
}
|
||||
.CodeMirror-foldmarker {
|
||||
font-family: inherit;
|
||||
text-shadow: none;
|
||||
font-weight: 600;
|
||||
color: grey;
|
||||
}
|
||||
.codeEditor {
|
||||
@media screen and (pointer : coarse) {
|
||||
font-size : 16px;
|
||||
}
|
||||
.CodeMirror-foldmarker {
|
||||
font-family : inherit;
|
||||
font-weight : 600;
|
||||
color : grey;
|
||||
text-shadow : none;
|
||||
}
|
||||
|
||||
.sourceMoveFlash .CodeMirror-line{
|
||||
animation-name: sourceMoveAnimation;
|
||||
animation-duration: 0.4s;
|
||||
}
|
||||
.CodeMirror-foldgutter {
|
||||
cursor : pointer;
|
||||
border-left : 1px solid #EEEEEE;
|
||||
transition : background 0.1s;
|
||||
&:hover { background : #DDDDDD; }
|
||||
}
|
||||
|
||||
.CodeMirror-vscrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
width: 20px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
width: 20px;
|
||||
background: linear-gradient(90deg, #858585 15px, #808080 15px);
|
||||
}
|
||||
}
|
||||
.sourceMoveFlash .CodeMirror-line {
|
||||
animation-name : sourceMoveAnimation;
|
||||
animation-duration : 0.4s;
|
||||
}
|
||||
|
||||
.CodeMirror-vscrollbar {
|
||||
&::-webkit-scrollbar { width : 20px; }
|
||||
&::-webkit-scrollbar-thumb {
|
||||
width : 20px;
|
||||
background : linear-gradient(90deg, #858585 15px, #808080 15px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//.cm-tab {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
|
||||
//}
|
||||
//.cm-tab {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAMCAQAAACOs/baAAAARUlEQVR4nGJgIAG8JkXxUAcCtDWemcGR1lY4MvgzCEKY7jSBjgxBDAG09UEQzAe0AMwMHrSOAwEGRtpaMIwAAAAA//8DAG4ID9EKs6YqAAAAAElFTkSuQmCC) no-repeat right;
|
||||
//}
|
||||
|
||||
//.cm-trailingspace {
|
||||
// .cm-space {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
|
||||
// }
|
||||
//}
|
||||
//.cm-trailingspace {
|
||||
// .cm-space {
|
||||
// background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAQAgMAAABW5NbuAAAACVBMVEVHcEwAAAAAAAAWawmTAAAAA3RSTlMAPBJ6PMxpAAAACXBIWXMAAC4jAAAuIwF4pT92AAAAFUlEQVQI12NgwACcCQysASAEZGAAACMuAX06aCQUAAAAAElFTkSuQmCC) no-repeat right;
|
||||
// }
|
||||
//}
|
||||
}
|
||||
|
||||
.emojiPreview {
|
||||
font-size: 1.5em;
|
||||
line-height: 1.2em;
|
||||
font-size : 1.5em;
|
||||
line-height : 1.2em;
|
||||
}
|
||||
@@ -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
|
||||
@@ -391,10 +391,31 @@ const forcedParagraphBreaks = {
|
||||
}
|
||||
};
|
||||
|
||||
const nonbreakingSpaces = {
|
||||
name : 'nonbreakingSpaces',
|
||||
level : 'inline',
|
||||
start(src) { return src.match(/:>+/m)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
const regex = /:(>+)/ym;
|
||||
const match = regex.exec(src);
|
||||
if(match?.length) {
|
||||
return {
|
||||
type : 'nonbreakingSpaces', // Should match "name" above
|
||||
raw : match[0], // Text to consume from the source
|
||||
length : match[1].length,
|
||||
text : ''
|
||||
};
|
||||
}
|
||||
},
|
||||
renderer(token) {
|
||||
return ` `.repeat(token.length).concat('');
|
||||
}
|
||||
};
|
||||
|
||||
const definitionListsSingleLine = {
|
||||
name : 'definitionListsSingleLine',
|
||||
level : 'block',
|
||||
start(src) { return src.match(/\n[^\n]*?::[^\n]*/m)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
start(src) { return src.match(/\n[^\n]*?::[^\n]*/m)?.index; }, // Hint to Marked.js to stop and check for a match
|
||||
tokenizer(src, tokens) {
|
||||
const regex = /^([^\n]*?)::([^\n]*)(?:\n|$)/ym;
|
||||
let match;
|
||||
@@ -748,11 +769,12 @@ const tableTerminators = [
|
||||
];
|
||||
|
||||
Marked.use(MarkedVariables());
|
||||
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks, superSubScripts,
|
||||
mustacheSpans, mustacheDivs, mustacheInjectInline] });
|
||||
Marked.use({ extensions : [definitionListsMultiLine, definitionListsSingleLine, forcedParagraphBreaks,
|
||||
nonbreakingSpaces, superSubScripts, mustacheSpans, mustacheDivs, mustacheInjectInline] });
|
||||
Marked.use(mustacheInjectBlock);
|
||||
Marked.use({ renderer: renderer, tokenizer: tokenizer, mangle: false });
|
||||
Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }), MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
|
||||
Marked.use(MarkedExtendedTables(tableTerminators), MarkedGFMHeadingId({ globalSlugs: true }),
|
||||
MarkedSmartypantsLite(), MarkedEmojis(MarkedEmojiOptions));
|
||||
|
||||
function cleanUrl(href) {
|
||||
try {
|
||||
@@ -854,7 +876,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 +887,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 +958,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,200 +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,
|
||||
showMoveArrows : true
|
||||
};
|
||||
},
|
||||
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');
|
||||
|
||||
// This lives here instead of in the initial render because you cannot touch localStorage until the componant mounts.
|
||||
const loadLiveScroll = window.localStorage.getItem('liveScroll') === 'true';
|
||||
this.setState({ liveScroll: loadLiveScroll });
|
||||
},
|
||||
window.addEventListener('resize', handleResize);
|
||||
return ()=>window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
componentWillUnmount : function() {
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
},
|
||||
const limitPosition = (x, min = 1, max = window.innerWidth - 13)=>Math.round(Math.min(max, Math.max(min, x)));
|
||||
|
||||
handleWindowResize : function() {
|
||||
// Allow divider to increase in size to last user-set position
|
||||
// Limit current position to between 10% and 90% of visible space
|
||||
const newLoc = this.limitPosition(this.state.userSetDividerPos, 0.1*(window.innerWidth-13), 0.9*(window.innerWidth-13));
|
||||
|
||||
this.setState({
|
||||
currentDividerPos : newLoc,
|
||||
windowWidth : window.innerWidth
|
||||
});
|
||||
},
|
||||
|
||||
limitPosition : function(x, min = 1, max = window.innerWidth - 13) {
|
||||
const result = Math.round(Math.min(max, Math.max(min, x)));
|
||||
return result;
|
||||
},
|
||||
|
||||
handleUp : function(e){
|
||||
//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
|
||||
});
|
||||
},
|
||||
setDividerPos(limitPosition(e.pageX));
|
||||
};
|
||||
|
||||
liveScrollToggle : function() {
|
||||
window.localStorage.setItem('liveScroll', String(!this.state.liveScroll));
|
||||
this.setState({ liveScroll: !this.state.liveScroll });
|
||||
},
|
||||
/*
|
||||
unFocus : function() {
|
||||
if(document.selection){
|
||||
document.selection.empty();
|
||||
}else{
|
||||
window.getSelection().removeAllRanges();
|
||||
}
|
||||
},
|
||||
*/
|
||||
const liveScrollToggle = ()=>{
|
||||
window.localStorage.setItem('liveScroll', String(!liveScroll));
|
||||
setLiveScroll(!liveScroll);
|
||||
};
|
||||
|
||||
setMoveArrows : function(newState) {
|
||||
if(this.state.showMoveArrows != newState){
|
||||
this.setState({
|
||||
showMoveArrows : newState
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
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>
|
||||
<div id='scrollToggleDiv' className={this.state.liveScroll ? 'arrow lock' : 'arrow unlock'}
|
||||
style={{ left: this.state.currentDividerPos-4 }}
|
||||
onClick={this.liveScrollToggle} >
|
||||
<i id='scrollToggle' className={this.state.liveScroll ? 'fas fa-lock' : 'fas fa-unlock'} />
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
},
|
||||
|
||||
renderDivider : function(){
|
||||
return <>
|
||||
{this.props.showDividerButtons && 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,
|
||||
liveScroll : this.state.liveScroll,
|
||||
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,69 +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;
|
||||
}
|
||||
&.lock{
|
||||
&.lock {
|
||||
.tooltipRight('De-sync Editor and Preview locations.');
|
||||
top : 90px;
|
||||
background: #666;
|
||||
top : 90px;
|
||||
background : #666666;
|
||||
}
|
||||
&.unlock{
|
||||
&.unlock {
|
||||
.tooltipRight('Sync Editor and Preview locations');
|
||||
top : 90px;
|
||||
}
|
||||
&:hover{
|
||||
background-color: #666;
|
||||
}
|
||||
&: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