mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 09:40:20 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b988efae19 | ||
|
|
8a8f036947 | ||
|
|
1e5ae8f649 | ||
|
|
40c3ee098e | ||
|
|
7441937f67 | ||
|
|
0ea935e5d9 | ||
|
|
8dbabc145f | ||
|
|
e057e9454e | ||
|
|
b30f4967cb | ||
|
|
ff82e89cd8 | ||
|
|
0b46b4d12e | ||
|
|
862f20d9d7 | ||
|
|
c41ad5fd68 | ||
|
|
ed59721453 | ||
|
|
6cd0a4fbce | ||
|
|
11d3c2dd66 | ||
|
|
863883a7f7 | ||
|
|
227d7877c8 | ||
|
|
c51d180acc | ||
|
|
8010f5bd14 | ||
|
|
2ba84e35bd | ||
|
|
69236a9062 | ||
|
|
534591afa8 | ||
|
|
3d663dcac3 | ||
|
|
61045e5681 | ||
|
|
7c8ef380f5 | ||
|
|
a0be74eb33 | ||
|
|
2a713a60b8 | ||
|
|
ef5c1b503d | ||
|
|
422e70329f | ||
|
|
fef5a9313b | ||
|
|
72422401a5 | ||
|
|
5bbe2181ec | ||
|
|
7a94086b3c | ||
|
|
dbaa27dd67 | ||
|
|
84678d3492 | ||
|
|
b3827f9596 | ||
|
|
a633a782db | ||
|
|
f5cca8cea1 | ||
|
|
ddb88af15b | ||
|
|
eec97fc5e3 | ||
|
|
54ce112835 | ||
|
|
88e332cfa0 | ||
|
|
4aa8c5eedf | ||
|
|
9ddf288450 | ||
|
|
d9c1bf7280 | ||
|
|
2725b67eef | ||
|
|
46982a3efd | ||
|
|
62bd6f2b20 | ||
|
|
6b093bd137 | ||
|
|
8e2b04a0bc | ||
|
|
fb16aadf1d | ||
|
|
cf4494721d | ||
|
|
da291a5c6e | ||
|
|
edb2672ca5 | ||
|
|
50c5d07382 | ||
|
|
d7a47b9095 | ||
|
|
04b26f4702 | ||
|
|
2a61b462e4 | ||
|
|
8849b015dd | ||
|
|
2a6adec845 | ||
|
|
9370ddfb34 | ||
|
|
4919e9ef6e | ||
|
|
f7c63ec620 | ||
|
|
b41dc111f9 | ||
|
|
79847e9450 | ||
|
|
8e44965a04 | ||
|
|
57cc7040d2 | ||
|
|
1ee73a0cfb | ||
|
|
82f028cbea | ||
|
|
6a89da85e6 | ||
|
|
ce09a25601 | ||
|
|
84194f6727 | ||
|
|
c4cc17c10d | ||
|
|
1e4b88e8ee | ||
|
|
f936247428 | ||
|
|
1727841a3a | ||
|
|
e65f61d36b | ||
|
|
17f8413143 | ||
|
|
e6a147a9c0 | ||
|
|
bc8167166d | ||
|
|
f9bfd370aa | ||
|
|
8d28106dd5 | ||
|
|
e2aa16af68 | ||
|
|
c1752c5fba | ||
|
|
10b2123eaf | ||
|
|
f094f7e867 | ||
|
|
1d84e1fc39 | ||
|
|
6ee9a4b6a2 | ||
|
|
d822c1a899 | ||
|
|
8d79bf953e | ||
|
|
100357d953 | ||
|
|
54e19914b3 | ||
|
|
963e6588c0 | ||
|
|
978964e069 | ||
|
|
8e2dacd856 | ||
|
|
a35e5dc12c | ||
|
|
28c89d3308 | ||
|
|
067770a527 | ||
|
|
af3ca0d091 | ||
|
|
c2cf48e347 | ||
|
|
8fe3013505 | ||
|
|
b00537c146 | ||
|
|
c5910e47d2 | ||
|
|
f703ea663a | ||
|
|
321e70ebed | ||
|
|
729417ba44 | ||
|
|
4083b1ad3f | ||
|
|
61c8b2fb24 | ||
|
|
ddfe351968 | ||
|
|
7557a3eed9 | ||
|
|
dc1e3fb3f1 | ||
|
|
6127a08a93 | ||
|
|
1a352628f1 | ||
|
|
d0a44d8afa | ||
|
|
ada5c359f3 | ||
|
|
d6a8f43866 | ||
|
|
7b9ea5e162 | ||
|
|
ebc0f017bf | ||
|
|
fda5293433 | ||
|
|
b136fae58c | ||
|
|
f15144d82d | ||
|
|
d87d819486 | ||
|
|
67d09a6fd5 | ||
|
|
84bda3cf4b | ||
|
|
4cb43bde1f | ||
|
|
f84fda742a | ||
|
|
f2b1ba112c | ||
|
|
1e2008b8bb | ||
|
|
85909d08cc | ||
|
|
c3f53033c9 | ||
|
|
5da7de66de | ||
|
|
52ca484634 | ||
|
|
6d6c26dd9b | ||
|
|
0f218ba8b6 | ||
|
|
debbd6f343 | ||
|
|
d09b72670c | ||
|
|
dd09c1d0d3 | ||
|
|
b78bcaa2f0 | ||
|
|
b8e66f1675 | ||
|
|
b65ae77fbd | ||
|
|
c710558736 | ||
|
|
aac53eaac7 | ||
|
|
8dfb54d9d5 | ||
|
|
f716c2bc15 | ||
|
|
8433cbbddd | ||
|
|
0094fd21a0 | ||
|
|
79f4809a7c | ||
|
|
279dce71cf | ||
|
|
8299911f6e | ||
|
|
2b9eab7667 | ||
|
|
e3713e1f62 | ||
|
|
964b314f41 | ||
|
|
2c596a52a6 | ||
|
|
5dfe6c3deb | ||
|
|
703f8ab0f8 | ||
|
|
411656fafe | ||
|
|
ec72bbcabf | ||
|
|
453fc3849f | ||
|
|
f910474970 | ||
|
|
64e8f8349a | ||
|
|
86bf48a626 | ||
|
|
e085e5bfeb | ||
|
|
a294955725 | ||
|
|
42dc123ece | ||
|
|
da93bfbca6 | ||
|
|
b67795f79b | ||
|
|
ad2b969eb5 | ||
|
|
b4040b60be | ||
|
|
01a7331500 | ||
|
|
a1281acec0 | ||
|
|
ec4d13c6dd | ||
|
|
e6db7d412e | ||
|
|
d8a07d526c | ||
|
|
12051ad993 | ||
|
|
a02a6646ef | ||
|
|
421f8d5648 | ||
|
|
64aa784b2b | ||
|
|
7d80512cc5 | ||
|
|
7db0248920 | ||
|
|
e6ad6c9697 | ||
|
|
9f2a81349f | ||
|
|
363ab00949 | ||
|
|
83963dce80 | ||
|
|
a4766c8804 | ||
|
|
ac3a36267f | ||
|
|
b11e2f201a | ||
|
|
19fe2e2b6f | ||
|
|
f4111cb064 | ||
|
|
df05624f8b | ||
|
|
67d9de32db | ||
|
|
8e70a2b6a9 | ||
|
|
dd7306d8de | ||
|
|
87f905a2a1 | ||
|
|
96aac700ca | ||
|
|
d5e5df6549 | ||
|
|
dac356ed06 | ||
|
|
be44f9c311 | ||
|
|
758f320969 | ||
|
|
fe89a9e967 | ||
|
|
5a5eb266ee | ||
|
|
0461864522 | ||
|
|
51e2b56d41 | ||
|
|
8062ffc39f | ||
|
|
c04da4b9bd | ||
|
|
9f36ef8789 | ||
|
|
6250e99813 | ||
|
|
d1e3bb6f8c | ||
|
|
0671dd11a3 | ||
|
|
e81b308db6 | ||
|
|
cad7d77fce | ||
|
|
ebbc339a0c | ||
|
|
cc41308a3f | ||
|
|
72bcdae363 | ||
|
|
0278322d9c | ||
|
|
0d617fd8f4 | ||
|
|
5b1597e588 | ||
|
|
618b6f8135 | ||
|
|
2af7df299c | ||
|
|
af2e7fdd05 | ||
|
|
a7bc1e14e9 | ||
|
|
e8a8007814 | ||
|
|
1db64f4dde | ||
|
|
f6e9401a68 | ||
|
|
efded26791 | ||
|
|
dec1ed49a1 | ||
|
|
98cee205d8 | ||
|
|
a3d4848c88 | ||
|
|
3adf1ae0c7 | ||
|
|
4dfdcdb124 | ||
|
|
f56488eca1 | ||
|
|
4152847483 | ||
|
|
351da69138 | ||
|
|
0d14b8c6ce | ||
|
|
599687f507 | ||
|
|
e82d4bb338 | ||
|
|
492bc71c3c | ||
|
|
00046b893b | ||
|
|
d2e928bb11 | ||
|
|
536fc4913c | ||
|
|
2a0d9cbf09 | ||
|
|
74d15218d6 | ||
|
|
f4d620e867 | ||
|
|
c2a58312ad | ||
|
|
fc9e8674d8 | ||
|
|
e3ff87652e | ||
|
|
5eeedf8744 | ||
|
|
29631f6f4b | ||
|
|
309425545f | ||
|
|
82192d0470 | ||
|
|
76b88632c6 | ||
|
|
d241f9d8f6 | ||
|
|
7a994b50ec | ||
|
|
b909f82f89 | ||
|
|
1905d5d68e | ||
|
|
728c799d8e | ||
|
|
17bb7c1264 | ||
|
|
415ccff09a | ||
|
|
92b0f96387 | ||
|
|
9578ee74fd | ||
|
|
7fb9c53a43 | ||
|
|
e6f1008840 | ||
|
|
073842b3c5 | ||
|
|
b181889a56 | ||
|
|
bbe83d805e | ||
|
|
d00c7be3ee | ||
|
|
f933f62df7 | ||
|
|
f0bce67a3b | ||
|
|
63219cfc8c | ||
|
|
96caba1837 | ||
|
|
d7b0423bda | ||
|
|
9c39226a59 | ||
|
|
83a0dd2565 | ||
|
|
255de2062f | ||
|
|
c8c0438d66 | ||
|
|
25e6619ba0 | ||
|
|
28657beaf1 | ||
|
|
4ebc9b1612 | ||
|
|
cb80bbfab1 | ||
|
|
737fce2ac0 | ||
|
|
0411766509 | ||
|
|
258f712672 | ||
|
|
2536861b79 | ||
|
|
682376656f | ||
|
|
0d6c8d0b57 | ||
|
|
371302a160 | ||
|
|
6b2eb03a25 | ||
|
|
e63c5b112d | ||
|
|
2301dacfa7 | ||
|
|
967b8482d3 | ||
|
|
cb43837c32 | ||
|
|
899617bd4d | ||
|
|
2757bfb365 | ||
|
|
1ed942bb8a | ||
|
|
179157ccd2 | ||
|
|
3e36b931a6 | ||
|
|
c9e60847d0 | ||
|
|
4d83dc0c05 | ||
|
|
995965d157 | ||
|
|
15fbb314b0 | ||
|
|
68e0032c6d | ||
|
|
cffca535b5 | ||
|
|
b2d2632e3b | ||
|
|
119379d4b4 | ||
|
|
c434f83dfc | ||
|
|
1527f364b1 | ||
|
|
5779d94d78 | ||
|
|
096a3dc46b | ||
|
|
c883f31d35 | ||
|
|
1fa9714d4e | ||
|
|
53b64ee222 | ||
|
|
e4b8396a3b | ||
|
|
8cf7e83493 | ||
|
|
68220f944b | ||
|
|
fc4d84574e | ||
|
|
2989e1d5b1 | ||
|
|
5844e20d55 | ||
|
|
2376237b6e | ||
|
|
bcf55bdc3a | ||
|
|
ac44706461 | ||
|
|
928db8d675 | ||
|
|
5c9407f729 | ||
|
|
93f50a7fb3 | ||
|
|
39d84f8fe8 | ||
|
|
d369a3614a | ||
|
|
078ab4125e | ||
|
|
3f0dc8b7be | ||
|
|
c020e09aff | ||
|
|
b0c007d44f | ||
|
|
b777ee2a12 | ||
|
|
b328cae552 | ||
|
|
5357bb47c3 | ||
|
|
447877733b | ||
|
|
feb491f8d4 | ||
|
|
8f59d76a4e | ||
|
|
3a52565ab4 | ||
|
|
08ae61161c | ||
|
|
1835245943 | ||
|
|
83de473e4e | ||
|
|
a532ee7f14 | ||
|
|
f00b265b34 | ||
|
|
2931cc990f | ||
|
|
25a4672d6d | ||
|
|
969a4016ee | ||
|
|
5c2d7079ce | ||
|
|
d1c71d0526 | ||
|
|
1fa2ab0146 | ||
|
|
26a03f4845 | ||
|
|
58bfad3adf | ||
|
|
f1c2f1f40f | ||
|
|
7d3e8eb89f | ||
|
|
3fe8f602c8 | ||
|
|
d87eed36a0 | ||
|
|
9cc0660410 | ||
|
|
58ff88890d | ||
|
|
77d60b21a1 | ||
|
|
9df2db0314 | ||
|
|
91839cf3ef | ||
|
|
74f2352387 | ||
|
|
f299ca1285 | ||
|
|
f47e67b5b4 | ||
|
|
fe16b94c1e | ||
|
|
b77b62bfa2 | ||
|
|
43d8bf1b92 | ||
|
|
dca9427bfb | ||
|
|
7f17616b75 | ||
|
|
4a9dcdd0ef | ||
|
|
a1a4176ffb | ||
|
|
a86a087915 | ||
|
|
f9ace4cde9 | ||
|
|
4677324425 | ||
|
|
7e5852c62c | ||
|
|
e01f99eb52 | ||
|
|
8ff9a41f2d | ||
|
|
075e86b00e | ||
|
|
f20080a603 | ||
|
|
3594d75b5d | ||
|
|
72767ad9a7 | ||
|
|
7db386fda3 | ||
|
|
39f3c89b0a | ||
|
|
15a598525f | ||
|
|
d30803276d | ||
|
|
be63a357dd | ||
|
|
c71a97cba2 | ||
|
|
357de728c0 | ||
|
|
db3c0c18f9 | ||
|
|
45706292e7 | ||
|
|
29b3f52e50 | ||
|
|
f81a0d1537 | ||
|
|
e613e8763b | ||
|
|
3d17810293 | ||
|
|
1871873da0 | ||
|
|
15b3213a4d | ||
|
|
4265288417 | ||
|
|
a36d6f9326 | ||
|
|
3c50f90b51 | ||
|
|
24a4b8e5ee | ||
|
|
e676f96905 | ||
|
|
a903154ab2 | ||
|
|
beedf2fefa | ||
|
|
dee5711000 | ||
|
|
e93c049e8c | ||
|
|
fc10ce4259 | ||
|
|
2573034149 | ||
|
|
a1fc2a91dc | ||
|
|
e86c37e636 | ||
|
|
8b71386b90 | ||
|
|
2edef5b4ab | ||
|
|
d2a25ae9a6 | ||
|
|
6f021aa387 | ||
|
|
d80afc7834 | ||
|
|
d8975d21be | ||
|
|
b0918686a2 | ||
|
|
71c9e1b3ee | ||
|
|
10759d777b | ||
|
|
c956ab5a4e | ||
|
|
f9b790668d | ||
|
|
0e5fef6f31 | ||
|
|
832265e091 | ||
|
|
8940a10397 | ||
|
|
1e1d9586d7 | ||
|
|
305864f0c7 | ||
|
|
d475214224 | ||
|
|
73204d4e0a | ||
|
|
533187d328 | ||
|
|
70db04a93f | ||
|
|
e8eae6a1fc | ||
|
|
8ea5a6a097 | ||
|
|
e6a2314a1e | ||
|
|
7836ce27e7 | ||
|
|
a5fc4a4893 | ||
|
|
358d893c33 | ||
|
|
ae211b5adf | ||
|
|
d0b2b5201e | ||
|
|
0130858584 | ||
|
|
6b923eb4c2 | ||
|
|
d033dad20f | ||
|
|
f882a14ee0 | ||
|
|
990d06b8e1 | ||
|
|
b6564c81f1 | ||
|
|
8df46d8512 | ||
|
|
5072861687 | ||
|
|
919bd9e29b | ||
|
|
c5e42607af | ||
|
|
e64e2c6d53 | ||
|
|
36274f35f4 | ||
|
|
d5e717e9ce | ||
|
|
13f78f6207 | ||
|
|
e7aedd9f6b | ||
|
|
fc76d0e576 | ||
|
|
a1b7194261 | ||
|
|
66d15f6ab0 | ||
|
|
561e62a05c | ||
|
|
b4b81684e0 | ||
|
|
898f625f1f | ||
|
|
105199991f | ||
|
|
89be5c1f6e | ||
|
|
ae671bc70a | ||
|
|
78e34b5522 | ||
|
|
42d79b3dcf | ||
|
|
2d4178a281 | ||
|
|
ac0e11e548 | ||
|
|
0543cc42de | ||
|
|
06c803388b | ||
|
|
58b00162d9 | ||
|
|
08084d96e8 | ||
|
|
8b63e84cba | ||
|
|
a2e76f2cf0 | ||
|
|
72f22d1235 | ||
|
|
76945ee5b5 | ||
|
|
d8b7d6e5f4 | ||
|
|
1369316401 | ||
|
|
29b0cc1f86 | ||
|
|
74f422b523 | ||
|
|
0fefdeec4c | ||
|
|
b99e7979db | ||
|
|
64b43a42dd | ||
|
|
9d35870863 | ||
|
|
526580cbb6 | ||
|
|
d3366ca7cd | ||
|
|
048a90f876 | ||
|
|
abc24cf381 | ||
|
|
9d21f19ab8 | ||
|
|
07a0072b87 | ||
|
|
dbe5705b47 | ||
|
|
0ba3255382 | ||
|
|
2e34b2d004 | ||
|
|
fc92b342ce | ||
|
|
45c4afd22b | ||
|
|
d9f1ecb834 | ||
|
|
a37190e0fc | ||
|
|
cd37c6c980 | ||
|
|
316567f219 | ||
|
|
e5c61b32e3 | ||
|
|
a41d5ff2c0 | ||
|
|
769a139e70 | ||
|
|
7729d9f33e | ||
|
|
8e13f2bce9 | ||
|
|
4e28ffef9c | ||
|
|
d06a7a2c76 | ||
|
|
886f3bb840 | ||
|
|
b501d7af33 | ||
|
|
c3ea57986f | ||
|
|
048947ea7e | ||
|
|
7834a660a5 | ||
|
|
eee7c49439 | ||
|
|
dbab502be4 | ||
|
|
f4d5279a09 | ||
|
|
c8430a8c14 | ||
|
|
63efa5d0f0 | ||
|
|
8cda80752f | ||
|
|
abeb2d2477 | ||
|
|
a7c74e8d8b | ||
|
|
4b7ed35300 | ||
|
|
6463a1ef87 | ||
|
|
f85dc3fa76 | ||
|
|
fb05b1a847 | ||
|
|
5b751217d5 | ||
|
|
07e6f663db | ||
|
|
23ed41bddf | ||
|
|
3e33c766d6 | ||
|
|
83078736f5 | ||
|
|
7ccb50e4f5 | ||
|
|
1032c9d9cf | ||
|
|
ecbecc9d86 | ||
|
|
797426335a | ||
|
|
e7f2068105 | ||
|
|
afc51165f4 | ||
|
|
87a6354e42 | ||
|
|
edc8c17e35 | ||
|
|
2a700f5690 | ||
|
|
a6ce798862 | ||
|
|
30fd008310 | ||
|
|
3608d4b657 | ||
|
|
7c1ee7f161 | ||
|
|
d978d626ae | ||
|
|
1059164255 | ||
|
|
741668a831 | ||
|
|
5f06ebfaec | ||
|
|
b5e79b37ed | ||
|
|
7076ce5df3 | ||
|
|
3e00b2ccc2 | ||
|
|
5681592572 | ||
|
|
87dcfdbff6 | ||
|
|
b035bed3d8 | ||
|
|
94d032c847 | ||
|
|
347d30c3e0 | ||
|
|
720ef251ae | ||
|
|
24577ad2a9 | ||
|
|
06cdd459db | ||
|
|
fad217db8a | ||
|
|
4aeb3aa3d2 | ||
|
|
8baa9124a6 | ||
|
|
1b1b04d083 | ||
|
|
982f0b1cf5 | ||
|
|
b5f3cbc38e | ||
|
|
28464685a7 | ||
|
|
3a6aa7dccd | ||
|
|
2cc30a85f2 | ||
|
|
c5ac0cf8ce | ||
|
|
95ad27e0df | ||
|
|
b0e2674b11 | ||
|
|
46e67512ac | ||
|
|
994ecf8ec0 | ||
|
|
d486eb2a8f | ||
|
|
8f574799e2 | ||
|
|
7496d994e1 | ||
|
|
ec728dabf6 | ||
|
|
2474ff15d6 | ||
|
|
53c205b4ce | ||
|
|
7b88f4c21a | ||
|
|
20368533a0 | ||
|
|
ac7712f141 | ||
|
|
0b9d33cac4 | ||
|
|
d4aca1c009 | ||
|
|
a5ca6b7a79 | ||
|
|
964caabaaf | ||
|
|
9455646d66 | ||
|
|
c2f67001d6 | ||
|
|
b57362c5e8 | ||
|
|
d5d04728fb | ||
|
|
b99e1ccc05 | ||
|
|
3390324c2a | ||
|
|
5748475e8d | ||
|
|
accb51cc67 | ||
|
|
f81fe11dae | ||
|
|
5309e4863d | ||
|
|
f26a2ac9ad | ||
|
|
c37047d8ee | ||
|
|
d5ca31a38b | ||
|
|
0958182306 | ||
|
|
9f979054de | ||
|
|
86e671ea49 | ||
|
|
47794f799b | ||
|
|
b6200c14fc | ||
|
|
53b0224cce | ||
|
|
f02e02b401 | ||
|
|
52ce4f6128 | ||
|
|
a12e075780 | ||
|
|
597eb6b2bb | ||
|
|
ba953b57e5 | ||
|
|
3adf69bd4c | ||
|
|
cb1c7da78c | ||
|
|
64bc774b1e | ||
|
|
172f3bb31b | ||
|
|
8183a645a1 | ||
|
|
f69e543376 | ||
|
|
fbce6e5e9b | ||
|
|
5efb90640c | ||
|
|
93f90873e8 | ||
|
|
8b8a0c8458 | ||
|
|
fc37c08687 | ||
|
|
933bd97393 | ||
|
|
ed5304cd30 | ||
|
|
e6a3ce6772 | ||
|
|
acc49aeccb | ||
|
|
f19938b6bb | ||
|
|
997931e462 | ||
|
|
e837c0948a | ||
|
|
361099b2cc | ||
|
|
5105cf6489 | ||
|
|
01043096f3 | ||
|
|
af86ea8785 | ||
|
|
b32a14fbd5 | ||
|
|
14de1ac072 | ||
|
|
ddc3335854 | ||
|
|
c01743b53a | ||
|
|
508d7a80ef | ||
|
|
49ee49d2b8 | ||
|
|
89999ab10f | ||
|
|
39effe9a0b | ||
|
|
ad3a38e900 | ||
|
|
b4742b84ab | ||
|
|
9ddad24e2a | ||
|
|
9f3c216576 | ||
|
|
664c285cbb | ||
|
|
7fb2c3de7b | ||
|
|
24f4bfd5ad | ||
|
|
45595d95ab | ||
|
|
568ea35ed7 | ||
|
|
6243550c84 | ||
|
|
6650618682 | ||
|
|
b26d9fa163 | ||
|
|
0c6acbfaa2 | ||
|
|
af7323a0ab | ||
|
|
1950aff865 | ||
|
|
9013ffc5ad | ||
|
|
6d56d69ae3 | ||
|
|
80f4b37337 | ||
|
|
0e287c9117 | ||
|
|
a3ed18294f | ||
|
|
8e4404ab49 | ||
|
|
8b6c459453 | ||
|
|
8d6d409cc2 | ||
|
|
9962f2bd9b | ||
|
|
6c816e573d | ||
|
|
7f1dd16dfc | ||
|
|
09a5737e21 | ||
|
|
7d94e575b7 | ||
|
|
bfd554e65c | ||
|
|
a692f270da | ||
|
|
6999faced9 | ||
|
|
ea70df6805 | ||
|
|
736fa07dca | ||
|
|
83a683435a | ||
|
|
7efba64c39 | ||
|
|
79521baad8 | ||
|
|
c1f0da1217 | ||
|
|
8da4a2ad04 | ||
|
|
6f8a502cf2 | ||
|
|
98fc5d6ffc | ||
|
|
8a8469ac2a | ||
|
|
059fc979dc | ||
|
|
21f2e2e6ef | ||
|
|
82e5ce1d76 | ||
|
|
b92292fe8f | ||
|
|
6b7cb473aa | ||
|
|
ac2bacf42c | ||
|
|
21c62c6b82 | ||
|
|
1a998f9ece | ||
|
|
fd10c4c9ad | ||
|
|
8d73d2f575 | ||
|
|
22b9930168 | ||
|
|
7337854bb7 | ||
|
|
f6303840c5 | ||
|
|
31bcf947cb | ||
|
|
0f8ff1f21c | ||
|
|
61554fa53a | ||
|
|
da4b40115d | ||
|
|
c7f9c95596 | ||
|
|
5c31adb314 | ||
|
|
cea6aee038 | ||
|
|
cb32b11586 | ||
|
|
d0de79cba2 | ||
|
|
c52f2481d3 | ||
|
|
690f49e60c | ||
|
|
fa78267068 | ||
|
|
e0391f7dd4 | ||
|
|
0f6883bdf3 | ||
|
|
b5a406f8eb | ||
|
|
80ffbcd562 | ||
|
|
8715133868 | ||
|
|
75cecab73f | ||
|
|
f778707236 | ||
|
|
b6b9d5b787 | ||
|
|
d19506d32f | ||
|
|
8349167a32 | ||
|
|
cf2bae0bfb | ||
|
|
eb75d60df1 | ||
|
|
57f1f9da5b | ||
|
|
c8c7230331 | ||
|
|
7e193ef603 | ||
|
|
5b1cd3dc2f | ||
|
|
b895a66e24 | ||
|
|
25dd4be3f8 | ||
|
|
6d70826b40 | ||
|
|
2dbe335b30 | ||
|
|
9e94321c90 | ||
|
|
6e07efdfa8 | ||
|
|
5c6aa15564 | ||
|
|
62c01725f9 | ||
|
|
b404ac6068 | ||
|
|
4520a432c4 | ||
|
|
aecdac5aca | ||
|
|
f2d6695fdc | ||
|
|
5435753573 | ||
|
|
90cfc3f696 | ||
|
|
7b7e376ba2 | ||
|
|
f347dc7d4d | ||
|
|
d174a4832e | ||
|
|
bc1cd58491 | ||
|
|
86ade7f1f6 | ||
|
|
f39f66192f | ||
|
|
330e0e3570 | ||
|
|
a54f23e2ec | ||
|
|
f7db51b457 | ||
|
|
ebaa31c1e0 | ||
|
|
123417d931 | ||
|
|
d7b0441376 | ||
|
|
a4c74a5980 | ||
|
|
5e09f6b599 | ||
|
|
f2968f0235 | ||
|
|
fa8036aa3c | ||
|
|
7556b3ed8e | ||
|
|
786c9295a9 | ||
|
|
c1cc1e3e3d | ||
|
|
158f31ad4f | ||
|
|
5e4319473e | ||
|
|
82c78ebd1e | ||
|
|
628a49d84e | ||
|
|
3c8db21d2f | ||
|
|
550f4a19cc | ||
|
|
cd2c69f5f0 | ||
|
|
cadd015d20 | ||
|
|
5aa2219541 | ||
|
|
4e77df9ff5 | ||
|
|
b40e6caa4d | ||
|
|
2a3aa981d2 | ||
|
|
653c61081a | ||
|
|
cc3b5cb8c6 | ||
|
|
df153dcc56 | ||
|
|
5878c80fa6 | ||
|
|
d04beea6f8 | ||
|
|
d160d985c9 | ||
|
|
f0a25358b4 | ||
|
|
36e1753f89 | ||
|
|
9defe9681b | ||
|
|
2bb6999656 | ||
|
|
309b488861 | ||
|
|
67192cf5c4 | ||
|
|
c84473ae43 | ||
|
|
f6328937c9 | ||
|
|
80db2517fd | ||
|
|
918bd0b6b5 | ||
|
|
e22f422615 | ||
|
|
1826f51664 | ||
|
|
e547be9ab8 | ||
|
|
89140f1fee | ||
|
|
1b57d09d0d | ||
|
|
e7d67e3ddb | ||
|
|
a4b5ac6c4e | ||
|
|
9b22f9e157 | ||
|
|
2ab0f59f13 | ||
|
|
611368abc6 | ||
|
|
d3b3be2a52 | ||
|
|
11a2c95ea7 | ||
|
|
3e612c39b7 | ||
|
|
58de6bb737 | ||
|
|
f6d8befb56 | ||
|
|
b49aa050e9 | ||
|
|
2ef8465e8d | ||
|
|
7878596608 | ||
|
|
1f2f9f571e | ||
|
|
855cb86088 | ||
|
|
ed3cf96c6f | ||
|
|
fdb477f351 | ||
|
|
ac6cc1041a | ||
|
|
7d09a649ac | ||
|
|
040ed6e605 | ||
|
|
d7e36f5157 | ||
|
|
1a08003834 | ||
|
|
64bc6ea71c | ||
|
|
ef4161889a | ||
|
|
dd9d6083c0 | ||
|
|
027b6a45a1 | ||
|
|
b717e5d7ad | ||
|
|
74965f5f95 | ||
|
|
fa812faf6d | ||
|
|
b459151798 | ||
|
|
73679919fb | ||
|
|
58b0ae29e1 | ||
|
|
773931a538 | ||
|
|
1ef814fe6b | ||
|
|
b010c687f3 | ||
|
|
05950fb287 | ||
|
|
56a685983b | ||
|
|
339c0a2bb7 | ||
|
|
a790453c9a | ||
|
|
04eb8a8fbb | ||
|
|
4ab1bc9638 | ||
|
|
699b9a9c71 | ||
|
|
3a203d274d | ||
|
|
887511fdda | ||
|
|
64f58455ff | ||
|
|
5163ec0da2 | ||
|
|
c47eb013b4 | ||
|
|
d4cbe12316 | ||
|
|
794222c85e | ||
|
|
675c661114 | ||
|
|
7700efde1f | ||
|
|
16832124ce | ||
|
|
bf5ca3d3eb | ||
|
|
189b6cae84 | ||
|
|
9bf0114148 | ||
|
|
9f3bbb5ed2 | ||
|
|
85b981e1c7 | ||
|
|
eb5ac8b7dd | ||
|
|
5ef32af248 | ||
|
|
9cb5837e3e | ||
|
|
5ae9c2b7ac | ||
|
|
7d16fed34e | ||
|
|
db2701f1d7 | ||
|
|
76818e2c98 | ||
|
|
c4e084ffbb | ||
|
|
a10b28bf6a | ||
|
|
90c0110615 | ||
|
|
73c594bb36 | ||
|
|
903544c680 | ||
|
|
32e1ec9d22 | ||
|
|
3516a21329 | ||
|
|
f6198dee30 | ||
|
|
13f14df880 | ||
|
|
f3ae64cf21 | ||
|
|
1820804b9f | ||
|
|
d3d76b12f7 | ||
|
|
51ad6ed99d | ||
|
|
1c8cea9ada | ||
|
|
a0c190137d | ||
|
|
83e1845290 | ||
|
|
052d8c1584 | ||
|
|
7b0e2213cf | ||
|
|
05e6d15478 | ||
|
|
be0283381b | ||
|
|
379a92846e | ||
|
|
bb6163a70f | ||
|
|
10f009f5b1 | ||
|
|
19c882ed2f | ||
|
|
e7cb6ac6f0 | ||
|
|
38e488ee4a | ||
|
|
589fa30fd1 | ||
|
|
64d351d835 | ||
|
|
4151ecb7e7 | ||
|
|
f4b5e5f07d | ||
|
|
c1639ea589 | ||
|
|
21b0ab064c | ||
|
|
1c0d8e9964 | ||
|
|
8a7949a8ab | ||
|
|
3c36db33e9 | ||
|
|
59d0cfc457 | ||
|
|
72f6d96dbc | ||
|
|
49c81f5426 | ||
|
|
08ed1705b1 | ||
|
|
47088c30c0 | ||
|
|
539dbdc034 | ||
|
|
129af0bad9 | ||
|
|
a9d95dd6b7 | ||
|
|
6d0978d0ed | ||
|
|
13cdf08e22 | ||
|
|
e59bc9cd86 | ||
|
|
6478d27d1c | ||
|
|
76d1800ec7 | ||
|
|
03ba747bc0 | ||
|
|
9eb3889d51 | ||
|
|
5d4bc7a886 | ||
|
|
135af977af | ||
|
|
6dfe81cbcb | ||
|
|
3ec2821058 | ||
|
|
1c5ac8efba | ||
|
|
ce7fa0e314 | ||
|
|
0f464b21a3 | ||
|
|
7169da8dae | ||
|
|
0f56e2dfc3 | ||
|
|
1b086c80a7 | ||
|
|
c27d5d0412 | ||
|
|
7b3043bf46 | ||
|
|
da19160e87 | ||
|
|
790a801f30 | ||
|
|
b08c66a483 | ||
|
|
524aa9124d | ||
|
|
df85b5ac45 | ||
|
|
c009ddc915 | ||
|
|
5407e4533c | ||
|
|
98088c7734 | ||
|
|
0d6fb33043 | ||
|
|
1dbad1b454 | ||
|
|
2c8a6c8ec6 | ||
|
|
06b9946dd8 | ||
|
|
dbcdf15244 | ||
|
|
7e2067d0b1 | ||
|
|
7489f720ea | ||
|
|
140e844238 | ||
|
|
de55d5afbe | ||
|
|
c5fccb52cc | ||
|
|
507ca57a50 | ||
|
|
b2e072e02c | ||
|
|
357f63d01f | ||
|
|
e6422d842a | ||
|
|
99519af83a | ||
|
|
514ce6ad24 | ||
|
|
f57ce52198 | ||
|
|
2a9c921ba6 | ||
|
|
0dd924f148 | ||
|
|
678eb0896d | ||
|
|
233c1c8e81 | ||
|
|
f6a851a3d8 | ||
|
|
4fba9d7802 | ||
|
|
d41ecb054c | ||
|
|
ec0c1bc9af | ||
|
|
05b77d24fa | ||
|
|
4535303807 | ||
|
|
f232fdc30b | ||
|
|
dab72d80dc | ||
|
|
6f83464b24 | ||
|
|
41cd0a8925 | ||
|
|
fc02a15753 | ||
|
|
bd9c5a058a | ||
|
|
c1692cc6b9 | ||
|
|
5259c09a5c | ||
|
|
10f801d31b | ||
|
|
28d19db328 | ||
|
|
0e13bfc50e | ||
|
|
4f4aa22ca1 | ||
|
|
6bec01198d | ||
|
|
5dbceb43b4 | ||
|
|
bc92978fa9 | ||
|
|
893f83b79e | ||
|
|
bde23f6c4d | ||
|
|
e6d4b38c64 | ||
|
|
23264044e6 | ||
|
|
d51a381a79 | ||
|
|
da4459304f | ||
|
|
085699f202 | ||
|
|
2c43aa463a | ||
|
|
41db7b4ad1 | ||
|
|
d0a9fa9a71 | ||
|
|
ed0675aaff | ||
|
|
cad8f58468 | ||
|
|
819d58cf31 | ||
|
|
b951515e3e | ||
|
|
4c3265bca5 | ||
|
|
11672a13f7 | ||
|
|
9a8141a0eb | ||
|
|
5462fb0647 | ||
|
|
6b74b8f770 | ||
|
|
99fff1a1a1 | ||
|
|
645f233b83 | ||
|
|
643e2738bd | ||
|
|
cd356a29ce | ||
|
|
341be982f9 | ||
|
|
2e7565f9eb | ||
|
|
1637a9c938 | ||
|
|
67c2be1858 | ||
|
|
b8d4c01e60 | ||
|
|
e259ab86f7 | ||
|
|
029b3523cb | ||
|
|
f4657ad817 | ||
|
|
5efddff154 | ||
|
|
25efe93507 | ||
|
|
a393e67ef8 | ||
|
|
7f6758cbe9 | ||
|
|
c5644a3321 | ||
|
|
4fec03101f | ||
|
|
10fed0a85c | ||
|
|
fff6316192 | ||
|
|
61623f50b1 | ||
|
|
9f626407cf | ||
|
|
3a51678495 | ||
|
|
c730a0e733 | ||
|
|
80a9971508 | ||
|
|
85788e1d7d | ||
|
|
23680547ea | ||
|
|
9767ead9f3 | ||
|
|
910c9a7f1f | ||
|
|
0bc6c6abb6 | ||
|
|
6361e16dba | ||
|
|
8572cbd761 | ||
|
|
1c0b91c5f4 | ||
|
|
2ebefdd0bf | ||
|
|
29f1384945 | ||
|
|
334e569fda | ||
|
|
2d4170a9bf | ||
|
|
6b2759f97d | ||
|
|
adf4312e5a | ||
|
|
b1707b8691 | ||
|
|
25dff3f2da | ||
|
|
bc88c0dfc2 | ||
|
|
1ca2dfd1b1 | ||
|
|
bdb9274485 | ||
|
|
14f9ac39ad | ||
|
|
d4b52f7de9 | ||
|
|
bdc65ec6f4 | ||
|
|
74966a0bc7 | ||
|
|
7fb31cecd5 | ||
|
|
317c82a9f0 | ||
|
|
9b5c89d235 | ||
|
|
c23e190e8e | ||
|
|
4a73371499 | ||
|
|
6f8af55489 | ||
|
|
2a05f62bf9 | ||
|
|
d6eb1eb76f |
No files matched your search
@@ -126,6 +126,8 @@ ENV \
|
||||
HEADER_LOGIN_FIRSTNAME="" \
|
||||
HEADER_LOGIN_LASTNAME="" \
|
||||
HEADER_LOGIN_EMAIL="" \
|
||||
HEADER_LOGIN_TRUSTED_IPS="" \
|
||||
HEADER_LOGIN_TRUSTED_PROXIES="" \
|
||||
LOGOUT_WITH_TIMER=false \
|
||||
LOGOUT_IN="" \
|
||||
LOGOUT_ON_HOURS="" \
|
||||
|
||||
+39
-10
@@ -20,7 +20,10 @@
|
||||
"strict": 0,
|
||||
"no-undef": 0,
|
||||
"accessor-pairs": 2,
|
||||
"comma-dangle": [2, "always-multiline"],
|
||||
"comma-dangle": [
|
||||
2,
|
||||
"always-multiline"
|
||||
],
|
||||
"consistent-return": 2,
|
||||
"dot-notation": 2,
|
||||
"eqeqeq": 2,
|
||||
@@ -28,15 +31,28 @@
|
||||
"no-cond-assign": 2,
|
||||
"no-constant-condition": 2,
|
||||
"no-eval": 2,
|
||||
"no-inner-declarations": [0],
|
||||
"no-inner-declarations": [
|
||||
0
|
||||
],
|
||||
"no-unneeded-ternary": 2,
|
||||
"radix": 2,
|
||||
"semi": [2, "always"],
|
||||
"camelcase": [2, { "properties": "never" }],
|
||||
"semi": [
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"camelcase": [
|
||||
2,
|
||||
{
|
||||
"properties": "never"
|
||||
}
|
||||
],
|
||||
"comma-spacing": 2,
|
||||
"comma-style": 2,
|
||||
"eol-last": 2,
|
||||
"linebreak-style": [2, "unix"],
|
||||
"linebreak-style": [
|
||||
2,
|
||||
"unix"
|
||||
],
|
||||
"meteor/audit-argument-checks": 0,
|
||||
"new-parens": 2,
|
||||
"no-lonely-if": 2,
|
||||
@@ -45,7 +61,13 @@
|
||||
"no-spaced-func": 2,
|
||||
"no-trailing-spaces": 2,
|
||||
"operator-linebreak": 2,
|
||||
"quotes": [2, "single", { "avoidEscape": true }],
|
||||
"quotes": [
|
||||
2,
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"semi-spacing": 2,
|
||||
"space-unary-ops": 2,
|
||||
"arrow-spacing": 2,
|
||||
@@ -57,6 +79,7 @@
|
||||
"prefer-spread": 2,
|
||||
"prefer-template": 2,
|
||||
"no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
@@ -72,11 +95,18 @@
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"meteor": {
|
||||
"extensions": [".js", ".jsx"]
|
||||
"extensions": [
|
||||
".js",
|
||||
".jsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": ["prettier", "meteor"],
|
||||
"plugins": [
|
||||
"prettier",
|
||||
"meteor",
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"globals": {
|
||||
"Meteor": false,
|
||||
"Session": false,
|
||||
@@ -95,7 +125,6 @@
|
||||
"_": false,
|
||||
"Avatar": true,
|
||||
"Avatars": true,
|
||||
|
||||
"CollectionHooks": false,
|
||||
"ESSearchResults": false,
|
||||
"FastRender": false,
|
||||
@@ -110,7 +139,7 @@
|
||||
"Ps": true,
|
||||
"Restivus": false,
|
||||
"SimpleSchema": false,
|
||||
"T9n": false,
|
||||
"T9n": false,
|
||||
"TAPi18n": false,
|
||||
"AccountsTemplates": true,
|
||||
"Activities": true,
|
||||
|
||||
@@ -4,3 +4,40 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
ignore:
|
||||
# Build-time-only (devDependency) Rspack toolchain. These packages compile
|
||||
# the bundle on the developer / CI machine and are NOT shipped in the
|
||||
# deployed WeKan server or client bundle, so their advisories do not affect
|
||||
# a running WeKan instance. (For example webpack-dev-server / @rspack/dev-
|
||||
# server, pulled via @rspack/cli, and the socket.io/ws chain pulled via
|
||||
# @rsdoctor/rspack-plugin, never run in WeKan — it builds with `meteor run`,
|
||||
# not the Rspack CLI or dev-server.)
|
||||
#
|
||||
# They also cannot simply be removed or bumped:
|
||||
# * @meteorjs/rspack re-pins and re-installs @rspack/cli and
|
||||
# @rsdoctor/rspack-plugin into package.json on every build, so deleting
|
||||
# them does not stick.
|
||||
# * The only upgrades Dependabot/npm offer are build-breaking: a
|
||||
# @meteorjs/rspack downgrade, or @rspack/cli 2.0 / Rspack 2.0, which
|
||||
# needs Meteor 3.6 (see https://github.com/meteor/meteor/pull/14360).
|
||||
# WeKan currently runs METEOR@3.5-rc.1.
|
||||
#
|
||||
# So pin them here until Meteor 3.6 + Rspack 2.0 land, then revisit.
|
||||
- dependency-name: "@meteorjs/rspack"
|
||||
- dependency-name: "@rspack/core"
|
||||
- dependency-name: "@rspack/cli"
|
||||
- dependency-name: "@rsdoctor/rspack-plugin"
|
||||
- dependency-name: "css-loader"
|
||||
- dependency-name: "style-loader"
|
||||
# Playwright end-to-end test harness — pure test-only tooling that never ships in
|
||||
# WeKan. It now depends only on @playwright/test and the official `mongodb` driver
|
||||
# (the heavy, CVE-prone `mongosh` CLI was replaced by helpers/mongo-runner.js).
|
||||
# Watch this directory so those two stay patched.
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tests/playwright"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -11,4 +11,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v6
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@v5
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -48,14 +48,14 @@ jobs:
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
name: Playwright E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: playwright-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Meteor unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-unit-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: meteor-local-unit-
|
||||
|
||||
- name: Install Meteor
|
||||
run: |
|
||||
if [ ! -x ~/.meteor/meteor ]; then
|
||||
curl https://install.meteor.com/ | sh
|
||||
fi
|
||||
echo "$HOME/.meteor" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Meteor unit tests
|
||||
# First Meteor build is slow; allow time before the test driver starts.
|
||||
timeout-minutes: 45
|
||||
# NOTE: do not set TEST_WATCH here. meteortesting:mocha computes
|
||||
# `testWatch: TEST_WATCH || …`, and the string '0' is truthy in JS, so
|
||||
# TEST_WATCH=0 turns ON watch mode and the process never exits — that is
|
||||
# what made this job hang until the 45-minute timeout. With TEST_WATCH
|
||||
# unset, `meteor test --once` runs the server tests and exits with the
|
||||
# correct status (matching the local rebuild-wekan.sh run).
|
||||
run: meteor test --once --driver-package meteortesting:mocha
|
||||
|
||||
e2e-tests:
|
||||
name: Playwright E2E (${{ matrix.project }})
|
||||
runs-on: ubuntu-24.04
|
||||
needs: unit-tests
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Playwright bundles Chromium, Firefox and WebKit builds that all run
|
||||
# headless on the Linux runner (installed per-job below with --with-deps).
|
||||
project: [chromium, firefox, webkit]
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
ports: ['27017:27017']
|
||||
options: >-
|
||||
--health-cmd "mongosh --eval 'db.adminCommand(\"ping\")'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-e2e-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: meteor-local-e2e-
|
||||
|
||||
- name: Install Meteor
|
||||
run: |
|
||||
if [ ! -x ~/.meteor/meteor ]; then
|
||||
curl https://install.meteor.com/ | sh
|
||||
fi
|
||||
echo "$HOME/.meteor" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install app npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright test dependencies
|
||||
run: npm ci
|
||||
working-directory: tests/playwright
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps ${{ matrix.project }}
|
||||
working-directory: tests/playwright
|
||||
|
||||
- name: Start WeKan in background
|
||||
run: |
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_test \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
meteor run --settings settings.json > wekan-run.log 2>&1 &
|
||||
# The first Meteor build is slow, so wait up to 20 minutes for the
|
||||
# server to start responding before giving up.
|
||||
for i in $(seq 1 120); do
|
||||
if curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan is up after $((i * 10))s"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for WeKan... attempt $i/120"
|
||||
sleep 10
|
||||
done
|
||||
if ! curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan failed to start. Last 200 log lines:"
|
||||
tail -n 200 wekan-run.log
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.project }})
|
||||
run: |
|
||||
npx playwright test \
|
||||
--project=${{ matrix.project }} \
|
||||
--reporter=github
|
||||
working-directory: tests/playwright
|
||||
env:
|
||||
WEKAN_BASE_URL: http://localhost:3000
|
||||
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_test
|
||||
# Make the firefox and webkit projects available to --project
|
||||
# (playwright.config.js only defines them when this is set).
|
||||
WEKAN_PLAYWRIGHT_ALL: '1'
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report-${{ matrix.project }}
|
||||
path: tests/playwright/playwright-report/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload test results (screenshots / videos)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-${{ matrix.project }}
|
||||
path: tests/playwright/test-results/
|
||||
retention-days: 7
|
||||
|
||||
e2e-regression:
|
||||
name: Puppeteer regression suite
|
||||
runs-on: ubuntu-24.04
|
||||
needs: unit-tests
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
ports: ['27017:27017']
|
||||
options: >-
|
||||
--health-cmd "mongosh --eval 'db.adminCommand(\"ping\")'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Meteor installation
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
|
||||
restore-keys: meteor-
|
||||
|
||||
- name: Cache Meteor build (.meteor/local)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .meteor/local
|
||||
key: meteor-local-regression-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: meteor-local-regression-
|
||||
|
||||
- name: Install Meteor
|
||||
run: |
|
||||
if [ ! -x ~/.meteor/meteor ]; then
|
||||
curl https://install.meteor.com/ | sh
|
||||
fi
|
||||
echo "$HOME/.meteor" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Chromium
|
||||
run: sudo apt-get install -y chromium-browser
|
||||
|
||||
- name: Start WeKan in background
|
||||
run: |
|
||||
MONGO_URL=mongodb://localhost:27017/wekan_regression \
|
||||
ROOT_URL=http://localhost:3000 \
|
||||
PORT=3000 \
|
||||
meteor run > wekan-run.log 2>&1 &
|
||||
# The first Meteor build is slow, so wait up to 20 minutes.
|
||||
for i in $(seq 1 120); do
|
||||
if curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan is up after $((i * 10))s"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for WeKan... attempt $i/120"
|
||||
sleep 10
|
||||
done
|
||||
if ! curl -sf http://localhost:3000 > /dev/null; then
|
||||
echo "WeKan failed to start. Last 200 log lines:"
|
||||
tail -n 200 wekan-run.log
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
DISABLE_CAPTCHA: 'true'
|
||||
|
||||
- name: Run list-regressions e2e
|
||||
run: node tests/e2e/list-regressions.js
|
||||
env:
|
||||
WEKAN_BASE_URL: http://localhost:3000
|
||||
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_regression
|
||||
CHROMIUM_PATH: /usr/bin/chromium-browser
|
||||
HEADLESS: 'true'
|
||||
+728
-332
File diff suppressed because it is too large.
Load diff
@@ -1,168 +0,0 @@
|
||||
name: Test suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
jobs:
|
||||
# the following are optional jobs and need to be configured according
|
||||
# to this project's settings:
|
||||
#
|
||||
# lintcode:
|
||||
# name: Javascript lint
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: checkout
|
||||
# uses: actions/checkout@v6
|
||||
#
|
||||
# - name: setup node
|
||||
# uses: actions/setup-node@v1
|
||||
# with:
|
||||
# node-version: '12.x'
|
||||
#
|
||||
# - name: cache dependencies
|
||||
# uses: actions/cache@v1
|
||||
# with:
|
||||
# path: ~/.npm
|
||||
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-node-
|
||||
#
|
||||
# - run: npm install
|
||||
# - run: npm run lint:code
|
||||
#
|
||||
# lintstyle:
|
||||
# name: SCSS lint
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [lintcode]
|
||||
# steps:
|
||||
# - name: checkout
|
||||
# uses: actions/checkout@v6
|
||||
#
|
||||
# - name: setup node
|
||||
# uses: actions/setup-node@v1
|
||||
# with:
|
||||
# node-version: '12.x'
|
||||
#
|
||||
# - name: cache dependencies
|
||||
# uses: actions/cache@v1
|
||||
# with:
|
||||
# path: ~/.npm
|
||||
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-node-
|
||||
# - run: npm install
|
||||
# - run: npm run lint:style
|
||||
#
|
||||
# lintdocs:
|
||||
# name: documentation lint
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [lintcode,lintstyle]
|
||||
# steps:
|
||||
# - name: checkout
|
||||
# uses: actions/checkout@v6
|
||||
#
|
||||
# - name: setup node
|
||||
# uses: actions/setup-node@v1
|
||||
# with:
|
||||
# node-version: '12.x'
|
||||
#
|
||||
# - name: cache dependencies
|
||||
# uses: actions/cache@v1
|
||||
# with:
|
||||
# path: ~/.npm
|
||||
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-node-
|
||||
#
|
||||
# - run: npm install
|
||||
# - run: npm run lint:markdown
|
||||
|
||||
tests:
|
||||
name: Meteor ${{ matrix.meteor }} tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# CHECKOUTS
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# CACHING
|
||||
- name: Install Meteor
|
||||
id: cache-meteor-install
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.meteor
|
||||
key: v1-meteor-${{ hashFiles('.meteor/versions') }}
|
||||
restore-keys: |
|
||||
v1-meteor-
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
id: cache-meteor-npm
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: v1-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
v1-npm-
|
||||
|
||||
- name: Cache Meteor build
|
||||
id: cache-meteor-build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
.meteor/local/resolver-result-cache.json
|
||||
.meteor/local/plugin-cache
|
||||
.meteor/local/isopacks
|
||||
.meteor/local/bundler-cache/scanner
|
||||
key: v1-meteor_build_cache-${{ github.ref }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
v1-meteor_build_cache-
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.22.2'
|
||||
|
||||
- name: Setup meteor
|
||||
uses: meteorengineer/setup-meteor@v3
|
||||
with:
|
||||
meteor-release: '3.4'
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
run: meteor npm install
|
||||
|
||||
- name: Run Tests
|
||||
run: sh ./test-wekan.sh -cv
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-folder
|
||||
path: .coverage/
|
||||
|
||||
coverage:
|
||||
name: Coverage report
|
||||
runs-on: ubuntu-latest
|
||||
needs: [tests]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download coverage
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: coverage-folder
|
||||
path: .coverage/
|
||||
|
||||
|
||||
- name: Coverage Report
|
||||
uses: VeryGoodOpenSource/very_good_coverage@v3.0.0
|
||||
with:
|
||||
path: ".coverage/lcov.info"
|
||||
min_coverage: 1 # TODO add tests and increase to 95!
|
||||
+17
@@ -1,4 +1,5 @@
|
||||
*~
|
||||
/*.log
|
||||
*.sw*
|
||||
.meteor-spk
|
||||
*.sublime-workspace
|
||||
@@ -31,6 +32,7 @@ Thumbs.db
|
||||
ehthumbs.db
|
||||
.eslintcache
|
||||
.meteor/local
|
||||
.meteor/local-test
|
||||
.devcontainer/docker-compose.extend.yml
|
||||
.devcontainer/volumes*/
|
||||
.coverage
|
||||
@@ -53,3 +55,18 @@ _build
|
||||
*/build-assets
|
||||
*/build-chunks
|
||||
.rsdoctor
|
||||
|
||||
# Tests
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Claude AI
|
||||
.claude/
|
||||
|
||||
# Tools like mongosh etc so that they work inside Flatpak sandbox
|
||||
.tools/
|
||||
|
||||
# Meteor custom local directory (METEOR_LOCAL_DIR)
|
||||
_build-local-test
|
||||
*/build-assets-local-test
|
||||
*/build-chunks-local-test
|
||||
+25
-29
@@ -3,12 +3,12 @@
|
||||
# 'meteor add' and 'meteor remove' will edit this file for you,
|
||||
# but you can also edit it by hand.
|
||||
|
||||
meteor-base
|
||||
meteor-base@1.5.2
|
||||
|
||||
# Build system
|
||||
ecmascript
|
||||
standard-minifier-js
|
||||
rspack
|
||||
ecmascript@0.18.0
|
||||
standard-minifier-js@3.2.0
|
||||
rspack@1.1.0
|
||||
|
||||
blaze@3.0.2
|
||||
|
||||
@@ -16,18 +16,11 @@ blaze@3.0.2
|
||||
aldeed:collection2
|
||||
aldeed:schema-index@4.0.0-beta.5
|
||||
reywood:publish-composite
|
||||
mongo
|
||||
|
||||
# The holy trifecta - this is a replacement for subs-manager
|
||||
# for IndexedDB persistence and auto-sync
|
||||
jam:offline
|
||||
# for optimistic methods with offline queuing
|
||||
jam:method
|
||||
# global subscription caching
|
||||
jam:pub-sub
|
||||
mongo@2.4.0-rc350.1
|
||||
|
||||
# Account system
|
||||
accounts-password
|
||||
accounts-password@3.3.0-rc350.1
|
||||
accounts-express@1.0.0-rc350.1
|
||||
communitypackages:core
|
||||
communitypackages:unstyled
|
||||
communitypackages:flow-routing
|
||||
@@ -41,29 +34,32 @@ wekan-oidc
|
||||
wekan-accounts-oidc
|
||||
|
||||
# Utilities
|
||||
check
|
||||
random
|
||||
reactive-dict
|
||||
session
|
||||
tracker
|
||||
audit-argument-checks
|
||||
check@1.5.0
|
||||
random@1.2.2
|
||||
reactive-dict@1.3.2
|
||||
session@1.2.3-rc350.1
|
||||
tracker@1.3.4
|
||||
audit-argument-checks@1.0.8
|
||||
|
||||
# UI components
|
||||
ostrio:i18n
|
||||
reactive-var
|
||||
shell-server
|
||||
email
|
||||
dynamic-import
|
||||
reactive-var@1.0.13
|
||||
shell-server@0.7.0
|
||||
email@3.2.0-rc350.1
|
||||
dynamic-import@0.7.4
|
||||
wekan-markdown
|
||||
quave:synced-cron
|
||||
ostrio:cookies
|
||||
ostrio:files
|
||||
# lmieulet:meteor-coverage
|
||||
meteortesting:mocha
|
||||
meteortesting:mocha@3.0.0
|
||||
matb33:collection-hooks
|
||||
service-configuration
|
||||
minifier-css
|
||||
ejson
|
||||
logging
|
||||
service-configuration@1.3.5
|
||||
minifier-css@2.0.2-rc350.1
|
||||
ejson@1.2.0-rc350.1
|
||||
logging@1.3.6
|
||||
wekan-fullcalendar
|
||||
wekan-fontawesome
|
||||
jam:offline
|
||||
jam:method
|
||||
jam:pub-sub
|
||||
+1
-1
@@ -1 +1 @@
|
||||
METEOR@3.4
|
||||
METEOR@3.5-rc.1
|
||||
+49
-49
@@ -1,48 +1,48 @@
|
||||
accounts-base@3.2.0
|
||||
accounts-oauth@1.4.6
|
||||
accounts-password@3.2.2
|
||||
aldeed:collection2@4.1.5
|
||||
accounts-base@3.3.0-rc350.1
|
||||
accounts-express@1.0.0-rc350.1
|
||||
accounts-oauth@1.4.7-rc350.1
|
||||
accounts-password@3.3.0-rc350.1
|
||||
aldeed:collection2@4.2.0
|
||||
aldeed:schema-index@4.0.0
|
||||
aldeed:simple-schema@1.13.1
|
||||
allow-deny@2.1.0
|
||||
audit-argument-checks@1.0.8
|
||||
autoupdate@2.0.1
|
||||
babel-compiler@7.13.0
|
||||
babel-compiler@7.14.0
|
||||
babel-runtime@1.5.2
|
||||
base64@1.0.13
|
||||
binary-heap@1.0.12
|
||||
blaze@3.0.2
|
||||
blaze-tools@2.0.0
|
||||
base64@1.0.14-rc350.1
|
||||
binary-heap@1.0.13-rc350.1
|
||||
blaze@3.0.3
|
||||
blaze-tools@2.0.1
|
||||
boilerplate-generator@2.1.0
|
||||
caching-compiler@2.0.1
|
||||
caching-html-compiler@2.0.0
|
||||
callback-hook@1.6.1
|
||||
caching-html-compiler@2.0.1
|
||||
callback-hook@1.8.0-rc350.1
|
||||
check@1.5.0
|
||||
communitypackages:core@2.0.3
|
||||
communitypackages:flow-routing@2.1.0
|
||||
communitypackages:unstyled@2.0.1
|
||||
core-runtime@1.0.0
|
||||
ddp@1.4.2
|
||||
ddp-client@3.1.1
|
||||
ddp-common@1.4.4
|
||||
ddp-rate-limiter@1.2.2
|
||||
ddp-server@3.1.2
|
||||
ddp-client@3.3.0-rc350.1
|
||||
ddp-common@1.4.5-rc350.1
|
||||
ddp-rate-limiter@1.3.0-rc350.1
|
||||
ddp-server@3.3.0-rc350.1
|
||||
diff-sequence@1.1.3
|
||||
dynamic-import@0.7.4
|
||||
ecmascript@0.17.0
|
||||
ecmascript@0.18.0
|
||||
ecmascript-runtime@0.8.3
|
||||
ecmascript-runtime-client@0.12.3
|
||||
ecmascript-runtime-client@0.13.0
|
||||
ecmascript-runtime-server@0.11.1
|
||||
ejson@1.1.5
|
||||
email@3.1.2
|
||||
ejson@1.2.0-rc350.1
|
||||
email@3.2.0-rc350.1
|
||||
es5-shim@4.8.1
|
||||
facts-base@1.0.2
|
||||
fetch@0.1.6
|
||||
fetch@0.2.0-rc350.1
|
||||
geojson-utils@1.0.12
|
||||
hot-code-push@1.0.5
|
||||
html-tools@2.0.0
|
||||
htmljs@2.0.1
|
||||
http@1.4.4
|
||||
html-tools@2.0.1
|
||||
htmljs@2.0.2
|
||||
id-map@1.2.0
|
||||
inter-process-messaging@0.1.2
|
||||
jam:method@1.9.1
|
||||
@@ -53,36 +53,36 @@ lai:collection-extensions@1.0.0
|
||||
localstorage@1.2.1
|
||||
logging@1.3.6
|
||||
matb33:collection-hooks@2.1.0
|
||||
meteor@2.2.0
|
||||
meteor@2.3.1-rc350.1
|
||||
meteor-base@1.5.2
|
||||
meteortesting:browser-tests@1.4.2
|
||||
meteortesting:mocha@2.1.0
|
||||
meteortesting:mocha-core@8.0.1
|
||||
minifier-css@2.0.1
|
||||
minifier-js@3.1.0
|
||||
minimongo@2.0.5
|
||||
meteortesting:browser-tests@1.8.0
|
||||
meteortesting:mocha@3.3.0
|
||||
meteortesting:mocha-core@8.2.0
|
||||
minifier-css@2.0.2-rc350.1
|
||||
minifier-js@3.2.0
|
||||
minimongo@2.2.0-rc350.1
|
||||
modern-browsers@0.2.3
|
||||
modules@0.20.3
|
||||
modules-runtime@0.13.2
|
||||
mongo@2.2.0
|
||||
mongo@2.4.0-rc350.1
|
||||
mongo-decimal@0.2.0
|
||||
mongo-dev-server@1.1.1
|
||||
mongo-id@1.0.9
|
||||
npm-mongo@6.16.1
|
||||
oauth@3.0.2
|
||||
oauth@3.0.4-rc350.1
|
||||
oauth2@1.3.3
|
||||
observe-sequence@2.0.0
|
||||
observe-sequence@2.0.1
|
||||
ordered-dict@1.2.0
|
||||
ostrio:cookies@2.9.1
|
||||
ostrio:cookies@2.9.2
|
||||
ostrio:cstorage@4.0.1
|
||||
ostrio:files@3.0.1
|
||||
ostrio:flow-router-extra@3.12.1
|
||||
ostrio:flow-router-extra@3.15.3
|
||||
ostrio:i18n@3.2.1
|
||||
promise@1.0.0
|
||||
quave:synced-cron@2.4.0
|
||||
raix:eventemitter@1.0.0
|
||||
random@1.2.2
|
||||
rate-limit@1.1.2
|
||||
rate-limit@1.2.0-rc350.1
|
||||
react-fast-refresh@0.3.0
|
||||
reactive-dict@1.3.2
|
||||
reactive-var@1.0.13
|
||||
@@ -90,24 +90,24 @@ reload@1.3.2
|
||||
retry@1.1.1
|
||||
reywood:publish-composite@1.9.0
|
||||
routepolicy@1.1.2
|
||||
rspack@1.0.0
|
||||
rspack@1.1.0
|
||||
service-configuration@1.3.5
|
||||
session@1.2.2
|
||||
session@1.2.3-rc350.1
|
||||
sha@1.0.10
|
||||
shell-server@0.7.0
|
||||
socket-stream-client@0.6.1
|
||||
spacebars@2.0.0
|
||||
spacebars-compiler@2.0.0
|
||||
socket-stream-client@0.7.0-rc350.1
|
||||
spacebars@2.0.1
|
||||
spacebars-compiler@2.0.1
|
||||
standard-minifier-js@3.2.0
|
||||
templating@1.4.4
|
||||
templating-compiler@2.0.0
|
||||
templating-runtime@2.0.1
|
||||
templating-tools@2.0.0
|
||||
tools-core@1.0.0
|
||||
templating@1.4.5
|
||||
templating-compiler@2.0.1
|
||||
templating-runtime@2.0.2
|
||||
templating-tools@2.0.1
|
||||
tools-core@1.1.0
|
||||
tracker@1.3.4
|
||||
typescript@5.9.3
|
||||
url@1.3.5
|
||||
webapp@2.1.0
|
||||
typescript@5.10.0
|
||||
url@1.3.6-rc350.1
|
||||
webapp@2.2.0-rc350.1
|
||||
webapp-hashing@1.1.2
|
||||
wekan-accounts-cas@0.2.0
|
||||
wekan-accounts-lockout@1.1.0
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
npm-packages/
|
||||
tests/
|
||||
+3595
-19
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -3,9 +3,14 @@
|
||||
For all code at WeKan GitHub Organization https://github.com/wekan
|
||||
|
||||
- All code in pull requests need to have permission already to add it to WeKan with MIT license, and will become MIT license.
|
||||
- All code xet7 add is MIT license.
|
||||
- For any dependencies, permissive licenses like https://copyfree.org are preferred
|
||||
- For anything currently that is non-permissive (like GPL, AGPL, SSPL), those will be replaced with permissive-licensed alternatives
|
||||
- Any intelligence allowed: Human, AI or any other. No discrimination, please have world peace. Mentioning participants optional.
|
||||
- All code is checked and validated by xet7 and many different security AI. If code is real working fix or feature, it usually will be merged.
|
||||
- All known vulnerabilities have been fixed at newest WeKan, please upgrade https://wekan.fi/upgrade/
|
||||
- There is no known backdoors. If WeKan is installed by someone else, first registered user is Admin and has Admin Panel access.
|
||||
- Maintainer of WeKan has no access to someone elses installed WeKan.
|
||||
- For someone elses installed WeKan, see [Forgot Password](https://github.com/wekan/wekan/blob/main/docs/Login/Forgot-Password.md)
|
||||
|
||||
# Reporting about violations or something else
|
||||
|
||||
|
||||
+10
-4
@@ -34,6 +34,16 @@ and click send PR.
|
||||
|
||||
## Contributing code
|
||||
|
||||
Use rebuild-wekan.sh to run all tests to verify you don't break any features, before sending PR.
|
||||
|
||||
Only send one feature or fix at one PR. Do not add many unrelated changes.
|
||||
|
||||
Only send those changed code lines, that are related to your one feature or fix.
|
||||
|
||||
Do not format unrelated code. Do not use code formatting or linting like eslist or prettier.
|
||||
|
||||
Send your code ASAP, because there are daily releases of WeKan with major features and fixes added.
|
||||
|
||||
[Building WeKan and sending PR](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md).
|
||||
|
||||
WeKan code contributors Hall of Fame is at ChangeLog, where
|
||||
@@ -49,10 +59,6 @@ Also see other docs:
|
||||
|
||||
https://github.com/wekan/wekan/tree/main/docs/DeveloperDocs
|
||||
|
||||
Do not use code formatting or linting like eslist or prettier.
|
||||
|
||||
Only send minimal changed code lines, that are related to feature or fix.
|
||||
|
||||
WeKan code has MIT license.
|
||||
|
||||
About 300 persons have contributed to WeKan, stats at:
|
||||
|
||||
+36
-12
@@ -1,24 +1,23 @@
|
||||
FROM ubuntu:24.04
|
||||
FROM ubuntu:26.04
|
||||
LABEL maintainer="wekan"
|
||||
LABEL org.opencontainers.image.ref.name="ubuntu"
|
||||
LABEL org.opencontainers.image.version="24.04"
|
||||
LABEL org.opencontainers.image.version="26.04"
|
||||
LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
|
||||
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=8.74
|
||||
ARG VERSION=9.63
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
|
||||
ENV \
|
||||
DEBUG=false \
|
||||
NODE_VERSION=v22.22.2 \
|
||||
METEOR_RELEASE=METEOR@3.4 \
|
||||
NODE_VERSION=v24.17.0 \
|
||||
METEOR_RELEASE=METEOR@3.5-beta.12 \
|
||||
USE_EDGE=false \
|
||||
METEOR_EDGE=1.5-beta.17 \
|
||||
NPM_VERSION=10.9.2 \
|
||||
NPM_VERSION=11.11.0 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
MONGO_OPLOG_URL="" \
|
||||
@@ -43,6 +42,7 @@ ENV \
|
||||
IMAGE_COMPRESS_RATIO="" \
|
||||
NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE="" \
|
||||
BIGEVENTS_PATTERN=NONE \
|
||||
NOTIFY_ON_ASSIGN="true" \
|
||||
NOTIFY_DUE_DAYS_BEFORE_AND_AFTER="" \
|
||||
NOTIFY_DUE_AT_HOUR_OF_DAY="" \
|
||||
EMAIL_NOTIFICATION_TIMEOUT=30000 \
|
||||
@@ -69,6 +69,8 @@ ENV \
|
||||
OAUTH2_TOKEN_ENDPOINT="" \
|
||||
OAUTH2_ID_MAP="" \
|
||||
OAUTH2_USERNAME_MAP="" \
|
||||
OAUTH2_AUTO_REGISTRATION="true" \
|
||||
OAUTH2_ADMIN_GROUPS="" \
|
||||
OAUTH2_FULLNAME_MAP="" \
|
||||
OAUTH2_ID_TOKEN_WHITELIST_FIELDS="" \
|
||||
OAUTH2_REQUEST_PERMISSIONS='openid profile email' \
|
||||
@@ -94,6 +96,7 @@ ENV \
|
||||
LDAP_BACKGROUND_SYNC_INTERVAL="" \
|
||||
LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED=false \
|
||||
LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false \
|
||||
LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS=false \
|
||||
LDAP_ENCRYPTION=false \
|
||||
LDAP_CA_CERT="" \
|
||||
LDAP_REJECT_UNAUTHORIZED=false \
|
||||
@@ -123,10 +126,16 @@ ENV \
|
||||
LDAP_DEFAULT_DOMAIN="" \
|
||||
LDAP_SYNC_ADMIN_STATUS="" \
|
||||
LDAP_SYNC_ADMIN_GROUPS="" \
|
||||
LDAP_SYNC_ORGANIZATIONS=false \
|
||||
LDAP_SYNC_ORGANIZATIONS_GROUPS="" \
|
||||
LDAP_SYNC_TEAMS=false \
|
||||
LDAP_SYNC_TEAMS_GROUPS="" \
|
||||
HEADER_LOGIN_ID="" \
|
||||
HEADER_LOGIN_FIRSTNAME="" \
|
||||
HEADER_LOGIN_LASTNAME="" \
|
||||
HEADER_LOGIN_EMAIL="" \
|
||||
HEADER_LOGIN_TRUSTED_IPS="" \
|
||||
HEADER_LOGIN_TRUSTED_PROXIES="" \
|
||||
LOGOUT_WITH_TIMER=false \
|
||||
LOGOUT_IN="" \
|
||||
LOGOUT_ON_HOURS="" \
|
||||
@@ -161,9 +170,14 @@ ENV \
|
||||
|
||||
RUN <<EOR
|
||||
set -o xtrace
|
||||
# Fail hard on any error so a missing release zip / failed download can never
|
||||
# produce a "successful" image with an empty /build (Cannot find /build/main.js).
|
||||
set -eo pipefail
|
||||
|
||||
# Create Wekan user
|
||||
useradd --user-group --system --home-dir /home/wekan wekan
|
||||
# Create Wekan user. --create-home is required because --system users do not
|
||||
# get a home directory by default; without it /home/wekan never exists and the
|
||||
# later `chown ... /home/wekan/` aborts the build (now that set -e is active).
|
||||
useradd --user-group --system --create-home --home-dir /home/wekan wekan
|
||||
|
||||
# OS Updates
|
||||
apt-get update --assume-yes
|
||||
@@ -191,7 +205,7 @@ rm -f "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt.asc
|
||||
ln -s "/usr/local/bin/node" "/usr/local/bin/nodejs"
|
||||
|
||||
# NPM configuration
|
||||
npm install -g npm@${NPM_VERSION} --production
|
||||
npm install -g npm@${NPM_VERSION}
|
||||
chown --recursive wekan:wekan /home/wekan/
|
||||
|
||||
# Temporary Tar swap for Meteor bundle
|
||||
@@ -201,16 +215,26 @@ ln -sf $(which bsdtar) $(which tar)
|
||||
# WeKan Bundle Installation
|
||||
mkdir -p /home/wekan/app
|
||||
cd /home/wekan/app
|
||||
wget "https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
# Retry the release-asset download: even though the CI `docker` job needs the
|
||||
# `release` job (so the asset is already uploaded), GitHub's
|
||||
# releases/download/<tag>/<asset> URL can briefly return 404 right after upload
|
||||
# (CDN/propagation lag). A plain wget treats 404 as fatal, which failed the
|
||||
# build; retry on transient HTTP errors so propagation lag no longer breaks it.
|
||||
WEKAN_ZIP_URL="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 "${WEKAN_ZIP_URL}" \
|
||||
|| { echo "Failed to download ${WEKAN_ZIP_URL} after retries"; exit 8; }
|
||||
unzip "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
|
||||
npm install --production --no-audit --prefix ./bundle/programs/server
|
||||
npm install --prefix ./bundle/programs/server
|
||||
mv /home/wekan/app/bundle /build
|
||||
|
||||
# Restore original tar
|
||||
mv $(which tar)~ $(which tar)
|
||||
|
||||
# Cleanup
|
||||
# Remove unused Go-based pebble binary shipped by base image to reduce CVE surface.
|
||||
apt-get remove --purge --assume-yes pebble || true
|
||||
rm -f /usr/bin/pebble
|
||||
apt-get remove --purge --assume-yes ${BUILD_DEPS}
|
||||
apt-get autoremove --assume-yes
|
||||
apt-get clean --assume-yes
|
||||
|
||||
@@ -47,12 +47,13 @@ See bottom of https://github.com/wekan/wekan/issues/3874
|
||||
## FAQ
|
||||
|
||||
**NOTE**:
|
||||
|
||||
- Please read the [FAQ](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md) first
|
||||
- Please don't feed the [trolls](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md#why-am-i-called-a-troll) and [spammers](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md#why-am-i-called-a-spammer) that are mentioned in the FAQ :)
|
||||
|
||||
## About WeKan ®
|
||||
|
||||
WeKan ® is an completely [Open Source][open_source] and [Free software][free_software]
|
||||
WeKan ® is a completely [Open Source][open_source] and [Free software][free_software]
|
||||
collaborative kanban board application with MIT license.
|
||||
|
||||
Whether you’re maintaining a personal todo list, planning your holidays with some friends,
|
||||
@@ -76,15 +77,15 @@ that by providing one-click installation on various platforms.
|
||||
- 1 GB RAM minimum free for WeKan ®. Production server should have minimum total 4 GB RAM.
|
||||
For thousands of users, for example with [Docker](https://github.com/wekan/wekan/blob/main/docker-compose.yml): 3 frontend servers,
|
||||
each having 2 CPU and 2 wekan-app containers. One backend wekan-db server with many CPUs.
|
||||
- Enough disk space and alerts about low disk space. If you run out disk space, MongoDB database gets corrupted.
|
||||
- Enough disk space and alerts about low disk space. If you run out of disk space, MongoDB database gets corrupted.
|
||||
- SECURITY: Updating to newest WeKan ® version very often. Please check you do not have automatic updates of Sandstorm or Snap turned off.
|
||||
Old versions have security issues because of old versions Node.js etc. Only newest WeKan ® is supported.
|
||||
WeKan ® on Sandstorm is not usually affected by any Standalone WeKan ® (Snap/Docker/Source) security issues.
|
||||
- [Reporting all new bugs immediately](https://github.com/wekan/wekan/issues).
|
||||
New features and fixes are added to WeKan ® [many times a day](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- [Backups](https://github.com/wekan/wekan/blob/main/docs/Backup/Backup.md) of WeKan ® database once a day miminum.
|
||||
- [Backups](https://github.com/wekan/wekan/blob/main/docs/Backup/Backup.md) of WeKan ® database once a day minimum.
|
||||
Bugs, updates, users deleting list or card, harddrive full, harddrive crash etc can eat your data. There is no undo yet.
|
||||
Some bug can cause WeKan ® board to not load at all, requiring manual fixing of database content.
|
||||
Some bugs can cause WeKan ® board to not load at all, requiring manual fixing of database content.
|
||||
|
||||
## Roadmap and Demo
|
||||
|
||||
@@ -92,7 +93,7 @@ that by providing one-click installation on various platforms.
|
||||
|
||||
[Developer Documentation][dev_docs]
|
||||
|
||||
- There is many companies and individuals contributing code to WeKan ®, to add features and bugfixes
|
||||
- There are many companies and individuals contributing code to WeKan ®, to add features and bugfixes
|
||||
[many times a day](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- [Please add Add new Feature Requests and Bug Reports immediately](https://github.com/wekan/wekan/issues).
|
||||
- [Commercial Support](https://wekan.fi/commercial-support/).
|
||||
@@ -102,12 +103,75 @@ By working directly with WeKan ® you get the benefit of active maintenance and
|
||||
|
||||
## Getting Started with Development
|
||||
|
||||
The default branch uses [Meteor 2 with Node.js 14](https://wekan.fi/install/).
|
||||
The default branch uses Meteor 3.5-beta.7 with Node.js 24.x.
|
||||
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
|
||||
|
||||
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-select-option-1-to-install-dependencies-and-then-enter). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
|
||||
|
||||
Please refer to the [developer documentation](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md) for more information.
|
||||
|
||||
## First-Time Setup for Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before building WeKan from source, ensure you have:
|
||||
|
||||
- **Git** - for cloning the repository
|
||||
- **Node.js 24.x** - WeKan requires Node.js 24.x
|
||||
- **Meteor** - the JavaScript framework WeKan is built with
|
||||
|
||||
### Installing Node.js 24.x
|
||||
|
||||
```bash
|
||||
# Using nvm (recommended)
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
source ~/.zshrc # or ~/.bashrc
|
||||
nvm install 24
|
||||
nvm use 24
|
||||
```
|
||||
|
||||
### Installing Meteor
|
||||
|
||||
```bash
|
||||
curl https://install.meteor.com/ | sh
|
||||
```
|
||||
|
||||
### Building WeKan
|
||||
|
||||
The `rebuild-wekan.sh` script guides you through a three-stage process:
|
||||
|
||||
1. **Install dependencies (Option 1)** - Downloads all required Meteor packages and npm modules
|
||||
2. **Build WeKan (Option 2)** - Compiles the application
|
||||
3. **Run WeKan (Option 3)** - Starts the development server at http://localhost:4000
|
||||
|
||||
```bash
|
||||
# Clone your fork
|
||||
git clone git@github.com:YOUR_USERNAME/wekan.git
|
||||
cd wekan
|
||||
|
||||
# Make the script executable
|
||||
chmod +x rebuild-wekan.sh
|
||||
|
||||
# Step 1: Install dependencies
|
||||
./rebuild-wekan.sh
|
||||
# Press 1 and Enter
|
||||
|
||||
# Step 2: Build WeKan (after dependencies complete)
|
||||
./rebuild-wekan.sh
|
||||
# Press 2 and Enter
|
||||
|
||||
# Step 3: Run WeKan in development mode
|
||||
./rebuild-wekan.sh
|
||||
# Press 3 and Enter
|
||||
```
|
||||
|
||||
|
||||
### WSL Users
|
||||
|
||||
WSL users can use Snap Candidate. See [install docs](https://wekan.fi/install/).
|
||||
|
||||
The development server will start at http://localhost:4000. Any changes you make to the source code will automatically trigger a rebuild and refresh your browser.
|
||||
|
||||
## Screenshot
|
||||
|
||||
[More screenshots at Features page](https://github.com/wekan/wekan/tree/main/docs/Features)
|
||||
|
||||
+93
-95
@@ -1,38 +1,34 @@
|
||||
## About money
|
||||
## About Money: No bounty
|
||||
|
||||
See [CONTRIBUTING](CONTRIBUTING.md)
|
||||
There is no bounty, like described at [CONTRIBUTING.md](CONTRIBUTING.md),
|
||||
because WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
|
||||
## Responsible Security Disclosure
|
||||
## Steps for Coordinated Vulnerability Disclosure
|
||||
|
||||
1. To send email, if possible, use PGP key [security-at-wekan.fi.asc](security-at-wekan.fi.asc)
|
||||
2. Send info about security issue ONLY to security@wekan.fi . NOT TO ANYWHERE ELSE. NO CC, NO BCC.
|
||||
3. Wait for new WeKan release that fixes security issue to appear to top of
|
||||
https://github.com/wekan/wekan/blob/main/CHANGELOG.md
|
||||
4. We will thank you by adding you to Hall of Fame: https://wekan.fi/hall-of-fame/
|
||||
5. All vulnerability details will be private to security@wekan.fi ,
|
||||
unless you help all WeKan platforms to have a way to upgrade, like sending
|
||||
database migrations code to security@wekan.fi or PRs to https://github.com/wekan/wekan/pulls .
|
||||
There is no benefit to Wordwide Security Community to have more details about vulnerabilities,
|
||||
if Worldwide Security Community does not help to make upgrades possible.
|
||||
6. If there some day becomes available a way to upgrade all WeKan platforms,
|
||||
this page will be updated to add permission for security researchers
|
||||
to request new GHSA or CVE ID and publish your vulnerability details at your blog, talks, etc,
|
||||
and send that info also to security@wekan.fi to be added to
|
||||
Hall of Fame: https://wekan.fi/hall-of-fame/ to get Upgrade Bonus Point Stars.
|
||||
In that case, it will become possible for security@wekan.fi to publish all
|
||||
remaining private security details, and publicly thank Worldwide Security Community.
|
||||
### 1. Security Researcher:
|
||||
|
||||
## Bonus Points
|
||||
- **Report**: Click "New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
|
||||
More info at [GitHub Docs about reporting privately](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/report-privately).
|
||||
- **Proof of Concept (PoC)**: If possible, include a fix or a reproduction script/code.
|
||||
- **If GitHub does not work**: Please send report to **security@wekan.fi**
|
||||
- **Optional Email Encryption**: An optional PGP key is available at [security-at-wekan.fi.asc](security-at-wekan.fi.asc).
|
||||
|
||||
- If you include code for fixing security issue
|
||||
### 2. WeKan Security Team:
|
||||
|
||||
## Losing Points
|
||||
- **Remediation**: Please wait for a new WeKan release that addresses the issue.
|
||||
Fixes are announced at the top of the [ChangeLog](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
|
||||
- **Recognition**: We will acknowledge your contribution by adding you to our
|
||||
[Hall of Fame](https://wekan.fi/hall-of-fame/).
|
||||
- **CVE Policy**: WeKan Security requests CVE at GitHub when releasing Security Advisory.
|
||||
|
||||
- If you ask about [bounty](CONTRIBUTING.md). There is no bounty. WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
- If you forget to include vulnerability details.
|
||||
- If you send info about security issue to somewhere else than security@wekan.fi
|
||||
### 3. Post-Release and Public Disclosure:
|
||||
|
||||
## How should reports be formatted?
|
||||
- Publicity: If you publish blog posts, give talks, or write articles about the
|
||||
reported vulnerabilities, please send the links to us. We will link them
|
||||
to your entry in the [Hall of Fame](https://wekan.fi/hall-of-fame/) for additional context.
|
||||
- Advisories reported at GitHub are listed at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
|
||||
|
||||
## How Should Reports Be Formatted?
|
||||
|
||||
```
|
||||
Name: %name
|
||||
@@ -54,31 +50,36 @@ added to the WeKan Hall of Fame https://wekan.fi/hall-of-fame/
|
||||
|
||||
## Which domains are in scope?
|
||||
|
||||
No public domains, because all those are donated to Wekan Open Source project,
|
||||
No public domains, because all those are donated to WeKan Open Source project,
|
||||
and we don't have any permissions to do security scans on those donated servers.
|
||||
|
||||
Please don't perform research that could impact other users. Secondly, please keep
|
||||
the reports short and succinct. If we fail to understand the logics of your bug, we will tell you.
|
||||
Please don't perform research that could impact other users. Second, please keep
|
||||
the reports short and succinct. If we fail to understand the logic of your bug, we will tell you.
|
||||
|
||||
You can [Install Wekan](https://github.com/wekan/wekan/releases) to your own computer
|
||||
You can [Install WeKan](https://wekan.fi/install/) on your own computer
|
||||
and scan it's vulnerabilities there.
|
||||
|
||||
## About Wekan versions
|
||||
## About WeKan Versions
|
||||
|
||||
There are only 2 versions of Wekan: Standalone Wekan, and Sandstorm Wekan.
|
||||
There are 2 versions of WeKan: Standalone WeKan, and Sandstorm WeKan.
|
||||
|
||||
### Standalone Wekan Security
|
||||
## Meteor Security
|
||||
|
||||
Standalone Wekan includes all non-Sandstorm platforms. Some Standalone Wekan platforms
|
||||
https://guide.meteor.com/security
|
||||
|
||||
### Standalone WeKan Security
|
||||
|
||||
Standalone WeKan includes all non-Sandstorm platforms. Some Standalone WeKan platforms
|
||||
like Snap and Docker have their own specific sandboxing etc features.
|
||||
|
||||
Standalone Wekan by default does not load any files from Internet, like fonts, CSS, etc.
|
||||
This also means all Standalone Wekan functionality works in offline local networks.
|
||||
WeKan is used at most countries of the world https://snapcraft.io/wekan
|
||||
and by by companies that have 30k users.
|
||||
Standalone WeKan by default does not load any files from Internet, like fonts, CSS, etc.
|
||||
This also means all Standalone WeKan functionality works in offline local networks.
|
||||
WeKan is used in most countries of the world https://snapcraft.io/wekan
|
||||
and by companies that have 30k users.
|
||||
|
||||
- Wekan private board attachments are not accessible without logging in.
|
||||
- There is feature to set board public, so that board is visible without logging in in readonly mode, with realtime updates.
|
||||
- WeKan private board attachments are not accessible without logging in.
|
||||
- There is feature to set board public, so that board is visible without logging
|
||||
in readonly mode, with realtime updates.
|
||||
- Admin Panel has feature to disable all public boards, so all boards are private.
|
||||
|
||||
## SSL/TLS
|
||||
@@ -90,24 +91,25 @@ and by by companies that have 30k users.
|
||||
|
||||
## XSS
|
||||
|
||||
- Dompurify https://www.npmjs.com/package/dompurify
|
||||
- WeKan uses dompurify npm package to filter for XSS at fields like cards, as you can see from
|
||||
- DOMPurify https://www.npmjs.com/package/dompurify
|
||||
- WeKan uses DOMPurify npm package to filter for XSS at fields like cards, as you can see from
|
||||
[package.json](https://github.com/wekan/wekan/blob/main/package.json). Other used versions can be seen from
|
||||
[Meteor versions file](https://github.com/wekan/wekan/blob/main/.meteor/versions).
|
||||
- Forms can include markdown links, html, image tags etc like you see at https://wekan.github.io .
|
||||
- Forms can include markdown links, html, image tags etc like you see at https://wekan.fi .
|
||||
- It's possible to add attachments to cards, and markdown/html links to files.
|
||||
- Dompurify cleans up viewed code, so Javascript in input fields does not execute
|
||||
- https://wekan.github.io/hall-of-fame/fieldbleed/
|
||||
- DOMPurify cleans up viewed code, so JavaScript in input fields does not execute
|
||||
- https://wekan.fi/hall-of-fame/fieldbleed/
|
||||
- Reaction in comment is now checked, that it does not have extra added code
|
||||
- https://wekan.github.io/hall-of-fame/reactionbleed/
|
||||
- https://wekan.fi/hall-of-fame/reactionbleed/
|
||||
- https://github.com/wekan/wekan/blob/main/packages/markdown/src/template-integration.js#L76
|
||||
|
||||
## QA about PubSub
|
||||
## QA About PubSub
|
||||
|
||||
Q:
|
||||
|
||||
Hello,
|
||||
I have just seen the Meteor DevTools Evolved extension and was wondering if anyone had asked themselves the question of security.
|
||||
I have just seen the Meteor DevTools Evolved extension and was wondering if anyone
|
||||
had asked themselves the question of security.
|
||||
Insofar as all data is shown in the minimongo tab in plain text.
|
||||
How can data be hidden from this extension?
|
||||
|
||||
@@ -115,32 +117,36 @@ A:
|
||||
|
||||
## PubSub
|
||||
|
||||
- It is not security issue to show some text or image, that user has permission to see. It is a security issue, if browserside is some text or image that user should not see.
|
||||
- Meteor has browserside minimongo database, made with Javascript, updated with Publish/Subscribe, PubSub.
|
||||
- Publish/Subscribe means, that realtime web framework reads database changes stream, and then immediately updates webpage,
|
||||
like like dashboards, chat, kanban. That is the point in any realtime web framework in any programming language.
|
||||
- Yes, you should check with Meteor DevTools Evolved Chromium/Firefox extension that at minimongo is only text that user has permission to see.
|
||||
- Do checking as logged in user, and logged out user.
|
||||
- It is not a security issue to show text or images that the user has permission to see.
|
||||
It is a security issue, if browserside is some text or image that user should not see.
|
||||
- Meteor has browserside minimongo database, made with JavaScript, updated with Publish/Subscribe, PubSub.
|
||||
- Publish/Subscribe means, that realtime web framework reads database changes stream,
|
||||
and then immediately updates webpage, like dashboards, chat, kanban.
|
||||
That is the point of any realtime web framework in any programming language.
|
||||
- Yes, you should check with Meteor DevTools Evolved Chromium/Firefox extension that
|
||||
at minimongo is only text that user has permission to see.
|
||||
- Check as a logged-in user and as a logged-out user.
|
||||
- Check permissions and sanitize before allowing some change, because someone could modify content of input field,
|
||||
PubSub/websocket data (for example with Burp Suite Community Edition), etc.
|
||||
- If you have REST API, also check that only those that have login token, and have permission, can view or edit text
|
||||
- If you have a REST API, also check that only those who have a login token and permission can view or edit text.
|
||||
- You should not include any data user is not allowed to see. Not to webpage text, not to websockets/PubSub, etc.
|
||||
- Minimongo should not have password hashes PubSub https://wekan.github.io/hall-of-fame/userbleed/
|
||||
- PubSub uses Websockets, so you need those to be enabled at webserver like Caddy/Nginx/Apache etc, examples of settings
|
||||
- Minimongo should not have password hashes PubSub https://wekan.fi/hall-of-fame/userbleed/
|
||||
- PubSub uses WebSockets, so you need those to be enabled on the webserver like Caddy/Nginx/Apache etc, examples of settings
|
||||
at right menu of https://github.com/wekan/wekan/wiki
|
||||
- Clientside https://github.com/wekan/wekan/tree/main/client/components subscribes to
|
||||
PubSub https://github.com/wekan/wekan/tree/main/server/publications or calls meteor methods at https://github.com/wekan/wekan/tree/main/models
|
||||
- Clientside https://github.com/wekan/wekan/tree/main/client/components subscribes to
|
||||
PubSub https://github.com/wekan/wekan/tree/main/server/publications or calls meteor methods
|
||||
at https://github.com/wekan/wekan/tree/main/models
|
||||
- For Admin:
|
||||
- You can have input field for password https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- You can save password to database https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- Check that only current user or Admin can change password https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- Note that currentUser uses code like Meteor.user() in .js file
|
||||
- Do not have password hashes in PubSub https://github.com/wekan/wekan/blob/main/server/publications/users.js
|
||||
- Do not include password hashes in PubSub https://github.com/wekan/wekan/blob/main/server/publications/users.js
|
||||
- Only show Admin Panel to Admin https://github.com/wekan/wekan/blob/main/client/components/settings/settingBody.jade#L3
|
||||
- If there is a lot of data, use pagination https://github.com/wekan/wekan/blob/main/client/components/settings/peopleBody.js
|
||||
- Only have limited amount of data published in PubSub. Limit in MongoDB query in publications how much is published. Too much could make browser too slow.
|
||||
- Use Environment variables for any email etc passwords.
|
||||
- But what if you would like to remove minimongo? And only use Meteor methods for saving? In that case, you don't have realtime updates,
|
||||
- If there is a large amount of data, use pagination https://github.com/wekan/wekan/blob/main/client/components/settings/peopleBody.js
|
||||
- Only publish a limited amount of data in PubSub. Limit in MongoDB query in publications how much is published. Too much could make browser too slow.
|
||||
- Environment variables for email and other passwords.
|
||||
- But what if you would like to remove Minimongo and only use Meteor methods for saving? In that case, you don't have realtime updates,
|
||||
and you need to write much more code to load and save data yourself, handle any multi user data saving conflicts yourself,
|
||||
and many Meteor Atmospherejs.com PubSub using packages would not work anymore https://github.com/wekan/we
|
||||
|
||||
@@ -148,14 +154,14 @@ A:
|
||||
|
||||
- With PubSub, there is checking, that someone modifying Websockets content, like permission isAdmin, can not change to Admin.
|
||||
- https://github.com/wekan/wekan/commit/cbad4cf5943d47b916f64b4582f8ca76a9dfd743
|
||||
- https://wekan.github.io/hall-of-fame/adminbleed/
|
||||
- https://wekan.fi/hall-of-fame/adminbleed/
|
||||
|
||||
## Permissions and Roles
|
||||
|
||||
- For any user permissions, it's best to use Meteor package package https://github.com/Meteor-Community-Packages/meteor-roles .
|
||||
- Currently WeKan has custom hardcoded permissions, WeKan does not yet use that meteor-roles package.
|
||||
- For any user permissions, it's best to use Meteor package https://github.com/Meteor-Community-Packages/meteor-roles .
|
||||
- Currently, WeKan has custom hardcoded permissions and does not yet use the meteor-roles package.
|
||||
- Using permissions at WeKan sidebar https://github.com/wekan/wekan/blob/main/client/components/sidebar/sidebar.js#L1854-L1875
|
||||
- List of roles https://github.com/wekan/wekan/wiki/REST-API-Role . Change at board or Admin Panel. Also Organizations/Teams.
|
||||
- List of roles: https://github.com/wekan/wekan/wiki/REST-API-Role . Change at board or Admin Panel. Also Organizations/Teams.
|
||||
- Worker role: https://github.com/wekan/wekan/issues/2788
|
||||
- Not implemented yet: Granular Roles https://github.com/wekan/wekan/issues/3022
|
||||
- Check is user logged in, with `if (Meteor.user()) {`
|
||||
@@ -164,8 +170,8 @@ A:
|
||||
|
||||
## Environment variables
|
||||
|
||||
- For any passwords, use environment variables, those are serverside
|
||||
- Do not copy environment variable to public variable that is visible browserside https://github.com/wekan/wekan/blob/main/server/max-size.js
|
||||
- For any passwords, use environment variables; those are serverside
|
||||
- Do not copy environment variables to public variables that are visible on the browserside https://github.com/wekan/wekan/blob/main/server/max-size.js
|
||||
|
||||
```
|
||||
Meteor.startup(() => {
|
||||
@@ -176,20 +182,20 @@ Meteor.startup(() => {
|
||||
```
|
||||
|
||||
- For serverside, you can set Meteor.settings.variablename, without text public
|
||||
- For WeKan kanban, there is feature for setting board public, it can be viewed by anyone, there is realtime updates. But
|
||||
- For WeKan kanban, there is a feature for setting a board to public; it can be viewed by anyone, with realtime updates. But
|
||||
- Some of those permissions are checked at users.js models at https://github.com/wekan/wekan/tree/main/models
|
||||
- Environment variables are used for email server passwords, etc, at all platforms https://github.com/wekan/wekan/commit/a781c0e7dcfdbe34c1483ee83cec12455b7026f7
|
||||
|
||||
## Escape HTML comment tags so that HTML comments are visible
|
||||
|
||||
- Someone reported, that it is problem that content of HTML comments in edit mode, are not visible at at view mode, so this makes HTML comments visible.
|
||||
- Someone reported that it was a problem that the content of HTML comments in edit mode was not visible in view mode, so this change makes HTML comments visible.
|
||||
- https://github.com/wekan/wekan/commit/167863d95711249e69bb3511175d73b34acbbdb3
|
||||
- https://wekan.github.io/hall-of-fame/invisiblebleed/
|
||||
- https://wekan.fi/hall-of-fame/invisiblebleed/
|
||||
|
||||
## Attachments: XSS in filename is sanitized
|
||||
|
||||
- https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
|
||||
- https://wekan.github.io/hall-of-fame/filebleed/
|
||||
- https://wekan.fi/hall-of-fame/filebleed/
|
||||
|
||||
### Attachments: Forced download to prevent stored XSS
|
||||
|
||||
@@ -247,22 +253,20 @@ Meteor.startup(() => {
|
||||
- https://github.com/wekan/wekan/commit/23e5e1e3bd081699ce39ce5887db7e612616014d
|
||||
- https://github.com/wekan/wekan/tree/main/packages/wekan-accounts-lockout
|
||||
|
||||
### Sandstorm Wekan Security
|
||||
### Sandstorm WeKan Security
|
||||
|
||||
On Sandstorm platform using environment variable Standalone Wekan features like Admin Panel etc are
|
||||
turned off, because Sandstorm platform provides SSO for all apps running on Sandstorm.
|
||||
On the Sandstorm platform, Standalone WeKan features like the Admin Panel are turned off using environment variables, because Sandstorm platform provides SSO for all apps running on Sandstorm.
|
||||
|
||||
[Sandstorm](https://sandstorm.io) is separate Open Source platform that has been
|
||||
[Sandstorm](https://sandstorm.io) is a separate Open Source platform that has been
|
||||
[security audited](https://sandstorm.io/news/2017-03-02-security-review) and found bugs fixed.
|
||||
Sandstorm also has passwordless login, LDAP, SAML, Google etc auth options already.
|
||||
At Sandstorm code is read-only and signed by app maintainers, only grain content can be modified.
|
||||
Wekan at Sandstorm runs in sandboxed grain, it does not have access elsewhere without user-visible
|
||||
PowerBox request or opening randomly-generated API key URL.
|
||||
Sandstorm also has passwordless login, LDAP, SAML, Google, and other auth options already.
|
||||
On Sandstorm, code is read-only and signed by app maintainers; only grain content can be modified.
|
||||
WeKan on Sandstorm runs in a sandboxed grain; it does not have access elsewhere without a user-visible PowerBox request or opening a randomly-generated API key URL.
|
||||
Also read [Sandstorm Security Practices](https://docs.sandstorm.io/en/latest/using/security-practices/) and
|
||||
[Sandstorm Security non-events](https://docs.sandstorm.io/en/latest/using/security-non-events/).
|
||||
For Sandstorm specific security issues you can contact [kentonv](https://github.com/kentonv) by email.
|
||||
For Sandstorm specific security issues you can contact [kentonv](https://github.com/kentonv) by email.
|
||||
|
||||
## What Wekan bugs are eligible?
|
||||
## What WeKan bugs are eligible?
|
||||
|
||||
Any typical web security bugs. If any of the previously mentioned is somehow problematic and
|
||||
a security issue, we'd like to know about it, and also how to fix it:
|
||||
@@ -274,18 +278,12 @@ a security issue, we'd like to know about it, and also how to fix it:
|
||||
- Authentication bypass
|
||||
- Server-side code execution
|
||||
|
||||
## What Wekan bugs are NOT eligible?
|
||||
## What WeKan bugs are NOT eligible?
|
||||
|
||||
Typical already known or "no impact" bugs such as:
|
||||
Typical already-known or 'no impact' bugs such as:
|
||||
|
||||
- [Wekan API old tokens not replaced correctly](https://github.com/wekan/wekan/issues/1437)
|
||||
- Missing Cookie flags on non-session cookies or 3rd party cookies
|
||||
- Logout CSRF
|
||||
- Social engineering
|
||||
- Denial of service
|
||||
- SSL BEAST/CRIME/etc. Wekan does not have SSL built-in, it uses Caddy/Nginx/Apache etc at front.
|
||||
Integrated Caddy support is updated often.
|
||||
- Email spoofing, SPF, DMARC & DKIM. Wekan does not include email server.
|
||||
- SSL BEAST/CRIME/etc. WeKan does not have SSL built-in; it uses Caddy/Nginx/Apache on the front end.
|
||||
|
||||
Wekan is Open Source with MIT license, and free to use also for commercial use.
|
||||
We welcome all fixes to improve security by email to security@wekan.fi
|
||||
WeKan is Open Source with MIT license, and free to use also for commercial use.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v8.74.0"
|
||||
appVersion: "v9.63.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
+94
-2
@@ -1,7 +1,52 @@
|
||||
// PWA
|
||||
// Fix dynamic-import chunk loading when Wekan runs under a sub-path (ROOT_URL includes a pathname,
|
||||
// e.g. https://example.com/wekan). Rspack uses __webpack_public_path__ to prefix chunk URLs; by
|
||||
// default it is set to /build-chunks/ (no context path). We update it here, at module evaluation
|
||||
// time, before any import() expression runs (those are deferred to Meteor.startup callbacks).
|
||||
/* global __webpack_public_path__:writable */
|
||||
try {
|
||||
const _cfg = typeof window !== 'undefined' && window.__meteor_runtime_config__;
|
||||
const _rootUrl = _cfg && _cfg.ROOT_URL;
|
||||
if (_rootUrl) {
|
||||
const _rootPath = new URL(_rootUrl).pathname.replace(/\/+$/, '');
|
||||
if (_rootPath && _rootPath !== '/') {
|
||||
__webpack_public_path__ = _rootPath + '/build-chunks/';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Expose Meteor on window. Under the Meteor 3.5 + rspack build, bare `Meteor`
|
||||
// references in app code are rewritten by rspack's ProvidePlugin into per-module
|
||||
// imports, so `Meteor` is NOT placed on the browser `window` the way the classic
|
||||
// Meteor linker did. Re-expose it so the browser console and the Playwright e2e
|
||||
// tests (which call Meteor.loginWithToken / Meteor.userId / Meteor.subscribe etc.
|
||||
// via page.evaluate in window scope) can reach it. This matches the long-standing
|
||||
// classic-Meteor behaviour where `Meteor` is a global.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.Meteor = Meteor;
|
||||
}
|
||||
|
||||
// Fix bug in jam:offline 0.4.1: s?.message.includes() crashes when s.message is
|
||||
// undefined (e.g. during WebSocket reconnect errors that don't carry a .message).
|
||||
// Wrap _debug so the optional chain is complete: s?.message?.includes().
|
||||
(function patchJamOfflineDebug() {
|
||||
const patched = Meteor._debug;
|
||||
Meteor._debug = function (m, s) {
|
||||
try {
|
||||
return patched.call(this, m, s);
|
||||
} catch (e) {
|
||||
// jam:offline's override threw — fall back to the bare console log.
|
||||
if (typeof console !== 'undefined') {
|
||||
console.log(m, s);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// PWA — use Meteor.absoluteUrl so the path is correct under sub-URL deployments
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/pwa-service-worker.js');
|
||||
const swPath = new URL('pwa-service-worker.js', Meteor.absoluteUrl()).pathname;
|
||||
navigator.serviceWorker.register(swPath);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +71,22 @@ import { Utils } from '/client/lib/utils';
|
||||
// Token already lives in localStorage; cookie adds same-origin send-on-request semantics
|
||||
Meteor.startup(() => {
|
||||
const COOKIE_NAME = 'meteor_login_token';
|
||||
const USER_ID_COOKIE = 'meteor_user_id';
|
||||
const TOKEN_EXPIRES_COOKIE = 'meteor_login_token_expires';
|
||||
|
||||
const getCookie = (name) => {
|
||||
try {
|
||||
const parts = document.cookie ? document.cookie.split(';') : [];
|
||||
for (const part of parts) {
|
||||
const [k, ...rest] = part.trim().split('=');
|
||||
if (decodeURIComponent(k) === name) {
|
||||
return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
};
|
||||
|
||||
const cookieAttrs = () => {
|
||||
const attrs = ['Path=/', 'SameSite=Lax'];
|
||||
try {
|
||||
@@ -53,6 +114,37 @@ Meteor.startup(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrapTokenFromCookie = () => {
|
||||
try {
|
||||
const currentToken = Accounts && typeof Accounts._storedLoginToken === 'function'
|
||||
? Accounts._storedLoginToken()
|
||||
: null;
|
||||
if (currentToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getCookie(COOKIE_NAME);
|
||||
const userId = getCookie(USER_ID_COOKIE);
|
||||
const tokenExpiresRaw = getCookie(TOKEN_EXPIRES_COOKIE);
|
||||
const tokenExpires = tokenExpiresRaw ? new Date(tokenExpiresRaw) : null;
|
||||
|
||||
if (!token || !userId || !tokenExpires || Number.isNaN(tokenExpires.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Accounts && typeof Accounts._storeLoginToken === 'function') {
|
||||
Accounts._storeLoginToken(userId, token, tokenExpires);
|
||||
if (Meteor.status && !Meteor.status().connected && typeof Meteor.reconnect === 'function') {
|
||||
Meteor.reconnect();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
bootstrapTokenFromCookie();
|
||||
|
||||
// Initial sync on startup
|
||||
syncCookie();
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
margin-left: 0.4vw;
|
||||
margin-inline-start: 0.4vw;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -258,14 +258,18 @@ Template.activity.helpers({
|
||||
|
||||
attachmentLink() {
|
||||
const attachment = this.activity.attachment();
|
||||
const attachmentUrl = attachment && typeof attachment.link === 'function'
|
||||
? attachment.link()
|
||||
: '';
|
||||
// trying to display url before file is stored generates js errors
|
||||
return (
|
||||
(attachment &&
|
||||
attachment.path &&
|
||||
attachmentUrl &&
|
||||
Blaze.toHTML(
|
||||
HTML.A(
|
||||
{
|
||||
href: `${attachment.link()}?download=true`,
|
||||
href: `${attachmentUrl}?download=true`,
|
||||
target: '_blank',
|
||||
},
|
||||
sanitizeText(attachment.name),
|
||||
@@ -290,7 +294,8 @@ Template.activity.helpers({
|
||||
|
||||
Template.commentReactions.events({
|
||||
'click .reaction'(event) {
|
||||
if (ReactiveCache.getCurrentUser().isBoardMember()) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user && user.isBoardMember()) {
|
||||
const codepoint = event.currentTarget.dataset['codepoint'];
|
||||
const commentId = Template.instance().data.commentId;
|
||||
const cardComment = ReactiveCache.getCardComment(commentId);
|
||||
@@ -302,7 +307,8 @@ Template.commentReactions.events({
|
||||
|
||||
Template.addReactionPopup.events({
|
||||
'click .add-comment-reaction'(event) {
|
||||
if (ReactiveCache.getCurrentUser().isBoardMember()) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user && user.isBoardMember()) {
|
||||
const codepoint = event.currentTarget.dataset['codepoint'];
|
||||
const commentId = Template.instance().data.commentId;
|
||||
const cardComment = ReactiveCache.getCardComment(commentId);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
opacity: 0.7;
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: -38px;
|
||||
inset-inline-start: -38px;
|
||||
}
|
||||
.new-comment.is-open .member {
|
||||
opacity: 1;
|
||||
@@ -84,7 +84,7 @@
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
margin-left: 3px;
|
||||
margin-inline-start: 3px;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -113,7 +113,7 @@
|
||||
font-size: clamp(14px, 2vw, 18px);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
.comments .comment .comment-desc .reactions .reaction {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -70,7 +70,7 @@ Template.comment.events({
|
||||
evt.preventDefault();
|
||||
const textarea = tpl.find('.js-edit-comment textarea,input[type=text]');
|
||||
const commentText = textarea && textarea.value ? textarea.value.trim() : '';
|
||||
const commentId = this._id;
|
||||
const commentId = tpl.data._id;
|
||||
if (commentText) {
|
||||
CardComments.update(commentId, {
|
||||
$set: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.board-conversion-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
.board-conversion-header h3 i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
@@ -127,7 +127,7 @@
|
||||
}
|
||||
|
||||
.conversion-status i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
}
|
||||
|
||||
.conversion-time i {
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
}
|
||||
|
||||
.conversion-info i {
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
color: #2196F3;
|
||||
}
|
||||
|
||||
@@ -174,8 +174,8 @@
|
||||
.board-conversion-header,
|
||||
.board-conversion-content,
|
||||
.board-conversion-footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
|
||||
.board-conversion-header h3 {
|
||||
|
||||
@@ -8,10 +8,10 @@ template(name="archivedBoards")
|
||||
each archivedBoards
|
||||
li.archived-lists-item
|
||||
div.board-header-btns
|
||||
button.board-header-btn.js-delete-board
|
||||
button.primary.board-header-btn.js-delete-board
|
||||
i.fa.fa-trash
|
||||
| {{_ 'delete-board'}}
|
||||
button.board-header-btn.js-restore-board
|
||||
button.primary.board-header-btn.js-restore-board
|
||||
i.fa.fa-undo
|
||||
| {{_ 'restore-board'}}
|
||||
= title
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.board-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow-x: hidden;
|
||||
@@ -30,8 +30,8 @@
|
||||
}
|
||||
.board-wrapper .board-canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
transition: margin 0.1s;
|
||||
@@ -276,12 +276,12 @@ body.mobile-mode.iphone-device .card-details .card-details-item-title {
|
||||
}
|
||||
.board-wrapper .board-canvas .board-overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
top: -100px;
|
||||
right: -400px;
|
||||
inset-inline-end: -400px;
|
||||
background: #000;
|
||||
opacity: 0.33;
|
||||
animation: fadeIn 0.2s;
|
||||
@@ -325,8 +325,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -335,8 +335,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -360,8 +360,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -370,8 +370,8 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
min-width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
@@ -560,7 +560,7 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
inset-inline-end: 5px;
|
||||
font-size: 25px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -21,16 +21,17 @@ template(name="boardBody")
|
||||
else
|
||||
// Debug information (remove in production)
|
||||
if debugBoardState
|
||||
.debug-info(style="position: fixed; top: 0; left: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
|
||||
.debug-info(style="position: fixed; top: 0; inset-inline-start: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
|
||||
| {{_ 'board'}}: {{currentBoard.title}} | {{_ 'view'}}: {{boardView}} | {{_ 'has-swimlanes'}}: {{hasSwimlanes}} | {{_ 'swimlanes'}}: {{currentBoard.swimlanes.length}}
|
||||
.board-wrapper(class=currentBoard.colorClass class="{{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
.board-canvas.js-swimlanes(
|
||||
class="{{#if hasSwimlanes}}dragscroll{{/if}}"
|
||||
.board-canvas.js-swimlanes.dragscroll(
|
||||
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
|
||||
class="{{#if draggingActive}}is-dragging-active{{/if}}"
|
||||
class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}"
|
||||
class="{{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
if currentBoard.showDependencies
|
||||
+dependencyOverlay
|
||||
if showOverlay
|
||||
.board-overlay
|
||||
if currentBoard.isTemplatesBoard
|
||||
@@ -49,6 +50,8 @@ template(name="boardBody")
|
||||
+calendarView
|
||||
else if isViewGantt
|
||||
+ganttView
|
||||
else if isViewTable
|
||||
+tableView
|
||||
else
|
||||
// Default view - show swimlanes if they exist, otherwise show lists
|
||||
if hasSwimlanes
|
||||
@@ -64,7 +67,7 @@ template(name="boardBody")
|
||||
|
||||
template(name="calendarView")
|
||||
if isViewCalendar
|
||||
.calendar-view.swimlane
|
||||
.calendar-view.swimlane.dragscroll
|
||||
if currentCard
|
||||
+cardDetails(currentCard)
|
||||
+fullcalendar(calendarOptions)
|
||||
@@ -2,6 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import dragscroll from '@wekanteam/dragscroll';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
import { boardConverter } from '/client/lib/boardConverter';
|
||||
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
|
||||
import Swimlanes from '/models/swimlanes';
|
||||
@@ -19,23 +20,26 @@ let BoardBody = null;
|
||||
|
||||
Template.board.onCreated(function () {
|
||||
this.isBoardReady = new ReactiveVar(false);
|
||||
// Kept for the template/jade which still references it; conversion is a no-op.
|
||||
this.isConverting = new ReactiveVar(false);
|
||||
this._swimlaneCreated = new Set(); // Track boards where we've created swimlanes
|
||||
this._boardProcessed = false; // Track if board has been processed
|
||||
this._lastProcessedBoardId = null; // Track last processed board ID
|
||||
this._swimlaneCreated = new Set(); // boards where a default swimlane was ensured
|
||||
|
||||
// Ensure a board has at least one swimlane. Guarded to run once per board and
|
||||
// fire-and-forget so it never blocks rendering. The board is marked
|
||||
// immediately to avoid duplicate concurrent calls, and the mark is rolled
|
||||
// back on failure so a later attempt can retry.
|
||||
this.ensureDefaultSwimlane = async (boardId) => {
|
||||
// Only create swimlane once per board
|
||||
if (this._swimlaneCreated.has(boardId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._swimlaneCreated.add(boardId);
|
||||
try {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
if (!board) return;
|
||||
|
||||
const swimlanes = board.swimlanes();
|
||||
if (swimlanes.length === 0) {
|
||||
if (!board) {
|
||||
this._swimlaneCreated.delete(boardId);
|
||||
return;
|
||||
}
|
||||
if (board.swimlanes().length === 0) {
|
||||
const swimlaneId = await Meteor.callAsync('ensureDefaultSwimlane', boardId);
|
||||
if (process.env.DEBUG === 'true' && swimlaneId) {
|
||||
console.log(
|
||||
@@ -43,67 +47,41 @@ Template.board.onCreated(function () {
|
||||
);
|
||||
}
|
||||
}
|
||||
this._swimlaneCreated.add(boardId);
|
||||
} catch (error) {
|
||||
this._swimlaneCreated.delete(boardId); // allow a later retry
|
||||
console.error('Error creating default swimlane:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.checkAndConvertBoard = async (boardId) => {
|
||||
try {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
this.isBoardReady.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isBoardReady.set(true);
|
||||
} catch (error) {
|
||||
console.error('Error during board conversion check:', error);
|
||||
this.isConverting.set(false);
|
||||
this.isBoardReady.set(true); // Show board even if conversion check failed
|
||||
}
|
||||
};
|
||||
|
||||
// The pattern we use to manually handle data loading is described here:
|
||||
// https://kadira.io/academy/meteor-routing-guide/content/subscriptions-and-data-management/using-subs-manager
|
||||
// XXX The boardId should be readed from some sort the component "props",
|
||||
// unfortunatly, Blaze doesn't have this notion.
|
||||
// Single reactive computation that subscribes to the current board and tracks
|
||||
// its ready state in one place. Previously a Tracker.autorun was created
|
||||
// *inside* this autorun on every re-run without being stopped, leaking
|
||||
// computations that raced on shared ready-state flags. When navigating
|
||||
// between boards — e.g. opening a freshly created board — that race could
|
||||
// leave swimlanes/lists unrendered until the board was re-selected. A single
|
||||
// self-managed autorun (auto-stopped by Blaze on destroy) avoids both the
|
||||
// leak and the race.
|
||||
// Pattern: https://kadira.io/academy/meteor-routing-guide/content/subscriptions-and-data-management/using-subs-manager
|
||||
this.autorun(() => {
|
||||
const currentBoardId = Session.get('currentBoard');
|
||||
if (!currentBoardId) return;
|
||||
if (!currentBoardId) {
|
||||
this.isBoardReady.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = Meteor.subscribe('board', currentBoardId, false);
|
||||
const ready = handle.ready();
|
||||
this.isBoardReady.set(ready);
|
||||
|
||||
// Use a separate autorun for subscription ready state to avoid reactive loops
|
||||
this.subscriptionReadyAutorun = Tracker.autorun(() => {
|
||||
if (handle.ready()) {
|
||||
if (
|
||||
!this._boardProcessed ||
|
||||
this._lastProcessedBoardId !== currentBoardId
|
||||
) {
|
||||
this._boardProcessed = true;
|
||||
this._lastProcessedBoardId = currentBoardId;
|
||||
|
||||
// Ensure default swimlane exists (only once per board)
|
||||
this.ensureDefaultSwimlane(currentBoardId);
|
||||
// Check if board needs conversion
|
||||
this.checkAndConvertBoard(currentBoardId);
|
||||
}
|
||||
} else {
|
||||
this.isBoardReady.set(false);
|
||||
}
|
||||
});
|
||||
if (ready) {
|
||||
// Run outside the computation so this autorun depends only on the board
|
||||
// id and the subscription's ready state — not on the swimlane data that
|
||||
// ensureDefaultSwimlane reads — which would otherwise cause extra re-runs.
|
||||
Tracker.nonreactive(() => this.ensureDefaultSwimlane(currentBoardId));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Template.board.onDestroyed(function () {
|
||||
// Clean up the subscription ready autorun to prevent memory leaks
|
||||
if (this.subscriptionReadyAutorun) {
|
||||
this.subscriptionReadyAutorun.stop();
|
||||
}
|
||||
});
|
||||
|
||||
Template.board.helpers({
|
||||
onlyShowCurrentCard() {
|
||||
const isMiniScreen = Utils.isMiniScreen();
|
||||
@@ -142,6 +120,7 @@ Template.board.helpers({
|
||||
|
||||
Template.boardBody.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
Meteor.subscribe('inviteToBoardRolesSettings');
|
||||
this.showOverlay = new ReactiveVar(false);
|
||||
this.draggingActive = new ReactiveVar(false);
|
||||
this._isDragging = false;
|
||||
@@ -374,8 +353,10 @@ Template.boardBody.onRendered(function () {
|
||||
el.setAttribute('tabindex', '0');
|
||||
el.setAttribute('role', 'button');
|
||||
});
|
||||
// Set the language attribute on the <html> element for accessibility
|
||||
// Set the language and direction attributes on the <html> element for
|
||||
// accessibility and RTL layout.
|
||||
document.documentElement.lang = TAPi18n.getLanguage();
|
||||
document.documentElement.dir = TAPi18n.getLanguageDirection();
|
||||
|
||||
// Ensure the accessible name for the board view switcher matches the visible label "Swimlanes"
|
||||
// This fixes WCAG 2.5.3: Label in Name
|
||||
@@ -606,6 +587,17 @@ Template.boardBody.onDestroyed(function () {
|
||||
});
|
||||
|
||||
Template.boardBody.helpers({
|
||||
openCards() {
|
||||
const isMobile = Utils.getMobileMode();
|
||||
if (!isMobile) {
|
||||
const openCardIds = Session.get('openCards') || [];
|
||||
return openCardIds
|
||||
.map((id) => ReactiveCache.getCard(id))
|
||||
.filter((card) => card);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
|
||||
draggingActive() {
|
||||
return Template.instance().draggingActive.get();
|
||||
},
|
||||
@@ -682,6 +674,19 @@ Template.boardBody.helpers({
|
||||
return boardView === 'board-view-gantt';
|
||||
},
|
||||
|
||||
isViewTable() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
let boardView;
|
||||
|
||||
if (currentUser) {
|
||||
boardView = (currentUser.profile || {}).boardView;
|
||||
} else {
|
||||
boardView = window.localStorage.getItem('boardView');
|
||||
}
|
||||
|
||||
return boardView === 'board-view-table';
|
||||
},
|
||||
|
||||
hasSwimlanes() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (!currentBoard) {
|
||||
@@ -917,8 +922,10 @@ document.addEventListener('keydown', function (e) {
|
||||
});
|
||||
|
||||
Template.calendarView.onRendered(function () {
|
||||
// Set the language attribute on the <html> element for accessibility
|
||||
// Set the language and direction attributes on the <html> element for
|
||||
// accessibility and RTL layout.
|
||||
document.documentElement.lang = TAPi18n.getLanguage();
|
||||
document.documentElement.dir = TAPi18n.getLanguageDirection();
|
||||
|
||||
this.autorun(function () {
|
||||
const calendarEl = document.getElementById('calendar-view');
|
||||
@@ -981,6 +988,7 @@ Template.calendarView.helpers({
|
||||
end: '18:00',
|
||||
},
|
||||
locale: TAPi18n.getLanguage(),
|
||||
isRTL: TAPi18n.isRTL(),
|
||||
events(fetchInfo, callback) {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const events = [];
|
||||
|
||||
@@ -9,7 +9,7 @@ THEME - NEPHRITIS
|
||||
background-color: #27ae60;
|
||||
}
|
||||
.board-color-nephritis .is-selected .minicard {
|
||||
border-left: 3px solid #27ae60;
|
||||
border-inline-start: 3px solid #27ae60;
|
||||
}
|
||||
.board-color-nephritis .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -47,7 +47,7 @@ THEME - NEPHRITIS
|
||||
}
|
||||
.board-color-nephritis .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #27ae60;
|
||||
border-right: 2px solid #27ae60;
|
||||
border-inline-end: 2px solid #27ae60;
|
||||
}
|
||||
.board-color-nephritis .checklist-progress-bar {
|
||||
background-color: #d4f1dd !important;
|
||||
@@ -95,7 +95,7 @@ THEME - NEPHRITIS
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-nephritis .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ THEME - Pomegranate
|
||||
background-color: #c0392b;
|
||||
}
|
||||
.board-color-pomegranate .is-selected .minicard {
|
||||
border-left: 3px solid #c0392b;
|
||||
border-inline-start: 3px solid #c0392b;
|
||||
}
|
||||
.board-color-pomegranate .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -154,7 +154,7 @@ THEME - Pomegranate
|
||||
}
|
||||
.board-color-pomegranate .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #c0392b;
|
||||
border-right: 2px solid #c0392b;
|
||||
border-inline-end: 2px solid #c0392b;
|
||||
}
|
||||
.board-color-pomegranate .checklist-progress-bar {
|
||||
background-color: #f5d5d2 !important;
|
||||
@@ -202,7 +202,7 @@ THEME - Pomegranate
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-pomegranate .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ THEME - Belize
|
||||
background-color: #2980b9;
|
||||
}
|
||||
.board-color-belize .is-selected .minicard {
|
||||
border-left: 3px solid #2980b9;
|
||||
border-inline-start: 3px solid #2980b9;
|
||||
}
|
||||
.board-color-belize .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -261,7 +261,7 @@ THEME - Belize
|
||||
}
|
||||
.board-color-belize .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2980b9;
|
||||
border-right: 2px solid #2980b9;
|
||||
border-inline-end: 2px solid #2980b9;
|
||||
}
|
||||
.board-color-belize .checklist-progress-bar {
|
||||
background-color: #d1e7f5 !important;
|
||||
@@ -309,7 +309,7 @@ THEME - Belize
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-belize .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ THEME - Wisteria
|
||||
background-color: #8e44ad;
|
||||
}
|
||||
.board-color-wisteria .is-selected .minicard {
|
||||
border-left: 3px solid #8e44ad;
|
||||
border-inline-start: 3px solid #8e44ad;
|
||||
}
|
||||
.board-color-wisteria .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -368,7 +368,7 @@ THEME - Wisteria
|
||||
}
|
||||
.board-color-wisteria .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #8e44ad;
|
||||
border-right: 2px solid #8e44ad;
|
||||
border-inline-end: 2px solid #8e44ad;
|
||||
}
|
||||
.board-color-wisteria .checklist-progress-bar {
|
||||
background-color: #e8d9f0 !important;
|
||||
@@ -416,7 +416,7 @@ THEME - Wisteria
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-wisteria .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@ THEME - Midnight
|
||||
background-color: #2c3e50;
|
||||
}
|
||||
.board-color-midnight .is-selected .minicard {
|
||||
border-left: 3px solid #2c3e50;
|
||||
border-inline-start: 3px solid #2c3e50;
|
||||
}
|
||||
.board-color-midnight .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -475,7 +475,7 @@ THEME - Midnight
|
||||
}
|
||||
.board-color-midnight .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2c3e50;
|
||||
border-right: 2px solid #2c3e50;
|
||||
border-inline-end: 2px solid #2c3e50;
|
||||
}
|
||||
.board-color-midnight .checklist-progress-bar {
|
||||
background-color: #d2dae2 !important;
|
||||
@@ -523,7 +523,7 @@ THEME - Midnight
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-midnight .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -544,7 +544,7 @@ THEME - Pumpkin
|
||||
background-color: #e67e22;
|
||||
}
|
||||
.board-color-pumpkin .is-selected .minicard {
|
||||
border-left: 3px solid #e67e22;
|
||||
border-inline-start: 3px solid #e67e22;
|
||||
}
|
||||
.board-color-pumpkin .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -582,7 +582,7 @@ THEME - Pumpkin
|
||||
}
|
||||
.board-color-pumpkin .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #e67e22;
|
||||
border-right: 2px solid #e67e22;
|
||||
border-inline-end: 2px solid #e67e22;
|
||||
}
|
||||
.board-color-pumpkin .checklist-progress-bar {
|
||||
background-color: #f9e5d1 !important;
|
||||
@@ -630,7 +630,7 @@ THEME - Pumpkin
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-pumpkin .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -651,7 +651,7 @@ THEME - Moderate Pink
|
||||
background-color: #cd5a91;
|
||||
}
|
||||
.board-color-moderatepink .is-selected .minicard {
|
||||
border-left: 3px solid #cd5a91;
|
||||
border-inline-start: 3px solid #cd5a91;
|
||||
}
|
||||
.board-color-moderatepink .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -689,7 +689,7 @@ THEME - Moderate Pink
|
||||
}
|
||||
.board-color-moderatepink .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #cd5a91;
|
||||
border-right: 2px solid #cd5a91;
|
||||
border-inline-end: 2px solid #cd5a91;
|
||||
}
|
||||
.board-color-moderatepink .checklist-progress-bar {
|
||||
background-color: #f4dde8 !important;
|
||||
@@ -737,7 +737,7 @@ THEME - Moderate Pink
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-moderatepink .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -758,7 +758,7 @@ THEME - Strong Cyan
|
||||
background-color: #00aecc;
|
||||
}
|
||||
.board-color-strongcyan .is-selected .minicard {
|
||||
border-left: 3px solid #00aecc;
|
||||
border-inline-start: 3px solid #00aecc;
|
||||
}
|
||||
.board-color-strongcyan .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -796,7 +796,7 @@ THEME - Strong Cyan
|
||||
}
|
||||
.board-color-strongcyan .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #00aecc;
|
||||
border-right: 2px solid #00aecc;
|
||||
border-inline-end: 2px solid #00aecc;
|
||||
}
|
||||
.board-color-strongcyan .checklist-progress-bar {
|
||||
background-color: #ccf2f9 !important;
|
||||
@@ -844,7 +844,7 @@ THEME - Strong Cyan
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-strongcyan .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -865,7 +865,7 @@ THEME - Lime Green
|
||||
background-color: #4bbf6b;
|
||||
}
|
||||
.board-color-limegreen .is-selected .minicard {
|
||||
border-left: 3px solid #4bbf6b;
|
||||
border-inline-start: 3px solid #4bbf6b;
|
||||
}
|
||||
.board-color-limegreen .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -903,7 +903,7 @@ THEME - Lime Green
|
||||
}
|
||||
.board-color-limegreen .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #4bbf6b;
|
||||
border-right: 2px solid #4bbf6b;
|
||||
border-inline-end: 2px solid #4bbf6b;
|
||||
}
|
||||
.board-color-limegreen .checklist-progress-bar {
|
||||
background-color: #daf4de !important;
|
||||
@@ -951,7 +951,7 @@ THEME - Lime Green
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-limegreen .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -972,7 +972,7 @@ THEME - Dark
|
||||
background-color: #2c3e51;
|
||||
}
|
||||
.board-color-dark .is-selected .minicard {
|
||||
border-left: 3px solid #2c3e51;
|
||||
border-inline-start: 3px solid #2c3e51;
|
||||
}
|
||||
.board-color-dark .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1011,7 +1011,7 @@ THEME - Dark
|
||||
}
|
||||
.board-color-dark .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2c3e51;
|
||||
border-right: 2px solid #2c3e51;
|
||||
border-inline-end: 2px solid #2c3e51;
|
||||
}
|
||||
.board-color-dark .checklist-progress-bar {
|
||||
background-color: #d2dae2 !important;
|
||||
@@ -1161,7 +1161,7 @@ THEME - Dark
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-dark .list {
|
||||
border-left: none; /* Remove this property to bring back lines in-between columns if needed*/
|
||||
border-inline-start: none; /* Remove this property to bring back lines in-between columns if needed*/
|
||||
padding: 0px 1px 8px 1px; /* Improves spacing between columns due to no borders, 8px padding at bottom to separate horizontal scrollbar/lists */
|
||||
}
|
||||
|
||||
@@ -1182,7 +1182,7 @@ THEME - Relax
|
||||
background-color: #27ae61;
|
||||
}
|
||||
.board-color-relax .is-selected .minicard {
|
||||
border-left: 3px solid #27ae61;
|
||||
border-inline-start: 3px solid #27ae61;
|
||||
}
|
||||
.board-color-relax .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1220,7 +1220,7 @@ THEME - Relax
|
||||
}
|
||||
.board-color-relax .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #27ae61;
|
||||
border-right: 2px solid #27ae61;
|
||||
border-inline-end: 2px solid #27ae61;
|
||||
}
|
||||
.board-color-relax .checklist-progress-bar {
|
||||
background-color: #d4f1dd !important;
|
||||
@@ -1263,7 +1263,7 @@ THEME - Relax
|
||||
background-color: #a7e366;
|
||||
}
|
||||
.board-color-relax .list {
|
||||
border-left: 1px dotted #000;
|
||||
border-inline-start: 1px dotted #000;
|
||||
}
|
||||
.board-color-relax .card-details .card-details-items ~ .js-open-inlined-form .viewer {
|
||||
background-color: #fff !important;
|
||||
@@ -1297,7 +1297,7 @@ THEME - Relax
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-relax .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
/* padding-bottom: 8px; - Removed to get rid of grey bars for relax theme */
|
||||
}
|
||||
|
||||
@@ -1318,7 +1318,7 @@ THEME - Corteza
|
||||
background-color: #568ba2;
|
||||
}
|
||||
.board-color-corteza .is-selected .minicard {
|
||||
border-left: 3px solid #568ba2;
|
||||
border-inline-start: 3px solid #568ba2;
|
||||
}
|
||||
.board-color-corteza .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1356,7 +1356,7 @@ THEME - Corteza
|
||||
}
|
||||
.board-color-corteza .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #568ba2;
|
||||
border-right: 2px solid #568ba2;
|
||||
border-inline-end: 2px solid #568ba2;
|
||||
}
|
||||
.board-color-corteza .checklist-progress-bar {
|
||||
background-color: #dce6ec !important;
|
||||
@@ -1404,7 +1404,7 @@ THEME - Corteza
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-corteza .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1424,7 +1424,7 @@ THEME - Clear Blue
|
||||
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
|
||||
}
|
||||
.board-color-clearblue .is-selected .minicard {
|
||||
border-left: 3px solid #499bea;
|
||||
border-inline-start: 3px solid #499bea;
|
||||
}
|
||||
.board-color-clearblue.pop-over .pop-over-list li a:not(.disabled):hover,
|
||||
.board-color-clearblue .sidebar .sidebar-content .sidebar-btn:hover,
|
||||
@@ -1467,7 +1467,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #499bea;
|
||||
border-right: 2px solid #499bea;
|
||||
border-inline-end: 2px solid #499bea;
|
||||
}
|
||||
.board-color-clearblue .checklist-progress-bar {
|
||||
background-color: #daeefb !important;
|
||||
@@ -1495,8 +1495,8 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .swimlane .list:first-child {
|
||||
min-width: 20px;
|
||||
margin-left: 10px; /* Added 10px margin left to stop lists being butted up against the edge of the screen */
|
||||
border-left: none;
|
||||
margin-inline-start: 10px; /* Added 10px margin left to stop lists being butted up against the edge of the screen */
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-clearblue .swimlane .list:nth-child {
|
||||
flex: 0 0 265px;
|
||||
@@ -1513,8 +1513,8 @@ THEME - Clear Blue
|
||||
flex: unset;
|
||||
padding-bottom: 16px;
|
||||
min-width: 20px;
|
||||
margin-left: 0px;
|
||||
border-left: none;
|
||||
margin-inline-start: 0px;
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-clearblue .list.list-composer .open-list-composer {
|
||||
border-radius: 7px;
|
||||
@@ -1550,7 +1550,7 @@ THEME - Clear Blue
|
||||
.board-color-clearblue .card-details {
|
||||
border-radius: 0 0 14px 14px;
|
||||
box-shadow: 0 0 7px 0 rgba(0,0,0,0.5);
|
||||
margin-left: -10px;
|
||||
margin-inline-start: -10px;
|
||||
}
|
||||
.board-color-clearblue .list-body .open-minicard-composer {
|
||||
border-radius: 7px;
|
||||
@@ -1584,7 +1584,7 @@ THEME - Clear Blue
|
||||
color: rgba(0,0,0,0.5);
|
||||
position: absolute;
|
||||
margin-top: -34px;
|
||||
margin-left: -11px;
|
||||
margin-inline-start: -11px;
|
||||
}
|
||||
.board-color-clearblue a.fa,
|
||||
.board-color-clearblue a i.fa {
|
||||
@@ -1605,7 +1605,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-left: 9px solid #00aecc;
|
||||
border-inline-start: 9px solid #00aecc;
|
||||
}
|
||||
.board-color-clearblue .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -1646,7 +1646,7 @@ THEME - Clear Blue
|
||||
vertical-align: middle;
|
||||
}
|
||||
.board-color-clearblue .swimlane-header-wrap .primary.confirm {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.board-color-clearblue .swimlane-header-wrap .fa.fa-times-thin {
|
||||
margin-top: 2px;
|
||||
@@ -1677,7 +1677,7 @@ THEME - Clear Blue
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-clearblue .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1698,7 +1698,7 @@ THEME - Natural
|
||||
background-color: #596557;
|
||||
}
|
||||
.board-color-natural .is-selected .minicard {
|
||||
border-left: 3px solid #596557;
|
||||
border-inline-start: 3px solid #596557;
|
||||
}
|
||||
.board-color-natural .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1736,7 +1736,7 @@ THEME - Natural
|
||||
}
|
||||
.board-color-natural .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #596557;
|
||||
border-right: 2px solid #596557;
|
||||
border-inline-end: 2px solid #596557;
|
||||
}
|
||||
.board-color-natural .checklist-progress-bar {
|
||||
background-color: #dee0dd !important;
|
||||
@@ -1793,7 +1793,7 @@ THEME - Natural
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-natural .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -1814,7 +1814,7 @@ THEME - Modern
|
||||
background-color: #2a80b8;
|
||||
}
|
||||
.board-color-modern .is-selected .minicard {
|
||||
border-left: 3px solid #2a80b8;
|
||||
border-inline-start: 3px solid #2a80b8;
|
||||
}
|
||||
.board-color-modern .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1852,7 +1852,7 @@ THEME - Modern
|
||||
}
|
||||
.board-color-modern .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2a80b8;
|
||||
border-right: 2px solid #2a80b8;
|
||||
border-inline-end: 2px solid #2a80b8;
|
||||
}
|
||||
.board-color-modern .checklist-progress-bar {
|
||||
background-color: #d1e7f5 !important;
|
||||
@@ -1904,7 +1904,7 @@ THEME - Modern
|
||||
display: none;
|
||||
}
|
||||
.board-color-modern#header-quick-access ul li:nth-child(3) {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.board-color-modern#header-quick-access ul li a {
|
||||
padding: 5px 10px;
|
||||
@@ -1952,7 +1952,7 @@ THEME - Modern
|
||||
}
|
||||
.board-color-modern .list {
|
||||
background: transparent;
|
||||
border-left: 0;
|
||||
border-inline-start: 0;
|
||||
margin: 10px 0;
|
||||
padding: 0px;
|
||||
border-radius: 5px;
|
||||
@@ -1964,8 +1964,8 @@ THEME - Modern
|
||||
}
|
||||
.board-color-modern .swimlane .list:first-child {
|
||||
min-width: 20px;
|
||||
margin-left: 0px;
|
||||
border-left: none;
|
||||
margin-inline-start: 0px;
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-modern .swimlane .list:nth-child {
|
||||
flex: 0 0 265px;
|
||||
@@ -2033,7 +2033,7 @@ THEME - Modern
|
||||
.board-color-modern .pop-over .header .close-btn {
|
||||
font-size: 20px;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
.board-color-modern .pop-over .content-container .content {
|
||||
padding: 5px 20px 20px;
|
||||
@@ -2043,7 +2043,7 @@ THEME - Modern
|
||||
border-radius: 5px;
|
||||
}
|
||||
.board-color-modern .pop-over-list li > a > i {
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
.board-color-modern .pop-over-list li>a .sub-name {
|
||||
margin-bottom: 8px;
|
||||
@@ -2086,7 +2086,7 @@ THEME - Modern
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-modern .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -2107,7 +2107,7 @@ THEME - Modern Dark
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
.board-color-moderndark .is-selected .minicard {
|
||||
border-left: 3px solid #2a2a2a;
|
||||
border-inline-start: 3px solid #2a2a2a;
|
||||
}
|
||||
.board-color-moderndark .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -2150,7 +2150,7 @@ THEME - Modern Dark
|
||||
}
|
||||
.board-color-moderndark .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2a2a2a;
|
||||
border-right: 2px solid #2a2a2a;
|
||||
border-inline-end: 2px solid #2a2a2a;
|
||||
}
|
||||
.board-color-moderndark .checklist-progress-bar {
|
||||
background-color: #d1d1d1 !important;
|
||||
@@ -2250,7 +2250,7 @@ THEME - Modern Dark
|
||||
display: none;
|
||||
}
|
||||
.board-color-moderndark#header-quick-access ul li:nth-child(3) {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.board-color-moderndark#header-quick-access ul li a {
|
||||
padding: 5px 10px;
|
||||
@@ -2293,8 +2293,8 @@ THEME - Modern Dark
|
||||
.board-color-moderndark .swimlane .list:first-child {
|
||||
color: #eee;
|
||||
min-width: 20px;
|
||||
margin-left: 0px;
|
||||
border-left: none;
|
||||
margin-inline-start: 0px;
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-moderndark .swimlane .list-composer .list-header-add .inlined-form .edit-controls .quiet,
|
||||
.board-color-moderndark .swimlane .list-composer .list-header-add .inlined-form .edit-controls .quiet a.js-list-template {
|
||||
@@ -2315,7 +2315,7 @@ THEME - Modern Dark
|
||||
background: #6a6a6a;
|
||||
}
|
||||
.board-color-moderndark .list-header .viewer {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.board-color-moderndark .list-header .list-header-name,
|
||||
.board-color-moderndark .minicard {
|
||||
@@ -2333,7 +2333,7 @@ THEME - Modern Dark
|
||||
}
|
||||
@media screen and (min-width: 801px) {
|
||||
.board-color-moderndark .list-header .list-header-name {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.board-color-moderndark .list-header .list-header-menu {
|
||||
padding: 0 10px 10px;
|
||||
@@ -2445,7 +2445,7 @@ THEME - Modern Dark
|
||||
padding: 0px 20px;
|
||||
}
|
||||
.board-color-moderndark .card-details .card-details-header .card-details-menu-mobile-web {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.board-color-moderndark .pop-over > .content-wrapper > .popup-container-depth-0 > .content {
|
||||
width: calc(100% - 20px);
|
||||
@@ -2455,7 +2455,7 @@ THEME - Modern Dark
|
||||
.board-color-moderndark .card-details {
|
||||
position: fixed;
|
||||
top: 82px;
|
||||
left: calc(50% - 384px);
|
||||
inset-inline-start: calc(50% - 384px);
|
||||
width: 768px;
|
||||
max-height: calc(100% - 60px);
|
||||
}
|
||||
@@ -2513,9 +2513,9 @@ THEME - Modern Dark
|
||||
}
|
||||
.board-color-moderndark .card-details .check-box.materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #fff;
|
||||
border-right: 2px solid #fff;
|
||||
border-inline-end: 2px solid #fff;
|
||||
border-top: 0;
|
||||
border-left: 0;
|
||||
border-inline-start: 0;
|
||||
}
|
||||
.board-color-moderndark .card-details .js-add-checklist-item {
|
||||
margin-top: 4px;
|
||||
@@ -2530,7 +2530,7 @@ THEME - Modern Dark
|
||||
.board-color-moderndark .sidebar {
|
||||
background-color: #222;
|
||||
box-shadow: -10px 0 5px -10px #444;
|
||||
border-left: 1px solid #333;
|
||||
border-inline-start: 1px solid #333;
|
||||
color: #ccc;
|
||||
}
|
||||
.board-color-moderndark .activities .activity .activity-desc .activity-comment {
|
||||
@@ -2581,7 +2581,7 @@ THEME - Modern Dark
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-moderndark .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -2603,7 +2603,7 @@ THEME - Exodark
|
||||
background-color: #222;
|
||||
}
|
||||
.board-color-exodark .is-selected .minicard {
|
||||
border-left: 3px solid #222;
|
||||
border-inline-start: 3px solid #222;
|
||||
}
|
||||
.board-color-exodark .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -2641,7 +2641,7 @@ THEME - Exodark
|
||||
}
|
||||
.board-color-exodark .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #dbdbdb!important;/*Fix contrast of checkbox*/
|
||||
border-right: 2px solid #dbdbdb!important;
|
||||
border-inline-end: 2px solid #dbdbdb!important;
|
||||
}
|
||||
.board-color-exodark .checklist-progress-bar {
|
||||
background-color: #cccccc !important;
|
||||
@@ -2698,8 +2698,8 @@ THEME - Exodark
|
||||
}
|
||||
.board-color-exodark .swimlane .list:first-child {
|
||||
min-width: 20px;
|
||||
margin-left: 10px; /*Added 10px margin to prevent butting up against edge of screen */
|
||||
border-left: none;
|
||||
margin-inline-start: 10px; /*Added 10px margin to prevent butting up against edge of screen */
|
||||
border-inline-start: none;
|
||||
}
|
||||
.board-color-exodark .swimlane .list:nth-child {
|
||||
flex: 0 0 265px;
|
||||
@@ -2960,10 +2960,10 @@ THEME - Clean Dark
|
||||
background: #009B64;
|
||||
width: min-content;
|
||||
color: #FFFFFF;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
padding-inline-start: 8px;
|
||||
padding-inline-end: 8px;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.board-color-cleandark .card-details hr {
|
||||
@@ -3203,7 +3203,7 @@ THEME - Clean Dark
|
||||
|
||||
.board-color-cleandark .card-details .check-box.materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #FFFFFF;
|
||||
border-right: 2px solid #FFFFFF;
|
||||
border-inline-end: 2px solid #FFFFFF;
|
||||
width: 11px;
|
||||
height: 19px;
|
||||
border-radius: 0;
|
||||
@@ -3234,12 +3234,12 @@ THEME - Clean Dark
|
||||
|
||||
.board-color-cleandark #cards .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #FFFFFF;
|
||||
border-right: 2px solid #FFFFFF;
|
||||
border-inline-end: 2px solid #FFFFFF;
|
||||
width: 5px;
|
||||
height: 13px;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
margin-left: 3px;
|
||||
margin-inline-start: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.board-color-cleandark .checklist-progress-bar {
|
||||
@@ -3258,7 +3258,7 @@ THEME - Clean Dark
|
||||
align-items: center;
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.board-color-cleandark#header-quick-access ul.header-quick-access-list {
|
||||
@@ -3280,7 +3280,7 @@ THEME - Clean Dark
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-cleandark .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -3370,18 +3370,19 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight .list,
|
||||
.board-color-cleandark .list {
|
||||
background: none;
|
||||
border-left: none;
|
||||
background: none !important;
|
||||
border-inline-start: none !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .list .list-header,
|
||||
.board-color-cleandark .list .list-header {
|
||||
border-bottom: none;
|
||||
display: flex;
|
||||
border-bottom: none !important;
|
||||
display: flex !important;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
background: none;
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .list .list-header div:has(.list-header-name),
|
||||
@@ -3444,11 +3445,11 @@ THEME - Clean Light
|
||||
border-radius: 12px;
|
||||
margin-top: 18px;
|
||||
padding: 8px;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 10px;
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .list-header-add:hover {
|
||||
@@ -3478,7 +3479,7 @@ THEME - Clean Light
|
||||
.board-color-cleandark .card-label {
|
||||
border-radius: 18px;
|
||||
margin-top: 6px;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
border: none;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
@@ -3528,14 +3529,14 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight .swimlane .swimlane-header-wrap .swimlane-header-plus-icon,
|
||||
.board-color-cleandark .swimlane .swimlane-header-wrap .swimlane-header-plus-icon {
|
||||
margin-left: 14px;
|
||||
margin-inline-start: 14px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .swimlane .swimlane-header-wrap .list-composer,
|
||||
.board-color-cleandark .swimlane .swimlane-header-wrap .list-composer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: 20px;
|
||||
margin-inline-start: 20px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .swimlane .swimlane-header-wrap .swimlane-header .viewer p,
|
||||
@@ -3669,10 +3670,10 @@ THEME - Clean Light
|
||||
background: #009B64;
|
||||
width: min-content;
|
||||
color: #FFFFFF;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
padding-inline-start: 8px;
|
||||
padding-inline-end: 8px;
|
||||
border-radius: 8px;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .card-details hr {
|
||||
@@ -3961,7 +3962,7 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight .card-details .check-box.materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-inline-end: 2px solid #000000;
|
||||
width: 11px;
|
||||
height: 19px;
|
||||
border-radius: 0;
|
||||
@@ -3992,12 +3993,12 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight #cards .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-inline-end: 2px solid #000000;
|
||||
width: 5px;
|
||||
height: 13px;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
margin-left: 3px;
|
||||
margin-inline-start: 3px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.board-color-cleanlight .checklist-progress-bar {
|
||||
@@ -4049,7 +4050,7 @@ THEME - Clean Light
|
||||
align-items: center;
|
||||
padding-bottom: 4px;
|
||||
padding-top: 4px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.board-color-cleanlight#header-quick-access ul.header-quick-access-list {
|
||||
@@ -4237,12 +4238,12 @@ THEME - Clean Light
|
||||
|
||||
.board-color-cleanlight .card-details-item-customfield:has(.checklist-item) div,
|
||||
.board-color-cleandark .card-details-item-customfield:has(.checklist-item) div {
|
||||
padding-right: 0 !important;
|
||||
padding-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .card-details .card-details-items .card-details-item.custom-fields,
|
||||
.board-color-cleanlight .card-details .card-details-items .card-details-item.custom-fields {
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
flex-grow: 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -4279,13 +4280,21 @@ THEME - Clean Light
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.board-color-cleandark .pop-over-list li > a {
|
||||
color: rgba(255, 255, 255, 1) !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .pop-over-list li > a {
|
||||
color: rgba(10, 10, 20, 0.85) !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .pop-over-list li > a i,
|
||||
.board-color-cleandark .pop-over-list li > a i {
|
||||
margin-right: 6px !important;
|
||||
margin-inline-end: 6px !important;
|
||||
}
|
||||
|
||||
.board-color-cleanlight .pop-over .quiet {
|
||||
margin-left: 100px !important;
|
||||
margin-inline-start: 100px !important;
|
||||
}
|
||||
|
||||
.board-color-cleandark .minicard:hover:not(.minicard-composer),
|
||||
@@ -4307,7 +4316,7 @@ THEME - Clean Light
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
|
||||
.board-color-cleanlight .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
display: block !important;
|
||||
float: none !important;
|
||||
margin-bottom: 2rem !important;
|
||||
border-left: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
clear: both !important;
|
||||
}
|
||||
@@ -249,7 +249,7 @@
|
||||
|
||||
.mobile-mode .list-header .list-header-menu-icon {
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
grid-row: 1/3 !important;
|
||||
@@ -264,7 +264,7 @@
|
||||
|
||||
.mobile-mode .list-header .list-header-handle {
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
grid-row: 1/3 !important;
|
||||
@@ -325,9 +325,9 @@
|
||||
width: auto !important;
|
||||
min-width: auto !important;
|
||||
display: flex !important;
|
||||
float: left !important;
|
||||
float: inline-start !important;
|
||||
margin-bottom: 0 !important;
|
||||
border-left: 1px solid #ccc !important;
|
||||
border-inline-start: 1px solid #ccc !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
|
||||
.desktop-mode .list-header .list-header-menu-icon {
|
||||
position: absolute !important;
|
||||
right: 60px !important;
|
||||
inset-inline-end: 60px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
grid-row: auto !important;
|
||||
@@ -367,7 +367,7 @@
|
||||
|
||||
.desktop-mode .list-header .list-header-handle {
|
||||
position: absolute !important;
|
||||
right: 10px !important;
|
||||
inset-inline-end: 10px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
grid-row: auto !important;
|
||||
@@ -463,7 +463,7 @@
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
@@ -557,8 +557,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -566,20 +566,20 @@
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
position: static !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.mobile-mode .list:first-child {
|
||||
margin-left: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-mode .list:last-child {
|
||||
margin-right: 0 !important;
|
||||
margin-inline-end: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
@@ -594,8 +594,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -612,8 +612,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -643,8 +643,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -652,8 +652,8 @@
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
position: static !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
@@ -707,8 +707,8 @@
|
||||
padding: 0 !important;
|
||||
float: none !important;
|
||||
clear: both !important;
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-inline-start: none !important;
|
||||
border-inline-end: none !important;
|
||||
border-top: none !important;
|
||||
border-bottom: 2px solid #ccc !important;
|
||||
flex: none !important;
|
||||
@@ -716,8 +716,8 @@
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
position: static !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
bottom: auto !important;
|
||||
transform: none !important;
|
||||
|
||||
@@ -1,151 +1,164 @@
|
||||
template(name="boardHeaderBar")
|
||||
h1.header-board-menu
|
||||
with currentBoard
|
||||
if $eq title 'Templates'
|
||||
| {{_ 'templates'}}
|
||||
else
|
||||
+viewer
|
||||
= title
|
||||
unless notDisplayThisBoard
|
||||
h1.header-board-menu
|
||||
with currentBoard
|
||||
if $eq title 'Templates'
|
||||
| {{_ 'templates'}}
|
||||
else
|
||||
+viewer
|
||||
= title
|
||||
|
||||
.board-header-btns.left
|
||||
unless isMiniScreen
|
||||
.board-header-btns.left
|
||||
unless isMiniScreen
|
||||
if currentBoard
|
||||
if currentUser
|
||||
with currentBoard
|
||||
if currentUser.isBoardAdmin
|
||||
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
|
||||
i.fa.fa-pencil-square-o
|
||||
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
span {{_ currentBoard.permission}}
|
||||
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
span {{_ watchLevel}}
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
if showStarCounter
|
||||
span.board-star-counter {{currentBoard.stars}}
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
span {{_ 'log-in'}}
|
||||
|
||||
.board-header-btns.center
|
||||
if currentUser
|
||||
+boardButtons
|
||||
|
||||
.board-header-btns.right
|
||||
if currentBoard
|
||||
if currentUser
|
||||
with currentBoard
|
||||
if currentUser.isBoardAdmin
|
||||
if isMiniScreen
|
||||
if currentUser
|
||||
with currentBoard
|
||||
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
|
||||
i.fa.fa-pencil-square-o
|
||||
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
span {{_ currentBoard.permission}}
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
span {{_ watchLevel}}
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
if showStarCounter
|
||||
span.board-star-counter {{currentBoard.stars}}
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
|
||||
i.fa.fa-times-thin
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
span {{_ 'log-in'}}
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
|
||||
.board-header-btns.center
|
||||
if isSandstorm
|
||||
if currentUser
|
||||
a.board-header-btn.js-open-archived-board
|
||||
i.fa.fa-archive
|
||||
|
||||
.board-header-btns.right
|
||||
if currentBoard
|
||||
if isMiniScreen
|
||||
if currentUser
|
||||
with currentBoard
|
||||
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
|
||||
i.fa.fa-pencil-square-o
|
||||
//if showSort
|
||||
//
|
||||
a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
|
||||
//
|
||||
i.fa(class="{{directionClass}}")
|
||||
//
|
||||
span {{_ 'sort'}}{{_ listSortShortDesc}}
|
||||
|
||||
a.board-header-btn(
|
||||
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
|
||||
title="{{_ currentBoard.permission}}")
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
|
||||
a.board-header-btn.js-watch-board(
|
||||
title="{{_ watchLevel }}")
|
||||
if $eq watchLevel "watching"
|
||||
i.fa.fa-eye
|
||||
if $eq watchLevel "tracking"
|
||||
i.fa.fa-bell
|
||||
if $eq watchLevel "muted"
|
||||
i.fa.fa-bell-slash
|
||||
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
|
||||
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
|
||||
i.fa.fa-sort
|
||||
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
|
||||
if isSortActive
|
||||
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
else
|
||||
a.board-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
i.fa.fa-sign-in
|
||||
|
||||
if isSandstorm
|
||||
if currentUser
|
||||
a.board-header-btn.js-open-archived-board
|
||||
i.fa.fa-archive
|
||||
|
||||
//if showSort
|
||||
//
|
||||
a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
|
||||
//
|
||||
i.fa(class="{{directionClass}}")
|
||||
//
|
||||
span {{_ 'sort'}}{{_ listSortShortDesc}}
|
||||
|
||||
a.board-header-btn.js-open-filter-view(
|
||||
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
|
||||
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
|
||||
i.fa.fa-filter
|
||||
span {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
|
||||
if Filter.isActive
|
||||
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
|
||||
i.fa.fa-search
|
||||
span {{_ 'search'}}
|
||||
|
||||
unless currentBoard.isTemplatesBoard
|
||||
a.board-header-btn.js-toggle-board-view
|
||||
i.fa.fa-caret-down
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
i.fa.fa-th-large
|
||||
if $eq boardView 'board-view-lists'
|
||||
i.fa.fa-trello
|
||||
if $eq boardView 'board-view-cal'
|
||||
i.fa.fa-calendar
|
||||
if $eq boardView 'board-view-gantt'
|
||||
i.fa.fa-bar-chart
|
||||
span
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
| {{_ 'swimlanes'}}
|
||||
if $eq boardView 'board-view-lists'
|
||||
| {{_ 'lists'}}
|
||||
if $eq boardView 'board-view-cal'
|
||||
| {{_ 'calendar'}}
|
||||
if $eq boardView 'board-view-gantt'
|
||||
| {{_ 'gantt'}}
|
||||
if canModifyBoard
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
|
||||
i.fa.fa-check-square-o
|
||||
span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
|
||||
if MultiSelection.isActive
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
a.board-header-btn.js-open-filter-view(
|
||||
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
|
||||
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
|
||||
i.fa.fa-filter
|
||||
span {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
|
||||
if Filter.isActive
|
||||
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
.separator
|
||||
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
|
||||
i.fa.fa-bars
|
||||
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
|
||||
i.fa.fa-search
|
||||
span {{_ 'search'}}
|
||||
|
||||
unless currentBoard.isTemplatesBoard
|
||||
a.board-header-btn.js-toggle-board-view
|
||||
i.fa.fa-caret-down
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
i.fa.fa-th-large
|
||||
if $eq boardView 'board-view-lists'
|
||||
i.fa.fa-trello
|
||||
if $eq boardView 'board-view-cal'
|
||||
i.fa.fa-calendar
|
||||
if $eq boardView 'board-view-gantt'
|
||||
i.fa.fa-bar-chart
|
||||
if $eq boardView 'board-view-table'
|
||||
i.fa.fa-table
|
||||
span
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
| {{_ 'swimlanes'}}
|
||||
if $eq boardView 'board-view-lists'
|
||||
| {{_ 'lists'}}
|
||||
if $eq boardView 'board-view-cal'
|
||||
| {{_ 'calendar'}}
|
||||
if $eq boardView 'board-view-gantt'
|
||||
| {{_ 'gantt'}}
|
||||
if $eq boardView 'board-view-table'
|
||||
| {{_ 'board-view-table'}}
|
||||
a.board-header-btn.js-toggle-dependencies(
|
||||
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
|
||||
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
|
||||
i.fa.fa-link
|
||||
span {{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}
|
||||
|
||||
if canModifyBoard
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
|
||||
i.fa.fa-check-square-o
|
||||
span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
|
||||
if MultiSelection.isActive
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
.separator
|
||||
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
|
||||
i.fa.fa-bars
|
||||
|
||||
template(name="boardVisibilityList")
|
||||
ul.pop-over-list
|
||||
@@ -227,6 +240,13 @@ template(name="boardChangeViewPopup")
|
||||
| {{_ 'board-view-gantt'}}
|
||||
if $eq Utils.boardView "board-view-gantt"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-table"
|
||||
a.js-open-table-view
|
||||
i.fa.fa-table
|
||||
| {{_ 'board-view-table'}}
|
||||
if $eq Utils.boardView "board-view-table"
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="createBoard")
|
||||
form
|
||||
@@ -246,9 +266,6 @@ template(name="createBoard")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
@@ -275,9 +292,6 @@ template(name="createBoardPopup")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
@@ -304,9 +318,6 @@ template(name="headerBarCreateBoardPopup")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
@@ -334,9 +345,6 @@ template(name="createTemplateContainerPopup")
|
||||
= " "
|
||||
| {{{_ 'board-private-info'}}}
|
||||
a.js-change-visibility {{_ 'change'}}.
|
||||
a.flex.js-toggle-add-template-container
|
||||
.materialCheckBox#add-template-container
|
||||
span {{_ 'add-template-container'}}
|
||||
input.primary.wide(type="submit" value="{{_ 'create'}}")
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
|
||||
@@ -39,7 +39,22 @@ Template.boardChangeTitlePopup.events({
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardHeaderBar.onCreated(function () {
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
});
|
||||
|
||||
Template.boardHeaderBar.helpers({
|
||||
notDisplayThisBoard() {
|
||||
const allowPrivateVisibilityOnly = TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly');
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
return (
|
||||
allowPrivateVisibilityOnly !== undefined &&
|
||||
allowPrivateVisibilityOnly.booleanValue &&
|
||||
currentBoard &&
|
||||
currentBoard.permission === 'public'
|
||||
);
|
||||
},
|
||||
|
||||
watchLevel() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
return currentBoard && currentBoard.getWatchLevel(Meteor.userId());
|
||||
@@ -166,6 +181,12 @@ Template.boardHeaderBar.events({
|
||||
console.warn('Sidebar not available for setView');
|
||||
}
|
||||
},
|
||||
'click .js-toggle-dependencies'() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (currentBoard) {
|
||||
currentBoard.setShowDependencies(!currentBoard.showDependencies);
|
||||
}
|
||||
},
|
||||
'click .js-multiselection-activate'() {
|
||||
const currentCard = Utils.getCurrentCardId();
|
||||
MultiSelection.activate();
|
||||
@@ -199,6 +220,10 @@ Template.boardChangeViewPopup.events({
|
||||
Utils.setBoardView('board-view-gantt');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-table-view'() {
|
||||
Utils.setBoardView('board-view-table');
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// Shared setup for all create board popups
|
||||
@@ -206,6 +231,9 @@ function setupCreateBoardState(tpl) {
|
||||
tpl.visibilityMenuIsOpen = new ReactiveVar(false);
|
||||
tpl.visibility = new ReactiveVar('private');
|
||||
tpl.boardId = new ReactiveVar('');
|
||||
// #5850: default to creating a regular board; the dedicated
|
||||
// createTemplateContainerPopup sets this true in its onRendered.
|
||||
Session.set('createBoardAsTemplate', false);
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
}
|
||||
|
||||
@@ -218,7 +246,7 @@ function createBoardHelpers() {
|
||||
return Template.instance().visibility.get();
|
||||
},
|
||||
notAllowPrivateVisibilityOnly() {
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly').booleanValue;
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
|
||||
},
|
||||
visibilityCheck() {
|
||||
return Template.currentData() === Template.instance().visibility.get();
|
||||
@@ -231,7 +259,11 @@ async function createBoardSubmit(tpl, event) {
|
||||
const title = tpl.find('.js-new-board-title').value;
|
||||
const slug = getSlug(title) || 'board';
|
||||
|
||||
const addTemplateContainer = $('#add-template-container.is-checked').length > 0;
|
||||
// #5850: template boards are created via the dedicated "Add Template Board"
|
||||
// flow (createTemplateContainerPopup), signalled by this Session flag, rather
|
||||
// than a checkbox on the generic Create Board popup. Consume it immediately.
|
||||
const addTemplateContainer = Session.get('createBoardAsTemplate') === true;
|
||||
Session.set('createBoardAsTemplate', false);
|
||||
if (addTemplateContainer) {
|
||||
tpl.boardId.set(
|
||||
await Meteor.callAsync('createBoardWithInitialSwimlanes', {
|
||||
@@ -241,9 +273,9 @@ async function createBoardSubmit(tpl, event) {
|
||||
type: 'template-container',
|
||||
migrationVersion: 1,
|
||||
swimlanes: [
|
||||
{ title: 'Card Templates', sort: 1, type: 'template-container' },
|
||||
{ title: 'List Templates', sort: 2, type: 'template-container' },
|
||||
{ title: 'Board Templates', sort: 3, type: 'template-container' },
|
||||
{ title: 'Card Templates', sort: 1, type: 'template-container', role: 'card' },
|
||||
{ title: 'List Templates', sort: 2, type: 'template-container', role: 'list' },
|
||||
{ title: 'Board Templates', sort: 3, type: 'template-container', role: 'board' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -288,21 +320,17 @@ async function createBoardSubmit(tpl, event) {
|
||||
function createBoardEvents() {
|
||||
return {
|
||||
'click .js-select-visibility'(event, tpl) {
|
||||
tpl.visibility.set(Template.currentData());
|
||||
tpl.visibility.set(this);
|
||||
tpl.visibilityMenuIsOpen.set(false);
|
||||
},
|
||||
'click .js-change-visibility'(event, tpl) {
|
||||
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
|
||||
},
|
||||
'click .js-import': Popup.open('boardImportBoard'),
|
||||
async 'submit'(event, tpl) {
|
||||
await createBoardSubmit(tpl, event);
|
||||
},
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-board-template': Popup.open('searchElement'),
|
||||
'click .js-toggle-add-template-container'() {
|
||||
$('#add-template-container').toggleClass('is-checked');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -330,8 +358,8 @@ Template.createTemplateContainerPopup.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.createTemplateContainerPopup.onRendered(function () {
|
||||
// Always pre-check the template container checkbox for this popup
|
||||
$('#add-template-container').addClass('is-checked');
|
||||
// #5850: this dedicated popup always creates a template board.
|
||||
Session.set('createBoardAsTemplate', true);
|
||||
});
|
||||
|
||||
Template.createTemplateContainerPopup.helpers(createBoardHelpers());
|
||||
@@ -347,13 +375,12 @@ Template.headerBarCreateBoardPopup.helpers(createBoardHelpers());
|
||||
|
||||
Template.headerBarCreateBoardPopup.events({
|
||||
'click .js-select-visibility'(event, tpl) {
|
||||
tpl.visibility.set(Template.currentData());
|
||||
tpl.visibility.set(this);
|
||||
tpl.visibilityMenuIsOpen.set(false);
|
||||
},
|
||||
'click .js-change-visibility'(event, tpl) {
|
||||
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
|
||||
},
|
||||
'click .js-import': Popup.open('boardImportBoard'),
|
||||
async submit(event, tpl) {
|
||||
await createBoardSubmit(tpl, event);
|
||||
// Immediately star boards created with the headerbar popup.
|
||||
@@ -361,8 +388,11 @@ Template.headerBarCreateBoardPopup.events({
|
||||
},
|
||||
'click .js-import-board': Popup.open('chooseBoardSource'),
|
||||
'click .js-board-template': Popup.open('searchElement'),
|
||||
'click .js-toggle-add-template-container'() {
|
||||
$('#add-template-container').toggleClass('is-checked');
|
||||
});
|
||||
|
||||
Template.boardVisibilityList.helpers({
|
||||
notAllowPrivateVisibilityOnly() {
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -372,7 +402,7 @@ Template.boardChangeVisibilityPopup.onCreated(function () {
|
||||
|
||||
Template.boardChangeVisibilityPopup.helpers({
|
||||
notAllowPrivateVisibilityOnly(){
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly').booleanValue;
|
||||
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
|
||||
},
|
||||
visibilityCheck() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
@@ -383,7 +413,7 @@ Template.boardChangeVisibilityPopup.helpers({
|
||||
Template.boardChangeVisibilityPopup.events({
|
||||
'click .js-select-visibility'() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const visibility = Template.currentData();
|
||||
const visibility = this;
|
||||
if (typeof visibility === 'string') {
|
||||
currentBoard.setVisibility(visibility);
|
||||
Popup.back();
|
||||
@@ -405,7 +435,7 @@ Template.boardChangeWatchPopup.helpers({
|
||||
|
||||
Template.boardChangeWatchPopup.events({
|
||||
'click .js-select-watch'() {
|
||||
const level = Template.currentData();
|
||||
const level = this;
|
||||
if (typeof level === 'string') {
|
||||
Meteor.call(
|
||||
'watch',
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
}
|
||||
|
||||
.boards-left-menu {
|
||||
border-right: 1px solid #e0e0e0;
|
||||
padding-right: 12px;
|
||||
border-inline-end: 1px solid #e0e0e0;
|
||||
padding-inline-end: 12px;
|
||||
}
|
||||
|
||||
.boards-left-menu ul.menu {
|
||||
@@ -46,7 +46,7 @@
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
}
|
||||
.boards-left-menu .menu-item.active a,
|
||||
.boards-left-menu .menu-item a:hover {
|
||||
@@ -62,6 +62,17 @@
|
||||
border: 2px dashed #2196F3;
|
||||
}
|
||||
|
||||
/* Highlight valid drop targets while a board is being dragged */
|
||||
.workspace-node.board-drag-hint > .workspace-node-content {
|
||||
background: #e8f5e9;
|
||||
border: 1px dashed #4CAF50;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.boards-left-menu .menu-item a.board-drag-hint {
|
||||
background: #e8f5e9;
|
||||
border: 1px dashed #4CAF50;
|
||||
}
|
||||
|
||||
.workspaces-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -76,12 +87,12 @@
|
||||
border: none;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.workspace-tree {
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
|
||||
.workspace-node {
|
||||
@@ -334,7 +345,7 @@
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-left: 10px; /* Extra space between MultiSelection toggle and Remove Filter */
|
||||
margin-inline-start: 10px; /* Extra space between MultiSelection toggle and Remove Filter */
|
||||
}
|
||||
|
||||
.boards-path-header .board-header-btn-close:hover {
|
||||
@@ -418,7 +429,7 @@
|
||||
margin: 0 8px;
|
||||
}
|
||||
.board-list li {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 20%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
@@ -522,7 +533,7 @@
|
||||
opacity: 0;
|
||||
padding: 9px 9px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
}
|
||||
@@ -533,7 +544,7 @@
|
||||
line-height: 10px;
|
||||
padding: 9px 9px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
}
|
||||
@@ -553,7 +564,7 @@
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
opacity: 0;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
padding: 9px 9px;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
@@ -565,7 +576,7 @@
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
opacity: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
padding: 9px 9px;
|
||||
transition-duration: 0.15s;
|
||||
transition-property: color, font-size, background;
|
||||
@@ -631,7 +642,7 @@
|
||||
.board-list .board-list-item .multi-selection-checkbox {
|
||||
position: absolute !important;
|
||||
bottom: 4px !important;
|
||||
left: 4px !important;
|
||||
inset-inline-start: 4px !important;
|
||||
top: auto !important;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -662,7 +673,7 @@
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
top: auto !important;
|
||||
left: 4px !important;
|
||||
inset-inline-start: 4px !important;
|
||||
transform: none !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
@@ -700,7 +711,7 @@ body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checke
|
||||
.board-backgrounds-list .board-background-select {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 50%;
|
||||
padding-top: 12px;
|
||||
position: relative;
|
||||
@@ -710,10 +721,10 @@ body.grey-icons-enabled .board-list.is-multiselection-active .js-board.is-checke
|
||||
padding-top: 0;
|
||||
}
|
||||
.board-backgrounds-list .board-background-select:nth-child(2n) {
|
||||
padding-left: 6px;
|
||||
padding-inline-start: 6px;
|
||||
}
|
||||
.board-backgrounds-list .board-background-select:nth-child(2n+1) {
|
||||
padding-right: 6px;
|
||||
padding-inline-end: 6px;
|
||||
}
|
||||
.board-backgrounds-list .board-background-select .background-box {
|
||||
color: #fff;
|
||||
@@ -758,32 +769,32 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
padding-right: 50px; /* Space for drag handle */
|
||||
padding-inline-end: 50px; /* Space for drag handle */
|
||||
}
|
||||
.board-list.mobile-view .board-list-item {
|
||||
overflow: visible;
|
||||
height: 8rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-right: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
}
|
||||
|
||||
.board-list.mobile-view .board-list-item .details {
|
||||
padding-right: 50px; /* Extra space for drag handle */
|
||||
padding-inline-end: 50px; /* Extra space for drag handle */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.board-list.mobile-view .board-list-item-sub-name {
|
||||
position: relative;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
inset-inline-start: -100px;
|
||||
}
|
||||
.board-list.mobile-view .board-handle {
|
||||
position: absolute;
|
||||
padding: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -867,32 +878,32 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
padding-right: 50px; /* Space for drag handle */
|
||||
padding-inline-end: 50px; /* Space for drag handle */
|
||||
}
|
||||
.board-list .board-list-item {
|
||||
overflow: visible;
|
||||
height: 8rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-right: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
}
|
||||
|
||||
.board-list .board-list-item .details {
|
||||
padding-right: 50px; /* Extra space for drag handle */
|
||||
padding-inline-end: 50px; /* Extra space for drag handle */
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.board-list .board-list-item-sub-name {
|
||||
position: relative;
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
inset-inline-start: -100px;
|
||||
}
|
||||
.board-list .board-handle {
|
||||
position: absolute;
|
||||
padding: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -922,7 +933,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
padding: 7px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.3);
|
||||
@@ -950,6 +961,22 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
font-size: inherit !important; /* Inherit the 2x scaling */
|
||||
}
|
||||
|
||||
/* Stack the two-column All Boards layout on small screens. The fixed
|
||||
"260px 1fr" grid had no mobile collapse, so on narrow non-iPhone browsers
|
||||
(Fairphone Firefox ESR / Ubuntu Touch Morph, narrow desktop) it overflowed
|
||||
and the left menu (Starred / Templates / Remaining / Workspaces / + Add
|
||||
board) was clipped. Width-based, so it applies to every browser. */
|
||||
.boards-layout {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.boards-left-menu {
|
||||
border-inline-end: none !important;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-inline-end: 0 !important;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.wrapper .fa, .wrapper .icon {
|
||||
font-size: 2em !important; /* 2x bigger icons */
|
||||
}
|
||||
@@ -1130,7 +1157,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
padding: 10px !important;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
right: 15px !important;
|
||||
inset-inline-end: 15px !important;
|
||||
font-size: 28px !important; /* Much larger drag handle */
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.4) !important;
|
||||
@@ -1194,13 +1221,13 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
.AllBoardTeams,
|
||||
.AllBoardOrgs,
|
||||
.AllBoardBtns {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.js-AllBoardOrgs {
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
.AllBoardTeams {
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
.AllBoardButtonsContainer {
|
||||
margin: 16px;
|
||||
@@ -1269,7 +1296,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
padding: 6px 0 6px 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 2px;
|
||||
margin-left: -4px;
|
||||
margin-inline-start: -4px;
|
||||
display: inline-block;
|
||||
}
|
||||
.minicard-lists {
|
||||
@@ -1285,7 +1312,7 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
}
|
||||
.flex-wrap .item {
|
||||
margin: 2px;
|
||||
padding-right: 6px;
|
||||
padding-inline-end: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1376,3 +1403,26 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* #5157: show the board's background image on the board list tile, reusing the
|
||||
* existing board.backgroundImageURL. A dark overlay keeps the title readable. */
|
||||
.board-list-item.has-background-image {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
.board-list-item.has-background-image .details,
|
||||
.board-list-item.has-background-image .board-list-item-name {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
}
|
||||
.board-list-item.has-background-image::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: inherit;
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -27,6 +27,15 @@ template(name="boardList")
|
||||
i.fa.fa-folder
|
||||
| {{_ 'allboards.remaining'}}
|
||||
span.menu-count {{menuItemCount 'remaining'}}
|
||||
if isSelectedMenu 'templates'
|
||||
if shareTargets.length
|
||||
li.menu-section-title {{_ 'share-template-with'}}
|
||||
each shareTargets
|
||||
li.menu-item.js-share-target(data-share-type="{{type}}", data-share-id="{{id}}", data-share-name="{{name}}", title="{{_ 'drag-template-here-to-share'}}")
|
||||
span.menu-label
|
||||
span.emoji-icon
|
||||
i.fa.fa-share-alt
|
||||
| {{name}}
|
||||
.workspaces-header
|
||||
span
|
||||
span.emoji-icon
|
||||
@@ -95,7 +104,7 @@ template(name="boardList")
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-times
|
||||
ul.board-list.clearfix.js-boards(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
|
||||
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
|
||||
li.js-add-board
|
||||
if isSelectedMenu 'templates'
|
||||
a.board-list-item.label(title="{{_ 'add-template-container'}}")
|
||||
@@ -130,7 +139,7 @@ template(name="boardList")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
span.board-handle(title="{{_ 'drag-board'}}")
|
||||
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
|
||||
@@ -154,17 +163,19 @@ template(name="boardList")
|
||||
span.emoji-icon
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
else
|
||||
.board-list-item
|
||||
//- #5157: reuse the board's existing backgroundImageURL as the
|
||||
//- tile background on the board list page.
|
||||
.board-list-item(class="{{#if backgroundImageURL}}has-background-image{{/if}}" style="{{#if backgroundImageURL}}background-image:url('{{backgroundImageURL}}');{{/if}}")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
span.board-handle(title="{{_ 'drag-board'}}")
|
||||
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
|
||||
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
|
||||
span.details
|
||||
span.board-list-item-name(title="{{_ 'board-drag-drop-reorder-or-click-open'}}")
|
||||
span.board-list-item-name(title="{{boardOpenAndMoveHint}}")
|
||||
+viewer
|
||||
= title
|
||||
unless currentSetting.hideBoardMemberList
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import getSlug from 'limax';
|
||||
import '/client/lib/dragscrollTouch';
|
||||
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
|
||||
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
|
||||
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
@@ -9,6 +11,50 @@ import { Utils } from '/client/lib/utils';
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
|
||||
// #5850: which sharing scopes (organizations/teams/domains) the admin enabled
|
||||
// in Admin Panel > People > Shared Templates (stored in localStorage, like the
|
||||
// Shared Templates admin UI). Used to gate the drag-to-share drop targets.
|
||||
function loadSharedTemplatesScopes() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem('sharedTemplatesScopes');
|
||||
const arr = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// #5850: share a (template) board with an Organization / Team / Domain by adding
|
||||
// it to the board's groups, add-only (does not remove existing shares and does
|
||||
// not add individual members). Called when a board is dropped on a share target.
|
||||
function shareBoardWith(boardId, shareType, name, id) {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
if (!board || !boardId) {
|
||||
return;
|
||||
}
|
||||
if (shareType === 'org') {
|
||||
const orgs = (board.orgs || []).slice();
|
||||
if (!orgs.some(o => o.orgId === id)) {
|
||||
orgs.push({ orgId: id, orgDisplayName: name, isActive: true });
|
||||
Meteor.call('setBoardOrgs', orgs, boardId);
|
||||
}
|
||||
} else if (shareType === 'team') {
|
||||
const teams = (board.teams || []).slice();
|
||||
if (!teams.some(t => t.teamId === id)) {
|
||||
teams.push({ teamId: id, teamDisplayName: name, isActive: true });
|
||||
// Preserve the board's current members (group-only sharing adds no
|
||||
// individual members); setBoardTeams sets both members and teams.
|
||||
Meteor.call('setBoardTeams', teams, board.members || [], boardId);
|
||||
}
|
||||
} else if (shareType === 'domain') {
|
||||
const domains = (board.domains || []).slice();
|
||||
if (!domains.some(d => d.domain === id)) {
|
||||
domains.push({ domain: id, isActive: true });
|
||||
Meteor.call('setBoardDomains', domains, boardId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ICON = '📁';
|
||||
|
||||
function getCurrentWorkspacesTree() {
|
||||
@@ -113,9 +159,18 @@ Template.boardListHeaderBar.helpers({
|
||||
Template.boardList.onCreated(function () {
|
||||
Meteor.subscribe('setting');
|
||||
Meteor.subscribe('tableVisibilityModeSettings');
|
||||
this.selectedMenu = new ReactiveVar('starred');
|
||||
// Honor the URL-addressable sub-view (#5850). The route sets
|
||||
// Session 'boardListMenu' to 'starred', 'templates' or 'remaining'.
|
||||
this.selectedMenu = new ReactiveVar(Session.get('boardListMenu') || 'starred');
|
||||
this.selectedWorkspaceIdVar = new ReactiveVar(null);
|
||||
this.workspacesTreeVar = new ReactiveVar([]);
|
||||
// #5850: the user's orgs/teams that have the per-org/team Shared Templates
|
||||
// flag set (plus email domains), fetched via a non-admin server method since
|
||||
// the org/team publications are admin-only. Gates the drag-to-share targets.
|
||||
this.shareableGroups = new ReactiveVar({ orgs: [], teams: [], domains: [] });
|
||||
Meteor.call('getMyShareableGroups', (err, res) => {
|
||||
if (!err && res) this.shareableGroups.set(res);
|
||||
});
|
||||
let currUser = ReactiveCache.getCurrentUser();
|
||||
let userLanguage;
|
||||
if (currUser && currUser.profile) {
|
||||
@@ -194,9 +249,38 @@ Template.boardList.onCreated(function () {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Switch the sub-view live when the route changes (e.g. navigating to
|
||||
// /templates or /remaining while boardList is already rendered). Only the
|
||||
// three recognized menu values are honored so a workspace selection is not
|
||||
// clobbered (#5850).
|
||||
this.autorun(() => {
|
||||
const m = Session.get('boardListMenu');
|
||||
if (m === 'starred' || m === 'templates' || m === 'remaining') {
|
||||
this.selectedMenu.set(m);
|
||||
this.selectedWorkspaceIdVar.set(null);
|
||||
}
|
||||
});
|
||||
|
||||
// The templates-container board is no longer auto-created at signup (#2339,
|
||||
// #5850), and it is no longer auto-created when the user merely opens the
|
||||
// Templates sub-view either: opening the view must not leave behind an empty
|
||||
// Template Container board the user never asked for. The container is created
|
||||
// only on demand -- either when the user explicitly adds one via the
|
||||
// "Add Template Board" flow (createTemplateContainerPopup), or lazily by
|
||||
// ensureTemplatesBoard right before a card/list/swimlane/board is actually
|
||||
// saved as a template. ensureTemplatesBoard remains idempotent (a no-op when
|
||||
// one already exists) so those save paths keep working.
|
||||
});
|
||||
|
||||
Template.boardList.onRendered(function () {
|
||||
// Enable drag-to-scroll on the All Boards page the same way the board
|
||||
// canvas/swimlanes do. On desktop the page scroll container is <body>, so we
|
||||
// tag it via enablePageDragscroll(); the .board-list.dragscroll element only
|
||||
// becomes a scroll container in mobile view, where its own class handles it.
|
||||
// Finger drag-scrolling is handled by dragscrollTouch.js.
|
||||
enablePageDragscroll();
|
||||
|
||||
// jQuery sortable is disabled in favor of HTML5 drag-and-drop for space management
|
||||
// The old sortable code has been removed to prevent conflicts
|
||||
/* OLD SORTABLE CODE - DISABLED
|
||||
@@ -241,6 +325,13 @@ Template.boardList.onRendered(function () {
|
||||
*/
|
||||
});
|
||||
|
||||
Template.boardList.onDestroyed(function () {
|
||||
// Stop tagging <body> as drag-scrollable when leaving the All Boards page so
|
||||
// other layouts (e.g. a board view, whose canvas does its own dragscroll) are
|
||||
// not affected.
|
||||
disablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.boardList.helpers({
|
||||
userHasTeams() {
|
||||
if (ReactiveCache.getCurrentUser()?.teams?.length > 0) return true;
|
||||
@@ -351,15 +442,14 @@ Template.boardList.helpers({
|
||||
'tableVisibilityMode-allowPrivateOnly',
|
||||
);
|
||||
|
||||
if (FlowRouter.getRouteName() === 'home') {
|
||||
// #5850: the All Boards sub-views are also reachable via their own routes
|
||||
// (/templates, /remaining), which must apply the same membership filtering
|
||||
// as the home route, otherwise their board list is empty (or falls into the
|
||||
// public-only branch below).
|
||||
const allBoardsRoutes = ['home', 'allboards-templates', 'allboards-remaining'];
|
||||
if (allBoardsRoutes.includes(FlowRouter.getRouteName())) {
|
||||
membershipOrs.push({ 'members.userId': Meteor.userId() });
|
||||
|
||||
if (
|
||||
allowPrivateVisibilityOnly !== undefined &&
|
||||
allowPrivateVisibilityOnly.booleanValue
|
||||
) {
|
||||
query.$and.push({ permission: 'private' });
|
||||
}
|
||||
const currUser = ReactiveCache.getCurrentUser();
|
||||
|
||||
let orgIdsUserBelongs = currUser?.orgIdsUserBelongs() || '';
|
||||
@@ -373,6 +463,12 @@ Template.boardList.helpers({
|
||||
let teamsIds = teamIdsUserBelongs.split(',');
|
||||
membershipOrs.push({ 'teams.teamId': { $in: teamsIds } });
|
||||
}
|
||||
|
||||
// #5850: boards shared with the user's email domain.
|
||||
const emailDomains = currUser?.emailDomains?.() || [];
|
||||
if (emailDomains.length) {
|
||||
membershipOrs.push({ 'domains.domain': { $in: emailDomains } });
|
||||
}
|
||||
if (membershipOrs.length) {
|
||||
query.$and.splice(2, 0, { $or: membershipOrs });
|
||||
}
|
||||
@@ -473,6 +569,24 @@ Template.boardList.helpers({
|
||||
isSelectedMenu(type) {
|
||||
return Template.instance().selectedMenu.get() === type;
|
||||
},
|
||||
// #5850: drag-to-share drop targets — the user's organizations/teams/domains,
|
||||
// gated by the admin's Shared Templates scopes (localStorage). Shown in the
|
||||
// Templates view so a personal Template Board can be dragged onto one to share.
|
||||
shareTargets() {
|
||||
const scopes = loadSharedTemplatesScopes();
|
||||
const groups = Template.instance().shareableGroups.get() || {};
|
||||
const targets = [];
|
||||
if (scopes.includes('organizations')) {
|
||||
(groups.orgs || []).forEach(o => targets.push(o));
|
||||
}
|
||||
if (scopes.includes('teams')) {
|
||||
(groups.teams || []).forEach(t => targets.push(t));
|
||||
}
|
||||
if (scopes.includes('domains')) {
|
||||
(groups.domains || []).forEach(d => targets.push(d));
|
||||
}
|
||||
return targets;
|
||||
},
|
||||
isSpaceSelected(id) {
|
||||
return Template.instance().selectedWorkspaceIdVar.get() === id;
|
||||
},
|
||||
@@ -539,6 +653,28 @@ Template.boardList.helpers({
|
||||
hasBoardsSelected() {
|
||||
return BoardMultiSelection.count() > 0;
|
||||
},
|
||||
boardWorkspaceDragHint() {
|
||||
const remaining = TAPi18n.__('allboards.remaining') || 'Remaining';
|
||||
const workspaces = TAPi18n.__('allboards.workspaces') || 'Workspaces';
|
||||
return (
|
||||
TAPi18n.__('drag-board-to-workspace', { remaining, workspaces }) ||
|
||||
`Drag board to assign to ${workspaces} (drop on workspace in left sidebar)`
|
||||
);
|
||||
},
|
||||
boardOpenAndMoveHint() {
|
||||
const remaining = TAPi18n.__('allboards.remaining') || 'Remaining';
|
||||
const workspaces = TAPi18n.__('allboards.workspaces') || 'Workspaces';
|
||||
return (
|
||||
TAPi18n.__(
|
||||
'board-open-and-move-between-remaining-and-workspaces',
|
||||
{
|
||||
remaining,
|
||||
workspaces,
|
||||
},
|
||||
) ||
|
||||
`Click board icon to open board. Drag board between ${remaining} and ${workspaces}.`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Template.workspaceTree.helpers({
|
||||
@@ -618,6 +754,43 @@ Template.boardList.events({
|
||||
}
|
||||
},
|
||||
// HTML5 DnD from boards to spaces
|
||||
// #5850: drag a (template) board onto an Org/Team/Domain target to share it.
|
||||
'dragover .js-share-target'(evt) {
|
||||
evt.preventDefault();
|
||||
if (evt.originalEvent.dataTransfer) {
|
||||
evt.originalEvent.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
evt.currentTarget.classList.add('board-drag-hint');
|
||||
},
|
||||
'dragleave .js-share-target'(evt) {
|
||||
evt.currentTarget.classList.remove('board-drag-hint');
|
||||
},
|
||||
'drop .js-share-target'(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const target = evt.currentTarget;
|
||||
target.classList.remove('board-drag-hint');
|
||||
const dt = evt.originalEvent.dataTransfer;
|
||||
const data = dt.getData('text/plain');
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const isMulti = dt.getData('application/x-board-multi') === 'true';
|
||||
let boardIds;
|
||||
if (isMulti) {
|
||||
try {
|
||||
boardIds = JSON.parse(data);
|
||||
} catch (e) {
|
||||
boardIds = [];
|
||||
}
|
||||
} else {
|
||||
boardIds = [data];
|
||||
}
|
||||
const shareType = target.getAttribute('data-share-type');
|
||||
const name = target.getAttribute('data-share-name');
|
||||
const id = target.getAttribute('data-share-id');
|
||||
boardIds.forEach(boardId => shareBoardWith(boardId, shareType, name, id));
|
||||
},
|
||||
'dragstart .js-board'(evt) {
|
||||
const boardId = this._id;
|
||||
|
||||
@@ -642,6 +815,20 @@ Template.boardList.events({
|
||||
evt.originalEvent.dataTransfer.setData('text/plain', boardId);
|
||||
} catch (e) {}
|
||||
}
|
||||
// Highlight valid drop targets in the sidebar so users know where to drop
|
||||
document.querySelectorAll('.workspace-node').forEach((el) => {
|
||||
el.classList.add('board-drag-hint');
|
||||
});
|
||||
document.querySelectorAll('.js-select-menu').forEach((el) => {
|
||||
if (el.getAttribute('data-type') === 'remaining') {
|
||||
el.classList.add('board-drag-hint');
|
||||
}
|
||||
});
|
||||
},
|
||||
'dragend .js-board'() {
|
||||
document.querySelectorAll('.workspace-node.board-drag-hint, .js-select-menu.board-drag-hint').forEach((el) => {
|
||||
el.classList.remove('board-drag-hint');
|
||||
});
|
||||
},
|
||||
'click .js-clone-board'(evt) {
|
||||
if (confirm(TAPi18n.__('duplicate-board-confirm'))) {
|
||||
@@ -675,7 +862,9 @@ Template.boardList.events({
|
||||
'click .js-archive-board'(evt) {
|
||||
if (confirm(TAPi18n.__('archive-board-confirm'))) {
|
||||
const boardId = this._id;
|
||||
Meteor.call('archiveBoard', boardId);
|
||||
Meteor.call('archiveBoard', boardId, (err) => {
|
||||
if (err) alert(err?.reason || err?.message || 'Failed to archive board');
|
||||
});
|
||||
evt.preventDefault();
|
||||
}
|
||||
},
|
||||
@@ -718,7 +907,9 @@ Template.boardList.events({
|
||||
confirm(TAPi18n.__('archive-board-confirm'))
|
||||
) {
|
||||
selectedBoards.forEach((boardId) => {
|
||||
Meteor.call('archiveBoard', boardId);
|
||||
Meteor.call('archiveBoard', boardId, (err) => {
|
||||
if (err) alert(err?.reason || err?.message || 'Failed to archive board');
|
||||
});
|
||||
});
|
||||
BoardMultiSelection.reset();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
.dependency-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* #3392: the overlay SVG stays click-through, but each line is clickable (to
|
||||
open the edit/delete popup) even though its container is pointer-events:none. */
|
||||
.dependency-line {
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dependency-temp-line {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* #3392: drag-to-connect handle on the minicard (no mode — cards stay
|
||||
clickable). Shown on hover; drag it onto another card to create a link. */
|
||||
.minicard .dependency-connect-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-end: -7px;
|
||||
right: -7px;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #eb144c;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
|
||||
cursor: crosshair;
|
||||
opacity: 0;
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
.minicard:hover .dependency-connect-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dependency-line-popup .dependency-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dependency-line-popup .card-dependency-icons li a.is-selected {
|
||||
border-color: #eb144c;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.dependency-line-delete {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #eb144c;
|
||||
}
|
||||
|
||||
/* #3392: card detail "Dependencies" rows */
|
||||
.card-details-dependencies-list .card-dependency {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .card-dependency-link {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .js-dependency-type {
|
||||
flex: 0 0 auto;
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.card-details-dependencies-list .js-dependency-color {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-dependencies-popup .dependency-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-dependency-icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.card-dependency-icons li a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.minicard .badge.minicard-dependencies .badge-icon {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
template(name="dependencyOverlay")
|
||||
svg.dependency-overlay.js-dependency-overlay(xmlns="http://www.w3.org/2000/svg")
|
||||
defs
|
||||
each dependencyMarkers
|
||||
marker(id="{{markerId}}" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse")
|
||||
path(d="M0,0 L7,3 L0,6 Z" fill="{{color}}")
|
||||
each dependencyLines
|
||||
path.dependency-line(d="{{path}}" fill="none" stroke="{{color}}" stroke-width="2" marker-end="{{markerUrl}}")
|
||||
if tempLine
|
||||
path.dependency-temp-line(d="{{tempLine}}" fill="none" stroke="#eb144c" stroke-width="2" stroke-dasharray="6,4")
|
||||
|
||||
template(name="dependencyLinePopup")
|
||||
.dependency-line-popup
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-type'}}
|
||||
select.js-line-type
|
||||
each typeOption
|
||||
if selected
|
||||
option(value="{{id}}" selected="selected") {{_ label}}
|
||||
else
|
||||
option(value="{{id}}") {{_ label}}
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-color'}}
|
||||
input.js-line-color(type="color" value="{{color}}")
|
||||
.dependency-field
|
||||
span {{_ 'dependency-icon'}}
|
||||
ul.card-dependency-icons
|
||||
each dependencyIcons
|
||||
li
|
||||
a.js-line-icon(data-icon="{{name}}" title="{{name}}" class="{{#if selected}}is-selected{{/if}}")
|
||||
i.fa(class="fa-{{name}}")
|
||||
hr
|
||||
a.js-line-delete.dependency-line-delete
|
||||
i.fa.fa-times
|
||||
| {{_ 'remove-dependency'}}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import {
|
||||
DEPENDENCY_ICON_CHOICES,
|
||||
DEPENDENCY_TYPES,
|
||||
dependencyTypeMeta,
|
||||
normalizeDependencies,
|
||||
} from '/models/metadata/dependencies';
|
||||
|
||||
// #3392: PI Program Board "Red Strings".
|
||||
// Draw colored, typed connection lines (SVG paths) between cards that declare
|
||||
// cardDependencies. The board stays fully usable (cards clickable) — drawing a
|
||||
// new link is initiated from a small connect handle on each minicard (drag it
|
||||
// onto another card, piplanning.io / Kendis / Miro style), and a link can be
|
||||
// edited/deleted by clicking its line. The overlay SVG itself stays
|
||||
// pointer-events:none; only the lines (and the minicard handles) capture events.
|
||||
//
|
||||
// Coordinates are computed from the live DOM (getBoundingClientRect of
|
||||
// [data-card-id] minicards) translated into the overlay SVG's own coordinate
|
||||
// space, so scrolling/resizing is handled by recomputing relative to the SVG's
|
||||
// current bounding rect.
|
||||
|
||||
// Stable, css-safe marker id for an arrowhead of a given color.
|
||||
function markerIdForColor(color) {
|
||||
return `dependency-arrowhead-${String(color).replace(/[^a-zA-Z0-9]/g, '')}`;
|
||||
}
|
||||
|
||||
Template.dependencyOverlay.onCreated(function () {
|
||||
this.lines = new ReactiveVar([]);
|
||||
this.markers = new ReactiveVar([]);
|
||||
this.tempLine = new ReactiveVar(null); // line being drawn while dragging
|
||||
// Guard against deferred (rAF / setTimeout) callbacks touching the DOM after
|
||||
// the overlay is destroyed ("Can't select in removed DomRange").
|
||||
this._overlayDestroyed = false;
|
||||
|
||||
// Rect of a card relative to the overlay SVG, or null if not rendered.
|
||||
this.rectOf = (cardId, svgRect) => {
|
||||
const el = document.querySelector(`[data-card-id="${cardId}"]`);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) return null;
|
||||
return { x: r.left - svgRect.left, y: r.top - svgRect.top, w: r.width, h: r.height };
|
||||
};
|
||||
|
||||
this.recompute = () => {
|
||||
if (this._overlayDestroyed || !this.view || this.view.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
const svg = this.find('.js-dependency-overlay');
|
||||
if (!svg) {
|
||||
return;
|
||||
}
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (!board) {
|
||||
this.lines.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const centerOf = cardId => this.rectOf(cardId, svgRect);
|
||||
|
||||
const cards = ReactiveCache.getCards({
|
||||
boardId: board._id,
|
||||
archived: false,
|
||||
});
|
||||
|
||||
const lines = [];
|
||||
const markerColors = new Set();
|
||||
cards.forEach(card => {
|
||||
const deps = normalizeDependencies(card.cardDependencies);
|
||||
if (deps.length === 0) {
|
||||
return;
|
||||
}
|
||||
const sourceRect = centerOf(card._id);
|
||||
if (!sourceRect) {
|
||||
return;
|
||||
}
|
||||
deps.forEach(dep => {
|
||||
const targetRect = centerOf(dep.cardId);
|
||||
if (!targetRect) {
|
||||
return;
|
||||
}
|
||||
const meta = dependencyTypeMeta(dep.type);
|
||||
// Draw the arrow from the prerequisite card's right edge to the
|
||||
// dependent card's left edge. For forward relations (blocks/fixes/
|
||||
// related-to) the source card is the prerequisite; for the reverse
|
||||
// relations (is-blocked-by/is-fixed-by) the target is.
|
||||
const prereq = meta.forward ? sourceRect : targetRect;
|
||||
const dependent = meta.forward ? targetRect : sourceRect;
|
||||
const x1 = prereq.x + prereq.w;
|
||||
const y1 = prereq.y + prereq.h / 2;
|
||||
const x2 = dependent.x;
|
||||
const y2 = dependent.y + dependent.h / 2;
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
const path = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
const markerId = markerIdForColor(dep.color);
|
||||
if (meta.directed) {
|
||||
markerColors.add(dep.color);
|
||||
}
|
||||
lines.push({
|
||||
path,
|
||||
color: dep.color,
|
||||
type: dep.type,
|
||||
icon: dep.icon,
|
||||
directed: meta.directed,
|
||||
markerId: meta.directed ? markerId : '',
|
||||
// Source card (owns the dependency) and target card.
|
||||
fromId: card._id,
|
||||
toId: dep.cardId,
|
||||
});
|
||||
});
|
||||
});
|
||||
this.lines.set(lines);
|
||||
this.markers.set(
|
||||
[...markerColors].map(color => ({
|
||||
color,
|
||||
markerId: markerIdForColor(color),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
this.scheduleRecompute = () => {
|
||||
if (this._overlayDestroyed) return;
|
||||
if (this._raf) {
|
||||
window.cancelAnimationFrame(this._raf);
|
||||
}
|
||||
this._raf = window.requestAnimationFrame(() => {
|
||||
this._raf = null;
|
||||
this.recompute();
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.onRendered(function () {
|
||||
const instance = this;
|
||||
const svg = this.find('.js-dependency-overlay');
|
||||
this.scrollEl = svg ? this.firstNode.closest('.board-canvas') : null;
|
||||
|
||||
this.autorun(() => {
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (board) {
|
||||
ReactiveCache.getCards({ boardId: board._id, archived: false });
|
||||
}
|
||||
instance.scheduleRecompute();
|
||||
});
|
||||
|
||||
this.onScroll = () => instance.scheduleRecompute();
|
||||
this.onResize = () => instance.scheduleRecompute();
|
||||
|
||||
if (this.scrollEl) {
|
||||
this.scrollEl.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
}
|
||||
window.addEventListener('scroll', this.onScroll, { passive: true });
|
||||
window.addEventListener('resize', this.onResize);
|
||||
|
||||
// --- drag-to-connect (from a minicard's connect handle) -----------------
|
||||
// Find the card under a viewport point. The overlay is pointer-events:none,
|
||||
// so elementsFromPoint returns the minicards directly.
|
||||
const cardIdAtPoint = (clientX, clientY) => {
|
||||
const els = document.elementsFromPoint(clientX, clientY);
|
||||
for (const el of els) {
|
||||
if (svg && svg.contains(el)) continue;
|
||||
const card = el.closest && el.closest('[data-card-id]');
|
||||
if (card) return card.getAttribute('data-card-id');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
this.onMouseDown = e => {
|
||||
if (e.button !== 0) return;
|
||||
const handle =
|
||||
e.target.closest && e.target.closest('.js-dependency-connect-handle');
|
||||
if (!handle) return;
|
||||
const cardEl = handle.closest('[data-card-id]');
|
||||
if (!cardEl) return;
|
||||
// Beat the card drag-sort: capture-phase + stop propagation.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
instance.dragSourceId = cardEl.getAttribute('data-card-id');
|
||||
};
|
||||
|
||||
this.onMouseMove = e => {
|
||||
if (!instance.dragSourceId || !svg) return;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const from = instance.rectOf(instance.dragSourceId, svgRect);
|
||||
if (!from) return;
|
||||
const x1 = from.x + from.w;
|
||||
const y1 = from.y + from.h / 2;
|
||||
const x2 = e.clientX - svgRect.left;
|
||||
const y2 = e.clientY - svgRect.top;
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
instance.tempLine.set(
|
||||
`M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`,
|
||||
);
|
||||
};
|
||||
|
||||
this.onMouseUp = e => {
|
||||
if (!instance.dragSourceId) return;
|
||||
const sourceId = instance.dragSourceId;
|
||||
instance.dragSourceId = null;
|
||||
instance.tempLine.set(null);
|
||||
const targetId = cardIdAtPoint(e.clientX, e.clientY);
|
||||
if (targetId && targetId !== sourceId) {
|
||||
const card = ReactiveCache.getCard(sourceId);
|
||||
if (card && typeof card.addDependency === 'function') {
|
||||
card.addDependency(targetId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Capture phase so the connect drag starts before the sortable grabs the card.
|
||||
document.addEventListener('mousedown', this.onMouseDown, true);
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
|
||||
this._initTimeout = Meteor.setTimeout(() => instance.scheduleRecompute(), 300);
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.onDestroyed(function () {
|
||||
this._overlayDestroyed = true;
|
||||
if (this.scrollEl) {
|
||||
this.scrollEl.removeEventListener('scroll', this.onScroll);
|
||||
}
|
||||
window.removeEventListener('scroll', this.onScroll);
|
||||
window.removeEventListener('resize', this.onResize);
|
||||
if (this.onMouseDown) document.removeEventListener('mousedown', this.onMouseDown, true);
|
||||
if (this.onMouseMove) document.removeEventListener('mousemove', this.onMouseMove);
|
||||
if (this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp);
|
||||
if (this._raf) {
|
||||
window.cancelAnimationFrame(this._raf);
|
||||
}
|
||||
if (this._initTimeout) {
|
||||
Meteor.clearTimeout(this._initTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.helpers({
|
||||
dependencyLines() {
|
||||
return Template.instance().lines.get();
|
||||
},
|
||||
dependencyMarkers() {
|
||||
return Template.instance().markers.get();
|
||||
},
|
||||
tempLine() {
|
||||
return Template.instance().tempLine.get();
|
||||
},
|
||||
markerUrl() {
|
||||
return this.markerId ? `url(#${this.markerId})` : '';
|
||||
},
|
||||
});
|
||||
|
||||
Template.dependencyOverlay.events({
|
||||
'click .dependency-line'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// `this` is the line data object (fromId/toId/type/color/icon).
|
||||
Popup.open('dependencyLine').call(this, event);
|
||||
},
|
||||
});
|
||||
|
||||
// #3392: popup to edit or delete an on-board dependency line. Data context is the
|
||||
// line object { fromId, toId, type, color, icon }.
|
||||
Template.dependencyLinePopup.helpers({
|
||||
typeOption() {
|
||||
const current = this.type;
|
||||
return DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
selected: t.id === current,
|
||||
}));
|
||||
},
|
||||
color() {
|
||||
return this.color;
|
||||
},
|
||||
dependencyIcons() {
|
||||
const current = this.icon;
|
||||
return DEPENDENCY_ICON_CHOICES.map(name => ({ name, selected: name === current }));
|
||||
},
|
||||
});
|
||||
|
||||
Template.dependencyLinePopup.events({
|
||||
'change .js-line-type'(event) {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.setDependencyProps(this.toId, { type: event.currentTarget.value });
|
||||
},
|
||||
'change .js-line-color'(event) {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.setDependencyProps(this.toId, { color: event.currentTarget.value });
|
||||
},
|
||||
'click .js-line-icon'(event) {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.setDependencyProps(this.toId, { icon: event.currentTarget.dataset.icon });
|
||||
},
|
||||
'click .js-line-delete'() {
|
||||
const card = ReactiveCache.getCard(this.fromId);
|
||||
if (card) card.removeDependency(this.toId);
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.original-positions-loading i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.original-positions-filters {
|
||||
@@ -117,7 +117,7 @@
|
||||
}
|
||||
|
||||
.original-position-item-details {
|
||||
margin-left: 24px;
|
||||
margin-inline-start: 24px;
|
||||
}
|
||||
|
||||
.original-position-description {
|
||||
@@ -191,7 +191,7 @@
|
||||
}
|
||||
|
||||
.original-position-item-details {
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Board Table view: search + pagination controls, styled to match the
|
||||
Admin Panel / People page (see client/components/settings/peopleBody.css).
|
||||
The table itself reuses the My Cards table styling (.my-cards-board-table). */
|
||||
|
||||
.table-view-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 1vh 0;
|
||||
}
|
||||
|
||||
.table-view-search-box {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.table-view-search-box input {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.4vw;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.table-view-search-box button {
|
||||
min-width: 90px;
|
||||
margin-inline-start: 6px;
|
||||
background: #01628c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.table-view-search-box button:hover {
|
||||
background: #005377;
|
||||
}
|
||||
|
||||
.table-view-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.table-view-pagination button {
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
border: 1px solid #bbb;
|
||||
background: #fff;
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table-view-pagination button.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table-view-page-info {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Sortable column headers (Excel-like): clickable, with a sort-direction
|
||||
arrow appended by the sortIndicator helper. */
|
||||
.my-cards-board-table th.js-table-view-sort {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-cards-board-table th.js-table-view-sort:hover {
|
||||
color: #01628c;
|
||||
}
|
||||
|
||||
/* Leftmost "Edit" cell: an Edit link (pencil icon + text) styled like the one
|
||||
on the Admin Panel / People page, opening the card when clicked. */
|
||||
.my-cards-board-table td.table-view-cell-edit {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-edit .edit-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-edit .edit-card i.fa {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
/* Word-wrap the Card / List / Swimlane cells so long text breaks onto the next
|
||||
line instead of overflowing across the other columns. */
|
||||
.my-cards-board-table td.table-view-cell-wrap {
|
||||
max-width: 250px;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* The +viewer template wraps its markdown in a block-level .viewer div. Inside
|
||||
a table cell it should not add extra vertical margins, and inside a label it
|
||||
must stay inline so the label keeps its pill shape. */
|
||||
.my-cards-board-table .viewer {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.my-cards-board-table .viewer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.my-cards-board-table .card-label .viewer,
|
||||
.my-cards-board-table .card-label .viewer p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Word-wrap the Labels cell so each label pill wraps onto the next line instead
|
||||
of stretching the column or overflowing across the date columns. */
|
||||
.my-cards-board-table td.table-view-cell-labels {
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-labels .div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-labels .card-label {
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Keep each date in a single column; the clickable badge already styles itself
|
||||
like the card detail / Gantt date badges. */
|
||||
.my-cards-board-table td.table-view-cell-date {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-cards-board-table td.table-view-cell-date .card-date {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
template(name="tableView")
|
||||
if currentBoard
|
||||
.wrapper
|
||||
.table-view-controls
|
||||
.table-view-search-box
|
||||
input.js-table-view-search(type="text" placeholder="{{_ 'search'}}" aria-label="{{_ 'search'}}")
|
||||
button.js-table-view-search-button(type="button")
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.table-view-pagination
|
||||
button.js-table-view-prev-page(type="button" class="{{#unless hasPrevPage}}disabled{{/unless}}" aria-label="{{_ 'previous'}}")
|
||||
i.fa.fa-chevron-left
|
||||
span.table-view-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-table-view-next-page(type="button" class="{{#unless hasNextPage}}disabled{{/unless}}" aria-label="{{_ 'next'}}")
|
||||
i.fa.fa-chevron-right
|
||||
table.my-cards-board-table
|
||||
caption.sr-only {{currentBoard.title}}
|
||||
thead
|
||||
tr
|
||||
th(scope="col")
|
||||
th.js-table-view-sort(scope="col" data-sort="card") {{_ "card"}} {{sortIndicator 'card'}}
|
||||
th.js-table-view-sort(scope="col" data-sort="list") {{_ "list"}} {{sortIndicator 'list'}}
|
||||
th.js-table-view-sort(scope="col" data-sort="swimlane") {{_ "swimlane"}} {{sortIndicator 'swimlane'}}
|
||||
unless isMiniScreen
|
||||
th(scope="col") {{_ "assignees"}}
|
||||
th(scope="col") {{_ "members"}}
|
||||
th(scope="col") {{_ "labels"}}
|
||||
if showReceivedColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="received") {{_ "card-received"}} {{sortIndicator 'received'}}
|
||||
if showStartColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="start") {{_ "card-start"}} {{sortIndicator 'start'}}
|
||||
if showDueColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="due") {{_ "card-due"}} {{sortIndicator 'due'}}
|
||||
if showEndColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="end") {{_ "card-end"}} {{sortIndicator 'end'}}
|
||||
tbody
|
||||
each row in rows
|
||||
tr
|
||||
td.table-view-cell-edit
|
||||
a.edit-card.js-table-view-edit-card(href=row.card.originRelativeUrl data-card-id=row.card._id title="{{_ 'edit'}}")
|
||||
i.fa.fa-pencil-square-o
|
||||
| {{_ 'edit'}}
|
||||
td.table-view-cell-wrap
|
||||
unless isMiniScreen
|
||||
.my-cards-board-badge(class=row.colorClass)
|
||||
.my-cards-card-title-table
|
||||
+viewer
|
||||
= row.title
|
||||
td.table-view-cell-wrap
|
||||
+viewer
|
||||
= row.listTitle
|
||||
td.table-view-cell-wrap
|
||||
+viewer
|
||||
= row.swimlaneTitle
|
||||
unless isMiniScreen
|
||||
td
|
||||
.div
|
||||
each assignee in row.card.getAssignees
|
||||
a.name
|
||||
+userAvatar(userId=assignee noRemove=true)
|
||||
td
|
||||
.div
|
||||
each member in row.card.getMembers
|
||||
a.name
|
||||
+userAvatar(userId=member noRemove=true)
|
||||
td.table-view-cell-labels
|
||||
.div
|
||||
each label in row.labels
|
||||
span.card-label(class="card-label-{{label.color}}" title="{{label.name}}")
|
||||
+viewer
|
||||
= label.name
|
||||
if showReceivedColumn
|
||||
td.table-view-cell-date
|
||||
with row.card
|
||||
if getReceived
|
||||
+cardReceivedDate
|
||||
else
|
||||
if canModifyCard
|
||||
a.card-label.add-label.js-received-date(title="{{_ 'card-received'}}")
|
||||
i.fa.fa-plus
|
||||
if showStartColumn
|
||||
td.table-view-cell-date
|
||||
with row.card
|
||||
if getStart
|
||||
+cardStartDate
|
||||
else
|
||||
if canModifyCard
|
||||
a.card-label.add-label.js-start-date(title="{{_ 'card-start'}}")
|
||||
i.fa.fa-plus
|
||||
if showDueColumn
|
||||
td.table-view-cell-date
|
||||
with row.card
|
||||
if getDue
|
||||
+cardDueDate
|
||||
else
|
||||
if canModifyCard
|
||||
a.card-label.add-label.js-due-date(title="{{_ 'card-due'}}")
|
||||
i.fa.fa-plus
|
||||
if showEndColumn
|
||||
td.table-view-cell-date
|
||||
with row.card
|
||||
if getEnd
|
||||
+cardEndDate
|
||||
else
|
||||
if canModifyCard
|
||||
a.card-label.add-label.js-end-date(title="{{_ 'card-end'}}")
|
||||
i.fa.fa-plus
|
||||
@@ -0,0 +1,254 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// Board "Table" view: lists every card of the current board in a table that
|
||||
// reuses the My Cards table styling (the .my-cards-board-table CSS classes in
|
||||
// client/components/main/myCards.css). It is the per-board counterpart of the
|
||||
// My Cards table view, which spans all boards.
|
||||
//
|
||||
// Search, column sorting (Excel-like) and pagination all run client-side: the
|
||||
// board's cards are already loaded reactively via board.cards(), so there is no
|
||||
// need for the server-side limit/skip publication the Admin People page uses.
|
||||
|
||||
const rowsPerPage = 25;
|
||||
|
||||
Template.tableView.onCreated(function () {
|
||||
this.searchQuery = new ReactiveVar('');
|
||||
this.sortField = new ReactiveVar('card'); // card | list | swimlane | due
|
||||
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
|
||||
this.page = new ReactiveVar(1);
|
||||
this.filteredRows = new ReactiveVar([]);
|
||||
|
||||
// Recompute the flat, filtered and sorted row list whenever the board cards,
|
||||
// search query or sort order change. Pagination is applied separately in the
|
||||
// rows() helper so paging does not rebuild the whole list.
|
||||
this.autorun(() => {
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (!board) {
|
||||
this.filteredRows.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = this.searchQuery.get().trim().toLowerCase();
|
||||
const field = this.sortField.get();
|
||||
const direction = this.sortDirection.get();
|
||||
|
||||
const rows = [];
|
||||
board.cards().forEach(card => {
|
||||
const swimlane = card.getSwimlane();
|
||||
const list = card.getList();
|
||||
if (!swimlane || swimlane.archived || !list || list.archived) return;
|
||||
|
||||
const labels = (card.labelIds || [])
|
||||
.map(labelId => {
|
||||
const label = board.getLabelById(labelId);
|
||||
return label ? { name: label.name || '', color: label.color } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
rows.push({
|
||||
card,
|
||||
title: card.title || '',
|
||||
listTitle: list.title || '',
|
||||
swimlaneTitle: swimlane.title || '',
|
||||
colorClass: board.colorClass(),
|
||||
receivedAt: card.getReceived() || null,
|
||||
startAt: card.getStart() || null,
|
||||
dueAt: card.getDue() || null,
|
||||
endAt: card.getEnd() || null,
|
||||
labels,
|
||||
});
|
||||
});
|
||||
|
||||
let filtered = rows;
|
||||
if (query) {
|
||||
filtered = rows.filter(row => {
|
||||
const haystack = [
|
||||
row.title,
|
||||
row.listTitle,
|
||||
row.swimlaneTitle,
|
||||
...row.labels.map(label => label.name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.indexOf(query) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
// Map a date sort field to the matching row property.
|
||||
const dateFieldProp = {
|
||||
received: 'receivedAt',
|
||||
start: 'startAt',
|
||||
due: 'dueAt',
|
||||
end: 'endAt',
|
||||
};
|
||||
|
||||
filtered = filtered.slice().sort((a, b) => {
|
||||
const dateProp = dateFieldProp[field];
|
||||
if (dateProp) {
|
||||
// Cards without the date sort last, regardless of direction.
|
||||
const av = a[dateProp] ? new Date(a[dateProp]).getTime() : Infinity;
|
||||
const bv = b[dateProp] ? new Date(b[dateProp]).getTime() : Infinity;
|
||||
return (av - bv) * direction;
|
||||
}
|
||||
let av;
|
||||
let bv;
|
||||
if (field === 'list') {
|
||||
av = a.listTitle;
|
||||
bv = b.listTitle;
|
||||
} else if (field === 'swimlane') {
|
||||
av = a.swimlaneTitle;
|
||||
bv = b.swimlaneTitle;
|
||||
} else {
|
||||
av = a.title;
|
||||
bv = b.title;
|
||||
}
|
||||
return (
|
||||
av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) *
|
||||
direction
|
||||
);
|
||||
});
|
||||
|
||||
this.filteredRows.set(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
Template.tableView.helpers({
|
||||
currentBoard() {
|
||||
return Utils.getCurrentBoard();
|
||||
},
|
||||
|
||||
rows() {
|
||||
const tpl = Template.instance();
|
||||
const all = tpl.filteredRows.get();
|
||||
const totalPages = Math.max(1, Math.ceil(all.length / rowsPerPage));
|
||||
// Clamp on read so a shrinking list (deleted cards) never shows an empty
|
||||
// page; no write here, to avoid a reactive loop.
|
||||
const page = Math.min(tpl.page.get(), totalPages);
|
||||
const start = (page - 1) * rowsPerPage;
|
||||
return all.slice(start, start + rowsPerPage);
|
||||
},
|
||||
|
||||
currentPage() {
|
||||
return Template.instance().page.get();
|
||||
},
|
||||
|
||||
totalPages() {
|
||||
const count = Template.instance().filteredRows.get().length;
|
||||
return Math.max(1, Math.ceil(count / rowsPerPage));
|
||||
},
|
||||
|
||||
hasPrevPage() {
|
||||
return Template.instance().page.get() > 1;
|
||||
},
|
||||
|
||||
hasNextPage() {
|
||||
const tpl = Template.instance();
|
||||
const totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(tpl.filteredRows.get().length / rowsPerPage),
|
||||
);
|
||||
return tpl.page.get() < totalPages;
|
||||
},
|
||||
|
||||
// A date column is shown unless BOTH its "Show at Card" (allowsXxxDate) and
|
||||
// "Show at Minicard" (allowsXxxDateOnMinicard) board settings are unchecked.
|
||||
showReceivedColumn() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return !!board && (board.allowsReceivedDate || board.allowsReceivedDateOnMinicard);
|
||||
},
|
||||
|
||||
showStartColumn() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return !!board && (board.allowsStartDate || board.allowsStartDateOnMinicard);
|
||||
},
|
||||
|
||||
showDueColumn() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return !!board && (board.allowsDueDate || board.allowsDueDateOnMinicard);
|
||||
},
|
||||
|
||||
showEndColumn() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return !!board && (board.allowsEndDate || board.allowsEndDateOnMinicard);
|
||||
},
|
||||
|
||||
// Excel-like sort arrow shown on the active sort column header.
|
||||
sortIndicator(field) {
|
||||
const tpl = Template.instance();
|
||||
if (tpl.sortField.get() !== field) return '';
|
||||
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
|
||||
},
|
||||
});
|
||||
|
||||
Template.tableView.events({
|
||||
'click .js-table-view-search-button'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.searchQuery.set(tpl.$('.js-table-view-search').val() || '');
|
||||
tpl.page.set(1);
|
||||
},
|
||||
|
||||
'keydown .js-table-view-search'(event, tpl) {
|
||||
if (event.keyCode === 13) {
|
||||
event.preventDefault();
|
||||
tpl.searchQuery.set(tpl.$('.js-table-view-search').val() || '');
|
||||
tpl.page.set(1);
|
||||
}
|
||||
},
|
||||
|
||||
'click .js-table-view-prev-page'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const current = tpl.page.get();
|
||||
if (current > 1) tpl.page.set(current - 1);
|
||||
},
|
||||
|
||||
'click .js-table-view-next-page'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(tpl.filteredRows.get().length / rowsPerPage),
|
||||
);
|
||||
const current = tpl.page.get();
|
||||
if (current < totalPages) tpl.page.set(current + 1);
|
||||
},
|
||||
|
||||
'click .js-table-view-sort'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget.dataset.sort;
|
||||
if (!field) return;
|
||||
if (tpl.sortField.get() === field) {
|
||||
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
|
||||
} else {
|
||||
tpl.sortField.set(field);
|
||||
tpl.sortDirection.set(1);
|
||||
}
|
||||
tpl.page.set(1);
|
||||
},
|
||||
|
||||
// Clicking the leftmost "Edit" link opens the Card Details popup on top of the
|
||||
// Board Table view (same mechanism as opening a card from search results).
|
||||
'click .js-table-view-edit-card'(event) {
|
||||
event.preventDefault();
|
||||
const cardId = event.currentTarget.dataset.cardId;
|
||||
if (!cardId) return;
|
||||
const board = Utils.getCurrentBoard();
|
||||
Meteor.subscribe('popupCardData', cardId, {
|
||||
onReady() {
|
||||
Session.set('popupCardId', cardId);
|
||||
if (board) Session.set('popupCardBoardId', board._id);
|
||||
if (!Popup.isOpen()) {
|
||||
Popup.open('cardDetails')(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Adding a date to a card that has none. The data context of each add button
|
||||
// is the card (set with `with row.card` in the template), so the popup edits
|
||||
// the right card. Editing an existing date is handled by the cardXxxDate
|
||||
// badge templates themselves (their own .js-edit-date click handlers).
|
||||
'click .js-received-date': Popup.open('editCardReceivedDate'),
|
||||
'click .js-start-date': Popup.open('editCardStartDate'),
|
||||
'click .js-due-date': Popup.open('editCardDueDate'),
|
||||
'click .js-end-date': Popup.open('editCardEndDate'),
|
||||
});
|
||||
@@ -23,7 +23,7 @@
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
max-height: 150px;
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
.attachment-thumbnail {
|
||||
max-width: 150px;
|
||||
@@ -48,7 +48,7 @@
|
||||
.attachment-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-right: 25px; /* Make sure the icons are not to far to the right */
|
||||
margin-inline-end: 25px; /* Make sure the icons are not to far to the right */
|
||||
}
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
@@ -56,10 +56,10 @@
|
||||
align-items: center;
|
||||
}
|
||||
.attachment-actions a {
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
.attachment-actions a:first-child {
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
.add-attachment {
|
||||
display: flex;
|
||||
@@ -74,7 +74,7 @@
|
||||
.icon {
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
.icon:hover {
|
||||
color: #666;
|
||||
@@ -84,7 +84,7 @@
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
z-index: 9999 !important;
|
||||
background: rgba(13, 13, 13, 0.95);
|
||||
}
|
||||
@@ -113,7 +113,7 @@
|
||||
cursor: pointer;
|
||||
font-size: 4em;
|
||||
position: absolute;
|
||||
right: 50px;
|
||||
inset-inline-end: 50px;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
}
|
||||
|
||||
.upload-progress-header i {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
|
||||
.upload-progress-error i,
|
||||
.upload-progress-success i {
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
/* Minicard specific styles */
|
||||
@@ -263,14 +263,14 @@
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
margin-left: 70px;
|
||||
margin-inline-start: 70px;
|
||||
}
|
||||
#next-attachment {
|
||||
font-size: 4em;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
margin-right: 70px;
|
||||
margin-inline-end: 70px;
|
||||
}
|
||||
#viewer-content {
|
||||
display: flex;
|
||||
@@ -325,20 +325,20 @@
|
||||
padding: 16px;
|
||||
}
|
||||
#prev-attachment {
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
position: absolute;
|
||||
bottom: 2.2em;
|
||||
font-size: 1.6em;
|
||||
padding: 16px;
|
||||
margin-left: 0;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
#next-attachment {
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
position: absolute;
|
||||
bottom: 2.2em;
|
||||
font-size: 1.6em;
|
||||
padding: 16px;
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
#pdf-viewer {
|
||||
width: 100%;
|
||||
@@ -365,7 +365,7 @@
|
||||
}
|
||||
.attachment-details {
|
||||
flex-direction: column;
|
||||
margin-right: 0px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
.attachment-actions {
|
||||
flex-direction: row;
|
||||
|
||||
@@ -125,23 +125,6 @@ template(name="attachmentActionsPopup")
|
||||
else
|
||||
| {{_ 'add-background-image'}}
|
||||
|
||||
if $neq versions.original.storage "fs"
|
||||
a.js-move-storage-fs
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-fs'}}
|
||||
|
||||
if $neq versions.original.storage "gridfs"
|
||||
if versions.original.storage
|
||||
a.js-move-storage-gridfs
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-gridfs'}}
|
||||
|
||||
if $neq versions.original.storage "s3"
|
||||
if versions.original.storage
|
||||
a.js-move-storage-s3
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'attachment-move-storage-s3'}}
|
||||
|
||||
template(name="attachmentRenamePopup")
|
||||
input.js-edit-attachment-name(type='text' autofocus value="{{getNameWithoutExtension}}" dir="auto")
|
||||
.edit-controls.clearfix
|
||||
|
||||
@@ -7,6 +7,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
|
||||
import Attachments from '/models/attachments';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { formatDateTime } from '/imports/lib/dateUtils';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
|
||||
const { filesize } = require('filesize');
|
||||
import prettyMilliseconds from 'pretty-ms';
|
||||
@@ -44,6 +45,10 @@ Template.attachmentGallery.events({
|
||||
},
|
||||
'click .js-rename': Popup.open('attachmentRename'),
|
||||
'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', async function() {
|
||||
const card = this.meta && this.meta.cardId ? ReactiveCache.getCard(this.meta.cardId) : null;
|
||||
if (card && card.coverId === this._id) {
|
||||
await card.unsetCover();
|
||||
}
|
||||
await Attachments.removeAsync(this._id);
|
||||
Popup.back();
|
||||
}),
|
||||
@@ -84,6 +89,10 @@ function attachmentCanBeOpened(attachment) {
|
||||
);
|
||||
}
|
||||
|
||||
function getAttachmentUrl(attachment) {
|
||||
return attachment && typeof attachment.link === 'function' ? attachment.link() : '';
|
||||
}
|
||||
|
||||
function openAttachmentViewer(attachmentId) {
|
||||
const attachment = ReactiveCache.getAttachment(attachmentId);
|
||||
|
||||
@@ -100,18 +109,18 @@ function openAttachmentViewer(attachmentId) {
|
||||
*/
|
||||
switch(true){
|
||||
case (attachment.isImage):
|
||||
$("#image-viewer").attr("src", attachment.link());
|
||||
$("#image-viewer").attr("src", getAttachmentUrl(attachment));
|
||||
$("#image-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (attachment.isPDF):
|
||||
$("#pdf-viewer").attr("data", attachment.link());
|
||||
$("#pdf-viewer").attr("data", getAttachmentUrl(attachment));
|
||||
$("#pdf-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (attachment.isVideo):
|
||||
// We have to create a new <source> DOM element and append it to the video
|
||||
// element, otherwise the video won't load
|
||||
let videoSource = document.createElement('source');
|
||||
videoSource.setAttribute('src', attachment.link());
|
||||
videoSource.setAttribute('src', getAttachmentUrl(attachment));
|
||||
$("#video-viewer").append(videoSource);
|
||||
|
||||
$("#video-viewer").removeClass("hidden");
|
||||
@@ -120,14 +129,14 @@ function openAttachmentViewer(attachmentId) {
|
||||
// We have to create a new <source> DOM element and append it to the audio
|
||||
// element, otherwise the audio won't load
|
||||
let audioSource = document.createElement('source');
|
||||
audioSource.setAttribute('src', attachment.link());
|
||||
audioSource.setAttribute('src', getAttachmentUrl(attachment));
|
||||
$("#audio-viewer").append(audioSource);
|
||||
|
||||
$("#audio-viewer").removeClass("hidden");
|
||||
break;
|
||||
case (attachment.isText):
|
||||
case (attachment.isJSON):
|
||||
$("#txt-viewer").attr("data", attachment.link());
|
||||
$("#txt-viewer").attr("data", getAttachmentUrl(attachment));
|
||||
$("#txt-viewer").removeClass("hidden");
|
||||
break;
|
||||
}
|
||||
@@ -262,6 +271,24 @@ Template.attachmentViewer.events({
|
||||
});
|
||||
|
||||
Template.attachmentGallery.helpers({
|
||||
attachments() {
|
||||
const card = Template.currentData();
|
||||
if (!card) return [];
|
||||
const cardId = typeof card.getRealId === 'function' ? card.getRealId() : card._id;
|
||||
if (!cardId) return [];
|
||||
const filesCursor = Attachments.find(
|
||||
{ 'meta.cardId': cardId },
|
||||
{ sort: { uploadedAt: -1 } },
|
||||
);
|
||||
// Call fetch() on the underlying Mongo cursor to establish a reactive
|
||||
// dependency directly inside this Blaze computation. Without this,
|
||||
// .each() (which calls cursor.map()) would not register reactivity and
|
||||
// newly-uploaded attachments would not appear until a page reload.
|
||||
if (filesCursor && filesCursor.cursor) {
|
||||
filesCursor.cursor.fetch();
|
||||
}
|
||||
return filesCursor.each();
|
||||
},
|
||||
isBoardAdmin() {
|
||||
return ReactiveCache.getCurrentUser().isBoardAdmin();
|
||||
},
|
||||
@@ -314,6 +341,7 @@ Template.cardAttachmentsPopup.helpers({
|
||||
|
||||
Template.cardAttachmentsPopup.events({
|
||||
async 'change .js-attach-file'(event, templateInstance) {
|
||||
event.stopPropagation();
|
||||
const card = this;
|
||||
const files = event.currentTarget.files;
|
||||
if (files) {
|
||||
@@ -336,6 +364,10 @@ Template.cardAttachmentsPopup.events({
|
||||
}
|
||||
},
|
||||
'click .js-computer-upload'(event, templateInstance) {
|
||||
// Prevent the click fired when the OS file-picker dialog closes from
|
||||
// triggering EscapeActions and closing the popup before the upload starts.
|
||||
// Same pattern as swimlanes.js after drag operations.
|
||||
EscapeActions.preventNextClick();
|
||||
templateInstance.find('.js-attach-file').click();
|
||||
event.preventDefault();
|
||||
},
|
||||
@@ -400,6 +432,11 @@ export async function handleFileUpload(card, files) {
|
||||
fileName: fileName,
|
||||
meta: Utils.getCommonAttachmentMetaFrom(card),
|
||||
chunkSize: 'dynamic',
|
||||
// Use HTTP transport instead of DDP so file chunks go over a dedicated
|
||||
// fetch POST rather than flooding the WebSocket/DDP channel. DDP
|
||||
// upload causes repeated DDP reconnects in Safari, showing the
|
||||
// "Loading, please wait" offline banner and stalling progress at ~95%.
|
||||
transport: 'http',
|
||||
};
|
||||
config.meta.fileId = fileId;
|
||||
|
||||
@@ -491,6 +528,7 @@ Template.previewClipboardImagePopup.events({
|
||||
meta: Utils.getCommonAttachmentMetaFrom(card),
|
||||
fileName: file.name || file.type.replace('image/', 'clipboard.'),
|
||||
chunkSize: 'dynamic',
|
||||
transport: 'http',
|
||||
};
|
||||
config.meta.fileId = fileId;
|
||||
const uploader = await Attachments.insertAsync(
|
||||
@@ -550,23 +588,11 @@ Template.attachmentActionsPopup.events({
|
||||
Utils.reload();
|
||||
event.preventDefault();
|
||||
},
|
||||
'click .js-move-storage-fs'() {
|
||||
Meteor.call('moveAttachmentToStorage', this._id, "fs");
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-move-storage-gridfs'() {
|
||||
Meteor.call('moveAttachmentToStorage', this._id, "gridfs");
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-move-storage-s3'() {
|
||||
Meteor.call('moveAttachmentToStorage', this._id, "s3");
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.attachmentRenamePopup.helpers({
|
||||
getNameWithoutExtension() {
|
||||
const ret = this.name.replace(new RegExp("\." + this.extension + "$"), "");
|
||||
const ret = this.name.replace(new RegExp("\\." + this.extension + "$"), "");
|
||||
return ret;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,11 +55,10 @@ template(name="cardCustomField-number")
|
||||
template(name="cardCustomField-checkbox")
|
||||
.js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}")
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
|
||||
template(name="cardCustomField-currency")
|
||||
if canModifyCard
|
||||
|
||||
@@ -106,9 +106,6 @@ Template['cardCustomField-checkbox'].onCreated(function () {
|
||||
});
|
||||
|
||||
Template['cardCustomField-checkbox'].events({
|
||||
'click .js-checklist-item .check-box-unicode'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
'click .js-checklist-item .check-box-container'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
@@ -170,7 +167,7 @@ Template['cardCustomField-date'].helpers({
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showISODate() {
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
.card-date time::before {
|
||||
font-size: inherit;
|
||||
margin-right: 0.3em;
|
||||
margin-inline-end: 0.3em;
|
||||
display: inline-block;
|
||||
}
|
||||
.customfield-date {
|
||||
|
||||
@@ -146,14 +146,12 @@ function cardDateHelpers(extraHelpers) {
|
||||
},
|
||||
showWeekOfYear() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
if (!user) return window.localStorage.getItem('showWeekOfYear') === 'true';
|
||||
return user.isShowWeekOfYear();
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showISODate() {
|
||||
@@ -196,7 +194,7 @@ Template.cardReceivedDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
|
||||
},
|
||||
@@ -237,7 +235,7 @@ Template.cardStartDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
|
||||
},
|
||||
@@ -286,7 +284,7 @@ Template.cardDueDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-due-on')} ${formattedDate}`;
|
||||
},
|
||||
@@ -356,7 +354,7 @@ Template.cardCustomFieldDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${formattedDate}`;
|
||||
},
|
||||
@@ -400,13 +398,13 @@ Template.minicardReceivedDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
}));
|
||||
@@ -446,13 +444,13 @@ Template.minicardStartDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
}));
|
||||
@@ -500,13 +498,13 @@ Template.minicardDueDate.helpers(cardDateHelpers({
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${TAPi18n.__('card-due-on')} ${formattedDate}`;
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
}));
|
||||
@@ -549,7 +547,7 @@ Template.minicardEndDate.helpers(cardDateHelpers({
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
}));
|
||||
@@ -570,13 +568,13 @@ Template.minicardCustomFieldDate.onCreated(function () {
|
||||
Template.minicardCustomFieldDate.helpers(cardDateHelpers({
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showTitle() {
|
||||
const tpl = Template.instance();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
|
||||
return `${formattedDate}`;
|
||||
},
|
||||
@@ -602,7 +600,7 @@ Template.voteEndDate.helpers(cardDateHelpers({
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showTitle() {
|
||||
@@ -630,7 +628,7 @@ Template.pokerEndDate.helpers(cardDateHelpers({
|
||||
},
|
||||
showDate() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : 'YYYY-MM-DD';
|
||||
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
|
||||
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
|
||||
},
|
||||
showTitle() {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
.assignee {
|
||||
display: block;
|
||||
position: relative;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
height: clamp(24px, 3.5vw, 36px);
|
||||
width: clamp(24px, 3.5vw, 36px);
|
||||
margin: 0.3vh;
|
||||
@@ -73,7 +73,7 @@
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
inset-inline-end: -1px;
|
||||
bottom: -1px;
|
||||
z-index: 15;
|
||||
}
|
||||
@@ -142,36 +142,36 @@ body.desktop-mode .card-details:not(.card-details-popup) {
|
||||
/* Default position for first card or when dragged */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) {
|
||||
top: 8px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
inset-inline-start: 20px;
|
||||
inset-inline-end: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
/* Stagger positions for multiple cards using nth-of-type */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
|
||||
top: 8px;
|
||||
left: 20px;
|
||||
inset-inline-start: 20px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) {
|
||||
top: 38px;
|
||||
left: 50px;
|
||||
inset-inline-start: 50px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) {
|
||||
top: 68px;
|
||||
left: 80px;
|
||||
inset-inline-start: 80px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) {
|
||||
top: 98px;
|
||||
left: 110px;
|
||||
inset-inline-start: 110px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) {
|
||||
top: 128px;
|
||||
left: 140px;
|
||||
inset-inline-start: 140px;
|
||||
}
|
||||
|
||||
/* For expanded cards, set dimensions */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) {
|
||||
right: 20px;
|
||||
inset-inline-end: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
bottom: auto !important;
|
||||
}
|
||||
.card-details .mCustomScrollBox {
|
||||
padding-left: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
.card-details .card-details-canvas {
|
||||
width: auto;
|
||||
@@ -208,16 +208,16 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-number {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
|
||||
/* Collapse toggle triangle */
|
||||
.card-details .card-details-header .card-collapse-toggle {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
font-size: 20px;
|
||||
padding: 7px 10px;
|
||||
margin-left: -10px;
|
||||
margin-right: 5px;
|
||||
margin-inline-start: -10px;
|
||||
margin-inline-end: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: #000;
|
||||
@@ -228,11 +228,11 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-drag-handle {
|
||||
font-size: 20px;
|
||||
padding: 8px 10px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
vertical-align: middle;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -246,14 +246,14 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .close-card-details-mobile-web,
|
||||
.card-details .card-details-header .card-details-menu-mobile-web,
|
||||
.card-details .card-details-header .copied-tooltip {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.card-details .card-details-header .close-card-details,
|
||||
.card-details .card-details-header .maximize-card-details,
|
||||
.card-details .card-details-header .minimize-card-details {
|
||||
font-size: 24px;
|
||||
padding: 5px 10px 5px 10px;
|
||||
margin-right: -8px;
|
||||
margin-inline-end: -8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
@@ -264,19 +264,19 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle {
|
||||
font-size: 24px;
|
||||
padding: 5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.card-details .card-details-header .card-copy-button {
|
||||
font-size: 17px;
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.card-details .card-details-header .card-copy-mobile-button {
|
||||
font-size: 17px;
|
||||
padding: 10px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.card-details .card-details-header .card-details-menu {
|
||||
font-size: 17px;
|
||||
@@ -287,17 +287,17 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
.card-details .card-details-header .card-details-menu-mobile-web {
|
||||
font-size: 17px;
|
||||
padding: 10px;
|
||||
margin-right: 30px;
|
||||
margin-inline-end: 30px;
|
||||
}
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle,
|
||||
.card-details .card-details-header .card-zoom-in,
|
||||
.card-details .card-details-header .card-zoom-out {
|
||||
font-size: 24px;
|
||||
padding: 5px 10px 5px 10px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
|
||||
/* Unify all card text to match title size */
|
||||
@@ -321,7 +321,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
}
|
||||
.card-details .card-details-header .card-details-watch {
|
||||
font-size: 17px;
|
||||
padding-left: 7px;
|
||||
padding-inline-start: 7px;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
.card-details .card-details-header .card-details-title {
|
||||
@@ -363,11 +363,11 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
padding: 0 5px;
|
||||
}
|
||||
.card-details .card-details-header .copied-tooltip {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
.card-details .card-description i.fa.fa-pencil-square-o {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.card-details .card-description textarea {
|
||||
min-height: 100px;
|
||||
@@ -380,11 +380,11 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
gap: 0.5em;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item {
|
||||
margin-right: 0.5em;
|
||||
margin-inline-end: 0.5em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item:last-child {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item.card-details-item-labels {
|
||||
display: block;
|
||||
@@ -409,7 +409,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
max-width: 28%;
|
||||
}
|
||||
.card-details .card-details-items .card-details-item.custom-fields {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.card-details .card-details-item-title {
|
||||
font-size: 16px;
|
||||
@@ -422,7 +422,7 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
@media screen and (min-width: 801px) {
|
||||
.card-details {
|
||||
top: 97px;
|
||||
left: calc(50% - (600px / 2));
|
||||
inset-inline-start: calc(50% - (600px / 2));
|
||||
width: 600px;
|
||||
bottom: 0;
|
||||
position: fixed;
|
||||
@@ -433,8 +433,8 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
body.mobile-mode .card-details {
|
||||
width: 100vw !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
@@ -456,23 +456,23 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
transition: flex-basis 0.1s;
|
||||
box-sizing: border-box;
|
||||
top: 97px;
|
||||
left: 0px;
|
||||
inset-inline-start: 0px;
|
||||
height: calc(100% - 100px);
|
||||
width: calc(100% - 20px);
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.card-details-maximized .card-details-left {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
top: 60px;
|
||||
left: 20px;
|
||||
inset-inline-start: 20px;
|
||||
width: 47%;
|
||||
border-right: solid 2px #dbdbdb;
|
||||
padding-right: 10px;
|
||||
border-inline-end: solid 2px #dbdbdb;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
.card-details-maximized .card-details-right {
|
||||
position: absolute;
|
||||
float: right;
|
||||
left: 50%;
|
||||
float: inline-end;
|
||||
inset-inline-start: 50%;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.card-details-maximized .card-details-header {
|
||||
@@ -480,12 +480,12 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
}
|
||||
}
|
||||
input[type="text"].attachment-add-link-input {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
margin: 0 0 8px;
|
||||
width: 80%;
|
||||
}
|
||||
input[type="submit"].attachment-add-link-submit {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
margin: 0 0 8px 4px;
|
||||
padding: 6px 12px;
|
||||
width: 18%;
|
||||
@@ -501,8 +501,8 @@ input[type="submit"].attachment-add-link-submit {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 100 !important;
|
||||
height: 100vh !important;
|
||||
@@ -515,38 +515,38 @@ input[type="submit"].attachment-add-link-submit {
|
||||
body.mobile-mode .card-details {
|
||||
z-index: 100 !important;
|
||||
width: 100vw !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
}
|
||||
.card-details .card-details-canvas {
|
||||
width: 100%;
|
||||
padding-left: 0px;
|
||||
padding-inline-start: 0px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.card-details .card-details-header .close-card-details {
|
||||
margin-right: 0px;
|
||||
margin-inline-end: 0px;
|
||||
display: block !important;
|
||||
}
|
||||
.card-details .card-details-header .close-card-details-mobile-web {
|
||||
display: block !important;
|
||||
margin-right: 5px !important;
|
||||
margin-inline-end: 5px !important;
|
||||
}
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle {
|
||||
display: block !important;
|
||||
margin-right: 5px !important;
|
||||
margin-inline-end: 5px !important;
|
||||
}
|
||||
.card-details .card-details-header .card-mobile-desktop-toggle {
|
||||
display: block !important;
|
||||
margin-right: 5px !important;
|
||||
margin-inline-end: 5px !important;
|
||||
}
|
||||
.card-details .card-details-header .card-details-menu {
|
||||
margin-right: 40px;
|
||||
margin-inline-end: 40px;
|
||||
}
|
||||
.card-details .card-details-header .maximize-card-details {
|
||||
margin-right: 40px;
|
||||
margin-inline-end: 40px;
|
||||
}
|
||||
.card-details .card-details-header .minimize-card-details {
|
||||
margin-right: 40px;
|
||||
margin-inline-end: 40px;
|
||||
}
|
||||
.card-details-popup {
|
||||
padding: 0px 10px;
|
||||
@@ -565,7 +565,7 @@ input[type="submit"].attachment-add-link-submit {
|
||||
}
|
||||
/* iPhone mobile: enlarge header buttons and increase spacing */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header {
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details,
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .maximize-card-details,
|
||||
@@ -577,12 +577,12 @@ input[type="submit"].attachment-add-link-submit {
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-out {
|
||||
font-size: 2em !important; /* 2x bigger */
|
||||
padding: 0.3em !important;
|
||||
margin-right: 0.75em !important; /* 2x space compared to default */
|
||||
margin-left: 0 !important;
|
||||
margin-inline-end: 0.75em !important; /* 2x space compared to default */
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
/* Avoid clipping of the close button on the right edge */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details {
|
||||
margin-right: 0.75em !important;
|
||||
margin-inline-end: 0.75em !important;
|
||||
}
|
||||
/* Enlarge the header title too */
|
||||
body.mobile-mode.iphone-device .card-details .card-details-header .card-details-title {
|
||||
@@ -598,8 +598,8 @@ body.mobile-mode .card-details {
|
||||
margin: 0px !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 100 !important;
|
||||
height: 100vh !important;
|
||||
@@ -731,7 +731,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.vote-title .js-edit-date {
|
||||
align-self: flex-start;
|
||||
margin-left: 6px;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
.vote-result {
|
||||
display: flex;
|
||||
@@ -750,7 +750,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.poker-title .js-edit-date {
|
||||
align-self: flex-start;
|
||||
margin-left: 6px;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
.poker-result {
|
||||
display: flex;
|
||||
@@ -827,11 +827,11 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
display: inline-block;
|
||||
}
|
||||
.poker-table-side-right {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.poker-table-side-right {
|
||||
padding-left: 0px;
|
||||
padding-inline-start: 0px;
|
||||
}
|
||||
}
|
||||
.estimation-add {
|
||||
@@ -842,16 +842,16 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.estimation-add input {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
margin: auto;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
width: 100px;
|
||||
border-radius: 2px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
.estimation-add button {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
margin: auto;
|
||||
border-radius: 2px;
|
||||
padding: 3px 10px;
|
||||
@@ -859,7 +859,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
.poker-card {
|
||||
width: 48px;
|
||||
height: 72px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
display: table;
|
||||
@@ -890,3 +890,194 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
transform: scale(1.01);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sticker picker + card-detail stickers + due-complete toggle */
|
||||
.card-stickers-picker {
|
||||
max-width: 320px;
|
||||
}
|
||||
.card-sticker-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 2px;
|
||||
border-radius: 3px;
|
||||
font-size: 18px;
|
||||
color: #4d4d4d;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.card-sticker-select:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.card-sticker-select.active {
|
||||
background: #d6ecd2;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-sticker-select .card-sticker-check {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
inset-inline-end: 2px;
|
||||
font-size: 10px;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-stickers .card-sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 8px 4px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.card-sticker .card-sticker-remove {
|
||||
margin-inline-start: 3px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-sticker .card-sticker-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
.card-due-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-inline-start: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the card title */
|
||||
.card-details-complete-toggle {
|
||||
float: inline-start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Title is 1.35em with 1.3 line-height (~28px box) and margin-top 7px; push
|
||||
the 13px checkbox down so it lands at the vertical middle of the title. */
|
||||
margin: 14px 10px 0 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Card locations: multiple locations, each editable with a remove button */
|
||||
.card-details-item-location .card-locations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-details-item-location .card-location {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e3e3;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.card-details-item-location .card-location-info {
|
||||
flex: 1;
|
||||
}
|
||||
.card-details-item-location .js-edit-location {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.card-details-item-location .card-location-address {
|
||||
color: #4d4d4d;
|
||||
}
|
||||
.card-details-item-location .card-location-remove {
|
||||
margin-inline-start: 8px;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.card-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* "Open map links at" provider selector in the location popup */
|
||||
.js-card-location-form .map-provider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.js-card-location-form .map-provider-row .js-map-provider {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Opened card (card details popup) docks to the very top of the window and
|
||||
overlays the global + board header bars instead of opening below them. The
|
||||
base .pop-over is position:absolute with a small margin/top offset, which left
|
||||
the card's top behind the fixed header bars; fix it to the viewport top with a
|
||||
z-index above every header (headers top out around z-index 1000-2000). */
|
||||
/* The card details content (.card-details) is always position:fixed (see the
|
||||
min-width:801px and max-width:800px media queries above), so it renders as its
|
||||
own framed, viewport-positioned box and escapes this .pop-over wrapper. That
|
||||
leaves the generic .pop-over chrome (white background, border, shadow, header
|
||||
and ~380px width) visible as a redundant empty second popup at the top of the
|
||||
page. Collapse the wrapper to nothing and make it invisible; the fixed card
|
||||
descendant still renders normally on top of the page. Closing is handled by
|
||||
the card's own close button (.js-close-card-details), clicking outside, or
|
||||
pressing Escape. */
|
||||
.pop-over[data-popup="cardDetailsPopup"] {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
inset-inline-start: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
min-height: 0 !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
overflow: visible !important;
|
||||
z-index: 100000 !important;
|
||||
}
|
||||
|
||||
.pop-over[data-popup="cardDetailsPopup"] > .header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pop-over[data-popup="cardDetailsPopup"] > .content-wrapper {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Sticker highlight styles inside the sticker picker (mascot = underline,
|
||||
computer = round), matching how they render on the card. */
|
||||
.card-sticker-select i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker-select i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -61,12 +61,16 @@ template(name="cardDetails")
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
if canModifyCard
|
||||
if currentBoard.allowsDueComplete
|
||||
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
h2.card-details-title.js-card-title(
|
||||
class="{{#if canModifyCard}}js-open-inlined-form is-editable{{else}}js-card-title-drag-handle{{/if}}")
|
||||
+viewer
|
||||
if currentBoard.allowsCardNumber
|
||||
span.card-number
|
||||
| ##{getCardNumber}
|
||||
| ##{getCardNumber}
|
||||
= getTitle
|
||||
if isWatching
|
||||
i.card-details-watch
|
||||
@@ -126,6 +130,49 @@ template(name="cardDetails")
|
||||
a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-stickers
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-sticky-note-o
|
||||
| {{_ 'stickers'}}
|
||||
.card-details-item-content.card-stickers
|
||||
each stickers
|
||||
span.card-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if canModifyCard
|
||||
a.card-sticker-remove.js-remove-sticker(data-index="{{@index}}" title="{{_ 'remove-sticker'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-stickers(title="{{_ 'add-sticker'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-location
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-map-marker
|
||||
| {{_ 'location'}}
|
||||
.card-details-item-content.card-locations
|
||||
each getLocations
|
||||
.card-location
|
||||
.card-location-info(class="{{#if canModifyCard}}js-edit-location{{/if}}" data-location-id="{{_id}}")
|
||||
if name
|
||||
.card-location-name= name
|
||||
if address
|
||||
.card-location-address= address
|
||||
if hasCoordinates
|
||||
a.card-location-map(href=mapUrl target="_blank" rel="noopener noreferrer")
|
||||
i.fa.fa-map-o
|
||||
= ' '
|
||||
| {{_ 'location-open-map'}}
|
||||
if canModifyCard
|
||||
a.card-location-remove.js-remove-location(data-location-id="{{_id}}" title="{{_ 'delete'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
+cardButtons(_id=_id boardId=boardId)
|
||||
|
||||
if currentBoard.hasAnyAllowsDate
|
||||
hr
|
||||
|
||||
@@ -275,6 +322,36 @@ template(name="cardDetails")
|
||||
+viewer
|
||||
= getAssignedBy
|
||||
|
||||
.card-details-item.card-details-item-dependencies
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-link
|
||||
| {{_ 'card-dependencies'}}
|
||||
.card-details-dependencies-list
|
||||
each getDependencyCards
|
||||
.card-dependency(data-target-id="{{card._id}}")
|
||||
i.fa(class="fa-{{icon}}" style="color:{{color}}")
|
||||
a.card-dependency-link(href="{{linkUrl}}")
|
||||
+viewer
|
||||
= card.title
|
||||
if canModifyCard
|
||||
select.js-dependency-type(data-target-id="{{card._id}}")
|
||||
each typeOption
|
||||
if selected
|
||||
option(value="{{id}}" selected="selected") {{_ label}}
|
||||
else
|
||||
option(value="{{id}}") {{_ label}}
|
||||
input.js-dependency-color(type="color" value="{{color}}" data-target-id="{{card._id}}" title="{{_ 'dependency-color'}}")
|
||||
a.js-dependency-icon(data-target-id="{{card._id}}" title="{{_ 'dependency-icon'}}")
|
||||
i.fa.fa-pencil
|
||||
a.card-dependency-remove.js-remove-dependency(data-target-id="{{card._id}}" title="{{_ 'remove-dependency'}}")
|
||||
i.fa.fa-times
|
||||
else
|
||||
span.card-dependency-type {{_ typeLabel}}
|
||||
if canModifyCard
|
||||
a.js-add-dependency(title="{{_ 'add-dependency'}}")
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-dependency'}}
|
||||
|
||||
if $or currentBoard.allowsCardSortingByNumber getSpentTime
|
||||
hr
|
||||
|
||||
@@ -297,7 +374,7 @@ template(name="cardDetails")
|
||||
i.fa.fa-list
|
||||
| {{_ 'list'}}
|
||||
select.js-select-card-details-lists(disabled="{{#unless canModifyCard}}disabled{{/unless}}")
|
||||
each currentBoard.lists
|
||||
each currentSwimlaneListsSorted
|
||||
option(value="{{_id}}" selected="{{#if isCurrentListId _id}}selected{{/if}}") {{title}}
|
||||
|
||||
//.card-details-items
|
||||
@@ -624,19 +701,18 @@ template(name="cardDetails")
|
||||
+attachmentGallery
|
||||
hr
|
||||
|
||||
unless currentUser.isNoComments
|
||||
if currentBoard.allowsComments
|
||||
.comment-title
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-comment-o
|
||||
| {{_ 'comments'}}
|
||||
|
||||
if currentBoard.allowsComments
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isNoComments
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
+commentForm
|
||||
+comments
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isNoComments
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
+commentForm
|
||||
+comments
|
||||
hr
|
||||
|
||||
.card-details-right
|
||||
@@ -691,154 +767,156 @@ template(name="editCardSortOrderForm")
|
||||
a.js-close-inlined-form
|
||||
|
||||
template(name="cardDetailsActionsPopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-toggle-watch-card
|
||||
if isWatching
|
||||
i.fa.fa-eye
|
||||
| {{_ 'unwatch'}}
|
||||
else
|
||||
i.fa.fa-eye
|
||||
| {{_ 'watch'}}
|
||||
hr
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
//li: a.js-members {{_ 'card-edit-members'}}
|
||||
//li: a.js-labels {{_ 'card-edit-labels'}}
|
||||
//li: a.js-attachments {{_ 'card-edit-attachments'}}
|
||||
li
|
||||
a.js-start-voting
|
||||
i.fa.fa-thumbs-up
|
||||
| {{_ 'card-edit-voting'}}
|
||||
li
|
||||
a.js-start-planning-poker
|
||||
i.fa.fa-thumbs-up
|
||||
| {{_ 'card-edit-planning-poker'}}
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-custom-fields
|
||||
i.fa.fa-list
|
||||
| {{_ 'card-edit-custom-fields'}}
|
||||
//li: a.js-received-date {{_ 'editCardReceivedDatePopup-title'}}
|
||||
//li: a.js-start-date {{_ 'editCardStartDatePopup-title'}}
|
||||
//li: a.js-due-date {{_ 'editCardDueDatePopup-title'}}
|
||||
//li: a.js-end-date {{_ 'editCardEndDatePopup-title'}}
|
||||
li
|
||||
a.js-spent-time
|
||||
i.fa.fa-clock-o
|
||||
| {{_ 'editCardSpentTimePopup-title'}}
|
||||
li
|
||||
a.js-set-card-color
|
||||
i.fa.fa-paint-brush
|
||||
| {{_ 'setCardColorPopup-title'}}
|
||||
li
|
||||
a.js-toggle-show-list-on-minicard
|
||||
if showListOnMinicard
|
||||
i.fa.fa-eye
|
||||
| {{_ 'hide-list-on-minicard'}}
|
||||
else
|
||||
i.fa.fa-eye
|
||||
| {{_ 'show-list-on-minicard'}}
|
||||
if canModifyCard
|
||||
if currentUser
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-toggle-watch-card
|
||||
if isWatching
|
||||
i.fa.fa-eye
|
||||
| {{_ 'unwatch'}}
|
||||
else
|
||||
i.fa.fa-eye
|
||||
| {{_ 'watch'}}
|
||||
hr
|
||||
else
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
hr
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
//li: a.js-members {{_ 'card-edit-members'}}
|
||||
//li: a.js-labels {{_ 'card-edit-labels'}}
|
||||
//li: a.js-attachments {{_ 'card-edit-attachments'}}
|
||||
li
|
||||
a.js-start-voting
|
||||
i.fa.fa-thumbs-up
|
||||
| {{_ 'card-edit-voting'}}
|
||||
li
|
||||
a.js-start-planning-poker
|
||||
i.fa.fa-thumbs-up
|
||||
| {{_ 'card-edit-planning-poker'}}
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-custom-fields
|
||||
i.fa.fa-list
|
||||
| {{_ 'card-edit-custom-fields'}}
|
||||
//li: a.js-received-date {{_ 'editCardReceivedDatePopup-title'}}
|
||||
//li: a.js-start-date {{_ 'editCardStartDatePopup-title'}}
|
||||
//li: a.js-due-date {{_ 'editCardDueDatePopup-title'}}
|
||||
//li: a.js-end-date {{_ 'editCardEndDatePopup-title'}}
|
||||
li
|
||||
a.js-spent-time
|
||||
i.fa.fa-clock-o
|
||||
| {{_ 'editCardSpentTimePopup-title'}}
|
||||
li
|
||||
a.js-set-card-color
|
||||
i.fa.fa-paint-brush
|
||||
| {{_ 'setCardColorPopup-title'}}
|
||||
li
|
||||
a.js-toggle-show-list-on-minicard
|
||||
if showListOnMinicard
|
||||
i.fa.fa-eye
|
||||
| {{_ 'hide-list-on-minicard'}}
|
||||
else
|
||||
i.fa.fa-eye
|
||||
| {{_ 'show-list-on-minicard'}}
|
||||
if canModifyCard
|
||||
hr
|
||||
else
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-export-card
|
||||
i.fa.fa-upload
|
||||
| {{_ 'export-card'}}
|
||||
unless canModifyCard
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-move-card-to-top
|
||||
i.fa.fa-arrow-up
|
||||
| {{_ 'moveCardToTop-title'}}
|
||||
li
|
||||
a.js-move-card-to-bottom
|
||||
i.fa.fa-arrow-down
|
||||
| {{_ 'moveCardToBottom-title'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-move-card
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'moveCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
li
|
||||
a.js-copy-card
|
||||
i.fa.fa-clipboard
|
||||
| {{_ 'copyCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-copy-checklist-cards
|
||||
i.fa.fa-copy
|
||||
| {{_ 'copyManyCardsPopup-title'}}
|
||||
unless archived
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-archive
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archive-card'}}
|
||||
if currentUser
|
||||
unless canModifyCard
|
||||
unless currentUser.isReadOnly
|
||||
unless currentUser.isReadAssignedOnly
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-more
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
| {{_ 'cardMorePopup-title'}}
|
||||
if canModifyCard
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-move-card-to-top
|
||||
i.fa.fa-arrow-up
|
||||
| {{_ 'moveCardToTop-title'}}
|
||||
li
|
||||
a.js-move-card-to-bottom
|
||||
i.fa.fa-arrow-down
|
||||
| {{_ 'moveCardToBottom-title'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-move-card
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'moveCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
li
|
||||
a.js-copy-card
|
||||
i.fa.fa-clipboard
|
||||
| {{_ 'copyCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-copy-checklist-cards
|
||||
i.fa.fa-copy
|
||||
| {{_ 'copyManyCardsPopup-title'}}
|
||||
unless archived
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-archive
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archive-card'}}
|
||||
a.js-move-card-to-top
|
||||
i.fa.fa-arrow-up
|
||||
| {{_ 'moveCardToTop-title'}}
|
||||
li
|
||||
a.js-move-card-to-bottom
|
||||
i.fa.fa-arrow-down
|
||||
| {{_ 'moveCardToBottom-title'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-move-card
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'moveCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
li
|
||||
a.js-copy-card
|
||||
i.fa.fa-clipboard
|
||||
| {{_ 'copyCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-copy-checklist-cards
|
||||
i.fa.fa-copy
|
||||
| {{_ 'copyManyCardsPopup-title'}}
|
||||
unless archived
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-archive
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archive-card'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-more
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
| {{_ 'cardMorePopup-title'}}
|
||||
if canModifyCard
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-more
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
| {{_ 'cardMorePopup-title'}}
|
||||
a.js-move-card-to-top
|
||||
i.fa.fa-arrow-up
|
||||
| {{_ 'moveCardToTop-title'}}
|
||||
li
|
||||
a.js-move-card-to-bottom
|
||||
i.fa.fa-arrow-down
|
||||
| {{_ 'moveCardToBottom-title'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
if currentUser.isBoardAdmin
|
||||
li
|
||||
a.js-move-card
|
||||
i.fa.fa-arrow-right
|
||||
| {{_ 'moveCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
li
|
||||
a.js-copy-card
|
||||
i.fa.fa-clipboard
|
||||
| {{_ 'copyCardPopup-title'}}
|
||||
unless currentUser.isWorker
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-copy-checklist-cards
|
||||
i.fa.fa-copy
|
||||
| {{_ 'copyManyCardsPopup-title'}}
|
||||
unless archived
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-archive
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archive-card'}}
|
||||
hr
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-more
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
| {{_ 'cardMorePopup-title'}}
|
||||
|
||||
template(name="exportCardPopup")
|
||||
ul.pop-over-list
|
||||
@@ -867,17 +945,17 @@ template(name="moveCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
@@ -902,17 +980,17 @@ template(name="copyCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
@@ -937,17 +1015,17 @@ template(name="copyManyCardsPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
@@ -972,17 +1050,17 @@ template(name="convertChecklistItemToCardPopup")
|
||||
label {{_ 'boards'}}:
|
||||
select.js-select-boards(autofocus)
|
||||
each boards
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'swimlanes'}}:
|
||||
select.js-select-swimlanes
|
||||
each swimlanes
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
|
||||
|
||||
label {{_ 'lists'}}:
|
||||
select.js-select-lists
|
||||
each lists
|
||||
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
option(value="{{_id}}" selected="{{#if isSelectedListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
|
||||
|
||||
label {{_ 'cards'}}:
|
||||
select.js-select-cards
|
||||
@@ -1195,3 +1273,78 @@ template(name="cardStartPlanningPokerPopup")
|
||||
if getPokerQuestion
|
||||
if currentUser.isBoardAdmin
|
||||
button.js-remove-poker.negate.wide.right {{_ 'delete'}}
|
||||
|
||||
template(name="cardLocationsPopup")
|
||||
form.js-card-location-form
|
||||
label
|
||||
| {{_ 'location-detect-from-map'}}
|
||||
input.js-location-map-link(type="text" placeholder="https://..." autofocus)
|
||||
button.primary.js-detect-location(type="button") {{_ 'location-detect'}}
|
||||
if detectMessage
|
||||
p.quiet.js-detect-message {{detectMessage}}
|
||||
hr
|
||||
label
|
||||
| {{_ 'location-name'}}
|
||||
input.js-location-name(type="text" value="{{location.name}}")
|
||||
label
|
||||
| {{_ 'location-address'}}
|
||||
input.js-location-address(type="text" value="{{location.address}}")
|
||||
label
|
||||
| {{_ 'location-latitude'}}
|
||||
input.js-location-latitude(type="number" step="any" value="{{location.latitude}}")
|
||||
label
|
||||
| {{_ 'location-longitude'}}
|
||||
input.js-location-longitude(type="number" step="any" value="{{location.longitude}}")
|
||||
button.primary.confirm.js-submit-location(type="submit") {{_ 'save'}}
|
||||
if location._id
|
||||
button.negate.wide.right.js-delete-location(type="button") {{_ 'delete'}}
|
||||
hr
|
||||
.map-provider-row
|
||||
span.map-provider-label {{_ 'location-open-map-at'}}
|
||||
select.js-map-provider
|
||||
optgroup(label="{{_ 'map-region-usa'}}")
|
||||
option(value="google" selected="{{#if isMapProvider 'google'}}selected{{/if}}") Google Maps
|
||||
option(value="bing" selected="{{#if isMapProvider 'bing'}}selected{{/if}}") Bing Maps
|
||||
option(value="apple" selected="{{#if isMapProvider 'apple'}}selected{{/if}}") Apple Maps
|
||||
option(value="waze" selected="{{#if isMapProvider 'waze'}}selected{{/if}}") Waze
|
||||
optgroup(label="{{_ 'map-region-europe'}}")
|
||||
option(value="openstreetmap" selected="{{#if isMapProvider 'openstreetmap'}}selected{{/if}}") OpenStreetMap
|
||||
option(value="here" selected="{{#if isMapProvider 'here'}}selected{{/if}}") HERE WeGo
|
||||
option(value="yandex" selected="{{#if isMapProvider 'yandex'}}selected{{/if}}") Yandex Maps
|
||||
option(value="mapy" selected="{{#if isMapProvider 'mapy'}}selected{{/if}}") Mapy.cz
|
||||
option(value="2gis" selected="{{#if isMapProvider '2gis'}}selected{{/if}}") 2GIS
|
||||
optgroup(label="{{_ 'map-region-asia'}}")
|
||||
option(value="baidu" selected="{{#if isMapProvider 'baidu'}}selected{{/if}}") Baidu Maps
|
||||
option(value="amap" selected="{{#if isMapProvider 'amap'}}selected{{/if}}") Amap (Gaode)
|
||||
button.primary.js-save-map-provider(type="button") {{_ 'save'}}
|
||||
if mapSavedMessage
|
||||
p.quiet.js-map-saved {{mapSavedMessage}}
|
||||
|
||||
template(name="cardDependenciesPopup")
|
||||
.card-dependencies-popup
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-type'}}
|
||||
select.js-new-dependency-type
|
||||
each typeOption
|
||||
if selected
|
||||
option(value="{{id}}" selected="selected") {{_ label}}
|
||||
else
|
||||
option(value="{{id}}") {{_ label}}
|
||||
label.dependency-field
|
||||
span {{_ 'dependency-color'}}
|
||||
input.js-new-dependency-color(type="color" value="{{defaultColor}}")
|
||||
input.js-dependency-search(type="text" placeholder="{{_ 'search'}}" autofocus)
|
||||
ul.pop-over-list
|
||||
each candidateCards
|
||||
li
|
||||
a.js-pick-dependency(data-target-id="{{_id}}")
|
||||
+viewer
|
||||
= title
|
||||
|
||||
template(name="cardDependencyIconPopup")
|
||||
.card-dependency-icon-popup
|
||||
ul.card-dependency-icons
|
||||
each dependencyIcons
|
||||
li
|
||||
a.js-pick-dependency-icon(data-icon="{{name}}" title="{{name}}")
|
||||
i.fa(class="fa-{{name}}")
|
||||
@@ -1,6 +1,19 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import {
|
||||
DEPENDENCY_TYPES,
|
||||
DEPENDENCY_ICON_CHOICES,
|
||||
DEFAULT_DEPENDENCY_COLOR,
|
||||
DEFAULT_DEPENDENCY_ICON,
|
||||
} from '/models/metadata/dependencies';
|
||||
|
||||
// Which dependency the icon picker should apply its choice to. The icon popup's
|
||||
// own data context is the dependency row (not the source card), so we capture
|
||||
// the source card + target id here when the picker is opened.
|
||||
let editingDependencyTargetId = null;
|
||||
let editingDependencyCard = null;
|
||||
import {
|
||||
setupDatePicker,
|
||||
datePickerRendered,
|
||||
@@ -53,6 +66,231 @@ import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import autosize from 'autosize';
|
||||
|
||||
// Id of the location currently being edited in the cardLocationsPopup; null
|
||||
// when adding a new location.
|
||||
const editingLocationId = new ReactiveVar(null);
|
||||
|
||||
// Chinese datum conversions ("eviltransform" algorithm). Baidu Maps uses BD-09
|
||||
// and Amap (Gaode) uses GCJ-02; both are offset from the WGS-84 coordinates
|
||||
// Wekan stores, so a raw lat/lon would land a few hundred metres off. Convert on
|
||||
// the way out (mapLinkFor) and back (parseMapLink). Coordinates outside mainland
|
||||
// China use no offset, so every non-Chinese provider is unaffected.
|
||||
const GCJ = (() => {
|
||||
const PI = Math.PI;
|
||||
const A = 6378245.0; // Krasovsky 1940 semi-major axis
|
||||
const EE = 0.00669342162296594323; // eccentricity squared
|
||||
const XPI = (PI * 3000.0) / 180.0;
|
||||
const outOfChina = (lat, lon) =>
|
||||
lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271;
|
||||
const tLat = (x, y) => {
|
||||
let r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
|
||||
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
|
||||
r += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3;
|
||||
r += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
|
||||
return r;
|
||||
};
|
||||
const tLon = (x, y) => {
|
||||
let r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
|
||||
r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
|
||||
r += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3;
|
||||
r += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
|
||||
return r;
|
||||
};
|
||||
const wgs2gcj = (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
let dLat = tLat(lon - 105, lat - 35);
|
||||
let dLon = tLon(lon - 105, lat - 35);
|
||||
const rad = (lat / 180) * PI;
|
||||
let magic = Math.sin(rad);
|
||||
magic = 1 - EE * magic * magic;
|
||||
const sq = Math.sqrt(magic);
|
||||
dLat = (dLat * 180) / (((A * (1 - EE)) / (magic * sq)) * PI);
|
||||
dLon = (dLon * 180) / ((A / sq) * Math.cos(rad) * PI);
|
||||
return [lat + dLat, lon + dLon];
|
||||
};
|
||||
// Approximate inverse by fixed-point iteration (converges to ~1e-9 deg).
|
||||
const gcj2wgs = (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
let wLat = lat;
|
||||
let wLon = lon;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const [gLat, gLon] = wgs2gcj(wLat, wLon);
|
||||
const dLat = gLat - lat;
|
||||
const dLon = gLon - lon;
|
||||
if (Math.abs(dLat) < 1e-9 && Math.abs(dLon) < 1e-9) break;
|
||||
wLat -= dLat;
|
||||
wLon -= dLon;
|
||||
}
|
||||
return [wLat, wLon];
|
||||
};
|
||||
const gcj2bd = (lat, lon) => {
|
||||
const z = Math.sqrt(lon * lon + lat * lat) + 0.00002 * Math.sin(lat * XPI);
|
||||
const theta = Math.atan2(lat, lon) + 0.000003 * Math.cos(lon * XPI);
|
||||
return [z * Math.sin(theta) + 0.006, z * Math.cos(theta) + 0.0065];
|
||||
};
|
||||
const bd2gcj = (lat, lon) => {
|
||||
const x = lon - 0.0065;
|
||||
const y = lat - 0.006;
|
||||
const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * XPI);
|
||||
const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * XPI);
|
||||
return [z * Math.sin(theta), z * Math.cos(theta)];
|
||||
};
|
||||
return {
|
||||
wgs2gcj,
|
||||
gcj2wgs,
|
||||
wgs2bd: (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
const [gLat, gLon] = wgs2gcj(lat, lon);
|
||||
return gcj2bd(gLat, gLon);
|
||||
},
|
||||
bd2wgs: (lat, lon) => {
|
||||
if (outOfChina(lat, lon)) return [lat, lon];
|
||||
const [gLat, gLon] = bd2gcj(lat, lon);
|
||||
return gcj2wgs(gLat, gLon);
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// Parse coordinates (and, when present, a place name / address) out of a map
|
||||
// link from common providers, grouped by region:
|
||||
// USA: Google Maps, Bing Maps, Apple Maps, Waze
|
||||
// Europe: OpenStreetMap, HERE WeGo, Yandex Maps, Mapy.cz, 2GIS
|
||||
// Asia: Baidu Maps, Amap (Gaode)
|
||||
// plus generic `?q=lat,lon` / `?ll=lat,lon` links. Returns whatever it can find
|
||||
// as { latitude, longitude, name, address }.
|
||||
function parseMapLink(url) {
|
||||
const result = {};
|
||||
if (!url) return result;
|
||||
const s = String(url).trim();
|
||||
|
||||
let lat;
|
||||
let lon;
|
||||
const setCoords = (la, lo) => {
|
||||
if (lat !== undefined) return;
|
||||
const a = parseFloat(la);
|
||||
const o = parseFloat(lo);
|
||||
if (!isNaN(a) && !isNaN(o) && Math.abs(a) <= 90 && Math.abs(o) <= 180) {
|
||||
lat = a;
|
||||
lon = o;
|
||||
}
|
||||
};
|
||||
|
||||
const N = '(-?\\d+(?:\\.\\d+)?)';
|
||||
// Coordinates are matched against a normalized copy where percent-encoded
|
||||
// commas/slashes are decoded, since copied URLs often arrive as `%2C`/`%2F`
|
||||
// (e.g. Waze `ll=45.69%2C-120.81`, Yandex `ll=37.6%2C55.7`).
|
||||
const sd = s.replace(/%2c/gi, ',').replace(/%2f/gi, '/');
|
||||
let m;
|
||||
// Provider-specific forms that use lon,lat order must be handled before the
|
||||
// generic lat,lon patterns below, since setCoords keeps only the first match.
|
||||
// Yandex Maps: ?ll=<lon>,<lat> or ?pt=<lon>,<lat>[,style]
|
||||
if (/yandex\./i.test(sd) && (m = sd.match(new RegExp(`[?&](?:ll|pt)=${N},${N}`)))) setCoords(m[2], m[1]);
|
||||
// Mapy.cz: ?x=<lon>&y=<lat>
|
||||
if (/mapy\./i.test(sd) && (m = sd.match(new RegExp(`[?&]y=${N}`)))) {
|
||||
const mx = sd.match(new RegExp(`[?&]x=${N}`));
|
||||
if (mx) setCoords(m[1], mx[1]);
|
||||
}
|
||||
// 2GIS: /geo/<lon>,<lat> or ?m=<lon>,<lat>[/zoom]
|
||||
if (/2gis\./i.test(sd) && (m = sd.match(new RegExp(`(?:/geo/|[?&]m=)${N},${N}`)))) setCoords(m[2], m[1]);
|
||||
// Amap (Gaode): ?position=<lon>,<lat> in GCJ-02 datum -> WGS-84.
|
||||
if (/amap\./i.test(sd) && (m = sd.match(new RegExp(`[?&]position=${N},${N}`)))) {
|
||||
const [wLat, wLon] = GCJ.gcj2wgs(parseFloat(m[2]), parseFloat(m[1]));
|
||||
setCoords(wLat, wLon);
|
||||
}
|
||||
// Baidu Maps: ?location=<lat>,<lon> in BD-09 datum -> WGS-84.
|
||||
if (/baidu\./i.test(sd) && (m = sd.match(new RegExp(`[?&]location=${N},${N}`)))) {
|
||||
const [wLat, wLon] = GCJ.bd2wgs(parseFloat(m[1]), parseFloat(m[2]));
|
||||
setCoords(wLat, wLon);
|
||||
}
|
||||
// HERE WeGo: ?map=<lat>,<lon>,<zoom> or share path /l/<lat>,<lon>
|
||||
if (/here\./i.test(sd) && (m = sd.match(new RegExp(`(?:[?&]map=|/l/)${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Google Maps place marker in the data part: !3d<lat>!4d<lon> (most precise).
|
||||
if ((m = sd.match(new RegExp(`!3d${N}!4d${N}`)))) setCoords(m[1], m[2]);
|
||||
// Google Maps view/place: @<lat>,<lon>
|
||||
if ((m = sd.match(new RegExp(`@${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// OpenStreetMap marker: ?mlat=..&mlon=..
|
||||
if ((m = sd.match(new RegExp(`[?&#]mlat=${N}`)))) {
|
||||
const mlon = sd.match(new RegExp(`[?&#]mlon=${N}`));
|
||||
if (mlon) setCoords(m[1], mlon[1]);
|
||||
}
|
||||
// OpenStreetMap map hash: #map=z/lat/lon
|
||||
if ((m = sd.match(new RegExp(`[#&]map=\\d+(?:\\.\\d+)?/${N}/${N}`)))) setCoords(m[1], m[2]);
|
||||
// Bing Maps: cp=lat~lon
|
||||
if ((m = sd.match(new RegExp(`[?&]cp=${N}~${N}`)))) setCoords(m[1], m[2]);
|
||||
// Apple Maps / Waze and generic ll=lat,lon
|
||||
if ((m = sd.match(new RegExp(`[?&]ll=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic q=lat,lon (Google/Apple query form)
|
||||
if ((m = sd.match(new RegExp(`[?&]q=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic center=lat,lon
|
||||
if ((m = sd.match(new RegExp(`[?&]center=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
|
||||
if (lat !== undefined) {
|
||||
result.latitude = lat;
|
||||
result.longitude = lon;
|
||||
}
|
||||
|
||||
const decode = (raw) => {
|
||||
try {
|
||||
return decodeURIComponent(raw.replace(/\+/g, ' ')).trim();
|
||||
} catch (e) {
|
||||
return raw.replace(/\+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
const isCoordText = (t) => /^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test(t);
|
||||
|
||||
// Place name from Google Maps /place/<Name>/
|
||||
if ((m = s.match(/\/place\/([^/@?]+)/))) {
|
||||
const name = decode(m[1]);
|
||||
if (name && !isCoordText(name)) result.name = name;
|
||||
}
|
||||
// Address from a textual q=/query=/destination= parameter.
|
||||
if ((m = s.match(/[?&](?:q|query|destination)=([^&]+)/))) {
|
||||
const q = decode(m[1]);
|
||||
if (q && !isCoordText(q)) result.address = q;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build an "open in map" link for the given provider and coordinates. Mirrors
|
||||
// the providers offered in the location popup's "Open map links at" selector,
|
||||
// grouped by region (USA, Europe, Asia). Note that several non-US providers
|
||||
// expect the coordinates in lon,lat order rather than lat,lon.
|
||||
function mapLinkFor(provider, lat, lon) {
|
||||
switch (provider) {
|
||||
// --- USA ---
|
||||
case 'google':
|
||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
|
||||
case 'bing':
|
||||
return `https://www.bing.com/maps?cp=${lat}~${lon}&lvl=16`;
|
||||
case 'apple':
|
||||
return `https://maps.apple.com/?ll=${lat},${lon}`;
|
||||
case 'waze':
|
||||
return `https://waze.com/ul?ll=${lat},${lon}`;
|
||||
// --- Europe ---
|
||||
case 'here':
|
||||
return `https://wego.here.com/?map=${lat},${lon},16`;
|
||||
case 'yandex':
|
||||
return `https://yandex.com/maps/?ll=${lon},${lat}&z=16`;
|
||||
case 'mapy':
|
||||
return `https://mapy.cz/zakladni?x=${lon}&y=${lat}&z=16`;
|
||||
case '2gis':
|
||||
return `https://2gis.ru/geo/${lon},${lat}`;
|
||||
// --- Asia (coordinates converted from WGS-84 into the local datum) ---
|
||||
case 'baidu': {
|
||||
const [bLat, bLon] = GCJ.wgs2bd(parseFloat(lat), parseFloat(lon));
|
||||
return `https://api.map.baidu.com/marker?location=${bLat},${bLon}&output=html`;
|
||||
}
|
||||
case 'amap': {
|
||||
const [gLat, gLon] = GCJ.wgs2gcj(parseFloat(lat), parseFloat(lon));
|
||||
return `https://uri.amap.com/marker?position=${gLon},${gLat}`;
|
||||
}
|
||||
case 'openstreetmap':
|
||||
default:
|
||||
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
|
||||
}
|
||||
}
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
const { calculateIndexData } = Utils;
|
||||
|
||||
@@ -121,9 +359,24 @@ Template.cardDetails.onCreated(function () {
|
||||
};
|
||||
|
||||
Meteor.subscribe('unsaved-edits');
|
||||
|
||||
// Surface legacy CollectionFS attachments for the current board so they show
|
||||
// up in the card's attachment gallery and can be read in place (without
|
||||
// migrating them to Meteor-Files first).
|
||||
this.autorun(() => {
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (board && board._id) {
|
||||
Meteor.subscribe('legacyBoardAttachments', board._id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Template.cardDetails.onRendered(function () {
|
||||
// A reactive re-render (e.g. a card moving between the inline swimlane render
|
||||
// and the draggable openCards popup) can create and then remove this instance
|
||||
// within the same flush. If our DOM range is already gone, calling this.$()
|
||||
// below throws "Can't select in removed DomRange", so bail out early.
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
this.calculateNextPeak();
|
||||
if (Meteor.settings.public.CARD_OPENED_WEBHOOK_ENABLED) {
|
||||
// Send Webhook but not create Activities records ---
|
||||
@@ -263,12 +516,61 @@ Template.cardDetails.helpers({
|
||||
return card.findWatcher(Meteor.userId());
|
||||
},
|
||||
|
||||
// Returns the card's locations (multiple supported), each enriched with the
|
||||
// coordinate flag and OpenStreetMap link used by the template.
|
||||
getLocations() {
|
||||
const card = Template.currentData();
|
||||
if (!card || !card.getLocations) return [];
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const provider = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return card.getLocations().map(loc => {
|
||||
const hasCoordinates =
|
||||
typeof loc.latitude === 'number' && typeof loc.longitude === 'number';
|
||||
const mapUrl = hasCoordinates
|
||||
? mapLinkFor(provider, loc.latitude, loc.longitude)
|
||||
: '';
|
||||
return { ...loc, hasCoordinates, mapUrl };
|
||||
});
|
||||
},
|
||||
|
||||
// #3392: PI Program Board "Red Strings". Resolve this card's dependencies
|
||||
// into card objects (with relation type, color, icon and a relative link).
|
||||
getDependencyCards() {
|
||||
const card = Template.currentData();
|
||||
if (!card || typeof card.getDependencies !== 'function') return [];
|
||||
return card
|
||||
.getDependencies()
|
||||
.map(dep => {
|
||||
const target = ReactiveCache.getCard(dep.cardId);
|
||||
if (!target) return null;
|
||||
return {
|
||||
card: target,
|
||||
linkUrl: target.originRelativeUrl(),
|
||||
type: dep.type,
|
||||
color: dep.color,
|
||||
icon: dep.icon,
|
||||
typeLabel: `dependency-type-${dep.type}`,
|
||||
// Per-row relation-type dropdown options with the current one marked.
|
||||
typeOption: DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
selected: t.id === dep.type,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
customFieldsGrid() {
|
||||
return ReactiveCache.getCurrentUser().hasCustomFieldsGrid();
|
||||
},
|
||||
|
||||
cardMaximized() {
|
||||
return !Utils.getPopupCardId() && ReactiveCache.getCurrentUser().hasCardMaximized();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const maximized = currentUser
|
||||
? currentUser.hasCardMaximized()
|
||||
: window.localStorage.getItem('cardMaximized') === 'true';
|
||||
return !Utils.getPopupCardId() && maximized;
|
||||
},
|
||||
|
||||
showActivities() {
|
||||
@@ -336,11 +638,27 @@ Template.cardDetails.helpers({
|
||||
return user && user.isVerticalScrollbars();
|
||||
},
|
||||
|
||||
currentSwimlaneListsSorted() {
|
||||
const card = Template.currentData();
|
||||
if (!card || !card.boardId) return [];
|
||||
const board = ReactiveCache.getBoard(card.boardId);
|
||||
if (!board) return [];
|
||||
const swimlaneId = card.swimlaneId;
|
||||
const selector = { boardId: card.boardId, archived: false };
|
||||
if (swimlaneId) {
|
||||
const defaultSwimlane = board.getDefaultSwimline && board.getDefaultSwimline();
|
||||
if (defaultSwimlane && defaultSwimlane._id === swimlaneId) {
|
||||
selector.swimlaneId = { $in: [swimlaneId, null, ''] };
|
||||
} else {
|
||||
selector.swimlaneId = swimlaneId;
|
||||
}
|
||||
}
|
||||
return ReactiveCache.getLists(selector, { sort: { sort: 1 } });
|
||||
},
|
||||
|
||||
isCurrentListId(listId) {
|
||||
// When called from within 'each currentBoard.lists' block, we need the card from parent context
|
||||
let data = Template.currentData();
|
||||
if (!data || typeof data.listId === 'undefined') {
|
||||
// Try parent context (for use within each loops)
|
||||
data = Template.parentData(1);
|
||||
}
|
||||
if (!data || typeof data.listId === 'undefined') return false;
|
||||
@@ -433,6 +751,17 @@ Template.cardDetails.events({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// When the card is shown inside a popup (opened from the Board Table view,
|
||||
// search results, or a mini-screen list), the redundant popup title header
|
||||
// is hidden, so this is the only close button. Close the popup and clear its
|
||||
// session state instead of running the board/route close flow below.
|
||||
if (Popup.isOpen() && Utils.getPopupCardId()) {
|
||||
Session.delete('popupCardId');
|
||||
Session.delete('popupCardBoardId');
|
||||
Popup.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve card context defensively because cardDetails can be rendered
|
||||
// from several parents (board, popup, gantt, etc.).
|
||||
const card = getCurrentCardFromContext({ ignorePopupCard: true }) || Template.currentData();
|
||||
@@ -480,7 +809,11 @@ Template.cardDetails.events({
|
||||
},
|
||||
'change .js-date-format-selector'(event) {
|
||||
const dateFormat = event.target.value;
|
||||
Meteor.call('changeDateFormat', dateFormat);
|
||||
if (Meteor.userId()) {
|
||||
Meteor.call('changeDateFormat', dateFormat);
|
||||
} else {
|
||||
window.localStorage.setItem('dateFormat', dateFormat);
|
||||
}
|
||||
},
|
||||
'click .js-open-card-details-menu': Popup.open('cardDetailsActions'),
|
||||
// Mobile: switch to desktop popup view (maximize)
|
||||
@@ -571,14 +904,96 @@ Template.cardDetails.events({
|
||||
const card = Template.currentData();
|
||||
Utils.goCardId(card.linkedId);
|
||||
},
|
||||
'click .js-add-dependency': Popup.open('cardDependencies'),
|
||||
'click .js-remove-dependency'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
const card = Template.currentData();
|
||||
if (card && targetId) {
|
||||
card.removeDependency(targetId);
|
||||
}
|
||||
},
|
||||
'change .js-dependency-type'(event) {
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
const card = Template.currentData();
|
||||
if (card && targetId) {
|
||||
card.setDependencyProps(targetId, { type: event.currentTarget.value });
|
||||
}
|
||||
},
|
||||
'change .js-dependency-color'(event) {
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
const card = Template.currentData();
|
||||
if (card && targetId) {
|
||||
card.setDependencyProps(targetId, { color: event.currentTarget.value });
|
||||
}
|
||||
},
|
||||
'click .js-dependency-icon'(event) {
|
||||
if (!Utils.canModifyCard()) return;
|
||||
// Remember the source card + which dependency the picked icon applies to.
|
||||
editingDependencyCard = Template.currentData();
|
||||
editingDependencyTargetId = event.currentTarget.dataset.targetId;
|
||||
Popup.open('cardDependencyIcon')(event);
|
||||
},
|
||||
'click .js-member': Popup.open('cardMember'),
|
||||
'click .js-add-members': Popup.open('cardMembers'),
|
||||
'click .js-assignee': Popup.open('cardAssignee'),
|
||||
'click .js-add-assignees': Popup.open('cardAssignees'),
|
||||
'click .js-add-labels': Popup.open('cardLabels'),
|
||||
'click .js-add-stickers': Popup.open('cardStickers'),
|
||||
'click .js-remove-sticker'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const index = parseInt(event.currentTarget.dataset.index, 10);
|
||||
const card = Template.currentData();
|
||||
if (card && !Number.isNaN(index)) {
|
||||
card.removeStickerAt(index);
|
||||
}
|
||||
},
|
||||
'click .js-add-location'(event) {
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-edit-location'(event) {
|
||||
// Let the "Open in map" link work without also opening the edit popup.
|
||||
if ($(event.target).closest('a.card-location-map').length) return;
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(event.currentTarget.dataset.locationId || null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-remove-location'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const locationId = event.currentTarget.dataset.locationId;
|
||||
const card = Template.currentData();
|
||||
if (card && locationId) {
|
||||
card.removeLocation(locationId);
|
||||
}
|
||||
},
|
||||
'click .js-received-date': Popup.open('editCardReceivedDate'),
|
||||
'click .js-start-date': Popup.open('editCardStartDate'),
|
||||
'click .js-due-date': Popup.open('editCardDueDate'),
|
||||
'click .js-toggle-due-complete'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
card.setDueComplete(!card.getDueComplete());
|
||||
},
|
||||
'click .js-end-date': Popup.open('editCardEndDate'),
|
||||
'click .js-show-positive-votes': Popup.open('positiveVoteMembers'),
|
||||
'click .js-show-negative-votes': Popup.open('negativeVoteMembers'),
|
||||
@@ -614,11 +1029,19 @@ Template.cardDetails.events({
|
||||
Meteor.call('toggleCustomFieldsGrid');
|
||||
},
|
||||
'click .js-maximize-card-details'() {
|
||||
Meteor.call('toggleCardMaximized');
|
||||
if (Meteor.userId()) {
|
||||
Meteor.call('toggleCardMaximized');
|
||||
} else {
|
||||
window.localStorage.setItem('cardMaximized', 'true');
|
||||
}
|
||||
autosize($('.card-details'));
|
||||
},
|
||||
'click .js-minimize-card-details'() {
|
||||
Meteor.call('toggleCardMaximized');
|
||||
if (Meteor.userId()) {
|
||||
Meteor.call('toggleCardMaximized');
|
||||
} else {
|
||||
window.localStorage.setItem('cardMaximized', 'false');
|
||||
}
|
||||
autosize($('.card-details'));
|
||||
},
|
||||
'click .js-vote'(e) {
|
||||
@@ -775,7 +1198,10 @@ Template.cardDetails.helpers({
|
||||
},
|
||||
isDateFormat(format) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (!currentUser) return format === 'YYYY-MM-DD';
|
||||
if (!currentUser) {
|
||||
const stored = window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD';
|
||||
return format === stored;
|
||||
}
|
||||
return currentUser.getDateFormat() === format;
|
||||
},
|
||||
// Upload progress helpers
|
||||
@@ -811,7 +1237,7 @@ const EXCEL_EXPORT_FIELDS = [
|
||||
{ field: 'checklists', label: 'checklists' },
|
||||
{ field: 'subtasks', label: 'export-card-subtasks' },
|
||||
{ field: 'comments', label: 'comments' },
|
||||
{ field: 'attachments', label: 'export-card-attachments' },
|
||||
{ field: 'attachments', label: 'attachments' },
|
||||
];
|
||||
|
||||
Template.exportCardPopup.onCreated(function () {
|
||||
@@ -1099,6 +1525,109 @@ Template.cardMembersPopup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// Popup that adds or edits a single card location (name, address, latitude,
|
||||
// longitude). Cards can hold multiple locations, like members.
|
||||
Template.cardLocationsPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.cardId = data && data._id;
|
||||
this.detectMsg = new ReactiveVar('');
|
||||
this.mapSavedMsg = new ReactiveVar('');
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.helpers({
|
||||
location() {
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
const found = card.getLocations().find(loc => loc._id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
detectMessage() {
|
||||
return Template.instance().detectMsg.get();
|
||||
},
|
||||
isMapProvider(provider) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const current = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return current === provider;
|
||||
},
|
||||
mapSavedMessage() {
|
||||
return Template.instance().mapSavedMsg.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.events({
|
||||
'click .js-detect-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const linkInput = tpl.find('.js-location-map-link');
|
||||
const parsed = parseMapLink(linkInput ? linkInput.value : '');
|
||||
let filled = false;
|
||||
if (typeof parsed.latitude === 'number') {
|
||||
tpl.find('.js-location-latitude').value = parsed.latitude;
|
||||
tpl.find('.js-location-longitude').value = parsed.longitude;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.name) {
|
||||
tpl.find('.js-location-name').value = parsed.name;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.address) {
|
||||
tpl.find('.js-location-address').value = parsed.address;
|
||||
filled = true;
|
||||
}
|
||||
tpl.detectMsg.set(
|
||||
TAPi18n.__(filled ? 'location-detect-done' : 'location-detect-none'),
|
||||
);
|
||||
},
|
||||
'submit .js-card-location-form'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
if (!card) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const name = tpl.find('.js-location-name').value.trim();
|
||||
const address = tpl.find('.js-location-address').value.trim();
|
||||
const latRaw = tpl.find('.js-location-latitude').value.trim();
|
||||
const lonRaw = tpl.find('.js-location-longitude').value.trim();
|
||||
const latitude = latRaw === '' ? undefined : parseFloat(latRaw);
|
||||
const longitude = lonRaw === '' ? undefined : parseFloat(lonRaw);
|
||||
const data = { name, address, latitude, longitude };
|
||||
const id = editingLocationId.get();
|
||||
if (id) {
|
||||
card.updateLocation(id, data);
|
||||
} else {
|
||||
card.addLocation(data);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-delete-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
card.removeLocation(id);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-save-map-provider'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const select = tpl.find('.js-map-provider');
|
||||
const provider = select ? select.value : 'openstreetmap';
|
||||
Meteor.call('setMapProvider', provider, err => {
|
||||
tpl.mapSavedMsg.set(
|
||||
TAPi18n.__(err ? 'server-error' : 'map-provider-saved'),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filterMembers = (filterTerm) => {
|
||||
let currBoard = Utils.getCurrentBoard();
|
||||
let members = currBoard.activeMembers();
|
||||
@@ -1177,6 +1706,15 @@ function registerCardDialogTemplate(templateName) {
|
||||
isDialogOptionListId(listId) {
|
||||
return Template.instance().dialog.isDialogOptionListId(listId);
|
||||
},
|
||||
isSelectedBoardId(boardId) {
|
||||
return Template.instance().dialog.isSelectedBoardId(boardId);
|
||||
},
|
||||
isSelectedSwimlaneId(swimlaneId) {
|
||||
return Template.instance().dialog.isSelectedSwimlaneId(swimlaneId);
|
||||
},
|
||||
isSelectedListId(listId) {
|
||||
return Template.instance().dialog.isSelectedListId(listId);
|
||||
},
|
||||
isDialogOptionCardId(cardId) {
|
||||
return Template.instance().dialog.isDialogOptionCardId(cardId);
|
||||
},
|
||||
@@ -1188,14 +1726,16 @@ function registerCardDialogTemplate(templateName) {
|
||||
Template[templateName].events({
|
||||
async 'click .js-done'(event, tpl) {
|
||||
const dialog = tpl.dialog;
|
||||
const boardSelect = tpl.$('.js-select-boards')[0];
|
||||
const boardId = boardSelect?.options[boardSelect?.selectedIndex]?.value;
|
||||
|
||||
const listSelect = tpl.$('.js-select-lists')[0];
|
||||
const listId = listSelect?.options[listSelect?.selectedIndex]?.value;
|
||||
|
||||
const swimlaneSelect = tpl.$('.js-select-swimlanes')[0];
|
||||
const swimlaneId = swimlaneSelect?.options[swimlaneSelect?.selectedIndex]?.value;
|
||||
// Read the target board/swimlane/list from the dialog's live reactive
|
||||
// selection rather than the DOM <select> elements. A reactive re-render
|
||||
// (e.g. the boards() helper transiently returning [] while a 'board'
|
||||
// subscription re-resolves) can leave a <select> momentarily option-less,
|
||||
// making selectedIndex -1 and the scraped id undefined — which then makes
|
||||
// card.move()/copyCard() fail with a 403 "may only update documents by ID"
|
||||
// validation error. The reactive vars stay correct across those renders.
|
||||
const boardId = dialog.selectedBoardId.get();
|
||||
const swimlaneId = dialog.selectedSwimlaneId.get();
|
||||
const listId = dialog.selectedListId.get();
|
||||
|
||||
const cardSelect = tpl.$('.js-select-cards')[0];
|
||||
const cardId = cardSelect?.options?.length > 0
|
||||
@@ -1858,8 +2398,8 @@ EscapeActions.register(
|
||||
Session.set('cardDetailsIsMouseDown', false);
|
||||
|
||||
} else {
|
||||
// Prevent close card when the user is selecting text and moves the mouse cursor outside the card detail area
|
||||
Utils.goBoardId(Session.get('currentBoard'));
|
||||
// Trigger the close button so all close logic (openCards, routing, mini-screen) runs consistently
|
||||
$('.js-close-card-details').first().trigger('click');
|
||||
}
|
||||
},
|
||||
() => {
|
||||
@@ -1951,3 +2491,95 @@ Template.cardAssigneePopup.events({
|
||||
},
|
||||
'click .js-edit-profile': Popup.open('editProfile'),
|
||||
});
|
||||
|
||||
// #3392: PI Program Board "Red Strings". Popup to pick another card on the
|
||||
// same board to add as a dependency. The popup's data context is the source
|
||||
// card (set by Popup.open on the .js-add-dependency element inside cardDetails).
|
||||
Template.cardDependenciesPopup.onCreated(function () {
|
||||
this.searchTerm = new ReactiveVar('');
|
||||
this.newType = new ReactiveVar(DEPENDENCY_TYPES[0].id);
|
||||
this.newColor = new ReactiveVar(DEFAULT_DEPENDENCY_COLOR);
|
||||
});
|
||||
|
||||
Template.cardDependenciesPopup.helpers({
|
||||
defaultColor() {
|
||||
return Template.instance().newColor.get();
|
||||
},
|
||||
typeOption() {
|
||||
const current = Template.instance().newType.get();
|
||||
return DEPENDENCY_TYPES.map(t => ({
|
||||
id: t.id,
|
||||
label: `dependency-type-${t.id}`,
|
||||
selected: t.id === current,
|
||||
}));
|
||||
},
|
||||
candidateCards() {
|
||||
const sourceCard = Template.currentData();
|
||||
if (!sourceCard) return [];
|
||||
const term = Template.instance().searchTerm.get().toLowerCase();
|
||||
const existingIds = (sourceCard.getDependencies
|
||||
? sourceCard.getDependencies()
|
||||
: []
|
||||
).map(dep => dep.cardId);
|
||||
const cards = ReactiveCache.getCards({
|
||||
boardId: sourceCard.boardId,
|
||||
archived: false,
|
||||
});
|
||||
return cards.filter(card => {
|
||||
if (card._id === sourceCard._id) return false;
|
||||
if (existingIds.includes(card._id)) return false;
|
||||
if (term && !(card.title || '').toLowerCase().includes(term)) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardDependenciesPopup.events({
|
||||
'keyup .js-dependency-search'(event) {
|
||||
Template.instance().searchTerm.set(event.currentTarget.value || '');
|
||||
},
|
||||
'change .js-new-dependency-type'(event) {
|
||||
Template.instance().newType.set(event.currentTarget.value);
|
||||
},
|
||||
'change .js-new-dependency-color'(event) {
|
||||
Template.instance().newColor.set(event.currentTarget.value);
|
||||
},
|
||||
'click .js-pick-dependency'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const sourceCard = Template.currentData();
|
||||
const targetId = event.currentTarget.dataset.targetId;
|
||||
if (sourceCard && targetId) {
|
||||
sourceCard.addDependency(targetId, {
|
||||
type: tpl.newType.get(),
|
||||
color: tpl.newColor.get(),
|
||||
});
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// #3392: icon picker for an existing dependency. The source card is the popup's
|
||||
// data context; the target id was stashed on the cardDetails instance by the
|
||||
// .js-dependency-icon click handler.
|
||||
Template.cardDependencyIconPopup.helpers({
|
||||
dependencyIcons() {
|
||||
return DEPENDENCY_ICON_CHOICES.map(name => ({ name }));
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardDependencyIconPopup.events({
|
||||
'click .js-pick-dependency-icon'(event) {
|
||||
event.preventDefault();
|
||||
// Use the source card captured when the picker was opened (the popup's own
|
||||
// data context is the dependency row, which has no setDependencyProps).
|
||||
const sourceCard = editingDependencyCard;
|
||||
const icon = event.currentTarget.dataset.icon || DEFAULT_DEPENDENCY_ICON;
|
||||
if (sourceCard && editingDependencyTargetId) {
|
||||
sourceCard.setDependencyProps(editingDependencyTargetId, { icon });
|
||||
}
|
||||
editingDependencyTargetId = null;
|
||||
editingDependencyCard = null;
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
template(name="cardStickersPopup")
|
||||
.card-stickers-picker.clearfix
|
||||
each stickerIcons
|
||||
a.card-sticker-select.js-select-sticker(class="{{#if isSelected}}active{{/if}}" title="{{stickerTitle}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if isSelected
|
||||
i.fa.fa-check.card-sticker-check
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { STICKER_PICKER } from '/models/metadata/stickers';
|
||||
|
||||
// Picker popup that lets a user add/remove stickers on a card. Each entry is
|
||||
// { icon, highlight?, name? }: the plain Font Awesome icons plus the mascot
|
||||
// (underlined) and computer (ringed) highlighted stickers. Opened from the card
|
||||
// details stickers section with the card as the data context.
|
||||
|
||||
Template.cardStickersPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.cardId = data && data._id;
|
||||
});
|
||||
|
||||
function pickerCard(tpl) {
|
||||
return ReactiveCache.getCard(tpl.cardId);
|
||||
}
|
||||
|
||||
Template.cardStickersPopup.helpers({
|
||||
stickerIcons() {
|
||||
return STICKER_PICKER;
|
||||
},
|
||||
isSelected() {
|
||||
const card = pickerCard(Template.instance());
|
||||
return !!(card && card.hasSticker(this.icon, this.highlight));
|
||||
},
|
||||
stickerTitle() {
|
||||
return this.name || this.icon;
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardStickersPopup.events({
|
||||
'click .js-select-sticker'(event) {
|
||||
event.preventDefault();
|
||||
const card = pickerCard(Template.instance());
|
||||
if (card) {
|
||||
card.toggleSticker(this.icon, this.highlight, this.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -14,5 +14,5 @@
|
||||
font-size: inherit;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
content: "\f017";
|
||||
margin-right: 0.3em;
|
||||
margin-inline-end: 0.3em;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ textarea.js-edit-checklist-item {
|
||||
color: #8c8c8c;
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.delete-text:hover,
|
||||
@@ -32,7 +32,7 @@ textarea.js-edit-checklist-item {
|
||||
align-items: center;
|
||||
}
|
||||
.checklist-progress-bar-container .checklist-progress-text {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
.checklist-progress-bar-container .checklist-progress-bar {
|
||||
width: 80%;
|
||||
@@ -51,7 +51,7 @@ textarea.js-edit-checklist-item {
|
||||
padding: 10px;
|
||||
}
|
||||
.checklist-title .checkbox {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 18px;
|
||||
@@ -63,21 +63,21 @@ textarea.js-edit-checklist-item {
|
||||
}
|
||||
.checklist-title .checklist-stat {
|
||||
margin: 0 0.5em;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
padding-top: 6px;
|
||||
}
|
||||
.checklist-title .checklist-stat.is-finished {
|
||||
color: #3cb500;
|
||||
}
|
||||
.checklist-title span.fa.checklist-handle {
|
||||
padding-right: 20px;
|
||||
padding-inline-end: 20px;
|
||||
padding-top: 3px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
#card-details-overlay {
|
||||
top: 0;
|
||||
bottom: -600px;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
.checklist {
|
||||
background: #f7f7f7;
|
||||
@@ -123,14 +123,14 @@ textarea.js-edit-checklist-item {
|
||||
background-color: #ebebeb;
|
||||
}
|
||||
.checklist-item .check-box-container {
|
||||
padding-right: 10px;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
.checklist-item .check-box {
|
||||
margin: 0.1em 0 0 0;
|
||||
}
|
||||
.checklist-item .check-box.is-checked {
|
||||
border-bottom: 2px solid #3cb500;
|
||||
border-right: 2px solid #3cb500;
|
||||
border-inline-end: 2px solid #3cb500;
|
||||
}
|
||||
.checklist-item .item-title {
|
||||
flex: 1;
|
||||
@@ -148,7 +148,7 @@ textarea.js-edit-checklist-item {
|
||||
}
|
||||
.checklist-item span.fa.checklistitem-handle {
|
||||
padding-top: 2px;
|
||||
padding-right: 10px;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
.js-delete-checklist-item,
|
||||
.js-convert-checklist-item-to-card {
|
||||
@@ -171,9 +171,9 @@ textarea.js-edit-checklist-item {
|
||||
}
|
||||
.add-checklist-top {
|
||||
/* more space to checklists title */
|
||||
padding-left: 20px;
|
||||
padding-inline-start: 20px;
|
||||
/* + is easier clickable */
|
||||
padding-right: 20px;
|
||||
padding-inline-end: 20px;
|
||||
}
|
||||
.add-checklist-top.js-open-inlined-form:hover {
|
||||
background: #dbdbdb;
|
||||
@@ -185,9 +185,9 @@ textarea.js-edit-checklist-item {
|
||||
width: 100%;
|
||||
}
|
||||
.checklist-details-menu {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
padding: 6px 10px 6px 10px;
|
||||
}
|
||||
.edit-controls label.toggle-label {
|
||||
margin-left: 2px;
|
||||
margin-inline-start: 2px;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ template(name="checklists")
|
||||
template(name="checklistDetail")
|
||||
.js-checklist.checklist.nodragscroll
|
||||
+inlinedForm(classNames="js-edit-checklist-title" checklist = checklist)
|
||||
+editChecklistItemForm(checklist = checklist)
|
||||
+editChecklistForm(checklist = checklist)
|
||||
else
|
||||
.checklist-title
|
||||
span
|
||||
@@ -85,6 +85,19 @@ template(name="addChecklistItemForm")
|
||||
label.toggle-label(for="toggleNewlineBecomesNewChecklistItemOriginOrder")
|
||||
| {{_ 'originOrder'}}
|
||||
|
||||
template(name="editChecklistForm")
|
||||
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
textarea.js-edit-checklist-item(rows='1' autofocus dir="auto")
|
||||
if $eq type 'item'
|
||||
= item.title
|
||||
else
|
||||
= checklist.title
|
||||
.edit-controls.clearfix
|
||||
button.primary.confirm.js-submit-edit-checklist-item-form(type="submit") {{_ 'save'}}
|
||||
a.fa.fa-times-thin.js-close-inlined-form(title="{{_ 'close-edit-checklist-item'}}")
|
||||
span(title=createdAt) {{ displayDate createdAt }}
|
||||
|
||||
template(name="editChecklistItemForm")
|
||||
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
@@ -171,6 +184,15 @@ template(name="checklistActionsPopup")
|
||||
else
|
||||
input.toggle-switch(type="checkbox" id="toggleHideAllChecklistItems_{{checklist._id}}")
|
||||
label.toggle-label(for="toggleHideAllChecklistItems_{{checklist._id}}")
|
||||
a.js-show-checklist-at-minicard
|
||||
i.fa.fa-th-list
|
||||
| {{_ "show-on-minicard"}} ...
|
||||
.material-toggle-switch(title="{{_ 'show-on-minicard'}}")
|
||||
if checklist.showChecklistAtMinicard
|
||||
input.toggle-switch(type="checkbox" id="toggleShowChecklistAtMinicard_{{checklist._id}}" checked="checked")
|
||||
else
|
||||
input.toggle-switch(type="checkbox" id="toggleShowChecklistAtMinicard_{{checklist._id}}")
|
||||
label.toggle-label(for="toggleShowChecklistAtMinicard_{{checklist._id}}")
|
||||
|
||||
template(name="copyChecklistPopup")
|
||||
+copyAndMoveChecklist
|
||||
|
||||
@@ -135,8 +135,11 @@ Template.checklists.events({
|
||||
event.preventDefault();
|
||||
const textarea = tpl.find('textarea.js-edit-checklist-item');
|
||||
const title = textarea.value.trim();
|
||||
const checklist = Template.currentData().checklist;
|
||||
checklist.setTitle(title);
|
||||
const formData = Blaze.getData(event.currentTarget) || Blaze.getData(event.target);
|
||||
const checklist = formData?.checklist;
|
||||
if (checklist) {
|
||||
checklist.setTitle(title);
|
||||
}
|
||||
},
|
||||
'submit .js-add-checklist-item'(event, tpl) {
|
||||
event.preventDefault();
|
||||
@@ -271,6 +274,11 @@ Template.checklistActionsPopup.events({
|
||||
Template.currentData().checklist.toggleHideAllChecklistItems();
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-show-checklist-at-minicard'(event) {
|
||||
event.preventDefault();
|
||||
Template.currentData().checklist.toggleShowChecklistAtMinicard();
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.editChecklistItemForm.onRendered(function () {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
margin-bottom: 5px;
|
||||
padding: 3px 8px;
|
||||
max-width: 210px;
|
||||
@@ -140,7 +140,7 @@
|
||||
}
|
||||
.edit-label .card-label,
|
||||
.create-label .card-label {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
height: 25px;
|
||||
margin: 0px 3% 7px 0px;
|
||||
width: 10.5%;
|
||||
@@ -153,7 +153,7 @@
|
||||
}
|
||||
.edit-labels .card-label {
|
||||
height: 30px;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
padding: 1px 5px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -164,7 +164,7 @@
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 260px;
|
||||
}
|
||||
.edit-labels-pop-over {
|
||||
@@ -185,24 +185,24 @@
|
||||
min-height: 18px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
transition: margin-right 0.1s;
|
||||
transition: margin-inline-end 0.1s;
|
||||
}
|
||||
.card-label-selectable .card-label-selectable-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: -20px;
|
||||
inset-inline-end: -20px;
|
||||
}
|
||||
.card-label-selectable.active:hover,
|
||||
.card-label-selectable.active,
|
||||
.card-label-selectable.active.selected:hover,
|
||||
.card-label-selectable.active.selected {
|
||||
padding-right: 32px;
|
||||
padding-inline-end: 32px;
|
||||
}
|
||||
.card-label-selectable.active:hover .card-label-selectable-icon,
|
||||
.card-label-selectable.active .card-label-selectable-icon,
|
||||
.card-label-selectable.active.selected:hover .card-label-selectable-icon,
|
||||
.card-label-selectable.active.selected .card-label-selectable-icon {
|
||||
right: 6px;
|
||||
inset-inline-end: 6px;
|
||||
}
|
||||
.card-label-selectable.selected,
|
||||
.card-label-selectable:hover {
|
||||
@@ -210,21 +210,21 @@
|
||||
}
|
||||
.active .card-label-selectable,
|
||||
.active .card-label-selectable:hover {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.active .card-label-selectable .card-label-selectable-icon {
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
.card-label-edit-button {
|
||||
border-radius: 3px;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
padding: 8px;
|
||||
}
|
||||
.card-label-edit-button:hover {
|
||||
background: #dbdbdb;
|
||||
}
|
||||
ul.edit-labels-pop-over span.label-handle {
|
||||
padding-right: 10px;
|
||||
padding-inline-end: 10px;
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
text-align: center;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.2vh;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.minicard-wrapper.placeholder {
|
||||
background: #ccc;
|
||||
@@ -16,8 +16,8 @@
|
||||
}
|
||||
.minicard-wrapper.ui-sortable-helper .and-n-other {
|
||||
width: 100%;
|
||||
height: 2.5vh;
|
||||
padding: 0.5vh;
|
||||
height: 25px;
|
||||
padding: 5px;
|
||||
background-color: #f2f2f2;
|
||||
text-align: center;
|
||||
border-radius: 0.4vw;
|
||||
@@ -26,17 +26,29 @@
|
||||
display: none;
|
||||
}
|
||||
.minicard-wrapper .multi-selection-checkbox + .minicard {
|
||||
margin-left: 1vw;
|
||||
margin-inline-start: 1vw;
|
||||
}
|
||||
/* Enlarge the clickable area of the small selection checkbox so it can be
|
||||
reliably clicked at the left side of the minicard. The visible 13px box (and
|
||||
its rotated is-checked checkmark) keep their size; an invisible overlay only
|
||||
extends the hit target. */
|
||||
.minicard-wrapper .multi-selection-checkbox::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
inset-inline-start: -8px;
|
||||
inset-inline-end: -8px;
|
||||
bottom: -8px;
|
||||
}
|
||||
@media only screen {
|
||||
.minicard {
|
||||
padding: 0.8vh 1vw 0.3vh;
|
||||
padding: 8px 1vw 3px;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
background-color: #fff;
|
||||
min-height: 2.5vh;
|
||||
box-shadow: 0 0.2vh 0.3vh rgba(0,0,0,0.15);
|
||||
min-height: 25px;
|
||||
box-shadow: 0 2px 3px rgba(0,0,0,0.15);
|
||||
border-radius: 0.3vw;
|
||||
color: #4d4d4d;
|
||||
overflow: hidden;
|
||||
@@ -44,16 +56,16 @@
|
||||
}
|
||||
}
|
||||
.minicard-details-menu-with-handle {
|
||||
float: right;
|
||||
padding-left: 0.7vw;
|
||||
float: inline-end;
|
||||
padding-inline-start: 0.7vw;
|
||||
font-size: clamp(14px, 3vw, 18px);
|
||||
padding: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.minicard-details-menu {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
font-size: clamp(14px, 3vw, 18px);
|
||||
padding-left: 0.7vw;
|
||||
padding-inline-start: 0.7vw;
|
||||
}
|
||||
@media print {
|
||||
.minicard-details-menu,
|
||||
@@ -64,7 +76,7 @@
|
||||
.minicard.linked-board .linked-icon,
|
||||
.minicard.linked-card .linked-icon {
|
||||
display: inline-block;
|
||||
margin-right: 1.5vw;
|
||||
margin-inline-end: 1.5vw;
|
||||
vertical-align: baseline;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -77,7 +89,7 @@
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
z-index: 25;
|
||||
box-shadow: -0.3vw 0.2vh 0.3vh rgba(0,0,0,0.2);
|
||||
box-shadow: -0.3vw 2px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
.minicard:hover:not(.minicard-composer),
|
||||
.is-selected .minicard,
|
||||
@@ -91,21 +103,21 @@
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
height: 20vh;
|
||||
height: 150px;
|
||||
user-select: none;
|
||||
margin: 0.8vh -1vw 0.8vh -1vw;
|
||||
margin: 8px -1vw 8px -1vw;
|
||||
border-radius: top 0.3vw;
|
||||
}
|
||||
.minicard .minicard-labels {
|
||||
float: none;
|
||||
margin-right: 6vw;
|
||||
margin-inline-end: 6vw;
|
||||
}
|
||||
.minicard .minicard-labels .minicard-label {
|
||||
width: clamp(12px, 1.5vw, 16px);
|
||||
height: clamp(12px, 1.5vw, 16px);
|
||||
border-radius: 0.3vw;
|
||||
margin-right: 0.4vw;
|
||||
margin-bottom: 0.4vh;
|
||||
margin-inline-end: 0.4vw;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.minicard .minicard-labels-no-text {
|
||||
display: flex;
|
||||
@@ -113,7 +125,7 @@
|
||||
}
|
||||
.minicard .minicard-custom-fields {
|
||||
display: block;
|
||||
margin-right: 6vw;
|
||||
margin-inline-end: 6vw;
|
||||
}
|
||||
.minicard .minicard-custom-field {
|
||||
display: flex;
|
||||
@@ -123,21 +135,21 @@
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
max-width: 13vw;
|
||||
margin-right: 0.5vw;
|
||||
margin-inline-end: 0.5vw;
|
||||
}
|
||||
.minicard .minicard-custom-field-item-fullwidth {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
margin-right: 0.5vw;
|
||||
margin-inline-end: 0.5vw;
|
||||
}
|
||||
.minicard .handle {
|
||||
width: clamp(20px, 2.5vw, 28px);
|
||||
height: clamp(20px, 2.5vw, 28px);
|
||||
position: absolute;
|
||||
right: 0vw;
|
||||
top: 4vh;
|
||||
inset-inline-end: 0vw;
|
||||
top: 28px;
|
||||
display: none;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -154,12 +166,12 @@
|
||||
text-align: center;
|
||||
}
|
||||
.minicard .minicard-title {
|
||||
margin-right: 1.5vw;
|
||||
margin-inline-end: 1.5vw;
|
||||
}
|
||||
.minicard .minicard-title .card-number {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-right: 0.7vw;
|
||||
margin-inline-end: 0.7vw;
|
||||
}
|
||||
@media only screen {
|
||||
.minicard .minicard-title p:last-child {
|
||||
@@ -176,11 +188,11 @@
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
margin-right: 6vw;
|
||||
margin-inline-end: 6vw;
|
||||
clear: both;
|
||||
}
|
||||
.minicard .date {
|
||||
margin-right: 0.4vw;
|
||||
margin-inline-end: 0.4vw;
|
||||
}
|
||||
|
||||
/* Unicode icons for minicard dates - matching cardDate.css */
|
||||
@@ -199,7 +211,7 @@
|
||||
|
||||
.minicard .card-date time::before {
|
||||
font-size: inherit;
|
||||
margin-right: 0.3em;
|
||||
margin-inline-end: 0.3em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -312,17 +324,17 @@
|
||||
}
|
||||
|
||||
.minicard .badges {
|
||||
float: left;
|
||||
margin-top: 1vh;
|
||||
float: inline-start;
|
||||
margin-top: 8px;
|
||||
color: #808080;
|
||||
}
|
||||
.minicard .badges:empty {
|
||||
display: none;
|
||||
}
|
||||
.minicard .badges .badge {
|
||||
float: left;
|
||||
margin-right: 1.5vw;
|
||||
margin-bottom: 0.4vh;
|
||||
float: inline-start;
|
||||
margin-inline-end: 1.5vw;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.minicard .badges .badge.is-finished {
|
||||
@@ -332,7 +344,7 @@
|
||||
color: #fff;
|
||||
}
|
||||
.minicard .badges .badge:last-of-type {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
.minicard .badges .badge .badge-icon,
|
||||
.minicard .badges .badge .badge-text {
|
||||
@@ -344,33 +356,33 @@
|
||||
}
|
||||
.minicard .badges .badge .badge-text {
|
||||
font-size: 0.9em;
|
||||
padding-left: 0.3vw;
|
||||
padding-inline-start: 0.3vw;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.minicard .badges .badge .check-list-text {
|
||||
padding-left: 0px;
|
||||
padding-inline-start: 0px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.minicard .minicard-members,
|
||||
.minicard .minicard-assignees,
|
||||
.minicard .minicard-creator {
|
||||
float: right;
|
||||
margin-left: 0.7vw;
|
||||
margin-bottom: 0.5vh;
|
||||
float: inline-end;
|
||||
margin-inline-start: 0.7vw;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.minicard .minicard-members .member,
|
||||
.minicard .minicard-assignees .member,
|
||||
.minicard .minicard-creator .member {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
border-radius: 50%;
|
||||
height: clamp(24px, 3.5vw, 32px);
|
||||
width: clamp(24px, 3.5vw, 32px);
|
||||
margin-bottom: 0.5vh;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.minicard .minicard-members .assignee,
|
||||
.minicard .minicard-assignees .assignee,
|
||||
.minicard .minicard-creator .assignee {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
border-radius: 50%;
|
||||
height: clamp(24px, 3.5vw, 32px);
|
||||
width: clamp(24px, 3.5vw, 32px);
|
||||
@@ -378,7 +390,7 @@
|
||||
.minicard .minicard-members + .badges,
|
||||
.minicard .minicard-assignees + .badges,
|
||||
.minicard .minicard-creator + .badges {
|
||||
margin-top: 0.7vh;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.minicard .minicard-assignees {
|
||||
border-bottom: 1px solid #f00;
|
||||
@@ -391,12 +403,12 @@
|
||||
display: none;
|
||||
}
|
||||
.minicard .minicard-description {
|
||||
padding: 0.8vh 0 0 1vw;
|
||||
padding: 8px 0 0 1vw;
|
||||
color: #000;
|
||||
background-color: #eee;
|
||||
width: 100%;
|
||||
margin-bottom: 0.3vh;
|
||||
margin-left: -0.5vw;
|
||||
margin-bottom: 3px;
|
||||
margin-inline-start: -0.5vw;
|
||||
border-radius: 0.4vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -405,7 +417,7 @@
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.minicard.minicard-composer {
|
||||
margin-bottom: 1.3vh;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.minicard.minicard-composer textarea.minicard-composer-textarea,
|
||||
.minicard.minicard-composer textarea.minicard-composer-textarea:focus {
|
||||
@@ -416,9 +428,9 @@
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 22vh;
|
||||
min-height: 5vh;
|
||||
margin-bottom: 2.5vh;
|
||||
max-height: 200px;
|
||||
min-height: 50px;
|
||||
margin-bottom: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.parent-prefix {
|
||||
@@ -736,7 +748,7 @@
|
||||
.minicard-list-name {
|
||||
font-size: 0.75em;
|
||||
color: #8c8c8c;
|
||||
margin-top: 0.2vh;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3vw;
|
||||
@@ -745,9 +757,11 @@
|
||||
/* Checklist display on minicard */
|
||||
.minicard-checklist {
|
||||
width: 100%;
|
||||
margin-top: 0.5vh;
|
||||
margin-bottom: 0.5vh;
|
||||
padding: 0.3vh 0.5vw;
|
||||
box-sizing: border-box;
|
||||
clear: both;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 5px;
|
||||
padding: 3px 0.5vw;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 0.3vw;
|
||||
border: 1px solid #e0e0e0;
|
||||
@@ -757,7 +771,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.3vh;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.minicard-checklist .checklist-title {
|
||||
@@ -765,6 +779,7 @@
|
||||
font-weight: bold;
|
||||
color: #4d4d4d;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.minicard-checklist .checklist-menu {
|
||||
@@ -783,13 +798,13 @@
|
||||
.minicard-checklist .checklist-item {
|
||||
font-size: 0.75em;
|
||||
color: #666;
|
||||
margin-bottom: 0.2vh;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3vw;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
padding: 0.2vh 0;
|
||||
padding: 2px 0;
|
||||
border-radius: 0.2vw;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
@@ -806,7 +821,7 @@
|
||||
.minicard-checklist .checklist-item .check-box-unicode {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.8em;
|
||||
margin-top: 0.1vh;
|
||||
margin-top: 1px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
@@ -819,3 +834,52 @@
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the minicard title */
|
||||
.minicard-title .minicard-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
/* Nudge down so the checkmark sits at the vertical middle of the title text
|
||||
(the checked materialCheckBox is shifted up 4px by its own positioning). */
|
||||
position: relative;
|
||||
top: 4px;
|
||||
margin-inline-end: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.minicard-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.minicard-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* #3984: Visual card aging — progressively fade cards that have not been
|
||||
* touched recently, when card aging is enabled for the board. */
|
||||
.minicard.minicard-aging-1 {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.minicard.minicard-aging-2 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.minicard.minicard-aging-3 {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.minicard.minicard-aging-1:hover,
|
||||
.minicard.minicard-aging-2:hover,
|
||||
.minicard.minicard-aging-3:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -2,26 +2,38 @@ template(name="minicard")
|
||||
.minicard.nodragscroll(
|
||||
class="{{#if isLinkedCard}}linked-card{{/if}}"
|
||||
class="{{#if isLinkedBoard}}linked-board{{/if}}"
|
||||
class="{{agingClass}}"
|
||||
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}")
|
||||
if canMoveCard
|
||||
if isTouchScreenOrShowDesktopDragHandles
|
||||
.handle
|
||||
i.fa.fa-arrows
|
||||
//- #459: keyboard/screen-reader reordering (visually hidden, focusable).
|
||||
.minicard-move-buttons.sr-only
|
||||
a.js-card-move-up(href="#" role="button" aria-label="{{_ 'move-card-up'}}") {{_ 'move-card-up'}}
|
||||
a.js-card-move-down(href="#" role="button" aria-label="{{_ 'move-card-down'}}") {{_ 'move-card-down'}}
|
||||
if canModifyCard
|
||||
a.minicard-details-menu-with-handle.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
|
||||
i.fa.fa-bars
|
||||
if showDependencyConnectHandle
|
||||
a.dependency-connect-handle.js-dependency-connect-handle(title="{{_ 'drag-to-connect'}}")
|
||||
i.fa.fa-dot-circle-o
|
||||
.dates
|
||||
if getReceived
|
||||
.date
|
||||
+minicardReceivedDate
|
||||
if getStart
|
||||
.date
|
||||
+minicardStartDate
|
||||
if getDue
|
||||
.date
|
||||
+minicardDueDate
|
||||
if getEnd
|
||||
+minicardEndDate
|
||||
if showReceived
|
||||
if getReceived
|
||||
.date
|
||||
+minicardReceivedDate
|
||||
if showStart
|
||||
if getStart
|
||||
.date
|
||||
+minicardStartDate
|
||||
if showDue
|
||||
if getDue
|
||||
.date(class="{{#if getDueComplete}}due-complete{{/if}}")
|
||||
+minicardDueDate
|
||||
if showEnd
|
||||
if getEnd
|
||||
+minicardEndDate
|
||||
if getSpentTime
|
||||
.date
|
||||
+cardSpentTime
|
||||
@@ -50,6 +62,10 @@ template(name="minicard")
|
||||
span {{_ 'upload-completed'}}
|
||||
|
||||
.minicard-title
|
||||
if showDueComplete
|
||||
if canModifyCard
|
||||
a.minicard-complete-toggle.js-toggle-card-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
|
||||
.parent-prefix
|
||||
| {{ parentString ' > ' }}
|
||||
@@ -68,19 +84,20 @@ template(name="minicard")
|
||||
span.linked-icon.linked-archived
|
||||
i.fa.fa-archive
|
||||
+viewer
|
||||
if currentBoard.allowsCardNumber
|
||||
if showCardNumber
|
||||
span.card-number
|
||||
| ##{getCardNumber}
|
||||
| ##{getCardNumber}
|
||||
= getTitle
|
||||
if labels
|
||||
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
|
||||
each labels
|
||||
unless hiddenMinicardLabelText
|
||||
span.js-card-label.card-label(class="card-label-{{color}}" title=name)
|
||||
+viewer
|
||||
= name
|
||||
if hiddenMinicardLabelText
|
||||
.minicard-label(class="card-label-{{color}}" title="{{name}}")
|
||||
if showLabels
|
||||
if labels
|
||||
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
|
||||
each labels
|
||||
unless hiddenMinicardLabelText
|
||||
span.js-card-label.card-label(class="card-label-{{color}}" title=name)
|
||||
+viewer
|
||||
= name
|
||||
if hiddenMinicardLabelText
|
||||
.minicard-label(class="card-label-{{color}}" title="{{name}}")
|
||||
|
||||
.minicard-custom-fields
|
||||
each customFieldsWD
|
||||
@@ -143,14 +160,26 @@ template(name="minicard")
|
||||
.minicard-creator
|
||||
+userAvatar(userId=this.userId noRemove=true)
|
||||
|
||||
if shouldShowChecklistAtMinicard
|
||||
each shouldShowChecklistAtMinicard
|
||||
+minicardChecklist(checklist=. card=..)
|
||||
.badges
|
||||
if canModifyCard
|
||||
if comments.length
|
||||
.badge(title="{{_ 'card-comments-title' comments.length }}")
|
||||
span.badge-icon.badge-comment.badge-text
|
||||
i.fa.fa-comment-o
|
||||
= ' '
|
||||
= comments.length
|
||||
if dependencyBadge
|
||||
.badge.badge-state-image-only.minicard-dependencies(title="{{_ 'card-dependencies'}}")
|
||||
span.badge-icon(style="color:{{dependencyBadge.color}}")
|
||||
i.fa(class="fa-{{dependencyBadge.icon}}")
|
||||
span.badge-text {{ dependencyBadge.count }}
|
||||
if stickers.length
|
||||
.badge.badge-state-image-only.minicard-stickers
|
||||
each stickers
|
||||
span.badge-icon.minicard-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if comments.length
|
||||
.badge(title="{{_ 'card-comments-title' comments.length }}")
|
||||
span.badge-icon.badge-comment.badge-text
|
||||
i.fa.fa-comment-o
|
||||
= ' '
|
||||
= comments.length
|
||||
//span.badge-comment.badge-text
|
||||
//|
|
||||
{{_ 'comment'}}
|
||||
@@ -179,21 +208,19 @@ template(name="minicard")
|
||||
span.badge-icon
|
||||
i.fa.fa-paperclip
|
||||
span.badge-text= attachments.length
|
||||
if allSubtasks.count
|
||||
.badge
|
||||
span.badge-icon
|
||||
i.fa.fa-globe
|
||||
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
|
||||
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
|
||||
if showSubtasks
|
||||
if allSubtasks.count
|
||||
.badge
|
||||
span.badge-icon
|
||||
i.fa.fa-globe
|
||||
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
|
||||
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
|
||||
if currentBoard.allowsCardSortingByNumber
|
||||
if currentBoard.allowsCardSortingByNumberOnMinicard
|
||||
.badge
|
||||
span.badge-icon
|
||||
i.fa.fa-sort-numeric-asc
|
||||
span.badge-text.check-list-sort {{ sort }}
|
||||
if shouldShowChecklistAtMinicard
|
||||
each shouldShowChecklistAtMinicard
|
||||
+minicardChecklist(checklist=. card=..)
|
||||
if currentBoard.allowsDescriptionTextOnMinicard
|
||||
if getDescription
|
||||
.minicard-description
|
||||
@@ -218,10 +245,26 @@ template(name="editCardSortOrderPopup")
|
||||
template(name="minicardChecklist")
|
||||
.minicard-checklist
|
||||
.checklist-header
|
||||
.checklist-title= checklist.title
|
||||
if canModifyCard
|
||||
+inlinedForm(classNames="js-edit-checklist-title" checklist=checklist)
|
||||
+editChecklistForm(checklist=checklist)
|
||||
else
|
||||
h4.checklist-title.js-open-inlined-form.is-editable= checklist.title
|
||||
else
|
||||
h4.checklist-title= checklist.title
|
||||
if canModifyCard
|
||||
a.checklist-menu.js-open-checklist-menu(title="{{_ 'checklistActionsPopup-title'}}")
|
||||
i.fa.fa-bars
|
||||
each visibleItems
|
||||
+checklistItemDetail(item = . checklist = checklist card = card)
|
||||
.checklist-items.js-checklist-items
|
||||
each visibleItems
|
||||
+inlinedForm(classNames="js-edit-checklist-item" item=. checklist=checklist)
|
||||
+editChecklistItemForm(type='item' item=. checklist=checklist)
|
||||
else
|
||||
+checklistItemDetail(item=. checklist=checklist card=card)
|
||||
if canModifyCard
|
||||
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist=checklist)
|
||||
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=false)
|
||||
else
|
||||
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
@@ -4,12 +4,63 @@ import { CustomFieldStringTemplate } from '/client/lib/customFields';
|
||||
import { handleFileUpload } from './attachments';
|
||||
import uploadProgressManager from '../../lib/uploadProgressManager';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import ChecklistItems from '/models/checklistItems';
|
||||
import Cards from '/models/cards';
|
||||
import { normalizeDependencies } from '/models/metadata/dependencies';
|
||||
|
||||
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
|
||||
if (!board) return false;
|
||||
if (board[onMinicardField] !== null && board[onMinicardField] !== undefined) {
|
||||
return board[onMinicardField];
|
||||
}
|
||||
if (legacyField && board[legacyField] !== null && board[legacyField] !== undefined) {
|
||||
return board[legacyField];
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Template.cards.events({
|
||||
// 'click .member': Popup.open('cardMember')
|
||||
// });
|
||||
|
||||
Template.minicard.helpers({
|
||||
// #3392: show a drag-to-connect handle on the minicard when the board's
|
||||
// dependency overlay is on and the user can edit the board. Dragging it onto
|
||||
// another card creates a dependency (handled in dependencyOverlay.js).
|
||||
showDependencyConnectHandle() {
|
||||
const board = ReactiveCache.getBoard(this.boardId);
|
||||
return !!(board && board.showDependencies && Utils.canModifyBoard());
|
||||
},
|
||||
// #3392: PI Program Board "Red Strings". Show a small badge on the minicard
|
||||
// when a card has dependencies: the first dependency's icon and color plus the
|
||||
// total count.
|
||||
dependencyBadge() {
|
||||
const deps = normalizeDependencies(this.cardDependencies);
|
||||
if (deps.length === 0) return null;
|
||||
return {
|
||||
icon: deps[0].icon,
|
||||
color: deps[0].color,
|
||||
count: deps.length,
|
||||
};
|
||||
},
|
||||
// #3984: visual card aging — fade cards that have not been touched recently,
|
||||
// based on dateLastActivity, when the board has card aging enabled.
|
||||
agingClass() {
|
||||
const board = ReactiveCache.getBoard(this.boardId);
|
||||
if (!board || !board.cardAging) return '';
|
||||
const last = this.dateLastActivity || this.modifiedAt || this.createdAt;
|
||||
if (!last) return '';
|
||||
const days = (Date.now() - new Date(last).getTime()) / 86400000;
|
||||
// #3984: thresholds are board-configurable (board settings / cardSettings API),
|
||||
// defaulting to 7 / 14 / 28 days.
|
||||
const d1 = board.cardAgingDays1 != null ? board.cardAgingDays1 : 7;
|
||||
const d2 = board.cardAgingDays2 != null ? board.cardAgingDays2 : 14;
|
||||
const d3 = board.cardAgingDays3 != null ? board.cardAgingDays3 : 28;
|
||||
if (days >= d3) return 'minicard-aging-3';
|
||||
if (days >= d2) return 'minicard-aging-2';
|
||||
if (days >= d1) return 'minicard-aging-1';
|
||||
return '';
|
||||
},
|
||||
formattedCurrencyCustomFieldValue(definition) {
|
||||
const customField = this
|
||||
.customFieldsWD()
|
||||
@@ -50,31 +101,48 @@ Template.minicard.helpers({
|
||||
},
|
||||
|
||||
showMembers() {
|
||||
// cache "board" to reduce the mini-mongodb access
|
||||
const board = this.board();
|
||||
let ret = false;
|
||||
if (board) {
|
||||
ret =
|
||||
board.allowsMembers === null ||
|
||||
board.allowsMembers === undefined ||
|
||||
board.allowsMembers
|
||||
;
|
||||
}
|
||||
return ret;
|
||||
return getMinicardFlag(board, 'allowsMembersOnMinicard', 'allowsMembers', true);
|
||||
},
|
||||
|
||||
showAssignee() {
|
||||
// cache "board" to reduce the mini-mongodb access
|
||||
const board = this.board();
|
||||
let ret = false;
|
||||
if (board) {
|
||||
ret =
|
||||
board.allowsAssignee === null ||
|
||||
board.allowsAssignee === undefined ||
|
||||
board.allowsAssignee
|
||||
;
|
||||
}
|
||||
return ret;
|
||||
return getMinicardFlag(board, 'allowsAssigneeOnMinicard', 'allowsAssignee', true);
|
||||
},
|
||||
showReceived() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsReceivedDateOnMinicard', 'allowsReceivedDate', true);
|
||||
},
|
||||
showStart() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsStartDateOnMinicard', 'allowsStartDate', true);
|
||||
},
|
||||
showDue() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsDueDateOnMinicard', 'allowsDueDate', true);
|
||||
},
|
||||
showEnd() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsEndDateOnMinicard', 'allowsEndDate', true);
|
||||
},
|
||||
showLabels() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsLabelsOnMinicard', 'allowsLabels', true);
|
||||
},
|
||||
showCardNumber() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsCardNumberOnMinicard', 'allowsCardNumber', false);
|
||||
},
|
||||
// "Mark as complete" toggle on the minicard. No legacy fallback to the
|
||||
// card-details setting (allowsDueComplete): hidden by default so it only
|
||||
// appears when explicitly enabled in Card Settings > Show at Minicard.
|
||||
showDueComplete() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsDueCompleteOnMinicard', null, false);
|
||||
},
|
||||
showSubtasks() {
|
||||
const board = this.board();
|
||||
return getMinicardFlag(board, 'allowsSubtasksOnMinicard', 'allowsSubtasks', true);
|
||||
},
|
||||
|
||||
hiddenMinicardLabelText() {
|
||||
@@ -91,10 +159,11 @@ Template.minicard.helpers({
|
||||
if (!this.coverId) return null;
|
||||
const attachment = ReactiveCache.getAttachment(this.coverId);
|
||||
if (!attachment) return null;
|
||||
const coverId = this.coverId;
|
||||
const coverLink = typeof attachment.link === 'function' ? attachment.link() : '';
|
||||
if (!coverLink) return null;
|
||||
return {
|
||||
link() {
|
||||
return `/cdn/storage/attachments/${coverId}`;
|
||||
return coverLink;
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -149,12 +218,56 @@ Template.minicard.helpers({
|
||||
}
|
||||
});
|
||||
|
||||
// #459: accessible reordering — keyboard/screen-reader users can move a card up
|
||||
// or down within its list via sr-only buttons (no drag-and-drop required). The
|
||||
// move swaps the card's sort value with its neighbour in the same list+swimlane.
|
||||
function moveCardBy(card, delta) {
|
||||
const siblings = ReactiveCache.getCards(
|
||||
{ listId: card.listId, swimlaneId: card.swimlaneId, archived: false },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
const idx = siblings.findIndex(c => c._id === card._id);
|
||||
const target = siblings[idx + delta];
|
||||
if (idx < 0 || !target) return;
|
||||
// Capture both sort values before either update; the docs are reactive and
|
||||
// card.sort would otherwise change after the first move.
|
||||
const cardSort = card.sort;
|
||||
const targetSort = target.sort;
|
||||
// Persist through the card model's move() mutation — the canonical client
|
||||
// path (e.g. editCardSortOrderPopup). A raw Cards.update of `sort` is the
|
||||
// wrong path here and would be reverted.
|
||||
card.move(card.boardId, card.swimlaneId, card.listId, targetSort);
|
||||
target.move(target.boardId, target.swimlaneId, target.listId, cardSort);
|
||||
}
|
||||
|
||||
Template.minicard.events({
|
||||
'click .js-linked-link'() {
|
||||
if (this.isLinkedCard()) Utils.goCardId(this.linkedId);
|
||||
else if (this.isLinkedBoard())
|
||||
Utils.goBoardId(this.linkedId);
|
||||
},
|
||||
'click .js-card-move-up'(event) {
|
||||
// The move buttons sit inside the minicard anchor; don't let the click
|
||||
// bubble up and open the card detail view.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
moveCardBy(this, -1);
|
||||
},
|
||||
'click .js-card-move-down'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
moveCardBy(this, 1);
|
||||
},
|
||||
'click .js-toggle-card-complete'(event) {
|
||||
// Trello-style "mark complete" toggle (left of the title). Don't let the
|
||||
// click open the card.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) {
|
||||
return;
|
||||
}
|
||||
this.setDueComplete(!this.getDueComplete());
|
||||
},
|
||||
'click .js-toggle-minicard-label-text'() {
|
||||
if (window.localStorage.getItem('hiddenMinicardLabelText')) {
|
||||
window.localStorage.removeItem('hiddenMinicardLabelText'); //true
|
||||
@@ -252,6 +365,13 @@ Template.minicardChecklist.helpers({
|
||||
});
|
||||
|
||||
Template.minicardChecklist.events({
|
||||
'click .js-convert-checklist-item-to-card'(event) {
|
||||
event.stopPropagation();
|
||||
const formData = Blaze.getData(event.currentTarget);
|
||||
if (!formData) return;
|
||||
const context = { currentData: () => formData };
|
||||
Popup.open('convertChecklistItemToCard').call(context, event);
|
||||
},
|
||||
'click .js-open-checklist-menu'(event) {
|
||||
const data = Template.currentData();
|
||||
const checklist = data.checklist || data;
|
||||
@@ -259,6 +379,66 @@ Template.minicardChecklist.events({
|
||||
const context = { currentData: () => ({ checklist, card }) };
|
||||
Popup.open('checklistActions').call(context, event);
|
||||
},
|
||||
'click .js-checklist-item .check-box-container'(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const data = Blaze.getData(event.target) || Blaze.getData(event.currentTarget);
|
||||
const item = data && data.item;
|
||||
if (item && item._id) {
|
||||
item.toggleItem();
|
||||
}
|
||||
},
|
||||
'click .js-submit-edit-checklist-item-form'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const $btn = $(event.currentTarget);
|
||||
const $form = $btn.closest('form');
|
||||
const textarea = $form.find('textarea.js-edit-checklist-item')[0];
|
||||
if (!textarea) return;
|
||||
const title = textarea.value.trim();
|
||||
if (!title) return;
|
||||
const formData = Blaze.getData($form[0]);
|
||||
if (formData && formData.item && formData.item._id) {
|
||||
formData.item.setTitle(title);
|
||||
} else if (formData && formData.checklist && formData.checklist._id) {
|
||||
formData.checklist.setTitle(title);
|
||||
}
|
||||
$form.find('.js-close-inlined-form').trigger('click');
|
||||
},
|
||||
'submit .js-add-checklist-item'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const textarea = tpl.find('textarea.js-add-checklist-item');
|
||||
if (!textarea) return;
|
||||
const title = textarea.value.trim();
|
||||
const data = Template.currentData() || {};
|
||||
const checklist = data.checklist;
|
||||
if (!checklist || !title) return;
|
||||
const items = checklist.items();
|
||||
const lastItem = items[items.length - 1] || null;
|
||||
const sortIndex = Utils.calculateIndexData(lastItem, null).base;
|
||||
ChecklistItems.insert({
|
||||
title,
|
||||
checklistId: checklist._id,
|
||||
cardId: checklist.cardId,
|
||||
sort: sortIndex,
|
||||
});
|
||||
textarea.value = '';
|
||||
textarea.focus();
|
||||
},
|
||||
'click .js-delete-checklist-item': Popup.afterConfirm('checklistItemDelete', function () {
|
||||
Popup.back();
|
||||
const item = this && this.item ? this.item : this;
|
||||
if (item && item._id) {
|
||||
ChecklistItems.remove(item._id);
|
||||
}
|
||||
}),
|
||||
'keydown textarea.js-add-checklist-item'(event) {
|
||||
if (event.keyCode === 13 && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const $form = $(event.currentTarget).closest('form');
|
||||
$form.find('button[type=submit]').click();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Template.editCardSortOrderPopup.events({
|
||||
|
||||
@@ -59,13 +59,13 @@ textarea.js-edit-subtask-item {
|
||||
.js-confirm-subtask-delete {
|
||||
background-color: #f7f7f7;
|
||||
position: absolute;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
width: 60%;
|
||||
margin-top: 0;
|
||||
margin-left: 13%;
|
||||
margin-inline-start: 13%;
|
||||
padding-bottom: 2%;
|
||||
padding-left: 3%;
|
||||
padding-right: 3%;
|
||||
padding-inline-start: 3%;
|
||||
padding-inline-end: 3%;
|
||||
z-index: 17;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -86,17 +86,17 @@ textarea.js-edit-subtask-item {
|
||||
padding: left 2% right 2%;
|
||||
}
|
||||
.js-confirm-subtask-delete .js-subtask-delete-buttons .confirm-subtask-delete {
|
||||
margin-left: 12%;
|
||||
float: left;
|
||||
margin-inline-start: 12%;
|
||||
float: inline-start;
|
||||
}
|
||||
.js-confirm-subtask-delete .js-subtask-delete-buttons .toggle-delete-subtask-dialog {
|
||||
margin-right: 12%;
|
||||
float: right;
|
||||
margin-inline-end: 12%;
|
||||
float: inline-end;
|
||||
}
|
||||
#card-details-overlay {
|
||||
top: 0;
|
||||
bottom: -600px;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
@@ -139,17 +139,20 @@ textarea.js-edit-subtask-item {
|
||||
.subtasks-item:hover {
|
||||
background-color: #ebebeb;
|
||||
}
|
||||
.subtasks-item .check-box-container {
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
.subtasks-item .check-box {
|
||||
margin: 0.1em 0 0 0;
|
||||
}
|
||||
.subtasks-item .check-box.is-checked {
|
||||
border-bottom: 2px solid #3cb500;
|
||||
border-right: 2px solid #3cb500;
|
||||
border-inline-end: 2px solid #3cb500;
|
||||
}
|
||||
/* Unicode checkbox icons styling */
|
||||
.subtasks-item .check-box-unicode {
|
||||
font-size: 1.3em;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
@@ -158,7 +161,7 @@ textarea.js-edit-subtask-item {
|
||||
/* Grey checkmarks when grey icons setting is enabled */
|
||||
body.grey-icons-enabled .subtasks-item .check-box.is-checked {
|
||||
border-bottom: 2px solid #7a7a7a;
|
||||
border-right: 2px solid #7a7a7a;
|
||||
border-inline-end: 2px solid #7a7a7a;
|
||||
}
|
||||
body.grey-icons-enabled .subtasks-item .check-box-unicode {
|
||||
filter: grayscale(100%);
|
||||
@@ -167,7 +170,7 @@ body.grey-icons-enabled .subtasks-item .check-box-unicode {
|
||||
}
|
||||
.subtasks-item .item-title {
|
||||
flex: 1;
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.subtasks-item .item-title.is-checked {
|
||||
color: #8c8c8c;
|
||||
|
||||
@@ -74,14 +74,13 @@ template(name="subtasksItems")
|
||||
template(name='subtaskItemDetail')
|
||||
.js-subtasks-item.subtasks-item
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
|
||||
@@ -119,7 +119,7 @@ Template.subtasks.helpers({
|
||||
});
|
||||
|
||||
Template.subtaskItemDetail.events({
|
||||
async 'click .js-subtasks-item .check-box-unicode'() {
|
||||
async 'click .js-subtasks-item .check-box-container'() {
|
||||
const item = Template.currentData().item;
|
||||
if (item && item._id) {
|
||||
await item.toggleItem();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
.original-position-loading i {
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.original-position-details {
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
.original-position-moved i {
|
||||
color: #f39c12;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.original-position-unchanged {
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
.original-position-unchanged i {
|
||||
color: #28a745;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
.original-position-text {
|
||||
|
||||
@@ -143,8 +143,8 @@ button {
|
||||
}
|
||||
input[type="submit"] .wide,
|
||||
button .wide {
|
||||
padding-left: 4vw;
|
||||
padding-right: 4vw;
|
||||
padding-inline-start: 4vw;
|
||||
padding-inline-end: 4vw;
|
||||
}
|
||||
input[type="submit"]:hover,
|
||||
button:hover,
|
||||
@@ -209,7 +209,7 @@ button.negate:active {
|
||||
}
|
||||
input[type="submit"] i.fa,
|
||||
button i.fa {
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
input[type="submit"].disabled,
|
||||
input[type="submit"]:disabled,
|
||||
@@ -248,7 +248,7 @@ input[type="hidden"] {
|
||||
}
|
||||
.radio-div input,
|
||||
.check-div input {
|
||||
left: -2.5vw;
|
||||
inset-inline-start: -2.5vw;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -289,7 +289,7 @@ textarea::-moz-placeholder {
|
||||
.add-controls button[type=submit],
|
||||
.edit-controls input[type=button],
|
||||
.add-controls input[type=button] {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
height: 4.5vh;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
@@ -301,7 +301,7 @@ textarea::-moz-placeholder {
|
||||
[type="checkbox"]:not(:checked),
|
||||
[type="checkbox"]:checked {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
inset-inline-start: -9999px;
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
@@ -318,14 +318,14 @@ textarea::-moz-placeholder {
|
||||
}
|
||||
.materialCheckBox.is-checked {
|
||||
top: -4px;
|
||||
left: -3px;
|
||||
inset-inline-start: -3px;
|
||||
width: 7px;
|
||||
height: 15px;
|
||||
margin-right: 6px;
|
||||
margin-inline-end: 6px;
|
||||
border-top: 2px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
border-inline-start: 2px solid transparent;
|
||||
border-bottom: 2px solid #3cb500;
|
||||
border-right: 2px solid #3cb500;
|
||||
border-inline-end: 2px solid #3cb500;
|
||||
transform: rotate(40deg);
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
@@ -334,7 +334,7 @@ textarea::-moz-placeholder {
|
||||
/* Grey checkmarks when grey icons setting is enabled */
|
||||
body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #7a7a7a;
|
||||
border-right: 2px solid #7a7a7a;
|
||||
border-inline-end: 2px solid #7a7a7a;
|
||||
}
|
||||
.button-link {
|
||||
background: #fff;
|
||||
@@ -369,12 +369,12 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
inset-inline-end: 5px;
|
||||
top: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.button-link.is-on {
|
||||
padding-right: 30px;
|
||||
padding-inline-end: 30px;
|
||||
max-width: 196px;
|
||||
}
|
||||
.button-link.is-on .on {
|
||||
@@ -383,11 +383,11 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
.button-link.inline {
|
||||
color: #666;
|
||||
padding: 2px 14px;
|
||||
margin-left: 4px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
.button-link.setting {
|
||||
height: 52px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
position: relative;
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -440,7 +440,7 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
height: 50px;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
@@ -501,7 +501,7 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
|
||||
button.quiet-button,
|
||||
button.loud-text-button {
|
||||
background: none;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
line-height: normal;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
@@ -535,7 +535,7 @@ button.loud-text-button:hover {
|
||||
}
|
||||
.emphasis-button.w-img,
|
||||
.quiet-button.w-img {
|
||||
padding-left: 28px;
|
||||
padding-inline-start: 28px;
|
||||
}
|
||||
.emphasis-button:hover,
|
||||
.quiet-button:hover {
|
||||
@@ -582,13 +582,13 @@ button.loud-text-button:hover {
|
||||
background: #dcdcdc;
|
||||
}
|
||||
.big-link.options {
|
||||
padding-right: 41px;
|
||||
padding-inline-end: 41px;
|
||||
}
|
||||
.big-link .option {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
inset-inline-end: 6px;
|
||||
top: 6px;
|
||||
}
|
||||
.big-link.none {
|
||||
@@ -599,7 +599,7 @@ button.loud-text-button:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.big-link.avatar-changer {
|
||||
padding-right: 51px;
|
||||
padding-inline-end: 51px;
|
||||
}
|
||||
.big-link.avatar-changer .member {
|
||||
border: 1px solid #ccc;
|
||||
@@ -607,7 +607,7 @@ button.loud-text-button:hover {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
}
|
||||
.big-link.avatar-changer .member .member-avatar {
|
||||
@@ -652,7 +652,7 @@ button.loud-text-button:hover {
|
||||
line-height: 34px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
font-size: 23px;
|
||||
@@ -687,7 +687,7 @@ button.loud-text-button:hover {
|
||||
.dropdown-menu li a img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dropdown-menu li a .minicard-label {
|
||||
@@ -719,7 +719,7 @@ button.loud-text-button:hover {
|
||||
}
|
||||
.toggle-label:after {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
inset-inline-start: -2px;
|
||||
top: -3px;
|
||||
display: block;
|
||||
width: 26px;
|
||||
@@ -737,7 +737,7 @@ button.loud-text-button:hover {
|
||||
background-color: #6fbeb5;
|
||||
}
|
||||
.toggle-switch:checked ~ .toggle-label:after {
|
||||
left: 20px;
|
||||
inset-inline-start: 20px;
|
||||
background-color: #179588;
|
||||
}
|
||||
.toggle-switch:checked:disabled ~ .toggle-label {
|
||||
|
||||
@@ -67,17 +67,17 @@
|
||||
|
||||
.gantt-container thead th,
|
||||
.gantt-container thead tr > td:first-child {
|
||||
border-right: 2px solid #666;
|
||||
border-inline-end: 2px solid #666;
|
||||
padding: 4px; /* half of 8px */
|
||||
width: 100px; /* half of 200px */
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
font-weight: bold;
|
||||
background-color: #e8e8e8;
|
||||
min-width: 100px; /* half of 200px */
|
||||
}
|
||||
|
||||
.gantt-container thead td {
|
||||
border-right: 1px solid #999;
|
||||
border-inline-end: 1px solid #999;
|
||||
padding: 2px 1px; /* half */
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.gantt-container tbody td {
|
||||
border-right: 1px solid #ccc;
|
||||
border-inline-end: 1px solid #ccc;
|
||||
padding: 1px; /* half */
|
||||
text-align: center;
|
||||
min-width: 15px; /* half of 30px */
|
||||
@@ -121,12 +121,12 @@
|
||||
}
|
||||
|
||||
.gantt-container tbody td:first-child {
|
||||
border-right: 2px solid #666;
|
||||
border-inline-end: 2px solid #666;
|
||||
padding: 4px; /* half of 8px */
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background-color: #fafafa !important;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
width: 100px; /* half of 200px */
|
||||
min-width: 100px; /* half of 200px */
|
||||
white-space: nowrap;
|
||||
@@ -155,7 +155,7 @@
|
||||
|
||||
.gantt-container .ganttview-today {
|
||||
background-color: #fcf8e3;
|
||||
border-right: 2px solid #ffb347;
|
||||
border-inline-end: 2px solid #ffb347;
|
||||
}
|
||||
|
||||
/* Task bar styling - VERY VISIBLE */
|
||||
|
||||
Vendored
+116
@@ -49,3 +49,119 @@ a.show-mapping {
|
||||
font-size: 90%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Trello API import: file rows, workspace tree, job progress and errors */
|
||||
/* The shared page spinners are centered with a large top margin (e.g. bounce
|
||||
uses margin:100px auto), which leaves a big empty gap above the spinner in
|
||||
this compact panel. Make spinners here small and inline instead. */
|
||||
.trello-api-import .sk-spinner {
|
||||
margin: 0 8px 0 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: auto;
|
||||
}
|
||||
.trello-api-import .import-file-row label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
.trello-api-import .import-file-row input,
|
||||
.trello-api-import .import-file-row select {
|
||||
width: 100%;
|
||||
}
|
||||
.trello-workspaces {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.trello-workspace {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trello-workspace .trello-boards {
|
||||
margin-inline-start: 22px;
|
||||
}
|
||||
.trello-workspace-title,
|
||||
.trello-board {
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.trello-workspace-title .materialCheckBox,
|
||||
.trello-board .materialCheckBox {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-end: 12px;
|
||||
}
|
||||
.trello-select-controls button {
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
.trello-job-controls button {
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
/* Import progress meter, styled like Admin Panel / Attachments / Move Attachments */
|
||||
.trello-progress-label {
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
.trello-progress-bar {
|
||||
background: #e9ecef;
|
||||
border-radius: 8px;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.trello-progress-fill {
|
||||
background: linear-gradient(90deg, #28a745, #20c997);
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
.trello-progress-fill.is-running::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
animation: trelloProgressShimmer 2s infinite;
|
||||
}
|
||||
@keyframes trelloProgressShimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
.trello-progress-percent {
|
||||
text-align: end;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trello-import-result.is-ok {
|
||||
color: #3cb500;
|
||||
}
|
||||
.trello-import-result.is-error {
|
||||
color: #eb4646;
|
||||
}
|
||||
.js-trello-errors-text {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Trello API credential Save/Delete buttons */
|
||||
.trello-api-creds-buttons button {
|
||||
margin-inline-end: 6px;
|
||||
}
|
||||
.trello-creds-saved {
|
||||
color: #3cb500;
|
||||
}
|
||||
|
||||
/* Simplified per-field hint text under the Trello file upload labels */
|
||||
.import-file-row .import-file-hint {
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
@@ -2,20 +2,136 @@ template(name="importHeaderBar")
|
||||
h1
|
||||
a.back-btn(href="{{pathFor 'home'}}")
|
||||
i.fa-arrow-left
|
||||
| {{_ title}}
|
||||
| {{title}}
|
||||
|
||||
template(name="import")
|
||||
.wrapper
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
if zipImporting
|
||||
.warning {{_ 'import-trello-zip-progress'}}
|
||||
+Template.dynamic(template=currentTemplate)
|
||||
|
||||
template(name="importTextarea")
|
||||
form
|
||||
p: label(for='import-textarea') {{_ instruction}} {{_ 'import-board-instruction-about-errors'}}
|
||||
textarea.js-import-json(id='import-textarea' placeholder="{{_ importPlaceHolder}}" autofocus)
|
||||
| {{jsonText}}
|
||||
p: label(for='import-textarea') {{instruction}} {{_ 'import-board-instruction-about-errors'}}
|
||||
if isExcelImport
|
||||
p.import-file-row
|
||||
label(for='import-excel-file') {{_ 'import-excel-file'}}
|
||||
input.js-import-excel-file(id='import-excel-file' type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
else
|
||||
textarea.js-import-json(id='import-textarea' placeholder="{{_ importPlaceHolder}}" autofocus)
|
||||
| {{jsonText}}
|
||||
if isTrelloImport
|
||||
p.import-file-row
|
||||
label(for='import-json-file') {{_ 'import-trello-json-file'}}
|
||||
span.quiet.import-file-hint {{_ 'import-trello-json-file-hint'}}
|
||||
input.js-import-json-file(id='import-json-file' type="file" accept=".json,application/json")
|
||||
p.import-file-row
|
||||
label(for='import-zip-file') {{_ 'import-trello-zip-file'}}
|
||||
span.quiet.import-file-hint {{_ 'import-trello-zip-file-hint'}}
|
||||
input.js-import-zip-file(id='import-zip-file' type="file" accept=".zip,application/zip")
|
||||
p.import-file-row
|
||||
label(for='import-workspace-name') {{_ 'import-trello-workspace'}}
|
||||
input.js-import-workspace-name(id='import-workspace-name' type="text" placeholder="{{_ 'import-trello-workspace-placeholder'}}")
|
||||
input.primary.wide(type="submit" value="{{_ 'import'}}")
|
||||
button.wide.js-import-without-mapping(type="button") {{_ 'import-without-mapping-members'}}
|
||||
if isTrelloImport
|
||||
hr
|
||||
+importTrelloApi
|
||||
|
||||
template(name="importTrelloApi")
|
||||
.trello-api-import
|
||||
h2 {{_ 'trello-api-import'}}
|
||||
p.quiet {{_ 'trello-api-import-desc'}}
|
||||
p.import-file-row
|
||||
label(for='trello-api-key') {{_ 'trello-api-key'}}
|
||||
input.js-trello-key(id='trello-api-key' type="text" autocomplete="off")
|
||||
p.import-file-row
|
||||
label(for='trello-api-token') {{_ 'trello-api-token'}}
|
||||
input.js-trello-token(id='trello-api-token' type="password" autocomplete="off")
|
||||
p.trello-api-creds-buttons
|
||||
button.primary.js-trello-save-creds(type="button") {{_ 'save'}}
|
||||
button.negate.js-trello-delete-creds(type="button") {{_ 'delete'}}
|
||||
if credsSaved
|
||||
p.quiet.trello-creds-saved
|
||||
i.fa.fa-check
|
||||
span {{_ 'trello-api-credentials-saved'}}
|
||||
p
|
||||
button.primary.js-trello-list-workspaces(type="button") {{_ 'trello-list-workspaces'}}
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
if loading.get
|
||||
+spinner
|
||||
if hasWorkspaces
|
||||
p.import-file-row
|
||||
label(for='trello-parent-workspace') {{_ 'import-trello-parent-workspace'}}
|
||||
select.js-trello-parent-workspace(id='trello-parent-workspace')
|
||||
option(value="") {{_ 'trello-parent-workspace-top'}}
|
||||
each flatWorkspaceNodes
|
||||
option(value=id)= label
|
||||
p.trello-select-controls
|
||||
button.primary.js-trello-select-all(type="button") {{_ 'select-all'}}
|
||||
button.primary.js-trello-unselect-all(type="button") {{_ 'unselect-all'}}
|
||||
.trello-workspaces
|
||||
each workspaceList
|
||||
.trello-workspace
|
||||
a.flex.trello-workspace-title.js-toggle-workspace
|
||||
.materialCheckBox(class="{{#if workspaceSelected}}is-checked{{/if}}")
|
||||
b {{name}}
|
||||
.trello-boards
|
||||
each boards
|
||||
a.flex.trello-board.js-toggle-board(data-board-id="{{id}}")
|
||||
.materialCheckBox(class="{{#if boardSelected}}is-checked{{/if}}")
|
||||
span {{name}}
|
||||
if closed
|
||||
span.quiet ({{_ 'archived'}})
|
||||
if canStartImport
|
||||
p
|
||||
button.primary.wide.js-trello-import-selected(type="button") {{_ 'trello-import-selected'}}
|
||||
if currentJob
|
||||
hr
|
||||
.trello-import-job
|
||||
h3 {{_ 'trello-import-progress'}}
|
||||
with currentJob
|
||||
p.trello-progress-label
|
||||
b {{_ 'status'}}:
|
||||
span {{_ status}} ({{jobProgressText}})
|
||||
.trello-progress-bar
|
||||
.trello-progress-fill(class="{{#if $eq status 'running'}}is-running{{/if}}" style="width: {{jobProgressPercent}}%")
|
||||
.trello-progress-percent {{jobProgressPercent}}%
|
||||
if lastError
|
||||
.warning {{lastError}}
|
||||
p.trello-job-controls
|
||||
if jobIsRunning
|
||||
button.primary.js-trello-cancel(type="button") {{_ 'trello-cancel'}}
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
|
||||
if jobCanResume
|
||||
button.primary.js-trello-resume(type="button") {{_ 'trello-resume'}}
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
|
||||
if jobIsFinished
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-delete-imported'}}
|
||||
button.primary.js-trello-clear(type="button") {{_ 'trello-clear-job'}}
|
||||
|
||||
if jobResults.length
|
||||
.trello-import-results
|
||||
each jobResults
|
||||
p.trello-import-result(class="{{#if success}}is-ok{{else}}is-error{{/if}}")
|
||||
if success
|
||||
i.fa.fa-check
|
||||
span {{title}} — {{attachmentsImported}} {{_ 'attachments'}}
|
||||
else
|
||||
i.fa.fa-warning
|
||||
span {{trelloBoardId}}: {{this.error}}
|
||||
|
||||
if jobHasErrors
|
||||
.trello-import-errors
|
||||
p
|
||||
b {{_ 'trello-import-errors'}}
|
||||
button.primary.js-trello-copy-errors(type="button") {{_ 'copy-to-clipboard'}}
|
||||
if copied.get
|
||||
span.quiet {{_ 'copied'}}
|
||||
textarea.js-trello-errors-text(readonly rows="8" wrap="off")= jobErrorText
|
||||
|
||||
template(name="importMapMembers")
|
||||
h2 {{_ 'import-map-members'}}
|
||||
@@ -48,6 +164,7 @@ template(name="importMapMembers")
|
||||
.mapping-item.ghost-item
|
||||
form
|
||||
input.primary.wide(type="submit" value="{{_ 'done'}}")
|
||||
button.wide.js-import-skip-mapping(type="button") {{_ 'import-without-mapping-members'}}
|
||||
else
|
||||
+spinner
|
||||
|
||||
|
||||
@@ -3,15 +3,36 @@ import { trelloGetMembersToMap } from './trelloMembersMapper';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { wekanGetMembersToMap } from './wekanMembersMapper';
|
||||
import { csvGetMembersToMap } from './csvMembersMapper';
|
||||
import { jiraGetMembersToMap } from './jiraMembersMapper';
|
||||
import { kanboardGetMembersToMap } from './kanboardMembersMapper';
|
||||
import getSlug from 'limax';
|
||||
import { UserSearchIndex } from '/models/users';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import TrelloImportJobs from '/models/trelloImportJobs';
|
||||
|
||||
const Papa = require('papaparse');
|
||||
|
||||
Template.importHeaderBar.helpers({
|
||||
title() {
|
||||
return `import-board-title-${Session.get('importSource')}`;
|
||||
const sourceNameByKey = {
|
||||
trello: 'Trello',
|
||||
wekan: 'JSON',
|
||||
csv: 'CSV-TSV',
|
||||
excel: 'Excel',
|
||||
jira: 'Jira',
|
||||
kanboard: 'Kanboard',
|
||||
deck: 'NextCloud Deck',
|
||||
openproject: 'OpenProject',
|
||||
github: 'GitHub',
|
||||
gitlab: 'GitLab',
|
||||
gitea: 'Gitea',
|
||||
forgejo: 'Forgejo',
|
||||
asana: 'Asana',
|
||||
zenkit: 'Zenkit',
|
||||
};
|
||||
const sourceName = sourceNameByKey[Session.get('importSource')] || 'JSON';
|
||||
return `${TAPi18n.__('import')} / ${sourceName}`;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,10 +61,116 @@ function _prepareAdditionalData(dataObject) {
|
||||
case 'csv':
|
||||
membersToMap = csvGetMembersToMap(dataObject);
|
||||
break;
|
||||
case 'jira':
|
||||
membersToMap = jiraGetMembersToMap(dataObject);
|
||||
break;
|
||||
case 'kanboard':
|
||||
membersToMap = kanboardGetMembersToMap(dataObject);
|
||||
break;
|
||||
default:
|
||||
// NextCloud Deck / OpenProject / GitHub / GitLab / Gitea / Forgejo:
|
||||
// these are imported without member mapping (members can be mapped later).
|
||||
membersToMap = [];
|
||||
break;
|
||||
}
|
||||
return membersToMap;
|
||||
}
|
||||
|
||||
// All Trello file/text imports are run on the server over HTTP instead of the
|
||||
// DDP `importBoard` method. This matters for correctness, not just size: Meteor
|
||||
// automatically re-sends an unacknowledged method call on every reconnect, so if
|
||||
// an import is heavy (or the connection hiccups) the WebSocket can enter an
|
||||
// endless drop/retry loop (the "Invalid frame header" flicker). An HTTP request
|
||||
// is never auto-retried, and a .zip's attachment bytes never touch the realtime
|
||||
// connection at all. The body is either a .zip File (application/zip) or a JSON
|
||||
// string { board, membersMapping } (application/json).
|
||||
async function postTrelloImport(body, contentType) {
|
||||
const token =
|
||||
(window.localStorage && window.localStorage.getItem('Meteor.loginToken')) || '';
|
||||
const resp = await fetch('/import-trello', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
let result = {};
|
||||
try {
|
||||
result = await resp.json();
|
||||
} catch (e) {
|
||||
result = {};
|
||||
}
|
||||
if (!resp.ok || result.error) {
|
||||
throw new Error(result.error || 'import-trello-failed');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// A safe URL slug for an imported board. Trello exports name the board `name`,
|
||||
// WeKan exports use `title`; getSlug (limax) throws on undefined, so guard it.
|
||||
function boardSlug(data) {
|
||||
const raw = (data && (data.title || data.name)) || '';
|
||||
return (raw && getSlug(raw)) || 'imported-board';
|
||||
}
|
||||
|
||||
// A WeKan export produced by a buggy older version (the `meta.boardId` exporter
|
||||
// regression) contains empty swimlanes/lists/cards arrays. Importing it can only
|
||||
// produce an empty board with a single Default swimlane, so detect that case and
|
||||
// warn instead of silently creating an empty board.
|
||||
function wekanExportIsEmpty(board) {
|
||||
const count = key => (Array.isArray(board && board[key]) ? board[key].length : 0);
|
||||
return count('swimlanes') === 0 && count('lists') === 0 && count('cards') === 0;
|
||||
}
|
||||
|
||||
// Navigate to a freshly server-imported board. The HTTP import (unlike a DDP
|
||||
// method) does not push the new board's documents to this client, so we first
|
||||
// subscribe to the board and wait until its lists/swimlanes/cards are loaded
|
||||
// into Minimongo — otherwise the board opens with an empty Swimlanes view until
|
||||
// the page is reloaded. The subscription is left running so the data stays
|
||||
// available when the board is reopened from All Boards in the same session. A
|
||||
// timeout is the safety net in case the subscription never signals ready.
|
||||
function goToImportedBoard(boardId, slug) {
|
||||
let navigated = false;
|
||||
const go = () => {
|
||||
if (navigated) return;
|
||||
navigated = true;
|
||||
FlowRouter.go('board', { id: boardId, slug });
|
||||
};
|
||||
Meteor.subscribe('board', boardId, false, { onReady: go });
|
||||
Meteor.setTimeout(go, 5000);
|
||||
}
|
||||
|
||||
// Find a workspace node by name anywhere in the user's personal workspace tree.
|
||||
function findWorkspaceByName(nodes, name) {
|
||||
for (const node of nodes || []) {
|
||||
if (node.name === name) return node;
|
||||
if (node.children) {
|
||||
const found = findWorkspaceByName(node.children, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Assign an imported board to a personal workspace named `wsName`, creating the
|
||||
// workspace (under an optional parent) only if one with that name doesn't exist.
|
||||
function assignBoardToNamedWorkspace(boardId, wsName, parentId = null) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
|
||||
const existing = findWorkspaceByName(tree, wsName);
|
||||
if (existing) {
|
||||
Meteor.call('assignBoardToWorkspace', boardId, existing.id);
|
||||
return;
|
||||
}
|
||||
Meteor.call('createWorkspace', { parentId, name: wsName }, (err, node) => {
|
||||
if (!err && node) {
|
||||
Meteor.call('assignBoardToWorkspace', boardId, node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Template.import.onCreated(function () {
|
||||
this.error = new ReactiveVar('');
|
||||
this.steps = ['importTextarea', 'importMapMembers'];
|
||||
@@ -51,6 +178,8 @@ Template.import.onCreated(function () {
|
||||
this.importedData = new ReactiveVar();
|
||||
this.membersToMap = new ReactiveVar([]);
|
||||
this.importSource = Session.get('importSource');
|
||||
// True while a Trello .zip package is being uploaded/imported server-side.
|
||||
this.zipImporting = new ReactiveVar(false);
|
||||
|
||||
this.nextStep = () => {
|
||||
const nextStepIndex = this._currentStepIndex.get() + 1;
|
||||
@@ -65,63 +194,179 @@ Template.import.onCreated(function () {
|
||||
this.error.set(error);
|
||||
};
|
||||
|
||||
this.importData = (evt, dataSource) => {
|
||||
// When skipMapping is true, the "map members" step is bypassed and the board
|
||||
// is imported immediately with whatever (possibly empty) mapping exists, so
|
||||
// members can be mapped later. This works for wekan, trello, csv and jira.
|
||||
this.importData = async (evt, dataSource, skipMapping = false) => {
|
||||
evt.preventDefault();
|
||||
const input = this.find('.js-import-json').value;
|
||||
const advance = async () => {
|
||||
if (skipMapping) {
|
||||
await this.finishImport();
|
||||
} else {
|
||||
this.nextStep();
|
||||
}
|
||||
};
|
||||
// Excel (.xlsx): the file is parsed on the server (with exceljs) into rows
|
||||
// and imported through the CSV creator. Member mapping is skipped (members
|
||||
// can be mapped later), so we read the file to base64 and import directly.
|
||||
if (dataSource === 'excel') {
|
||||
const el = this.find('.js-import-excel-file');
|
||||
if (!el || !el.files || !el.files[0]) {
|
||||
this.setError('error-json-malformed');
|
||||
return;
|
||||
}
|
||||
const buf = await el.files[0].arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
this.importedData.set({ excelBase64: window.btoa(binary) });
|
||||
this.membersToMap.set([]);
|
||||
await this.finishImport();
|
||||
return;
|
||||
}
|
||||
if (dataSource === 'csv') {
|
||||
const input = this.find('.js-import-json').value;
|
||||
const csv = input.indexOf('\t') > 0 ? input.replace(/(\t)/g, ',') : input;
|
||||
const ret = Papa.parse(csv);
|
||||
if (ret && ret.data && ret.data.length) this.importedData.set(ret.data);
|
||||
else throw new Meteor.Error('error-csv-schema');
|
||||
const membersToMap = _prepareAdditionalData(ret.data);
|
||||
this.membersToMap.set(membersToMap);
|
||||
this.nextStep();
|
||||
} else {
|
||||
try {
|
||||
const dataObject = JSON.parse(input);
|
||||
this.setError('');
|
||||
this.importedData.set(dataObject);
|
||||
const membersToMap = _prepareAdditionalData(dataObject);
|
||||
// store members data and mapping in Session
|
||||
// (we go deep and 2-way, so storing in data context is not a viable option)
|
||||
this.membersToMap.set(membersToMap);
|
||||
this.nextStep();
|
||||
} catch (e) {
|
||||
this.setError('error-json-malformed');
|
||||
await advance();
|
||||
return;
|
||||
}
|
||||
// Trello: a .zip package (one or more board .json files plus per-board
|
||||
// attachment subdirectories from the Trello Attachments Downloader) imports
|
||||
// all of its boards at once, through a separate field from the single .json.
|
||||
if (dataSource === 'trello') {
|
||||
const zipEl = this.find('.js-import-zip-file');
|
||||
if (zipEl && zipEl.files && zipEl.files[0]) {
|
||||
await this.importTrelloZip(zipEl.files[0]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// A single board: JSON may come from an uploaded .json file (large Trello
|
||||
// exports are awkward to paste) or from the textarea.
|
||||
let input = this.find('.js-import-json').value;
|
||||
const jsonFileEl = this.find('.js-import-json-file');
|
||||
if (jsonFileEl && jsonFileEl.files && jsonFileEl.files[0]) {
|
||||
input = await jsonFileEl.files[0].text();
|
||||
}
|
||||
const dataObject = JSON.parse(input);
|
||||
// Guard against importing a broken/old WeKan export that has no board
|
||||
// content (see wekanExportIsEmpty): warn the user to re-export rather than
|
||||
// silently creating an empty board with only a Default swimlane.
|
||||
if (this.importSource === 'wekan' && wekanExportIsEmpty(dataObject)) {
|
||||
this.setError('error-import-empty-board');
|
||||
return;
|
||||
}
|
||||
this.setError('');
|
||||
|
||||
// Trello: remember the target personal-workspace name for finishImport.
|
||||
this.workspaceName = '';
|
||||
if (dataSource === 'trello') {
|
||||
const wsEl = this.find('.js-import-workspace-name');
|
||||
this.workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
|
||||
}
|
||||
|
||||
this.importedData.set(dataObject);
|
||||
const membersToMap = _prepareAdditionalData(dataObject);
|
||||
// store members data and mapping in Session
|
||||
// (we go deep and 2-way, so storing in data context is not a viable option)
|
||||
this.membersToMap.set(membersToMap);
|
||||
await advance();
|
||||
} catch (e) {
|
||||
this.setError('error-json-malformed');
|
||||
}
|
||||
};
|
||||
|
||||
this.finishImport = () => {
|
||||
const additionalData = {};
|
||||
// Upload a Trello .zip package to the server, which extracts it (with
|
||||
// zip-bomb / path-traversal guards), imports every board and streams each
|
||||
// attachment to the Default storage, then go to All Boards.
|
||||
this.importTrelloZip = async (zipFile) => {
|
||||
this.setError('');
|
||||
const wsEl = this.find('.js-import-workspace-name');
|
||||
const workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
|
||||
|
||||
this.zipImporting.set(true);
|
||||
let result;
|
||||
try {
|
||||
result = await postTrelloImport(zipFile, 'application/zip');
|
||||
} catch (e) {
|
||||
this.zipImporting.set(false);
|
||||
this.setError((e && e.message) || 'import-trello-failed');
|
||||
return;
|
||||
}
|
||||
this.zipImporting.set(false);
|
||||
|
||||
(result.boardIds || []).forEach(boardId => {
|
||||
if (workspaceName) {
|
||||
assignBoardToNamedWorkspace(boardId, workspaceName);
|
||||
}
|
||||
});
|
||||
Session.set('fromBoard', null);
|
||||
// Go to All Boards, where the newly imported boards appear.
|
||||
FlowRouter.go('home');
|
||||
};
|
||||
|
||||
this.finishImport = async () => {
|
||||
const membersMapping = this.membersToMap.get();
|
||||
const mappingById = {};
|
||||
if (membersMapping) {
|
||||
const mappingById = {};
|
||||
membersMapping.forEach(member => {
|
||||
if (member.wekanId) {
|
||||
mappingById[member.id] = member.wekanId;
|
||||
}
|
||||
});
|
||||
additionalData.membersMapping = mappingById;
|
||||
}
|
||||
const importedData = this.importedData.get();
|
||||
|
||||
// Trello: import over HTTP (see postTrelloImport) so the realtime DDP
|
||||
// connection is never used for the board payload and can't enter the
|
||||
// drop/retry "Invalid frame header" flicker loop. Do NOT mutate the still-
|
||||
// mounted map-members template (e.g. clearing membersToMap) before
|
||||
// navigating away — that forces an empty re-render mid-teardown and can
|
||||
// throw "Can't select in removed DomRange". We navigate away, which
|
||||
// destroys the import templates.
|
||||
if (this.importSource === 'trello') {
|
||||
let result;
|
||||
try {
|
||||
result = await postTrelloImport(
|
||||
JSON.stringify({ board: importedData, membersMapping: mappingById }),
|
||||
'application/json',
|
||||
);
|
||||
} catch (e) {
|
||||
this.setError((e && e.message) || 'import-trello-failed');
|
||||
return;
|
||||
}
|
||||
const boardId = (result.boardIds || [])[0];
|
||||
if (!boardId) {
|
||||
this.setError('import-trello-failed');
|
||||
return;
|
||||
}
|
||||
Session.set('fromBoard', null);
|
||||
if (this.workspaceName) {
|
||||
assignBoardToNamedWorkspace(boardId, this.workspaceName);
|
||||
}
|
||||
goToImportedBoard(boardId, boardSlug(importedData));
|
||||
return;
|
||||
}
|
||||
|
||||
// wekan / csv: unchanged DDP import.
|
||||
this.membersToMap.set([]);
|
||||
Meteor.call(
|
||||
'importBoard',
|
||||
this.importedData.get(),
|
||||
additionalData,
|
||||
importedData,
|
||||
{ membersMapping: mappingById },
|
||||
this.importSource,
|
||||
Session.get('fromBoard'),
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
this.setError(err.error);
|
||||
} else {
|
||||
let title = getSlug(this.importedData.get().title) || 'imported-board';
|
||||
Session.set('fromBoard', null);
|
||||
FlowRouter.go('board', {
|
||||
id: res,
|
||||
slug: title,
|
||||
});
|
||||
//Utils.goBoardId(res);
|
||||
goToImportedBoard(res, boardSlug(importedData));
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -135,11 +380,42 @@ Template.import.helpers({
|
||||
currentTemplate() {
|
||||
return Template.instance().steps[Template.instance()._currentStepIndex.get()];
|
||||
},
|
||||
zipImporting() {
|
||||
return Template.instance().zipImporting.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTextarea.helpers({
|
||||
instruction() {
|
||||
return `import-board-instruction-${Session.get('importSource')}`;
|
||||
const importSource = Session.get('importSource');
|
||||
const issueSourceConfig = {
|
||||
github: {
|
||||
sourceName: 'GitHub',
|
||||
endpoint: 'GET /repos/OWNER/REPO/issues',
|
||||
},
|
||||
gitlab: {
|
||||
sourceName: 'GitLab',
|
||||
endpoint: 'GET /projects/ID/issues',
|
||||
},
|
||||
gitea: {
|
||||
sourceName: 'Gitea',
|
||||
endpoint: 'GET /repos/OWNER/REPO/issues',
|
||||
},
|
||||
forgejo: {
|
||||
sourceName: 'Forgejo',
|
||||
endpoint: 'GET /repos/OWNER/REPO/issues',
|
||||
},
|
||||
};
|
||||
|
||||
if (issueSourceConfig[importSource]) {
|
||||
const { sourceName, endpoint } = issueSourceConfig[importSource];
|
||||
return TAPi18n.__('import-board-instruction-issues', {
|
||||
sourceName,
|
||||
endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
return TAPi18n.__(`import-board-instruction-${importSource}`);
|
||||
},
|
||||
importPlaceHolder() {
|
||||
const importSource = Session.get('importSource');
|
||||
@@ -149,6 +425,12 @@ Template.importTextarea.helpers({
|
||||
return 'import-json-placeholder';
|
||||
}
|
||||
},
|
||||
isTrelloImport() {
|
||||
return Session.get('importSource') === 'trello';
|
||||
},
|
||||
isExcelImport() {
|
||||
return Session.get('importSource') === 'excel';
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTextarea.events({
|
||||
@@ -158,6 +440,14 @@ Template.importTextarea.events({
|
||||
return importTpl.importData(evt, Session.get('importSource'));
|
||||
}
|
||||
},
|
||||
// Import immediately, skipping the "map members" step (members can be mapped
|
||||
// later). Works for wekan, trello and jira (and csv).
|
||||
'click .js-import-without-mapping'(evt, tpl) {
|
||||
const importTpl = findParentTemplateInstance(tpl, 'import');
|
||||
if (importTpl) {
|
||||
return importTpl.importData(evt, Session.get('importSource'), true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Module-level reference so popup children can access importMapMembers methods
|
||||
@@ -288,6 +578,15 @@ Template.importMapMembers.events({
|
||||
importTpl.nextStep();
|
||||
}
|
||||
},
|
||||
// Import now without finishing member mapping; only members already mapped
|
||||
// (if any) are applied, the rest can be mapped later.
|
||||
'click .js-import-skip-mapping'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const importTpl = findParentTemplateInstance(tpl, 'import');
|
||||
if (importTpl) {
|
||||
importTpl.finishImport();
|
||||
}
|
||||
},
|
||||
'click .js-select-member'(evt, tpl) {
|
||||
const memberToMap = Template.currentData();
|
||||
if (memberToMap.wekan) {
|
||||
@@ -320,7 +619,11 @@ Template.importMapMembersAddPopup.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.importMapMembersAddPopup.onRendered(function () {
|
||||
this.find('.js-search-member-input').focus();
|
||||
// Guard against the DOM range being gone (e.g. the popup was closed during a
|
||||
// re-render) — calling find/$ then throws "Can't select in removed DomRange".
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
const input = this.find('.js-search-member-input');
|
||||
if (input) input.focus();
|
||||
});
|
||||
|
||||
Template.importMapMembersAddPopup.onDestroyed(function () {
|
||||
@@ -380,3 +683,310 @@ Template.importMapMembersAddPopup.helpers({
|
||||
return importMemberPopupState.noResults;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Trello API import: key/token -> list workspaces & boards -> import
|
||||
// selected boards (with attachments) server-side, placing each under a
|
||||
// personal workspace named after its Trello workspace.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function flattenWorkspaceTree(nodes, depth = 0, acc = []) {
|
||||
(nodes || []).forEach(node => {
|
||||
acc.push({ id: node.id, label: `${'— '.repeat(depth)}${node.name}` });
|
||||
if (node.children && node.children.length) {
|
||||
flattenWorkspaceTree(node.children, depth + 1, acc);
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Build the copy-paste-friendly error text for a job: the error log plus a
|
||||
// summary line per failed board.
|
||||
function jobErrorText(job) {
|
||||
if (!job) return '';
|
||||
const lines = [];
|
||||
(job.errorLog || []).forEach(line => lines.push(line));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
Template.importTrelloApi.onCreated(function () {
|
||||
this.error = new ReactiveVar('');
|
||||
this.loading = new ReactiveVar(false);
|
||||
this.workspaces = new ReactiveVar([]);
|
||||
this.copied = new ReactiveVar(false);
|
||||
// Board selection state: map of trelloBoardId -> true. Tracked here (rather
|
||||
// than via DOM checkboxes) because the UI uses animated .materialCheckBox
|
||||
// elements, not native inputs.
|
||||
this.selectedBoards = new ReactiveVar({});
|
||||
// The import itself runs server-side as a persisted job; watch it reactively
|
||||
// so progress survives navigating away and back.
|
||||
this.subscribe('trelloImportJobs');
|
||||
});
|
||||
|
||||
// Collect every board id across all listed workspaces.
|
||||
function allBoardIds(workspaces) {
|
||||
const ids = {};
|
||||
(workspaces || []).forEach(ws => {
|
||||
(ws.boards || []).forEach(b => {
|
||||
ids[b.id] = true;
|
||||
});
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
Template.importTrelloApi.helpers({
|
||||
error() {
|
||||
return Template.instance().error;
|
||||
},
|
||||
loading() {
|
||||
return Template.instance().loading;
|
||||
},
|
||||
copied() {
|
||||
return Template.instance().copied;
|
||||
},
|
||||
hasWorkspaces() {
|
||||
return Template.instance().workspaces.get().length > 0;
|
||||
},
|
||||
workspaceList() {
|
||||
return Template.instance().workspaces.get();
|
||||
},
|
||||
boardSelected() {
|
||||
return !!Template.instance().selectedBoards.get()[this.id];
|
||||
},
|
||||
workspaceSelected() {
|
||||
const sel = Template.instance().selectedBoards.get();
|
||||
const boards = this.boards || [];
|
||||
return boards.length > 0 && boards.every(b => sel[b.id]);
|
||||
},
|
||||
flatWorkspaceNodes() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
|
||||
return flattenWorkspaceTree(tree);
|
||||
},
|
||||
credsSaved() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return !!(user && user.profile && user.profile.trelloApiSaved);
|
||||
},
|
||||
|
||||
// --- current background job ---
|
||||
currentJob() {
|
||||
return TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
},
|
||||
jobIsRunning() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return job && job.status === 'running';
|
||||
},
|
||||
jobCanResume() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return job && (job.status === 'paused' || job.status === 'error');
|
||||
},
|
||||
jobIsFinished() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return job && (job.status === 'done' || job.status === 'cancelled');
|
||||
},
|
||||
canStartImport() {
|
||||
// Don't start a second import while one is active (running/paused/error),
|
||||
// which would create a hidden concurrent job.
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return !job || job.status === 'done' || job.status === 'cancelled';
|
||||
},
|
||||
jobProgressText() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return '';
|
||||
return `${job.currentIndex} / ${job.total}`;
|
||||
},
|
||||
jobProgressPercent() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job || !job.total) return 0;
|
||||
return Math.round((job.currentIndex / job.total) * 100);
|
||||
},
|
||||
jobResults() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return (job && job.results) || [];
|
||||
},
|
||||
jobHasErrors() {
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
return !!(job && job.errorLog && job.errorLog.length);
|
||||
},
|
||||
jobErrorText() {
|
||||
return jobErrorText(TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } }));
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTrelloApi.events({
|
||||
'click .js-trello-save-creds'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
if (!key || !token) {
|
||||
tpl.error.set('trello-api-credentials-required');
|
||||
return;
|
||||
}
|
||||
tpl.error.set('');
|
||||
Meteor.call('saveTrelloCredentials', key, token, err => {
|
||||
if (err) {
|
||||
tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
return;
|
||||
}
|
||||
// Don't keep the token sitting in the browser; it now lives server-side.
|
||||
tpl.find('.js-trello-key').value = '';
|
||||
tpl.find('.js-trello-token').value = '';
|
||||
});
|
||||
},
|
||||
'click .js-trello-delete-creds'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.error.set('');
|
||||
Meteor.call('deleteTrelloCredentials', err => {
|
||||
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
});
|
||||
tpl.find('.js-trello-key').value = '';
|
||||
tpl.find('.js-trello-token').value = '';
|
||||
},
|
||||
'click .js-trello-list-workspaces'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
// key/token may be empty when saved credentials exist; the server falls
|
||||
// back to the saved ones and returns an error if neither is available.
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
tpl.error.set('');
|
||||
tpl.loading.set(true);
|
||||
Meteor.call('trelloListWorkspaces', key, token, (err, res) => {
|
||||
tpl.loading.set(false);
|
||||
if (err) {
|
||||
tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
tpl.workspaces.set([]);
|
||||
tpl.selectedBoards.set({});
|
||||
} else {
|
||||
const workspaces = res || [];
|
||||
tpl.workspaces.set(workspaces);
|
||||
// Preselect all boards by default.
|
||||
tpl.selectedBoards.set(allBoardIds(workspaces));
|
||||
}
|
||||
});
|
||||
},
|
||||
// Toggle a single board's animated checkbox.
|
||||
'click .js-toggle-board'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const id = this.id;
|
||||
const sel = { ...tpl.selectedBoards.get() };
|
||||
if (sel[id]) {
|
||||
delete sel[id];
|
||||
} else {
|
||||
sel[id] = true;
|
||||
}
|
||||
tpl.selectedBoards.set(sel);
|
||||
},
|
||||
// Toggle all boards in a workspace: if all are selected, clear them; else
|
||||
// select them all.
|
||||
'click .js-toggle-workspace'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const boards = this.boards || [];
|
||||
const sel = { ...tpl.selectedBoards.get() };
|
||||
const allSelected = boards.length > 0 && boards.every(b => sel[b.id]);
|
||||
boards.forEach(b => {
|
||||
if (allSelected) {
|
||||
delete sel[b.id];
|
||||
} else {
|
||||
sel[b.id] = true;
|
||||
}
|
||||
});
|
||||
tpl.selectedBoards.set(sel);
|
||||
},
|
||||
'click .js-trello-select-all'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.selectedBoards.set(allBoardIds(tpl.workspaces.get()));
|
||||
},
|
||||
'click .js-trello-unselect-all'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
tpl.selectedBoards.set({});
|
||||
},
|
||||
'click .js-trello-import-selected'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
const sel = tpl.selectedBoards.get();
|
||||
const boardIds = Object.keys(sel).filter(id => sel[id]);
|
||||
if (!boardIds.length) {
|
||||
tpl.error.set('trello-select-boards');
|
||||
return;
|
||||
}
|
||||
const parentEl = tpl.find('.js-trello-parent-workspace');
|
||||
const parentId = parentEl && parentEl.value ? parentEl.value : null;
|
||||
|
||||
tpl.error.set('');
|
||||
// Start the server-side job; progress shows up via the subscription. The
|
||||
// user is free to navigate away and come back.
|
||||
Meteor.call('trelloStartImport', key, token, boardIds, parentId, err => {
|
||||
if (err) {
|
||||
tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
}
|
||||
});
|
||||
},
|
||||
'click .js-trello-resume'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
const key = tpl.find('.js-trello-key').value.trim();
|
||||
const token = tpl.find('.js-trello-token').value.trim();
|
||||
if (!key || !token) {
|
||||
tpl.error.set('trello-api-credentials-required');
|
||||
return;
|
||||
}
|
||||
tpl.error.set('');
|
||||
Meteor.call('trelloResumeImport', job._id, key, token, err => {
|
||||
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
|
||||
});
|
||||
},
|
||||
'click .js-trello-cancel'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
Meteor.call('trelloCancelImport', job._id, false);
|
||||
},
|
||||
'click .js-trello-cancel-delete'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
// eslint-disable-next-line no-alert
|
||||
if (!window.confirm(TAPi18n.__('trello-cancel-delete-confirm'))) return;
|
||||
Meteor.call('trelloCancelImport', job._id, true);
|
||||
},
|
||||
'click .js-trello-clear'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
if (!job) return;
|
||||
Meteor.call('trelloClearImportJob', job._id, false);
|
||||
},
|
||||
'click .js-trello-copy-errors'(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
|
||||
const text = jobErrorText(job);
|
||||
if (!text) return;
|
||||
const done = () => {
|
||||
tpl.copied.set(true);
|
||||
setTimeout(() => tpl.copied.set(false), 2000);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, () => {
|
||||
// Fall back to selecting the textarea so the user can copy manually.
|
||||
const ta = tpl.find('.js-trello-errors-text');
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.select();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const ta = tpl.find('.js-trello-errors-text');
|
||||
if (ta) {
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
done();
|
||||
} catch (e) {
|
||||
// user can copy manually from the selected textarea
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Extract the distinct assignees from a Jira export so the import "map members"
|
||||
// step can map each Jira user to a WeKan user.
|
||||
export function jiraGetMembersToMap(data) {
|
||||
const issues = Array.isArray(data) ? data : data.issues || [];
|
||||
const byId = {};
|
||||
issues.forEach(issue => {
|
||||
const assignee = issue.fields && issue.fields.assignee;
|
||||
if (!assignee) return;
|
||||
const id = assignee.accountId || assignee.name || assignee.emailAddress;
|
||||
if (!id || byId[id]) return;
|
||||
byId[id] = {
|
||||
id,
|
||||
username: assignee.name || assignee.displayName || id,
|
||||
fullName: assignee.displayName || assignee.name || id,
|
||||
wekanId: null,
|
||||
};
|
||||
});
|
||||
return Object.values(byId);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Extract the distinct task owners from a Kanboard export so the import
|
||||
// "map members" step can map each Kanboard user to a WeKan user.
|
||||
export function kanboardGetMembersToMap(data) {
|
||||
const tasks = Array.isArray(data) ? data : data.tasks || [];
|
||||
const byId = {};
|
||||
tasks.forEach(task => {
|
||||
const id = task.owner_id || task.owner_username || task.owner_name;
|
||||
if (!id || byId[id]) return;
|
||||
byId[id] = {
|
||||
id: String(id),
|
||||
username: task.owner_username || task.owner_name || String(id),
|
||||
fullName: task.owner_name || task.owner_username || String(id),
|
||||
wekanId: null,
|
||||
};
|
||||
});
|
||||
return Object.values(byId);
|
||||
}
|
||||
@@ -4,15 +4,24 @@ export function wekanGetMembersToMap(data) {
|
||||
// we will work on the list itself (an ordered array of objects) when a
|
||||
// mapping is done, we add a 'wekan' field to the object representing the
|
||||
// imported member
|
||||
const membersToMap = data.members;
|
||||
const users = data.users;
|
||||
const membersToMap = data.members || [];
|
||||
const users = data.users || [];
|
||||
// auto-map based on username
|
||||
const mappable = [];
|
||||
membersToMap.forEach(importedMember => {
|
||||
importedMember.id = importedMember.userId;
|
||||
delete importedMember.userId;
|
||||
const user = users.filter(user => {
|
||||
return user._id === importedMember.id;
|
||||
})[0];
|
||||
// The exported `users` array only contains users that still exist in the
|
||||
// source instance, so a member whose account was deleted (or any other
|
||||
// dangling user reference, e.g. from an older export) has no matching
|
||||
// user. Skip it instead of dereferencing `undefined`, which would throw
|
||||
// and make the whole import fail as "error-json-malformed".
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
if (user.profile && user.profile.fullname) {
|
||||
importedMember.fullName = user.profile.fullname;
|
||||
}
|
||||
@@ -21,6 +30,7 @@ export function wekanGetMembersToMap(data) {
|
||||
if (wekanUser) {
|
||||
importedMember.wekanId = wekanUser._id;
|
||||
}
|
||||
mappable.push(importedMember);
|
||||
});
|
||||
return membersToMap;
|
||||
return mappable;
|
||||
}
|
||||
@@ -4,16 +4,16 @@
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: #dedede;
|
||||
border-left: 1px solid #ccc;
|
||||
border-inline-start: 1px solid #ccc;
|
||||
padding: 0;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
/* List resize handle */
|
||||
.list-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
inset-inline-end: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
@@ -84,16 +84,17 @@
|
||||
flex-basis: auto !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
/* Override any conflicting layout properties */
|
||||
float: left !important;
|
||||
display: block !important;
|
||||
/* Override any conflicting layout properties.
|
||||
Keep the flex column layout so the list-body stays height-bounded and
|
||||
keeps its vertical scrollbar (display:block would collapse it). */
|
||||
float: inline-start !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
position: relative !important;
|
||||
/* Force width to be respected */
|
||||
width: var(--list-width, auto) !important;
|
||||
min-width: var(--list-width, auto) !important;
|
||||
max-width: var(--list-width, auto) !important;
|
||||
/* Ensure the width is applied immediately */
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body.list-resizing-active {
|
||||
@@ -129,8 +130,9 @@ body.list-resizing-active * {
|
||||
flex-basis: auto !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
float: left !important;
|
||||
display: block !important;
|
||||
float: inline-start !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Ensure the width persists after resize is complete */
|
||||
@@ -145,19 +147,20 @@ body.list-resizing-active * {
|
||||
flex-basis: auto !important;
|
||||
flex-grow: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
float: left !important;
|
||||
display: block !important;
|
||||
float: inline-start !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
/* Ensure consistent header height for all lists */
|
||||
.list-header {
|
||||
/* Maintain consistent height and padding for all lists */
|
||||
min-height: 2.5vh !important;
|
||||
min-height: 30px !important;
|
||||
height: auto !important;
|
||||
padding: 2.5vh 1.5vw 0.5vh !important;
|
||||
padding: 28px 1.5vw 6px !important;
|
||||
/* Make sure the background covers the full height */
|
||||
background-color: #e4e4e4 !important;
|
||||
border-bottom: 0.8vh solid #e4e4e4 !important;
|
||||
border-bottom: 8px solid #e4e4e4 !important;
|
||||
/* Use original display for consistent button positioning */
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
@@ -189,7 +192,7 @@ body.list-resizing-active * {
|
||||
.list-header .js-open-list-menu {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
right: 10px !important;
|
||||
inset-inline-end: 10px !important;
|
||||
z-index: 15 !important;
|
||||
display: inline-block !important;
|
||||
padding: 4px !important;
|
||||
@@ -198,7 +201,7 @@ body.list-resizing-active * {
|
||||
.list-header .list-header-plus-top {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
right: 30px !important;
|
||||
inset-inline-end: 30px !important;
|
||||
z-index: 15 !important;
|
||||
display: inline-block !important;
|
||||
padding: 4px !important;
|
||||
@@ -207,7 +210,7 @@ body.list-resizing-active * {
|
||||
.list-header .list-header-handle-desktop {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
right: 80px !important;
|
||||
inset-inline-end: 80px !important;
|
||||
z-index: 15 !important;
|
||||
display: inline-block !important;
|
||||
cursor: move !important;
|
||||
@@ -229,11 +232,11 @@ body.list-resizing-active * {
|
||||
flex: 1;
|
||||
}
|
||||
.list:first-child {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
flex: none;
|
||||
}
|
||||
.card-details + .list {
|
||||
border-left: none;
|
||||
border-inline-start: none;
|
||||
}
|
||||
.list.ui-sortable-helper {
|
||||
box-shadow: -2px 2px 8px rgba(0,0,0,0.3), 0 0 1px rgba(0,0,0,0.5);
|
||||
@@ -311,7 +314,7 @@ body.list-resizing-active * {
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
width: auto;
|
||||
left: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
top: auto !important;
|
||||
}
|
||||
.list.list-collapsed .list-header .list-header-handle {
|
||||
@@ -322,7 +325,7 @@ body.list-resizing-active * {
|
||||
display: block;
|
||||
width: auto;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
@@ -336,8 +339,8 @@ body.list-resizing-active * {
|
||||
display: block;
|
||||
width: auto;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
transform: none !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
@@ -373,7 +376,7 @@ body.list-resizing-active * {
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -401,24 +404,24 @@ form.inlined-form .list-composer {
|
||||
.list.list-composer .list-name-input,
|
||||
.list .list-composer .list-name-input {
|
||||
background: #fff;
|
||||
margin: -0.4vh 0 1vh;
|
||||
margin: -4px 0 8px;
|
||||
}
|
||||
.list-header-add {
|
||||
flex: 0 0 auto;
|
||||
padding: 1.5vh 1.5vw;
|
||||
padding: 14px 1.5vw;
|
||||
position: relative;
|
||||
min-height: 2.5vh;
|
||||
min-height: 30px;
|
||||
}
|
||||
.list-header {
|
||||
flex: 0 0 auto;
|
||||
padding: 2.5vh 1.5vw 0.5vh;
|
||||
padding: 28px 1.5vw 6px;
|
||||
position: relative;
|
||||
min-height: 2.5vh;
|
||||
min-height: 30px;
|
||||
background-color: #e4e4e4;
|
||||
border-bottom: 0.8vh solid #e4e4e4;
|
||||
border-bottom: 8px solid #e4e4e4;
|
||||
}
|
||||
.list-header.list-header-card-count {
|
||||
min-height: 4.5vh;
|
||||
min-height: 45px;
|
||||
height: auto;
|
||||
}
|
||||
.list-header.ui-sortable-handle {
|
||||
@@ -433,7 +436,7 @@ form.inlined-form .list-composer {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
min-height: 1.2vh;
|
||||
min-height: 14px;
|
||||
min-width: 4vw;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
@@ -443,7 +446,7 @@ form.inlined-form .list-composer {
|
||||
/* Sum badge shown before list title */
|
||||
.list-header .list-sum-badge {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
@@ -456,19 +459,19 @@ form.inlined-form .list-composer {
|
||||
width: 1.3vw;
|
||||
height: 35vh;
|
||||
margin-top: -12vh;
|
||||
margin-left: -14vw;
|
||||
margin-right: 0;
|
||||
margin-inline-start: -14vw;
|
||||
margin-inline-end: 0;
|
||||
transform: rotate(90deg);
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.list-header .list-header-watch-icon {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
color: #a6a6a6;
|
||||
}
|
||||
.list-header .list-header-menu {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
@media print {
|
||||
.list-header .list-header-menu,
|
||||
@@ -478,7 +481,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-plus-top {
|
||||
color: #a6a6a6;
|
||||
margin-right: 15px;
|
||||
margin-inline-end: 15px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -487,13 +490,13 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-collapse-left {
|
||||
color: #a6a6a6;
|
||||
margin-right: 15px;
|
||||
margin-inline-end: 15px;
|
||||
}
|
||||
/* List header collapse button styling - positioned at top left */
|
||||
.list-header .js-collapse {
|
||||
position: absolute !important;
|
||||
top: 5px !important;
|
||||
left: 10px !important;
|
||||
inset-inline-start: 10px !important;
|
||||
color: #a6a6a6;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
@@ -527,8 +530,8 @@ form.inlined-form .list-composer {
|
||||
.list:not(.list-collapsed):not(.mobile-view) .list-header .js-collapse {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
transform: none !important;
|
||||
margin: 0 !important;
|
||||
min-width: 24px;
|
||||
@@ -580,7 +583,7 @@ form.inlined-form .list-composer {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -631,7 +634,7 @@ form.inlined-form .list-composer {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -682,7 +685,7 @@ form.inlined-form .list-composer {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: static;
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
top: auto;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
@@ -702,7 +705,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-collapse {
|
||||
color: #a6a6a6;
|
||||
margin-right: 15px;
|
||||
margin-inline-end: 15px;
|
||||
}
|
||||
.list-header .highlight {
|
||||
color: #ce1414;
|
||||
@@ -716,7 +719,7 @@ form.inlined-form .list-composer {
|
||||
.js-open-list-menu,
|
||||
.list-header-menu a {
|
||||
color: #4d4d4d;
|
||||
padding-left: 4px;
|
||||
padding-inline-start: 4px;
|
||||
}
|
||||
.js-open-list-menu {
|
||||
font-size: 18px;
|
||||
@@ -757,7 +760,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
}
|
||||
.list-body .open-minicard-composer i.fa {
|
||||
margin-right: 7px;
|
||||
margin-inline-end: 7px;
|
||||
}
|
||||
.list-body .open-minicard-composer:hover {
|
||||
background: #fafafa;
|
||||
@@ -775,16 +778,16 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
#js-wip-limit-edit .wip-limit-value {
|
||||
width: 20%;
|
||||
margin-right: 5%;
|
||||
margin-inline-end: 5%;
|
||||
}
|
||||
#js-wip-limit-edit .wip-limit-error {
|
||||
display: none;
|
||||
}
|
||||
#js-wip-limit-edit .soft-wip-limit {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
#js-wip-limit-edit div {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
#js-list-width-edit .list-width-error {
|
||||
display: none;
|
||||
@@ -796,7 +799,7 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -806,19 +809,19 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.list.mobile-view:first-child {
|
||||
margin-left: 0px;
|
||||
margin-inline-start: 0px;
|
||||
}
|
||||
.list.mobile-view.ui-sortable-helper {
|
||||
flex: 0 0 60px;
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -830,7 +833,7 @@ form.inlined-form .list-composer {
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -857,10 +860,10 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list.mobile-view .list-header .list-header-left-icon {
|
||||
padding: 7px;
|
||||
padding-right: 27px;
|
||||
padding-inline-end: 27px;
|
||||
margin-top: 1px;
|
||||
top: -7px;
|
||||
left: -7px;
|
||||
inset-inline-start: -7px;
|
||||
}
|
||||
.list.mobile-view .list-header .list-header-menu-icon {
|
||||
padding: 14px;
|
||||
@@ -868,7 +871,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* Force positioning for iPhone */
|
||||
position: absolute !important;
|
||||
right: 60px !important;
|
||||
inset-inline-end: 60px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
z-index: 10;
|
||||
@@ -879,7 +882,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* Force positioning for iPhone */
|
||||
position: absolute !important;
|
||||
right: 10px !important;
|
||||
inset-inline-end: 10px !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
z-index: 10;
|
||||
@@ -902,9 +905,9 @@ form.inlined-form .list-composer {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
align-self: start;
|
||||
text-align: left;
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
text-align: start;
|
||||
padding-inline-start: 0;
|
||||
margin-inline-start: 0;
|
||||
font-size: 16px !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -936,7 +939,7 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -946,19 +949,19 @@ form.inlined-form .list-composer {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
min-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.list:first-child {
|
||||
margin-left: 0px;
|
||||
margin-inline-start: 0px;
|
||||
}
|
||||
.list.ui-sortable-helper {
|
||||
flex: 0 0 60px;
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -970,7 +973,7 @@ form.inlined-form .list-composer {
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
border-left: 0px !important;
|
||||
border-inline-start: 0px !important;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -993,10 +996,10 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-header .list-header-left-icon {
|
||||
padding: 7px;
|
||||
padding-right: 27px;
|
||||
padding-inline-end: 27px;
|
||||
margin-top: 1px;
|
||||
top: -7px;
|
||||
left: -7px;
|
||||
inset-inline-start: -7px;
|
||||
}
|
||||
.list-header .list-header-menu-icon {
|
||||
padding: 14px;
|
||||
@@ -1004,7 +1007,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* iOS Safari fallback positioning */
|
||||
position: absolute;
|
||||
right: 60px;
|
||||
inset-inline-end: 60px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -1014,7 +1017,7 @@ form.inlined-form .list-composer {
|
||||
text-align: center;
|
||||
/* iOS Safari fallback positioning */
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
inset-inline-end: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
@@ -1080,7 +1083,7 @@ form.inlined-form .list-composer {
|
||||
.list.mobile-view .list-header .list-header-menu-icon {
|
||||
/* Remove absolute positioning for iPhone 12 Mini */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1094,7 +1097,7 @@ form.inlined-form .list-composer {
|
||||
.list.mobile-view .list-header .list-header-handle {
|
||||
/* Remove absolute positioning for iPhone 12 Mini */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1119,9 +1122,9 @@ form.inlined-form .list-composer {
|
||||
grid-row: 2 !important;
|
||||
grid-column: 2 !important;
|
||||
align-self: start !important;
|
||||
text-align: left !important;
|
||||
padding-left: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
text-align: start !important;
|
||||
padding-inline-start: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
@@ -1145,7 +1148,7 @@ form.inlined-form .list-composer {
|
||||
.iphone-device .list.mobile-view .list-header .list-header-menu-icon {
|
||||
/* Remove absolute positioning for iPhone devices */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1159,7 +1162,7 @@ form.inlined-form .list-composer {
|
||||
.iphone-device .list.mobile-view .list-header .list-header-handle {
|
||||
/* Remove absolute positioning for iPhone devices */
|
||||
position: static !important;
|
||||
right: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
top: auto !important;
|
||||
transform: none !important;
|
||||
/* Use grid positioning */
|
||||
@@ -1196,7 +1199,7 @@ form.inlined-form .list-composer {
|
||||
|
||||
/* Allow long list titles to expand on desktop (non-mobile, non-collapsed) */
|
||||
.list:not(.mobile-view):not(.list-collapsed) .list-header {
|
||||
padding-top: calc(2.5vh - 6px) !important;
|
||||
padding-top: 22px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
@@ -1216,7 +1219,7 @@ form.inlined-form .list-composer {
|
||||
align-items: baseline;
|
||||
}
|
||||
.link-board-wrapper .js-link-board {
|
||||
margin-left: 15px;
|
||||
margin-inline-start: 15px;
|
||||
}
|
||||
.search-card-results {
|
||||
max-height: 250px;
|
||||
@@ -1321,8 +1324,8 @@ form.inlined-form .list-composer {
|
||||
.list.list-collapsed:not(.mobile-view) .list-header .list-header-handle {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
transform: none !important;
|
||||
display: block !important;
|
||||
margin: 4px auto 2px auto !important;
|
||||
@@ -1337,8 +1340,8 @@ form.inlined-form .list-composer {
|
||||
.list.list-collapsed:not(.mobile-view) .list-header .js-collapse {
|
||||
position: relative !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
display: block !important;
|
||||
margin: 2px auto !important;
|
||||
z-index: 400 !important;
|
||||
@@ -1374,7 +1377,7 @@ form.inlined-form .list-composer {
|
||||
transform: none !important;
|
||||
writing-mode: vertical-lr !important;
|
||||
text-orientation: mixed !important;
|
||||
text-align: left !important;
|
||||
text-align: start !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@@ -1444,8 +1447,8 @@ form.inlined-form .list-composer {
|
||||
flex: 0 0 auto;
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
right: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
transform: none !important;
|
||||
margin: 2px 2px 0 0 !important;
|
||||
padding: 2px 4px !important;
|
||||
@@ -1463,7 +1466,7 @@ form.inlined-form .list-composer {
|
||||
flex: 1 0 100%;
|
||||
min-width: 0;
|
||||
padding-top: 4px !important;
|
||||
padding-right: 0 !important;
|
||||
padding-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
/* Hide standalone duplicate drag handle outside the menu group */
|
||||
@@ -1475,7 +1478,7 @@ form.inlined-form .list-composer {
|
||||
.list:not(.list-collapsed):not(.mobile-view) .list-header .list-header-watch-icon {
|
||||
order: 2;
|
||||
flex: 0 0 auto;
|
||||
padding-left: 0;
|
||||
padding-inline-start: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@@ -1486,7 +1489,7 @@ form.inlined-form .list-composer {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
margin-top: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -1497,8 +1500,8 @@ form.inlined-form .list-composer {
|
||||
.list:not(.list-collapsed):not(.mobile-view) .list-header .list-header-menu .js-open-list-menu {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
left: auto !important;
|
||||
inset-inline-end: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
transform: none !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
@@ -1533,7 +1536,7 @@ form.inlined-form .list-composer {
|
||||
|
||||
.list.list-collapsed .list-header .js-collapse {
|
||||
position: relative !important;
|
||||
left: -10px !important;
|
||||
inset-inline-start: -10px !important;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
@@ -38,6 +38,15 @@ Template.list.onRendered(function () {
|
||||
const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
|
||||
const $cards = this.$('.js-minicards');
|
||||
|
||||
// Destroy any existing sortable before re-initializing. Without this, a
|
||||
// re-render of the list (e.g. when a Card Details panel opens) binds a SECOND
|
||||
// sortable to the same .js-minicards element, so dragging a card from its
|
||||
// handle fires the `stop` handler twice and the card appears to duplicate.
|
||||
// Mirrors the guard in swimlanes.js initSortable().
|
||||
if ($cards.data('uiSortable') || $cards.data('sortable')) {
|
||||
$cards.sortable('destroy');
|
||||
}
|
||||
|
||||
$cards.sortable({
|
||||
connectWith: '.js-minicards:not(.js-list-full)',
|
||||
tolerance: 'pointer',
|
||||
@@ -345,9 +354,19 @@ Template.list.onCreated(function () {
|
||||
return 550;
|
||||
};
|
||||
|
||||
// Read the horizontal page coordinate from either a jQuery mouse event
|
||||
// or a native touch event (touchstart/move expose `touches`, touchend
|
||||
// exposes `changedTouches`).
|
||||
const getEventPageX = (e) => {
|
||||
const oe = e.originalEvent || e;
|
||||
if (oe.touches && oe.touches.length) return oe.touches[0].pageX;
|
||||
if (oe.changedTouches && oe.changedTouches.length) return oe.changedTouches[0].pageX;
|
||||
return e.pageX;
|
||||
};
|
||||
|
||||
const startResize = (e) => {
|
||||
isResizing = true;
|
||||
startX = e.pageX || e.originalEvent.touches[0].pageX;
|
||||
startX = getEventPageX(e);
|
||||
startWidth = $list.outerWidth();
|
||||
|
||||
// Add visual feedback
|
||||
@@ -366,7 +385,7 @@ Template.list.onCreated(function () {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
|
||||
const currentX = getEventPageX(e);
|
||||
const deltaX = currentX - startX;
|
||||
const newWidth = Math.max(minWidth, startWidth + deltaX);
|
||||
|
||||
@@ -390,7 +409,7 @@ Template.list.onCreated(function () {
|
||||
isResizing = false;
|
||||
|
||||
// Calculate final width
|
||||
const currentX = e.pageX || e.originalEvent.touches[0].pageX;
|
||||
const currentX = getEventPageX(e);
|
||||
const deltaX = currentX - startX;
|
||||
const finalWidth = Math.max(minWidth, startWidth + deltaX);
|
||||
const listConstraint = getListConstraint();
|
||||
@@ -468,10 +487,15 @@ Template.list.onCreated(function () {
|
||||
$(document).on('mousemove', doResize);
|
||||
$(document).on('mouseup', stopResize);
|
||||
|
||||
// Touch events for mobile
|
||||
$resizeHandle.on('touchstart', startResize, { passive: false });
|
||||
$(document).on('touchmove', doResize, { passive: false });
|
||||
$(document).on('touchend', stopResize, { passive: false });
|
||||
// Touch events for mobile.
|
||||
// NOTE: jQuery's .on() does not accept an addEventListener-style options
|
||||
// object — passing { passive: false } made jQuery bind the object itself
|
||||
// as the handler, throwing "handler.apply is not a function" on every
|
||||
// touch. Use the native API so { passive: false } actually applies and
|
||||
// preventDefault() works during the resize.
|
||||
$resizeHandle[0].addEventListener('touchstart', startResize, { passive: false });
|
||||
document.addEventListener('touchmove', doResize, { passive: false });
|
||||
document.addEventListener('touchend', stopResize, { passive: false });
|
||||
|
||||
// Prevent dragscroll interference
|
||||
$resizeHandle.on('mousedown', (e) => {
|
||||
@@ -492,8 +516,8 @@ Template.list.onCreated(function () {
|
||||
tpl.view.onViewDestroyed(() => {
|
||||
$(document).off('mousemove', doResize);
|
||||
$(document).off('mouseup', stopResize);
|
||||
$(document).off('touchmove', doResize);
|
||||
$(document).off('touchend', stopResize);
|
||||
document.removeEventListener('touchmove', doResize);
|
||||
document.removeEventListener('touchend', stopResize);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
template(name="listBody")
|
||||
unless collapsed
|
||||
.list-body(class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}")
|
||||
.minicards.clearfix.js-minicards(class="{{#if reachedWipLimit}}js-list-full{{/if}}")
|
||||
+inlinedForm(autoclose=false position="top")
|
||||
+addCardForm(listId=_id position="top")
|
||||
ul.sidebar-list
|
||||
each customFieldsSum
|
||||
li
|
||||
.list-body(class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}")
|
||||
.minicards.clearfix.js-minicards(class="{{#if reachedWipLimit}}js-list-full{{/if}}")
|
||||
+inlinedForm(autoclose=false position="top")
|
||||
+addCardForm(listId=_id position="top")
|
||||
ul.sidebar-list
|
||||
each customFieldsSum
|
||||
li
|
||||
+viewer
|
||||
= name
|
||||
if $eq customFieldsSum.type "number"
|
||||
+viewer
|
||||
= name
|
||||
if $eq customFieldsSum.type "number"
|
||||
+viewer
|
||||
= value
|
||||
if $eq customFieldsSum.type "currency"
|
||||
+viewer
|
||||
= formattedCurrencyCustomFieldValue(value)
|
||||
each (cardsWithLimit (idOrNull ../../_id))
|
||||
a.minicard-wrapper.js-minicard(href=originRelativeUrl
|
||||
class="{{#if cardIsSelected}}is-selected{{/if}}"
|
||||
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
if MultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-multi-selection(
|
||||
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
+minicard(this)
|
||||
if (showSpinner (idOrNull ../../_id))
|
||||
+spinnerList
|
||||
if canSeeAddCard
|
||||
+inlinedForm(autoclose=false position="bottom")
|
||||
+addCardForm(listId=_id position="bottom")
|
||||
else
|
||||
a.open-minicard-composer.js-card-composer.js-open-inlined-form(title="{{_ 'add-card-to-bottom-of-list'}}")
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-card'}}
|
||||
= value
|
||||
if $eq customFieldsSum.type "currency"
|
||||
+viewer
|
||||
= formattedCurrencyCustomFieldValue(value)
|
||||
each (cardsWithLimit (idOrNull ../../_id))
|
||||
a.minicard-wrapper.js-minicard(href=originRelativeUrl
|
||||
data-card-id="{{_id}}"
|
||||
class="{{#if cardIsSelected}}is-selected{{/if}}"
|
||||
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
if MultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-multi-selection(
|
||||
class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
+minicard(this)
|
||||
if (showSpinner (idOrNull ../../_id))
|
||||
+spinnerList
|
||||
if canSeeAddCard
|
||||
+inlinedForm(autoclose=false position="bottom")
|
||||
+addCardForm(listId=_id position="bottom")
|
||||
else
|
||||
a.open-minicard-composer.js-card-composer.js-open-inlined-form(title="{{_ 'add-card-to-bottom-of-list'}}")
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-card'}}
|
||||
+inlinedForm(autoclose=false position="bottom")
|
||||
+addCardForm(listId=_id position="bottom")
|
||||
|
||||
template(name="spinnerList")
|
||||
.sk-spinner.sk-spinner-list(
|
||||
|
||||
@@ -192,6 +192,26 @@ Template.listBody.onCreated(function () {
|
||||
}
|
||||
const clickedTitle = $target.closest('.minicard-title').length > 0;
|
||||
const clickedLinkedReference = $target.closest('.js-linked-link').length > 0;
|
||||
const clickedChecklist = $target.closest('.minicard-checklist').length > 0;
|
||||
|
||||
// When multi-selection is active (or Shift is held), a click anywhere on the
|
||||
// minicard toggles its selection. This must be checked before the title and
|
||||
// checklist handlers below, otherwise clicking a card's title (which covers
|
||||
// most of the minicard) would open the card details instead of (de)selecting
|
||||
// it, making it impossible to check some cards in multi-selection mode.
|
||||
if (MultiSelection.isActive() || evt.shiftKey) {
|
||||
evt.stopImmediatePropagation();
|
||||
evt.preventDefault();
|
||||
const methodName = evt.shiftKey ? 'toggleRange' : 'toggle';
|
||||
MultiSelection[methodName](card._id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clickedChecklist) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Title clicks should open the regular board card details view.
|
||||
if (clickedTitle && !clickedLinkedReference) {
|
||||
@@ -207,16 +227,7 @@ Template.listBody.onCreated(function () {
|
||||
return;
|
||||
}
|
||||
|
||||
if (MultiSelection.isActive() || evt.shiftKey) {
|
||||
evt.stopImmediatePropagation();
|
||||
evt.preventDefault();
|
||||
const methodName = evt.shiftKey ? 'toggleRange' : 'toggle';
|
||||
MultiSelection[methodName](card._id);
|
||||
|
||||
// If the card is already selected, we want to de-select it.
|
||||
// XXX We should probably modify the minicard href attribute instead of
|
||||
// overwriting the event in case the card is already selected.
|
||||
} else if (Utils.isMiniScreen()) {
|
||||
if (Utils.isMiniScreen()) {
|
||||
evt.preventDefault();
|
||||
Session.set('popupCardId', card._id);
|
||||
this.cardDetailsPopup(evt);
|
||||
@@ -237,9 +248,18 @@ Template.listBody.onCreated(function () {
|
||||
};
|
||||
|
||||
this.toggleMultiSelection = (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.stopImmediatePropagation();
|
||||
evt.preventDefault();
|
||||
MultiSelection.toggle(Template.currentData()._id);
|
||||
// Resolve the card from the clicked checkbox element. Using
|
||||
// Blaze.getData(evt.currentTarget) is reliable for the per-card data
|
||||
// context, whereas Template.currentData() can resolve to the enclosing
|
||||
// list context and toggle the wrong id (so the checkbox appears to do
|
||||
// nothing). Falls back to Template.currentData() just in case.
|
||||
const card =
|
||||
Blaze.getData(evt.currentTarget) || Template.currentData();
|
||||
if (card && card._id) {
|
||||
MultiSelection.toggle(card._id);
|
||||
}
|
||||
};
|
||||
|
||||
this.cardDetailsPopup = (event) => {
|
||||
@@ -335,9 +355,10 @@ Template.listBody.helpers({
|
||||
canSeeAddCard() {
|
||||
const tpl = Template.instance();
|
||||
const list = Template.currentData();
|
||||
// #2095: count all cards in the list, not only the filtered/visible ones.
|
||||
const reachedWipLimit = !list.getWipLimit('soft') &&
|
||||
list.getWipLimit('enabled') &&
|
||||
list.getWipLimit('value') <= list.cards().length;
|
||||
list.getWipLimit('value') <= list.cardsUnfiltered().length;
|
||||
return (
|
||||
!reachedWipLimit &&
|
||||
Utils.canModifyCard()
|
||||
@@ -349,7 +370,8 @@ Template.listBody.helpers({
|
||||
return (
|
||||
!list.getWipLimit('soft') &&
|
||||
list.getWipLimit('enabled') &&
|
||||
list.getWipLimit('value') <= list.cards().length
|
||||
// #2095: count all cards in the list, not only the filtered/visible ones.
|
||||
list.getWipLimit('value') <= list.cardsUnfiltered().length
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ template(name="listHeader")
|
||||
.list-header.js-list-header.nodragscroll(
|
||||
class="{{#if limitToShowCardsCount}}list-header-card-count{{/if}}"
|
||||
class=colorClass)
|
||||
//- #459: keyboard/screen-reader list reordering (visually hidden, focusable).
|
||||
.list-move-buttons.sr-only
|
||||
a.js-list-move-left(href="#" role="button" aria-label="{{_ 'move-list-left'}}") {{_ 'move-list-left'}}
|
||||
a.js-list-move-right(href="#" role="button" aria-label="{{_ 'move-list-right'}}") {{_ 'move-list-right'}}
|
||||
+inlinedForm
|
||||
+editListTitleForm
|
||||
else
|
||||
|
||||
@@ -148,7 +148,36 @@ Template.listHeader.onCreated(function () {
|
||||
};
|
||||
});
|
||||
|
||||
// #459: accessible reordering — move a list left/right via sr-only buttons by
|
||||
// swapping its sort value with the adjacent list (no drag-and-drop required).
|
||||
function moveListBy(list, delta) {
|
||||
const siblings = ReactiveCache.getLists(
|
||||
{ boardId: list.boardId, archived: false },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
const idx = siblings.findIndex(l => l._id === list._id);
|
||||
const target = siblings[idx + delta];
|
||||
if (idx < 0 || !target) return;
|
||||
// Capture both sort values before either update; the docs are reactive and
|
||||
// list.sort would otherwise change after the first update.
|
||||
const listSort = list.sort;
|
||||
const targetSort = target.sort;
|
||||
// Lists are a server-restricted collection; persist the swap through the
|
||||
// updateListSort method — the same path the drag-and-drop reorder uses. A raw
|
||||
// client Lists.update of `sort` is reverted by the server.
|
||||
Meteor.call('updateListSort', list._id, list.boardId, { sort: targetSort });
|
||||
Meteor.call('updateListSort', target._id, target.boardId, { sort: listSort });
|
||||
}
|
||||
|
||||
Template.listHeader.events({
|
||||
'click .js-list-move-left'(event) {
|
||||
event.preventDefault();
|
||||
moveListBy(Template.currentData(), -1);
|
||||
},
|
||||
'click .js-list-move-right'(event) {
|
||||
event.preventDefault();
|
||||
moveListBy(Template.currentData(), 1);
|
||||
},
|
||||
async 'click .js-list-star'(event) {
|
||||
event.preventDefault();
|
||||
const list = Template.currentData();
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
border-radius: 0 0 0.5vw 0.5vw;
|
||||
min-width: min(400px, 52vw);
|
||||
margin-bottom: 2.5vh;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
border-width: 0.3vw;
|
||||
border-style: solid;
|
||||
border-color: #a2a2a2;
|
||||
@@ -46,8 +46,8 @@
|
||||
}
|
||||
.my-cards-dueat-list-wrapper {
|
||||
max-width: min(500px, 65vw);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.my-cards-board-table thead {
|
||||
border-bottom: 3px solid #4d4d4d;
|
||||
@@ -62,15 +62,15 @@
|
||||
}
|
||||
.my-cards-card-title-table {
|
||||
font-weight: bold;
|
||||
padding-left: 2px;
|
||||
padding-inline-start: 2px;
|
||||
max-width: 243px;
|
||||
}
|
||||
.my-cards-board-badge {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
border-radius: 5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
|
||||
/* Accessibility page styles */
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
}
|
||||
.broken-cards-wrapper {
|
||||
max-width: min(500px, 65vw);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.broken-cards-card-title {
|
||||
font-weight: bold;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.due-cards-dueat-list-wrapper {
|
||||
max-width: min(500px, 65vw);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
.new-comment a.fa.fa-brands.fa-markdown,
|
||||
.inlined-form a.fa.fa-brands.fa-markdown {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 60px;
|
||||
inset-inline-end: 60px;
|
||||
}
|
||||
.new-comment a.fa.fa-copy,
|
||||
.inlined-form a.fa.fa-copy {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
right: 5px;
|
||||
inset-inline-end: 5px;
|
||||
}
|
||||
.js-inlined-form.viewer.btn-sm {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 6px;
|
||||
inset-inline-end: 6px;
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
border-color: #808080;
|
||||
border-style: solid;
|
||||
margin-bottom: 2rem;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.global-search-board-title {
|
||||
font-size: 1.4rem;
|
||||
@@ -48,8 +48,8 @@
|
||||
}
|
||||
.global-search-results-list-wrapper {
|
||||
max-width: 500px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.global-search-field-name {
|
||||
font-weight: bold;
|
||||
@@ -69,8 +69,8 @@
|
||||
.global-search-page {
|
||||
width: 40%;
|
||||
min-width: 400px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
line-height: 150%;
|
||||
}
|
||||
.global-search-page h1 {
|
||||
@@ -81,8 +81,8 @@
|
||||
}
|
||||
.global-search-query-input {
|
||||
width: 90% !important;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.js-new-search {
|
||||
cursor: pointer;
|
||||
@@ -121,11 +121,11 @@ code {
|
||||
}
|
||||
.global-search-next-page {
|
||||
border: none;
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
}
|
||||
.global-search-previous-page {
|
||||
border: none;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
.global-search-instructions li {
|
||||
margin-bottom: 0.3rem;
|
||||
|
||||
@@ -4,6 +4,36 @@ template(name="globalSearchHeaderBar")
|
||||
i.fa.fa-search
|
||||
| {{_ 'globalSearch-title'}}
|
||||
|
||||
.board-header-btns.left
|
||||
a.board-header-btn.js-global-search-view-change(title="{{_ 'globalSearchViewChange-title'}}")
|
||||
i.fa.fa-caret-down
|
||||
if $eq globalSearchView 'me'
|
||||
i.fa.fa-user
|
||||
| {{_ 'globalSearchViewChange-choice-me'}}
|
||||
if $eq globalSearchView 'all'
|
||||
i.fa.fa-users
|
||||
| {{_ 'globalSearchViewChange-choice-all'}}
|
||||
|
||||
template(name="globalSearchViewChangePopup")
|
||||
if currentUser
|
||||
ul.pop-over-list
|
||||
li
|
||||
a.js-global-search-view-me
|
||||
i.fa.fa-user
|
||||
| {{_ 'globalSearchViewChange-choice-me'}}
|
||||
if $eq Utils.globalSearchView "me"
|
||||
i.fa.fa-check
|
||||
hr
|
||||
li
|
||||
a.js-global-search-view-all
|
||||
i.fa.fa-users
|
||||
| {{_ 'globalSearchViewChange-choice-all'}}
|
||||
span.sub-name
|
||||
+viewer
|
||||
| {{_ 'globalSearchViewChange-choice-all-description' }}
|
||||
if $eq Utils.globalSearchView "all"
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="globalSearchModalTitle")
|
||||
if currentUser
|
||||
h2
|
||||
@@ -29,7 +59,8 @@ template(name="resultsPaged")
|
||||
template(name="globalSearch")
|
||||
if currentUser
|
||||
.wrapper(class=boardColorClass)
|
||||
form.global-search-page.js-search-query-form(onsubmit="return false;")
|
||||
form.global-search-page.js-search-query-form(role="search" onsubmit="return false;")
|
||||
label.sr-only(for="global-search-input") {{_ 'globalSearch-title'}}
|
||||
input.global-search-query-input(
|
||||
style="display: inline-block;"
|
||||
id="global-search-input"
|
||||
@@ -37,10 +68,11 @@ template(name="globalSearch")
|
||||
name="searchQuery"
|
||||
placeholder="{{_ 'search-example'}}"
|
||||
value="{{ queryValue }}"
|
||||
aria-label="{{_ 'globalSearch-title'}}"
|
||||
autofocus dir="auto"
|
||||
)
|
||||
button.js-new-search.board-color-belize(type="button" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
|
||||
| 🧹
|
||||
button.js-new-search.board-color-belize(type="button" aria-label="{{_ 'globalSearch-title'}}" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
|
||||
span(aria-hidden="true") 🧹
|
||||
if searching
|
||||
+spinner
|
||||
else if hasResults
|
||||
|
||||
@@ -2,12 +2,34 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { CardSearchPaged } from '../../lib/cardSearch';
|
||||
import { LABEL_COLORS } from '/models/metadata/colors';
|
||||
import { Query, QueryErrors } from '../../../config/query-classes';
|
||||
import { OPERATOR_USER } from '/config/search-const';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// const subManager = new SubsManager();
|
||||
|
||||
// #3392-adjacent: "my cards / all cards" toggle for global search (mirrors the
|
||||
// due-cards view toggle). When "me", the current user is injected as a `user:`
|
||||
// predicate (member OR assignee), reusing the existing search logic.
|
||||
Template.globalSearchHeaderBar.helpers({
|
||||
globalSearchView() {
|
||||
return Utils.globalSearchView();
|
||||
},
|
||||
});
|
||||
|
||||
Template.globalSearchHeaderBar.events({
|
||||
'click .js-due-cards-view-change': Popup.open('globalSearchViewChange'),
|
||||
'click .js-global-search-view-change': Popup.open('globalSearchViewChange'),
|
||||
});
|
||||
|
||||
Template.globalSearchViewChangePopup.events({
|
||||
'click .js-global-search-view-me'() {
|
||||
Utils.setGlobalSearchView('me');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-global-search-view-all'() {
|
||||
Utils.setGlobalSearchView('all');
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
Template.globalSearch.onCreated(function () {
|
||||
@@ -42,27 +64,32 @@ Template.globalSearch.onCreated(function () {
|
||||
Template.globalSearch.onRendered(function () {
|
||||
Meteor.subscribe('setting');
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
//console.log('lang:', TAPi18n.getLanguage());
|
||||
|
||||
if (Session.get('globalQuery')) {
|
||||
searchAllBoards(this, Session.get('globalQuery'));
|
||||
}
|
||||
const tpl = this;
|
||||
// Re-run the search reactively when the query OR the "my/all" view changes,
|
||||
// so toggling the view filters immediately without a page reload.
|
||||
this.autorun(() => {
|
||||
const view = Utils.globalSearchView(); // reactive dependency
|
||||
const queryText = Session.get('globalQuery'); // reactive dependency
|
||||
if (queryText || view === 'me') {
|
||||
searchAllBoards(tpl, queryText || '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function searchAllBoards(tpl, queryText) {
|
||||
const search = tpl.search;
|
||||
|
||||
queryText = queryText.trim();
|
||||
// eslint-disable-next-line no-console
|
||||
//console.log('queryText:', queryText);
|
||||
queryText = (queryText || '').trim();
|
||||
|
||||
search.query.set(queryText);
|
||||
|
||||
search.resetSearch();
|
||||
tpl.parsingErrors = new QueryErrors();
|
||||
|
||||
if (!queryText) {
|
||||
const mine = Utils.globalSearchView() === 'me';
|
||||
|
||||
// Nothing to search: empty query and not restricting to the current user.
|
||||
if (!queryText && !mine) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,10 +98,17 @@ function searchAllBoards(tpl, queryText) {
|
||||
const query = new Query();
|
||||
query.buildParams(queryText);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
// console.log('params:', query.getParams());
|
||||
const queryParams = query.getQueryParams();
|
||||
|
||||
tpl.queryParams = query.getQueryParams().getParams();
|
||||
// "My cards": restrict to cards where the current user is member or assignee.
|
||||
if (mine) {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (currentUser && currentUser.username) {
|
||||
queryParams.addPredicate(OPERATOR_USER, currentUser.username);
|
||||
}
|
||||
}
|
||||
|
||||
tpl.queryParams = queryParams.getParams();
|
||||
|
||||
if (query.hasErrors()) {
|
||||
search.searching.set(false);
|
||||
@@ -84,7 +118,7 @@ function searchAllBoards(tpl, queryText) {
|
||||
return;
|
||||
}
|
||||
|
||||
search.runGlobalSearch(query.getQueryParams());
|
||||
search.runGlobalSearch(queryParams);
|
||||
}
|
||||
|
||||
function errorMessages(tpl) {
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
line-height: 1.7em;
|
||||
padding: 0 10px;
|
||||
margin: 0;
|
||||
margin-right: 10px;
|
||||
float: left;
|
||||
margin-inline-end: 10px;
|
||||
float: inline-start;
|
||||
border-radius: 3px;
|
||||
}
|
||||
#header #header-main-bar h1 .board-header-watch-icon {
|
||||
padding-left: 7px;
|
||||
padding-inline-start: 7px;
|
||||
}
|
||||
#header #header-main-bar h1 a.fa,
|
||||
#header #header-main-bar h1 a i.fa {
|
||||
@@ -26,14 +26,14 @@
|
||||
}
|
||||
#header #header-main-bar h1 .back-btn {
|
||||
font-size: 0.9em;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
#header #header-main-bar .wekan-logo {
|
||||
margin: 3px auto auto;
|
||||
width: 97px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
#header #header-main-bar .wekan-logo:hover {
|
||||
opacity: 0.9;
|
||||
@@ -44,10 +44,10 @@
|
||||
width: auto;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns.left {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns.right {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn {
|
||||
border-radius: 3px;
|
||||
@@ -55,13 +55,13 @@
|
||||
padding: 0;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
overflow: hidden;
|
||||
line-height: 28px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn i.fa {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
display: block;
|
||||
line-height: 28px;
|
||||
color: #f2f2f2;
|
||||
@@ -70,16 +70,16 @@
|
||||
#header #header-main-bar .board-header-btn i.fa + span {
|
||||
display: inline-block;
|
||||
margin-top: 1px;
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn .board-header-btn-close {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn .board-header-btn-close i.fa {
|
||||
margin: 0 6px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn .board-header-btn-icon {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
display: block;
|
||||
line-height: 28px;
|
||||
color: #27ae60;
|
||||
@@ -100,10 +100,30 @@
|
||||
}
|
||||
#header #header-main-bar .separator {
|
||||
margin: 2px 4px;
|
||||
border-left: 1px solid rgba(255,255,255,0.3);
|
||||
border-inline-start: 1px solid rgba(255,255,255,0.3);
|
||||
height: 24px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
/* On non-board pages (All Boards / home, public, my cards, …) the second bar
|
||||
holds only a title (e.g. "My Boards"). The fixed 40px bar height plus the
|
||||
floated h1 with a 1.7em line-height clipped the bottom of the text on iOS
|
||||
Safari. Vertically center the title in a bar that can grow so it is never
|
||||
cut off. Scoped to non-board-view so the board header (floated buttons) is
|
||||
untouched. */
|
||||
body:not(.board-view) #header #header-main-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: auto;
|
||||
min-height: 40px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
body:not(.board-view) #header #header-main-bar h1 {
|
||||
float: none;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
#header-quick-access {
|
||||
color: #fff;
|
||||
transition: background-color 0.4s;
|
||||
@@ -121,7 +141,7 @@
|
||||
#header-quick-access .home-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 1rem;
|
||||
margin-inline-end: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -141,7 +161,7 @@
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
#header-quick-access .allBoards {
|
||||
@@ -157,6 +177,10 @@
|
||||
#header-quick-access i.fa:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
/* Drag-handle toggle icons 30% smaller than the other quick-access icons. */
|
||||
#header-quick-access .js-toggle-desktop-drag-handles .fa {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
#header-quick-access #header-user-bar,
|
||||
#header-quick-access #header-new-board-icon,
|
||||
#header-quick-access ul li {
|
||||
@@ -177,24 +201,25 @@
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list {
|
||||
transition: opacity 0.2s;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 10px;
|
||||
margin: -10px;
|
||||
margin: 10px;
|
||||
flex: 1; /* Take up available space */
|
||||
min-width: 0; /* Allow shrinking below content size */
|
||||
display: flex; /* Use flexbox for better control */
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
scrollbar-color: white #2573a7;
|
||||
}
|
||||
|
||||
/* Hide scrollbar completely */
|
||||
#header-quick-access ul.header-quick-access-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#header-quick-access ul.header-quick-access-list {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: thin; /* IE and Edge */
|
||||
scrollbar-width: thin; /* Firefox */
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list li {
|
||||
display: inline-block; /* Keep inline-block for proper spacing */
|
||||
@@ -232,10 +257,10 @@
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list li:first-child .fa-home,
|
||||
#header-quick-access ul.header-quick-access-list li:nth-child(3) .fa-globe {
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
#header-quick-access ul.header-quick-access-list li a.js-create-board {
|
||||
margin-left: 5px;
|
||||
margin-inline-start: 5px;
|
||||
}
|
||||
#header-quick-access #header-user-bar,
|
||||
#header-quick-access #header-new-board-icon {
|
||||
@@ -245,10 +270,10 @@
|
||||
margin: 2px 0;
|
||||
}
|
||||
#header-quick-access #header-user-bar .header-user-bar-avatar {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
#header-quick-access #header-user-bar .header-user-bar-avatar .member,
|
||||
#header-quick-access #header-help {
|
||||
@@ -261,7 +286,7 @@
|
||||
#header-quick-access #header-user-bar .header-user-bar-name,
|
||||
#header-quick-access #header-help {
|
||||
margin: 4px 8px 0 0;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
/* Zoom Controls in Header */
|
||||
@@ -274,7 +299,7 @@
|
||||
border-radius: 0.5vw;
|
||||
box-shadow: 0 0.2vh 0.5vh rgba(0,0,0,0.1);
|
||||
margin: 0 1vw;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
#header-quick-access .zoom-controls .board-header-btn {
|
||||
@@ -376,7 +401,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 1vw;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
#header-quick-access .mobile-mode-toggle .board-header-btn {
|
||||
@@ -461,7 +486,7 @@
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
#header-quick-access #header-user-bar .header-user-bar-name i.fa-chevron-down {
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
#header-quick-access #header-new-board-icon {
|
||||
flex-grow: 1;
|
||||
@@ -497,12 +522,12 @@
|
||||
|
||||
/* Mobile home icon styling */
|
||||
#header-quick-access .home-icon {
|
||||
margin-right: 0.5rem;
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon a {
|
||||
@@ -525,7 +550,7 @@
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 14px;
|
||||
margin-right: 3px;
|
||||
margin-inline-end: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,7 +612,7 @@
|
||||
/* Mobile header wrapping and spacing */
|
||||
#header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
@@ -783,7 +808,7 @@
|
||||
/* iPhone 12 Mini header wrapping and spacing */
|
||||
#header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
@@ -843,11 +868,11 @@
|
||||
|
||||
#header-quick-access .home-icon .fa-home {
|
||||
font-size: 20px !important; /* Much larger icon */
|
||||
margin-right: 6px !important;
|
||||
margin-inline-end: 6px !important;
|
||||
}
|
||||
|
||||
#header-quick-access .home-icon {
|
||||
margin-right: 1rem !important;
|
||||
margin-inline-end: 1rem !important;
|
||||
}
|
||||
|
||||
/* Make zoom controls larger */
|
||||
@@ -931,7 +956,7 @@
|
||||
/* iPhone device header wrapping and spacing */
|
||||
.iphone-device #header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
@@ -1032,7 +1057,7 @@
|
||||
/* iPhone 12 Mini header wrapping and spacing - JavaScript fallback */
|
||||
.iphone-device #header-quick-access .home-icon {
|
||||
flex-shrink: 0 !important;
|
||||
margin-right: 0.5rem !important;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
@@ -1142,7 +1167,7 @@
|
||||
|
||||
#header-quick-access ul {
|
||||
width: calc(100% - 60px);
|
||||
margin-right: 10px;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
#header-quick-access ul li {
|
||||
height: 100%;
|
||||
@@ -1154,9 +1179,76 @@
|
||||
display: none;
|
||||
}
|
||||
#header-quick-access #header-user-bar {
|
||||
right: 0px;
|
||||
inset-inline-end: auto;
|
||||
padding: 10px;
|
||||
margin: -8px 0 -10px -10px;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* ---- Small-screen quick-access bar (ALL browsers, width-based) ----
|
||||
These rules use width-based selectors (not the .iphone-device class) so
|
||||
they apply to every browser at small width — Fairphone/Android, narrow
|
||||
desktop and iPhone alike. Keep every item visible: let the bar wrap and
|
||||
grow instead of pinning its height and clipping the overflow. */
|
||||
#header-quick-access {
|
||||
height: auto !important;
|
||||
min-height: 28px !important;
|
||||
overflow: visible !important;
|
||||
flex-wrap: wrap !important;
|
||||
align-items: center !important;
|
||||
row-gap: 4px !important;
|
||||
}
|
||||
/* The All Boards page scales this bar up to 2em-3em for every browser, which
|
||||
is too big. Tame it to a moderate size so ~7 items fit in two wrapped rows.
|
||||
(.iphone-device variants also cover the board view on detected iPhones.) */
|
||||
body:not(.board-view) #header-quick-access,
|
||||
.wrapper ~ #header-quick-access,
|
||||
.iphone-device #header-quick-access,
|
||||
.iphone-device .wrapper ~ #header-quick-access,
|
||||
.iphone-device body:not(.board-view) #header-quick-access {
|
||||
font-size: 1.2em !important;
|
||||
}
|
||||
body:not(.board-view) #header-quick-access .fa,
|
||||
body:not(.board-view) #header-quick-access .icon,
|
||||
.wrapper ~ #header-quick-access .fa,
|
||||
.wrapper ~ #header-quick-access .icon,
|
||||
.iphone-device #header-quick-access .fa,
|
||||
.iphone-device #header-quick-access .icon,
|
||||
.iphone-device .wrapper ~ #header-quick-access .fa,
|
||||
.iphone-device .wrapper ~ #header-quick-access .icon,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .fa,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .icon {
|
||||
font-size: 1.5em !important;
|
||||
}
|
||||
/* Zoom (100%), drag-handle toggle and mobile/desktop toggle 30% smaller than
|
||||
the other quick-access controls at small width — for all browsers. */
|
||||
body:not(.board-view) #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
body:not(.board-view) #header-quick-access .mobile-mode-toggle .fa,
|
||||
.wrapper ~ #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
.wrapper ~ #header-quick-access .mobile-mode-toggle .fa,
|
||||
.iphone-device #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
.iphone-device #header-quick-access .mobile-mode-toggle .fa,
|
||||
.iphone-device .wrapper ~ #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
.iphone-device .wrapper ~ #header-quick-access .mobile-mode-toggle .fa,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .js-toggle-desktop-drag-handles .fa,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .mobile-mode-toggle .fa {
|
||||
font-size: 1.05em !important; /* ~30% smaller than the 1.5em icons */
|
||||
}
|
||||
body:not(.board-view) #header-quick-access .zoom-controls,
|
||||
body:not(.board-view) #header-quick-access .zoom-controls .zoom-level,
|
||||
body:not(.board-view) #header-quick-access .zoom-controls .zoom-input,
|
||||
.wrapper ~ #header-quick-access .zoom-controls,
|
||||
.wrapper ~ #header-quick-access .zoom-controls .zoom-level,
|
||||
.wrapper ~ #header-quick-access .zoom-controls .zoom-input,
|
||||
.iphone-device #header-quick-access .zoom-controls,
|
||||
.iphone-device #header-quick-access .zoom-controls .zoom-level,
|
||||
.iphone-device #header-quick-access .zoom-controls .zoom-input,
|
||||
.iphone-device .wrapper ~ #header-quick-access .zoom-controls,
|
||||
.iphone-device .wrapper ~ #header-quick-access .zoom-controls .zoom-level,
|
||||
.iphone-device .wrapper ~ #header-quick-access .zoom-controls .zoom-input,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .zoom-controls,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .zoom-controls .zoom-level,
|
||||
.iphone-device body:not(.board-view) #header-quick-access .zoom-controls .zoom-input {
|
||||
font-size: 0.7em !important; /* ~30% smaller than surrounding text */
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
@@ -1169,7 +1261,7 @@
|
||||
display: none;
|
||||
}
|
||||
#header #header-user-bar {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
}
|
||||
.announcement .viewer {
|
||||
|
||||
@@ -5,7 +5,7 @@ template(name="header")
|
||||
Reddit "subreddit" bar.
|
||||
The first link goes to the boards page.
|
||||
if currentUser
|
||||
#header-quick-access(class=currentBoard.colorClass)
|
||||
#header-quick-access(class=currentBoard.colorClass role="navigation" aria-label="{{_ 'all-boards'}}")
|
||||
// Home icon - always at left side of logo
|
||||
span.home-icon.allBoards
|
||||
a(href="{{pathFor 'home'}}")
|
||||
@@ -68,12 +68,8 @@ template(name="header")
|
||||
+viewer
|
||||
= title
|
||||
else
|
||||
li.current.empty(title="{{_ 'quick-access-description'}}")
|
||||
li.current(title="{{_ 'quick-access-description'}}")
|
||||
| {{_ 'quick-access-description'}}
|
||||
#header-new-board-icon
|
||||
// Next line is used only for spacing at header,
|
||||
// there is no visible clickable icon.
|
||||
#header-new-board-icon
|
||||
//
|
||||
Hide duplicate create board button,
|
||||
//
|
||||
@@ -112,14 +108,13 @@ template(name="header")
|
||||
|
||||
if currentUser.isBoardMember
|
||||
if hasAnnouncement
|
||||
.announcement
|
||||
.announcement(role="status" aria-live="polite")
|
||||
p
|
||||
i.fa.fa-bullhorn
|
||||
i.fa.fa-bullhorn(aria-hidden="true")
|
||||
+viewer
|
||||
| #{announcement}
|
||||
a
|
||||
.js-close-announcement
|
||||
i.fa.fa-times-thin
|
||||
a.js-close-announcement(aria-label="{{_ 'close'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
|
||||
template(name="offlineWarning")
|
||||
.offline-warning
|
||||
|
||||
@@ -172,7 +172,7 @@ Template.header.events({
|
||||
if (Utils.isMiniScreen()) {
|
||||
FlowRouter.go('bookmarks');
|
||||
} else {
|
||||
Popup.open('bookmarksPopup')(evt);
|
||||
Popup.open('bookmarks')(evt);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}
|
||||
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-keys {
|
||||
margin-top: 5px;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-keys kbd {
|
||||
padding: 5px 8px;
|
||||
|
||||
@@ -32,6 +32,111 @@ a:focus {
|
||||
color: unset;
|
||||
text-decoration: unset;
|
||||
}
|
||||
/* Accessibility (WCAG 2.4.7 Focus Visible): the resets above strip focus
|
||||
* outlines from every element, which leaves keyboard users with no indication
|
||||
* of where focus is. Restore a clearly visible focus indicator for keyboard
|
||||
* navigation. :focus-visible only paints the outline for keyboard focus, so
|
||||
* mouse clicks stay visually unchanged. */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
summary:focus-visible,
|
||||
[tabindex]:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[contenteditable="true"]:focus-visible {
|
||||
outline: 2px solid #1565c0;
|
||||
outline-offset: 2px;
|
||||
/* Add a light halo so the outline is visible on both light and dark colored
|
||||
* board headers. */
|
||||
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* Browsers that do not support :focus-visible fall back to :focus so keyboard
|
||||
* users are never left without an indicator. */
|
||||
.no-focus-visible-support a:focus,
|
||||
.no-focus-visible-support button:focus,
|
||||
.no-focus-visible-support [tabindex]:focus {
|
||||
outline: 2px solid #1565c0;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Screen-reader-only helper: visually hidden but available to assistive tech. */
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Visually-hidden but still *activatable* controls (#459 accessible reordering).
|
||||
* The plain `.sr-only` pattern clips its content to a 1x1 box with
|
||||
* `clip: rect(0,0,0,0)` + `overflow:hidden`, which removes the element from the
|
||||
* hit-test stack: a real pointer click (or Playwright's coordinate-based click)
|
||||
* at the control's own position resolves to whatever sits *behind* it, so the
|
||||
* click handler never fires. For interactive controls we instead keep a real,
|
||||
* hit-testable box that is merely transparent and out of flow. Keyboard/AT users
|
||||
* still reach it via Tab / the accessibility tree; sighted users don't see it. */
|
||||
.minicard-move-buttons.sr-only,
|
||||
.list-move-buttons.sr-only {
|
||||
position: static !important;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
clip-path: none;
|
||||
white-space: normal;
|
||||
/* Keep the group flush so the (transparent) controls don't visibly nudge the
|
||||
* surrounding layout. */
|
||||
display: inline-flex;
|
||||
vertical-align: top;
|
||||
line-height: 0;
|
||||
}
|
||||
.minicard-move-buttons.sr-only a,
|
||||
.list-move-buttons.sr-only a {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
text-indent: -9999px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Skip-to-content link (WCAG 2.4.1 Bypass Blocks): hidden until focused, then
|
||||
* pinned to the top-left so keyboard users can jump past the header chrome. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
z-index: 10000;
|
||||
padding: 10px 16px;
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
border-radius: 0 0 4px 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.skip-link:focus,
|
||||
.skip-link:focus-visible {
|
||||
left: 0;
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: -4px;
|
||||
}
|
||||
/* The skip-link target must not show a persistent outline when focused
|
||||
* programmatically. */
|
||||
#content:focus {
|
||||
outline: none;
|
||||
}
|
||||
.badge {
|
||||
display: unset;
|
||||
min-width: unset;
|
||||
@@ -119,8 +224,8 @@ body.mobile-mode #content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
@@ -139,7 +244,7 @@ body.mobile-mode #content {
|
||||
}
|
||||
#modal .modal-content .modal-close-btn {
|
||||
display: block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
font-size: clamp(18px, 4vw, 24px);
|
||||
}
|
||||
#modal .modal-content-wide {
|
||||
@@ -156,7 +261,7 @@ body.mobile-mode #content {
|
||||
}
|
||||
#modal .modal-content-wide .modal-close-btn {
|
||||
display: block;
|
||||
float: right;
|
||||
float: inline-end;
|
||||
font-size: clamp(18px, 4vw, 24px);
|
||||
}
|
||||
h1 {
|
||||
@@ -245,7 +350,7 @@ tt {
|
||||
}
|
||||
blockquote {
|
||||
margin: 1vh 0 1vh 1vw;
|
||||
border-left: 1px solid #ccc;
|
||||
border-inline-start: 1px solid #ccc;
|
||||
color: #666;
|
||||
padding: 0 0 0 1vw;
|
||||
}
|
||||
@@ -264,12 +369,12 @@ td,
|
||||
th {
|
||||
vertical-align: top;
|
||||
border-top: 1px solid #ccc;
|
||||
border-left: 1px solid #ccc;
|
||||
border-inline-start: 1px solid #ccc;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
padding: 0.8vh 0.8vw;
|
||||
border-right: 1px solid #ccc;
|
||||
border-inline-end: 1px solid #ccc;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
th {
|
||||
@@ -315,18 +420,18 @@ kbd {
|
||||
text-align: center;
|
||||
}
|
||||
.left {
|
||||
float: left;
|
||||
float: inline-start;
|
||||
}
|
||||
.right {
|
||||
float: right;
|
||||
float: inline-end;
|
||||
}
|
||||
.first {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
.last {
|
||||
margin-right: 0;
|
||||
padding-right: 0;
|
||||
margin-inline-end: 0;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
.top {
|
||||
margin-top: 0;
|
||||
@@ -441,7 +546,7 @@ a:not(.disabled).is-active i.fa {
|
||||
line-height: 1.3;
|
||||
}
|
||||
.gutter {
|
||||
margin-left: 5vw;
|
||||
margin-inline-start: 5vw;
|
||||
}
|
||||
.viewer {
|
||||
min-height: 2.5vh;
|
||||
@@ -454,12 +559,12 @@ a:not(.disabled).is-active i.fa {
|
||||
}
|
||||
.viewer ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 3vw;
|
||||
padding-inline-start: 3vw;
|
||||
padding-bottom: 1.5vh;
|
||||
}
|
||||
.viewer ul {
|
||||
list-style-type: initial;
|
||||
padding-left: 3vw;
|
||||
padding-inline-start: 3vw;
|
||||
padding-bottom: 1.5vh;
|
||||
}
|
||||
.viewer em {
|
||||
@@ -478,7 +583,7 @@ a:not(.disabled).is-active i.fa {
|
||||
color: #333;
|
||||
}
|
||||
.basicTabs-container .tabs-list {
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
width: 100%;
|
||||
}
|
||||
.basicTabs-container .tabs-list .tab-item {
|
||||
@@ -593,12 +698,20 @@ a:not(.disabled).is-active i.fa {
|
||||
}
|
||||
|
||||
#header-quick-access {
|
||||
/* Keep quick-access items in one row */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
/* On narrow screens (e.g. iPhone 12 mini, 375px) the icon buttons cannot
|
||||
shrink below their min-width, so a single nowrap row overflows and the
|
||||
base rule's overflow:hidden clips the right-side items (mobile/desktop
|
||||
toggle, notifications, member/user menu). Allow wrapping to a second row
|
||||
and let the bar grow so nothing is ever hidden. */
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
/* Hide elements that should move to the hamburger menu on mobile */
|
||||
@@ -607,32 +720,30 @@ a:not(.disabled).is-active i.fa {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Show only the home icon (hide the trailing text) on mobile */
|
||||
/* Show the home link with its full "All Boards" text on mobile. The label is
|
||||
a bare text node (not a span), so the old max-width:28px + overflow:hidden
|
||||
clip revealed only "A"; removing it shows the whole label. The bar wraps,
|
||||
so the extra width is fine. */
|
||||
#header-quick-access .home-icon a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 28px; /* enough to display the icon */
|
||||
overflow: hidden;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hide text in home icon on mobile, show only icon */
|
||||
#header-quick-access .home-icon a span:not(.fa) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing for mobile header elements */
|
||||
#header-quick-access .zoom-controls {
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.mobile-mode-toggle {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
#header-user-bar {
|
||||
margin-left: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Ensure header elements don't wrap on very small screens */
|
||||
@@ -673,22 +784,18 @@ a:not(.disabled).is-active i.fa {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Admin panel mobile optimization */
|
||||
/* Admin panel mobile optimization.
|
||||
Keep the side-by-side (row) layout from settingBody.css even on narrow
|
||||
screens — do NOT switch to a stacked column layout, which left the menu
|
||||
only 32vw wide with empty space beside it and pushed the content off
|
||||
screen. The side menu keeps a fixed basis and the main body fills the
|
||||
rest, so both stay visible. */
|
||||
.setting-content .content-body {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.setting-content .content-body .side-menu {
|
||||
width: 100%;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body {
|
||||
order: 1;
|
||||
min-height: 60vh;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
@@ -856,56 +963,56 @@ a:not(.disabled).is-active i.fa {
|
||||
}
|
||||
.lds-roller div:nth-child(1):after {
|
||||
top: 50px;
|
||||
left: 50px;
|
||||
inset-inline-start: 50px;
|
||||
}
|
||||
.lds-roller div:nth-child(2) {
|
||||
animation-delay: -0.072s;
|
||||
}
|
||||
.lds-roller div:nth-child(2):after {
|
||||
top: 54px;
|
||||
left: 45px;
|
||||
inset-inline-start: 45px;
|
||||
}
|
||||
.lds-roller div:nth-child(3) {
|
||||
animation-delay: -0.108s;
|
||||
}
|
||||
.lds-roller div:nth-child(3):after {
|
||||
top: 57px;
|
||||
left: 39px;
|
||||
inset-inline-start: 39px;
|
||||
}
|
||||
.lds-roller div:nth-child(4) {
|
||||
animation-delay: -0.144s;
|
||||
}
|
||||
.lds-roller div:nth-child(4):after {
|
||||
top: 58px;
|
||||
left: 32px;
|
||||
inset-inline-start: 32px;
|
||||
}
|
||||
.lds-roller div:nth-child(5) {
|
||||
animation-delay: -0.18s;
|
||||
}
|
||||
.lds-roller div:nth-child(5):after {
|
||||
top: 57px;
|
||||
left: 25px;
|
||||
inset-inline-start: 25px;
|
||||
}
|
||||
.lds-roller div:nth-child(6) {
|
||||
animation-delay: -0.216s;
|
||||
}
|
||||
.lds-roller div:nth-child(6):after {
|
||||
top: 54px;
|
||||
left: 19px;
|
||||
inset-inline-start: 19px;
|
||||
}
|
||||
.lds-roller div:nth-child(7) {
|
||||
animation-delay: -0.252s;
|
||||
}
|
||||
.lds-roller div:nth-child(7):after {
|
||||
top: 50px;
|
||||
left: 14px;
|
||||
inset-inline-start: 14px;
|
||||
}
|
||||
.lds-roller div:nth-child(8) {
|
||||
animation-delay: -0.288s;
|
||||
}
|
||||
.lds-roller div:nth-child(8):after {
|
||||
top: 45px;
|
||||
left: 10px;
|
||||
inset-inline-start: 10px;
|
||||
}
|
||||
.at-link {
|
||||
color: #17683a;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
template(name="main")
|
||||
html(lang="{{TAPi18n.getLanguage}}")
|
||||
html(lang="{{htmlLang}}" dir="{{htmlDir}}")
|
||||
head
|
||||
title
|
||||
meta(name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes, viewport-fit=cover")
|
||||
@@ -78,27 +78,32 @@ template(name="userFormsLayout")
|
||||
option(value="{{tag}}") {{name}} (RTL)
|
||||
else
|
||||
option(value="{{tag}}") {{name}}
|
||||
//- Extra space below the login / register dialog so its bottom scrolls
|
||||
//- fully into view and scrolling continues a little past the form.
|
||||
br
|
||||
br
|
||||
|
||||
template(name="defaultLayout")
|
||||
a.skip-link(href="#content") {{_ 'skip-to-content'}}
|
||||
+header
|
||||
#content
|
||||
#content(role="main" tabindex="-1")
|
||||
| {{{afterBodyStart}}}
|
||||
+Template.dynamic(template=content)
|
||||
| {{{beforeBodyEnd}}}
|
||||
+boardConversionProgress
|
||||
if (Modal.isOpen)
|
||||
#modal
|
||||
#modal(role="dialog" aria-modal="true" aria-label="{{_ 'modal-title'}}")
|
||||
.overlay
|
||||
if (Modal.isWide)
|
||||
.modal-content-wide.modal-container
|
||||
a.modal-close-btn.js-close-modal
|
||||
i.fa.fa-times-thin
|
||||
a.modal-close-btn.js-close-modal(aria-label="{{_ 'close-dialog'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
+Template.dynamic(template=Modal.getHeaderName)
|
||||
+Template.dynamic(template=Modal.getTemplateName)
|
||||
else
|
||||
.modal-content.modal-container
|
||||
a.modal-close-btn.js-close-modal
|
||||
i.fa.fa-times-thin
|
||||
a.modal-close-btn.js-close-modal(aria-label="{{_ 'close-dialog'}}")
|
||||
i.fa.fa-times-thin(aria-hidden="true")
|
||||
+Template.dynamic(template=Modal.getHeaderName)
|
||||
+Template.dynamic(template=Modal.getTemplateName)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Users from '/models/users';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
|
||||
|
||||
let alreadyCheck = 1;
|
||||
let isCheckDone = false;
|
||||
@@ -47,6 +48,10 @@ Template.userFormsLayout.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onRendered(() => {
|
||||
// Login / register pages scroll on <body>; enable drag-to-scroll there so the
|
||||
// gesture works the same as on the board swimlanes view.
|
||||
enablePageDragscroll();
|
||||
|
||||
Meteor.call('getAuthenticationsEnabled', (_, result) => {
|
||||
let enabledAuthenticationMethods = ['password']; // we show/hide this based on isPasswordLoginEnabled
|
||||
|
||||
@@ -57,9 +62,18 @@ Template.userFormsLayout.onRendered(() => {
|
||||
}
|
||||
|
||||
Meteor.call('isPasswordLoginEnabled', (_, result) => {
|
||||
if (result) {
|
||||
// Issue #6380: the password form (.at-pwd-form) is hidden by default in CSS
|
||||
// and only revealed here. If this method call errors / returns late, or the
|
||||
// accounts-templates form has not rendered its fields yet when this runs,
|
||||
// the login page is left without username and password fields. Treat
|
||||
// password login as enabled unless it is explicitly disabled
|
||||
// (result === false), and wait for the form element to appear before
|
||||
// showing it (same pattern as the OAuth button above).
|
||||
if (result === false) return;
|
||||
(function showPwdFormWhenReady() {
|
||||
if (!$('.at-pwd-form')[0]) return setTimeout(showPwdFormWhenReady, 100);
|
||||
$('.at-pwd-form').show();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
Meteor.call('isDisableRegistration', (_, result) => {
|
||||
@@ -182,6 +196,12 @@ Template.userFormsLayout.onRendered(() => {
|
||||
});
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onDestroyed(() => {
|
||||
// Stop drag-scrolling <body> after leaving login / register (e.g. once logged
|
||||
// in) so other layouts keep their normal behaviour.
|
||||
disablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.userFormsLayout.helpers({
|
||||
isLegalNoticeLinkExist() {
|
||||
const currSet = Template.instance().currentSetting.get();
|
||||
@@ -234,6 +254,16 @@ Template.userFormsLayout.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
Template.main.helpers({
|
||||
htmlLang() {
|
||||
return TAPi18n.getLanguage() || 'en';
|
||||
},
|
||||
|
||||
htmlDir() {
|
||||
return TAPi18n.getLanguageDirection() || 'ltr';
|
||||
},
|
||||
});
|
||||
|
||||
Template.userFormsLayout.events({
|
||||
'change .js-userform-set-language'(event) {
|
||||
const tag = $(event.currentTarget).val();
|
||||
@@ -257,6 +287,30 @@ Template.defaultLayout.events({
|
||||
},
|
||||
});
|
||||
|
||||
// Accessibility (WCAG 2.4.3 Focus Order): when a modal dialog opens, move
|
||||
// keyboard focus into it so screen-reader and keyboard users land inside the
|
||||
// dialog instead of being left behind in the page. When it closes, restore
|
||||
// focus to whatever element was focused before it opened.
|
||||
Template.defaultLayout.onRendered(function () {
|
||||
let lastFocused = null;
|
||||
this.autorun(() => {
|
||||
const isOpen = Modal.isOpen();
|
||||
Tracker.afterFlush(() => {
|
||||
if (isOpen) {
|
||||
lastFocused = document.activeElement;
|
||||
const modal = document.getElementById('modal');
|
||||
if (modal) {
|
||||
const focusTarget = modal.querySelector('.modal-close-btn') || modal;
|
||||
focusTarget.focus();
|
||||
}
|
||||
} else if (lastFocused && typeof lastFocused.focus === 'function') {
|
||||
lastFocused.focus();
|
||||
lastFocused = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function authentication(event, templateInstance) {
|
||||
const match = $('#at-field-username_and_email').val();
|
||||
const password = $('#at-field-password').val();
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
border-radius: 0 0 0.5vw 0.5vw;
|
||||
min-width: min(400px, 52vw);
|
||||
margin-bottom: 2.5vh;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
border-width: 0.3vw;
|
||||
border-style: solid;
|
||||
border-color: #a2a2a2;
|
||||
@@ -46,8 +46,8 @@
|
||||
}
|
||||
.my-cards-dueat-list-wrapper {
|
||||
max-width: min(500px, 65vw);
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.my-cards-board-table thead {
|
||||
border-bottom: 3px solid #4d4d4d;
|
||||
@@ -62,13 +62,13 @@
|
||||
}
|
||||
.my-cards-card-title-table {
|
||||
font-weight: bold;
|
||||
padding-left: 2px;
|
||||
padding-inline-start: 2px;
|
||||
max-width: 243px;
|
||||
}
|
||||
.my-cards-board-badge {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
float: left;
|
||||
float: inline-start;
|
||||
border-radius: 5px;
|
||||
margin-right: 5px;
|
||||
margin-inline-end: 5px;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ template(name="myCards")
|
||||
.wrapper
|
||||
each board in myCardsList
|
||||
.my-cards-board-wrapper
|
||||
.my-cards-board-title(class=board.colorClass, id="header")
|
||||
.my-cards-board-title(class=board.colorClass)
|
||||
a(href=board.originRelativeUrl)
|
||||
+viewer
|
||||
= board.title
|
||||
@@ -52,15 +52,17 @@ template(name="myCards")
|
||||
if $eq myCardsView 'table'
|
||||
.wrapper
|
||||
table.my-cards-board-table
|
||||
caption.sr-only {{_ 'my-cards'}}
|
||||
thead
|
||||
th {{_ "Card"}}
|
||||
th {{_ "List"}}
|
||||
th {{_ "Board"}}
|
||||
th {{_ "Swimlane"}}
|
||||
unless isMiniScreen
|
||||
th {{_ "Members"}}
|
||||
th {{_ "Labels"}}
|
||||
th {{_ "Due Date"}}
|
||||
tr
|
||||
th(scope="col") {{_ "Card"}}
|
||||
th(scope="col") {{_ "List"}}
|
||||
th(scope="col") {{_ "Board"}}
|
||||
th(scope="col") {{_ "Swimlane"}}
|
||||
unless isMiniScreen
|
||||
th(scope="col") {{_ "Members"}}
|
||||
th(scope="col") {{_ "Labels"}}
|
||||
th(scope="col") {{_ "Due Date"}}
|
||||
tbody
|
||||
each board in myCardsList
|
||||
each swimlane in board.mySwimlanes
|
||||
@@ -69,7 +71,7 @@ template(name="myCards")
|
||||
tr
|
||||
td
|
||||
unless isMiniScreen
|
||||
.my-cards-board-badge(class=board.colorClass, id="header")
|
||||
.my-cards-board-badge(class=board.colorClass)
|
||||
.my-cards-card-title-table
|
||||
| {{card.title}}
|
||||
//a.minicard-wrapper(href=card.originRelativeUrl)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CardSearchPaged } from '../../lib/cardSearch';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
|
||||
|
||||
Template.myCardsHeaderBar.helpers({
|
||||
myCardsSort() {
|
||||
@@ -40,6 +41,16 @@ Template.myCards.onCreated(function () {
|
||||
Meteor.subscribe('setting');
|
||||
});
|
||||
|
||||
Template.myCards.onRendered(function () {
|
||||
// My Cards renders into <body>'s page scroll area (.wrapper inside #content),
|
||||
// so enable drag-to-scroll on <body> like the All Boards page does.
|
||||
enablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.myCards.onDestroyed(function () {
|
||||
disablePageDragscroll();
|
||||
});
|
||||
|
||||
Template.myCards.helpers({
|
||||
userId() {
|
||||
return Meteor.userId();
|
||||
|
||||
Loaded 100 of 749 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user