Compare commits
677 Commits
feature/ca
...
main
Author | SHA1 | Date | |
---|---|---|---|
a94198f2eb | |||
a6e7e6efa6 | |||
6f5f7b190a | |||
0473c385d6 | |||
68a76faacc | |||
4579dd3ce7 | |||
f4fee26fe4 | |||
00398085fd | |||
13c8e6dbcd | |||
1b5f8a069b | |||
21fec9461d | |||
df3af739f8 | |||
b08d34bc96 | |||
be03efd015 | |||
c353fd151d | |||
c32e9badf0 | |||
8f64ab5dce | |||
63ca8ffc7c | |||
fe9ddf33ca | |||
74777d6bed | |||
76d1c56fab | |||
f75e9135c2 | |||
480f0f8da9 | |||
ac832186d6 | |||
738e1e8370 | |||
|
9b85817ddb | ||
|
49a6569db0 | ||
e762f7f073 | |||
e5d2b23cb3 | |||
6eddf23c0d | |||
70934c6a0b | |||
49955eb03a | |||
2b302974cc | |||
|
64d632b0a5 | ||
|
e28f0f5be4 | ||
c618b7e0b6 | |||
380af7fbf2 | |||
9d3ef30be6 | |||
fc00169863 | |||
b652225a12 | |||
29d8422e94 | |||
|
cdbd1c2ac4 | ||
cb37fd773d | |||
d2577f1df6 | |||
27534a98e9 | |||
de77393905 | |||
dd450263fb | |||
d8106dcb73 | |||
8114012997 | |||
bc4ea82639 | |||
cd97cfe612 | |||
e6136fdee4 | |||
2c395c720a | |||
8b639fd4af | |||
f7bd5f13ac | |||
0ae774b95c | |||
be899c3eb0 | |||
dc02827a33 | |||
951e511bf9 | |||
5f1d936ca4 | |||
c3845e5690 | |||
f4db2df37b | |||
5668fc7a2c | |||
12a0099fbf | |||
7f4027cbe8 | |||
b57aa33cae | |||
b80de79594 | |||
ead3e45a59 | |||
4a7d2d8414 | |||
0befa58908 | |||
8ae84eaf42 | |||
cd10bc9d7a | |||
f4545fbe2f | |||
c6ffe8acab | |||
2fb16f9a77 | |||
be427449e4 | |||
5e889ebe36 | |||
8c62c96109 | |||
7908a1e657 | |||
09b62da2ae | |||
076494afa5 | |||
b604d5da75 | |||
59889cccf3 | |||
f8ffb428d3 | |||
c2ea932c09 | |||
033d97e919 | |||
4694c14760 | |||
7e8e26679c | |||
838db379ff | |||
77da84d21a | |||
91b80cba67 | |||
ac57c8572a | |||
858448b877 | |||
8409db98f6 | |||
04cc01daaf | |||
70f8d46b6c | |||
15114e487b | |||
7f79f2a7b3 | |||
7387112172 | |||
|
3743cdc058 | ||
|
ce02462cfe | ||
|
7af21c48d5 | ||
|
943e554a58 | ||
e885d8ae70 | |||
2cef6cb343 | |||
7ebca9afc5 | |||
d86eb9ea0b | |||
|
063592ca3d | ||
d9fa0dd2bc | |||
4cbacafbbb | |||
2ce260bcf1 | |||
d606be3459 | |||
d79b14221d | |||
fdbacc2084 | |||
dd123dadd2 | |||
b088a5e864 | |||
63008e59a3 | |||
3a1da44c94 | |||
fccb0dcc61 | |||
|
b3b7669983 | ||
d114c673d8 | |||
a6771b8d37 | |||
91b06a9af6 | |||
08502619c4 | |||
26de8e66fa | |||
f944c35e99 | |||
36fb250d1f | |||
b7aa7891e9 | |||
ccaed029f2 | |||
770578240a | |||
791678720b | |||
21e44e15bd | |||
9a612849a3 | |||
c4173c3c35 | |||
46afeb159a | |||
940494a02d | |||
1a55d04c97 | |||
8ccfe716f6 | |||
e3243f012d | |||
eef37c4f70 | |||
e7e1cc0e55 | |||
f8031b5aef | |||
ac61e8d449 | |||
2c671c6a13 | |||
d01c3eabb9 | |||
d85d86eaa7 | |||
e1b04eed72 | |||
543dae2f30 | |||
40479d0c6e | |||
f5fba7880f | |||
ed332f5dd3 | |||
9ecd18a5ee | |||
63070e63bd | |||
32353e3b76 | |||
14f1ee1885 | |||
8440869bcd | |||
14397ffb31 | |||
c3c4d2a0a5 | |||
b03d5d80cd | |||
2bf9be3676 | |||
807fb6a9fe | |||
a106511646 | |||
7ed71f66ea | |||
4463b83b78 | |||
14f14f6d3e | |||
e1449642bd | |||
5dfaeb39ca | |||
9584ef127a | |||
|
cb9e1e891d | ||
41c0594bd2 | |||
9acc9039a6 | |||
f530d4763e | |||
3f4bcbcecd | |||
89d8c9d639 | |||
2de523f2e0 | |||
22e62a2cea | |||
92c89d145c | |||
306d562f65 | |||
4b8a7e1556 | |||
d0c6576efa | |||
e684e9a90b | |||
805e1bc554 | |||
8ee793c054 | |||
1a119f97c5 | |||
10fe38cef6 | |||
b4cbe1e1fd | |||
8b8e413af0 | |||
c8029ec3c4 | |||
6225c54447 | |||
809e876091 | |||
470022899f | |||
8d1d60ba80 | |||
028ad3dc14 | |||
f4df628e47 | |||
150b847638 | |||
684acc01bd | |||
3218cf60f0 | |||
bba9a8e008 | |||
852d71e8b7 | |||
c65bb4e93b | |||
1c3f655323 | |||
bd19234041 | |||
|
ac630ac775 | ||
f758eefa88 | |||
|
4137bab7fc | ||
d253d2e743 | |||
fe5770082a | |||
2041722b8a | |||
648b947a05 | |||
f58e114947 | |||
144e7bd10c | |||
b96e89ca8c | |||
|
20f0496fa5 | ||
e535584412 | |||
c5b9e2a1ff | |||
|
e45bc83132 | ||
|
bc8b04fc7a | ||
|
84284a6211 | ||
|
716de2c99c | ||
|
769d94e879 | ||
|
baa86530c8 | ||
7613afbf27 | |||
aeb886e4c5 | |||
306bd1c179 | |||
f3cf79fa65 | |||
8c945b049a | |||
f69111c105 | |||
03afbfc6c8 | |||
c07288dd8b | |||
03f3c52548 | |||
384037c694 | |||
734db0795c | |||
8f6c0f3738 | |||
f3324c6aaf | |||
f8d009a6aa | |||
|
59e915cc10 | ||
|
0c9dad9891 | ||
|
b7ad29f056 | ||
a7434f24df | |||
f61d5bac46 | |||
2911dee3cc | |||
58c361ee29 | |||
d3fc1439b5 | |||
bb544c353c | |||
cc90a1cea5 | |||
cffba33748 | |||
055e0a2901 | |||
c8d1921bcd | |||
9e09314f75 | |||
c3833d90d8 | |||
e24cb55e1b | |||
f897611ad1 | |||
ea8fe297d9 | |||
c6b604f1fa | |||
4792adfbd1 | |||
|
04c94418d7 | ||
12bec04c42 | |||
e8eb94d71b | |||
5137f9f6cb | |||
a2f65d86c2 | |||
08dcc897cc | |||
c975fb0fee | |||
b73cf344b6 | |||
d4b45dc255 | |||
e2beaba3b9 | |||
dd9be97793 | |||
6cde8e2640 | |||
5c6fcffd75 | |||
9b243873c5 | |||
dc347ed8ba | |||
d0d1086dac | |||
fb91b71395 | |||
|
b688f35446 | ||
|
91580f97a1 | ||
d95fc32d67 | |||
|
6b5ac20ef8 | ||
|
66f0df5037 | ||
|
981babd691 | ||
46fa43d67f | |||
4ef54f1bc2 | |||
|
b029889f27 | ||
|
44f8e3ea9b | ||
|
ae36c041a7 | ||
8a722f294c | |||
5674acbee6 | |||
bf9371c60a | |||
181930f537 | |||
cee22a1942 | |||
673458b41d | |||
b56e99bfdf | |||
a5b5d99129 | |||
e1b9a9921c | |||
709ebdf567 | |||
d41ee3dc8c | |||
e342f6e3ed | |||
8b2450d6f9 | |||
943eab5e7e | |||
d55328e03b | |||
30aa1cd1cf | |||
01f3196938 | |||
ced8e0fd3c | |||
|
b838e7494d | ||
745b4b56ec | |||
c5b539912d | |||
|
ed6dc94873 | ||
233b9693eb | |||
|
2656c74f37 | ||
28b571588e | |||
ae5d3ecac5 | |||
26213d5e8c | |||
ae0ecb9f23 | |||
75af78eecc | |||
cf202738be | |||
|
ed78a83ed9 | ||
d0635ae4a1 | |||
552751b346 | |||
e83cf50e51 | |||
810635f5a5 | |||
213cbfb440 | |||
11e5ff42a6 | |||
bb64452382 | |||
e31d388ec1 | |||
a4dd8ec0d1 | |||
7b4b97b0d3 | |||
665cc84174 | |||
6c6ccc647e | |||
0684806db0 | |||
|
d0d4096f8b | ||
507a73e74c | |||
1fa164453c | |||
fcadbc352b | |||
7824ba089a | |||
31f344503c | |||
|
e63fd587c8 | ||
c1c6f21ad2 | |||
060057e268 | |||
4e02f77382 | |||
780ac4eb74 | |||
d46374839e | |||
ce45034776 | |||
7fada671fc | |||
feea191ecf | |||
db605e0d21 | |||
0fe433891a | |||
0a2d5ef820 | |||
31f0c384ac | |||
73651ef964 | |||
03eee061ff | |||
9a499f68e8 | |||
b5927be136 | |||
be78fc177d | |||
|
30cc89fe25 | ||
|
8e6e52bf02 | ||
|
20e059c921 | ||
36f1e846bb | |||
1e50aeebd8 | |||
05e624ce39 | |||
4e6b9f4cbd | |||
17bc35864b | |||
52c115cea8 | |||
df1a7dd19e | |||
34994e919b | |||
d6669fa8e2 | |||
6d86d4ce59 | |||
15da2657d3 | |||
1c23a8c570 | |||
054f804427 | |||
052cd36085 | |||
e49fd16a3a | |||
c92b59db1d | |||
1cef4f6e0b | |||
46e3a9516a | |||
8674c5da47 | |||
f299241a6c | |||
038fb55801 | |||
|
8f43619f73 | ||
9b7882de7a | |||
70a50ca1c2 | |||
99d38e1f8e | |||
00be1d4095 | |||
04a971e767 | |||
917006b69f | |||
a87d5836c1 | |||
|
e23f3c2570 | ||
|
b7207c6eaf | ||
58986c4a7a | |||
7e82aa83e6 | |||
b6c4bb1801 | |||
a47d106926 | |||
3be15b0a5f | |||
6e44f9eaba | |||
525ff7903c | |||
6e043e3b9e | |||
b34213c301 | |||
e831c3eb65 | |||
|
d3c303ba2a | ||
|
108e7af578 | ||
|
d1ff800b41 | ||
|
ee430b8687 | ||
4e6f961a24 | |||
f72f0424cc | |||
061e2b42c9 | |||
b2aa2df59d | |||
ead145b9ce | |||
d8d4803e2d | |||
b06b419817 | |||
14472a45ed | |||
|
28a448a1aa | ||
ecb5be4b17 | |||
1873c74776 | |||
7aede352f1 | |||
5b406b0172 | |||
a515b0c3a4 | |||
85e85aa2bb | |||
54c5cabf48 | |||
8389587a60 | |||
8bbdbe62a4 | |||
1dda3240e7 | |||
2c47102c56 | |||
6d61a4b24d | |||
65ea02d861 | |||
e9d58d12f4 | |||
8bbbfde5aa | |||
9b84d57f0e | |||
ab1f5047a1 | |||
3c11660cf7 | |||
776bd2e86f | |||
226319a6f0 | |||
7298ecb7b7 | |||
5040a76781 | |||
e1a7fb4999 | |||
6394485524 | |||
1e4dd415cf | |||
1774fdc604 | |||
|
782abbb82a | ||
|
3bd9b02768 | ||
a0903dd7be | |||
|
2f2e639a6e | ||
|
77fc5c0c6f | ||
|
d4fe3781f7 | ||
27c8bb3d19 | |||
20d57c2446 | |||
48224e28b8 | |||
54148683f8 | |||
fd3f058d22 | |||
e9ad3c2126 | |||
fac41b63b9 | |||
283b7c7082 | |||
5058046018 | |||
8e7e86808e | |||
dd26d0de0f | |||
8d90d61fd2 | |||
e0645d4525 | |||
3293e9892c | |||
8a6774f8e3 | |||
2004478c88 | |||
4d48090411 | |||
02b30832b3 | |||
877b243c69 | |||
c6b24dd8f1 | |||
89cd8eafc4 | |||
9e6afdb752 | |||
2645edc9e0 | |||
87d2b4fed3 | |||
f496c9d678 | |||
19a4b17004 | |||
4f8cce0f45 | |||
32a0106819 | |||
7ca355db66 | |||
6ba974f9fa | |||
986130a0ac | |||
7824ddc13f | |||
57d5afa530 | |||
3e456782df | |||
2aecf3245b | |||
4d2c27e74e | |||
4a6126287a | |||
14dd49e4b0 | |||
e0fd5f8fe0 | |||
c9e23cdd29 | |||
f7c7ea50eb | |||
55bed2e5e8 | |||
370186726a | |||
0aff057f7b | |||
a5a716e09b | |||
0f7caafd54 | |||
0eb87663e0 | |||
87f7a515a6 | |||
4d15f7ae98 | |||
4223d23ce5 | |||
f74cf516d2 | |||
9ca8857d89 | |||
ba1942e757 | |||
3cb68c945f | |||
46ebd45a74 | |||
be35c73f6e | |||
08f84bf7e3 | |||
265081417d | |||
6195637001 | |||
b304712b1e | |||
b9eba00603 | |||
a33758e37e | |||
1f5283d548 | |||
a0ca6bb8fb | |||
|
e0fe8dc673 | ||
|
0fdfcccee9 | ||
de33dc6c89 | |||
bbb3c81f68 | |||
149defddda | |||
cb95a569d3 | |||
3918da3bc7 | |||
a6bb2daf81 | |||
6f11102153 | |||
53ff2e0209 | |||
59add72cd6 | |||
0c72d836fe | |||
b5b5facde6 | |||
5bcd51cc5f | |||
cb00c869e1 | |||
cb81497e8d | |||
b24145c47c | |||
|
1ce5a21e66 | ||
|
a70b92253a | ||
bc7b577748 | |||
1a784d0709 | |||
|
ee2c56156b | ||
6d472bf5ca | |||
c7b4c25caa | |||
852b864ee6 | |||
f795d2d0f3 | |||
3ba9cd2d99 | |||
c1a981c60b | |||
|
0a1d0084e2 | ||
1fc857d9a2 | |||
571b019c00 | |||
1864359751 | |||
4c24118b48 | |||
|
dd0e04b106 | ||
49cd66581d | |||
eb7b1bf432 | |||
f4372ecd05 | |||
3f61c6b21a | |||
36d5262f1d | |||
|
4e893a3196 | ||
1d46b85170 | |||
ae971b23bc | |||
2de94bc902 | |||
9fc158831b | |||
9d48700cd9 | |||
a3657abaf3 | |||
2ac3d29c13 | |||
0cc7166767 | |||
1c8e26bdc6 | |||
cc32ca244c | |||
c1078255fc | |||
c329c37c7b | |||
2fc96cb6a7 | |||
74d785d606 | |||
56365591cf | |||
f884020c55 | |||
1d2abf56f9 | |||
e52c139c9f | |||
529b3d2890 | |||
7e29dde717 | |||
a85e27b497 | |||
5766ae48d7 | |||
df32893ce6 | |||
a60ad77bdc | |||
7f5f44d7f0 | |||
54d456e886 | |||
1d43d1bd65 | |||
12a3c238b8 | |||
a6db1e7391 | |||
bb94c1ba3a | |||
a776e1d2f3 | |||
3b940cb56c | |||
6f51921588 | |||
b79d238bdc | |||
f05e81190f | |||
373a766f5c | |||
515300d43a | |||
80163ee992 | |||
9c730d3381 | |||
f6d46ce394 | |||
526d2c0cfc | |||
3ee8231b9e | |||
fc320d0067 | |||
b988e3c0ec | |||
|
26568fe5c6 | ||
accd3ef392 | |||
f36e1d78b2 | |||
a105883ef0 | |||
3271144406 | |||
1965535605 | |||
b8a2160a93 | |||
2b12b0fafd | |||
ccce0c6ac4 | |||
f101cf4f82 | |||
d072a7d277 | |||
217eb9102e | |||
e6f140912e | |||
ee3de6730c | |||
51e55732ff | |||
81a1aa202f | |||
10c262fe96 | |||
ba4f825bc6 | |||
8e1ab8e09b | |||
fa54e1f1de | |||
2bf83a672c | |||
435535f8cd | |||
9a2f95ecc6 | |||
97dd55d946 | |||
4fef047d74 | |||
3f96ce6d60 | |||
c30c2e00cb | |||
b5b56a6e4a | |||
69821fb663 | |||
50fa592aad | |||
a3a3ef850c | |||
|
e6a935f49d | ||
cc8b03778c | |||
18c31482df | |||
427f18d59e | |||
d1b61a1489 | |||
5cfb99bfc2 | |||
18d7ca0820 | |||
fd77aaa123 | |||
e0456cdfa1 | |||
0b68a473ef | |||
7c3ece5816 | |||
d7ed5b8f11 | |||
dfe401a9dc | |||
0bf68effb8 | |||
9b2e9fc17f | |||
35c861b711 | |||
3750b0f78b | |||
eec02a55a4 | |||
60ef07da0f | |||
e064c3bf96 | |||
1964c1352c | |||
6fee114610 | |||
4ae18ec162 | |||
d66ad12f5c | |||
43b22360a5 | |||
17d791027c | |||
|
3c5c3cad10 | ||
|
36d4599276 | ||
59401bc1da | |||
3a7a4bdc42 | |||
66648be6d5 | |||
117980a8fc | |||
34d0f28678 | |||
0cbffad49d | |||
7bb1b1b769 | |||
6b358107b6 | |||
15640e98ec | |||
8d88b3792d | |||
672d63fbed | |||
aeabc42844 | |||
8fc01f774a | |||
a745966984 | |||
e779681905 | |||
7c3c2945f8 | |||
3d6aca3510 | |||
72518212da | |||
b31da0cefe | |||
b75c79fd5e | |||
421ff9a188 | |||
51a740f53c | |||
2ef2bb7700 | |||
ecc3d3cf3f | |||
db49b9b532 | |||
2b20f328cb | |||
3c89147ee2 | |||
78a5096e0d | |||
4ee7a8bac6 | |||
0c678b6e44 |
73
.drone.yml
|
@ -1,3 +1,4 @@
|
|||
---
|
||||
kind: pipeline
|
||||
name: build
|
||||
|
||||
|
@ -79,25 +80,13 @@ steps:
|
|||
depends_on:
|
||||
- dependencies
|
||||
|
||||
# Building in dev mode to avoid the service worker for testing
|
||||
- name: build-dev
|
||||
image: node:16
|
||||
pull: true
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: .cache/yarn/
|
||||
CYPRESS_CACHE_FOLDER: .cache/cypress/
|
||||
commands:
|
||||
- yarn build:dev
|
||||
depends_on:
|
||||
- dependencies
|
||||
|
||||
- name: build-prod
|
||||
image: node:16
|
||||
pull: true
|
||||
environment:
|
||||
YARN_CACHE_FOLDER: .cache/yarn/
|
||||
commands:
|
||||
- yarn build --dest dist-prod
|
||||
- yarn build
|
||||
depends_on:
|
||||
- dependencies
|
||||
|
||||
|
@ -109,8 +98,17 @@ steps:
|
|||
depends_on:
|
||||
- dependencies
|
||||
|
||||
- name: typecheck
|
||||
failure: ignore
|
||||
image: node:16
|
||||
pull: true
|
||||
commands:
|
||||
- yarn typecheck
|
||||
depends_on:
|
||||
- dependencies
|
||||
|
||||
- name: test-frontend
|
||||
image: cypress/browsers:node14.17.0-chrome91-ff89
|
||||
image: cypress/browsers:node16.5.0-chrome94-ff93
|
||||
pull: true
|
||||
environment:
|
||||
CYPRESS_API_URL: http://api:3456/api/v1
|
||||
|
@ -119,12 +117,12 @@ steps:
|
|||
CYPRESS_CACHE_FOLDER: .cache/cypress/
|
||||
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
|
||||
commands:
|
||||
- sed -i 's/localhost/api/g' dist-dev/index.html
|
||||
- yarn serve:dist-dev & npx wait-on http://localhost:5000
|
||||
- sed -i 's/localhost/api/g' dist/index.html
|
||||
- yarn serve:dist & npx wait-on http://localhost:5000
|
||||
- yarn test:frontend --browser chrome
|
||||
depends_on:
|
||||
- dependencies
|
||||
- build-dev
|
||||
- build-prod
|
||||
|
||||
- name: upload-test-results
|
||||
image: plugins/s3
|
||||
|
@ -148,6 +146,26 @@ steps:
|
|||
- failure
|
||||
- success
|
||||
|
||||
- name: deploy-preview
|
||||
image: node:16
|
||||
pull: true
|
||||
environment:
|
||||
NETLIFY_AUTH_TOKEN:
|
||||
from_secret: netlify_auth_token
|
||||
NETLIFY_SITE_ID:
|
||||
from_secret: netlify_site_id
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
|
||||
- node ./scripts/deploy-preview-netlify.js
|
||||
depends_on:
|
||||
- build-prod
|
||||
when:
|
||||
event:
|
||||
include:
|
||||
- pull_request
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: release-latest
|
||||
|
@ -340,6 +358,9 @@ trigger:
|
|||
ref:
|
||||
- refs/heads/main
|
||||
- "refs/tags/**"
|
||||
event:
|
||||
exclude:
|
||||
- cron
|
||||
|
||||
steps:
|
||||
- name: docker-unstable
|
||||
|
@ -437,6 +458,9 @@ trigger:
|
|||
ref:
|
||||
- refs/heads/main
|
||||
- "refs/tags/**"
|
||||
event:
|
||||
exclude:
|
||||
- cron
|
||||
|
||||
steps:
|
||||
- name: docker-unstable
|
||||
|
@ -483,6 +507,9 @@ trigger:
|
|||
ref:
|
||||
- refs/heads/main
|
||||
- "refs/tags/**"
|
||||
event:
|
||||
exclude:
|
||||
- cron
|
||||
|
||||
depends_on:
|
||||
- docker-amd64-release
|
||||
|
@ -544,6 +571,9 @@ trigger:
|
|||
ref:
|
||||
- refs/heads/main
|
||||
- "refs/tags/**"
|
||||
event:
|
||||
exclude:
|
||||
- cron
|
||||
|
||||
depends_on:
|
||||
- build
|
||||
|
@ -580,7 +610,9 @@ trigger:
|
|||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
- cron
|
||||
cron:
|
||||
- update_translations
|
||||
|
||||
steps:
|
||||
- name: download
|
||||
|
@ -631,3 +663,8 @@ steps:
|
|||
environment:
|
||||
CROWDIN_KEY:
|
||||
from_secret: crowdin_key
|
||||
---
|
||||
kind: signature
|
||||
hmac: 188ee90100c5fc5922a445e531e7a47453121edddb2a64a182eb23ed2bf602de
|
||||
|
||||
...
|
||||
|
|
|
@ -21,5 +21,9 @@ indent_size = 2
|
|||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{scss,css}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[.nvmrc]
|
||||
insert_final_newline = false
|
3
.gitignore
vendored
|
@ -26,3 +26,6 @@ stats.html
|
|||
# Test files
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
|
|
@ -9,6 +9,13 @@ All releases can be found on https://code.vikunja.io/frontend/releases.
|
|||
|
||||
The releases aim at the api versions which is why there are missing versions.
|
||||
|
||||
## [0.18.2] - 2021-11-23
|
||||
|
||||
### Fixed
|
||||
|
||||
* fix(docker): properly replace api url
|
||||
* fix: edit saved filter title
|
||||
|
||||
## [0.18.1] - 2021-09-08
|
||||
|
||||
### Added
|
||||
|
|
|
@ -24,12 +24,6 @@ RUN \
|
|||
# Stage 2: copy
|
||||
FROM nginx
|
||||
|
||||
RUN apt-get update && apt-get install -y apt-utils openssl && \
|
||||
mkdir -p /etc/nginx/ssl && \
|
||||
openssl genrsa -out /etc/nginx/ssl/dummy.key 2048 && \
|
||||
openssl req -new -key /etc/nginx/ssl/dummy.key -out /etc/nginx/ssl/dummy.csr -subj "/C=DE/L=Berlin/O=Vikunja/CN=Vikunja Snakeoil" && \
|
||||
openssl x509 -req -days 3650 -in /etc/nginx/ssl/dummy.csr -signkey /etc/nginx/ssl/dummy.key -out /etc/nginx/ssl/dummy.crt
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY run.sh /run.sh
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
|
||||
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
|
||||
[![Download](https://img.shields.io/badge/download-v0.18.1-brightgreen.svg)](https://dl.vikunja.io)
|
||||
[![Download](https://img.shields.io/badge/download-v0.18.2-brightgreen.svg)](https://dl.vikunja.io)
|
||||
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
|
||||
|
||||
This is the web frontend for Vikunja, written in Vue.js.
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/app',
|
||||
],
|
||||
}
|
|
@ -11,7 +11,7 @@ export class UserFactory extends Factory {
|
|||
|
||||
return {
|
||||
id: '{increment}',
|
||||
username: faker.lorem.word(10) + faker.random.uuid(),
|
||||
username: faker.lorem.word(10) + faker.datatype.uuid(),
|
||||
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
|
||||
status: 0,
|
||||
created: formatISO(now),
|
||||
|
|
|
@ -31,15 +31,14 @@ describe('Lists', () => {
|
|||
cy.url()
|
||||
.should('contain', '/namespaces/1/list')
|
||||
cy.get('.card-header-title')
|
||||
.contains('Create a new list')
|
||||
.contains('New list')
|
||||
cy.get('input.input')
|
||||
.type('New List')
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.wait(1000) // Waiting until the request to create the new list is done
|
||||
cy.get('.global-notification')
|
||||
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new list is done
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', '/lists/')
|
||||
|
@ -102,7 +101,7 @@ describe('Lists', () => {
|
|||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
|
@ -220,10 +219,10 @@ describe('Lists', () => {
|
|||
cy.get('.table-view .filter-container .items .button')
|
||||
.contains('Columns')
|
||||
.click()
|
||||
cy.get('.table-view .filter-container .card .card-content .fancycheckbox .check')
|
||||
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||
.contains('Priority')
|
||||
.click()
|
||||
cy.get('.table-view .filter-container .card .card-content .fancycheckbox .check')
|
||||
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||
.contains('Done')
|
||||
.click()
|
||||
|
||||
|
@ -393,7 +392,7 @@ describe('Lists', () => {
|
|||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
|
||||
.first()
|
||||
.type(3)
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field a.button.is-primary')
|
||||
cy.get('[data-cy="setBucketLimit"]')
|
||||
.first()
|
||||
.click()
|
||||
|
||||
|
@ -446,7 +445,7 @@ describe('Lists', () => {
|
|||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(tasks[0].title)
|
||||
.first()
|
||||
.drag('.kanban .bucket:nth-child(2) .tasks .dropper div')
|
||||
.drag('.kanban .bucket:nth-child(2) .tasks .dropper')
|
||||
|
||||
cy.get('.kanban .bucket:nth-child(2) .tasks')
|
||||
.should('contain', tasks[0].title)
|
||||
|
@ -501,7 +500,7 @@ describe('Lists', () => {
|
|||
.first()
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
cy.get('.global-notification', { timeout: 1000 })
|
||||
.should('contain', 'Success')
|
||||
cy.go('back')
|
||||
cy.get('.kanban .bucket')
|
||||
|
|
|
@ -15,7 +15,7 @@ describe('Namepaces', () => {
|
|||
|
||||
it('Should be all there', () => {
|
||||
cy.visit('/namespaces')
|
||||
cy.get('.namespace h1 span')
|
||||
cy.get('[data-cy="namespace-title"]')
|
||||
.should('contain', namespaces[0].title)
|
||||
})
|
||||
|
||||
|
@ -23,14 +23,14 @@ describe('Namepaces', () => {
|
|||
const newNamespaceTitle = 'New Namespace'
|
||||
|
||||
cy.visit('/namespaces')
|
||||
cy.get('a.button')
|
||||
.contains('Create a new namespace')
|
||||
cy.get('[data-cy="new-namespace"]')
|
||||
.should('contain', 'New namespace')
|
||||
.click()
|
||||
|
||||
cy.url()
|
||||
.should('contain', '/namespaces/new')
|
||||
cy.get('.card-header-title')
|
||||
.should('contain', 'Create a new namespace')
|
||||
.should('contain', 'New namespace')
|
||||
cy.get('input.input')
|
||||
.type(newNamespaceTitle)
|
||||
cy.get('.button')
|
||||
|
@ -67,12 +67,12 @@ describe('Namepaces', () => {
|
|||
.contains('Save')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
cy.get('.global-notification', { timeout: 1000 })
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists')
|
||||
.should('contain', newNamespaceName)
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
cy.get('.content.namespaces-list')
|
||||
cy.get('[data-cy="namespaces-list"]')
|
||||
.should('contain', newNamespaceName)
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
})
|
||||
|
@ -89,7 +89,7 @@ describe('Namepaces', () => {
|
|||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
|
@ -116,30 +116,30 @@ describe('Namepaces', () => {
|
|||
|
||||
// Initial
|
||||
cy.visit('/namespaces')
|
||||
cy.get('.namespaces-list .namespace')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
|
||||
// Show archived
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('be.checked')
|
||||
cy.get('.namespaces-list .namespace')
|
||||
cy.get('.namespace')
|
||||
.should('contain', 'Archived')
|
||||
|
||||
// Don't show archived
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
|
||||
// Second time visiting after unchecking
|
||||
cy.visit('/namespaces')
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
cy.get('.namespaces-list .namespace')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
import '../../support/authenticateUser'
|
||||
|
||||
const setHours = hours => {
|
||||
const date = new Date()
|
||||
date.setHours(hours)
|
||||
cy.clock(+date)
|
||||
}
|
||||
|
||||
describe('Home Page', () => {
|
||||
it('shows the right salutation in the night', () => {
|
||||
setHours(4)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Good Night')
|
||||
})
|
||||
it('shows the right salutation in the morning', () => {
|
||||
setHours(8)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Good Morning')
|
||||
})
|
||||
it('shows the right salutation in the day', () => {
|
||||
setHours(13)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Hi')
|
||||
})
|
||||
it('shows the right salutation in the night', () => {
|
||||
setHours(20)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Good Evening')
|
||||
})
|
||||
it('shows the right salutation in the night again', () => {
|
||||
setHours(23)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Good Night')
|
||||
})
|
||||
})
|
|
@ -7,7 +7,7 @@ describe('The Menu', () => {
|
|||
})
|
||||
|
||||
it('Can be hidden on desktop', () => {
|
||||
cy.get('a.menu-show-button:visible')
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.namespace-container')
|
||||
.should('not.have.class', 'is-active')
|
||||
|
@ -21,7 +21,7 @@ describe('The Menu', () => {
|
|||
|
||||
it('Is can be shown on mobile', () => {
|
||||
cy.viewport('iphone-8')
|
||||
cy.get('a.menu-show-button:visible')
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.namespace-container')
|
||||
.should('have.class', 'is-active')
|
||||
|
|
|
@ -128,7 +128,7 @@ describe('Task', () => {
|
|||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Done!')
|
||||
.contains('Mark task done!')
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .heading .is-done')
|
||||
|
@ -168,7 +168,7 @@ describe('Task', () => {
|
|||
.click()
|
||||
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
||||
.type('{selectall}New Description')
|
||||
cy.get('.task-view .details.content.description .editor a')
|
||||
cy.get('[data-cy="saveEditor"]')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
|
@ -263,8 +263,7 @@ describe('Task', () => {
|
|||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Assign this task to a user')
|
||||
cy.get('[data-cy="taskDetail.assign"]')
|
||||
.click()
|
||||
cy.get('.task-view .column.assignees .multiselect input')
|
||||
.type(users[1].username)
|
||||
|
@ -405,7 +404,7 @@ describe('Task', () => {
|
|||
cy.get('.datepicker .datepicker-popup a')
|
||||
.contains('Tomorrow')
|
||||
.click()
|
||||
cy.get('.datepicker .datepicker-popup a.button')
|
||||
cy.get('[data-cy="closeDatepicker"]')
|
||||
.contains('Confirm')
|
||||
.click()
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ const testAndAssertFailed = fixture => {
|
|||
|
||||
cy.wait(5000) // It can take waaaayy too long to log the user in
|
||||
cy.url().should('include', '/')
|
||||
cy.get('div.notification.is-danger').contains('Wrong username or password.')
|
||||
cy.get('div.message.danger').contains('Wrong username or password.')
|
||||
}
|
||||
|
||||
context('Login', () => {
|
||||
|
|
|
@ -24,15 +24,15 @@ context('Registration', () => {
|
|||
cy.visit('/register')
|
||||
cy.get('#username').type(fixture.username)
|
||||
cy.get('#email').type(fixture.email)
|
||||
cy.get('#password1').type(fixture.password)
|
||||
cy.get('#password2').type(fixture.password)
|
||||
cy.get('#password').type(fixture.password)
|
||||
cy.get('#passwordValidation').type(fixture.password)
|
||||
cy.get('#register-submit').click()
|
||||
cy.url().should('include', '/')
|
||||
cy.clock(1625656161057) // 13:00
|
||||
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
||||
})
|
||||
|
||||
it('Should fail', () => {
|
||||
it.only('Should fail', () => {
|
||||
const fixture = {
|
||||
username: 'test',
|
||||
password: '123456',
|
||||
|
@ -42,9 +42,9 @@ context('Registration', () => {
|
|||
cy.visit('/register')
|
||||
cy.get('#username').type(fixture.username)
|
||||
cy.get('#email').type(fixture.email)
|
||||
cy.get('#password1').type(fixture.password)
|
||||
cy.get('#password2').type(fixture.password)
|
||||
cy.get('#password').type(fixture.password)
|
||||
cy.get('#passwordValidation').type(fixture.password)
|
||||
cy.get('#register-submit').click()
|
||||
cy.get('div.notification.is-danger').contains('A user with this username already exists.')
|
||||
cy.get('div.message.danger').contains('A user with this username already exists.')
|
||||
})
|
||||
})
|
|
@ -8,17 +8,17 @@ describe('User Settings', () => {
|
|||
})
|
||||
|
||||
it('Changes the user avatar', () => {
|
||||
cy.visit('/user/settings')
|
||||
cy.visit('/user/settings/avatar')
|
||||
|
||||
cy.get('input[name=avatarProvider][value=upload]')
|
||||
.click()
|
||||
cy.get('input[type=file]')
|
||||
cy.get('input[type=file]', { timeout: 1000 })
|
||||
.attachFile('image.jpg')
|
||||
cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south')
|
||||
.trigger('mousedown', {which: 1})
|
||||
.trigger('mousemove', {clientY: 100})
|
||||
.trigger('mouseup')
|
||||
cy.get('a.button.is-primary')
|
||||
cy.get('[data-cy="uploadAvatar"]')
|
||||
.contains('Upload Avatar')
|
||||
.click()
|
||||
|
||||
|
@ -28,11 +28,12 @@ describe('User Settings', () => {
|
|||
})
|
||||
|
||||
it('Updates the name', () => {
|
||||
cy.visit('/user/settings')
|
||||
cy.visit('/user/settings/general')
|
||||
|
||||
cy.get('input#newName')
|
||||
cy.get('.general-settings .control input.input')
|
||||
.first()
|
||||
.type('Lorem Ipsum')
|
||||
cy.get('.card.general-settings .button.is-primary')
|
||||
cy.get('[data-cy="saveGeneralSettings"]')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
|
|
|
@ -2,3 +2,10 @@
|
|||
import './commands'
|
||||
import 'cypress-file-upload'
|
||||
import '@4tw/cypress-drag-drop'
|
||||
|
||||
// see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275
|
||||
Cypress.on('window:before:load', (win) => {
|
||||
// disable service workers
|
||||
// @ts-ignore
|
||||
delete win.navigator.__proto__.ServiceWorker
|
||||
})
|
|
@ -30,7 +30,10 @@
|
|||
// It has to be the full url, including the last /api/v1 part and port.
|
||||
// You can change this if your api is not reachable on the same port as the frontend.
|
||||
window.API_URL = 'http://localhost:3456/api/v1'
|
||||
//
|
||||
// Enable error tracking with sentry. If this is set to true, will send anonymized data to
|
||||
// our sentry instance to notify us of potential problems.
|
||||
window.SENTRY_ENABLED = false
|
||||
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
15
netlify.toml
Normal file
|
@ -0,0 +1,15 @@
|
|||
[build]
|
||||
command = "yarn build"
|
||||
publish = "dist"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
X-Frame-Options = "DENY"
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
X-Robots-Tag = "noindex"
|
11
nginx.conf
|
@ -60,19 +60,20 @@ http {
|
|||
server {
|
||||
listen 80;
|
||||
listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
|
||||
listen 443 default_server ssl http2;
|
||||
|
||||
server_name _;
|
||||
|
||||
expires $expires;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/dummy.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/dummy.key;
|
||||
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
index index.html;
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
inkscape:label="ink_ext_XXXXXX 1"
|
||||
style="display:inline"
|
||||
transform="translate(-92.67749,-674.48297)"><circle
|
||||
style="fill:#5974d9;fill-opacity:1;stroke:none;stroke-width:2.88757133;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
|
||||
style="fill:#196aff;fill-opacity:1;stroke:none;stroke-width:2.88757133;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
|
||||
id="path920"
|
||||
cx="242.67749"
|
||||
cy="828.77881"
|
||||
|
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
154
package.json
|
@ -7,84 +7,103 @@
|
|||
"serve:dist-dev": "node scripts/serve-dist.js",
|
||||
"serve:dist": "vite preview",
|
||||
"build": "vite build && workbox copyLibraries dist/",
|
||||
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
|
||||
"build:dev": "vite build -m development --outDir dist-dev/",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
|
||||
"cypress:open": "cypress open",
|
||||
"test:unit": "jest",
|
||||
"test:frontend": "cypress run"
|
||||
"test:unit": "vitest run",
|
||||
"test:frontend": "cypress run",
|
||||
"browserslist:update": "npx browserslist@latest --update-db"
|
||||
},
|
||||
"dependencies": {
|
||||
"browserslist": "4.17.1",
|
||||
"bulma": "0.9.3",
|
||||
"@github/hotkey": "1.6.1",
|
||||
"@kyvg/vue3-notification": "2.3.4",
|
||||
"@sentry/tracing": "6.16.1",
|
||||
"@sentry/vue": "6.16.1",
|
||||
"@types/is-touch-device": "1.0.0",
|
||||
"@vue/compat": "3.2.26",
|
||||
"@vueuse/core": "7.5.2",
|
||||
"@vueuse/router": "7.5.3",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"camel-case": "4.1.2",
|
||||
"codemirror": "5.65.0",
|
||||
"copy-to-clipboard": "3.3.1",
|
||||
"date-fns": "2.24.0",
|
||||
"dompurify": "2.3.3",
|
||||
"highlight.js": "11.2.0",
|
||||
"date-fns": "2.28.0",
|
||||
"dompurify": "2.3.4",
|
||||
"easymde": "2.15.0",
|
||||
"flatpickr": "4.6.9",
|
||||
"flexsearch": "0.7.21",
|
||||
"highlight.js": "11.4.0",
|
||||
"is-touch-device": "1.0.1",
|
||||
"marked": "3.0.4",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"marked": "4.0.9",
|
||||
"register-service-worker": "1.7.2",
|
||||
"snake-case": "3.0.4",
|
||||
"ufo": "0.7.9",
|
||||
"verte": "0.0.12",
|
||||
"vue": "2.6.14",
|
||||
"vue-advanced-cropper": "1.8.2",
|
||||
"vue-drag-resize": "1.5.4",
|
||||
"vue-easymde": "1.4.0",
|
||||
"vue-i18n": "8.26.3",
|
||||
"vue-shortkey": "3.1.7",
|
||||
"vuedraggable": "2.24.3",
|
||||
"vuex": "3.6.2",
|
||||
"workbox-precaching": "6.3.0"
|
||||
"v-tooltip": "4.0.0-beta.13",
|
||||
"vue": "3.2.26",
|
||||
"vue-advanced-cropper": "2.7.1",
|
||||
"vue-drag-resize": "2.0.3",
|
||||
"vue-flatpickr-component": "9.0.5",
|
||||
"vue-i18n": "9.2.0-beta.26",
|
||||
"vue-router": "4.0.12",
|
||||
"vuedraggable": "4.1.0",
|
||||
"vuex": "4.0.2",
|
||||
"workbox-precaching": "6.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@4tw/cypress-drag-drop": "2.0.0",
|
||||
"@4tw/cypress-drag-drop": "2.1.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/vue-fontawesome": "2.0.2",
|
||||
"@types/jest": "27.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "4.32.0",
|
||||
"@typescript-eslint/parser": "4.32.0",
|
||||
"@vue/babel-preset-app": "4.5.13",
|
||||
"@vue/eslint-config-typescript": "7.0.0",
|
||||
"@vue/runtime-dom": "latest",
|
||||
"autoprefixer": "10.3.6",
|
||||
"axios": "0.21.4",
|
||||
"babel-eslint": "10.1.0",
|
||||
"cypress": "8.5.0",
|
||||
"@fortawesome/vue-fontawesome": "3.0.0-5",
|
||||
"@types/flexsearch": "0.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "5.9.0",
|
||||
"@typescript-eslint/parser": "5.9.0",
|
||||
"@vitejs/plugin-legacy": "1.6.4",
|
||||
"@vitejs/plugin-vue": "2.0.1",
|
||||
"@vue/eslint-config-typescript": "10.0.0",
|
||||
"autoprefixer": "10.4.2",
|
||||
"axios": "0.24.0",
|
||||
"browserslist": "4.19.1",
|
||||
"caniuse-lite": "1.0.30001298",
|
||||
"cypress": "9.2.0",
|
||||
"cypress-file-upload": "5.0.8",
|
||||
"esbuild": "0.13.3",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-plugin-vue": "7.18.0",
|
||||
"express": "4.17.1",
|
||||
"esbuild": "0.14.10",
|
||||
"eslint": "8.6.0",
|
||||
"eslint-plugin-vue": "8.2.0",
|
||||
"express": "4.17.2",
|
||||
"faker": "5.5.3",
|
||||
"jest": "27.2.4",
|
||||
"rollup-plugin-terser": "7.0.2",
|
||||
"netlify-cli": "8.6.15",
|
||||
"happy-dom": "2.25.1",
|
||||
"postcss": "8.4.5",
|
||||
"postcss-preset-env": "7.2.0",
|
||||
"rollup": "2.63.0",
|
||||
"rollup-plugin-visualizer": "5.5.2",
|
||||
"sass": "1.42.1",
|
||||
"ts-jest": "27.0.5",
|
||||
"typescript": "4.4.3",
|
||||
"vite": "2.6.1",
|
||||
"vite-plugin-pwa": "0.11.2",
|
||||
"vite-plugin-vue2": "1.8.2",
|
||||
"vue-flatpickr-component": "8.1.7",
|
||||
"vue-notification": "1.3.20",
|
||||
"vue-router": "3.5.2",
|
||||
"vue-template-compiler": "2.6.14",
|
||||
"sass": "1.47.0",
|
||||
"slugify": "1.6.5",
|
||||
"typescript": "4.5.4",
|
||||
"vite": "2.7.10",
|
||||
"vite-plugin-pwa": "0.11.12",
|
||||
"vite-svg-loader": "3.1.1",
|
||||
"vitest": "0.0.139",
|
||||
"vue-tsc": "0.30.2",
|
||||
"wait-on": "6.0.0",
|
||||
"workbox-cli": "6.3.0"
|
||||
"workbox-cli": "6.4.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
"node": true,
|
||||
"vue/setup-compiler-macros": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:vue/essential",
|
||||
"plugin:vue/vue3-essential",
|
||||
"@vue/typescript"
|
||||
],
|
||||
"rules": {
|
||||
|
@ -103,7 +122,9 @@
|
|||
"semi": [
|
||||
"error",
|
||||
"never"
|
||||
]
|
||||
],
|
||||
"vue/script-setup-uses-vars": "error",
|
||||
"vue/multi-word-component-names": 0
|
||||
},
|
||||
"parser": "vue-eslint-parser",
|
||||
"parserOptions": {
|
||||
|
@ -113,37 +134,16 @@
|
|||
"ignorePatterns": [
|
||||
"*.test.*",
|
||||
"cypress/*"
|
||||
]
|
||||
],
|
||||
"globals": {
|
||||
"defineProps": "readonly"
|
||||
}
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not ie > 0",
|
||||
"not dead",
|
||||
"Firefox ESR"
|
||||
],
|
||||
"jest": {
|
||||
"testPathIgnorePatterns": [
|
||||
"cypress"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"preset": "ts-jest",
|
||||
"roots": [
|
||||
"<rootDir>/src"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(js|tsx?)$": "ts-jest"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"js",
|
||||
"json"
|
||||
]
|
||||
},
|
||||
"license": "AGPL-3.0-or-later"
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"packageManager": "yarn@1.22.17"
|
||||
}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
# Shell script because yaml doesn't understand the header is a string literal and not a yaml symbol
|
||||
|
||||
curl -d operation=pull -H "Authorization: Token $WEBLATE_TOKEN" https://hosted.weblate.org/api/projects/vikunja/repository/
|
||||
curl -d operation=push -H "Authorization: Token $WEBLATE_TOKEN" https://hosted.weblate.org/api/projects/vikunja/repository/
|
5
run.sh
|
@ -3,7 +3,8 @@
|
|||
# This shell script sets the api url based on an environment variable and starts nginx in foreground.
|
||||
|
||||
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
|
||||
|
||||
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
|
||||
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
|
||||
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
|
||||
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"
|
||||
|
||||
|
@ -14,6 +15,8 @@ VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g')
|
|||
|
||||
sed -i "s/http\:\/\/localhost\:3456//g" /usr/share/nginx/html/index.html # replacing in two steps to make sure api urls from releases are properly replaced as well
|
||||
sed -i "s/'\/api\/v1/'$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
|
||||
sed -i "s/\.SENTRY_ENABLED = false/\.SENTRY_ENABLED = $VIKUNJA_SENTRY_ENABLED/g" /usr/share/nginx/html/index.html
|
||||
sed -i "s/\.SENTRY_DSN = '.*'/\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'/g" /usr/share/nginx/html/index.html
|
||||
|
||||
sed -i "s/listen 80/listen $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf
|
||||
sed -i "s/listen 443/listen $VIKUNJA_HTTPS_PORT/g" /etc/nginx/nginx.conf
|
||||
|
|
66
scripts/deploy-preview-netlify.js
Normal file
|
@ -0,0 +1,66 @@
|
|||
const slugify = require('slugify')
|
||||
const {exec} = require('child_process')
|
||||
const axios = require('axios')
|
||||
|
||||
const BOT_USER_ID = 513
|
||||
const giteaToken = process.env.GITEA_TOKEN
|
||||
const siteId = process.env.NETLIFY_SITE_ID
|
||||
const branchSlug = slugify(process.env.DRONE_SOURCE_BRANCH)
|
||||
const prNumber = process.env.DRONE_PULL_REQUEST
|
||||
|
||||
const prIssueCommentsUrl = `https://kolaente.dev/api/v1/repos/vikunja/frontend/issues/${prNumber}/comments`
|
||||
const alias = `${prNumber}-${branchSlug}`
|
||||
const fullPreviewUrl = `https://${alias}--vikunja-frontend-preview.netlify.app`
|
||||
|
||||
const promiseExec = cmd => {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(cmd, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
resolve(stdout)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
(async function () {
|
||||
let stdout = await promiseExec(`./node_modules/.bin/netlify link --id ${siteId}`)
|
||||
console.log(stdout)
|
||||
stdout = await promiseExec(`./node_modules/.bin/netlify deploy --alias ${alias}`)
|
||||
console.log(stdout)
|
||||
|
||||
const {data} = await axios.get(prIssueCommentsUrl)
|
||||
const hasComment = data.some(c => c.user.id === BOT_USER_ID)
|
||||
|
||||
if (hasComment) {
|
||||
console.log(`PR #${prNumber} already has a comment with a link, not sending another comment.`)
|
||||
return
|
||||
}
|
||||
|
||||
await axios.post(prIssueCommentsUrl, {
|
||||
body: `
|
||||
Hi ${process.env.DRONE_COMMIT_AUTHOR}!
|
||||
|
||||
Thank you for creating a PR!
|
||||
|
||||
I've deployed the changes of this PR on a preview environment under this URL: ${fullPreviewUrl}
|
||||
|
||||
You can use this url to view the changes live and test them out.
|
||||
You will need to manually connect this to an api running somehwere. The easiest to use is https://try.vikunja.io/.
|
||||
|
||||
Have a nice day!
|
||||
|
||||
> Beep boop, I'm a bot.
|
||||
`,
|
||||
}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'accept': 'application/json',
|
||||
'Authorization': `token ${giteaToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Preview comment sent successfully to PR #${prNumber}!`)
|
||||
})()
|
1
scripts/deploy-preview-netlify.js.sha384
Normal file
|
@ -0,0 +1 @@
|
|||
55ce0faaa2c1919341617ccfaeccbb6029ac12107964ff488985cff13dd952f1a991df3ab0d4b0705deb761e508e6434 ./scripts/deploy-preview-netlify.js
|
184
src/App.vue
|
@ -1,116 +1,94 @@
|
|||
<template>
|
||||
<div :class="{'is-touch': isTouch}">
|
||||
<div :class="{'is-hidden': !online}">
|
||||
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
|
||||
<div class="offline" style="height: 0;width: 0;"></div>
|
||||
<top-navigation v-if="authUser"/>
|
||||
<content-auth v-if="authUser"/>
|
||||
<content-link-share v-else-if="authLinkShare"/>
|
||||
<content-no-auth v-else/>
|
||||
<notification/>
|
||||
</div>
|
||||
<div class="app offline" v-if="!online">
|
||||
<div class="offline-message">
|
||||
<h1>You are offline.</h1>
|
||||
<p>Please check your network connection and try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ready>
|
||||
<template v-if="authUser">
|
||||
<top-navigation/>
|
||||
<content-auth/>
|
||||
</template>
|
||||
<content-link-share v-else-if="authLinkShare"/>
|
||||
<no-auth-wrapper v-else>
|
||||
<router-view/>
|
||||
</no-auth-wrapper>
|
||||
<Notification/>
|
||||
|
||||
<transition name="fade">
|
||||
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
|
||||
</transition>
|
||||
</div>
|
||||
</ready>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState, mapGetters} from 'vuex'
|
||||
<script lang="ts" setup>
|
||||
import {computed, watch, Ref} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
import {useStore} from 'vuex'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import isTouchDevice from 'is-touch-device'
|
||||
import {success} from '@/message'
|
||||
|
||||
import Notification from './components/misc/notification'
|
||||
import {KEYBOARD_SHORTCUTS_ACTIVE, ONLINE} from './store/mutation-types'
|
||||
import KeyboardShortcuts from './components/misc/keyboard-shortcuts'
|
||||
import TopNavigation from './components/home/topNavigation'
|
||||
import ContentAuth from './components/home/contentAuth'
|
||||
import ContentLinkShare from './components/home/contentLinkShare'
|
||||
import ContentNoAuth from './components/home/contentNoAuth'
|
||||
import {setLanguage} from './i18n/setup'
|
||||
import Notification from '@/components/misc/notification.vue'
|
||||
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue'
|
||||
import TopNavigation from './components/home/topNavigation.vue'
|
||||
import ContentAuth from './components/home/contentAuth.vue'
|
||||
import ContentLinkShare from './components/home/contentLinkShare.vue'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||
import Ready from '@/components/misc/ready.vue'
|
||||
|
||||
import {setLanguage} from './i18n'
|
||||
import AccountDeleteService from '@/services/accountDelete'
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: {
|
||||
ContentNoAuth,
|
||||
ContentLinkShare,
|
||||
ContentAuth,
|
||||
TopNavigation,
|
||||
KeyboardShortcuts,
|
||||
Notification,
|
||||
},
|
||||
beforeMount() {
|
||||
this.setupOnlineStatus()
|
||||
this.setupPasswortResetRedirect()
|
||||
this.setupEmailVerificationRedirect()
|
||||
this.setupAccountDeletionVerification()
|
||||
},
|
||||
beforeCreate() {
|
||||
this.$store.dispatch('config/update')
|
||||
.then(() => {
|
||||
this.$store.dispatch('auth/checkAuth')
|
||||
})
|
||||
this.$store.dispatch('auth/checkAuth')
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
import {useBodyClass} from '@/composables/useBodyClass'
|
||||
|
||||
setLanguage()
|
||||
},
|
||||
created() {
|
||||
// Make sure to always load the home route when running with electron
|
||||
if (this.$route.fullPath.endsWith('frontend/index.html')) {
|
||||
this.$router.push({name: 'home'})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isTouch() {
|
||||
return isTouchDevice()
|
||||
},
|
||||
...mapState({
|
||||
online: ONLINE,
|
||||
keyboardShortcutsActive: KEYBOARD_SHORTCUTS_ACTIVE,
|
||||
}),
|
||||
...mapGetters('auth', [
|
||||
'authUser',
|
||||
'authLinkShare',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
setupOnlineStatus() {
|
||||
this.$store.commit(ONLINE, navigator.onLine)
|
||||
window.addEventListener('online', () => this.$store.commit(ONLINE, navigator.onLine))
|
||||
window.addEventListener('offline', () => this.$store.commit(ONLINE, navigator.onLine))
|
||||
},
|
||||
setupPasswortResetRedirect() {
|
||||
if (typeof this.$route.query.userPasswordReset !== 'undefined') {
|
||||
localStorage.removeItem('passwordResetToken') // Delete an eventually preexisting old token
|
||||
localStorage.setItem('passwordResetToken', this.$route.query.userPasswordReset)
|
||||
this.$router.push({name: 'user.password-reset.reset'})
|
||||
}
|
||||
},
|
||||
setupEmailVerificationRedirect() {
|
||||
if (typeof this.$route.query.userEmailConfirm !== 'undefined') {
|
||||
localStorage.removeItem('emailConfirmToken') // Delete an eventually preexisting old token
|
||||
localStorage.setItem('emailConfirmToken', this.$route.query.userEmailConfirm)
|
||||
this.$router.push({name: 'user.login'})
|
||||
}
|
||||
},
|
||||
setupAccountDeletionVerification() {
|
||||
if (typeof this.$route.query.accountDeletionConfirm !== 'undefined') {
|
||||
const accountDeletionService = new AccountDeleteService()
|
||||
accountDeletionService.confirm(this.$route.query.accountDeletionConfirm)
|
||||
.then(() => {
|
||||
this.$message.success({message: this.$t('user.deletion.confirmSuccess')})
|
||||
this.$store.dispatch('auth/refreshUserInfo')
|
||||
})
|
||||
.catch(e => this.$message.error(e))
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
useBodyClass('is-touch', isTouchDevice)
|
||||
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
|
||||
|
||||
const authUser = computed(() => store.getters['auth/authUser'])
|
||||
const authLinkShare = computed(() => store.getters['auth/authLinkShare'])
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
// setup account deletion verification
|
||||
const accountDeletionConfirm = useRouteQuery('accountDeletionConfirm') as Ref<null | string>
|
||||
watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
||||
if (accountDeletionConfirm === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const accountDeletionService = new AccountDeleteService()
|
||||
await accountDeletionService.confirm(accountDeletionConfirm)
|
||||
success({message: t('user.deletion.confirmSuccess')})
|
||||
store.dispatch('auth/refreshUserInfo')
|
||||
}, { immediate: true })
|
||||
|
||||
// setup passwort reset redirect
|
||||
const userPasswordReset = useRouteQuery('userPasswordReset') as Ref<null | string>
|
||||
watch(userPasswordReset, (userPasswordReset) => {
|
||||
if (userPasswordReset === null) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('passwordResetToken', userPasswordReset)
|
||||
router.push({name: 'user.password-reset.reset'})
|
||||
}, { immediate: true })
|
||||
|
||||
// setup email verification redirect
|
||||
const userEmailConfirm = useRouteQuery('userEmailConfirm') as Ref<null | string>
|
||||
watch(userEmailConfirm, (userEmailConfirm) => {
|
||||
if (userEmailConfirm === null) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('emailConfirmToken', userEmailConfirm)
|
||||
router.push({name: 'user.login'})
|
||||
}, { immediate: true })
|
||||
|
||||
setLanguage()
|
||||
useColorScheme()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/styles/global.scss';
|
||||
</style>
|
||||
|
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="256" height="256">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 256 256" width="256" height="256">
|
||||
<path d="M2268.2 2512.3a953.7 953.7 0 0 1-50 57c-180.5 189.5-426.2 294-691.6 294A953.7 953.7 0 0 1 847.8 2582a952.7 952.7 0 0 1-281.2-678.8 953.8 953.8 0 0 1 281.2-678.9 953.7 953.7 0 0 1 678.8-281.1 953.7 953.7 0 0 1 678.8 281.1 953.7 953.7 0 0 1 281.2 678.9c0 219.2-78.9 437.2-218.4 609" style="fill:#196aff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1823.7 1650.9c35.7 104.2 94.7 136.1 102 297 2.6 56.5-14.7 236-14.7 236s28 72-25.8 152.3c-83.5 124.3-255.4 132.8-345.7 132.8-90.3 0-260.2-8.5-343.7-132.8C1142 2256 1170 2184 1170 2184s-9.5-92.4-16.7-173.8c-1.7-19.1.1-94.7 2.4-113a453 453 0 0 1 25.8-96.2c14.4-39.6 36.8-79.9 54-120.5 51.8-122.8 8.4-274.9 11.1-407.3 2.2-94-20-189.3-28.7-281.2a960.4 960.4 0 0 1 308.7-50.6 958.6 958.6 0 0 1 344.9 63.6c-20.4 115-44.1 224.2-47.8 265.9-10.6 125.9-41.3 259.4 0 380" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36655635" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
<path d="M1162.9 2383.9c1.1-18.8 3-38 8.3-56.2 1.6-5.7 4-19.7 11.4-21.8 9-2.6 25.9 8.3 32.3 13 12.3 9 23.9 18.5 36.2 27.6 8 6 16.5 10.5 24.3 16.5 8.4 6.6 14.7 14.5 21.7 22.2 8.4 9.4 14.8 19 21.3 29.5 5.1 8.2 37.1 13.5 42.2 21 5.6 8.3 1 18.6 1 28.7 0 74.2 4.4 147.6 6.1 220.3 1.8 50 21.4 109.2-53.4 85.8-160.3-50-158.5-271.3-151.4-386.6M1869.1 2279.7c-1.6 1.8-4.2 3.2-6.3 4.8a208 208 0 0 0-25.1 21.5c-9.4 9.6-19.2 19-28.2 28.9-7.9 8.7-17.3 16.6-25 25.6-5.1 6-10 12.3-14.6 18.5-2.3 3.2-3.5 7-5.3 10.4-2.7 5-40 10.1-36.2 15 6.3 8.3 20.3 15.4 23.7 25 17.2 48.6 24.8 244.5 26.8 294.5 5.4 127.8 117.6-6.3 137.2-57.7 57-149.7 23.2-258.8-46.3-386.6" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:.36633128" transform="matrix(.13333 0 0 -.13333 -75.5 381.8)"/>
|
||||
|
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
BIN
src/assets/no-auth-image.jpg
Normal file
After Width: | Height: | Size: 519 KiB |
118
src/components/base/BaseButton.vue
Normal file
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<component
|
||||
:is="componentNodeName"
|
||||
class="base-button"
|
||||
:class="{ 'base-button--type-button': isButton }"
|
||||
v-bind="elementBindings"
|
||||
:disabled="disabled || undefined"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// see https://v3.vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// this component removes styling differences between links / vue-router links and button elements
|
||||
// by doing so we make it easy abstract the functionality from style and enable easier and semantic
|
||||
// correct button and link usage. Also see: https://css-tricks.com/a-complete-guide-to-links-and-buttons/#accessibility-considerations
|
||||
|
||||
// the component tries to heuristically determine what it should be checking the props (see the
|
||||
// componentNodeName and elementBindings ref for this).
|
||||
|
||||
// NOTE: Do NOT use buttons with @click to push routes. => Use router-links instead!
|
||||
|
||||
import { ref, watchEffect, computed, useAttrs, PropType } from 'vue'
|
||||
|
||||
const BASE_BUTTON_TYPES_MAP = Object.freeze({
|
||||
button: 'button',
|
||||
submit: 'submit',
|
||||
})
|
||||
|
||||
type BaseButtonTypes = keyof typeof BASE_BUTTON_TYPES_MAP
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String as PropType<BaseButtonTypes>,
|
||||
default: 'button',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const componentNodeName = ref<Node['nodeName']>('button')
|
||||
interface ElementBindings {
|
||||
type?: string;
|
||||
rel?: string,
|
||||
}
|
||||
|
||||
const elementBindings = ref({})
|
||||
|
||||
const attrs = useAttrs()
|
||||
watchEffect(() => {
|
||||
// by default this component is a button element with the attribute of the type "button" (default prop value)
|
||||
let nodeName = 'button'
|
||||
let bindings: ElementBindings = {type: props.type}
|
||||
|
||||
// if we find a "to" prop we set it as router-link
|
||||
if ('to' in attrs) {
|
||||
nodeName = 'router-link'
|
||||
bindings = {}
|
||||
}
|
||||
|
||||
// if there is a href we assume the user wants an external link via a link element
|
||||
// we also set the attribute rel to "noopener" but make it possible to overwrite this by the user.
|
||||
if ('href' in attrs) {
|
||||
nodeName = 'a'
|
||||
bindings = {rel: 'noopener'}
|
||||
}
|
||||
|
||||
componentNodeName.value = nodeName
|
||||
elementBindings.value = {
|
||||
...bindings,
|
||||
...attrs,
|
||||
}
|
||||
})
|
||||
|
||||
const isButton = computed(() => componentNodeName.value === 'button')
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// NOTE: we do not use scoped styles to reduce specifity and make it easy to overwrite
|
||||
|
||||
// We reset the default styles of a button element to enable easier styling
|
||||
:where(.base-button--type-button) {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
text-align: center;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
:where(.base-button) {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
user-select: none;
|
||||
pointer-events: auto; // disable possible resets
|
||||
|
||||
&:focus {
|
||||
outline: transparent;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
</style>
|
17
src/components/home/Logo.vue
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import LogoFull from '@/assets/logo-full.svg?component'
|
||||
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
|
||||
|
||||
const Logo = computed(() => new Date().getMonth() === 5 ? LogoFullPride : LogoFull)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Logo alt="Vikunja" class="logo" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
color: var(--logo-text-color);
|
||||
}
|
||||
</style>
|
77
src/components/home/MenuButton.vue
Normal file
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<button
|
||||
type="button"
|
||||
@click="$store.commit('toggleMenu')"
|
||||
class="menu-show-button"
|
||||
@shortkey="() => $store.commit('toggleMenu')"
|
||||
v-shortcut="'Control+e'"
|
||||
:title="$t('keyboardShortcuts.toggleMenu')"
|
||||
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue'
|
||||
import {store} from '@/store'
|
||||
|
||||
const menuActive = computed(() => store.menuActive)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$lineWidth: 2rem;
|
||||
$size: $lineWidth + 1rem;
|
||||
|
||||
.menu-show-button {
|
||||
// FIXME: create general button component
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
|
||||
min-height: $size;
|
||||
width: $size;
|
||||
|
||||
position: relative;
|
||||
|
||||
$transformX: translateX(-50%);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
height: 3px;
|
||||
width: $lineWidth;
|
||||
left: 50%;
|
||||
transform: $transformX;
|
||||
background-color: var(--grey-400);
|
||||
border-radius: 2px;
|
||||
transition: all $transition;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 50%;
|
||||
transform: $transformX translateY(-0.4rem)
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 50%;
|
||||
transform: $transformX translateY(0.4rem)
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
&::before,
|
||||
&::after {
|
||||
background-color: var(--grey-600);
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: $transformX translateY(-0.5rem);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: $transformX translateY(0.5rem)
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
20
src/components/home/PoweredByLink.vue
Normal file
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<a class="menu-bottom-link" :href="poweredByUrl" target="_blank" rel="noreferrer noopener nofollow">
|
||||
{{ $t('misc.poweredBy') }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {POWERED_BY as poweredByUrl} from '@/urls'
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.menu-bottom-link {
|
||||
color: var(--grey-300);
|
||||
text-align: center;
|
||||
display: block;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
</style>
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<a @click="$store.commit('menuActive', false)" class="menu-hide-button" v-if="menuActive">
|
||||
<icon icon="times"></icon>
|
||||
<icon icon="times" />
|
||||
</a>
|
||||
<div
|
||||
:class="{'has-background': background}"
|
||||
|
@ -22,15 +22,16 @@
|
|||
|
||||
<router-view/>
|
||||
|
||||
<transition name="modal">
|
||||
<router-view name="popup"/>
|
||||
</transition>
|
||||
<router-view name="popup" v-slot="{ Component }">
|
||||
<transition name="modal">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
|
||||
<a
|
||||
class="keyboard-shortcuts-button"
|
||||
@click="showKeyboardShortcuts()"
|
||||
@shortkey="showKeyboardShortcuts()"
|
||||
v-shortkey="['?']"
|
||||
v-shortcut="'?'"
|
||||
>
|
||||
<icon icon="keyboard"/>
|
||||
</a>
|
||||
|
@ -39,97 +40,187 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
<script lang="ts" setup>
|
||||
import {watch, computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useEventListener} from '@vueuse/core'
|
||||
|
||||
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
|
||||
import Navigation from '@/components/home/navigation.vue'
|
||||
import QuickActions from '@/components/quick-actions/quick-actions.vue'
|
||||
|
||||
export default {
|
||||
name: 'contentAuth',
|
||||
components: {QuickActions, Navigation},
|
||||
watch: {
|
||||
'$route': {
|
||||
handler: 'doStuffAfterRoute',
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.renewTokenOnFocus()
|
||||
this.loadLabels()
|
||||
},
|
||||
computed: mapState({
|
||||
background: 'background',
|
||||
menuActive: MENU_ACTIVE,
|
||||
userInfo: state => state.auth.info,
|
||||
authenticated: state => state.auth.authenticated,
|
||||
}),
|
||||
methods: {
|
||||
doStuffAfterRoute() {
|
||||
// this.setTitle('') // Reset the title if the page component does not set one itself
|
||||
this.hideMenuOnMobile()
|
||||
this.resetCurrentList()
|
||||
},
|
||||
resetCurrentList() {
|
||||
// Reset the current list highlight in menu if the current list is not list related.
|
||||
if (
|
||||
this.$route.name === 'home' ||
|
||||
this.$route.name === 'namespace.edit' ||
|
||||
this.$route.name === 'teams.index' ||
|
||||
this.$route.name === 'teams.edit' ||
|
||||
this.$route.name === 'tasks.range' ||
|
||||
this.$route.name === 'labels.index' ||
|
||||
this.$route.name === 'migrate.start' ||
|
||||
this.$route.name === 'migrate.wunderlist' ||
|
||||
this.$route.name === 'user.settings' ||
|
||||
this.$route.name === 'namespaces.index'
|
||||
) {
|
||||
this.$store.commit(CURRENT_LIST, null)
|
||||
}
|
||||
},
|
||||
renewTokenOnFocus() {
|
||||
// Try renewing the token every time vikunja is loaded initially
|
||||
// (When opening the browser the focus event is not fired)
|
||||
this.$store.dispatch('auth/renewToken')
|
||||
const store = useStore()
|
||||
|
||||
// Check if the token is still valid if the window gets focus again to maybe renew it
|
||||
window.addEventListener('focus', () => {
|
||||
const background = computed(() => store.state.background)
|
||||
const menuActive = computed(() => store.state.menuActive)
|
||||
|
||||
if (!this.authenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
const expiresIn = (this.userInfo !== null ? this.userInfo.exp : 0) - +new Date() / 1000
|
||||
|
||||
// If the token expiry is negative, it is already expired and we have no choice but to redirect
|
||||
// the user to the login page
|
||||
if (expiresIn < 0) {
|
||||
this.$store.dispatch('auth/checkAuth')
|
||||
this.$router.push({name: 'user.login'})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the token is valid for less than 60 hours and renew if thats the case
|
||||
if (expiresIn < 60 * 3600) {
|
||||
this.$store.dispatch('auth/renewToken')
|
||||
console.debug('renewed token')
|
||||
}
|
||||
})
|
||||
},
|
||||
hideMenuOnMobile() {
|
||||
if (window.innerWidth < 769) {
|
||||
this.$store.commit(MENU_ACTIVE, false)
|
||||
}
|
||||
},
|
||||
showKeyboardShortcuts() {
|
||||
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
|
||||
},
|
||||
loadLabels() {
|
||||
this.$store.dispatch('labels/loadAllLabels')
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
},
|
||||
},
|
||||
function showKeyboardShortcuts() {
|
||||
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// hide menu on mobile
|
||||
watch(() => route.fullPath, () => window.innerWidth < 769 && store.commit(MENU_ACTIVE, false))
|
||||
|
||||
// FIXME: this is really error prone
|
||||
// Reset the current list highlight in menu if the current route is not list related.
|
||||
watch(() => route.name as string, (routeName) => {
|
||||
if (
|
||||
routeName &&
|
||||
(
|
||||
[
|
||||
'home',
|
||||
'namespace.edit',
|
||||
'teams.index',
|
||||
'teams.edit',
|
||||
'tasks.range',
|
||||
'labels.index',
|
||||
'migrate.start',
|
||||
'migrate.wunderlist',
|
||||
'namespaces.index',
|
||||
].includes(routeName) ||
|
||||
routeName.startsWith('user.settings')
|
||||
)
|
||||
) {
|
||||
store.dispatch(CURRENT_LIST, null)
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Reset the title if the page component does not set one itself
|
||||
|
||||
function useRenewTokenOnFocus() {
|
||||
const router = useRouter()
|
||||
|
||||
const userInfo = computed(() => store.state.auth.info)
|
||||
const authenticated = computed(() => store.state.auth.authenticated)
|
||||
|
||||
// Try renewing the token every time vikunja is loaded initially
|
||||
// (When opening the browser the focus event is not fired)
|
||||
store.dispatch('auth/renewToken')
|
||||
|
||||
// Check if the token is still valid if the window gets focus again to maybe renew it
|
||||
useEventListener('focus', () => {
|
||||
if (!authenticated.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
|
||||
|
||||
// If the token expiry is negative, it is already expired and we have no choice but to redirect
|
||||
// the user to the login page
|
||||
if (expiresIn < 0) {
|
||||
store.dispatch('auth/checkAuth')
|
||||
router.push({name: 'user.login'})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the token is valid for less than 60 hours and renew if thats the case
|
||||
if (expiresIn < 60 * 3600) {
|
||||
store.dispatch('auth/renewToken')
|
||||
console.debug('renewed token')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useRenewTokenOnFocus()
|
||||
store.dispatch('labels/loadAllLabels')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.menu-hide-button {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 31;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 2rem;
|
||||
color: var(--grey-400);
|
||||
line-height: 1;
|
||||
transition: all $transition;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
height: 1rem;
|
||||
color: var(--grey-600);
|
||||
}
|
||||
}
|
||||
|
||||
.app-container {
|
||||
min-height: calc(100vh - 65px);
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
padding-top: $navbar-height;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
|
||||
z-index: 2;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: 0;
|
||||
padding-top: 1.5rem;
|
||||
min-height: calc(100vh - 4rem);
|
||||
}
|
||||
|
||||
&.is-menu-enabled {
|
||||
margin-left: $navbar-width;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
min-width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.task\.detail {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(250, 250, 250, 0.8);
|
||||
z-index: 5;
|
||||
opacity: 0;
|
||||
transition: all $transition;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard-shortcuts-button {
|
||||
position: fixed;
|
||||
bottom: calc(1rem - 4px);
|
||||
right: 1rem;
|
||||
z-index: 4500; // The modal has a z-index of 4000
|
||||
|
||||
color: var(--grey-500);
|
||||
transition: color $transition;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<div
|
||||
:class="[background ? 'has-background' : '', $route.name+'-view']"
|
||||
:class="[background ? 'has-background' : '', $route.name as string +'-view']"
|
||||
:style="{'background-image': `url(${background})`}"
|
||||
class="link-share-container"
|
||||
>
|
||||
<div class="container has-text-centered link-share-view">
|
||||
<div class="column is-10 is-offset-1">
|
||||
<img alt="Vikunja" class="logo" :src="logoUrl" />
|
||||
<Logo class="logo" />
|
||||
<h1
|
||||
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
|
||||
class="title">
|
||||
|
@ -14,37 +14,47 @@
|
|||
</h1>
|
||||
<div class="box has-text-left view">
|
||||
<router-view/>
|
||||
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank" rel="noreferrer noopener nofollow">
|
||||
{{ $t('misc.poweredBy') }}
|
||||
</a>
|
||||
<PoweredByLink />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
import {CURRENT_LIST} from '@/store/mutation-types'
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
|
||||
import logoUrl from '@/assets/logo-full.svg'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import PoweredByLink from './PoweredByLink.vue'
|
||||
|
||||
export default {
|
||||
name: 'contentLinkShare',
|
||||
data() {
|
||||
return {
|
||||
logoUrl,
|
||||
}
|
||||
},
|
||||
computed: mapState({
|
||||
currentList: CURRENT_LIST,
|
||||
background: 'background',
|
||||
}),
|
||||
methods: {
|
||||
logout() {
|
||||
this.$store.dispatch('auth/logout')
|
||||
this.$router.push({name: 'user.login'})
|
||||
},
|
||||
},
|
||||
}
|
||||
const store = useStore()
|
||||
const currentList = computed(() => store.state.currentList)
|
||||
const background = computed(() => store.state.background)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.link-share-container.has-background .view {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-width: 300px;
|
||||
width: 90%;
|
||||
margin: 2rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-shadow: 0 0 1rem var(--white);
|
||||
}
|
||||
|
||||
// FIXME: this should be defined somewhere deep
|
||||
.link-share-view .card {
|
||||
background-color: var(--white);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
<template>
|
||||
<div class="no-auth-wrapper">
|
||||
<div class="noauth-container">
|
||||
<img alt="Vikunja" :src="logoUrl" width="400" height="117" />
|
||||
<div class="message is-info" v-if="motd !== ''">
|
||||
<div class="message-header">
|
||||
<p>{{ $t('misc.info') }}</p>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
{{ motd }}
|
||||
</div>
|
||||
</div>
|
||||
<router-view/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
|
||||
import logoUrl from '@/assets/logo-full.svg'
|
||||
|
||||
export default {
|
||||
name: 'contentNoAuth',
|
||||
data() {
|
||||
return {
|
||||
logoUrl,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.redirectToHome()
|
||||
},
|
||||
computed: mapState({
|
||||
motd: state => state.config.motd,
|
||||
}),
|
||||
methods: {
|
||||
redirectToHome() {
|
||||
// Check if the user is already logged in and redirect them to the home page if not
|
||||
if (
|
||||
this.$route.name !== 'user.login' &&
|
||||
this.$route.name !== 'user.password-reset.request' &&
|
||||
this.$route.name !== 'user.password-reset.reset' &&
|
||||
this.$route.name !== 'user.register' &&
|
||||
this.$route.name !== 'link-share.auth' &&
|
||||
this.$route.name !== 'openid.auth' &&
|
||||
localStorage.getItem('passwordResetToken') === null &&
|
||||
localStorage.getItem('emailConfirmToken') === null
|
||||
) {
|
||||
this.$router.push({name: 'user.login'})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -2,7 +2,7 @@
|
|||
<div :class="{'is-active': menuActive}" class="namespace-container">
|
||||
<div class="menu top-menu">
|
||||
<router-link :to="{name: 'home'}" class="logo">
|
||||
<img alt="Vikunja" :src="logoUrl" width="164" height="48"/>
|
||||
<Logo width="164" height="48" />
|
||||
</router-link>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
|
@ -48,20 +48,20 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<aside class="menu namespaces-lists loader-container" :class="{'is-loading': loading}">
|
||||
<template v-for="(n, nk) in namespaces">
|
||||
<div :key="n.id" class="namespace-title" :class="{'has-menu': n.id > 0}">
|
||||
<aside class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
|
||||
<template v-for="(n, nk) in namespaces" :key="n.id" >
|
||||
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
|
||||
<span
|
||||
@click="toggleLists(n.id)"
|
||||
class="menu-label"
|
||||
v-tooltip="getNamespaceTitle(n) + ' (' + n.lists.filter(l => !l.isArchived).length + ')'">
|
||||
v-tooltip="namespaceTitles[nk]">
|
||||
<span class="name">
|
||||
<span
|
||||
:style="{ backgroundColor: n.hexColor }"
|
||||
class="color-bubble"
|
||||
v-if="n.hexColor !== ''">
|
||||
</span>
|
||||
{{ getNamespaceTitle(n) }} ({{ n.lists.filter(l => !l.isArchived).length }})
|
||||
{{ namespaceTitles[nk] }}
|
||||
</span>
|
||||
</span>
|
||||
<a
|
||||
|
@ -74,9 +74,9 @@
|
|||
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
|
||||
</div>
|
||||
<div
|
||||
v-if="listsVisible[n.id] ?? true"
|
||||
:key="n.id + 'child'"
|
||||
class="more-container"
|
||||
v-if="typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true"
|
||||
>
|
||||
<!--
|
||||
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
|
||||
|
@ -84,78 +84,96 @@
|
|||
-->
|
||||
<draggable
|
||||
v-bind="dragOptions"
|
||||
:value="activeLists[nk]"
|
||||
@input="(lists) => updateActiveLists(n, lists)"
|
||||
:modelValue="activeLists[nk]"
|
||||
@update:modelValue="(lists) => updateActiveLists(n, lists)"
|
||||
:group="`namespace-${n.id}-lists`"
|
||||
@start="() => drag = true"
|
||||
@end="e => saveListPosition(e, nk)"
|
||||
handle=".handle"
|
||||
:disabled="n.id < 0"
|
||||
:class="{'dragging-disabled': n.id < 0}"
|
||||
:disabled="n.id < 0 || null"
|
||||
tag="transition-group"
|
||||
item-key="id"
|
||||
:component-data="{
|
||||
type: 'transition',
|
||||
tag: 'ul',
|
||||
name: !drag ? 'flip-list' : null,
|
||||
class: [
|
||||
'menu-list can-be-hidden',
|
||||
{ 'dragging-disabled': n.id < 0 }
|
||||
]
|
||||
}"
|
||||
>
|
||||
<transition-group
|
||||
type="transition"
|
||||
:name="!drag ? 'flip-list' : null"
|
||||
tag="ul"
|
||||
class="menu-list can-be-hidden"
|
||||
>
|
||||
<template #item="{element: l}">
|
||||
<li
|
||||
v-for="l in activeLists[nk]"
|
||||
:key="l.id"
|
||||
class="loader-container"
|
||||
class="loader-container is-loading-small"
|
||||
:class="{'is-loading': listUpdating[l.id]}"
|
||||
>
|
||||
<router-link
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentList.id === l.id}"
|
||||
:to="{ name: 'list.index', params: { listId: l.id} }"
|
||||
tag="span"
|
||||
v-slot="{ href, navigate, isActive }"
|
||||
custom
|
||||
>
|
||||
<span class="icon handle">
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
<span
|
||||
:style="{ backgroundColor: l.hexColor }"
|
||||
class="color-bubble"
|
||||
v-if="l.hexColor !== ''">
|
||||
</span>
|
||||
<span class="list-menu-title">
|
||||
{{ getListTitle(l) }}
|
||||
</span>
|
||||
<span
|
||||
:class="{'is-favorite': l.isFavorite}"
|
||||
@click.stop="toggleFavoriteList(l)"
|
||||
class="favorite">
|
||||
<icon icon="star" v-if="l.isFavorite"/>
|
||||
<icon :icon="['far', 'star']" v-else/>
|
||||
</span>
|
||||
<a
|
||||
@click="navigate"
|
||||
:href="href"
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': isActive || currentList?.id === l.id}"
|
||||
>
|
||||
<span class="icon handle">
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
<span
|
||||
:style="{ backgroundColor: l.hexColor }"
|
||||
class="color-bubble"
|
||||
v-if="l.hexColor !== ''">
|
||||
</span>
|
||||
<span class="list-menu-title">
|
||||
{{ getListTitle(l) }}
|
||||
</span>
|
||||
<span
|
||||
:class="{'is-favorite': l.isFavorite}"
|
||||
@click.prevent.stop="toggleFavoriteList(l)"
|
||||
class="favorite">
|
||||
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']" />
|
||||
</span>
|
||||
</a>
|
||||
</router-link>
|
||||
<list-settings-dropdown :list="l" v-if="l.id > 0"/>
|
||||
<span class="list-setting-spacer" v-else></span>
|
||||
</li>
|
||||
</transition-group>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank" rel="noreferrer noopener nofollow">
|
||||
{{ $t('misc.poweredBy') }}
|
||||
</a>
|
||||
<PoweredByLink />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
import {CURRENT_LIST, MENU_ACTIVE, LOADING, LOADING_MODULE} from '@/store/mutation-types'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import PoweredByLink from '@/components/home/PoweredByLink.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
|
||||
import {CURRENT_LIST, MENU_ACTIVE, LOADING, LOADING_MODULE} from '@/store/mutation-types'
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
|
||||
import logoUrl from '@/assets/logo-full.svg'
|
||||
|
||||
export default {
|
||||
name: 'navigation',
|
||||
|
||||
components: {
|
||||
ListSettingsDropdown,
|
||||
NamespaceSettingsDropdown,
|
||||
draggable,
|
||||
Logo,
|
||||
PoweredByLink,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
listsVisible: {},
|
||||
|
@ -165,14 +183,8 @@ export default {
|
|||
ghostClass: 'ghost',
|
||||
},
|
||||
listUpdating: {},
|
||||
logoUrl,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ListSettingsDropdown,
|
||||
NamespaceSettingsDropdown,
|
||||
draggable,
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
namespaces: state => state.namespaces.namespaces.filter(n => !n.isArchived),
|
||||
|
@ -182,15 +194,22 @@ export default {
|
|||
loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces',
|
||||
}),
|
||||
activeLists() {
|
||||
return this.namespaces.map(({lists}) => lists.filter(item => !item.isArchived))
|
||||
return this.namespaces.map(({lists}) => lists?.filter(item => !item.isArchived))
|
||||
},
|
||||
namespaceTitles() {
|
||||
return this.namespaces.map((namespace, index) => {
|
||||
const title = this.getNamespaceTitle(namespace)
|
||||
return `${title} (${this.activeLists[index]?.length ?? 0})`
|
||||
})
|
||||
},
|
||||
},
|
||||
beforeCreate() {
|
||||
// FIXME: async action in beforeCreate, might be unfinished when component mounts
|
||||
this.$store.dispatch('namespaces/loadNamespaces')
|
||||
.then(namespaces => {
|
||||
namespaces.forEach(n => {
|
||||
if (typeof this.listsVisible[n.id] === 'undefined') {
|
||||
this.$set(this.listsVisible, n.id, true)
|
||||
this.listsVisible[n.id] = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -209,18 +228,13 @@ export default {
|
|||
return
|
||||
}
|
||||
this.$store.dispatch('lists/toggleListFavorite', list)
|
||||
.catch(e => this.$message.error(e))
|
||||
},
|
||||
resize() {
|
||||
// Hide the menu by default on mobile
|
||||
if (window.innerWidth < 770) {
|
||||
this.$store.commit(MENU_ACTIVE, false)
|
||||
} else {
|
||||
this.$store.commit(MENU_ACTIVE, true)
|
||||
}
|
||||
this.$store.commit(MENU_ACTIVE, window.innerWidth >= 770)
|
||||
},
|
||||
toggleLists(namespaceId) {
|
||||
this.$set(this.listsVisible, namespaceId, !this.listsVisible[namespaceId] ?? false)
|
||||
this.listsVisible[namespaceId] = !this.listsVisible[namespaceId]
|
||||
},
|
||||
updateActiveLists(namespace, activeLists) {
|
||||
// this is a bit hacky: since we do have to filter out the archived items from the list
|
||||
|
@ -240,34 +254,309 @@ export default {
|
|||
|
||||
this.$store.commit('namespaces/setNamespaceById', newNamespace)
|
||||
},
|
||||
saveListPosition(e, namespaceIndex) {
|
||||
|
||||
async saveListPosition(e, namespaceIndex) {
|
||||
const listsActive = this.activeLists[namespaceIndex]
|
||||
const list = listsActive[e.newIndex]
|
||||
const listBefore = listsActive[e.newIndex - 1] ?? null
|
||||
const listAfter = listsActive[e.newIndex + 1] ?? null
|
||||
this.$set(this.listUpdating, list.id, true)
|
||||
this.listUpdating[list.id] = true
|
||||
|
||||
const position = calculateItemPosition(listBefore !== null ? listBefore.position : null, listAfter !== null ? listAfter.position : null)
|
||||
|
||||
// create a copy of the list in order to not violate vuex mutations
|
||||
this.$store.dispatch('lists/updateList', {
|
||||
...list,
|
||||
position,
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.$set(this.listUpdating, list.id, false)
|
||||
try {
|
||||
// create a copy of the list in order to not violate vuex mutations
|
||||
await this.$store.dispatch('lists/updateList', {
|
||||
...list,
|
||||
position,
|
||||
})
|
||||
} finally {
|
||||
this.listUpdating[list.id] = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="scss" scoped>
|
||||
$navbar-padding: 2rem;
|
||||
$vikunja-nav-background: var(--site-background);
|
||||
$vikunja-nav-color: var(--grey-700);
|
||||
$vikunja-nav-selected-width: 0.4rem;
|
||||
|
||||
.namespace-container {
|
||||
z-index: 6;
|
||||
background: $vikunja-nav-background;
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 0 1rem;
|
||||
transition: transform $transition-duration ease-in;
|
||||
position: fixed;
|
||||
top: $navbar-height;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
overflow-x: auto;
|
||||
width: $navbar-width;
|
||||
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
top: 0;
|
||||
width: 70vw;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
transform: translateX(0);
|
||||
transition: transform $transition-duration ease-out;
|
||||
}
|
||||
|
||||
.menu {
|
||||
.menu-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
font-family: $vikunja-font;
|
||||
color: $vikunja-nav-color;
|
||||
font-weight: 500;
|
||||
min-height: 2.5rem;
|
||||
padding-top: 0;
|
||||
padding-left: $navbar-padding;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menu-label,
|
||||
.menu-list span.list-menu-link,
|
||||
.menu-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
|
||||
.list-menu-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-bubble {
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
|
||||
.favorite {
|
||||
margin-left: .25rem;
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
&.is-favorite {
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
.color-bubble {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
.is-archived {
|
||||
min-width: 85px;
|
||||
}
|
||||
}
|
||||
|
||||
.namespace-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.menu-label {
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 auto;
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
a:not(.dropdown-item) {
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
padding: .5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-lists-icon {
|
||||
svg {
|
||||
transition: all $transition;
|
||||
transform: rotate(90deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active svg {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .toggle-lists-icon svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:not(.has-menu) .toggle-lists-icon {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-label,
|
||||
.nsettings,
|
||||
.menu-list span.list-menu-link,
|
||||
.menu-list a {
|
||||
color: $vikunja-nav-color;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
opacity: 0;
|
||||
padding: .5rem;
|
||||
cursor: pointer;
|
||||
transition: $transition;
|
||||
}
|
||||
|
||||
&:hover :deep(.dropdown-trigger) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform $transition-duration;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: var(--grey-200);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
span.list-menu-link, li > a {
|
||||
padding: 0.75rem .5rem 0.75rem ($navbar-padding * 1.5 - 1.75rem);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
border-radius: 0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border-left: $vikunja-nav-selected-width solid transparent;
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
vertical-align: middle;
|
||||
padding-right: 0.5rem;
|
||||
|
||||
&.handle {
|
||||
opacity: 0;
|
||||
transition: opacity $transition;
|
||||
margin-right: .25rem;
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .icon.handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: var(--primary);
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
|
||||
.icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
|
||||
padding-left: 2rem;
|
||||
margin-right: 1rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.namespaces-lists {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--grey-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
margin-top: math.div($navbar-padding, 2);
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
font-weight: 500;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
span.list-menu-link, li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-setting-spacer {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.namespaces-list.loader-container.is-loading {
|
||||
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,24 +5,11 @@
|
|||
class="navbar main-theme is-fixed-top"
|
||||
role="navigation"
|
||||
>
|
||||
<div class="navbar-brand">
|
||||
<router-link :to="{name: 'home'}" class="navbar-item logo">
|
||||
<img width="164" height="48" alt="Vikunja" :src="logoUrl" />
|
||||
</router-link>
|
||||
<a
|
||||
@click="$store.commit('toggleMenu')"
|
||||
class="menu-show-button"
|
||||
@shortkey="() => $store.commit('toggleMenu')"
|
||||
v-shortkey="['ctrl', 'e']"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
@click="$store.commit('toggleMenu')"
|
||||
class="menu-show-button"
|
||||
>
|
||||
</a>
|
||||
<div class="list-title" ref="listTitle" :style="{'display': currentList.id ? '': 'none'}">
|
||||
<router-link :to="{name: 'home'}" class="logo-link">
|
||||
<Logo width="164" height="48"/>
|
||||
</router-link>
|
||||
<MenuButton class="menu-button"/>
|
||||
<div class="list-title" ref="listTitle" v-show="currentList.id">
|
||||
<template v-if="currentList.id">
|
||||
<h1
|
||||
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
|
||||
|
@ -39,8 +26,8 @@
|
|||
<a
|
||||
@click="openQuickActions"
|
||||
class="trigger-button pr-0"
|
||||
@shortkey="openQuickActions"
|
||||
v-shortkey="['ctrl', 'k']"
|
||||
v-shortcut="'Control+k'"
|
||||
:title="$t('keyboardShortcuts.quickSearch')"
|
||||
>
|
||||
<icon icon="search"/>
|
||||
</a>
|
||||
|
@ -50,7 +37,7 @@
|
|||
<dropdown class="is-right" ref="usernameDropdown">
|
||||
<template #trigger>
|
||||
<x-button
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
:shadow="false">
|
||||
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
|
||||
<span class="icon is-small">
|
||||
|
@ -101,9 +88,8 @@ import Update from '@/components/home/update.vue'
|
|||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import Notifications from '@/components/notifications/notifications.vue'
|
||||
|
||||
import logoUrl from '@/assets/logo-full.svg'
|
||||
import logoFullPrideUrl from '@/assets/logo-full-pride.svg'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
|
||||
export default {
|
||||
name: 'topNavigation',
|
||||
|
@ -112,11 +98,10 @@ export default {
|
|||
Dropdown,
|
||||
ListSettingsDropdown,
|
||||
Update,
|
||||
Logo,
|
||||
MenuButton,
|
||||
},
|
||||
computed: {
|
||||
logoUrl() {
|
||||
return (new Date()).getMonth() === 5 ? logoFullPrideUrl : logoUrl
|
||||
},
|
||||
...mapState({
|
||||
userInfo: state => state.auth.info,
|
||||
userAvatar: state => state.auth.avatarUrl,
|
||||
|
@ -149,3 +134,160 @@ export default {
|
|||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$vikunja-nav-logo-full-width: 164px;
|
||||
$user-dropdown-width-mobile: 5rem;
|
||||
|
||||
$hamburger-menu-icon-spacing: 1rem;
|
||||
$hamburger-menu-icon-width: 28px;
|
||||
|
||||
.navbar {
|
||||
z-index: 4 !important;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 2rem;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
align-self: stretch;
|
||||
margin-right: auto;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: $hamburger-menu-icon-spacing;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar.main-theme {
|
||||
background: var(--site-background);
|
||||
z-index: 5 !important;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.navbar-end {
|
||||
margin-left: 0;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
.user {
|
||||
width: $user-dropdown-width-mobile;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
line-height: 1;
|
||||
|
||||
.button {
|
||||
padding: 0 0.25rem;
|
||||
height: 1rem;
|
||||
|
||||
.icon {
|
||||
width: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
|
||||
:deep() {
|
||||
.trigger-button {
|
||||
cursor: pointer;
|
||||
color: var(--grey-400);
|
||||
padding: .5rem;
|
||||
font-size: 1.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
> * > .trigger-button {
|
||||
width: $navbar-icon-width;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger .button) {
|
||||
background: none;
|
||||
|
||||
&:focus:not(:active), &:active {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
$edit-icon-width: 1rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
// We need a fixed width for overflowing ellipsis to work
|
||||
--nav-username-width: 0;
|
||||
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width} - #{$vikunja-nav-logo-full-width} - var(--nav-username-width));
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
// We need a fixed width for overflowing ellipsis to work
|
||||
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width});
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
color: var(--grey-400);
|
||||
margin-left: 1rem;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -47,3 +47,42 @@ export default {
|
|||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.update-notification {
|
||||
margin: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $warning;
|
||||
padding: 0 0 0 .5rem;
|
||||
border-radius: $radius;
|
||||
font-size: .9rem;
|
||||
color: var(--grey-900);
|
||||
justify-content: space-between;
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
margin: 0;
|
||||
width: 450px;
|
||||
left: calc(50vw - 225px);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> * + * {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
12
src/components/input/AsyncEditor.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { defineAsyncComponent } from 'vue'
|
||||
import ErrorComponent from '@/components/misc/error.vue'
|
||||
import LoadingComponent from '@/components/misc/loading.vue'
|
||||
|
||||
const Editor = () => import('@/components/input/editor.vue')
|
||||
|
||||
export default defineAsyncComponent({
|
||||
loader: Editor,
|
||||
loadingComponent: LoadingComponent,
|
||||
errorComponent: ErrorComponent,
|
||||
timeout: 60000,
|
||||
})
|
|
@ -1,74 +1,103 @@
|
|||
<template>
|
||||
<a
|
||||
<BaseButton
|
||||
class="button"
|
||||
:class="{
|
||||
'is-loading': loading,
|
||||
'has-no-shadow': !shadow,
|
||||
'is-primary': type === 'primary',
|
||||
'is-outlined': type === 'secondary',
|
||||
'is-text is-inverted has-no-shadow underline-none':
|
||||
type === 'tertary',
|
||||
}"
|
||||
:disabled="disabled || null"
|
||||
@click="click"
|
||||
:href="href !== '' ? href : null"
|
||||
:class="[
|
||||
variantClass,
|
||||
{
|
||||
'is-loading': loading,
|
||||
'has-no-shadow': !shadow || variant === 'tertiary',
|
||||
}
|
||||
]"
|
||||
>
|
||||
<icon :icon="icon" v-if="showIconOnly"/>
|
||||
<span class="icon is-small" v-else-if="icon !== ''">
|
||||
<icon :icon="icon"/>
|
||||
</span>
|
||||
<slot></slot>
|
||||
</a>
|
||||
<slot />
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'x-button',
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
to: {
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
showIconOnly() {
|
||||
return this.icon !== '' && typeof this.$slots.default === 'undefined'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
click(e) {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.to !== false) {
|
||||
this.$router.push(this.to)
|
||||
}
|
||||
|
||||
this.$emit('click', e)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useSlots, PropType} from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const BUTTON_TYPES_MAP = Object.freeze({
|
||||
primary: 'is-primary',
|
||||
secondary: 'is-outlined',
|
||||
tertiary: 'is-text is-inverted underline-none',
|
||||
})
|
||||
|
||||
type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String as PropType<ButtonTypes>,
|
||||
default: 'primary',
|
||||
},
|
||||
icon: {
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const variantClass = computed(() => BUTTON_TYPES_MAP[props.variant])
|
||||
|
||||
const slots = useSlots()
|
||||
const showIconOnly = computed(() => props.icon !== '' && typeof slots.default === 'undefined')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.button {
|
||||
transition: all $transition;
|
||||
border: 0;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
height: $button-height;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: inline-flex;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&.fullheight {
|
||||
padding-right: 7px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.is-active,
|
||||
&.is-focused,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus:not(:active) {
|
||||
box-shadow: var(--shadow-xs) !important;
|
||||
}
|
||||
|
||||
&.is-primary.is-outlined:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.is-small {
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.underline-none {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
</style>
|
|
@ -1,31 +1,50 @@
|
|||
<template>
|
||||
<div class="color-picker-container">
|
||||
<verte
|
||||
:showHistory="true"
|
||||
:colorHistory="[
|
||||
'#1973ff',
|
||||
'#7F23FF',
|
||||
'#ff4136',
|
||||
'#ff851b',
|
||||
'#ffeb10',
|
||||
'#00db60',
|
||||
]"
|
||||
:enableAlpha="false"
|
||||
:menuPosition="menuPosition"
|
||||
:rgbSliders="true"
|
||||
model="hex"
|
||||
picker="square"
|
||||
v-model="color"
|
||||
:class="{'is-empty': empty}"
|
||||
/>
|
||||
<x-button @click="reset" class="is-small ml-2" :shadow="false" type="secondary">
|
||||
<datalist :id="colorListID">
|
||||
<option v-for="color in defaultColors" :key="color" :value="color" />
|
||||
</datalist>
|
||||
|
||||
<div class="picker">
|
||||
<input
|
||||
class="picker__input"
|
||||
type="color"
|
||||
v-model="color"
|
||||
:list="colorListID"
|
||||
:class="{'is-empty': isEmpty}"
|
||||
/>
|
||||
<svg class="picker__pattern" v-show="isEmpty" viewBox="0 0 22 22" fill="fff">
|
||||
<pattern id="checker" width="11" height="11" patternUnits="userSpaceOnUse" fill="FFF">
|
||||
<rect fill="#cccccc" x="0" width="5.5" height="5.5" y="0"></rect>
|
||||
<rect fill="#cccccc" x="5.5" width="5.5" height="5.5" y="5.5"></rect>
|
||||
</pattern>
|
||||
<rect width="22" height="22" fill="url(#checker)"></rect>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
v-if="!isEmpty"
|
||||
:disabled="isEmpty"
|
||||
@click="reset"
|
||||
class="is-small ml-2"
|
||||
:shadow="false"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('input.resetColor') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import verte from 'verte'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'#1973ff',
|
||||
'#7F23FF',
|
||||
'#ff4136',
|
||||
'#ff851b',
|
||||
'#ffeb10',
|
||||
'#00db60',
|
||||
]
|
||||
|
||||
export default {
|
||||
name: 'colorPicker',
|
||||
|
@ -33,13 +52,12 @@ export default {
|
|||
return {
|
||||
color: '',
|
||||
lastChangeTimeout: null,
|
||||
defaultColors: DEFAULT_COLORS,
|
||||
colorListID: createRandomID(),
|
||||
}
|
||||
},
|
||||
components: {
|
||||
verte,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
menuPosition: {
|
||||
|
@ -47,10 +65,11 @@ export default {
|
|||
default: 'top',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
watch: {
|
||||
value: {
|
||||
handler(value) {
|
||||
this.color = value
|
||||
modelValue: {
|
||||
handler(modelValue) {
|
||||
this.color = modelValue
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
|
@ -59,14 +78,14 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
empty() {
|
||||
isEmpty() {
|
||||
return this.color === '#000000' || this.color === ''
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
update(force = false) {
|
||||
|
||||
if(this.empty && !force) {
|
||||
if(this.isEmpty && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -75,7 +94,7 @@ export default {
|
|||
}
|
||||
|
||||
this.lastChangeTimeout = setTimeout(() => {
|
||||
this.$emit('input', this.color)
|
||||
this.$emit('update:modelValue', this.color)
|
||||
this.$emit('change')
|
||||
}, 500)
|
||||
},
|
||||
|
@ -89,16 +108,53 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'verte/dist/verte.css';
|
||||
<style lang="scss" scoped>
|
||||
.color-picker-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.verte.is-empty {
|
||||
.verte__icon {
|
||||
opacity: 0;
|
||||
// reset / see https://stackoverflow.com/a/11471224/15522256
|
||||
input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
border: none;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.verte__guide {
|
||||
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAGklEQVQYlWM4c+bMf3TMgA0MBYWDzDkUKQQAlHCpV9ycHeMAAAAASUVORK5CYII=);
|
||||
$PICKER_SIZE: 24px;
|
||||
$BORDER_WIDTH: 1px;
|
||||
.picker {
|
||||
display: grid;
|
||||
width: $PICKER_SIZE;
|
||||
height: $PICKER_SIZE;
|
||||
overflow: hidden;
|
||||
border-radius: 100%;
|
||||
border: $BORDER_WIDTH solid var(--grey-300);
|
||||
box-shadow: $shadow;
|
||||
|
||||
& > * {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input.picker__input {
|
||||
padding: 0;
|
||||
width: $PICKER_SIZE - 2 * $BORDER_WIDTH;
|
||||
height: $PICKER_SIZE - 2 * $BORDER_WIDTH;
|
||||
}
|
||||
|
||||
.picker__input.is-empty {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.picker__pattern {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -101,6 +101,7 @@
|
|||
class="is-fullwidth"
|
||||
:shadow="false"
|
||||
@click="close"
|
||||
v-cy="'closeDatepicker'"
|
||||
>
|
||||
{{ $t('misc.confirm') }}
|
||||
</x-button>
|
||||
|
@ -112,6 +113,7 @@
|
|||
<script>
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
import {format} from 'date-fns'
|
||||
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
|
||||
|
@ -136,13 +138,13 @@ export default {
|
|||
flatPickr,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
|
||||
},
|
||||
chooseDateLabel: {
|
||||
type: String,
|
||||
default() {
|
||||
return this.$t('input.datepicker.chooseDate')
|
||||
return i18n.global.t('input.datepicker.chooseDate')
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
|
@ -150,14 +152,15 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'change', 'close', 'close-on-change'],
|
||||
mounted() {
|
||||
document.addEventListener('click', this.hideDatePopup)
|
||||
},
|
||||
beforeDestroy() {
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('click', this.hideDatePopup)
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
modelValue: {
|
||||
handler: 'setDateValue',
|
||||
immediate: true,
|
||||
},
|
||||
|
@ -191,7 +194,7 @@ export default {
|
|||
},
|
||||
updateData() {
|
||||
this.changed = true
|
||||
this.$emit('input', this.date)
|
||||
this.$emit('update:modelValue', this.date)
|
||||
this.$emit('change', this.date)
|
||||
},
|
||||
toggleDatePopup() {
|
||||
|
@ -241,3 +244,74 @@ export default {
|
|||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker {
|
||||
input.input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.disabled a {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.datepicker-popup {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
width: 320px;
|
||||
background: var(--white);
|
||||
border-radius: $radius;
|
||||
box-shadow: $shadow;
|
||||
|
||||
@media screen and (max-width: ($tablet)) {
|
||||
width: calc(100vw - 5rem);
|
||||
}
|
||||
|
||||
a:not(.button) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 .5rem;
|
||||
width: 100%;
|
||||
height: 2.25rem;
|
||||
color: var(--text);
|
||||
transition: all $transition;
|
||||
|
||||
&:first-child {
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--light);
|
||||
}
|
||||
|
||||
.text {
|
||||
width: 100%;
|
||||
font-size: .85rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-right: .25rem;
|
||||
|
||||
.weekday {
|
||||
color: var(--text-light);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
a.button {
|
||||
margin: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
:deep(.flatpickr-calendar) {
|
||||
margin: 0 auto 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -5,7 +5,7 @@
|
|||
<vue-easymde
|
||||
:configs="config"
|
||||
@change="bubble"
|
||||
@input="handleInput"
|
||||
@update:modelValue="handleInput"
|
||||
class="content"
|
||||
v-if="isEditActive"
|
||||
v-model="text"/>
|
||||
|
@ -21,12 +21,10 @@
|
|||
</p>
|
||||
|
||||
<ul class="actions" v-if="bottomActions.length > 0">
|
||||
<template v-if="isEditEnabled && !showPreviewText && showSave">
|
||||
<li>
|
||||
<a v-if="showEditButton" @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
|
||||
<a v-else-if="isEditActive" @click="toggleEdit" class="done-edit">{{ $t('misc.save') }}</a>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="isEditEnabled && !showPreviewText && showSave">
|
||||
<a v-if="showEditButton" @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
|
||||
<a v-else-if="isEditActive" @click="toggleEdit" class="done-edit">{{ $t('misc.save') }}</a>
|
||||
</li>
|
||||
<li v-for="(action, k) in bottomActions" :key="k">
|
||||
<a @click="action.action">{{ action.title }}</a>
|
||||
</li>
|
||||
|
@ -37,7 +35,7 @@
|
|||
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<x-button v-else-if="isEditActive" @click="toggleEdit" type="secondary" :shadow="false">
|
||||
<x-button v-else-if="isEditActive" @click="toggleEdit" variant="secondary" :shadow="false" v-cy="'saveEditor'">
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</template>
|
||||
|
@ -45,15 +43,17 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import VueEasymde from 'vue-easymde'
|
||||
import EasyMDE from 'easymde'
|
||||
import marked from 'marked'
|
||||
import VueEasymde from './vue-easymde/vue-easymde.vue'
|
||||
import {marked} from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
|
||||
import {createEasyMDEConfig} from './editorConfig'
|
||||
|
||||
import AttachmentModel from '../../models/attachment'
|
||||
import AttachmentService from '../../services/attachment'
|
||||
import {findCheckboxesInText} from '../../helpers/checklistFromText'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
export default {
|
||||
name: 'editor',
|
||||
|
@ -61,7 +61,7 @@ export default {
|
|||
VueEasymde,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
@ -71,9 +71,7 @@ export default {
|
|||
},
|
||||
uploadEnabled: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
default: false,
|
||||
},
|
||||
uploadCallback: {
|
||||
type: Function,
|
||||
|
@ -94,13 +92,14 @@ export default {
|
|||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: () => '',
|
||||
default: '',
|
||||
},
|
||||
showSave: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
computed: {
|
||||
showPreviewText() {
|
||||
return this.isPreviewActive && this.text === '' && this.emptyText !== ''
|
||||
|
@ -119,154 +118,30 @@ export default {
|
|||
preview: '',
|
||||
attachmentService: null,
|
||||
loadedAttachments: {},
|
||||
|
||||
config: {
|
||||
autoDownloadFontAwesome: false,
|
||||
spellChecker: false,
|
||||
config: createEasyMDEConfig({
|
||||
placeholder: this.placeholder,
|
||||
uploadImage: this.uploadEnabled,
|
||||
imageUploadFunction: this.uploadCallback,
|
||||
minHeight: '150px',
|
||||
toolbar: [
|
||||
{
|
||||
name: 'heading-1',
|
||||
action: EasyMDE.toggleHeading1,
|
||||
title: this.$t('input.editor.heading1'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'heading-2',
|
||||
action: EasyMDE.toggleHeading2,
|
||||
title: this.$t('input.editor.heading2'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'heading-3',
|
||||
action: EasyMDE.toggleHeading3,
|
||||
title: this.$t('input.editor.heading3'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'heading-smaller',
|
||||
action: EasyMDE.toggleHeadingSmaller,
|
||||
title: this.$t('input.editor.headingSmaller'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'heading-bigger',
|
||||
action: EasyMDE.toggleHeadingBigger,
|
||||
title: this.$t('input.editor.headingBigger'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'bold',
|
||||
action: EasyMDE.toggleBold,
|
||||
title: this.$t('input.editor.bold'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 3H6.5H15.25C18.15 3 20.5 5.36 20.5 8.25C20.5 9.8 19.81 11.19 18.73 12.15C20.37 13.04 21.5 14.76 21.5 16.75C21.5 19.64 19.15 22 16.25 22H6.5H3.5C2.95 22 2.5 21.55 2.5 21C2.5 20.45 2.95 20 3.5 20H5.5V5H3.5C2.95 5 2.5 4.55 2.5 4C2.5 3.45 2.95 3 3.5 3ZM7.5 20H16.25C18.04 20 19.5 18.54 19.5 16.75C19.5 14.96 18.04 13.5 16.25 13.5H7.5V20ZM7.5 11.5H15.25C17.04 11.5 18.5 10.04 18.5 8.25C18.5 6.46 17.04 5 15.25 5H7.5V11.5Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
action: EasyMDE.toggleItalic,
|
||||
title: this.$t('input.editor.italic'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M14.0967 4.2H17.0001C17.3301 4.2 17.6001 3.93 17.6001 3.6C17.6001 3.27 17.3301 3 17.0001 3H10.2001C9.8701 3 9.6001 3.27 9.6001 3.6C9.6001 3.93 9.8701 4.2 10.2001 4.2H12.8748L9.90335 19.8H6.9999C6.6699 19.8 6.3999 20.07 6.3999 20.4C6.3999 20.73 6.6699 21 6.9999 21H13.7999C14.1299 21 14.3999 20.73 14.3999 20.4C14.3999 20.07 14.1299 19.8 13.7999 19.8H11.1253L14.0967 4.2Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'strikethrough',
|
||||
action: EasyMDE.toggleStrikethrough,
|
||||
title: this.$t('input.editor.strikethrough'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.25 7.17005C18.25 7.50005 17.98 7.77005 17.65 7.77005C17.32 7.77005 17.05 7.50005 17.05 7.17005V5.96005C15.97 5.12005 14.17 4.56005 12.79 4.31005C11.1 4.00005 9.51 4.30005 8.41 5.12005C7.2 6.03005 6.67 7.67005 7.19 8.88005C7.56 9.73005 8.37 10.31 8.98 10.64C9.57215 10.9644 10.1961 11.2013 10.8465 11.3999H20.4C20.73 11.3999 21 11.6699 21 11.9999C21 12.3299 20.73 12.5999 20.4 12.5999H15.3012C16.6583 13.0929 17.5255 13.7765 17.95 14.69C18.73 16.36 17.74 18.33 16.36 19.41C15.05 20.4401 13.35 21 11.54 21H11.16C9.78 20.9401 8.34 20.5301 6.95 19.85V20.3601C6.95 20.6901 6.68 20.96 6.35 20.96C6.02 20.96 5.75 20.6901 5.75 20.3601V17.36C5.75 17.03 6.02 16.76 6.35 16.76C6.68 16.76 6.95 17.03 6.95 17.36V18.5C8.35 19.2801 9.81 19.74 11.21 19.8C12.86 19.89 14.46 19.39 15.62 18.48C16.6 17.71 17.37 16.3 16.86 15.21C16.55 14.54 15.8 14.0201 14.58 13.63C13.9711 13.4331 13.3222 13.2762 12.6906 13.1235C12.6168 13.1056 12.5432 13.0878 12.47 13.07C12.4313 13.0607 12.3925 13.0514 12.3537 13.0421C11.7861 12.9055 11.2108 12.767 10.6413 12.5999H3.6C3.27 12.5999 3 12.3299 3 11.9999C3 11.6699 3.27 11.3999 3.6 11.3999H7.90288C7.04984 10.8343 6.42752 10.1363 6.09 9.36005C5.34 7.63005 6.03 5.40005 7.69 4.16005C9.05 3.15005 10.99 2.77005 13 3.13005C13.64 3.25005 15.53 3.66005 17.05 4.53005V4.17005C17.05 3.84005 17.32 3.57005 17.65 3.57005C17.98 3.57005 18.25 3.84005 18.25 4.17005V7.17005Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
action: EasyMDE.toggleCodeBlock,
|
||||
title: this.$t('input.editor.code'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.57 20.9601C8.64 20.9901 8.71 21.0001 8.78 21.0001C9.02 21.0001 9.24 20.8501 9.34 20.6101L15.79 3.81005C15.9 3.50005 15.75 3.15005 15.44 3.03005C15.14 2.92005 14.79 3.07005 14.67 3.38005L8.22 20.1801C8.11 20.4901 8.26 20.8401 8.57 20.9601ZM7.00007 18.0001C6.85007 18.0001 6.69007 17.9401 6.58007 17.8201L1.18007 12.4201C0.950068 12.1901 0.950068 11.8101 1.18007 11.5701L6.58007 6.17006C6.81007 5.94006 7.19007 5.94006 7.43007 6.17006C7.66007 6.40006 7.66007 6.78006 7.43007 7.02006L2.45007 12.0001L7.43007 16.9801C7.66007 17.2101 7.66007 17.5901 7.43007 17.8301C7.31007 17.9401 7.15007 18.0001 7.00007 18.0001ZM17 18.0001C16.85 18.0001 16.69 17.9401 16.58 17.8201C16.35 17.5901 16.35 17.2101 16.58 16.9701L21.55 12.0001L16.57 7.02006C16.34 6.79006 16.34 6.41006 16.57 6.17006C16.81 5.94006 17.19 5.94006 17.42 6.17006L22.82 11.5701C23.05 11.8001 23.05 12.1801 22.82 12.4201L17.42 17.8201C17.31 17.9401 17.15 18.0001 17 18.0001Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'quote',
|
||||
action: EasyMDE.toggleBlockquote,
|
||||
title: this.$t('input.editor.quote'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.373 5.16357H5.62695C4.79102 5.16357 4.11133 5.84326 4.11133 6.6792V16.2095C4.11133 17.0464 4.79102 17.7261 5.62695 17.7261H6.8877V21.1245C6.8877 21.3667 7.0332 21.5854 7.25684 21.6782C7.33203 21.7095 7.41016 21.7241 7.4873 21.7241C7.64258 21.7241 7.7959 21.6636 7.91113 21.5493L11.748 17.7261H19.373C20.209 17.7261 20.8887 17.0464 20.8887 16.2095V6.6792C20.8887 5.84326 20.209 5.16357 19.373 5.16357ZM19.6895 16.2095C19.6895 16.3843 19.5469 16.5269 19.373 16.5269H11.5C11.3408 16.5269 11.1895 16.5894 11.0762 16.7017L8.08691 19.6802V17.1265C8.08691 16.7954 7.81836 16.5269 7.4873 16.5269H5.62695C5.45312 16.5269 5.31055 16.3843 5.31055 16.2095V6.6792C5.31055 6.50537 5.45312 6.36279 5.62695 6.36279H19.373C19.5469 6.36279 19.6895 6.50537 19.6895 6.6792V16.2095ZM10.3431 8.45264C9.46326 8.45264 8.75 9.16589 8.75 10.0458C8.75 10.9257 9.46326 11.639 10.3431 11.639C10.4775 11.639 10.6058 11.6173 10.7305 11.5861V11.6195C10.7305 12.1322 10.3135 12.5492 9.75586 12.5492C9.4248 12.5492 9.17871 12.8177 9.17871 13.1488C9.17871 13.4799 9.46973 13.7484 9.80078 13.7484C10.9746 13.7484 11.9297 12.7933 11.9297 11.6195V10.1176L11.9294 10.1165L11.9292 10.1155C11.9297 10.1049 11.9312 10.0946 11.9326 10.0843L11.9326 10.0843C11.9345 10.0716 11.9363 10.059 11.9363 10.0458C11.9363 9.16589 11.223 8.45264 10.3431 8.45264ZM13.0637 10.0458C13.0637 9.16589 13.7771 8.45264 14.657 8.45264C15.5369 8.45264 16.2501 9.16589 16.2501 10.0458C16.2501 10.0584 16.2484 10.0706 16.2466 10.0828C16.2452 10.0929 16.2437 10.103 16.2433 10.1134C16.2433 10.1149 16.2441 10.1161 16.2441 10.1176V11.6195C16.2441 12.7933 15.2891 13.7484 14.1152 13.7484C13.7842 13.7484 13.4922 13.4799 13.4922 13.1488C13.4922 12.8177 13.7383 12.5492 14.0693 12.5492C14.6279 12.5492 15.0449 12.1322 15.0449 11.6195V11.5858C14.9202 11.6173 14.7915 11.639 14.657 11.639C13.7771 11.639 13.0637 10.9257 13.0637 10.0458Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'unordered-list',
|
||||
action: EasyMDE.toggleUnorderedList,
|
||||
title: this.$t('input.editor.unorderedList'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.5281 3.7002H3.5281C3.1981 3.7002 2.9281 3.9702 2.9281 4.3002V7.3002C2.9281 7.6302 3.1981 7.9002 3.5281 7.9002H6.5281C6.8581 7.9002 7.1281 7.6302 7.1281 7.3002V4.3002C7.1281 3.9702 6.8581 3.7002 6.5281 3.7002ZM5.9281 6.7002H4.1281V4.9002H5.9281V6.7002ZM3.5281 9.90015H6.5281C6.8581 9.90015 7.1281 10.1701 7.1281 10.5001V13.5001C7.1281 13.8301 6.8581 14.1001 6.5281 14.1001H3.5281C3.1981 14.1001 2.9281 13.8301 2.9281 13.5001V10.5001C2.9281 10.1701 3.1981 9.90015 3.5281 9.90015ZM4.1281 12.9001H5.9281V11.1001H4.1281V12.9001ZM3.5281 16.1001H6.5281C6.8581 16.1001 7.1281 16.3701 7.1281 16.7001V19.7001C7.1281 20.0301 6.8581 20.3001 6.5281 20.3001H3.5281C3.1981 20.3001 2.9281 20.0301 2.9281 19.7001V16.7001C2.9281 16.3701 3.1981 16.1001 3.5281 16.1001ZM4.1281 19.1001H5.9281V17.3001H4.1281V19.1001ZM9.72817 6.4002H20.7282C21.0582 6.4002 21.3282 6.1302 21.3282 5.8002C21.3282 5.4702 21.0582 5.2002 20.7282 5.2002H9.72817C9.39817 5.2002 9.12817 5.4702 9.12817 5.8002C9.12817 6.1302 9.39817 6.4002 9.72817 6.4002ZM9.72817 11.4001H20.7282C21.0582 11.4001 21.3282 11.6701 21.3282 12.0001C21.3282 12.3301 21.0582 12.6001 20.7282 12.6001H9.72817C9.39817 12.6001 9.12817 12.3301 9.12817 12.0001C9.12817 11.6701 9.39817 11.4001 9.72817 11.4001ZM9.72817 17.6001H20.7282C21.0582 17.6001 21.3282 17.8701 21.3282 18.2001C21.3282 18.5301 21.0582 18.8001 20.7282 18.8001H9.72817C9.39817 18.8001 9.12817 18.5301 9.12817 18.2001C9.12817 17.8701 9.39817 17.6001 9.72817 17.6001Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'ordered-list',
|
||||
action: EasyMDE.toggleOrderedList,
|
||||
title: this.$t('input.editor.orderedList'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4.19494 8.29994H5.99494C6.26494 8.29994 6.49494 8.07995 6.49494 7.79994C6.49494 7.51995 6.27494 7.29994 5.99494 7.29994H5.59494V3.79994C5.59494 3.62994 5.50494 3.46994 5.36494 3.37994C5.22494 3.28994 5.04494 3.26994 4.89494 3.33994L3.89494 3.76994C3.64494 3.87994 3.52494 4.17994 3.63494 4.42994C3.74494 4.67994 4.03494 4.79994 4.29494 4.68994L4.59494 4.55994V7.29994H4.19494C3.91494 7.29994 3.69494 7.51995 3.69494 7.79994C3.69494 8.07995 3.91494 8.29994 4.19494 8.29994ZM20.195 6.39995H9.19497C8.86497 6.39995 8.59497 6.12995 8.59497 5.79995C8.59497 5.46995 8.86497 5.19995 9.19497 5.19995H20.195C20.525 5.19995 20.795 5.46995 20.795 5.79995C20.795 6.12995 20.525 6.39995 20.195 6.39995ZM3.78486 14.36H6.37486C6.65486 14.36 6.87486 14.14 6.87486 13.86C6.87486 13.58 6.65486 13.36 6.37486 13.36H4.88486C5.00486 13.23 5.12486 13.09 5.23486 12.95C5.26626 12.9151 5.29645 12.8802 5.32626 12.8458L5.32629 12.8457C5.38192 12.7814 5.43627 12.7186 5.49486 12.66C5.73486 12.4 5.98486 12.12 6.17486 11.79C6.47486 11.25 6.41486 10.63 6.01486 10.17C5.57486 9.66 4.86486 9.5 4.24486 9.74C3.74486 9.95 3.39486 10.35 3.22486 10.91C3.14486 11.18 3.29486 11.46 3.56486 11.54C3.82486 11.61 4.10486 11.46 4.18486 11.2C4.29486 10.85 4.48486 10.73 4.62486 10.67C4.88486 10.57 5.13486 10.68 5.26486 10.82C5.38486 10.96 5.40486 11.12 5.30486 11.29C5.17595 11.5202 4.99618 11.7165 4.80458 11.9257L4.75486 11.98C4.67298 12.0801 4.58283 12.1801 4.49946 12.2727L4.49945 12.2727L4.47486 12.3C4.12486 12.72 3.76486 13.13 3.40486 13.53C3.27486 13.68 3.23486 13.9 3.32486 14.07C3.41486 14.24 3.58486 14.36 3.78486 14.36ZM3.68486 20.3699C4.04486 20.5899 4.46486 20.6999 4.87486 20.6999C5.13486 20.6999 5.38486 20.6499 5.61486 20.5499C6.31486 20.2799 6.73486 19.5599 6.60486 18.8799C6.53486 18.5499 6.35486 18.2899 6.12486 18.0899C6.32486 17.8999 6.45486 17.6499 6.50486 17.3799C6.57486 17.0099 6.49486 16.6299 6.27486 16.3099C5.85486 15.6899 5.07486 15.5199 4.10486 15.8299C3.83486 15.9199 3.69486 16.1999 3.77486 16.4599C3.86486 16.7299 4.14486 16.8699 4.40486 16.7899C4.70486 16.6999 5.24486 16.5799 5.45486 16.8899C5.51486 16.9899 5.54486 17.0999 5.52486 17.1999C5.51486 17.2699 5.47486 17.3599 5.36486 17.4299C5.26486 17.4999 5.12486 17.5399 4.95486 17.5799L4.77486 17.6299C4.54486 17.6999 4.40486 17.9099 4.41486 18.1499C4.42486 18.3899 4.61486 18.5799 4.84486 18.6099C5.20486 18.6599 5.58486 18.8299 5.63486 19.0799C5.67486 19.2999 5.46486 19.5499 5.25486 19.6299C4.94486 19.7599 4.52486 19.7099 4.21486 19.5199C3.97486 19.3699 3.67486 19.4399 3.52486 19.6799C3.37486 19.9199 3.44486 20.2299 3.68486 20.3699ZM20.195 18.7999H9.19497C8.86497 18.7999 8.59497 18.5299 8.59497 18.1999C8.59497 17.8699 8.86497 17.5999 9.19497 17.5999H20.195C20.525 17.5999 20.795 17.8699 20.795 18.1999C20.795 18.5299 20.525 18.7999 20.195 18.7999ZM9.19497 12.5999H20.195C20.525 12.5999 20.795 12.3299 20.795 11.9999C20.795 11.6699 20.525 11.3999 20.195 11.3999H9.19497C8.86497 11.3999 8.59497 11.6699 8.59497 11.9999C8.59497 12.3299 8.86497 12.5999 9.19497 12.5999Z"/></svg>',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'clean-block',
|
||||
action: EasyMDE.cleanBlock,
|
||||
title: this.$t('input.editor.cleanBlock'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9.25989 6.18384H20.4513C20.7823 6.18384 21.0509 6.45239 21.0509 6.78345V17.9749C21.0509 18.3059 20.7823 18.5745 20.4513 18.5745H9.25989C9.0929 18.5745 8.93469 18.5061 8.82043 18.384L3.6095 12.7883C3.39563 12.5579 3.39563 12.2004 3.6095 11.97L8.82043 6.37427C8.93469 6.2522 9.0929 6.18384 9.25989 6.18384ZM9.52063 17.3752H19.8517V7.38306H9.52063L4.86926 12.3792L9.52063 17.3752ZM12.7755 15.0686C12.6222 15.0686 12.4679 15.01 12.3517 14.8928C12.1173 14.6584 12.1173 14.2786 12.3517 14.0452L14.0503 12.3469L12.3517 10.6487C12.1173 10.4153 12.1173 10.0354 12.3517 9.80103C12.5841 9.56665 12.965 9.56665 13.1993 9.80103L14.8981 11.4994L16.5968 9.80103C16.8312 9.56665 17.212 9.56665 17.4445 9.80103C17.6788 10.0354 17.6788 10.4153 17.4445 10.6487L15.7458 12.3469L17.4445 14.0452C17.6788 14.2786 17.6788 14.6584 17.4445 14.8928C17.3282 15.01 17.174 15.0686 17.0206 15.0686C16.8673 15.0686 16.714 15.01 16.5968 14.8928L14.8981 13.1945L13.1993 14.8928C13.0822 15.01 12.9288 15.0686 12.7755 15.0686Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
action: EasyMDE.drawLink,
|
||||
title: this.$t('input.editor.link'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11.4399 15.3452C11.4999 15.3652 11.5699 15.3752 11.6299 15.3752C11.8799 15.3752 12.1199 15.2152 12.1999 14.9652C12.2999 14.6452 12.1299 14.3052 11.8199 14.2052C11.3499 14.0452 10.9299 13.7852 10.5699 13.4152C10.1999 13.0552 9.9399 12.6452 9.7799 12.1552C9.6599 11.8252 9.5999 11.4652 9.5999 11.0952C9.5999 10.2152 9.9399 9.38518 10.5699 8.75518L15.1599 4.15518C16.4499 2.87518 18.5399 2.87518 19.8299 4.15518C20.4499 4.78518 20.7899 5.61518 20.7899 6.49518C20.7899 7.37518 20.4499 8.20518 19.8299 8.82518L16.7399 11.9052C16.5099 12.1452 16.5099 12.5252 16.7399 12.7552C16.9799 12.9852 17.3599 12.9852 17.5899 12.7552L20.6799 9.67518C21.5299 8.83518 21.9999 7.69518 21.9999 6.49518C21.9999 5.29518 21.5299 4.16518 20.6899 3.30518C18.9299 1.55518 16.0799 1.55518 14.3199 3.30518L9.7299 7.90518C8.8699 8.75518 8.3999 9.88518 8.3999 11.0952C8.3999 11.6152 8.4899 12.1152 8.6499 12.5552C8.8599 13.1952 9.2399 13.7952 9.7199 14.2652C10.1999 14.7552 10.7999 15.1352 11.4399 15.3452ZM3.32 20.6851C4.2 21.5551 5.35 21.9951 6.5 21.9951C7.65 21.9951 8.81 21.5551 9.69 20.7051L14.28 16.1051C15.14 15.2551 15.61 14.1251 15.61 12.9151C15.61 12.4551 15.54 11.9951 15.4 11.5551C15.17 10.8651 14.8 10.2551 14.28 9.73509C13.76 9.21509 13.15 8.84509 12.46 8.61509C12.14 8.51509 11.8 8.68509 11.7 8.99509C11.6 9.30509 11.77 9.64509 12.1 9.75509C12.61 9.91509 13.06 10.1951 13.44 10.5751C13.82 10.9551 14.09 11.4051 14.26 11.9151C14.36 12.2351 14.41 12.5651 14.41 12.9051C14.41 13.7951 14.06 14.6251 13.43 15.2451L8.84 19.8451C7.55 21.1251 5.46 21.1251 4.17 19.8451C3.55 19.2151 3.21 18.3951 3.21 17.5051C3.21 16.6151 3.55 15.7851 4.17 15.1651L7.35 11.9851C7.58 11.7451 7.59 11.3651 7.35 11.1351C7.11 10.9051 6.73 10.9051 6.5 11.1351L3.32 14.3151C2.47 15.1551 2 16.2851 2 17.4951C2 18.7051 2.47 19.8351 3.32 20.6851Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
action: EasyMDE.drawImage,
|
||||
title: this.$t('input.editor.image'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4C2.89543 4 2 4.89543 2 6V16V17.5152V18C2 19.1046 2.89543 20 4 20H20C21.0528 20 21.9156 19.1866 21.9942 18.1539L22 18.1632V18V16V6C22 4.89543 21.1046 4 20 4H4ZM3.2 17.7V16.5642L6.78192 13.7254C6.8616 13.6622 6.97597 13.6689 7.04776 13.7409L10.3126 17.0146C10.7026 17.4056 11.3357 17.4065 11.7268 17.0165C11.7606 16.9827 11.792 16.9465 11.8207 16.9083L16.736 10.352C16.8023 10.2636 16.9277 10.2457 17.016 10.312C17.0355 10.3265 17.0521 10.3445 17.0651 10.365L20.8 16.2669V17.7C20.8 18.3075 20.3075 18.8 19.7 18.8H4.3C3.69249 18.8 3.2 18.3075 3.2 17.7ZM17.3865 8.61836L20.8 14.08V6.3C20.8 5.69249 20.3075 5.2 19.7 5.2H4.3C3.69249 5.2 3.2 5.69249 3.2 6.3V15.04L6.65054 12.2796C6.84949 12.1204 7.13629 12.1363 7.31645 12.3164L10.8369 15.8369C10.915 15.915 11.0417 15.915 11.1198 15.8369C11.1265 15.8302 16.5625 8.58336 16.5625 8.58336C16.7282 8.36245 17.0416 8.31768 17.2625 8.48336C17.3118 8.52034 17.3538 8.56611 17.3865 8.61836ZM8 8.5C8 9.32843 7.32843 10 6.5 10C5.67157 10 5 9.32843 5 8.5C5 7.67157 5.67157 7 6.5 7C7.32843 7 8 7.67157 8 8.5Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'table',
|
||||
action: EasyMDE.drawTable,
|
||||
title: this.$t('input.editor.table'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.18524 3.08496H19.4152C20.6752 3.08496 21.7152 4.11496 21.7152 5.38496V18.615C21.7152 19.885 20.6852 20.915 19.4152 20.915H6.18524C4.91524 20.915 3.88525 19.885 3.88525 18.615V5.38496C3.88525 4.11496 4.91524 3.08496 6.18524 3.08496ZM19.4052 19.705C20.0152 19.705 20.5052 19.215 20.5052 18.605H20.5153V5.38496C20.5153 4.77496 20.0252 4.28496 19.4152 4.28496H6.18524C5.57524 4.28496 5.08521 4.77496 5.08521 5.38496V18.605C5.08521 19.215 5.57524 19.705 6.18524 19.705H19.4052ZM17.4453 9.15503H8.15527C7.82527 9.15503 7.5553 9.42503 7.5553 9.75503C7.5553 10.085 7.82527 10.355 8.15527 10.355H17.4453C17.7753 10.355 18.0453 10.085 18.0453 9.75503C18.0453 9.42503 17.7753 9.15503 17.4453 9.15503ZM17.4453 13.635H8.15527C7.82527 13.635 7.5553 13.905 7.5553 14.235C7.5553 14.565 7.82527 14.835 8.15527 14.835H17.4453C17.7753 14.835 18.0453 14.565 18.0453 14.235C18.0453 13.905 17.7753 13.635 17.4453 13.635Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'horizontal-rule',
|
||||
action: EasyMDE.drawHorizontalRule,
|
||||
title: this.$t('input.editor.horizontalRule'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M21 13H3C2.45 13 2 12.55 2 12C2 11.45 2.45 11 3 11H21C21.55 11 22 11.45 22 12C22 12.55 21.55 13 21 13Z"/></svg>',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'side-by-side',
|
||||
action: EasyMDE.toggleSideBySide,
|
||||
title: this.$t('input.editor.sideBySide'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.4787 14.58C18.3587 14.69 18.2987 14.85 18.2987 15C18.2987 15.15 18.3587 15.31 18.4787 15.42C18.7187 15.65 19.0987 15.65 19.3287 15.42L22.3287 12.42C22.5587 12.18 22.5587 11.8 22.3287 11.57L19.3287 8.56996C19.0887 8.33996 18.7087 8.33996 18.4787 8.56996C18.2487 8.80996 18.2487 9.18996 18.4787 9.41996L20.451 11.3999L14.4487 11.3999L14.4487 4.6C14.4487 4.27 14.1787 4 13.8487 4C13.5187 4 13.2487 4.27 13.2487 4.6L13.2487 19.4C13.2487 19.73 13.5187 20 13.8487 20C14.1787 20 14.4487 19.73 14.4487 19.4L14.4487 12.5999L20.4511 12.5999L18.4787 14.58ZM9.54878 19.4L9.54878 12.5999L3.5486 12.5999L5.52867 14.58C5.75867 14.81 5.75867 15.19 5.52867 15.43C5.29867 15.66 4.91867 15.66 4.67867 15.43L1.67867 12.43C1.63274 12.384 1.5956 12.3323 1.56725 12.2774C1.53058 12.2077 1.50724 12.1299 1.50068 12.0477C1.49934 12.0317 1.49867 12.0158 1.49867 12C1.49867 11.9841 1.49933 11.9682 1.50067 11.9522C1.51454 11.778 1.60365 11.6242 1.73526 11.5234L4.67867 8.57997C4.90867 8.34997 5.28867 8.34997 5.52867 8.57997C5.75867 8.80997 5.75867 9.18997 5.52867 9.42997L3.55107 11.3999L9.54878 11.3999L9.54878 4.6C9.54878 4.27 9.81878 4 10.1488 4C10.4788 4 10.7488 4.27 10.7488 4.6L10.7488 11.9999L10.7488 19.4C10.7488 19.73 10.4788 20 10.1488 20C9.81878 20 9.54878 19.73 9.54878 19.4Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'guide',
|
||||
action: () => {
|
||||
window.open('https://www.markdownguide.org/basic-syntax/', '_blank')
|
||||
},
|
||||
title: this.$t('input.editor.guide'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.4999 2.3999H6.4999C5.0699 2.3999 3.8999 3.5699 3.8999 4.9999V18.9999C3.8999 20.4299 5.0699 21.5999 6.4999 21.5999H19.4999C19.8299 21.5999 20.0999 21.3299 20.0999 20.9999V16.9999V2.9999C20.0999 2.6699 19.8299 2.3999 19.4999 2.3999ZM5.0999 4.9999V16.8118C5.50468 16.5513 5.98546 16.3999 6.4999 16.3999H18.8999V3.5999H6.4999C5.7299 3.5999 5.0999 4.2299 5.0999 4.9999ZM6.4999 17.5999H18.8999V20.3999H6.4999C5.7299 20.3999 5.0999 19.7699 5.0999 18.9999C5.0999 18.2299 5.7299 17.5999 6.4999 17.5999ZM8.4999 8.5999H15.4999C15.8299 8.5999 16.0999 8.3299 16.0999 7.9999C16.0999 7.6699 15.8299 7.3999 15.4999 7.3999H8.4999C8.1699 7.3999 7.8999 7.6699 7.8999 7.9999C7.8999 8.3299 8.1699 8.5999 8.4999 8.5999ZM15.4999 11.3999H8.4999C8.1699 11.3999 7.8999 11.6699 7.8999 11.9999C7.8999 12.3299 8.1699 12.5999 8.4999 12.5999H15.4999C15.8299 12.5999 16.0999 12.3299 16.0999 11.9999C16.0999 11.6699 15.8299 11.3999 15.4999 11.3999Z"/></svg>',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
checkboxId: createRandomID(),
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.text = newVal
|
||||
modelValue(modelValue) {
|
||||
this.text = modelValue
|
||||
this.$nextTick(this.renderPreview)
|
||||
},
|
||||
text(newVal, oldVal) {
|
||||
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
|
||||
if (oldVal === '' && this.text === this.value) {
|
||||
if (oldVal === '' && this.text === this.modelValue) {
|
||||
return
|
||||
}
|
||||
this.bubble()
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.value !== '') {
|
||||
this.text = this.value
|
||||
if (this.modelValue !== '') {
|
||||
this.text = this.modelValue
|
||||
}
|
||||
|
||||
if (this.previewIsDefault && this.hasPreview) {
|
||||
|
@ -299,7 +174,7 @@ export default {
|
|||
}
|
||||
|
||||
this.changeTimeout = setTimeout(() => {
|
||||
this.$emit('input', this.text)
|
||||
this.$emit('update:modelValue',this.text)
|
||||
this.$emit('change', this.text)
|
||||
}, timeout)
|
||||
},
|
||||
|
@ -335,7 +210,7 @@ export default {
|
|||
}
|
||||
|
||||
checkboxNum++
|
||||
return `<input type="checkbox" data-checkbox-num="${checkboxNum}" ${checked} class="text-checkbox-${this._uid}"/>`
|
||||
return `<input type="checkbox" data-checkbox-num="${checkboxNum}" ${checked} class="text-checkbox-${this.checkboxId}"/>`
|
||||
},
|
||||
link: (href, title, text) => {
|
||||
const isLocal = href.startsWith(`${location.protocol}//${location.hostname}`)
|
||||
|
@ -358,7 +233,7 @@ export default {
|
|||
// dom tree. If we're calling this right after setting this.preview it could be the images were
|
||||
// not already made available.
|
||||
// Some docs at https://stackoverflow.com/q/62865160/10924593
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(async () => {
|
||||
const attachmentImage = document.getElementsByClassName('attachment-image')
|
||||
if (attachmentImage) {
|
||||
for (const img of attachmentImage) {
|
||||
|
@ -379,15 +254,13 @@ export default {
|
|||
this.attachmentService = new AttachmentService()
|
||||
}
|
||||
|
||||
this.attachmentService.getBlobUrl(attachment)
|
||||
.then(url => {
|
||||
img.src = url
|
||||
this.loadedAttachments[cacheKey] = url
|
||||
})
|
||||
const url = await this.attachmentService.getBlobUrl(attachment)
|
||||
img.src = url
|
||||
this.loadedAttachments[cacheKey] = url
|
||||
}
|
||||
}
|
||||
|
||||
const textCheckbox = document.getElementsByClassName(`text-checkbox-${this._uid}`)
|
||||
const textCheckbox = document.getElementsByClassName(`text-checkbox-${this.checkboxId}`)
|
||||
if (textCheckbox) {
|
||||
for (const check of textCheckbox) {
|
||||
check.removeEventListener('change', this.handleCheckboxClick)
|
||||
|
@ -435,8 +308,11 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../node_modules/highlight.js/scss/base16/equilibrium-gray-light';
|
||||
@import '../../../node_modules/easymde/dist/easymde.min.css';
|
||||
@import 'codemirror/lib/codemirror.css';
|
||||
@import './vue-easymde/vue-easymde.css';
|
||||
@import 'highlight.js/scss/base16/equilibrium-gray-light';
|
||||
|
||||
$editor-border-color: #ddd;
|
||||
|
||||
.editor {
|
||||
.clear {
|
||||
|
@ -462,15 +338,20 @@ export default {
|
|||
.CodeMirror {
|
||||
padding: .5rem;
|
||||
border: 1px solid $editor-border-color;
|
||||
background: var(--white);
|
||||
|
||||
&-lines pre {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
&-placeholder {
|
||||
color: $grey-400 !important;
|
||||
color: var(--grey-400) !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&-cursor {
|
||||
border-color: var(--grey-700);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
|
@ -497,7 +378,7 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
margin-left: -3px;
|
||||
|
@ -507,7 +388,7 @@ export default {
|
|||
|
||||
pre.CodeMirror-line {
|
||||
margin-bottom: 0 !important;
|
||||
color: $grey-700 !important;
|
||||
color: var(--grey-700) !important;
|
||||
}
|
||||
|
||||
.cm-header {
|
||||
|
@ -522,7 +403,7 @@ ul.actions {
|
|||
li {
|
||||
display: inline-block;
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
content: '·';
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
@ -533,10 +414,10 @@ ul.actions {
|
|||
}
|
||||
|
||||
&, a {
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
|
||||
&.done-edit {
|
||||
color: $primary;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
134
src/components/input/editorConfig.js
Normal file
|
@ -0,0 +1,134 @@
|
|||
import EasyMDE from 'easymde'
|
||||
import {i18n} from '@/i18n'
|
||||
|
||||
export function createEasyMDEConfig({ placeholder, uploadImage, imageUploadFunction }) {
|
||||
return {
|
||||
autoDownloadFontAwesome: false,
|
||||
spellChecker: false,
|
||||
placeholder,
|
||||
uploadImage,
|
||||
imageUploadFunction,
|
||||
minHeight: '150px',
|
||||
toolbar: [
|
||||
{
|
||||
name: 'heading-1',
|
||||
action: EasyMDE.toggleHeading1,
|
||||
title: i18n.global.t('input.editor.heading1'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'heading-2',
|
||||
action: EasyMDE.toggleHeading2,
|
||||
title: i18n.global.t('input.editor.heading2'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'heading-3',
|
||||
action: EasyMDE.toggleHeading3,
|
||||
title: i18n.global.t('input.editor.heading3'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'heading-smaller',
|
||||
action: EasyMDE.toggleHeadingSmaller,
|
||||
title: i18n.global.t('input.editor.headingSmaller'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'heading-bigger',
|
||||
action: EasyMDE.toggleHeadingBigger,
|
||||
title: i18n.global.t('input.editor.headingBigger'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'bold',
|
||||
action: EasyMDE.toggleBold,
|
||||
title: i18n.global.t('input.editor.bold'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 3H6.5H15.25C18.15 3 20.5 5.36 20.5 8.25C20.5 9.8 19.81 11.19 18.73 12.15C20.37 13.04 21.5 14.76 21.5 16.75C21.5 19.64 19.15 22 16.25 22H6.5H3.5C2.95 22 2.5 21.55 2.5 21C2.5 20.45 2.95 20 3.5 20H5.5V5H3.5C2.95 5 2.5 4.55 2.5 4C2.5 3.45 2.95 3 3.5 3ZM7.5 20H16.25C18.04 20 19.5 18.54 19.5 16.75C19.5 14.96 18.04 13.5 16.25 13.5H7.5V20ZM7.5 11.5H15.25C17.04 11.5 18.5 10.04 18.5 8.25C18.5 6.46 17.04 5 15.25 5H7.5V11.5Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
action: EasyMDE.toggleItalic,
|
||||
title: i18n.global.t('input.editor.italic'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M14.0967 4.2H17.0001C17.3301 4.2 17.6001 3.93 17.6001 3.6C17.6001 3.27 17.3301 3 17.0001 3H10.2001C9.8701 3 9.6001 3.27 9.6001 3.6C9.6001 3.93 9.8701 4.2 10.2001 4.2H12.8748L9.90335 19.8H6.9999C6.6699 19.8 6.3999 20.07 6.3999 20.4C6.3999 20.73 6.6699 21 6.9999 21H13.7999C14.1299 21 14.3999 20.73 14.3999 20.4C14.3999 20.07 14.1299 19.8 13.7999 19.8H11.1253L14.0967 4.2Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'strikethrough',
|
||||
action: EasyMDE.toggleStrikethrough,
|
||||
title: i18n.global.t('input.editor.strikethrough'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.25 7.17005C18.25 7.50005 17.98 7.77005 17.65 7.77005C17.32 7.77005 17.05 7.50005 17.05 7.17005V5.96005C15.97 5.12005 14.17 4.56005 12.79 4.31005C11.1 4.00005 9.51 4.30005 8.41 5.12005C7.2 6.03005 6.67 7.67005 7.19 8.88005C7.56 9.73005 8.37 10.31 8.98 10.64C9.57215 10.9644 10.1961 11.2013 10.8465 11.3999H20.4C20.73 11.3999 21 11.6699 21 11.9999C21 12.3299 20.73 12.5999 20.4 12.5999H15.3012C16.6583 13.0929 17.5255 13.7765 17.95 14.69C18.73 16.36 17.74 18.33 16.36 19.41C15.05 20.4401 13.35 21 11.54 21H11.16C9.78 20.9401 8.34 20.5301 6.95 19.85V20.3601C6.95 20.6901 6.68 20.96 6.35 20.96C6.02 20.96 5.75 20.6901 5.75 20.3601V17.36C5.75 17.03 6.02 16.76 6.35 16.76C6.68 16.76 6.95 17.03 6.95 17.36V18.5C8.35 19.2801 9.81 19.74 11.21 19.8C12.86 19.89 14.46 19.39 15.62 18.48C16.6 17.71 17.37 16.3 16.86 15.21C16.55 14.54 15.8 14.0201 14.58 13.63C13.9711 13.4331 13.3222 13.2762 12.6906 13.1235C12.6168 13.1056 12.5432 13.0878 12.47 13.07C12.4313 13.0607 12.3925 13.0514 12.3537 13.0421C11.7861 12.9055 11.2108 12.767 10.6413 12.5999H3.6C3.27 12.5999 3 12.3299 3 11.9999C3 11.6699 3.27 11.3999 3.6 11.3999H7.90288C7.04984 10.8343 6.42752 10.1363 6.09 9.36005C5.34 7.63005 6.03 5.40005 7.69 4.16005C9.05 3.15005 10.99 2.77005 13 3.13005C13.64 3.25005 15.53 3.66005 17.05 4.53005V4.17005C17.05 3.84005 17.32 3.57005 17.65 3.57005C17.98 3.57005 18.25 3.84005 18.25 4.17005V7.17005Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
action: EasyMDE.toggleCodeBlock,
|
||||
title: i18n.global.t('input.editor.code'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.57 20.9601C8.64 20.9901 8.71 21.0001 8.78 21.0001C9.02 21.0001 9.24 20.8501 9.34 20.6101L15.79 3.81005C15.9 3.50005 15.75 3.15005 15.44 3.03005C15.14 2.92005 14.79 3.07005 14.67 3.38005L8.22 20.1801C8.11 20.4901 8.26 20.8401 8.57 20.9601ZM7.00007 18.0001C6.85007 18.0001 6.69007 17.9401 6.58007 17.8201L1.18007 12.4201C0.950068 12.1901 0.950068 11.8101 1.18007 11.5701L6.58007 6.17006C6.81007 5.94006 7.19007 5.94006 7.43007 6.17006C7.66007 6.40006 7.66007 6.78006 7.43007 7.02006L2.45007 12.0001L7.43007 16.9801C7.66007 17.2101 7.66007 17.5901 7.43007 17.8301C7.31007 17.9401 7.15007 18.0001 7.00007 18.0001ZM17 18.0001C16.85 18.0001 16.69 17.9401 16.58 17.8201C16.35 17.5901 16.35 17.2101 16.58 16.9701L21.55 12.0001L16.57 7.02006C16.34 6.79006 16.34 6.41006 16.57 6.17006C16.81 5.94006 17.19 5.94006 17.42 6.17006L22.82 11.5701C23.05 11.8001 23.05 12.1801 22.82 12.4201L17.42 17.8201C17.31 17.9401 17.15 18.0001 17 18.0001Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'quote',
|
||||
action: EasyMDE.toggleBlockquote,
|
||||
title: i18n.global.t('input.editor.quote'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.373 5.16357H5.62695C4.79102 5.16357 4.11133 5.84326 4.11133 6.6792V16.2095C4.11133 17.0464 4.79102 17.7261 5.62695 17.7261H6.8877V21.1245C6.8877 21.3667 7.0332 21.5854 7.25684 21.6782C7.33203 21.7095 7.41016 21.7241 7.4873 21.7241C7.64258 21.7241 7.7959 21.6636 7.91113 21.5493L11.748 17.7261H19.373C20.209 17.7261 20.8887 17.0464 20.8887 16.2095V6.6792C20.8887 5.84326 20.209 5.16357 19.373 5.16357ZM19.6895 16.2095C19.6895 16.3843 19.5469 16.5269 19.373 16.5269H11.5C11.3408 16.5269 11.1895 16.5894 11.0762 16.7017L8.08691 19.6802V17.1265C8.08691 16.7954 7.81836 16.5269 7.4873 16.5269H5.62695C5.45312 16.5269 5.31055 16.3843 5.31055 16.2095V6.6792C5.31055 6.50537 5.45312 6.36279 5.62695 6.36279H19.373C19.5469 6.36279 19.6895 6.50537 19.6895 6.6792V16.2095ZM10.3431 8.45264C9.46326 8.45264 8.75 9.16589 8.75 10.0458C8.75 10.9257 9.46326 11.639 10.3431 11.639C10.4775 11.639 10.6058 11.6173 10.7305 11.5861V11.6195C10.7305 12.1322 10.3135 12.5492 9.75586 12.5492C9.4248 12.5492 9.17871 12.8177 9.17871 13.1488C9.17871 13.4799 9.46973 13.7484 9.80078 13.7484C10.9746 13.7484 11.9297 12.7933 11.9297 11.6195V10.1176L11.9294 10.1165L11.9292 10.1155C11.9297 10.1049 11.9312 10.0946 11.9326 10.0843L11.9326 10.0843C11.9345 10.0716 11.9363 10.059 11.9363 10.0458C11.9363 9.16589 11.223 8.45264 10.3431 8.45264ZM13.0637 10.0458C13.0637 9.16589 13.7771 8.45264 14.657 8.45264C15.5369 8.45264 16.2501 9.16589 16.2501 10.0458C16.2501 10.0584 16.2484 10.0706 16.2466 10.0828C16.2452 10.0929 16.2437 10.103 16.2433 10.1134C16.2433 10.1149 16.2441 10.1161 16.2441 10.1176V11.6195C16.2441 12.7933 15.2891 13.7484 14.1152 13.7484C13.7842 13.7484 13.4922 13.4799 13.4922 13.1488C13.4922 12.8177 13.7383 12.5492 14.0693 12.5492C14.6279 12.5492 15.0449 12.1322 15.0449 11.6195V11.5858C14.9202 11.6173 14.7915 11.639 14.657 11.639C13.7771 11.639 13.0637 10.9257 13.0637 10.0458Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'unordered-list',
|
||||
action: EasyMDE.toggleUnorderedList,
|
||||
title: i18n.global.t('input.editor.unorderedList'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.5281 3.7002H3.5281C3.1981 3.7002 2.9281 3.9702 2.9281 4.3002V7.3002C2.9281 7.6302 3.1981 7.9002 3.5281 7.9002H6.5281C6.8581 7.9002 7.1281 7.6302 7.1281 7.3002V4.3002C7.1281 3.9702 6.8581 3.7002 6.5281 3.7002ZM5.9281 6.7002H4.1281V4.9002H5.9281V6.7002ZM3.5281 9.90015H6.5281C6.8581 9.90015 7.1281 10.1701 7.1281 10.5001V13.5001C7.1281 13.8301 6.8581 14.1001 6.5281 14.1001H3.5281C3.1981 14.1001 2.9281 13.8301 2.9281 13.5001V10.5001C2.9281 10.1701 3.1981 9.90015 3.5281 9.90015ZM4.1281 12.9001H5.9281V11.1001H4.1281V12.9001ZM3.5281 16.1001H6.5281C6.8581 16.1001 7.1281 16.3701 7.1281 16.7001V19.7001C7.1281 20.0301 6.8581 20.3001 6.5281 20.3001H3.5281C3.1981 20.3001 2.9281 20.0301 2.9281 19.7001V16.7001C2.9281 16.3701 3.1981 16.1001 3.5281 16.1001ZM4.1281 19.1001H5.9281V17.3001H4.1281V19.1001ZM9.72817 6.4002H20.7282C21.0582 6.4002 21.3282 6.1302 21.3282 5.8002C21.3282 5.4702 21.0582 5.2002 20.7282 5.2002H9.72817C9.39817 5.2002 9.12817 5.4702 9.12817 5.8002C9.12817 6.1302 9.39817 6.4002 9.72817 6.4002ZM9.72817 11.4001H20.7282C21.0582 11.4001 21.3282 11.6701 21.3282 12.0001C21.3282 12.3301 21.0582 12.6001 20.7282 12.6001H9.72817C9.39817 12.6001 9.12817 12.3301 9.12817 12.0001C9.12817 11.6701 9.39817 11.4001 9.72817 11.4001ZM9.72817 17.6001H20.7282C21.0582 17.6001 21.3282 17.8701 21.3282 18.2001C21.3282 18.5301 21.0582 18.8001 20.7282 18.8001H9.72817C9.39817 18.8001 9.12817 18.5301 9.12817 18.2001C9.12817 17.8701 9.39817 17.6001 9.72817 17.6001Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'ordered-list',
|
||||
action: EasyMDE.toggleOrderedList,
|
||||
title: i18n.global.t('input.editor.orderedList'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4.19494 8.29994H5.99494C6.26494 8.29994 6.49494 8.07995 6.49494 7.79994C6.49494 7.51995 6.27494 7.29994 5.99494 7.29994H5.59494V3.79994C5.59494 3.62994 5.50494 3.46994 5.36494 3.37994C5.22494 3.28994 5.04494 3.26994 4.89494 3.33994L3.89494 3.76994C3.64494 3.87994 3.52494 4.17994 3.63494 4.42994C3.74494 4.67994 4.03494 4.79994 4.29494 4.68994L4.59494 4.55994V7.29994H4.19494C3.91494 7.29994 3.69494 7.51995 3.69494 7.79994C3.69494 8.07995 3.91494 8.29994 4.19494 8.29994ZM20.195 6.39995H9.19497C8.86497 6.39995 8.59497 6.12995 8.59497 5.79995C8.59497 5.46995 8.86497 5.19995 9.19497 5.19995H20.195C20.525 5.19995 20.795 5.46995 20.795 5.79995C20.795 6.12995 20.525 6.39995 20.195 6.39995ZM3.78486 14.36H6.37486C6.65486 14.36 6.87486 14.14 6.87486 13.86C6.87486 13.58 6.65486 13.36 6.37486 13.36H4.88486C5.00486 13.23 5.12486 13.09 5.23486 12.95C5.26626 12.9151 5.29645 12.8802 5.32626 12.8458L5.32629 12.8457C5.38192 12.7814 5.43627 12.7186 5.49486 12.66C5.73486 12.4 5.98486 12.12 6.17486 11.79C6.47486 11.25 6.41486 10.63 6.01486 10.17C5.57486 9.66 4.86486 9.5 4.24486 9.74C3.74486 9.95 3.39486 10.35 3.22486 10.91C3.14486 11.18 3.29486 11.46 3.56486 11.54C3.82486 11.61 4.10486 11.46 4.18486 11.2C4.29486 10.85 4.48486 10.73 4.62486 10.67C4.88486 10.57 5.13486 10.68 5.26486 10.82C5.38486 10.96 5.40486 11.12 5.30486 11.29C5.17595 11.5202 4.99618 11.7165 4.80458 11.9257L4.75486 11.98C4.67298 12.0801 4.58283 12.1801 4.49946 12.2727L4.49945 12.2727L4.47486 12.3C4.12486 12.72 3.76486 13.13 3.40486 13.53C3.27486 13.68 3.23486 13.9 3.32486 14.07C3.41486 14.24 3.58486 14.36 3.78486 14.36ZM3.68486 20.3699C4.04486 20.5899 4.46486 20.6999 4.87486 20.6999C5.13486 20.6999 5.38486 20.6499 5.61486 20.5499C6.31486 20.2799 6.73486 19.5599 6.60486 18.8799C6.53486 18.5499 6.35486 18.2899 6.12486 18.0899C6.32486 17.8999 6.45486 17.6499 6.50486 17.3799C6.57486 17.0099 6.49486 16.6299 6.27486 16.3099C5.85486 15.6899 5.07486 15.5199 4.10486 15.8299C3.83486 15.9199 3.69486 16.1999 3.77486 16.4599C3.86486 16.7299 4.14486 16.8699 4.40486 16.7899C4.70486 16.6999 5.24486 16.5799 5.45486 16.8899C5.51486 16.9899 5.54486 17.0999 5.52486 17.1999C5.51486 17.2699 5.47486 17.3599 5.36486 17.4299C5.26486 17.4999 5.12486 17.5399 4.95486 17.5799L4.77486 17.6299C4.54486 17.6999 4.40486 17.9099 4.41486 18.1499C4.42486 18.3899 4.61486 18.5799 4.84486 18.6099C5.20486 18.6599 5.58486 18.8299 5.63486 19.0799C5.67486 19.2999 5.46486 19.5499 5.25486 19.6299C4.94486 19.7599 4.52486 19.7099 4.21486 19.5199C3.97486 19.3699 3.67486 19.4399 3.52486 19.6799C3.37486 19.9199 3.44486 20.2299 3.68486 20.3699ZM20.195 18.7999H9.19497C8.86497 18.7999 8.59497 18.5299 8.59497 18.1999C8.59497 17.8699 8.86497 17.5999 9.19497 17.5999H20.195C20.525 17.5999 20.795 17.8699 20.795 18.1999C20.795 18.5299 20.525 18.7999 20.195 18.7999ZM9.19497 12.5999H20.195C20.525 12.5999 20.795 12.3299 20.795 11.9999C20.795 11.6699 20.525 11.3999 20.195 11.3999H9.19497C8.86497 11.3999 8.59497 11.6699 8.59497 11.9999C8.59497 12.3299 8.86497 12.5999 9.19497 12.5999Z"/></svg>',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'clean-block',
|
||||
action: EasyMDE.cleanBlock,
|
||||
title: i18n.global.t('input.editor.cleanBlock'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9.25989 6.18384H20.4513C20.7823 6.18384 21.0509 6.45239 21.0509 6.78345V17.9749C21.0509 18.3059 20.7823 18.5745 20.4513 18.5745H9.25989C9.0929 18.5745 8.93469 18.5061 8.82043 18.384L3.6095 12.7883C3.39563 12.5579 3.39563 12.2004 3.6095 11.97L8.82043 6.37427C8.93469 6.2522 9.0929 6.18384 9.25989 6.18384ZM9.52063 17.3752H19.8517V7.38306H9.52063L4.86926 12.3792L9.52063 17.3752ZM12.7755 15.0686C12.6222 15.0686 12.4679 15.01 12.3517 14.8928C12.1173 14.6584 12.1173 14.2786 12.3517 14.0452L14.0503 12.3469L12.3517 10.6487C12.1173 10.4153 12.1173 10.0354 12.3517 9.80103C12.5841 9.56665 12.965 9.56665 13.1993 9.80103L14.8981 11.4994L16.5968 9.80103C16.8312 9.56665 17.212 9.56665 17.4445 9.80103C17.6788 10.0354 17.6788 10.4153 17.4445 10.6487L15.7458 12.3469L17.4445 14.0452C17.6788 14.2786 17.6788 14.6584 17.4445 14.8928C17.3282 15.01 17.174 15.0686 17.0206 15.0686C16.8673 15.0686 16.714 15.01 16.5968 14.8928L14.8981 13.1945L13.1993 14.8928C13.0822 15.01 12.9288 15.0686 12.7755 15.0686Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'link',
|
||||
action: EasyMDE.drawLink,
|
||||
title: i18n.global.t('input.editor.link'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11.4399 15.3452C11.4999 15.3652 11.5699 15.3752 11.6299 15.3752C11.8799 15.3752 12.1199 15.2152 12.1999 14.9652C12.2999 14.6452 12.1299 14.3052 11.8199 14.2052C11.3499 14.0452 10.9299 13.7852 10.5699 13.4152C10.1999 13.0552 9.9399 12.6452 9.7799 12.1552C9.6599 11.8252 9.5999 11.4652 9.5999 11.0952C9.5999 10.2152 9.9399 9.38518 10.5699 8.75518L15.1599 4.15518C16.4499 2.87518 18.5399 2.87518 19.8299 4.15518C20.4499 4.78518 20.7899 5.61518 20.7899 6.49518C20.7899 7.37518 20.4499 8.20518 19.8299 8.82518L16.7399 11.9052C16.5099 12.1452 16.5099 12.5252 16.7399 12.7552C16.9799 12.9852 17.3599 12.9852 17.5899 12.7552L20.6799 9.67518C21.5299 8.83518 21.9999 7.69518 21.9999 6.49518C21.9999 5.29518 21.5299 4.16518 20.6899 3.30518C18.9299 1.55518 16.0799 1.55518 14.3199 3.30518L9.7299 7.90518C8.8699 8.75518 8.3999 9.88518 8.3999 11.0952C8.3999 11.6152 8.4899 12.1152 8.6499 12.5552C8.8599 13.1952 9.2399 13.7952 9.7199 14.2652C10.1999 14.7552 10.7999 15.1352 11.4399 15.3452ZM3.32 20.6851C4.2 21.5551 5.35 21.9951 6.5 21.9951C7.65 21.9951 8.81 21.5551 9.69 20.7051L14.28 16.1051C15.14 15.2551 15.61 14.1251 15.61 12.9151C15.61 12.4551 15.54 11.9951 15.4 11.5551C15.17 10.8651 14.8 10.2551 14.28 9.73509C13.76 9.21509 13.15 8.84509 12.46 8.61509C12.14 8.51509 11.8 8.68509 11.7 8.99509C11.6 9.30509 11.77 9.64509 12.1 9.75509C12.61 9.91509 13.06 10.1951 13.44 10.5751C13.82 10.9551 14.09 11.4051 14.26 11.9151C14.36 12.2351 14.41 12.5651 14.41 12.9051C14.41 13.7951 14.06 14.6251 13.43 15.2451L8.84 19.8451C7.55 21.1251 5.46 21.1251 4.17 19.8451C3.55 19.2151 3.21 18.3951 3.21 17.5051C3.21 16.6151 3.55 15.7851 4.17 15.1651L7.35 11.9851C7.58 11.7451 7.59 11.3651 7.35 11.1351C7.11 10.9051 6.73 10.9051 6.5 11.1351L3.32 14.3151C2.47 15.1551 2 16.2851 2 17.4951C2 18.7051 2.47 19.8351 3.32 20.6851Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
action: EasyMDE.drawImage,
|
||||
title: i18n.global.t('input.editor.image'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4C2.89543 4 2 4.89543 2 6V16V17.5152V18C2 19.1046 2.89543 20 4 20H20C21.0528 20 21.9156 19.1866 21.9942 18.1539L22 18.1632V18V16V6C22 4.89543 21.1046 4 20 4H4ZM3.2 17.7V16.5642L6.78192 13.7254C6.8616 13.6622 6.97597 13.6689 7.04776 13.7409L10.3126 17.0146C10.7026 17.4056 11.3357 17.4065 11.7268 17.0165C11.7606 16.9827 11.792 16.9465 11.8207 16.9083L16.736 10.352C16.8023 10.2636 16.9277 10.2457 17.016 10.312C17.0355 10.3265 17.0521 10.3445 17.0651 10.365L20.8 16.2669V17.7C20.8 18.3075 20.3075 18.8 19.7 18.8H4.3C3.69249 18.8 3.2 18.3075 3.2 17.7ZM17.3865 8.61836L20.8 14.08V6.3C20.8 5.69249 20.3075 5.2 19.7 5.2H4.3C3.69249 5.2 3.2 5.69249 3.2 6.3V15.04L6.65054 12.2796C6.84949 12.1204 7.13629 12.1363 7.31645 12.3164L10.8369 15.8369C10.915 15.915 11.0417 15.915 11.1198 15.8369C11.1265 15.8302 16.5625 8.58336 16.5625 8.58336C16.7282 8.36245 17.0416 8.31768 17.2625 8.48336C17.3118 8.52034 17.3538 8.56611 17.3865 8.61836ZM8 8.5C8 9.32843 7.32843 10 6.5 10C5.67157 10 5 9.32843 5 8.5C5 7.67157 5.67157 7 6.5 7C7.32843 7 8 7.67157 8 8.5Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'table',
|
||||
action: EasyMDE.drawTable,
|
||||
title: i18n.global.t('input.editor.table'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.18524 3.08496H19.4152C20.6752 3.08496 21.7152 4.11496 21.7152 5.38496V18.615C21.7152 19.885 20.6852 20.915 19.4152 20.915H6.18524C4.91524 20.915 3.88525 19.885 3.88525 18.615V5.38496C3.88525 4.11496 4.91524 3.08496 6.18524 3.08496ZM19.4052 19.705C20.0152 19.705 20.5052 19.215 20.5052 18.605H20.5153V5.38496C20.5153 4.77496 20.0252 4.28496 19.4152 4.28496H6.18524C5.57524 4.28496 5.08521 4.77496 5.08521 5.38496V18.605C5.08521 19.215 5.57524 19.705 6.18524 19.705H19.4052ZM17.4453 9.15503H8.15527C7.82527 9.15503 7.5553 9.42503 7.5553 9.75503C7.5553 10.085 7.82527 10.355 8.15527 10.355H17.4453C17.7753 10.355 18.0453 10.085 18.0453 9.75503C18.0453 9.42503 17.7753 9.15503 17.4453 9.15503ZM17.4453 13.635H8.15527C7.82527 13.635 7.5553 13.905 7.5553 14.235C7.5553 14.565 7.82527 14.835 8.15527 14.835H17.4453C17.7753 14.835 18.0453 14.565 18.0453 14.235C18.0453 13.905 17.7753 13.635 17.4453 13.635Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'horizontal-rule',
|
||||
action: EasyMDE.drawHorizontalRule,
|
||||
title: i18n.global.t('input.editor.horizontalRule'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M21 13H3C2.45 13 2 12.55 2 12C2 11.45 2.45 11 3 11H21C21.55 11 22 11.45 22 12C22 12.55 21.55 13 21 13Z"/></svg>',
|
||||
},
|
||||
'|',
|
||||
{
|
||||
name: 'side-by-side',
|
||||
action: EasyMDE.toggleSideBySide,
|
||||
title: i18n.global.t('input.editor.sideBySide'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.4787 14.58C18.3587 14.69 18.2987 14.85 18.2987 15C18.2987 15.15 18.3587 15.31 18.4787 15.42C18.7187 15.65 19.0987 15.65 19.3287 15.42L22.3287 12.42C22.5587 12.18 22.5587 11.8 22.3287 11.57L19.3287 8.56996C19.0887 8.33996 18.7087 8.33996 18.4787 8.56996C18.2487 8.80996 18.2487 9.18996 18.4787 9.41996L20.451 11.3999L14.4487 11.3999L14.4487 4.6C14.4487 4.27 14.1787 4 13.8487 4C13.5187 4 13.2487 4.27 13.2487 4.6L13.2487 19.4C13.2487 19.73 13.5187 20 13.8487 20C14.1787 20 14.4487 19.73 14.4487 19.4L14.4487 12.5999L20.4511 12.5999L18.4787 14.58ZM9.54878 19.4L9.54878 12.5999L3.5486 12.5999L5.52867 14.58C5.75867 14.81 5.75867 15.19 5.52867 15.43C5.29867 15.66 4.91867 15.66 4.67867 15.43L1.67867 12.43C1.63274 12.384 1.5956 12.3323 1.56725 12.2774C1.53058 12.2077 1.50724 12.1299 1.50068 12.0477C1.49934 12.0317 1.49867 12.0158 1.49867 12C1.49867 11.9841 1.49933 11.9682 1.50067 11.9522C1.51454 11.778 1.60365 11.6242 1.73526 11.5234L4.67867 8.57997C4.90867 8.34997 5.28867 8.34997 5.52867 8.57997C5.75867 8.80997 5.75867 9.18997 5.52867 9.42997L3.55107 11.3999L9.54878 11.3999L9.54878 4.6C9.54878 4.27 9.81878 4 10.1488 4C10.4788 4 10.7488 4.27 10.7488 4.6L10.7488 11.9999L10.7488 19.4C10.7488 19.73 10.4788 20 10.1488 20C9.81878 20 9.54878 19.73 9.54878 19.4Z"/></svg>',
|
||||
},
|
||||
{
|
||||
name: 'guide',
|
||||
action() {
|
||||
window.open('https://www.markdownguide.org/basic-syntax/', '_blank')
|
||||
},
|
||||
title: i18n.global.t('input.editor.guide'),
|
||||
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.4999 2.3999H6.4999C5.0699 2.3999 3.8999 3.5699 3.8999 4.9999V18.9999C3.8999 20.4299 5.0699 21.5999 6.4999 21.5999H19.4999C19.8299 21.5999 20.0999 21.3299 20.0999 20.9999V16.9999V2.9999C20.0999 2.6699 19.8299 2.3999 19.4999 2.3999ZM5.0999 4.9999V16.8118C5.50468 16.5513 5.98546 16.3999 6.4999 16.3999H18.8999V3.5999H6.4999C5.7299 3.5999 5.0999 4.2299 5.0999 4.9999ZM6.4999 17.5999H18.8999V20.3999H6.4999C5.7299 20.3999 5.0999 19.7699 5.0999 18.9999C5.0999 18.2299 5.7299 17.5999 6.4999 17.5999ZM8.4999 8.5999H15.4999C15.8299 8.5999 16.0999 8.3299 16.0999 7.9999C16.0999 7.6699 15.8299 7.3999 15.4999 7.3999H8.4999C8.1699 7.3999 7.8999 7.6699 7.8999 7.9999C7.8999 8.3299 8.1699 8.5999 8.4999 8.5999ZM15.4999 11.3999H8.4999C8.1699 11.3999 7.8999 11.6699 7.8999 11.9999C7.8999 12.3299 8.1699 12.5999 8.4999 12.5999H15.4999C15.8299 12.5999 16.0999 12.3299 16.0999 11.9999C16.0999 11.6699 15.8299 11.3999 15.4999 11.3999Z"/></svg>',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
|
@ -2,10 +2,9 @@
|
|||
<div :class="{'is-disabled': disabled}" class="fancycheckbox">
|
||||
<input
|
||||
:checked="checked"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || null"
|
||||
:id="checkBoxId"
|
||||
@change="(event) => updateData(event.target.checked)"
|
||||
style="display: none;"
|
||||
type="checkbox"/>
|
||||
<label :for="checkBoxId" class="check">
|
||||
<svg height="18px" viewBox="0 0 18 18" width="18px">
|
||||
|
@ -21,16 +20,18 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
export default {
|
||||
name: 'fancycheckbox',
|
||||
data() {
|
||||
return {
|
||||
checked: false,
|
||||
checkBoxId: 'fancycheckbox' + Math.random(),
|
||||
checkBoxId: `fancycheckbox_${createRandomID()}`,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
|
@ -39,10 +40,11 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
watch: {
|
||||
value: {
|
||||
handler(value) {
|
||||
this.checked = value
|
||||
modelValue: {
|
||||
handler(modelValue) {
|
||||
this.checked = modelValue
|
||||
|
||||
},
|
||||
immediate: true,
|
||||
|
@ -51,9 +53,90 @@ export default {
|
|||
methods: {
|
||||
updateData(checked) {
|
||||
this.checked = checked
|
||||
this.$emit('input', checked)
|
||||
this.$emit('update:modelValue', checked)
|
||||
this.$emit('change', checked)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fancycheckbox {
|
||||
display: inline-block;
|
||||
padding-right: 5px;
|
||||
padding-top: 3px;
|
||||
|
||||
// FIXME: should be a prop
|
||||
&.is-block {
|
||||
margin: .5rem .2rem;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.check {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin: auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
vertical-align: top;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke: #c8ccd4;
|
||||
stroke-width: 1.5;
|
||||
transform: translate3d(0, 0, 0);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.check:hover svg {
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
.is-disabled .check:hover svg {
|
||||
stroke: #c8ccd4;
|
||||
}
|
||||
|
||||
path {
|
||||
stroke-dasharray: 60;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
polyline {
|
||||
stroke-dasharray: 22;
|
||||
stroke-dashoffset: 66;
|
||||
}
|
||||
|
||||
input[type=checkbox]:checked + .check {
|
||||
svg {
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
path {
|
||||
stroke-dashoffset: 60;
|
||||
transition: all 0.3s linear;
|
||||
}
|
||||
|
||||
polyline {
|
||||
stroke-dashoffset: 42;
|
||||
transition: all 0.2s linear;
|
||||
transition-delay: 0.15s;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -7,7 +7,9 @@
|
|||
@focus="focus"
|
||||
>
|
||||
<div class="control" :class="{'is-loading': loading || localLoading}">
|
||||
<div class="input-wrapper input" :class="{'has-multiple': multiple && Array.isArray(internalValue) && internalValue.length > 0}">
|
||||
<div
|
||||
class="input-wrapper input"
|
||||
:class="{'has-multiple': hasMultiple}">
|
||||
<template v-if="Array.isArray(internalValue)">
|
||||
<template v-for="(item, key) in internalValue">
|
||||
<slot name="tag" :item="item">
|
||||
|
@ -81,16 +83,9 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {i18n} from '@/i18n'
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
|
||||
/**
|
||||
* Available events:
|
||||
* @search: Triggered every time the search query input changes
|
||||
* @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
|
||||
* @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
|
||||
* @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: 'multiselect',
|
||||
data() {
|
||||
|
@ -133,7 +128,7 @@ export default {
|
|||
},
|
||||
},
|
||||
// The object with the value, updated every time an entry is selected.
|
||||
value: {
|
||||
modelValue: {
|
||||
default() {
|
||||
return null
|
||||
},
|
||||
|
@ -141,44 +136,36 @@ export default {
|
|||
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
|
||||
creatable: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
default: false,
|
||||
},
|
||||
// The text shown next to the new value option.
|
||||
createPlaceholder: {
|
||||
type: String,
|
||||
default() {
|
||||
return this.$t('input.multiselect.createPlaceholder')
|
||||
return i18n.global.t('input.multiselect.createPlaceholder')
|
||||
},
|
||||
},
|
||||
// The text shown next to an option.
|
||||
selectPlaceholder: {
|
||||
type: String,
|
||||
default() {
|
||||
return this.$t('input.multiselect.selectPlaceholder')
|
||||
return i18n.global.t('input.multiselect.selectPlaceholder')
|
||||
},
|
||||
},
|
||||
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
default: false,
|
||||
},
|
||||
// If true, displays the search results inline instead of using a dropdown.
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
default: false,
|
||||
},
|
||||
// If true, shows search results when no query is specified.
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return true
|
||||
},
|
||||
default: true,
|
||||
},
|
||||
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
|
||||
searchDelay: {
|
||||
|
@ -187,19 +174,34 @@ export default {
|
|||
return 200
|
||||
},
|
||||
},
|
||||
closeAfterSelect: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Available events:
|
||||
* @search: Triggered every time the search query input changes
|
||||
* @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
|
||||
* @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
|
||||
* @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
|
||||
*/
|
||||
emits: ['update:modelValue', 'search', 'select', 'create', 'remove'],
|
||||
|
||||
mounted() {
|
||||
document.addEventListener('click', this.hideSearchResultsHandler)
|
||||
},
|
||||
beforeDestroy() {
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('click', this.hideSearchResultsHandler)
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
modelValue: {
|
||||
handler(value) {
|
||||
this.setSelectedObject(value)
|
||||
},
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
|
@ -230,6 +232,9 @@ export default {
|
|||
|
||||
return this.searchResults
|
||||
},
|
||||
hasMultiple() {
|
||||
return this.multiple && Array.isArray(this.internalValue) && this.internalValue.length > 0
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
|
||||
|
@ -278,13 +283,15 @@ export default {
|
|||
this.internalValue = object
|
||||
}
|
||||
|
||||
this.$emit('input', this.internalValue)
|
||||
this.$emit('update:modelValue', this.internalValue)
|
||||
this.$emit('select', object)
|
||||
this.setSelectedObject(object)
|
||||
this.closeSearchResults()
|
||||
if (this.closeAfterSelect && this.filteredSearchResults.length > 0 && !this.creatableAvailable) {
|
||||
this.closeSearchResults()
|
||||
}
|
||||
},
|
||||
setSelectedObject(object, resetOnly = false) {
|
||||
this.$set(this, 'internalValue', object)
|
||||
this.internalValue = object
|
||||
|
||||
// We assume we're getting an array when multiple is enabled and can therefore leave the query
|
||||
// value etc as it is
|
||||
|
@ -352,7 +359,7 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
this.$emit('input', this.internalValue)
|
||||
this.$emit('update:modelValue', this.internalValue)
|
||||
this.$emit('remove', item)
|
||||
},
|
||||
focus() {
|
||||
|
@ -361,3 +368,131 @@ export default {
|
|||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.multiselect {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
.control.is-loading::after {
|
||||
top: .75rem;
|
||||
}
|
||||
|
||||
&.has-search-results .input-wrapper {
|
||||
border-radius: $radius $radius 0 0;
|
||||
border-color: var(--primary) !important;
|
||||
background: var(--white) !important;
|
||||
|
||||
&, &:focus-within {
|
||||
border-bottom-color: var(--grey-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
padding: 0;
|
||||
background: var(--white) !important;
|
||||
border-color: var(--grey-200) !important;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--grey-300) !important;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
background: transparent;
|
||||
height: auto;
|
||||
|
||||
&::placeholder {
|
||||
font-style: normal !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-multiple .input {
|
||||
max-width: 250px;
|
||||
|
||||
input {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary) !important;
|
||||
background: var(--white) !important;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 0 .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
background: var(--white);
|
||||
border-radius: 0 0 $radius $radius;
|
||||
border: 1px solid var(--primary);
|
||||
border-top: none;
|
||||
|
||||
max-height: 50vh;
|
||||
overflow-x: auto;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
|
||||
&-inline {
|
||||
position: static;
|
||||
}
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
display: block;
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
text-transform: none;
|
||||
font-family: $family-sans-serif;
|
||||
font-weight: normal;
|
||||
padding: .5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.search-result {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
padding: .5rem .75rem;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: .75rem;
|
||||
color: transparent;
|
||||
transition: color $transition;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
&:focus, &:hover {
|
||||
background: var(--grey-100);
|
||||
box-shadow: none !important;
|
||||
|
||||
.hint-text {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--grey-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
374
src/components/input/vue-easymde/vue-easymde.css
Normal file
|
@ -0,0 +1,374 @@
|
|||
.EasyMDEContainer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.EasyMDEContainer.sided--no-fullscreen {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .CodeMirror {
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
border: 1px solid #ddd;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
z-index: 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .CodeMirror-scroll {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .CodeMirror-fullscreen {
|
||||
background: #fff;
|
||||
position: fixed !important;
|
||||
top: 50px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
z-index: 8;
|
||||
border-right: none !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .CodeMirror-sided {
|
||||
width: 50% !important;
|
||||
}
|
||||
|
||||
.EasyMDEContainer.sided--no-fullscreen .CodeMirror-sided {
|
||||
border-right: none!important;
|
||||
border-bottom-right-radius: 0px;
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .CodeMirror-placeholder {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.EasyMDEContainer .CodeMirror-focused .CodeMirror-selected {
|
||||
background: #d9d9d9;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
position: relative;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-o-user-select: none;
|
||||
user-select: none;
|
||||
padding: 9px 10px;
|
||||
border-top: 1px solid #bbb;
|
||||
border-left: 1px solid #bbb;
|
||||
border-right: 1px solid #bbb;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-toolbar.fullscreen {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 1;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.editor-toolbar.fullscreen::before {
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background: -moz-linear-gradient(left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
background: -webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255, 255, 255, 1)), color-stop(100%, rgba(255, 255, 255, 0)));
|
||||
background: -webkit-linear-gradient(left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
background: -o-linear-gradient(left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
background: -ms-linear-gradient(left, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.editor-toolbar.fullscreen::after {
|
||||
width: 20px;
|
||||
height: 50px;
|
||||
background: -moz-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
||||
background: -webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255, 255, 255, 0)), color-stop(100%, rgba(255, 255, 255, 1)));
|
||||
background: -webkit-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
||||
background: -o-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
||||
background: -ms-linear-gradient(left, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
||||
background: linear-gradient(to right, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.EasyMDEContainer.sided--no-fullscreen .editor-toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-toolbar button, .editor-toolbar .easymde-dropdown {
|
||||
background: transparent;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none !important;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.editor-toolbar button.active,
|
||||
.editor-toolbar button:hover {
|
||||
background: #fcfcfc;
|
||||
border-color: #95a5a6;
|
||||
}
|
||||
|
||||
.editor-toolbar i.separator {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
border-left: 1px solid #d9d9d9;
|
||||
border-right: 1px solid #fff;
|
||||
color: transparent;
|
||||
text-indent: -10px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.editor-toolbar button:after {
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
font-size: 65%;
|
||||
vertical-align: text-bottom;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-1:after {
|
||||
content: "1";
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-2:after {
|
||||
content: "2";
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-3:after {
|
||||
content: "3";
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-bigger:after {
|
||||
content: "▲";
|
||||
}
|
||||
|
||||
.editor-toolbar button.heading-smaller:after {
|
||||
content: "▼";
|
||||
}
|
||||
|
||||
.editor-toolbar.disabled-for-preview button:not(.no-disable) {
|
||||
opacity: .6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.editor-toolbar i.no-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-statusbar {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: #959694;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.EasyMDEContainer.sided--no-fullscreen .editor-statusbar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-statusbar span {
|
||||
display: inline-block;
|
||||
min-width: 4em;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.editor-statusbar .lines:before {
|
||||
content: 'lines: '
|
||||
}
|
||||
|
||||
.editor-statusbar .words:before {
|
||||
content: 'words: '
|
||||
}
|
||||
|
||||
.editor-statusbar .characters:before {
|
||||
content: 'characters: '
|
||||
}
|
||||
|
||||
.editor-preview-full {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 7;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor-preview-side {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
top: 50px;
|
||||
right: 0;
|
||||
z-index: 9;
|
||||
overflow: auto;
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.editor-preview-active-side {
|
||||
display: block
|
||||
}
|
||||
|
||||
.EasyMDEContainer.sided--no-fullscreen .editor-preview-active-side {
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
position: static;
|
||||
}
|
||||
|
||||
.editor-preview-active {
|
||||
display: block
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
padding: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.editor-preview > p {
|
||||
margin-top: 0
|
||||
}
|
||||
|
||||
.editor-preview pre {
|
||||
background: #eee;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.editor-preview table td,
|
||||
.editor-preview table th {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-tag {
|
||||
color: #63a35c;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-attribute {
|
||||
color: #795da3;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-string {
|
||||
color: #183691;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-header-1 {
|
||||
font-size: 200%;
|
||||
line-height: 200%;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-header-2 {
|
||||
font-size: 160%;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-header-3 {
|
||||
font-size: 125%;
|
||||
line-height: 125%;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-header-4 {
|
||||
font-size: 110%;
|
||||
line-height: 110%;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-comment {
|
||||
background: rgba(0, 0, 0, .05);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-link {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-url {
|
||||
color: #aab2b3;
|
||||
}
|
||||
|
||||
.cm-s-easymde .cm-quote {
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.editor-toolbar .easymde-dropdown {
|
||||
position: relative;
|
||||
background: linear-gradient(to bottom right, #fff 0%, #fff 84%, #333 50%, #333 100%);
|
||||
border-radius: 0;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.editor-toolbar .easymde-dropdown:hover {
|
||||
background: linear-gradient(to bottom right, #fff 0%, #fff 84%, #333 50%, #333 100%);
|
||||
}
|
||||
|
||||
.easymde-dropdown-content {
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
background-color: #f9f9f9;
|
||||
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2);
|
||||
padding: 8px;
|
||||
z-index: 2;
|
||||
top: 30px;
|
||||
}
|
||||
|
||||
.easymde-dropdown:active .easymde-dropdown-content,
|
||||
.easymde-dropdown:focus .easymde-dropdown-content {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
span[data-img-src]::after{
|
||||
content: '';
|
||||
background-image: var(--bg-image);
|
||||
display: block;
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
background-size: contain;
|
||||
height: 0;
|
||||
padding-top: var(--height);
|
||||
width: var(--width);
|
||||
background-repeat: no-repeat;
|
||||
}
|
157
src/components/input/vue-easymde/vue-easymde.vue
Normal file
|
@ -0,0 +1,157 @@
|
|||
<template>
|
||||
<div class="vue-easymde">
|
||||
<textarea
|
||||
class="vue-simplemde-textarea"
|
||||
:name="name"
|
||||
:value="modelValue"
|
||||
@input="handleInput($event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import EasyMDE from 'easymde'
|
||||
import {marked} from 'marked'
|
||||
|
||||
export default {
|
||||
name: 'vue-easymde',
|
||||
props: {
|
||||
modelValue: String,
|
||||
name: String,
|
||||
previewClass: String,
|
||||
autoinit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
highlight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sanitize: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
configs: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
previewRender: {
|
||||
type: Function,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'blur', 'initialized'],
|
||||
data() {
|
||||
return {
|
||||
isValueUpdateFromInner: false,
|
||||
easymde: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.autoinit) this.initialize()
|
||||
},
|
||||
deactivated() {
|
||||
const editor = this.easymde
|
||||
if (!editor) return
|
||||
const isFullScreen = editor.codemirror.getOption('fullScreen')
|
||||
if (isFullScreen) editor.toggleFullScreen()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.easymde) {
|
||||
this.easymde.toTextArea()
|
||||
this.easymde.cleanup()
|
||||
this.easymde = null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initialize() {
|
||||
const configs = Object.assign({
|
||||
element: this.$el.firstElementChild,
|
||||
initialValue: this.modelValue,
|
||||
previewRender: this.previewRender,
|
||||
renderingConfig: {},
|
||||
}, this.configs)
|
||||
|
||||
// Synchronize the values of value and initialValue
|
||||
if (configs.initialValue) {
|
||||
this.$emit('update:modelValue', configs.initialValue)
|
||||
}
|
||||
|
||||
// Determine whether to enable code highlighting
|
||||
if (this.highlight) {
|
||||
configs.renderingConfig.codeSyntaxHighlighting = true
|
||||
}
|
||||
|
||||
// Set whether to render the input html
|
||||
marked.setOptions({ sanitize: this.sanitize })
|
||||
|
||||
// Instantiated editor
|
||||
this.easymde = new EasyMDE(configs)
|
||||
|
||||
// Add a custom previewClass
|
||||
const className = this.previewClass || ''
|
||||
this.addPreviewClass(className)
|
||||
|
||||
// Binding event
|
||||
this.bindingEvents()
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$emit('initialized', this.easymde)
|
||||
})
|
||||
},
|
||||
|
||||
addPreviewClass(className) {
|
||||
const wrapper = this.easymde.codemirror.getWrapperElement()
|
||||
const preview = document.createElement('div')
|
||||
wrapper.nextSibling.className += ` ${className}`
|
||||
preview.className = `editor-preview ${className}`
|
||||
wrapper.appendChild(preview)
|
||||
},
|
||||
|
||||
bindingEvents() {
|
||||
this.easymde.codemirror.on('change', this.handleCodemirrorInput)
|
||||
this.easymde.codemirror.on('blur', this.handleCodemirrorBlur)
|
||||
},
|
||||
|
||||
handleCodemirrorInput(instance, changeObj) {
|
||||
if (changeObj.origin === 'setValue') {
|
||||
return
|
||||
}
|
||||
const val = this.easymde.value()
|
||||
this.handleInput(val)
|
||||
},
|
||||
|
||||
handleCodemirrorBlur() {
|
||||
const val = this.easymde.value()
|
||||
this.isValueUpdateFromInner = true
|
||||
this.$emit('blur', val)
|
||||
},
|
||||
|
||||
handleInput(val) {
|
||||
this.isValueUpdateFromInner = true
|
||||
this.$emit('update:modelValue', val)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
modelValue(val) {
|
||||
if (this.isValueUpdateFromInner) {
|
||||
this.isValueUpdateFromInner = false
|
||||
} else {
|
||||
this.easymde.value(val)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vue-easymde .markdown-body {
|
||||
padding: 0.5em
|
||||
}
|
||||
|
||||
.vue-easymde .editor-preview-active, .vue-easymde .editor-preview-active-side {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
|
@ -2,13 +2,13 @@
|
|||
<dropdown>
|
||||
<template v-if="isSavedFilter">
|
||||
<dropdown-item
|
||||
:to="{ name: `${listRoutePrefix}.settings.edit`, params: { listId: list.id } }"
|
||||
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }"
|
||||
icon="pen"
|
||||
>
|
||||
{{ $t('menu.edit') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: `${listRoutePrefix}.settings.delete`, params: { listId: list.id } }"
|
||||
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }"
|
||||
icon="trash-alt"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
|
@ -16,7 +16,7 @@
|
|||
</template>
|
||||
<template v-else-if="list.isArchived">
|
||||
<dropdown-item
|
||||
:to="{ name: `${listRoutePrefix}.settings.archive`, params: { listId: list.id } }"
|
||||
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }"
|
||||
icon="archive"
|
||||
>
|
||||
{{ $t('menu.unarchive') }}
|
||||
|
@ -24,32 +24,32 @@
|
|||
</template>
|
||||
<template v-else>
|
||||
<dropdown-item
|
||||
:to="{ name: `${listRoutePrefix}.settings.edit`, params: { listId: list.id } }"
|
||||
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }"
|
||||
icon="pen"
|
||||
>
|
||||
{{ $t('menu.edit') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: `${listRoutePrefix}.settings.background`, params: { listId: list.id } }"
|
||||
:to="{ name: `${listRoutePrefix}.background`, params: { listId: list.id } }"
|
||||
v-if="backgroundsEnabled"
|
||||
icon="image"
|
||||
>
|
||||
{{ $t('menu.setBackground') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: `${listRoutePrefix}.settings.share`, params: { listId: list.id } }"
|
||||
:to="{ name: `${listRoutePrefix}.share`, params: { listId: list.id } }"
|
||||
icon="share-alt"
|
||||
>
|
||||
{{ $t('menu.share') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: `${listRoutePrefix}.settings.duplicate`, params: { listId: list.id } }"
|
||||
:to="{ name: `${listRoutePrefix}.duplicate`, params: { listId: list.id } }"
|
||||
icon="paste"
|
||||
>
|
||||
{{ $t('menu.duplicate') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{ name: `${listRoutePrefix}.settings.archive`, params: { listId: list.id } }"
|
||||
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }"
|
||||
icon="archive"
|
||||
>
|
||||
{{ $t('menu.archive') }}
|
||||
|
@ -63,7 +63,7 @@
|
|||
@change="sub => subscription = sub"
|
||||
/>
|
||||
<dropdown-item
|
||||
:to="{ name: `${listRoutePrefix}.settings.delete`, params: { listId: list.id } }"
|
||||
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }"
|
||||
icon="trash-alt"
|
||||
class="has-text-danger"
|
||||
>
|
||||
|
@ -106,15 +106,19 @@ export default {
|
|||
listRoutePrefix() {
|
||||
let name = 'list'
|
||||
|
||||
|
||||
if (this.$route.name !== null && this.$route.name.startsWith('list.')) {
|
||||
name = this.$route.name
|
||||
// HACK: we should implement a better routing for the modals
|
||||
const settingsRoutes = ['edit', 'delete', 'archive', 'background', 'share', 'duplicate']
|
||||
const suffix = settingsRoutes.find((route) => this.$route.name.endsWith(`.settings.${route}`))
|
||||
name = this.$route.name.replace(`.settings.${suffix}`,'')
|
||||
}
|
||||
|
||||
if (this.isSavedFilter) {
|
||||
name = name.replace('list.', 'filter.')
|
||||
}
|
||||
|
||||
return name
|
||||
return `${name}.settings`
|
||||
},
|
||||
isSavedFilter() {
|
||||
return getSavedFilterIdFromListId(this.list.id) > 0
|
||||
|
|
|
@ -1,67 +1,98 @@
|
|||
<template>
|
||||
<transition name="fade">
|
||||
<filters
|
||||
@change="change"
|
||||
v-if="visibleInternal"
|
||||
v-model="params"
|
||||
ref="filters"
|
||||
/>
|
||||
</transition>
|
||||
<x-button
|
||||
v-if="hasFilters"
|
||||
variant="secondary"
|
||||
@click="clearFilters"
|
||||
>
|
||||
{{ $t('filters.clear') }}
|
||||
</x-button>
|
||||
<popup>
|
||||
<template #trigger="{toggle}">
|
||||
<x-button
|
||||
@click.prevent.stop="toggle()"
|
||||
variant="secondary"
|
||||
icon="filter"
|
||||
>
|
||||
{{ $t('filters.title') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<template #content="{isOpen}">
|
||||
<filters
|
||||
v-model="value"
|
||||
ref="filters"
|
||||
class="filter-popup"
|
||||
:class="{'is-open': isOpen}"
|
||||
/>
|
||||
</template>
|
||||
</popup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import Filters from '../../../components/list/partials/filters'
|
||||
import Filters from '@/components/list/partials/filters'
|
||||
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
|
||||
import Popup from '@/components/misc/popup'
|
||||
|
||||
export default {
|
||||
name: 'filter-popup',
|
||||
data() {
|
||||
return {
|
||||
params: null,
|
||||
visibleInternal: false,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Popup,
|
||||
Filters,
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('click', this.hidePopup)
|
||||
props: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('click', this.hidePopup)
|
||||
emits: ['update:modelValue'],
|
||||
computed: {
|
||||
value: {
|
||||
get() {
|
||||
return this.modelValue
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:modelValue', value)
|
||||
},
|
||||
},
|
||||
hasFilters() {
|
||||
// this.value also contains the page parameter which we don't want to include in filters
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {filter_by, filter_value, filter_comparator, filter_concat, s} = this.value
|
||||
const def = {...getDefaultParams()}
|
||||
|
||||
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
|
||||
const defaultParams = {
|
||||
filter_by: def.filter_by,
|
||||
filter_value: def.filter_value,
|
||||
filter_comparator: def.filter_comparator,
|
||||
filter_concat: def.filter_concat,
|
||||
s: s ? def.s : undefined,
|
||||
}
|
||||
|
||||
return JSON.stringify(params) !== JSON.stringify(defaultParams)
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
modelValue: {
|
||||
handler(value) {
|
||||
this.params = value
|
||||
this.value = value
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
visible() {
|
||||
this.visibleInternal = !this.visibleInternal
|
||||
},
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: true,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
change() {
|
||||
this.$emit('change', this.params)
|
||||
this.$emit('input', this.params)
|
||||
},
|
||||
hidePopup(e) {
|
||||
if (this.visibleInternal) {
|
||||
closeWhenClickedOutside(e, this.$refs.filters.$el, () => {
|
||||
this.visibleInternal = false
|
||||
})
|
||||
}
|
||||
clearFilters() {
|
||||
this.value = {...getDefaultParams()}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.filter-popup {
|
||||
margin: 0;
|
||||
|
||||
&.is-open {
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
<template>
|
||||
<card class="filters has-overflow">
|
||||
<fancycheckbox v-model="params.filter_include_nulls">
|
||||
{{ $t('filters.attributes.includeNulls') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox
|
||||
v-model="filters.requireAllFilters"
|
||||
@change="setFilterConcat()"
|
||||
>
|
||||
{{ $t('filters.attributes.requireAll') }}
|
||||
</fancycheckbox>
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<fancycheckbox v-model="params.filter_include_nulls">
|
||||
{{ $t('filters.attributes.includeNulls') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox
|
||||
v-model="filters.requireAllFilters"
|
||||
@change="setFilterConcat()"
|
||||
>
|
||||
{{ $t('filters.attributes.requireAll') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
|
||||
{{ $t('filters.attributes.showDoneTasks') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
|
||||
{{ $t('filters.attributes.showDoneTasks') }}
|
||||
</fancycheckbox>
|
||||
</div>
|
||||
</fancycheckbox>
|
||||
<fancycheckbox
|
||||
v-if="!$route.name.includes('list.kanban') || !$route.name.includes('list.table')"
|
||||
v-model="sortAlphabetically"
|
||||
>
|
||||
{{ $t('filters.attributes.sortAlphabetically') }}
|
||||
</fancycheckbox>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('misc.search') }}</label>
|
||||
|
@ -35,7 +36,7 @@
|
|||
<label class="label">{{ $t('task.attributes.priority') }}</label>
|
||||
<div class="control single-value-control">
|
||||
<priority-select
|
||||
:disabled="!filters.usePriority"
|
||||
:disabled="!filters.usePriority || null"
|
||||
v-model.number="filters.priority"
|
||||
@change="setPriority"
|
||||
/>
|
||||
|
@ -53,7 +54,7 @@
|
|||
<percent-done-select
|
||||
v-model.number="filters.percentDone"
|
||||
@change="setPercentDoneFilter"
|
||||
:disabled="!filters.usePercentDone"
|
||||
:disabled="!filters.usePercentDone || null"
|
||||
/>
|
||||
<fancycheckbox
|
||||
v-model="filters.usePercentDone"
|
||||
|
@ -189,6 +190,9 @@ import ListService from '@/services/list'
|
|||
import NamespaceService from '@/services/namespace'
|
||||
import EditLabels from '@/components/tasks/partials/editLabels.vue'
|
||||
|
||||
import {objectToSnakeCase} from '@/helpers/case'
|
||||
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in taskList.js
|
||||
const DEFAULT_PARAMS = {
|
||||
sort_by: [],
|
||||
|
@ -218,6 +222,8 @@ const DEFAULT_FILTERS = {
|
|||
namespace: '',
|
||||
}
|
||||
|
||||
export const ALPHABETICAL_SORT = 'title'
|
||||
|
||||
export default {
|
||||
name: 'filters',
|
||||
components: {
|
||||
|
@ -253,20 +259,35 @@ export default {
|
|||
this.filters.requireAllFilters = this.params.filter_concat === 'and'
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
watch: {
|
||||
value: {
|
||||
modelValue: {
|
||||
handler(value) {
|
||||
this.params = value
|
||||
// FIXME: filters should only be converted to snake case in
|
||||
// the last moment
|
||||
this.params = objectToSnakeCase(value)
|
||||
this.prepareFilters()
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
sortAlphabetically: {
|
||||
get() {
|
||||
return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
|
||||
},
|
||||
set(sortAlphabetically) {
|
||||
this.params.sort_by = sortAlphabetically
|
||||
? [ALPHABETICAL_SORT]
|
||||
: getDefaultParams().sort_by
|
||||
|
||||
this.change()
|
||||
},
|
||||
},
|
||||
foundLabels() {
|
||||
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
|
||||
},
|
||||
|
@ -286,7 +307,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
change() {
|
||||
this.$emit('input', this.params)
|
||||
this.$emit('update:modelValue', this.params)
|
||||
this.$emit('change', this.params)
|
||||
},
|
||||
prepareFilters() {
|
||||
|
@ -342,11 +363,11 @@ export default {
|
|||
this.params.filter_by.forEach((f, i) => {
|
||||
if (f === filterName && this.params.filter_comparator[i] === 'greater_equals') {
|
||||
foundStart = true
|
||||
this.$set(this.params.filter_value, i, formatISO(new Date(parts[0])))
|
||||
this.params.filter_value[i] = formatISO(new Date(parts[0]))
|
||||
}
|
||||
if (f === filterName && this.params.filter_comparator[i] === 'less_equals') {
|
||||
foundEnd = true
|
||||
this.$set(this.params.filter_value, i, formatISO(new Date(parts[1])))
|
||||
this.params.filter_value[i] = formatISO(new Date(parts[1]))
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -404,7 +425,7 @@ export default {
|
|||
this.params.filter_by.forEach((f, i) => {
|
||||
if (f === filterName) {
|
||||
found = true
|
||||
this.$set(this.params.filter_value, i, this.filters[variableName])
|
||||
this.params.filter_value[i] = this.filters[variableName]
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -457,17 +478,9 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
let foundDone = false
|
||||
this.params.filter_by.forEach((f, i) => {
|
||||
if (f === 'done') {
|
||||
foundDone = i
|
||||
}
|
||||
})
|
||||
if (foundDone === false) {
|
||||
this.$set(this.filters, 'done', true)
|
||||
}
|
||||
this.filters.done = this.params.filter_by.some((f) => f === 'done') === false
|
||||
},
|
||||
prepareRelatedObjectFilter(kind, filterName = null, servicePrefix = null) {
|
||||
async prepareRelatedObjectFilter(kind, filterName = null, servicePrefix = null) {
|
||||
if (filterName === null) {
|
||||
filterName = kind
|
||||
}
|
||||
|
@ -477,13 +490,11 @@ export default {
|
|||
}
|
||||
|
||||
this.prepareSingleValue(filterName)
|
||||
if (typeof this.filters[filterName] !== 'undefined' && this.filters[filterName] !== '') {
|
||||
this[`${servicePrefix}Service`].getAll({}, {s: this.filters[filterName]})
|
||||
.then(r => {
|
||||
this.$set(this, kind, r)
|
||||
})
|
||||
.catch(e => this.$message.error(e))
|
||||
if (typeof this.filters[filterName] === 'undefined' || this.filters[filterName] === '') {
|
||||
return
|
||||
}
|
||||
|
||||
this[kind] = await this[`${servicePrefix}Service`].getAll({}, {s: this.filters[filterName]})
|
||||
},
|
||||
setDoneFilter() {
|
||||
if (this.filters.done) {
|
||||
|
@ -521,24 +532,18 @@ export default {
|
|||
this.setDateFilter('reminders')
|
||||
},
|
||||
clear(kind) {
|
||||
this.$set(this, `found${kind}`, [])
|
||||
this[`found${kind}`] = []
|
||||
},
|
||||
find(kind, query) {
|
||||
async find(kind, query) {
|
||||
|
||||
if (query === '') {
|
||||
this.clear(kind)
|
||||
}
|
||||
|
||||
this[`${kind}Service`].getAll({}, {s: query})
|
||||
.then(response => {
|
||||
// Filter users from the results who are already assigned
|
||||
const unassignedUsers = response.filter(({id}) => !includesById(this[kind], id))
|
||||
const response = await this[`${kind}Service`].getAll({}, {s: query})
|
||||
|
||||
this.$set(this, `found${kind}`, unassignedUsers)
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
// Filter users from the results who are already assigned
|
||||
this[`found${kind}`] = response.filter(({id}) => !includesById(this[kind], id))
|
||||
},
|
||||
add(kind, filterName) {
|
||||
this.$nextTick(() => {
|
||||
|
@ -562,7 +567,7 @@ export default {
|
|||
ids.push(u.id)
|
||||
})
|
||||
|
||||
this.$set(this.filters, filterName, ids.join(','))
|
||||
this.filters[filterName] = ids.join(',')
|
||||
this.setSingleValueFilter(filterName, filterName, '', 'in')
|
||||
},
|
||||
findLabels(query) {
|
||||
|
@ -597,7 +602,7 @@ export default {
|
|||
labelIDs.push(u.id)
|
||||
})
|
||||
|
||||
this.$set(this.filters, 'labels', labelIDs.join(','))
|
||||
this.filters.labels = labelIDs.join(',')
|
||||
this.setSingleValueFilter('labels', 'labels', '', 'in')
|
||||
},
|
||||
},
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
}"
|
||||
:to="{ name: 'list.index', params: { listId: list.id} }"
|
||||
class="list-card"
|
||||
tag="span"
|
||||
v-if="list !== null && (showArchived ? true : !list.isArchived)"
|
||||
>
|
||||
<div class="is-archived-container">
|
||||
|
@ -21,67 +20,189 @@
|
|||
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
|
||||
@click.stop="toggleFavoriteList(list)"
|
||||
class="favorite">
|
||||
<icon icon="star" v-if="list.isFavorite"/>
|
||||
<icon :icon="['far', 'star']" v-else/>
|
||||
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="title">{{ list.title }}</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
|
||||
import ListService from '@/services/list'
|
||||
|
||||
export default {
|
||||
name: 'list-card',
|
||||
data() {
|
||||
return {
|
||||
background: null,
|
||||
backgroundLoading: false,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
required: true,
|
||||
},
|
||||
showArchived: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
list: {
|
||||
handler: 'loadBackground',
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
loadBackground() {
|
||||
if (this.list === null || !this.list.backgroundInformation || this.backgroundLoading) {
|
||||
return
|
||||
}
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
|
||||
this.backgroundLoading = true
|
||||
const background = ref<string | null>(null)
|
||||
const backgroundLoading = ref(false)
|
||||
|
||||
const listService = new ListService()
|
||||
listService.background(this.list)
|
||||
.then(b => {
|
||||
this.$set(this, 'background', b)
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => this.backgroundLoading = false)
|
||||
},
|
||||
toggleFavoriteList(list) {
|
||||
// The favorites pseudo list is always favorite
|
||||
// Archived lists cannot be marked favorite
|
||||
if (list.id === -1 || list.isArchived) {
|
||||
return
|
||||
}
|
||||
this.$store.dispatch('lists/toggleListFavorite', list)
|
||||
.catch(e => this.$message.error(e))
|
||||
},
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showArchived: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
})
|
||||
|
||||
watch(props.list, loadBackground, { immediate: true })
|
||||
|
||||
async function loadBackground() {
|
||||
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
backgroundLoading.value = true
|
||||
|
||||
const listService = new ListService()
|
||||
try {
|
||||
background.value = await listService.background(props.list)
|
||||
} finally {
|
||||
backgroundLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const store = useStore()
|
||||
|
||||
function toggleFavoriteList(list) {
|
||||
// The favorites pseudo list is always favorite
|
||||
// Archived lists cannot be marked favorite
|
||||
if (list.id === -1 || list.isArchived) {
|
||||
return
|
||||
}
|
||||
store.dispatch('lists/toggleListFavorite', list)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-card {
|
||||
cursor: pointer;
|
||||
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
|
||||
height: $list-height;
|
||||
background: var(--white);
|
||||
margin: 0 $list-spacing $list-spacing 0;
|
||||
padding: 1rem;
|
||||
border-radius: $radius;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow $transition;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus:not(:active) {
|
||||
box-shadow: var(--shadow-xs) !important;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $widescreen) {
|
||||
&:nth-child(#{$lists-per-row}n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $widescreen) and (min-width: $tablet) {
|
||||
$lists-per-row: 3;
|
||||
& {
|
||||
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
|
||||
}
|
||||
|
||||
&:nth-child(#{$lists-per-row}n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
$lists-per-row: 2;
|
||||
& {
|
||||
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
|
||||
}
|
||||
|
||||
&:nth-child(#{$lists-per-row}n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $mobile) {
|
||||
$lists-per-row: 1;
|
||||
& {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.is-archived-container {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
|
||||
.is-archived {
|
||||
font-size: .75rem;
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
align-self: flex-end;
|
||||
font-family: $vikunja-font;
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&.has-light-text .title {
|
||||
color: var(--light);
|
||||
}
|
||||
|
||||
&.has-background {
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
|
||||
.title {
|
||||
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
.favorite {
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
&.is-archived {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.is-favorite {
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,196 +0,0 @@
|
|||
<template>
|
||||
<div class="content">
|
||||
<h1>{{ $t('migrate.titleService', {name: name}) }}</h1>
|
||||
<p>{{ $t('migrate.descriptionDo') }}</p>
|
||||
<template v-if="isMigrating === false && message === '' && lastMigrationDate === null">
|
||||
<template v-if="isFileMigrator">
|
||||
<p>{{ $t('migrate.importUpload', {name: name}) }}</p>
|
||||
<input
|
||||
@change="migrate"
|
||||
class="is-hidden"
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
/>
|
||||
<x-button
|
||||
:loading="migrationService.loading"
|
||||
:disabled="migrationService.loading"
|
||||
@click="$refs.uploadInput.click()"
|
||||
>
|
||||
{{ $t('migrate.upload') }}
|
||||
</x-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>{{ $t('migrate.authorize', {name: name}) }}</p>
|
||||
<x-button
|
||||
:loading="migrationService.loading"
|
||||
:disabled="migrationService.loading"
|
||||
:href="authUrl"
|
||||
>
|
||||
{{ $t('migrate.getStarted') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</template>
|
||||
<div
|
||||
class="migration-in-progress-container"
|
||||
v-else-if="isMigrating === true && message === '' && lastMigrationDate === null">
|
||||
<div class="migration-in-progress">
|
||||
<img :alt="name" :src="serviceIconSource"/>
|
||||
<div class="progress-dots">
|
||||
<span v-for="i in progressDotsCount" :key="i" />
|
||||
</div>
|
||||
<img alt="Vikunja" :src="logoUrl">
|
||||
</div>
|
||||
<p>{{ $t('migrate.inProgress') }}</p>
|
||||
</div>
|
||||
<div v-else-if="lastMigrationDate">
|
||||
<p>
|
||||
{{ $t('migrate.alreadyMigrated1', {name: name, date: formatDate(lastMigrationDate)}) }}<br/>
|
||||
{{ $t('migrate.alreadyMigrated2') }}
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<x-button @click="migrate">{{ $t('migrate.confirm') }}</x-button>
|
||||
<x-button :to="{name: 'home'}" type="tertary" class="has-text-danger">{{ $t('misc.cancel') }}</x-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="message is-primary">
|
||||
<div class="message-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
<x-button :to="{name: 'home'}">{{ $t('misc.refresh') }}</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AbstractMigrationService from '../../services/migrator/abstractMigration'
|
||||
import AbstractMigrationFileService from '../../services/migrator/abstractMigrationFile'
|
||||
import {SERVICE_ICONS} from '../../helpers/migrator'
|
||||
|
||||
import logoUrl from '@/assets/logo.svg'
|
||||
|
||||
const PROGRESS_DOTS_COUNT = 8
|
||||
|
||||
export default {
|
||||
name: 'migration',
|
||||
data() {
|
||||
return {
|
||||
progressDotsCount: PROGRESS_DOTS_COUNT,
|
||||
authUrl: '',
|
||||
isMigrating: false,
|
||||
lastMigrationDate: null,
|
||||
message: '',
|
||||
migratorAuthCode: '',
|
||||
migrationService: null,
|
||||
logoUrl,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
identifier: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isFileMigrator: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
serviceIconSource() {
|
||||
return SERVICE_ICONS[this.identifier]()
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.message = ''
|
||||
|
||||
if (this.isFileMigrator) {
|
||||
this.migrationService = new AbstractMigrationFileService(this.identifier)
|
||||
return
|
||||
}
|
||||
|
||||
this.migrationService = new AbstractMigrationService(this.identifier)
|
||||
this.getAuthUrl()
|
||||
|
||||
if (typeof this.$route.query.code !== 'undefined' || location.hash.startsWith('#token=')) {
|
||||
if (location.hash.startsWith('#token=')) {
|
||||
this.migratorAuthCode = location.hash.substring(7)
|
||||
console.debug(location.hash.substring(7))
|
||||
} else {
|
||||
this.migratorAuthCode = this.$route.query.code
|
||||
}
|
||||
this.migrationService.getStatus()
|
||||
.then(r => {
|
||||
if (r.time) {
|
||||
if (typeof r.time === 'string' && r.time.startsWith('0001-')) {
|
||||
this.lastMigrationDate = null
|
||||
} else {
|
||||
this.lastMigrationDate = new Date(r.time)
|
||||
}
|
||||
|
||||
if (this.lastMigrationDate) {
|
||||
return
|
||||
}
|
||||
}
|
||||
this.migrate()
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAuthUrl() {
|
||||
this.migrationService.getAuthUrl()
|
||||
.then(r => {
|
||||
this.authUrl = r.url
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
},
|
||||
migrate() {
|
||||
this.isMigrating = true
|
||||
this.lastMigrationDate = null
|
||||
this.message = ''
|
||||
|
||||
if (this.isFileMigrator) {
|
||||
return this.migrateFile()
|
||||
}
|
||||
|
||||
this.migrationService.migrate({code: this.migratorAuthCode})
|
||||
.then(r => {
|
||||
this.message = r.message
|
||||
this.$store.dispatch('namespaces/loadNamespaces')
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isMigrating = false
|
||||
})
|
||||
},
|
||||
migrateFile() {
|
||||
if (this.$refs.uploadInput.files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.migrationService.migrate(this.$refs.uploadInput.files[0])
|
||||
.then(r => {
|
||||
this.message = r.message
|
||||
this.$store.dispatch('namespaces/loadNamespaces')
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isMigrating = false
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
42
src/components/misc/Done.vue
Normal file
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isDone"
|
||||
class="is-done"
|
||||
:class="{ 'is-done--small': variant === 'small' }"
|
||||
>
|
||||
{{ $t('task.attributes.done') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {PropType} from 'vue'
|
||||
type Variants = 'default' | 'small'
|
||||
|
||||
defineProps({
|
||||
isDone: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
variant: {
|
||||
type: String as PropType<Variants>,
|
||||
default: 'default',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.is-done {
|
||||
background: var(--success);
|
||||
color: var(--white);
|
||||
padding: .5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.is-done--small {
|
||||
padding: .2rem .3rem;
|
||||
}
|
||||
</style>
|
|
@ -16,42 +16,39 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<x-button @click="setApiUrl" :disabled="apiUrl === ''">
|
||||
<x-button @click="setApiUrl" :disabled="apiUrl === '' || null">
|
||||
{{ $t('apiConfig.change') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="api-url-info" v-else>
|
||||
<i18n path="apiConfig.signInOn">
|
||||
<i18n-t keypath="apiConfig.use">
|
||||
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
|
||||
</i18n>
|
||||
<br />
|
||||
</i18n-t>
|
||||
<br/>
|
||||
<a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="notification is-success mt-2"
|
||||
v-if="successMsg !== '' && errorMsg === ''"
|
||||
>
|
||||
<message variant="success" v-if="successMsg !== '' && errorMsg === ''" class="mt-2">
|
||||
{{ successMsg }}
|
||||
</div>
|
||||
<div
|
||||
class="notification is-danger mt-2"
|
||||
v-if="errorMsg !== '' && successMsg === ''"
|
||||
>
|
||||
</message>
|
||||
<message variant="danger" v-if="errorMsg !== '' && successMsg === ''" class="mt-2">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
</message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { parseURL } from 'ufo'
|
||||
|
||||
const API_DEFAULT_PORT = 3456
|
||||
import Message from '@/components/misc/message'
|
||||
import {parseURL} from 'ufo'
|
||||
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
|
||||
|
||||
export default {
|
||||
name: 'apiConfig',
|
||||
components: {
|
||||
Message,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
configureApi: false,
|
||||
|
@ -60,6 +57,7 @@ export default {
|
|||
successMsg: '',
|
||||
}
|
||||
},
|
||||
emits: ['foundApi'],
|
||||
created() {
|
||||
if (this.apiUrl === '') {
|
||||
this.configureApi = true
|
||||
|
@ -67,132 +65,67 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
apiDomain() {
|
||||
return parseURL(this.apiUrl).host
|
||||
return parseURL(this.apiUrl).host || parseURL(window.location.href).host
|
||||
},
|
||||
},
|
||||
props: {
|
||||
configureOpen: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
configureOpen: {
|
||||
handler(value) {
|
||||
this.configureApi = value
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setApiUrl() {
|
||||
async setApiUrl() {
|
||||
if (this.apiUrl === '') {
|
||||
// Don't try to check and set an empty url
|
||||
this.errorMsg = this.$t('apiConfig.urlRequired')
|
||||
return
|
||||
}
|
||||
|
||||
let urlToCheck = this.apiUrl
|
||||
try {
|
||||
const url = await checkAndSetApiUrl(this.apiUrl)
|
||||
|
||||
// Check if the url has an http prefix
|
||||
if (
|
||||
!urlToCheck.startsWith('http://') &&
|
||||
!urlToCheck.startsWith('https://')
|
||||
) {
|
||||
urlToCheck = `http://${urlToCheck}`
|
||||
if (url === '') {
|
||||
// If the config setter function could not figure out a url
|
||||
throw new Error('URL cannot be empty.')
|
||||
}
|
||||
|
||||
// Set it + save it to local storage to save us the hoops
|
||||
this.errorMsg = ''
|
||||
this.$message.success({message: this.$t('apiConfig.success', {domain: this.apiDomain})})
|
||||
this.configureApi = false
|
||||
this.apiUrl = url
|
||||
this.$emit('foundApi', this.apiUrl)
|
||||
} catch (e) {
|
||||
// Still not found, url is still invalid
|
||||
this.successMsg = ''
|
||||
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
|
||||
}
|
||||
|
||||
urlToCheck = new URL(urlToCheck)
|
||||
const origUrlToCheck = urlToCheck
|
||||
|
||||
const oldUrl = window.API_URL
|
||||
window.API_URL = urlToCheck.toString()
|
||||
|
||||
// Check if the api is reachable at the provided url
|
||||
this.$store
|
||||
.dispatch('config/update')
|
||||
.catch((e) => {
|
||||
// Check if it is reachable at /api/v1 and http
|
||||
if (
|
||||
!urlToCheck.pathname.endsWith('/api/v1') &&
|
||||
!urlToCheck.pathname.endsWith('/api/v1/')
|
||||
) {
|
||||
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return this.$store.dispatch('config/update')
|
||||
}
|
||||
return Promise.reject(e)
|
||||
})
|
||||
.catch((e) => {
|
||||
// Check if it has a port and if not check if it is reachable at https
|
||||
if (urlToCheck.protocol === 'http:') {
|
||||
urlToCheck.protocol = 'https:'
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return this.$store.dispatch('config/update')
|
||||
}
|
||||
return Promise.reject(e)
|
||||
})
|
||||
.catch((e) => {
|
||||
// Check if it is reachable at /api/v1 and https
|
||||
urlToCheck.pathname = origUrlToCheck.pathname
|
||||
if (
|
||||
!urlToCheck.pathname.endsWith('/api/v1') &&
|
||||
!urlToCheck.pathname.endsWith('/api/v1/')
|
||||
) {
|
||||
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return this.$store.dispatch('config/update')
|
||||
}
|
||||
return Promise.reject(e)
|
||||
})
|
||||
.catch((e) => {
|
||||
// Check if it is reachable at port API_DEFAULT_PORT and https
|
||||
if (urlToCheck.port !== API_DEFAULT_PORT) {
|
||||
urlToCheck.protocol = 'https:'
|
||||
urlToCheck.port = API_DEFAULT_PORT
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return this.$store.dispatch('config/update')
|
||||
}
|
||||
return Promise.reject(e)
|
||||
})
|
||||
.catch((e) => {
|
||||
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and https
|
||||
urlToCheck.pathname = origUrlToCheck.pathname
|
||||
if (
|
||||
!urlToCheck.pathname.endsWith('/api/v1') &&
|
||||
!urlToCheck.pathname.endsWith('/api/v1/')
|
||||
) {
|
||||
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return this.$store.dispatch('config/update')
|
||||
}
|
||||
return Promise.reject(e)
|
||||
})
|
||||
.catch((e) => {
|
||||
// Check if it is reachable at port API_DEFAULT_PORT and http
|
||||
if (urlToCheck.port !== API_DEFAULT_PORT) {
|
||||
urlToCheck.protocol = 'http:'
|
||||
urlToCheck.port = API_DEFAULT_PORT
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return this.$store.dispatch('config/update')
|
||||
}
|
||||
return Promise.reject(e)
|
||||
})
|
||||
.catch((e) => {
|
||||
// Check if it is reachable at :API_DEFAULT_PORT and /api/v1 and http
|
||||
urlToCheck.pathname = origUrlToCheck.pathname
|
||||
if (
|
||||
!urlToCheck.pathname.endsWith('/api/v1') &&
|
||||
!urlToCheck.pathname.endsWith('/api/v1/')
|
||||
) {
|
||||
urlToCheck.pathname = `${urlToCheck.pathname}api/v1`
|
||||
window.API_URL = urlToCheck.toString()
|
||||
return this.$store.dispatch('config/update')
|
||||
}
|
||||
return Promise.reject(e)
|
||||
})
|
||||
.catch(() => {
|
||||
// Still not found, url is still invalid
|
||||
this.successMsg = ''
|
||||
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
|
||||
window.API_URL = oldUrl
|
||||
})
|
||||
.then((r) => {
|
||||
if (typeof r !== 'undefined') {
|
||||
// Set it + save it to local storage to save us the hoops
|
||||
this.errorMsg = ''
|
||||
this.successMsg = this.$t('apiConfig.success', {domain: this.apiDomain})
|
||||
localStorage.setItem('API_URL', window.API_URL)
|
||||
this.configureApi = false
|
||||
this.apiUrl = window.API_URL
|
||||
this.$emit('foundApi', this.apiUrl)
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.api-config {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.api-url-info {
|
||||
font-size: .9rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.url {
|
||||
border-bottom: 1px dashed var(--primary);
|
||||
}
|
||||
</style>
|
|
@ -4,7 +4,13 @@
|
|||
<p class="card-header-title">
|
||||
{{ title }}
|
||||
</p>
|
||||
<a @click="$emit('close')" class="card-header-icon" v-if="hasClose">
|
||||
<a
|
||||
v-if="hasClose"
|
||||
class="card-header-icon"
|
||||
:aria-label="$t('misc.close')"
|
||||
@click="$emit('close')"
|
||||
v-tooltip="$t('misc.close')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="closeIcon"/>
|
||||
</span>
|
||||
|
@ -18,38 +24,59 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'card',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
padding: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeIcon: {
|
||||
type: String,
|
||||
default: 'angle-right',
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasContent: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
}
|
||||
padding: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeIcon: {
|
||||
type: String,
|
||||
default: 'times',
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasContent: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
background-color: var(--white);
|
||||
border-radius: $radius;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--card-border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
box-shadow: none;
|
||||
border-bottom: 1px solid var(--card-border-color);
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
// FIXME: should maybe be merged somehow with modal
|
||||
:deep(.modal-card-foot) {
|
||||
background-color: var(--grey-50);
|
||||
border-top: 0;
|
||||
}
|
||||
</style>
|
|
@ -6,7 +6,6 @@
|
|||
:padding="false"
|
||||
class="has-text-left has-overflow"
|
||||
:has-close="true"
|
||||
close-icon="times"
|
||||
@close="$router.back()"
|
||||
:loading="loading"
|
||||
>
|
||||
|
@ -15,25 +14,25 @@
|
|||
</div>
|
||||
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
|
||||
<x-button
|
||||
v-if="tertiary !== ''"
|
||||
:shadow="false"
|
||||
type="tertary"
|
||||
@click.prevent.stop="$emit('tertary')"
|
||||
v-if="tertary !== ''"
|
||||
variant="tertiary"
|
||||
@click.prevent.stop="$emit('tertiary')"
|
||||
>
|
||||
{{ tertary }}
|
||||
{{ tertiary }}
|
||||
</x-button>
|
||||
<x-button
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
@click.prevent.stop="$router.back()"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
type="primary"
|
||||
v-if="primaryLabel !== ''"
|
||||
variant="primary"
|
||||
@click.prevent.stop="primary"
|
||||
:icon="primaryIcon"
|
||||
:disabled="primaryDisabled"
|
||||
v-if="primaryLabel !== ''"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
</x-button>
|
||||
|
@ -43,6 +42,8 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
export default {
|
||||
name: 'create-edit',
|
||||
props: {
|
||||
|
@ -53,7 +54,7 @@ export default {
|
|||
primaryLabel: {
|
||||
type: String,
|
||||
default() {
|
||||
return this.$t('misc.create')
|
||||
return i18n.global.t('misc.create')
|
||||
},
|
||||
},
|
||||
primaryIcon: {
|
||||
|
@ -64,7 +65,7 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tertary: {
|
||||
tertiary: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
@ -77,6 +78,7 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['create', 'primary', 'tertiary'],
|
||||
methods: {
|
||||
primary() {
|
||||
this.$emit('create')
|
||||
|
|
|
@ -11,18 +11,15 @@
|
|||
</router-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'dropdown-item',
|
||||
props: {
|
||||
to: {
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
to: {
|
||||
required: true,
|
||||
},
|
||||
}
|
||||
icon: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
<template>
|
||||
<div class="dropdown is-right is-active" ref="dropdown">
|
||||
<div class="dropdown-trigger" @click="open = !open">
|
||||
<slot name="trigger">
|
||||
<div class="dropdown-trigger is-flex" @click="open = !open">
|
||||
<slot name="trigger" :close="close">
|
||||
<icon :icon="triggerIcon" class="icon"/>
|
||||
</slot>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div class="dropdown-menu" v-if="open">
|
||||
<div class="dropdown-content">
|
||||
<slot></slot>
|
||||
<slot :close="close"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
@ -26,10 +26,10 @@ export default {
|
|||
}
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('click', this.hide)
|
||||
document.addEventListener('click', this.handleClickOutside)
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('click', this.hide)
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('click', this.handleClickOutside)
|
||||
},
|
||||
props: {
|
||||
triggerIcon: {
|
||||
|
@ -37,14 +37,22 @@ export default {
|
|||
default: 'ellipsis-h',
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
methods: {
|
||||
hide(e) {
|
||||
if (this.open) {
|
||||
closeWhenClickedOutside(e, this.$refs.dropdown, () => {
|
||||
this.open = false
|
||||
this.$emit('close', e)
|
||||
})
|
||||
close() {
|
||||
this.open = false
|
||||
},
|
||||
toggleOpen() {
|
||||
this.open = !this.open
|
||||
},
|
||||
handleClickOutside(e) {
|
||||
if (!this.open) {
|
||||
return
|
||||
}
|
||||
closeWhenClickedOutside(e, this.$refs.dropdown, () => {
|
||||
this.open = false
|
||||
this.$emit('close', e)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
<template>
|
||||
<div class="notification is-danger">
|
||||
<i18n path="loadingError.failed">
|
||||
<a @click="() => location.reload()">{{ $t('loadingError.tryAgain') }}</a>
|
||||
<message variant="danger">
|
||||
<i18n-t keypath="loadingError.failed">
|
||||
<a @click="reload">{{ $t('loadingError.tryAgain') }}</a>
|
||||
<a href="https://vikunja.io/contact/" rel="noreferrer noopener nofollow" target="_blank">{{ $t('loadingError.contact') }}</a>
|
||||
</i18n>
|
||||
</div>
|
||||
</i18n-t>
|
||||
</message>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'error',
|
||||
<script lang="ts" setup>
|
||||
import Message from '@/components/misc/message.vue'
|
||||
|
||||
function reload() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,72 +0,0 @@
|
|||
<template>
|
||||
<modal @close="close()">
|
||||
<card class="has-background-white has-no-shadow" :title="$t('keyboardShortcuts.title')">
|
||||
<div class="message is-primary">
|
||||
<div class="message-body">
|
||||
{{ $t('keyboardShortcuts.allPages') }}
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<strong>{{ $t('keyboardShortcuts.toggleMenu') }}</strong>
|
||||
<shortcut :keys="['ctrl', 'e']"/>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ $t('keyboardShortcuts.quickSearch') }}</strong>
|
||||
<shortcut :keys="['ctrl', 'k']"/>
|
||||
</p>
|
||||
<h3>{{ $t('list.kanban.title') }}</h3>
|
||||
<div class="message is-primary" v-if="$route.name === 'list.kanban'">
|
||||
<div class="message-body">
|
||||
{{ $t('keyboardShortcuts.currentPageOnly') }}
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<strong>{{ $t('keyboardShortcuts.task.done') }}</strong>
|
||||
<shortcut :keys="['ctrl', 'click']"/>
|
||||
</p>
|
||||
<h3>{{ $t('keyboardShortcuts.task.title') }}</h3>
|
||||
<div
|
||||
class="message is-primary"
|
||||
v-if="$route.name === 'task.detail' || $route.name === 'task.list.detail' || $route.name === 'task.gantt.detail' || $route.name === 'task.kanban.detail' || $route.name === 'task.detail'">
|
||||
<div class="message-body">
|
||||
{{ $t('keyboardShortcuts.currentPageOnly') }}
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<strong>{{ $t('keyboardShortcuts.task.assign') }}</strong>
|
||||
<shortcut :keys="['a']"/>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ $t('keyboardShortcuts.task.labels') }}</strong>
|
||||
<shortcut :keys="['l']"/>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ $t('keyboardShortcuts.task.dueDate') }}</strong>
|
||||
<shortcut :keys="['d']"/>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ $t('keyboardShortcuts.task.attachment') }}</strong>
|
||||
<shortcut :keys="['f']"/>
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ $t('keyboardShortcuts.task.related') }}</strong>
|
||||
<shortcut :keys="['r']"/>
|
||||
</p>
|
||||
</card>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
|
||||
import Shortcut from '@/components/misc/shortcut.vue'
|
||||
|
||||
export default {
|
||||
name: 'keyboard-shortcuts',
|
||||
components: {Shortcut},
|
||||
methods: {
|
||||
close() {
|
||||
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
79
src/components/misc/keyboard-shortcuts/index.vue
Normal file
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<modal @close="close()">
|
||||
<card class="has-background-white has-no-shadow keyboard-shortcuts" :title="$t('keyboardShortcuts.title')">
|
||||
<template v-for="(s, i) in shortcuts" :key="i">
|
||||
<h3>{{ $t(s.title) }}</h3>
|
||||
|
||||
<message>
|
||||
{{
|
||||
s.available($route) ? $t('keyboardShortcuts.currentPageOnly') : $t('keyboardShortcuts.allPages')
|
||||
}}
|
||||
</message>
|
||||
|
||||
<dl class="shortcut-list">
|
||||
<template v-for="(sc, si) in s.shortcuts" :key="si">
|
||||
<dt class="shortcut-title">{{ $t(sc.title) }}</dt>
|
||||
<shortcut
|
||||
class="shortcut-keys"
|
||||
is="dd"
|
||||
:keys="sc.keys"
|
||||
:combination="typeof sc.combination !== 'undefined' ? $t(`keyboardShortcuts.${sc.combination}`) : null"/>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
</card>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
|
||||
import Shortcut from '@/components/misc/shortcut.vue'
|
||||
import Message from '@/components/misc/message'
|
||||
import {KEYBOARD_SHORTCUTS} from './shortcuts'
|
||||
|
||||
export default {
|
||||
name: 'keyboard-shortcuts',
|
||||
components: {
|
||||
Message,
|
||||
Shortcut,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
shortcuts: KEYBOARD_SHORTCUTS,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.keyboard-shortcuts {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
padding: .75rem;
|
||||
}
|
||||
|
||||
.shortcut-list {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
|
||||
.shortcut-title {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.shortcut-keys {
|
||||
justify-content: end;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
</style>
|
88
src/components/misc/keyboard-shortcuts/shortcuts.js
Normal file
|
@ -0,0 +1,88 @@
|
|||
import {isAppleDevice} from '@/helpers/isAppleDevice'
|
||||
|
||||
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
|
||||
|
||||
export const KEYBOARD_SHORTCUTS = [
|
||||
{
|
||||
title: 'keyboardShortcuts.general',
|
||||
available: () => null,
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.toggleMenu',
|
||||
keys: [ctrl, 'e'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.quickSearch',
|
||||
keys: [ctrl, 'k'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'list.kanban.title',
|
||||
available: (route) => route.name === 'list.kanban',
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.task.done',
|
||||
keys: [ctrl, 'click'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.list.title',
|
||||
available: (route) => route.name.startsWith('list.'),
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.list.switchToListView',
|
||||
keys: ['g', 'l'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.list.switchToGanttView',
|
||||
keys: ['g', 'g'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.list.switchToTableView',
|
||||
keys: ['g', 't'],
|
||||
combination: 'then',
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.list.switchToKanbanView',
|
||||
keys: ['g', 'k'],
|
||||
combination: 'then',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.title',
|
||||
available: (route) => [
|
||||
'task.detail',
|
||||
'task.list.detail',
|
||||
'task.gantt.detail',
|
||||
'task.kanban.detail',
|
||||
'task.detail',
|
||||
].includes(route.name),
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.task.assign',
|
||||
keys: ['a'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.labels',
|
||||
keys: ['l'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.dueDate',
|
||||
keys: ['d'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.attachment',
|
||||
keys: ['f'],
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.related',
|
||||
keys: ['r'],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
|
@ -6,14 +6,21 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'legal',
|
||||
computed: mapState({
|
||||
imprintUrl: state => state.config.legal.imprintUrl,
|
||||
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
|
||||
}),
|
||||
}
|
||||
const store = useStore()
|
||||
|
||||
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.legal-links {
|
||||
margin-top: 1rem;
|
||||
text-align: right;
|
||||
color: var(--grey-300);
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
|
@ -2,12 +2,6 @@
|
|||
<div class="loader-container is-loading"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'loading',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loader-container {
|
||||
height: 100%;
|
||||
|
|
48
src/components/misc/message.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div class="message-wrapper">
|
||||
<div class="message" :class="variant">
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-wrapper {
|
||||
border-radius: $radius;
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: .75rem 1rem;
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.info {
|
||||
border: 1px solid var(--primary);
|
||||
background: hsla(var(--primary-hsl), .05);
|
||||
}
|
||||
|
||||
.danger {
|
||||
border: 1px solid var(--danger);
|
||||
background: hsla(var(--danger-h), var(--danger-s), var(--danger-l), .05);
|
||||
}
|
||||
|
||||
.warning {
|
||||
border: 1px solid var(--warning);
|
||||
background: hsla(var(--warning-h), var(--warning-s), var(--warning-l), .05);
|
||||
}
|
||||
|
||||
.success {
|
||||
border: 1px solid var(--success);
|
||||
background: hsla(var(--success-h), var(--success-s), var(--success-l), .05);
|
||||
}
|
||||
</style>
|
134
src/components/misc/no-auth-wrapper.vue
Normal file
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<div class="no-auth-wrapper">
|
||||
<Logo class="logo" width="200" height="58"/>
|
||||
<div class="noauth-container">
|
||||
<section class="image" :class="{'has-message': motd !== ''}">
|
||||
<Message v-if="motd !== ''">
|
||||
{{ motd }}
|
||||
</Message>
|
||||
<h2 class="image-title">
|
||||
{{ $t('misc.welcomeBack') }}
|
||||
</h2>
|
||||
</section>
|
||||
<section class="content">
|
||||
<div>
|
||||
<h2 class="title" v-if="title">{{ title }}</h2>
|
||||
<api-config/>
|
||||
<slot/>
|
||||
</div>
|
||||
<legal/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import Legal from '@/components/misc/legal.vue'
|
||||
import ApiConfig from '@/components/misc/api-config.vue'
|
||||
import {useStore} from 'vuex'
|
||||
import {computed} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
const {t} = useI18n()
|
||||
|
||||
const motd = computed(() => store.state.config.motd)
|
||||
// @ts-ignore
|
||||
const title = computed(() => t(route.meta.title ?? ''))
|
||||
useTitle(() => title.value)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.no-auth-wrapper {
|
||||
background: var(--site-background) url('@/assets/llama.svg?url') no-repeat fixed bottom left;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
place-items: center;
|
||||
|
||||
@media screen and (max-width: $fullhd) {
|
||||
padding-bottom: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.noauth-container {
|
||||
max-width: $desktop;
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (min-width: $desktop) {
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 50%;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
background: url('@/assets/no-auth-image.jpg') no-repeat bottom/cover;
|
||||
position: relative;
|
||||
|
||||
&.has-message {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
padding: 2rem 2rem 1.5rem;
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $desktop) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.image-title {
|
||||
color: var(--white);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
</style>
|
|
@ -11,7 +11,7 @@
|
|||
>
|
||||
<div v-if="item.title" class="notification-title">{{ item.title }}</div>
|
||||
<div class="notification-content">
|
||||
<template v-for="(t, k) in item.text">{{ t }}<br :key="k"/></template>
|
||||
<template v-for="(t, k) in item.text" :key="k">{{ t }}<br /></template>
|
||||
</div>
|
||||
<div
|
||||
class="buttons is-right"
|
||||
|
@ -26,7 +26,7 @@
|
|||
@click="action.callback"
|
||||
:shadow="false"
|
||||
class="is-small"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
v-for="(action, i) in item.data.actions"
|
||||
>
|
||||
{{ action.title }}
|
||||
|
|
|
@ -1,46 +1,43 @@
|
|||
<template>
|
||||
<nav
|
||||
aria-label="pagination"
|
||||
class="pagination is-centered p-4"
|
||||
role="navigation"
|
||||
v-if="totalPages > 1"
|
||||
>
|
||||
<router-link
|
||||
:disabled="currentPage === 1"
|
||||
:to="getRouteForPagination(currentPage - 1)"
|
||||
class="pagination-previous"
|
||||
tag="button">
|
||||
{{ $t('misc.previous') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
:disabled="currentPage === totalPages"
|
||||
:to="getRouteForPagination(currentPage + 1)"
|
||||
class="pagination-next"
|
||||
tag="button">
|
||||
{{ $t('misc.next') }}
|
||||
</router-link>
|
||||
<ul class="pagination-list">
|
||||
<template v-for="(p, i) in pages">
|
||||
<li :key="'page' + i" v-if="p.isEllipsis">
|
||||
<span class="pagination-ellipsis">…</span>
|
||||
</li>
|
||||
<li :key="'page' + i" v-else>
|
||||
<router-link
|
||||
:aria-label="'Goto page ' + p.number"
|
||||
:class="{ 'is-current': p.number === currentPage }"
|
||||
:to="getRouteForPagination(p.number)"
|
||||
class="pagination-link"
|
||||
>
|
||||
{{ p.number }}
|
||||
</router-link>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav
|
||||
aria-label="pagination"
|
||||
class="pagination is-centered p-4"
|
||||
role="navigation"
|
||||
v-if="totalPages > 1"
|
||||
>
|
||||
<router-link
|
||||
:disabled="currentPage === 1 || null"
|
||||
:to="getRouteForPagination(currentPage - 1)"
|
||||
class="pagination-previous">
|
||||
{{ $t('misc.previous') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
:disabled="currentPage === totalPages || null"
|
||||
:to="getRouteForPagination(currentPage + 1)"
|
||||
class="pagination-next">
|
||||
{{ $t('misc.next') }}
|
||||
</router-link>
|
||||
<ul class="pagination-list">
|
||||
<li :key="`page-${i}`" v-for="(p, i) in pages">
|
||||
<span class="pagination-ellipsis" v-if="p.isEllipsis">…</span>
|
||||
<router-link
|
||||
v-else
|
||||
class="pagination-link"
|
||||
:aria-label="'Goto page ' + p.number"
|
||||
:class="{ 'is-current': p.number === currentPage }"
|
||||
:to="getRouteForPagination(p.number)"
|
||||
>
|
||||
{{ p.number }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
function createPagination(totalPages, currentPage) {
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
|
||||
function createPagination(totalPages: number, currentPage: number) {
|
||||
const pages = []
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
|
||||
|
@ -84,42 +81,30 @@ function getRouteForPagination(page = 1, type = 'list') {
|
|||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Pagination',
|
||||
|
||||
props: {
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
const props = defineProps({
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
|
||||
computed: {
|
||||
pages() {
|
||||
return createPagination(this.totalPages, this.currentPage)
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
methods: {
|
||||
getRouteForPagination,
|
||||
},
|
||||
}
|
||||
const pages = computed(() => createPagination(props.totalPages, props.currentPage))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pagination {
|
||||
padding-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pagination-previous,
|
||||
.pagination-next {
|
||||
&:not(:disabled):hover {
|
||||
background: $scheme-main;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.pagination-previous,
|
||||
.pagination-next {
|
||||
&:not(:disabled):hover {
|
||||
background: $scheme-main;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
54
src/components/misc/popup.vue
Normal file
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<slot name="trigger" :isOpen="open" :toggle="toggle"></slot>
|
||||
<div class="popup" :class="{'is-open': open}" ref="popup">
|
||||
<slot name="content" :isOpen="open"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import {onBeforeUnmount, onMounted, ref} from 'vue'
|
||||
|
||||
const open = ref(false)
|
||||
const popup = ref(null)
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
function hidePopup(e) {
|
||||
if (!open.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// we actually want to use popup.$el, not its value.
|
||||
// eslint-disable-next-line vue/no-ref-as-operand
|
||||
closeWhenClickedOutside(e, popup.value, () => {
|
||||
open.value = false
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', hidePopup)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', hidePopup)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.popup {
|
||||
transition: opacity $transition;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
|
||||
&.is-open {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
129
src/components/misc/ready.vue
Normal file
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
|
||||
<div class="offline" style="height: 0;width: 0;"></div>
|
||||
<div class="app offline" v-if="!online">
|
||||
<div class="offline-message">
|
||||
<h1>{{ $t('offline.title') }}</h1>
|
||||
<p>{{ $t('offline.text') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="ready">
|
||||
<slot/>
|
||||
</template>
|
||||
<section v-else-if="error !== ''">
|
||||
<no-auth-wrapper>
|
||||
<card>
|
||||
<p v-if="error === ERROR_NO_API_URL">
|
||||
{{ $t('ready.noApiUrlConfigured') }}
|
||||
</p>
|
||||
<message variant="danger" v-else>
|
||||
<p>
|
||||
{{ $t('ready.errorOccured') }}<br/>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p>
|
||||
{{ $t('ready.checkApiUrl') }}
|
||||
</p>
|
||||
</message>
|
||||
<api-config :configure-open="true" @found-api="load"/>
|
||||
</card>
|
||||
</no-auth-wrapper>
|
||||
</section>
|
||||
<transition name="fade">
|
||||
<section class="vikunja-loading" v-if="showLoading">
|
||||
<Logo class="logo"/>
|
||||
<p>
|
||||
<span class="loader-container is-loading-small is-loading"></span>
|
||||
{{ $t('ready.loading') }}
|
||||
</p>
|
||||
</section>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
|
||||
import Logo from '@/assets/logo.svg?component'
|
||||
import ApiConfig from '@/components/misc/api-config.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||
|
||||
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
|
||||
import {useOnline} from '@/composables/useOnline'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const ready = computed(() => store.state.vikunjaReady)
|
||||
const online = useOnline()
|
||||
|
||||
const error = ref('')
|
||||
const showLoading = computed(() => !ready.value && error.value === '')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
await store.dispatch('loadApp')
|
||||
} catch(e: any) {
|
||||
error.value = e
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vikunja-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: var(--grey-100);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 1rem;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
margin-right: 1rem;
|
||||
|
||||
&.is-loading::after {
|
||||
border-left-color: var(--grey-400);
|
||||
border-bottom-color: var(--grey-400);
|
||||
}
|
||||
}
|
||||
|
||||
.offline {
|
||||
background: url('@/assets/llama-nightscape.jpg') no-repeat center;
|
||||
background-size: cover;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
bottom: 5vh;
|
||||
color: $white;
|
||||
padding: 0 1rem;
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
color: $white;
|
||||
font-weight: 700 !important;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,22 +1,27 @@
|
|||
<template>
|
||||
<span class="shortcuts">
|
||||
<template v-for="(k, i) in keys">
|
||||
<kbd :key="i">{{ k }}</kbd>
|
||||
<span v-if="i < keys.length - 1" :key="`plus${i}`">+</span>
|
||||
<component :is="is" class="shortcuts">
|
||||
<template v-for="(k, i) in keys" :key="i">
|
||||
<kbd>{{ k }}</kbd>
|
||||
<span v-if="i < keys.length - 1">{{ combination }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'shortcut',
|
||||
props: {
|
||||
keys: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
keys: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
}
|
||||
combination: {
|
||||
type: String,
|
||||
default: '+',
|
||||
},
|
||||
is: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -27,8 +32,8 @@ export default {
|
|||
|
||||
kbd {
|
||||
padding: .1rem .35rem;
|
||||
border: 1px solid $grey-300;
|
||||
background: $grey-100;
|
||||
border: 1px solid var(--grey-300);
|
||||
background: var(--grey-100);
|
||||
border-radius: 3px;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<x-button
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
:icon="icon"
|
||||
v-tooltip="tooltipText"
|
||||
@click="changeSubscription"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || null"
|
||||
v-if="isButton"
|
||||
>
|
||||
{{ buttonText }}
|
||||
|
@ -22,100 +22,89 @@
|
|||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import {computed, shallowRef} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import SubscriptionService from '@/services/subscription'
|
||||
import SubscriptionModel from '@/models/subscription'
|
||||
|
||||
export default {
|
||||
name: 'task-subscription',
|
||||
data() {
|
||||
return {
|
||||
subscriptionService: new SubscriptionService(),
|
||||
}
|
||||
},
|
||||
props: {
|
||||
entity: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
subscription: {
|
||||
required: true,
|
||||
},
|
||||
entityId: {
|
||||
required: true,
|
||||
},
|
||||
isButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
tooltipText() {
|
||||
if (this.disabled) {
|
||||
return this.$t('task.subscription.subscribedThroughParent', {
|
||||
entity: this.entity,
|
||||
parent: this.subscription.entity,
|
||||
})
|
||||
}
|
||||
import {success} from '@/message'
|
||||
|
||||
return this.subscription !== null ?
|
||||
this.$t('task.subscription.subscribed', {entity: this.entity}) :
|
||||
this.$t('task.subscription.notSubscribed', {entity: this.entity})
|
||||
},
|
||||
buttonText() {
|
||||
return this.subscription !== null ? this.$t('task.subscription.unsubscribe') : this.$t('task.subscription.subscribe')
|
||||
},
|
||||
icon() {
|
||||
return this.subscription !== null ? ['far', 'bell-slash'] : 'bell'
|
||||
},
|
||||
disabled() {
|
||||
if (this.subscription === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.subscription.entity !== this.entity
|
||||
},
|
||||
const props = defineProps({
|
||||
entity: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
methods: {
|
||||
changeSubscription() {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.subscription === null) {
|
||||
this.subscribe()
|
||||
} else {
|
||||
this.unsubscribe()
|
||||
}
|
||||
},
|
||||
subscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: this.entity,
|
||||
entityId: this.entityId,
|
||||
})
|
||||
this.subscriptionService.create(subscription)
|
||||
.then(() => {
|
||||
this.$emit('change', subscription)
|
||||
this.$message.success({message: this.$t('task.subscription.subscribeSuccess', {entity: this.entity})})
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
},
|
||||
unsubscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: this.entity,
|
||||
entityId: this.entityId,
|
||||
})
|
||||
this.subscriptionService.delete(subscription)
|
||||
.then(() => {
|
||||
this.$emit('change', null)
|
||||
this.$message.success({message: this.$t('task.subscription.unsubscribeSuccess', {entity: this.entity})})
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
},
|
||||
subscription: {
|
||||
required: true,
|
||||
},
|
||||
entityId: {
|
||||
required: true,
|
||||
},
|
||||
isButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const subscriptionService = shallowRef(new SubscriptionService())
|
||||
|
||||
const {t} = useI18n()
|
||||
const tooltipText = computed(() => {
|
||||
if (disabled.value) {
|
||||
return t('task.subscription.subscribedThroughParent', {
|
||||
entity: props.entity,
|
||||
parent: props.subscription.entity,
|
||||
})
|
||||
}
|
||||
|
||||
return props.subscription !== null ?
|
||||
t('task.subscription.subscribed', {entity: props.entity}) :
|
||||
t('task.subscription.notSubscribed', {entity: props.entity})
|
||||
})
|
||||
|
||||
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
|
||||
const icon = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
|
||||
const disabled = computed(() => {
|
||||
if (props.subscription === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return props.subscription.entity !== props.entity
|
||||
})
|
||||
|
||||
function changeSubscription() {
|
||||
if (disabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.subscription === null) {
|
||||
subscribe()
|
||||
} else {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: props.entity,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
await subscriptionService.value.create(subscription)
|
||||
emit('change', subscription)
|
||||
success({message: t('task.subscription.subscribeSuccess', {entity: props.entity})})
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: props.entity,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
await subscriptionService.value.delete(subscription)
|
||||
emit('change', null)
|
||||
success({message: t('task.subscription.unsubscribeSuccess', {entity: props.entity})})
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -11,31 +11,28 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'user',
|
||||
props: {
|
||||
user: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
showUsername: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
avatarSize: {
|
||||
required: false,
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
isInline: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
user: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
}
|
||||
showUsername: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
avatarSize: {
|
||||
required: false,
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
isInline: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -12,8 +12,7 @@
|
|||
class="modal-container"
|
||||
:class="{'has-overflow': overflow}"
|
||||
@click.self.prevent.stop="$emit('close')"
|
||||
@shortkey="$emit('close')"
|
||||
v-shortkey="['esc']"
|
||||
v-shortcut="'Escape'"
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
|
@ -32,14 +31,15 @@
|
|||
<div class="actions">
|
||||
<x-button
|
||||
@click="$emit('close')"
|
||||
type="tertary"
|
||||
variant="tertiary"
|
||||
class="has-text-danger"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
@click="$emit('submit')"
|
||||
type="primary"
|
||||
variant="primary"
|
||||
v-cy="'modalPrimary'"
|
||||
:shadow="false"
|
||||
>
|
||||
{{ $t('misc.doit') }}
|
||||
|
@ -102,6 +102,7 @@ export default {
|
|||
validator: validValue(VARIANTS),
|
||||
},
|
||||
},
|
||||
emits: ['close', 'submit'],
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -180,7 +181,7 @@ export default {
|
|||
.hint-modal {
|
||||
z-index: 4600;
|
||||
|
||||
::v-deep.card-content {
|
||||
:deep(.card-content) {
|
||||
text-align: left;
|
||||
|
||||
.info {
|
||||
|
@ -193,10 +194,6 @@ export default {
|
|||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
.message-body {
|
||||
padding: .5rem .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,39 +9,23 @@
|
|||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
export default {
|
||||
name: 'namespace-search',
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Multiselect,
|
||||
},
|
||||
computed: {
|
||||
namespaces() {
|
||||
if (this.query === '') {
|
||||
return []
|
||||
}
|
||||
|
||||
return this.$store.state.namespaces.namespaces.filter(n => {
|
||||
return !n.isArchived &&
|
||||
n.id > 0 &&
|
||||
n.title.toLowerCase().includes(this.query.toLowerCase())
|
||||
})
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
findNamespaces(query) {
|
||||
this.query = query
|
||||
},
|
||||
select(namespace) {
|
||||
this.$emit('selected', namespace)
|
||||
},
|
||||
},
|
||||
const emit = defineEmits(['selected'])
|
||||
|
||||
const query = ref('')
|
||||
|
||||
const store = useStore()
|
||||
const namespaces = computed(() => store.getters['namespaces/searchNamespace'](query.value))
|
||||
|
||||
function findNamespaces(newQuery: string) {
|
||||
query.value = newQuery
|
||||
}
|
||||
|
||||
function select(namespace) {
|
||||
emit('selected', namespace)
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -52,30 +52,22 @@
|
|||
</dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted} from 'vue'
|
||||
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import TaskSubscription from '@/components/misc/subscription.vue'
|
||||
|
||||
export default {
|
||||
name: 'namespace-settings-dropdown',
|
||||
data() {
|
||||
return {
|
||||
subscription: null,
|
||||
}
|
||||
const props = defineProps({
|
||||
namespace: {
|
||||
type: Object, // NamespaceModel
|
||||
required: true,
|
||||
},
|
||||
components: {
|
||||
DropdownItem,
|
||||
Dropdown,
|
||||
TaskSubscription,
|
||||
},
|
||||
props: {
|
||||
namespace: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.subscription = this.namespace.subscription
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const subscription = ref(null)
|
||||
onMounted(() => {
|
||||
subscription.value = props.namespace.subscription
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -72,7 +72,7 @@ export default {
|
|||
document.addEventListener('click', this.hidePopup)
|
||||
this.interval = setInterval(this.loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
|
||||
},
|
||||
beforeDestroy() {
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('click', this.hidePopup)
|
||||
clearInterval(this.interval)
|
||||
},
|
||||
|
@ -93,14 +93,8 @@ export default {
|
|||
closeWhenClickedOutside(e, this.$refs.popup, () => this.showNotifications = false)
|
||||
}
|
||||
},
|
||||
loadNotifications() {
|
||||
this.notificationService.getAll()
|
||||
.then(r => {
|
||||
this.$set(this, 'allNotifications', r)
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
async loadNotifications() {
|
||||
this.allNotifications = await this.notificationService.getAll()
|
||||
},
|
||||
to(n, index) {
|
||||
const to = {
|
||||
|
@ -127,19 +121,125 @@ export default {
|
|||
break
|
||||
}
|
||||
|
||||
return () => {
|
||||
return async () => {
|
||||
if (to.name !== '') {
|
||||
this.$router.push(to)
|
||||
}
|
||||
|
||||
n.read = true
|
||||
this.notificationService.update(n)
|
||||
.then(r => {
|
||||
this.$set(this.allNotifications, index, r)
|
||||
})
|
||||
.catch(e => this.$message.error(e))
|
||||
this.allNotifications[index] = await this.notificationService.update(n)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notifications {
|
||||
width: $navbar-icon-width;
|
||||
|
||||
.unread-indicator {
|
||||
position: absolute;
|
||||
top: .75rem;
|
||||
right: 1.15rem;
|
||||
width: .75rem;
|
||||
height: .75rem;
|
||||
|
||||
background: var(--primary);
|
||||
border-radius: 100%;
|
||||
border: 2px solid var(--white);
|
||||
}
|
||||
|
||||
.notifications-list {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
margin-top: 1rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
background: var(--white);
|
||||
width: 350px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
padding: .75rem .25rem;
|
||||
border-radius: $radius;
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: .85rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
max-height: calc(100vh - 1rem - #{$navbar-height});
|
||||
}
|
||||
|
||||
.head {
|
||||
font-family: $vikunja-font;
|
||||
font-size: 1rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.single-notification {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0;
|
||||
|
||||
transition: background-color $transition;
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.read-indicator {
|
||||
width: .35rem;
|
||||
height: .35rem;
|
||||
background: var(--primary);
|
||||
border-radius: 100%;
|
||||
margin-left: .5rem;
|
||||
|
||||
&.read {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
margin: 0 .5rem;
|
||||
|
||||
span {
|
||||
font-family: $family-sans-serif;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
img {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.created {
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--grey-800);
|
||||
}
|
||||
}
|
||||
|
||||
.nothing {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
color: var(--grey-500);
|
||||
|
||||
.explainer {
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -61,9 +61,11 @@ import TeamModel from '@/models/team'
|
|||
|
||||
import {CURRENT_LIST, LOADING, LOADING_MODULE, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
|
||||
import ListModel from '@/models/list'
|
||||
import createTask from '@/components/tasks/mixins/createTask'
|
||||
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
|
||||
import {getHistory} from '../../modules/listHistory'
|
||||
import {getHistory} from '@/modules/listHistory'
|
||||
import {parseTaskText, PrefixMode} from '@/modules/parseTaskText'
|
||||
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
|
||||
import {PREFIXES} from '@/modules/parseTaskText'
|
||||
|
||||
const TYPE_LIST = 'list'
|
||||
const TYPE_TASK = 'task'
|
||||
|
@ -97,9 +99,6 @@ export default {
|
|||
teamService: new TeamService(),
|
||||
}
|
||||
},
|
||||
mixins: [
|
||||
createTask,
|
||||
],
|
||||
computed: {
|
||||
active() {
|
||||
const active = this.$store.state[QUICK_ACTIONS_ACTIVE]
|
||||
|
@ -111,40 +110,33 @@ export default {
|
|||
results() {
|
||||
let lists = []
|
||||
if (this.searchMode === SEARCH_MODE_ALL || this.searchMode === SEARCH_MODE_LISTS) {
|
||||
let query = this.query
|
||||
if (this.searchMode === SEARCH_MODE_LISTS) {
|
||||
query = query.substr(1)
|
||||
const {list} = this.parsedQuery
|
||||
|
||||
if (list === null) {
|
||||
lists = []
|
||||
} else {
|
||||
const ncache = {}
|
||||
const history = getHistory()
|
||||
// Puts recently visited lists at the top
|
||||
const allLists = [...new Set([
|
||||
...history.map(l => {
|
||||
return this.$store.getters['lists/getListById'](l.id)
|
||||
}),
|
||||
...this.$store.getters['lists/searchList'](list),
|
||||
])]
|
||||
|
||||
lists = allLists.filter(l => {
|
||||
if (typeof l === 'undefined' || l === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof ncache[l.namespaceId] === 'undefined') {
|
||||
ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId)
|
||||
}
|
||||
|
||||
return !ncache[l.namespaceId].isArchived
|
||||
})
|
||||
}
|
||||
|
||||
const ncache = {}
|
||||
|
||||
const history = getHistory()
|
||||
// Puts recently visited lists at the top
|
||||
const allLists = [...new Set([
|
||||
...history.map(l => {
|
||||
return this.$store.getters['lists/getListById'](l.id)
|
||||
}),
|
||||
...Object.values(this.$store.state.lists)])]
|
||||
|
||||
lists = (allLists.filter(l => {
|
||||
if (typeof l === 'undefined' || l === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (l.isArchived) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof ncache[l.namespaceId] === 'undefined') {
|
||||
ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId)
|
||||
}
|
||||
|
||||
if (ncache[l.namespaceId].isArchived) {
|
||||
return false
|
||||
}
|
||||
|
||||
return l.title.toLowerCase().includes(query.toLowerCase())
|
||||
}) ?? [])
|
||||
}
|
||||
|
||||
const cmds = this.availableCmds
|
||||
|
@ -211,7 +203,9 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
return this.$t('quickActions.hint')
|
||||
const prefixes = PREFIXES[getQuickAddMagicMode()] ?? PREFIXES[PrefixMode.Default]
|
||||
|
||||
return this.$t('quickActions.hint', prefixes)
|
||||
},
|
||||
currentList() {
|
||||
return Object.keys(this.$store.state[CURRENT_LIST]).length === 0 ? null : this.$store.state[CURRENT_LIST]
|
||||
|
@ -240,18 +234,23 @@ export default {
|
|||
|
||||
return cmds
|
||||
},
|
||||
parsedQuery() {
|
||||
return parseTaskText(this.query, getQuickAddMagicMode())
|
||||
},
|
||||
searchMode() {
|
||||
if (this.query === '') {
|
||||
return SEARCH_MODE_ALL
|
||||
}
|
||||
|
||||
if (this.query.startsWith('#')) {
|
||||
const {text, list, labels, assignees} = this.parsedQuery
|
||||
|
||||
if (assignees.length === 0 && text !== '') {
|
||||
return SEARCH_MODE_TASKS
|
||||
}
|
||||
if (this.query.startsWith('*')) {
|
||||
if (assignees.length === 0 && list !== null && text === '' && labels.length === 0) {
|
||||
return SEARCH_MODE_LISTS
|
||||
}
|
||||
if (this.query.startsWith('@')) {
|
||||
if (assignees.length > 0 && list === null && text === '' && labels.length === 0) {
|
||||
return SEARCH_MODE_TEAMS
|
||||
}
|
||||
|
||||
|
@ -272,12 +271,7 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
let query = this.query
|
||||
if (this.searchMode === SEARCH_MODE_TASKS) {
|
||||
query = query.substr(1)
|
||||
}
|
||||
|
||||
if (query === '' || this.selectedCmd !== null) {
|
||||
if (this.selectedCmd !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -286,20 +280,44 @@ export default {
|
|||
this.taskSearchTimeout = null
|
||||
}
|
||||
|
||||
this.taskSearchTimeout = setTimeout(() => {
|
||||
this.taskService.getAll({}, {s: query})
|
||||
.then(r => {
|
||||
r = r.map(t => {
|
||||
t.type = TYPE_TASK
|
||||
const list = this.$store.getters['lists/getListById'](t.listId) === null ? null : this.$store.getters['lists/getListById'](t.listId)
|
||||
if (list !== null) {
|
||||
t.title = `${t.title} (${list.title})`
|
||||
}
|
||||
const {text, list, labels} = this.parsedQuery
|
||||
|
||||
return t
|
||||
})
|
||||
this.$set(this, 'foundTasks', r)
|
||||
})
|
||||
const params = {
|
||||
s: text,
|
||||
filter_by: [],
|
||||
filter_value: [],
|
||||
filter_comparator: [],
|
||||
}
|
||||
|
||||
if (list !== null) {
|
||||
const l = this.$store.getters['lists/findListByExactname'](list)
|
||||
if (l !== null) {
|
||||
params.filter_by.push('list_id')
|
||||
params.filter_value.push(l.id)
|
||||
params.filter_comparator.push('equals')
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.length > 0) {
|
||||
const labelIds = this.$store.getters['labels/getLabelsByExactTitles'](labels).map(l => l.id)
|
||||
if (labelIds.length > 0) {
|
||||
params.filter_by.push('labels')
|
||||
params.filter_value.push(labelIds.join())
|
||||
params.filter_comparator.push('in')
|
||||
}
|
||||
}
|
||||
|
||||
this.taskSearchTimeout = setTimeout(async () => {
|
||||
const r = await this.taskService.getAll({}, params)
|
||||
this.foundTasks = r.map(t => {
|
||||
t.type = TYPE_TASK
|
||||
const list = this.$store.getters['lists/getListById'](t.listId)
|
||||
if (list !== null) {
|
||||
t.title = `${t.title} (${list.title})`
|
||||
}
|
||||
|
||||
return t
|
||||
})
|
||||
}, 150)
|
||||
},
|
||||
searchTeams() {
|
||||
|
@ -308,12 +326,7 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
let query = this.query
|
||||
if (this.searchMode === SEARCH_MODE_TEAMS) {
|
||||
query = query.substr(1)
|
||||
}
|
||||
|
||||
if (query === '' || this.selectedCmd !== null) {
|
||||
if (this.query === '' || this.selectedCmd !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -322,15 +335,15 @@ export default {
|
|||
this.teamSearchTimeout = null
|
||||
}
|
||||
|
||||
this.teamSearchTimeout = setTimeout(() => {
|
||||
this.teamService.getAll({}, {s: query})
|
||||
.then(r => {
|
||||
r = r.map(t => {
|
||||
t.title = t.name
|
||||
return t
|
||||
})
|
||||
this.$set(this, 'foundTeams', r)
|
||||
})
|
||||
const {assignees} = this.parsedQuery
|
||||
|
||||
this.teamSearchTimeout = setTimeout(async () => {
|
||||
const teamSearchPromises = assignees.map((t) => this.teamService.getAll({}, {s: t}))
|
||||
const teamsResult = await Promise.all(teamSearchPromises)
|
||||
this.foundTeams = teamsResult.flatMap(team => {
|
||||
team.title = team.name
|
||||
return team
|
||||
})
|
||||
}, 150)
|
||||
},
|
||||
closeQuickActions() {
|
||||
|
@ -358,7 +371,7 @@ export default {
|
|||
this.doAction(this.results[0].type, this.results[0].items[0])
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (this.selectedCmd === null) {
|
||||
return
|
||||
}
|
||||
|
@ -382,22 +395,20 @@ export default {
|
|||
break
|
||||
}
|
||||
},
|
||||
newTask() {
|
||||
async newTask() {
|
||||
if (this.currentList === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.createNewTask(this.query, 0, this.currentList.id)
|
||||
.then(r => {
|
||||
this.$message.success({message: this.$t('task.createSuccess')})
|
||||
this.$router.push({name: 'task.detail', params: {id: r.id}})
|
||||
this.closeQuickActions()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
const task = await this.$store.dispatch('tasks/createNewTask', {
|
||||
title: this.query,
|
||||
listId: this.currentList.id,
|
||||
})
|
||||
this.$message.success({message: this.$t('task.createSuccess')})
|
||||
this.$router.push({name: 'task.detail', params: {id: task.id}})
|
||||
this.closeQuickActions()
|
||||
},
|
||||
newList() {
|
||||
async newList() {
|
||||
if (this.currentList === null) {
|
||||
return
|
||||
}
|
||||
|
@ -406,42 +417,27 @@ export default {
|
|||
title: this.query,
|
||||
namespaceId: this.currentList.namespaceId,
|
||||
})
|
||||
this.$store.dispatch('lists/createList', newList)
|
||||
.then(r => {
|
||||
this.$message.success({message: this.$t('list.create.createdSuccess')})
|
||||
this.$router.push({name: 'list.index', params: {listId: r.id}})
|
||||
this.closeQuickActions()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
const list = await this.$store.dispatch('lists/createList', newList)
|
||||
this.$message.success({message: this.$t('list.create.createdSuccess')})
|
||||
this.$router.push({name: 'list.index', params: {listId: list.id}})
|
||||
this.closeQuickActions()
|
||||
},
|
||||
newNamespace() {
|
||||
async newNamespace() {
|
||||
const newNamespace = new NamespaceModel({title: this.query})
|
||||
|
||||
this.$store.dispatch('namespaces/createNamespace', newNamespace)
|
||||
.then(() => {
|
||||
this.$message.success({message: this.$t('namespace.create.success')})
|
||||
this.closeQuickActions()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
await this.$store.dispatch('namespaces/createNamespace', newNamespace)
|
||||
this.$message.success({message: this.$t('namespace.create.success')})
|
||||
this.closeQuickActions()
|
||||
},
|
||||
newTeam() {
|
||||
async newTeam() {
|
||||
const newTeam = new TeamModel({name: this.query})
|
||||
this.teamService.create(newTeam)
|
||||
.then(r => {
|
||||
this.$router.push({
|
||||
name: 'teams.edit',
|
||||
params: {id: r.id},
|
||||
})
|
||||
this.$message.success({message: this.$t('team.create.success')})
|
||||
this.closeQuickActions()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
const team = await this.teamService.create(newTeam)
|
||||
this.$router.push({
|
||||
name: 'teams.edit',
|
||||
params: {id: team.id},
|
||||
})
|
||||
this.$message.success({message: this.$t('team.create.success')})
|
||||
this.closeQuickActions()
|
||||
},
|
||||
select(parentIndex, index) {
|
||||
|
||||
|
@ -490,16 +486,78 @@ export default {
|
|||
<style lang="scss" scoped>
|
||||
.quick-actions {
|
||||
// FIXME: changed position should be an option of the modal
|
||||
::v-deep.modal-content {
|
||||
:deep(.modal-content) {
|
||||
top: 3rem;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.action-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.input {
|
||||
border: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
&.has-active-cmd .input {
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
.active-cmd {
|
||||
font-size: 1.25rem;
|
||||
margin-left: .5rem;
|
||||
background-color: var(--grey-100);
|
||||
color: var(--grey-800);
|
||||
}
|
||||
}
|
||||
|
||||
.results {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
color: var(--grey-800);
|
||||
|
||||
.result {
|
||||
&-title {
|
||||
background: var(--grey-50);
|
||||
padding: .5rem;
|
||||
display: block;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
&-items {
|
||||
button {
|
||||
font-size: .9rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--grey-800);
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
text-transform: none;
|
||||
font-family: $family-sans-serif;
|
||||
font-weight: normal;
|
||||
padding: .5rem .75rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus, &:hover {
|
||||
background: var(--grey-50);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--grey-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HACK:
|
||||
// FIXME:
|
||||
.modal-container-smaller ::v-deep.hint-modal .modal-container {
|
||||
.modal-container-smaller :deep(.hint-modal .modal-container) {
|
||||
height: calc(100vh - 5rem);
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
|
@ -68,7 +68,7 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<x-button @click="add" icon="plus">
|
||||
<x-button @click="add(listId)" icon="plus">
|
||||
{{ $t('list.share.share') }}
|
||||
</x-button>
|
||||
</div>
|
||||
|
@ -160,7 +160,7 @@
|
|||
<transition name="modal">
|
||||
<modal
|
||||
@close="showDeleteModal = false"
|
||||
@submit="remove()"
|
||||
@submit="remove(listId)"
|
||||
v-if="showDeleteModal"
|
||||
>
|
||||
<template #header>
|
||||
|
@ -215,59 +215,41 @@ export default {
|
|||
frontendUrl: (state) => state.config.frontendUrl,
|
||||
}),
|
||||
methods: {
|
||||
load() {
|
||||
async load(listId) {
|
||||
// If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here
|
||||
if (this.listId === 0) {
|
||||
if (listId === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.linkShareService
|
||||
.getAll({listId: this.listId})
|
||||
.then((r) => {
|
||||
this.linkShares = r
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
this.linkShares = await this.linkShareService.getAll({listId})
|
||||
},
|
||||
add() {
|
||||
async add(listId) {
|
||||
const newLinkShare = new LinkShareModel({
|
||||
right: this.selectedRight,
|
||||
listId: this.listId,
|
||||
listId,
|
||||
name: this.name,
|
||||
password: this.password,
|
||||
})
|
||||
this.linkShareService
|
||||
.create(newLinkShare)
|
||||
.then(() => {
|
||||
this.selectedRight = rights.READ
|
||||
this.name = ''
|
||||
this.password = ''
|
||||
this.showNewForm = false
|
||||
this.$message.success({message: this.$t('list.share.links.createSuccess')})
|
||||
this.load()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
await this.linkShareService.create(newLinkShare)
|
||||
this.selectedRight = rights.READ
|
||||
this.name = ''
|
||||
this.password = ''
|
||||
this.showNewForm = false
|
||||
this.$message.success({message: this.$t('list.share.links.createSuccess')})
|
||||
await this.load(listId)
|
||||
},
|
||||
remove() {
|
||||
async remove(listId) {
|
||||
const linkshare = new LinkShareModel({
|
||||
id: this.linkIdToDelete,
|
||||
listId: this.listId,
|
||||
listId,
|
||||
})
|
||||
this.linkShareService
|
||||
.delete(linkshare)
|
||||
.then(() => {
|
||||
this.$message.success({message: this.$t('list.share.links.deleteSuccess')})
|
||||
this.load()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.showDeleteModal = false
|
||||
})
|
||||
try {
|
||||
await this.linkShareService.delete(linkshare)
|
||||
this.$message.success({message: this.$t('list.share.links.deleteSuccess')})
|
||||
await this.load(listId)
|
||||
} finally {
|
||||
this.showDeleteModal = false
|
||||
}
|
||||
},
|
||||
copy,
|
||||
getShareLink(hash) {
|
||||
|
@ -276,3 +258,10 @@ export default {
|
|||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// FIXME: I think this is not needed
|
||||
.sharables-list:not(.card-content) {
|
||||
overflow-y: auto
|
||||
}
|
||||
</style>
|
|
@ -71,7 +71,7 @@
|
|||
<div class="select">
|
||||
<select
|
||||
@change="toggleType(s)"
|
||||
class="button mr-2"
|
||||
class="mr-2"
|
||||
v-model="selectedRight[s.id]"
|
||||
>
|
||||
<option
|
||||
|
@ -272,45 +272,34 @@ export default {
|
|||
this.load()
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.stuffService
|
||||
.getAll(this.stuffModel)
|
||||
.then((r) => {
|
||||
this.$set(this, 'sharables', r)
|
||||
r.forEach((s) =>
|
||||
this.$set(this.selectedRight, s.id, s.right),
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
async load() {
|
||||
this.sharables = await this.stuffService.getAll(this.stuffModel)
|
||||
this.sharables.forEach((s) =>
|
||||
this.selectedRight[s.id] = s.right,
|
||||
)
|
||||
},
|
||||
deleteSharable() {
|
||||
|
||||
async deleteSharable() {
|
||||
if (this.shareType === 'user') {
|
||||
this.stuffModel.userId = this.sharable.username
|
||||
} else if (this.shareType === 'team') {
|
||||
this.stuffModel.teamId = this.sharable.id
|
||||
}
|
||||
|
||||
this.stuffService
|
||||
.delete(this.stuffModel)
|
||||
.then(() => {
|
||||
this.showDeleteModal = false
|
||||
for (const i in this.sharables) {
|
||||
if (
|
||||
(this.sharables[i].username === this.stuffModel.userId && this.shareType === 'user') ||
|
||||
(this.sharables[i].id === this.stuffModel.teamId && this.shareType === 'team')
|
||||
) {
|
||||
this.sharables.splice(i, 1)
|
||||
}
|
||||
}
|
||||
this.$message.success({message: this.$t('list.share.userTeam.removeSuccess', {type: this.shareTypeName, sharable: this.sharableName})})
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
await this.stuffService.delete(this.stuffModel)
|
||||
this.showDeleteModal = false
|
||||
for (const i in this.sharables) {
|
||||
if (
|
||||
(this.sharables[i].username === this.stuffModel.userId && this.shareType === 'user') ||
|
||||
(this.sharables[i].id === this.stuffModel.teamId && this.shareType === 'team')
|
||||
) {
|
||||
this.sharables.splice(i, 1)
|
||||
}
|
||||
}
|
||||
this.$message.success({message: this.$t('list.share.userTeam.removeSuccess', {type: this.shareTypeName, sharable: this.sharableName})})
|
||||
},
|
||||
add(admin) {
|
||||
|
||||
async add(admin) {
|
||||
if (admin === null) {
|
||||
admin = false
|
||||
}
|
||||
|
@ -325,17 +314,12 @@ export default {
|
|||
this.stuffModel.teamId = this.sharable.id
|
||||
}
|
||||
|
||||
this.stuffService
|
||||
.create(this.stuffModel)
|
||||
.then(() => {
|
||||
this.$message.success({message: this.$t('list.share.userTeam.addedSuccess', {type: this.shareTypeName})})
|
||||
this.load()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
await this.stuffService.create(this.stuffModel)
|
||||
this.$message.success({message: this.$t('list.share.userTeam.addedSuccess', {type: this.shareTypeName})})
|
||||
await this.load()
|
||||
},
|
||||
toggleType(sharable) {
|
||||
|
||||
async toggleType(sharable) {
|
||||
if (
|
||||
this.selectedRight[sharable.id] !== rights.ADMIN &&
|
||||
this.selectedRight[sharable.id] !== rights.READ &&
|
||||
|
@ -351,43 +335,32 @@ export default {
|
|||
this.stuffModel.teamId = sharable.id
|
||||
}
|
||||
|
||||
this.stuffService
|
||||
.update(this.stuffModel)
|
||||
.then((r) => {
|
||||
for (const i in this.sharables) {
|
||||
if (
|
||||
(this.sharables[i].username ===
|
||||
this.stuffModel.userId &&
|
||||
this.shareType === 'user') ||
|
||||
(this.sharables[i].id === this.stuffModel.teamId &&
|
||||
this.shareType === 'team')
|
||||
) {
|
||||
this.$set(this.sharables[i], 'right', r.right)
|
||||
}
|
||||
}
|
||||
this.$message.success({message: this.$t('list.share.userTeam.updatedSuccess', {type: this.shareTypeName})})
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
const r = await this.stuffService.update(this.stuffModel)
|
||||
for (const i in this.sharables) {
|
||||
if (
|
||||
(this.sharables[i].username ===
|
||||
this.stuffModel.userId &&
|
||||
this.shareType === 'user') ||
|
||||
(this.sharables[i].id === this.stuffModel.teamId &&
|
||||
this.shareType === 'team')
|
||||
) {
|
||||
this.sharables[i].right = r.right
|
||||
}
|
||||
}
|
||||
this.$message.success({message: this.$t('list.share.userTeam.updatedSuccess', {type: this.shareTypeName})})
|
||||
},
|
||||
find(query) {
|
||||
|
||||
async find(query) {
|
||||
if (query === '') {
|
||||
this.$set(this, 'found', [])
|
||||
this.clearAll()
|
||||
return
|
||||
}
|
||||
|
||||
this.searchService
|
||||
.getAll({}, {s: query})
|
||||
.then((response) => {
|
||||
this.$set(this, 'found', response)
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
this.found = await this.searchService.getAll({}, {s: query})
|
||||
},
|
||||
|
||||
clearAll() {
|
||||
this.$set(this, 'found', [])
|
||||
this.found = []
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -3,14 +3,13 @@
|
|||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left is-expanded">
|
||||
<textarea
|
||||
:disabled="taskService.loading || null"
|
||||
class="input"
|
||||
:disabled="taskService.loading || undefined"
|
||||
class="add-task-textarea input"
|
||||
:placeholder="$t('list.list.addPlaceholder')"
|
||||
type="text"
|
||||
rows="1"
|
||||
v-focus
|
||||
v-model="newTaskTitle"
|
||||
ref="newTaskInput"
|
||||
:style="{'height': `${textAreaHeight}px`}"
|
||||
@keyup="errorMessage = ''"
|
||||
@keydown.enter="handleEnter"
|
||||
/>
|
||||
|
@ -20,7 +19,8 @@
|
|||
</p>
|
||||
<p class="control">
|
||||
<x-button
|
||||
:disabled="newTaskTitle === '' || taskService.loading || null"
|
||||
class="add-task-button"
|
||||
:disabled="newTaskTitle === '' || taskService.loading || undefined"
|
||||
@click="addTask()"
|
||||
icon="plus"
|
||||
:loading="taskService.loading"
|
||||
|
@ -32,117 +32,171 @@
|
|||
<p class="help is-danger" v-if="errorMessage !== ''">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<quick-add-magic v-if="errorMessage === ''"/>
|
||||
<quick-add-magic v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TaskService from '../../services/task'
|
||||
import createTask from '@/components/tasks/mixins/createTask'
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, unref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useStore} from 'vuex'
|
||||
import { tryOnMounted, debouncedWatch, useWindowSize, MaybeRef } from '@vueuse/core'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
|
||||
|
||||
const INITIAL_SCROLL_HEIGHT = 40
|
||||
|
||||
const cleanupTitle = title => {
|
||||
function cleanupTitle(title: string) {
|
||||
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'add-task',
|
||||
data() {
|
||||
return {
|
||||
newTaskTitle: '',
|
||||
taskService: new TaskService(),
|
||||
errorMessage: '',
|
||||
textAreaHeight: INITIAL_SCROLL_HEIGHT,
|
||||
function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
const textarea = ref<HTMLInputElement>()
|
||||
const minHeight = ref(0)
|
||||
|
||||
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
|
||||
function resize(textareaEl: HTMLInputElement|undefined) {
|
||||
if (!textareaEl) return
|
||||
|
||||
let empty
|
||||
|
||||
// the value here is the the attribute value
|
||||
if (!textareaEl.value && textareaEl.placeholder) {
|
||||
empty = true
|
||||
textareaEl.value = textareaEl.placeholder
|
||||
}
|
||||
},
|
||||
mixins: [
|
||||
createTask,
|
||||
],
|
||||
components: {
|
||||
QuickAddMagic,
|
||||
},
|
||||
props: {
|
||||
defaultPosition: {
|
||||
type: Number,
|
||||
required: false,
|
||||
|
||||
const cs = getComputedStyle(textareaEl)
|
||||
|
||||
textareaEl.style.minHeight = ''
|
||||
textareaEl.style.height = '0'
|
||||
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
|
||||
const height = textareaEl.scrollHeight + offset + 'px'
|
||||
|
||||
textareaEl.style.height = height
|
||||
|
||||
// calculate min-height for the first time
|
||||
if (!minHeight.value) {
|
||||
minHeight.value = parseFloat(height)
|
||||
}
|
||||
|
||||
textareaEl.style.minHeight = minHeight.value.toString()
|
||||
|
||||
|
||||
if (empty) {
|
||||
textareaEl.value = ''
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
tryOnMounted(() => {
|
||||
if (textarea.value) {
|
||||
// we don't want scrollbars
|
||||
textarea.value.style.overflowY = 'hidden'
|
||||
}
|
||||
})
|
||||
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
|
||||
debouncedWatch(
|
||||
windowWidth,
|
||||
() => resize(textarea.value),
|
||||
{ debounce: 200 },
|
||||
)
|
||||
|
||||
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
|
||||
// So instead we watch the value that we bound to it.
|
||||
watch(
|
||||
() => [textarea.value, unref(value)],
|
||||
() => resize(textarea.value),
|
||||
{
|
||||
immediate: true, // calculate initial size
|
||||
flush: 'post', // resize after value change is rendered to DOM
|
||||
},
|
||||
)
|
||||
|
||||
return textarea
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
defaultPosition: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
watch: {
|
||||
newTaskTitle(newVal) {
|
||||
let scrollHeight = this.$refs.newTaskInput.scrollHeight
|
||||
if (scrollHeight < INITIAL_SCROLL_HEIGHT || newVal === '') {
|
||||
scrollHeight = INITIAL_SCROLL_HEIGHT
|
||||
}
|
||||
})
|
||||
|
||||
this.textAreaHeight = scrollHeight
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addTask() {
|
||||
if (this.newTaskTitle === '') {
|
||||
this.errorMessage = this.$t('list.create.addTitleRequired')
|
||||
return
|
||||
}
|
||||
this.errorMessage = ''
|
||||
const emit = defineEmits(['taskAdded'])
|
||||
|
||||
if (this.taskService.loading) {
|
||||
return
|
||||
}
|
||||
const newTaskTitle = ref('')
|
||||
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
|
||||
|
||||
const newTasks = []
|
||||
this.newTaskTitle.split(/[\r\n]+/).forEach(t => {
|
||||
const title = cleanupTitle(t)
|
||||
if (title === '') {
|
||||
return
|
||||
}
|
||||
|
||||
newTasks.push(
|
||||
this.createNewTask(title, 0, this.$store.state.auth.settings.defaultListId, this.defaultPosition)
|
||||
.then(task => {
|
||||
this.$emit('taskAdded', task)
|
||||
return task
|
||||
}),
|
||||
)
|
||||
})
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
|
||||
Promise.all(newTasks)
|
||||
.then(() => {
|
||||
this.newTaskTitle = ''
|
||||
})
|
||||
.catch(e => {
|
||||
if (e === 'NO_LIST') {
|
||||
this.errorMessage = this.$t('list.create.addListRequired')
|
||||
return
|
||||
}
|
||||
this.$message.error(e)
|
||||
})
|
||||
},
|
||||
handleEnter(e) {
|
||||
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
|
||||
// the new task(s). The vue event modifier don't allow this, hence this method.
|
||||
if (e.shiftKey) {
|
||||
return
|
||||
}
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const errorMessage = ref('')
|
||||
|
||||
e.preventDefault()
|
||||
this.addTask()
|
||||
},
|
||||
},
|
||||
async function addTask() {
|
||||
if (newTaskTitle.value === '') {
|
||||
errorMessage.value = t('list.create.addTitleRequired')
|
||||
return
|
||||
}
|
||||
errorMessage.value = ''
|
||||
|
||||
if (taskService.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskTitleBackup = newTaskTitle.value
|
||||
const newTasks = newTaskTitle.value.split(/[\r\n]+/).map(async uncleanedTitle => {
|
||||
const title = cleanupTitle(uncleanedTitle)
|
||||
if (title === '') {
|
||||
return
|
||||
}
|
||||
|
||||
const task = await store.dispatch('tasks/createNewTask', {
|
||||
title,
|
||||
listId: store.state.auth.settings.defaultListId,
|
||||
position: props.defaultPosition,
|
||||
})
|
||||
emit('taskAdded', task)
|
||||
return task
|
||||
})
|
||||
|
||||
try {
|
||||
newTaskTitle.value = ''
|
||||
await Promise.all(newTasks)
|
||||
} catch (e: any) {
|
||||
newTaskTitle.value = taskTitleBackup
|
||||
if (e?.message === 'NO_LIST') {
|
||||
errorMessage.value = t('list.create.addListRequired')
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter(e: KeyboardEvent) {
|
||||
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
|
||||
// the new task(s). The vue event modifier don't allow this, hence this method.
|
||||
if (e.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
addTask()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-add {
|
||||
margin-bottom: 0;
|
||||
|
||||
.button {
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.input, .textarea {
|
||||
.add-task-button {
|
||||
height: 2.5rem;
|
||||
}
|
||||
.add-task-textarea {
|
||||
transition: border-color $transition;
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
<template>
|
||||
<card
|
||||
class="taskedit"
|
||||
:title="$t('list.list.editTask')"
|
||||
@close="$emit('close')"
|
||||
:has-close="true"
|
||||
>
|
||||
<form @submit.prevent="editTaskSubmit()">
|
||||
<div class="field">
|
||||
<label class="label" for="tasktext">{{ $t('task.attributes.title') }}</label>
|
||||
|
@ -66,31 +72,27 @@
|
|||
{{ $t('task.openDetail') }}
|
||||
</router-link>
|
||||
</form>
|
||||
</card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListService from '../../services/list'
|
||||
import AsyncEditor from '@/components/input/AsyncEditor'
|
||||
|
||||
import TaskService from '../../services/task'
|
||||
import TaskModel from '../../models/task'
|
||||
import priorities from '../../models/constants/priorities'
|
||||
import EditLabels from './partials/editLabels'
|
||||
import Reminders from './partials/reminders'
|
||||
import ColorPicker from '../input/colorPicker'
|
||||
import LoadingComponent from '../misc/loading'
|
||||
import ErrorComponent from '../misc/error'
|
||||
|
||||
export default {
|
||||
name: 'edit-task',
|
||||
data() {
|
||||
return {
|
||||
listId: this.$route.params.id,
|
||||
listService: new ListService(),
|
||||
taskService: new TaskService(),
|
||||
|
||||
priorities: priorities,
|
||||
list: {},
|
||||
editorActive: false,
|
||||
newTask: new TaskModel(),
|
||||
isTaskEdit: false,
|
||||
taskEditTask: TaskModel,
|
||||
}
|
||||
|
@ -99,12 +101,7 @@ export default {
|
|||
ColorPicker,
|
||||
Reminders,
|
||||
EditLabels,
|
||||
editor: () => ({
|
||||
component: import('../../components/input/editor'),
|
||||
loading: LoadingComponent,
|
||||
error: ErrorComponent,
|
||||
timeout: 60000,
|
||||
}),
|
||||
editor: AsyncEditor,
|
||||
},
|
||||
props: {
|
||||
task: {
|
||||
|
@ -139,18 +136,44 @@ export default {
|
|||
this.editorActive = false
|
||||
this.$nextTick(() => (this.editorActive = true))
|
||||
},
|
||||
editTaskSubmit() {
|
||||
this.taskService
|
||||
.update(this.taskEditTask)
|
||||
.then((r) => {
|
||||
this.$set(this, 'taskEditTask', r)
|
||||
this.initTaskFields()
|
||||
this.$message.success({message: this.$t('task.detail.updateSuccess')})
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
async editTaskSubmit() {
|
||||
this.taskEditTask = await this.taskService.update(this.taskEditTask)
|
||||
this.initTaskFields()
|
||||
this.$message.success({message: this.$t('task.detail.updateSuccess')})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.priority-select {
|
||||
.select,
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
ul.assingees {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
padding: 0.5rem 0.5rem 0;
|
||||
|
||||
a {
|
||||
float: right;
|
||||
color: var(--danger);
|
||||
transition: all $transition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -2,23 +2,15 @@
|
|||
<div class="gantt-chart">
|
||||
<div class="filter-container">
|
||||
<div class="items">
|
||||
<x-button
|
||||
@click.prevent.stop="showTaskFilter = !showTaskFilter"
|
||||
type="secondary"
|
||||
icon="filter"
|
||||
>
|
||||
{{ $t('filters.title') }}
|
||||
</x-button>
|
||||
<filter-popup
|
||||
v-model="params"
|
||||
@update:modelValue="loadTasks()"
|
||||
/>
|
||||
</div>
|
||||
<filter-popup
|
||||
@change="loadTasks()"
|
||||
:visible="showTaskFilter"
|
||||
v-model="params"
|
||||
/>
|
||||
</div>
|
||||
<div class="dates">
|
||||
<template v-for="(y, yk) in days">
|
||||
<div :key="yk + 'year'" class="months">
|
||||
<template v-for="(y, yk) in days" :key="yk + 'year'">
|
||||
<div class="months">
|
||||
<div
|
||||
:key="mk + 'month'"
|
||||
class="month"
|
||||
|
@ -86,9 +78,8 @@
|
|||
:w="t.durationDays * dayWidth"
|
||||
:x="t.offsetDays * dayWidth - 6"
|
||||
:y="0"
|
||||
@clicked="setTaskDragged(t)"
|
||||
@dragstop="resizeTask"
|
||||
@resizestop="resizeTask"
|
||||
@dragstop="(e) => resizeTask(t, e)"
|
||||
@resizestop="(e) => resizeTask(t, e)"
|
||||
axis="x"
|
||||
class="task"
|
||||
>
|
||||
|
@ -136,9 +127,8 @@
|
|||
:sticks="['mr', 'ml']"
|
||||
:x="dayOffsetUntilToday * dayWidth - 6"
|
||||
:y="0"
|
||||
@clicked="setTaskDragged(t)"
|
||||
@dragstop="resizeTask"
|
||||
@resizestop="resizeTask"
|
||||
@dragstop="(e) => resizeTask(t, e)"
|
||||
@resizestop="(e) => resizeTask(t, e)"
|
||||
axis="x"
|
||||
class="task nodate"
|
||||
v-tooltip="$t('list.gantt.noDates')"
|
||||
|
@ -169,15 +159,13 @@
|
|||
</x-button>
|
||||
</form>
|
||||
<transition name="fade">
|
||||
<card
|
||||
<edit-task
|
||||
v-if="isTaskEdit"
|
||||
class="taskedit"
|
||||
:title="$t('list.list.editTask')"
|
||||
@close="() => {isTaskEdit = false;taskToEdit = null}"
|
||||
:has-close="true"
|
||||
>
|
||||
<edit-task :task="taskToEdit"/>
|
||||
</card>
|
||||
:task="taskToEdit"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -195,6 +183,8 @@ import {mapState} from 'vuex'
|
|||
import Rights from '../../models/constants/rights.json'
|
||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
|
||||
export default {
|
||||
name: 'GanttChart',
|
||||
components: {
|
||||
|
@ -213,10 +203,10 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
dateFrom: {
|
||||
default: new Date(new Date().setDate(new Date().getDate() - 15)),
|
||||
default: () => new Date(new Date().setDate(new Date().getDate() - 15)),
|
||||
},
|
||||
dateTo: {
|
||||
default: new Date(new Date().setDate(new Date().getDate() + 30)),
|
||||
default: () => new Date(new Date().setDate(new Date().getDate() + 30)),
|
||||
},
|
||||
// The width of a day in pixels, used to calculate all sorts of things.
|
||||
dayWidth: {
|
||||
|
@ -232,7 +222,6 @@ export default {
|
|||
theTasks: [], // Pretty much a copy of the prop, since we cant mutate the prop directly
|
||||
tasksWithoutDates: [],
|
||||
taskService: new TaskService(),
|
||||
taskDragged: null, // Saves to currently dragged task to be able to update it
|
||||
fullWidth: 0,
|
||||
now: new Date(),
|
||||
dayOffsetUntilToday: 0,
|
||||
|
@ -242,7 +231,6 @@ export default {
|
|||
newTaskFieldActive: false,
|
||||
priorities: priorities,
|
||||
taskCollectionService: new TaskCollectionService(),
|
||||
showTaskFilter: false,
|
||||
|
||||
params: {
|
||||
sort_by: ['done', 'id'],
|
||||
|
@ -266,6 +254,7 @@ export default {
|
|||
canWrite: (state) => state.currentList.maxRight > Rights.READ,
|
||||
}),
|
||||
methods: {
|
||||
colorIsDark,
|
||||
buildTheGanttChart() {
|
||||
this.setDates()
|
||||
this.prepareGanttDays()
|
||||
|
@ -298,56 +287,43 @@ export default {
|
|||
this.fullWidth += this.dayWidth
|
||||
}
|
||||
console.debug('prepareGanttDays; years:', years)
|
||||
this.$set(this, 'days', years)
|
||||
this.days = years
|
||||
},
|
||||
|
||||
parseTasks() {
|
||||
this.setDates()
|
||||
this.loadTasks()
|
||||
},
|
||||
loadTasks() {
|
||||
this.$set(this, 'theTasks', [])
|
||||
this.$set(this, 'tasksWithoutDates', [])
|
||||
|
||||
const getAllTasks = (page = 1) => {
|
||||
return this.taskCollectionService
|
||||
.getAll({listId: this.listId}, this.params, page)
|
||||
.then((tasks) => {
|
||||
if (page < this.taskCollectionService.totalPages) {
|
||||
return getAllTasks(page + 1).then((nextTasks) => {
|
||||
return tasks.concat(nextTasks)
|
||||
})
|
||||
} else {
|
||||
return tasks
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
return Promise.reject(e)
|
||||
})
|
||||
async loadTasks() {
|
||||
this.theTasks = []
|
||||
this.tasksWithoutDates = []
|
||||
|
||||
const getAllTasks = async (page = 1) => {
|
||||
const tasks = await this.taskCollectionService.getAll({listId: this.listId}, this.params, page)
|
||||
if (page < this.taskCollectionService.totalPages) {
|
||||
const nextTasks = await getAllTasks(page + 1)
|
||||
return tasks.concat(nextTasks)
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
getAllTasks()
|
||||
.then((tasks) => {
|
||||
this.theTasks = tasks
|
||||
.filter((t) => {
|
||||
if (t.startDate === null && !t.done) {
|
||||
this.tasksWithoutDates.push(t)
|
||||
}
|
||||
return (
|
||||
t.startDate >= this.startDate &&
|
||||
t.endDate <= this.endDate
|
||||
)
|
||||
})
|
||||
.map((t) => {
|
||||
return this.addGantAttributes(t)
|
||||
})
|
||||
.sort(function (a, b) {
|
||||
if (a.startDate < b.startDate) return -1
|
||||
if (a.startDate > b.startDate) return 1
|
||||
return 0
|
||||
})
|
||||
const tasks = await getAllTasks()
|
||||
this.theTasks = tasks
|
||||
.filter((t) => {
|
||||
if (t.startDate === null && !t.done) {
|
||||
this.tasksWithoutDates.push(t)
|
||||
}
|
||||
return (
|
||||
t.startDate >= this.startDate &&
|
||||
t.endDate <= this.endDate
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
.map((t) => this.addGantAttributes(t))
|
||||
.sort(function (a, b) {
|
||||
if (a.startDate < b.startDate) return -1
|
||||
if (a.startDate > b.startDate) return 1
|
||||
return 0
|
||||
})
|
||||
},
|
||||
addGantAttributes(t) {
|
||||
|
@ -360,15 +336,14 @@ export default {
|
|||
t.offsetDays = Math.floor((t.startDate - this.startDate) / 1000 / 60 / 60 / 24)
|
||||
return t
|
||||
},
|
||||
setTaskDragged(t) {
|
||||
this.taskDragged = t
|
||||
},
|
||||
resizeTask(newRect) {
|
||||
async resizeTask(taskDragged, newRect) {
|
||||
if (this.isTaskEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
const didntHaveDates = this.taskDragged.startDate === null ? true : false
|
||||
let newTask = {...taskDragged}
|
||||
|
||||
const didntHaveDates = newTask.startDate === null ? true : false
|
||||
|
||||
let startDate = new Date(this.startDate)
|
||||
startDate.setDate(
|
||||
|
@ -378,62 +353,52 @@ export default {
|
|||
startDate.setUTCMinutes(0)
|
||||
startDate.setUTCSeconds(0)
|
||||
startDate.setUTCMilliseconds(0)
|
||||
this.taskDragged.startDate = startDate
|
||||
newTask.startDate = startDate
|
||||
let endDate = new Date(startDate)
|
||||
endDate.setDate(
|
||||
startDate.getDate() + newRect.width / this.dayWidth,
|
||||
)
|
||||
this.taskDragged.startDate = startDate
|
||||
this.taskDragged.endDate = endDate
|
||||
newTask.startDate = startDate
|
||||
newTask.endDate = endDate
|
||||
|
||||
// We take the task from the overall tasks array because the one in it has bad data after it was updated once.
|
||||
// FIXME: This is a workaround. We should use a better mechanism to get the task or, even better,
|
||||
// prevent it from containing outdated Data in the first place.
|
||||
for (const tt in this.theTasks) {
|
||||
if (this.theTasks[tt].id === this.taskDragged.id) {
|
||||
this.$set(this, 'taskDragged', this.theTasks[tt])
|
||||
if (this.theTasks[tt].id === newTask.id) {
|
||||
newTask = this.theTasks[tt]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const ganttData = {
|
||||
endDate: this.taskDragged.endDate,
|
||||
durationDays: this.taskDragged.durationDays,
|
||||
offsetDays: this.taskDragged.offsetDays,
|
||||
endDate: newTask.endDate,
|
||||
durationDays: newTask.durationDays,
|
||||
offsetDays: newTask.offsetDays,
|
||||
}
|
||||
|
||||
this.taskService
|
||||
.update(this.taskDragged)
|
||||
.then(r => {
|
||||
r.endDate = ganttData.endDate
|
||||
r.durationDays = ganttData.durationDays
|
||||
r.offsetDays = ganttData.offsetDays
|
||||
const r = await this.taskService.update(newTask)
|
||||
r.endDate = ganttData.endDate
|
||||
r.durationDays = ganttData.durationDays
|
||||
r.offsetDays = ganttData.offsetDays
|
||||
|
||||
// If the task didn't have dates before, we'll update the list
|
||||
if (didntHaveDates) {
|
||||
for (const t in this.tasksWithoutDates) {
|
||||
if (this.tasksWithoutDates[t].id === r.id) {
|
||||
this.tasksWithoutDates.splice(t, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.theTasks.push(this.addGantAttributes(r))
|
||||
} else {
|
||||
for (const tt in this.theTasks) {
|
||||
if (this.theTasks[tt].id === r.id) {
|
||||
this.$set(
|
||||
this.theTasks,
|
||||
tt,
|
||||
this.addGantAttributes(r),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
// If the task didn't have dates before, we'll update the list
|
||||
if (didntHaveDates) {
|
||||
for (const t in this.tasksWithoutDates) {
|
||||
if (this.tasksWithoutDates[t].id === r.id) {
|
||||
this.tasksWithoutDates.splice(t, 1)
|
||||
break
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
}
|
||||
this.theTasks.push(this.addGantAttributes(r))
|
||||
} else {
|
||||
for (const tt in this.theTasks) {
|
||||
if (this.theTasks[tt].id === r.id) {
|
||||
this.theTasks[tt] = this.addGantAttributes(r)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
editTask(task) {
|
||||
this.taskToEdit = task
|
||||
|
@ -453,7 +418,7 @@ export default {
|
|||
this.$nextTick(() => (this.newTaskFieldActive = false))
|
||||
}
|
||||
},
|
||||
addNewTask() {
|
||||
async addNewTask() {
|
||||
if (!this.newTaskFieldActive) {
|
||||
return
|
||||
}
|
||||
|
@ -461,16 +426,10 @@ export default {
|
|||
title: this.newTaskTitle,
|
||||
listId: this.listId,
|
||||
})
|
||||
this.taskService
|
||||
.create(task)
|
||||
.then((r) => {
|
||||
this.tasksWithoutDates.push(this.addGantAttributes(r))
|
||||
this.newTaskTitle = ''
|
||||
this.hideCrateNewTask()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
const r = await this.taskService.create(task)
|
||||
this.tasksWithoutDates.push(this.addGantAttributes(r))
|
||||
this.newTaskTitle = ''
|
||||
this.hideCrateNewTask()
|
||||
},
|
||||
formatYear(date) {
|
||||
return this.format(date, 'MMMM, yyyy')
|
||||
|
@ -478,3 +437,196 @@ export default {
|
|||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$gantt-border: 1px solid var(--grey-200);
|
||||
$gantt-vertical-border-color: var(--grey-100);
|
||||
|
||||
.gantt-chart {
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--grey-200);
|
||||
|
||||
.dates {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
|
||||
.months {
|
||||
display: flex;
|
||||
|
||||
.month {
|
||||
padding: 0.5rem 0 0;
|
||||
border-right: $gantt-border;
|
||||
font-family: $vikunja-font;
|
||||
font-weight: bold;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.days {
|
||||
display: flex;
|
||||
|
||||
.day {
|
||||
padding: 0.5rem 0;
|
||||
font-weight: normal;
|
||||
|
||||
&.today {
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: 5px 5px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.theday {
|
||||
padding: 0 .5rem;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tasks {
|
||||
max-width: unset !important;
|
||||
border-top: $gantt-border;
|
||||
|
||||
.row {
|
||||
height: 45px;
|
||||
|
||||
.task {
|
||||
display: inline-block;
|
||||
border: 2px solid var(--primary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
height: 31px !important;
|
||||
|
||||
-webkit-touch-callout: none; // iOS Safari
|
||||
user-select: none; // Non-prefixed version
|
||||
|
||||
&.is-current-edit {
|
||||
border-color: var(--warning) !important;
|
||||
}
|
||||
|
||||
&.has-light-text {
|
||||
color: var(--light);
|
||||
|
||||
&.done span:after {
|
||||
border-top: 1px solid var(--light);
|
||||
}
|
||||
|
||||
.edit-toggle {
|
||||
color: var(--light);
|
||||
}
|
||||
}
|
||||
|
||||
&.has-dark-text {
|
||||
color: var(--text);
|
||||
|
||||
&.done span:after {
|
||||
border-top: 1px solid var(--dark);
|
||||
}
|
||||
|
||||
.edit-toggle {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
&.done span {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 57%;
|
||||
}
|
||||
}
|
||||
|
||||
span:not(.high-priority) {
|
||||
max-width: calc(100% - 20px);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
&.has-high-priority {
|
||||
max-width: calc(100% - 90px);
|
||||
}
|
||||
|
||||
&.has-not-so-high-priority {
|
||||
max-width: calc(100% - 70px);
|
||||
}
|
||||
|
||||
&.has-super-high-priority {
|
||||
max-width: calc(100% - 111px);
|
||||
}
|
||||
|
||||
&.icon {
|
||||
width: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.high-priority {
|
||||
margin: 0 0 0 .5rem;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.edit-toggle {
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.nodate {
|
||||
border: 2px dashed var(--grey-300);
|
||||
background: var(--grey-100);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.taskedit {
|
||||
position: fixed;
|
||||
top: 10vh;
|
||||
right: 10vw;
|
||||
z-index: 5;
|
||||
|
||||
// FIXME: should be an option of the card, e.g. overflow
|
||||
:deep(.card-content) {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-task {
|
||||
padding: 1rem .7rem .4rem .7rem;
|
||||
display: flex;
|
||||
max-width: 450px;
|
||||
|
||||
.input {
|
||||
margin-right: .7rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: .68rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,141 +0,0 @@
|
|||
import {parseTaskText} from '@/modules/parseTaskText'
|
||||
import TaskModel from '@/models/task'
|
||||
import {formatISO} from 'date-fns'
|
||||
import LabelTask from '@/models/labelTask'
|
||||
import LabelModel from '@/models/label'
|
||||
import LabelTaskService from '@/services/labelTask'
|
||||
import {mapState} from 'vuex'
|
||||
import UserService from '@/services/user'
|
||||
import TaskService from '@/services/task'
|
||||
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
taskService: TaskService,
|
||||
labelTaskService: LabelTaskService,
|
||||
userService: UserService,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.labelTaskService = new LabelTaskService()
|
||||
this.userService = new UserService()
|
||||
this.taskService = new TaskService()
|
||||
},
|
||||
computed: mapState({
|
||||
labels: state => state.labels.labels,
|
||||
}),
|
||||
methods: {
|
||||
createNewTask(newTaskTitle, bucketId = 0, lId = 0, position = 0) {
|
||||
const parsedTask = parseTaskText(newTaskTitle, getQuickAddMagicMode())
|
||||
const assignees = []
|
||||
|
||||
// Uses the following ways to get the list id of the new task:
|
||||
// 1. If specified in quick add magic, look in store if it exists and use it if it does
|
||||
// 2. Else check if a list was passed as parameter
|
||||
// 3. Otherwise use the id from the route parameter
|
||||
// 4. If none of the above worked, reject the promise with an error.
|
||||
let listId = null
|
||||
if (parsedTask.list !== null) {
|
||||
const list = this.$store.getters['lists/findListByExactname'](parsedTask.list)
|
||||
listId = list === null ? null : list.id
|
||||
}
|
||||
if (lId !== 0) {
|
||||
listId = lId
|
||||
}
|
||||
if (typeof this.$route.params.listId !== 'undefined') {
|
||||
listId = parseInt(this.$route.params.listId)
|
||||
}
|
||||
|
||||
if (typeof listId === 'undefined' || listId === null) {
|
||||
return Promise.reject('NO_LIST')
|
||||
}
|
||||
|
||||
// Separate closure because we need to wait for the results of the user search if users were entered in the
|
||||
// task create request. Because _that_ happens in a promise, we'll need something to call when it resolves.
|
||||
const createTask = () => {
|
||||
const task = new TaskModel({
|
||||
title: parsedTask.text,
|
||||
listId: listId,
|
||||
dueDate: parsedTask.date !== null ? formatISO(parsedTask.date) : null, // I don't know why, but it all goes up in flames when I just pass in the date normally.
|
||||
priority: parsedTask.priority,
|
||||
assignees: assignees,
|
||||
bucketId: bucketId,
|
||||
position: position,
|
||||
})
|
||||
return this.taskService.create(task)
|
||||
.then(task => {
|
||||
|
||||
if (parsedTask.labels.length > 0) {
|
||||
|
||||
const labelAddsToWaitFor = []
|
||||
|
||||
const addLabelToTask = label => {
|
||||
const labelTask = new LabelTask({
|
||||
taskId: task.id,
|
||||
labelId: label.id,
|
||||
})
|
||||
return this.labelTaskService.create(labelTask)
|
||||
.then(result => {
|
||||
task.labels.push(label)
|
||||
return Promise.resolve(result)
|
||||
})
|
||||
.catch(e => Promise.reject(e))
|
||||
}
|
||||
|
||||
// Then do everything that is involved in finding, creating and adding the label to the task
|
||||
parsedTask.labels.forEach(labelTitle => {
|
||||
// Check if the label exists
|
||||
const label = Object.values(this.labels).find(l => {
|
||||
return l.title.toLowerCase() === labelTitle.toLowerCase()
|
||||
})
|
||||
|
||||
// Label found, use it
|
||||
if (typeof label !== 'undefined') {
|
||||
labelAddsToWaitFor.push(addLabelToTask(label))
|
||||
} else {
|
||||
// label not found, create it
|
||||
const label = new LabelModel({title: labelTitle})
|
||||
labelAddsToWaitFor.push(this.$store.dispatch('labels/createLabel', label)
|
||||
.then(res => {
|
||||
return addLabelToTask(res)
|
||||
})
|
||||
.catch(e => Promise.reject(e)),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// This waits until all labels are created and added to the task
|
||||
return Promise.all(labelAddsToWaitFor)
|
||||
.then(() => {
|
||||
return Promise.resolve(task)
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve(task)
|
||||
})
|
||||
.catch(e => Promise.reject(e))
|
||||
}
|
||||
|
||||
if (parsedTask.assignees.length > 0) {
|
||||
const searches = []
|
||||
parsedTask.assignees.forEach(a => {
|
||||
searches.push(this.userService.getAll({}, {s: a})
|
||||
.then(users => {
|
||||
const user = users.find(u => u.username.toLowerCase() === a.toLowerCase())
|
||||
if (typeof user !== 'undefined') {
|
||||
assignees.push(user)
|
||||
}
|
||||
return Promise.resolve(users)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Promise.all(searches)
|
||||
.then(() => createTask())
|
||||
}
|
||||
|
||||
return createTask()
|
||||
},
|
||||
},
|
||||
}
|
|
@ -1,14 +1,14 @@
|
|||
import TaskCollectionService from '@/services/taskCollection'
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in filters.vue
|
||||
const DEFAULT_PARAMS = {
|
||||
export const getDefaultParams = () => ({
|
||||
sort_by: ['position', 'id'],
|
||||
order_by: ['asc', 'desc'],
|
||||
filter_by: ['done'],
|
||||
filter_value: ['false'],
|
||||
filter_comparator: ['equals'],
|
||||
filter_concat: 'and',
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* This mixin provides a base set of methods and properties to get tasks on a list.
|
||||
|
@ -26,7 +26,7 @@ export default {
|
|||
searchTerm: '',
|
||||
|
||||
showTaskFilter: false,
|
||||
params: DEFAULT_PARAMS,
|
||||
params: {...getDefaultParams()},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -38,13 +38,12 @@ export default {
|
|||
'$route.path': 'loadTasksOnSavedFilter',
|
||||
},
|
||||
methods: {
|
||||
loadTasks(
|
||||
async loadTasks(
|
||||
page,
|
||||
search = '',
|
||||
params = null,
|
||||
forceLoading = false,
|
||||
) {
|
||||
|
||||
// Because this function is triggered every time on topNavigation, we're putting a condition here to only load it when we actually want to show tasks
|
||||
// FIXME: This is a bit hacky -> Cleanup.
|
||||
if (
|
||||
|
@ -76,17 +75,9 @@ export default {
|
|||
}
|
||||
|
||||
this.tasks = []
|
||||
|
||||
this.taskCollectionService.getAll(list, params, page)
|
||||
.then(r => {
|
||||
this.tasks = r
|
||||
this.currentPage = page
|
||||
|
||||
this.loadedList = JSON.parse(JSON.stringify(currentList))
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
this.tasks = await this.taskCollectionService.getAll(list, params, page)
|
||||
this.currentPage = page
|
||||
this.loadedList = JSON.parse(JSON.stringify(currentList))
|
||||
},
|
||||
|
||||
loadTasksForPage(e) {
|
||||
|
@ -102,7 +93,7 @@ export default {
|
|||
this.initTasks(page, search)
|
||||
},
|
||||
loadTasksOnSavedFilter() {
|
||||
if(typeof this.$route.params.listId !== 'undefined' && parseInt(this.$route.params.listId) < 0) {
|
||||
if (typeof this.$route.params.listId !== 'undefined' && parseInt(this.$route.params.listId) < 0) {
|
||||
this.loadTasks(1, '', null, true)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
<div class="filename">{{ a.file.name }}</div>
|
||||
<div class="info">
|
||||
<p class="collapses">
|
||||
<i18n path="task.attachment.createdBy">
|
||||
<i18n-t keypath="task.attachment.createdBy">
|
||||
<span v-tooltip="formatDate(a.created)">
|
||||
{{ formatDateSince(a.created) }}
|
||||
</span>
|
||||
|
@ -44,7 +44,7 @@
|
|||
:user="a.createdBy"
|
||||
:is-inline="true"
|
||||
/>
|
||||
</i18n>
|
||||
</i18n-t>
|
||||
<span>
|
||||
{{ a.file.getHumanSize() }}
|
||||
</span>
|
||||
|
@ -83,7 +83,7 @@
|
|||
@click="$refs.files.click()"
|
||||
class="mb-4"
|
||||
icon="cloud-upload-alt"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
>
|
||||
{{ $t('task.attachment.upload') }}
|
||||
|
@ -218,24 +218,19 @@ export default {
|
|||
uploadFiles(files) {
|
||||
uploadFiles(this.attachmentService, this.taskId, files)
|
||||
},
|
||||
deleteAttachment() {
|
||||
this.attachmentService
|
||||
.delete(this.attachmentToDelete)
|
||||
.then((r) => {
|
||||
this.$store.commit(
|
||||
'attachments/removeById',
|
||||
this.attachmentToDelete.id,
|
||||
)
|
||||
this.$message.success(r)
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.showDeleteModal = false
|
||||
})
|
||||
async deleteAttachment() {
|
||||
try {
|
||||
const r = await this.attachmentService.delete(this.attachmentToDelete)
|
||||
this.$store.commit(
|
||||
'attachments/removeById',
|
||||
this.attachmentToDelete.id,
|
||||
)
|
||||
this.$message.success(r)
|
||||
} finally{
|
||||
this.showDeleteModal = false
|
||||
}
|
||||
},
|
||||
viewOrDownload(attachment) {
|
||||
async viewOrDownload(attachment) {
|
||||
if (
|
||||
attachment.file.name.endsWith('.jpg') ||
|
||||
attachment.file.name.endsWith('.png') ||
|
||||
|
@ -243,9 +238,7 @@ export default {
|
|||
attachment.file.name.endsWith('.gif')
|
||||
) {
|
||||
this.showImageModal = true
|
||||
this.attachmentService.getBlobUrl(attachment).then((url) => {
|
||||
this.attachmentImageBlobUrl = url
|
||||
})
|
||||
this.attachmentImageBlobUrl = await this.attachmentService.getBlobUrl(attachment)
|
||||
} else {
|
||||
this.downloadAttachment(attachment)
|
||||
}
|
||||
|
@ -256,3 +249,137 @@ export default {
|
|||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.attachments {
|
||||
input[type=file] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.files {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.attachment {
|
||||
margin-bottom: .5rem;
|
||||
display: block;
|
||||
transition: background-color $transition;
|
||||
border-radius: $radius;
|
||||
padding: .5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-200);
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-weight: bold;
|
||||
margin-bottom: .25rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.info {
|
||||
color: var(--grey-500);
|
||||
font-size: .9rem;
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
|
||||
> span:not(:last-child):after,
|
||||
> a:not(:last-child):after {
|
||||
content: '·';
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $mobile) {
|
||||
&.collapses {
|
||||
flex-direction: column;
|
||||
|
||||
> span:not(:last-child):after,
|
||||
> a:not(:last-child):after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user .username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
position: fixed;
|
||||
background: rgba(250, 250, 250, 0.8);
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
text-align: center;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drop-hint {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
.icon {
|
||||
width: 100%;
|
||||
font-size: 5rem;
|
||||
height: auto;
|
||||
text-shadow: var(--shadow-md);
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: .5rem auto 2rem;
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--shadow-md);
|
||||
background: var(--primary);
|
||||
padding: 1rem;
|
||||
color: var(--white);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
from,
|
||||
20%,
|
||||
53%,
|
||||
80%,
|
||||
to {
|
||||
animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
40%,
|
||||
43% {
|
||||
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
transform: translate3d(0, -30px, 0);
|
||||
}
|
||||
|
||||
70% {
|
||||
animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
|
||||
transform: translate3d(0, -15px, 0);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: translate3d(0, -4px, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -39,7 +39,7 @@ export default {
|
|||
|
||||
<style scoped lang="scss">
|
||||
.checklist-summary {
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
|
@ -49,10 +49,10 @@ export default {
|
|||
margin-right: .25rem;
|
||||
|
||||
circle {
|
||||
stroke: $grey-400;
|
||||
stroke: var(--grey-400);
|
||||
|
||||
&:last-child {
|
||||
stroke: $primary;
|
||||
stroke: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
</transition>
|
||||
</div>
|
||||
<editor
|
||||
:has-preview="true"
|
||||
:hasPreview="true"
|
||||
:is-edit-enabled="canWrite"
|
||||
:upload-callback="attachmentUpload"
|
||||
:upload-enabled="true"
|
||||
|
@ -110,7 +110,7 @@
|
|||
taskCommentService.loading &&
|
||||
!isCommentEdit,
|
||||
}"
|
||||
:has-preview="false"
|
||||
:hasPreview="false"
|
||||
:upload-callback="attachmentUpload"
|
||||
:upload-enabled="true"
|
||||
:placeholder="$t('task.comment.placeholder')"
|
||||
|
@ -134,9 +134,9 @@
|
|||
|
||||
<transition name="modal">
|
||||
<modal
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteComment()"
|
||||
v-if="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="() => deleteComment(commentToDelete)"
|
||||
>
|
||||
<template #header><span>{{ $t('task.comment.delete') }}</span></template>
|
||||
|
||||
|
@ -152,22 +152,17 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import AsyncEditor from '@/components/input/AsyncEditor'
|
||||
|
||||
import TaskCommentService from '../../../services/taskComment'
|
||||
import TaskCommentModel from '../../../models/taskComment'
|
||||
import LoadingComponent from '../../misc/loading'
|
||||
import ErrorComponent from '../../misc/error'
|
||||
import {uploadFile} from '@/helpers/attachments'
|
||||
import {mapState} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'comments',
|
||||
components: {
|
||||
editor: () => ({
|
||||
component: import('../../input/editor'),
|
||||
loading: LoadingComponent,
|
||||
error: ErrorComponent,
|
||||
timeout: 60000,
|
||||
}),
|
||||
editor: AsyncEditor,
|
||||
},
|
||||
props: {
|
||||
taskId: {
|
||||
|
@ -191,7 +186,6 @@ export default {
|
|||
taskCommentService: new TaskCommentService(),
|
||||
newComment: new TaskCommentModel(),
|
||||
editorActive: true,
|
||||
actions: {},
|
||||
|
||||
saved: null,
|
||||
saving: null,
|
||||
|
@ -200,43 +194,46 @@ export default {
|
|||
},
|
||||
watch: {
|
||||
taskId: {
|
||||
handler(taskId) {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loadComments()
|
||||
this.newComment.taskId = taskId
|
||||
this.commentEdit.taskId = taskId
|
||||
this.commentToDelete.taskId = taskId
|
||||
},
|
||||
handler: 'loadComments',
|
||||
immediate: true,
|
||||
},
|
||||
canWrite() {
|
||||
this.makeActions()
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
userAvatar: state => state.auth.info.getAvatarUrl(48),
|
||||
enabled: state => state.config.taskCommentsEnabled,
|
||||
}),
|
||||
actions() {
|
||||
if (!this.canWrite) {
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(this.comments.map((c) => ([
|
||||
c.id,
|
||||
[{
|
||||
action: () => this.toggleDelete(c.id),
|
||||
title: this.$t('misc.delete'),
|
||||
}],
|
||||
])))
|
||||
},
|
||||
},
|
||||
computed: mapState({
|
||||
userAvatar: state => state.auth.info.getAvatarUrl(48),
|
||||
enabled: state => state.config.taskCommentsEnabled,
|
||||
}),
|
||||
|
||||
methods: {
|
||||
attachmentUpload(...args) {
|
||||
return uploadFile(this.taskId, ...args)
|
||||
},
|
||||
|
||||
loadComments() {
|
||||
this.taskCommentService
|
||||
.getAll({taskId: this.taskId})
|
||||
.then(r => {
|
||||
this.$set(this, 'comments', r)
|
||||
this.makeActions()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
async loadComments(taskId) {
|
||||
if (!this.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.newComment.taskId = taskId
|
||||
this.commentEdit.taskId = taskId
|
||||
this.commentToDelete.taskId = taskId
|
||||
this.comments = await this.taskCommentService.getAll({taskId})
|
||||
},
|
||||
addComment() {
|
||||
|
||||
async addComment() {
|
||||
if (this.newComment.comment === '') {
|
||||
return
|
||||
}
|
||||
|
@ -250,30 +247,27 @@ export default {
|
|||
this.$nextTick(() => (this.editorActive = true))
|
||||
this.creating = true
|
||||
|
||||
this.taskCommentService
|
||||
.create(this.newComment)
|
||||
.then((r) => {
|
||||
this.comments.push(r)
|
||||
this.newComment.comment = ''
|
||||
this.$message.success({message: this.$t('task.comment.addedSuccess')})
|
||||
this.makeActions()
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.creating = false
|
||||
})
|
||||
try {
|
||||
const comment = await this.taskCommentService.create(this.newComment)
|
||||
this.comments.push(comment)
|
||||
this.newComment.comment = ''
|
||||
this.$message.success({message: this.$t('task.comment.addedSuccess')})
|
||||
} finally {
|
||||
this.creating = false
|
||||
}
|
||||
},
|
||||
|
||||
toggleEdit(comment) {
|
||||
this.isCommentEdit = !this.isCommentEdit
|
||||
this.commentEdit = comment
|
||||
},
|
||||
|
||||
toggleDelete(commentId) {
|
||||
this.showDeleteModal = !this.showDeleteModal
|
||||
this.commentToDelete.id = commentId
|
||||
},
|
||||
editComment() {
|
||||
|
||||
async editComment() {
|
||||
if (this.commentEdit.comment === '') {
|
||||
return
|
||||
}
|
||||
|
@ -281,56 +275,68 @@ export default {
|
|||
this.saving = this.commentEdit.id
|
||||
|
||||
this.commentEdit.taskId = this.taskId
|
||||
this.taskCommentService
|
||||
.update(this.commentEdit)
|
||||
.then((r) => {
|
||||
for (const c in this.comments) {
|
||||
if (this.comments[c].id === this.commentEdit.id) {
|
||||
this.$set(this.comments, c, r)
|
||||
}
|
||||
try {
|
||||
const comment = await this.taskCommentService.update(this.commentEdit)
|
||||
for (const c in this.comments) {
|
||||
if (this.comments[c].id === this.commentEdit.id) {
|
||||
this.comments[c] = comment
|
||||
}
|
||||
this.saved = this.commentEdit.id
|
||||
setTimeout(() => {
|
||||
this.saved = null
|
||||
}, 2000)
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isCommentEdit = false
|
||||
this.saving = null
|
||||
})
|
||||
}
|
||||
this.saved = this.commentEdit.id
|
||||
setTimeout(() => {
|
||||
this.saved = null
|
||||
}, 2000)
|
||||
} finally {
|
||||
this.isCommentEdit = false
|
||||
this.saving = null
|
||||
}
|
||||
},
|
||||
deleteComment() {
|
||||
this.taskCommentService
|
||||
.delete(this.commentToDelete)
|
||||
.then(() => {
|
||||
for (const a in this.comments) {
|
||||
if (this.comments[a].id === this.commentToDelete.id) {
|
||||
this.comments.splice(a, 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.showDeleteModal = false
|
||||
})
|
||||
},
|
||||
makeActions() {
|
||||
if (this.canWrite) {
|
||||
this.comments.forEach((c) => {
|
||||
this.$set(this.actions, c.id, [
|
||||
{
|
||||
action: () => this.toggleDelete(c.id),
|
||||
title: this.$t('misc.delete'),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
async deleteComment(commentToDelete) {
|
||||
try {
|
||||
await this.taskCommentService.delete(commentToDelete)
|
||||
const index = this.comments.findIndex(({id}) => id === commentToDelete.id)
|
||||
this.comments.splice(index, 1)
|
||||
} finally {
|
||||
this.showDeleteModal = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.media-left {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.comment-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
|
||||
img {
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding-right: 0;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
span {
|
||||
font-size: .75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.media-content {
|
||||
width: calc(100% - 48px - 2rem);
|
||||
}
|
||||
</style>
|
|
@ -8,21 +8,21 @@
|
|||
<x-button
|
||||
@click.prevent.stop="() => deferDays(1)"
|
||||
:shadow="false"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('task.deferDueDate.1day') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
@click.prevent.stop="() => deferDays(3)"
|
||||
:shadow="false"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('task.deferDueDate.3days') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
@click.prevent.stop="() => deferDays(7)"
|
||||
:shadow="false"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('task.deferDueDate.1week') }}
|
||||
</x-button>
|
||||
|
@ -57,12 +57,14 @@ export default {
|
|||
flatPickr,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
modelValue: {
|
||||
handler(value) {
|
||||
this.task = value
|
||||
this.dueDate = value.dueDate
|
||||
|
@ -83,7 +85,7 @@ export default {
|
|||
|
||||
this.changeInterval = setInterval(this.updateDueDate, 1000)
|
||||
},
|
||||
beforeDestroy() {
|
||||
beforeUnmount() {
|
||||
if (this.changeInterval) {
|
||||
clearInterval(this.changeInterval)
|
||||
}
|
||||
|
@ -110,7 +112,8 @@ export default {
|
|||
this.dueDate = this.dueDate.setDate(this.dueDate.getDate() + days)
|
||||
this.updateDueDate()
|
||||
},
|
||||
updateDueDate() {
|
||||
|
||||
async updateDueDate() {
|
||||
if (!this.dueDate) {
|
||||
return
|
||||
}
|
||||
|
@ -120,17 +123,70 @@ export default {
|
|||
}
|
||||
|
||||
this.task.dueDate = new Date(this.dueDate)
|
||||
this.taskService
|
||||
.update(this.task)
|
||||
.then((r) => {
|
||||
this.lastValue = r.dueDate
|
||||
this.task = r
|
||||
this.$emit('input', r)
|
||||
})
|
||||
.catch((e) => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
const task = await this.taskService.update(this.task)
|
||||
this.lastValue = task.dueDate
|
||||
this.task = task
|
||||
this.$emit('update:modelValue', task)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 100px is roughly the size the pane is pulled to the right
|
||||
$defer-task-max-width: 350px + 100px;
|
||||
|
||||
.defer-task {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
max-width: $defer-task-max-width;
|
||||
border-radius: $radius;
|
||||
border: 1px solid var(--grey-200);
|
||||
padding: 1rem;
|
||||
margin: 1rem;
|
||||
background: var(--white);
|
||||
color: var(--text);
|
||||
cursor: default;
|
||||
z-index: 10;
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
@media screen and (max-width: ($defer-task-max-width)) {
|
||||
left: .5rem;
|
||||
right: .5rem;
|
||||
max-width: 100%;
|
||||
width: calc(100vw - 1rem - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
.defer-days {
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
margin: .5rem 0;
|
||||
}
|
||||
|
||||
:deep() {
|
||||
input.input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flatpickr-calendar {
|
||||
margin: 0 auto;
|
||||
box-shadow: none;
|
||||
|
||||
@media screen and (max-width: ($defer-task-max-width)) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
span {
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.flatpickr-innerContainer {
|
||||
@media screen and (max-width: ($defer-task-max-width)) {
|
||||
overflow: scroll;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -30,8 +30,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingComponent from '@/components/misc/loading.vue'
|
||||
import ErrorComponent from '@/components/misc/error.vue'
|
||||
import AsyncEditor from '@/components/input/AsyncEditor'
|
||||
|
||||
import {LOADING} from '@/store/mutation-types'
|
||||
import {mapState} from 'vuex'
|
||||
|
@ -39,12 +38,7 @@ import {mapState} from 'vuex'
|
|||
export default {
|
||||
name: 'description',
|
||||
components: {
|
||||
editor: () => ({
|
||||
component: import('@/components/input/editor.vue'),
|
||||
loading: LoadingComponent,
|
||||
error: ErrorComponent,
|
||||
timeout: 60000,
|
||||
}),
|
||||
editor: AsyncEditor,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -57,7 +51,7 @@ export default {
|
|||
loading: LOADING,
|
||||
}),
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
attachmentUpload: {
|
||||
|
@ -67,8 +61,9 @@ export default {
|
|||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
watch: {
|
||||
value: {
|
||||
modelValue: {
|
||||
handler(value) {
|
||||
this.task = value
|
||||
},
|
||||
|
@ -76,24 +71,19 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
save() {
|
||||
async save() {
|
||||
this.saving = true
|
||||
|
||||
this.$store.dispatch('tasks/update', this.task)
|
||||
.then(t => {
|
||||
this.task = t
|
||||
this.$emit('input', t)
|
||||
this.saved = true
|
||||
setTimeout(() => {
|
||||
this.saved = false
|
||||
}, 2000)
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false
|
||||
})
|
||||
try {
|
||||
this.task = await this.$store.dispatch('tasks/update', this.task)
|
||||
this.$emit('update:modelValue', this.task)
|
||||
this.saved = true
|
||||
setTimeout(() => {
|
||||
this.saved = false
|
||||
}, 2000)
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
<multiselect
|
||||
:loading="listUserService.loading"
|
||||
:placeholder="$t('task.assignee.placeholder')"
|
||||
:disabled="disabled"
|
||||
:multiple="true"
|
||||
@search="findUser"
|
||||
:search-results="foundUsers"
|
||||
|
@ -54,10 +53,11 @@ export default {
|
|||
disabled: {
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
newAssignee: new UserModel(),
|
||||
|
@ -68,62 +68,82 @@ export default {
|
|||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
modelValue: {
|
||||
handler(value) {
|
||||
this.assignees = value
|
||||
},
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addAssignee(user) {
|
||||
this.$store.dispatch('tasks/addAssignee', {user: user, taskId: this.taskId})
|
||||
.then(() => {
|
||||
this.$emit('input', this.assignees)
|
||||
this.$message.success({message: this.$t('task.assignee.assignSuccess')})
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
async addAssignee(user) {
|
||||
await this.$store.dispatch('tasks/addAssignee', {user: user, taskId: this.taskId})
|
||||
this.$emit('update:modelValue', this.assignees)
|
||||
this.$message.success({message: this.$t('task.assignee.assignSuccess')})
|
||||
},
|
||||
removeAssignee(user) {
|
||||
this.$store.dispatch('tasks/removeAssignee', {user: user, taskId: this.taskId})
|
||||
.then(() => {
|
||||
// Remove the assignee from the list
|
||||
for (const a in this.assignees) {
|
||||
if (this.assignees[a].id === user.id) {
|
||||
this.assignees.splice(a, 1)
|
||||
}
|
||||
}
|
||||
this.$message.success({message: this.$t('task.assignee.assignSuccess')})
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
|
||||
async removeAssignee(user) {
|
||||
await this.$store.dispatch('tasks/removeAssignee', {user: user, taskId: this.taskId})
|
||||
|
||||
// Remove the assignee from the list
|
||||
for (const a in this.assignees) {
|
||||
if (this.assignees[a].id === user.id) {
|
||||
this.assignees.splice(a, 1)
|
||||
}
|
||||
}
|
||||
this.$message.success({message: this.$t('task.assignee.unassignSuccess')})
|
||||
},
|
||||
findUser(query) {
|
||||
|
||||
async findUser(query) {
|
||||
if (query === '') {
|
||||
this.clearAllFoundUsers()
|
||||
return
|
||||
}
|
||||
|
||||
this.listUserService.getAll({listId: this.listId}, {s: query})
|
||||
.then(response => {
|
||||
// Filter the results to not include users who are already assigned
|
||||
const filteredResponse = response.filter(({id}) => !includesById(this.assignees, id))
|
||||
const response = await this.listUserService.getAll({listId: this.listId}, {s: query})
|
||||
|
||||
this.$set(this, 'foundUsers', filteredResponse)
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
// Filter the results to not include users who are already assigned
|
||||
this.foundUsers = response.filter(({id}) => !includesById(this.assignees, id))
|
||||
},
|
||||
|
||||
clearAllFoundUsers() {
|
||||
this.$set(this, 'foundUsers', [])
|
||||
this.foundUsers = []
|
||||
},
|
||||
|
||||
focus() {
|
||||
this.$refs.multiselect.focus()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.assignee {
|
||||
position: relative;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -1.5rem;
|
||||
}
|
||||
|
||||
:deep(.user img) {
|
||||
border: 2px solid var(--white);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.remove-assignee {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 2px;
|
||||
color: var(--danger);
|
||||
background: var(--white);
|
||||
padding: 0 4px;
|
||||
display: block;
|
||||
border-radius: 100%;
|
||||
font-size: .75rem;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -12,6 +12,7 @@
|
|||
:create-placeholder="$t('task.label.createPlaceholder')"
|
||||
v-model="labels"
|
||||
:search-delay="10"
|
||||
:close-after-select="false"
|
||||
>
|
||||
<template #tag="props">
|
||||
<span
|
||||
|
@ -47,7 +48,7 @@ import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
|
|||
export default {
|
||||
name: 'edit-labels',
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
default: () => [],
|
||||
type: Array,
|
||||
},
|
||||
|
@ -60,6 +61,7 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'change'],
|
||||
data() {
|
||||
return {
|
||||
labelTaskService: new LabelTaskService(),
|
||||
|
@ -72,11 +74,12 @@ export default {
|
|||
Multiselect,
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
modelValue: {
|
||||
handler(value) {
|
||||
this.labels = value
|
||||
},
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
|
@ -91,9 +94,10 @@ export default {
|
|||
findLabel(query) {
|
||||
this.query = query
|
||||
},
|
||||
addLabel(label, showNotification = true) {
|
||||
|
||||
async addLabel(label, showNotification = true) {
|
||||
const bubble = () => {
|
||||
this.$emit('input', this.labels)
|
||||
this.$emit('update:modelValue', this.labels)
|
||||
this.$emit('change', this.labels)
|
||||
}
|
||||
|
||||
|
@ -102,25 +106,21 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
this.$store.dispatch('tasks/addLabel', {label: label, taskId: this.taskId})
|
||||
.then(() => {
|
||||
bubble()
|
||||
if (showNotification) {
|
||||
this.$message.success({message: this.$t('task.label.addSuccess')})
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
await this.$store.dispatch('tasks/addLabel', {label: label, taskId: this.taskId})
|
||||
bubble()
|
||||
if (showNotification) {
|
||||
this.$message.success({message: this.$t('task.label.addSuccess')})
|
||||
}
|
||||
},
|
||||
removeLabel(label) {
|
||||
|
||||
async removeLabel(label) {
|
||||
const removeFromState = () => {
|
||||
for (const l in this.labels) {
|
||||
if (this.labels[l].id === label.id) {
|
||||
this.labels.splice(l, 1)
|
||||
}
|
||||
}
|
||||
this.$emit('input', this.labels)
|
||||
this.$emit('update:modelValue', this.labels)
|
||||
this.$emit('change', this.labels)
|
||||
}
|
||||
|
||||
|
@ -129,32 +129,29 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
this.$store.dispatch('tasks/removeLabel', {label: label, taskId: this.taskId})
|
||||
.then(() => {
|
||||
removeFromState()
|
||||
this.$message.success({message: this.$t('task.label.removeSuccess')})
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
await this.$store.dispatch('tasks/removeLabel', {label: label, taskId: this.taskId})
|
||||
removeFromState()
|
||||
this.$message.success({message: this.$t('task.label.removeSuccess')})
|
||||
},
|
||||
createAndAddLabel(title) {
|
||||
|
||||
async createAndAddLabel(title) {
|
||||
if (this.taskId === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const newLabel = new LabelModel({title: title})
|
||||
this.$store.dispatch('labels/createLabel', newLabel)
|
||||
.then(r => {
|
||||
this.addLabel(r, false)
|
||||
this.labels.push(r)
|
||||
this.$message.success({message: this.$t('task.label.addCreateSuccess')})
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
const label = await this.$store.dispatch('labels/createLabel', newLabel)
|
||||
this.addLabel(label, false)
|
||||
this.labels.push(label)
|
||||
this.$message.success({message: this.$t('task.label.addCreateSuccess')})
|
||||
},
|
||||
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag {
|
||||
margin: .5rem 0 0 .5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
<template>
|
||||
<div class="heading">
|
||||
<h1 class="title task-id">{{ textIdentifier }}</h1>
|
||||
<div class="is-done" v-if="task.done">Done</div>
|
||||
<Done class="heading__done" :is-done="task.done" />
|
||||
<h1
|
||||
class="title input"
|
||||
:class="{'disabled': !canWrite}"
|
||||
@blur="save($event.target.textContent)"
|
||||
@keydown.enter.prevent.stop="$event.target.blur()"
|
||||
:contenteditable="canWrite ? 'true' : 'false'"
|
||||
spellcheck="false"
|
||||
:contenteditable="canWrite ? true : undefined"
|
||||
:spellcheck="false"
|
||||
>
|
||||
{{ task.title.trim() }}
|
||||
</h1>
|
||||
|
@ -28,8 +28,13 @@
|
|||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
|
||||
import Done from '@/components/misc/Done.vue'
|
||||
|
||||
export default {
|
||||
name: 'heading',
|
||||
components: {
|
||||
Done,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showSavedMessage: false,
|
||||
|
@ -39,14 +44,14 @@ export default {
|
|||
computed: {
|
||||
...mapState(['loading']),
|
||||
task() {
|
||||
return this.value
|
||||
return this.modelValue
|
||||
},
|
||||
textIdentifier() {
|
||||
return this.task?.getTextIdentifier() || ''
|
||||
},
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
required: true,
|
||||
},
|
||||
canWrite: {
|
||||
|
@ -54,8 +59,11 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['update:modelValue'],
|
||||
|
||||
methods: {
|
||||
save(title) {
|
||||
async save(title) {
|
||||
// We only want to save if the title was actually changed.
|
||||
// Because the contenteditable does not have a change event
|
||||
// we're building it ourselves and only continue
|
||||
|
@ -71,22 +79,24 @@ export default {
|
|||
title,
|
||||
}
|
||||
|
||||
this.$store.dispatch('tasks/update', newTask)
|
||||
.then((task) => {
|
||||
this.$emit('input', task)
|
||||
this.showSavedMessage = true
|
||||
setTimeout(() => {
|
||||
this.showSavedMessage = false
|
||||
}, 2000)
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.saving = false
|
||||
})
|
||||
try {
|
||||
const task = await this.$store.dispatch('tasks/update', newTask)
|
||||
this.$emit('update:modelValue', task)
|
||||
this.showSavedMessage = true
|
||||
setTimeout(() => {
|
||||
this.showSavedMessage = false
|
||||
}, 2000)
|
||||
}
|
||||
finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.heading__done {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
</style>
|
|
@ -1,18 +1,18 @@
|
|||
<template>
|
||||
<div
|
||||
class="task loader-container draggable"
|
||||
:class="{
|
||||
'is-loading': loadingInternal || loading,
|
||||
'draggable': !(loadingInternal || loading),
|
||||
'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}` && task.hexColor !== task.defaultColor,
|
||||
}"
|
||||
:style="{'background-color': task.hexColor !== '#' && task.hexColor !== `#${task.defaultColor}` ? task.hexColor : false}"
|
||||
@click.ctrl="() => markTaskAsDone(task)"
|
||||
@click.ctrl="() => toggleTaskDone(task)"
|
||||
@click.exact="() => $router.push({ name: 'task.kanban.detail', params: { id: task.id } })"
|
||||
@click.meta="() => markTaskAsDone(task)"
|
||||
class="task loader-container draggable"
|
||||
@click.meta="() => toggleTaskDone(task)"
|
||||
>
|
||||
<span class="task-id">
|
||||
<span class="is-done" v-if="task.done">Done</span>
|
||||
<Done class="kanban-card__done" :is-done="task.done" variant="small" />
|
||||
<template v-if="task.identifier === ''">
|
||||
#{{ task.index }}
|
||||
</template>
|
||||
|
@ -58,6 +58,9 @@
|
|||
<span v-if="task.description" class="icon">
|
||||
<icon icon="align-left"/>
|
||||
</span>
|
||||
<span class="icon" v-if="task.repeatAfter.amount > 0">
|
||||
<icon icon="history"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -66,13 +69,17 @@
|
|||
import {playPop} from '../../../helpers/playPop'
|
||||
import PriorityLabel from '../../../components/tasks/partials/priorityLabel'
|
||||
import User from '../../../components/misc/user'
|
||||
import Done from '@/components/misc/Done.vue'
|
||||
import Labels from '../../../components/tasks/partials/labels'
|
||||
import ChecklistSummary from './checklist-summary'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
|
||||
export default {
|
||||
name: 'kanban-card',
|
||||
components: {
|
||||
ChecklistSummary,
|
||||
Done,
|
||||
PriorityLabel,
|
||||
User,
|
||||
Labels,
|
||||
|
@ -93,24 +100,174 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
markTaskAsDone(task) {
|
||||
colorIsDark,
|
||||
async toggleTaskDone(task) {
|
||||
this.loadingInternal = true
|
||||
this.$store.dispatch('tasks/update', {
|
||||
...task,
|
||||
done: !task.done,
|
||||
})
|
||||
.then(() => {
|
||||
if (task.done) {
|
||||
playPop()
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
this.$message.error(e)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingInternal = false
|
||||
try {
|
||||
await this.$store.dispatch('tasks/update', {
|
||||
...task,
|
||||
done: !task.done,
|
||||
})
|
||||
if (task.done) {
|
||||
playPop()
|
||||
}
|
||||
} finally {
|
||||
this.loadingInternal = false
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$task-background: var(--white);
|
||||
|
||||
.task {
|
||||
-webkit-touch-callout: none; // iOS Safari
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: block;
|
||||
border: 3px solid transparent;
|
||||
|
||||
font-size: .9rem;
|
||||
margin: .5rem;
|
||||
padding: .4rem;
|
||||
border-radius: $radius;
|
||||
background: $task-background;
|
||||
|
||||
&.loader-container.is-loading::after {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
top: calc(50% - .75rem);
|
||||
left: calc(50% - .75rem);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: $family-sans-serif;
|
||||
font-size: .85rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: 8px 0 0 0;
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
float: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
margin-right: .25rem;
|
||||
}
|
||||
|
||||
&.overdue {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.label-wrapper .tag {
|
||||
margin: .5rem .5rem 0 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
:deep(.tag),
|
||||
.assignees,
|
||||
.icon,
|
||||
.priority-label {
|
||||
margin-top: .25rem;
|
||||
margin-right: .25rem;
|
||||
}
|
||||
|
||||
.assignees {
|
||||
display: flex;
|
||||
|
||||
.user {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
|
||||
img {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: should be in labels.vue
|
||||
:deep(.tag) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.priority-label {
|
||||
font-size: .75rem;
|
||||
height: 2rem;
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
padding: 0 .25rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer .icon,
|
||||
.due-date,
|
||||
.priority-label {
|
||||
background: var(--grey-100);
|
||||
border-radius: $radius;
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
.task-id {
|
||||
color: var(--grey-500);
|
||||
font-size: .8rem;
|
||||
margin-bottom: .25rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&.is-moving {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
span {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&.has-light-text {
|
||||
color: var(--white);
|
||||
|
||||
.task-id {
|
||||
color: var(--grey-200);
|
||||
}
|
||||
|
||||
.footer .icon,
|
||||
.due-date,
|
||||
.priority-label {
|
||||
background: var(--grey-800);
|
||||
}
|
||||
|
||||
.footer {
|
||||
.icon svg {
|
||||
fill: var(--white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kanban-card__done {
|
||||
margin-right: .25rem;
|
||||
}
|
||||
</style>
|