forked from vikunja/frontend
Compare commits
1284 Commits
stop-nowra
...
main
Author | SHA1 | Date |
---|---|---|
renovate | 6711a08de9 | |
renovate | 7fe33c6662 | |
renovate | e61b215dc1 | |
renovate | 3b5cb1ade3 | |
renovate | 89e28cbdf2 | |
renovate | e9e836f068 | |
kolaente | aa5e11915e | |
kolaente | 7f279c98e1 | |
kolaente | 3c1861eb6a | |
kolaente | 75262b716f | |
ThatHurleyGuy | 7e623d919e | |
kolaente | 3f42ce2b34 | |
renovate | 8b8da40265 | |
Frederick [Bot] | 0f23cc2162 | |
Frederick [Bot] | adf80d9184 | |
Frederick [Bot] | e3dfcafc29 | |
Frederick [Bot] | a9df58109f | |
kolaente | 59a7360608 | |
renovate | 29e128c64c | |
renovate | cec50d912c | |
renovate | 53564ec46c | |
renovate | e9cd7aac69 | |
renovate | a47bfb3ff1 | |
renovate | 86eb4da2e3 | |
renovate | d1882e9c3f | |
renovate | 974755ffc2 | |
Frederick [Bot] | f00d49cada | |
Frederick [Bot] | e41ec4e8b2 | |
kolaente | 218d72494a | |
kolaente | bde212d432 | |
kolaente | 64a8dd189b | |
kolaente | ba766a29af | |
renovate | e02a106c64 | |
renovate | ccdc5d4868 | |
renovate | 9240739a4b | |
renovate | 963d91c4d5 | |
renovate | f33d154b37 | |
Frederick [Bot] | b12db63de0 | |
Frederick [Bot] | a3e729a3c8 | |
kolaente | 8e0ba555ed | |
kolaente | 9cf81e1478 | |
renovate | 4350d78178 | |
renovate | cea27bb754 | |
renovate | 4b7f8c265d | |
renovate | 412e6e77b4 | |
renovate | 45abdda680 | |
renovate | 0b2188d72d | |
renovate | 143a2a105d | |
renovate | 68d18934d8 | |
renovate | cea3274a90 | |
Frederick [Bot] | 72f57a220d | |
Frederick [Bot] | b94acfcc84 | |
kolaente | 5f2787e18d | |
kolaente | 2eac17ed57 | |
renovate | 8d566c9371 | |
renovate | ab5118b51b | |
kolaente | 0b294de132 | |
renovate | c1149273f9 | |
renovate | 7496be5a44 | |
renovate | a35b0f64a2 | |
renovate | 6c2b30f8ef | |
renovate | daa720669a | |
konrad | 26fc9b4e4f | |
kolaente | 37af478811 | |
kolaente | 22223a56bd | |
kolaente | c367b70ccc | |
kolaente | 9103ad8505 | |
kolaente | e4eaca82e1 | |
kolaente | 229beec1d1 | |
kolaente | 803f9c81c2 | |
kolaente | c6b123734b | |
kolaente | 0154b2a475 | |
kolaente | 32ca8853bc | |
kolaente | f6d5cbcf6f | |
kolaente | d7503dc4a2 | |
kolaente | 632e3c5a0b | |
kolaente | c61f1a45fb | |
kolaente | 2f3196ef86 | |
kolaente | 2864854cd4 | |
kolaente | a453449fea | |
kolaente | abb6630b4b | |
kolaente | 63c40b29b0 | |
Frederick [Bot] | b7ff71ba76 | |
kolaente | 19a78f1f75 | |
kolaente | d6a41fa518 | |
kolaente | 859fc1e94e | |
kolaente | aa715dd9e1 | |
kolaente | daa2ed3b1c | |
kolaente | 1443e23f18 | |
kolaente | 34420b623c | |
kolaente | 80dc35eabb | |
kolaente | cb1d2b3834 | |
kolaente | 0ef775e9b9 | |
kolaente | a7e4e3adf9 | |
kolaente | d005875bbf | |
kolaente | dc3ee112bd | |
kolaente | 9b20dc1899 | |
kolaente | 22103626b8 | |
kolaente | 4f2d7b3ce2 | |
kolaente | 76d31c84ad | |
kolaente | 66c37f10e0 | |
kolaente | 0b2aa723a6 | |
kolaente | d75a963d08 | |
kolaente | beefc1d5ef | |
kolaente | 17c23d9463 | |
kolaente | 02ab1b8c0a | |
kolaente | e81c98fe5b | |
kolaente | 3bf806f00c | |
kolaente | aea3f86a8f | |
kolaente | 5297208d92 | |
kolaente | c84bcfddba | |
kolaente | 0772acbead | |
renovate | 123c665d9d | |
kolaente | 4f3efe4454 | |
kolaente | 671c658868 | |
kolaente | 05bf7ccf0b | |
renovate | b76acb15c7 | |
kolaente | 953361c480 | |
kolaente | 8b60e5b2c8 | |
kolaente | faf93a6088 | |
kolaente | 8e07d9647a | |
kolaente | 08959fdb77 | |
kolaente | e716fd1bf9 | |
kolaente | 63865028b8 | |
kolaente | e760ce45e4 | |
kolaente | af9eb358ee | |
kolaente | ddcf6bf0a5 | |
kolaente | 9c71e30efe | |
kolaente | c58ad47782 | |
kolaente | ca0d9e6bd5 | |
kolaente | ad3234b19f | |
Dominik Pschenitschni | 24b8915983 | |
kolaente | 01c2acdf34 | |
kolaente | ff2b4b8bf4 | |
kolaente | d73c62a424 | |
renovate | cac41a1c86 | |
kolaente | aeed4b3a3b | |
renovate | 8992caadf9 | |
renovate | b2b423aee5 | |
konrad | 5d991e539b | |
renovate | accde483cb | |
renovate | 2d5e560b74 | |
kolaente | 5d91134b48 | |
kolaente | 0e5415a2c9 | |
kolaente | 779aad1b2d | |
kolaente | 3d2fe4cf65 | |
renovate | c38421466b | |
Frederick [Bot] | df09bca010 | |
kolaente | b9717f504d | |
Frederick [Bot] | bb7c4f40a0 | |
Frederick [Bot] | 0b9ef50f80 | |
Frederick [Bot] | cd295960a4 | |
renovate | eb591fdd3c | |
renovate | 23e1899fce | |
renovate | 22968ba639 | |
renovate | b345f0ad61 | |
Frederick [Bot] | 4df34701ab | |
renovate | a5f7487bd0 | |
kolaente | ae001c6ca7 | |
kolaente | f0b340a9c7 | |
renovate | 40538df392 | |
renovate | fc17b16c60 | |
renovate | 6c59b4e2d2 | |
renovate | e8a38ed482 | |
renovate | f5604dcac6 | |
renovate | 049c644959 | |
kolaente | 07b1e9a6b7 | |
renovate | 7aedf6ee1f | |
renovate | bc9bfe3300 | |
renovate | c2005c6c71 | |
renovate | d7cbade64e | |
renovate | 06b00b77ed | |
renovate | f2392cef7e | |
renovate | e6f2b36d88 | |
kolaente | 7d2fcd26f2 | |
renovate | 369e22f224 | |
kolaente | 0ff5b90ebd | |
kolaente | e89245e42d | |
kolaente | 35717a1e29 | |
kolaente | e46cf2fa1b | |
kolaente | 1ad6d5a66b | |
kolaente | 4754bb99f0 | |
kolaente | 608e99fffc | |
kolaente | d6741d19e3 | |
kolaente | ec52be0353 | |
renovate | 2d5ab4f5f0 | |
Giacomo Rossetto | a71755e408 | |
kolaente | 66c7a05cdb | |
kolaente | 287daf9125 | |
kolaente | 8507808058 | |
kolaente | 93c155dd2f | |
kolaente | b1c4748969 | |
kolaente | 0887860b2a | |
kolaente | 0b1c8ed4dd | |
kolaente | 3988a3f9f8 | |
Frederick [Bot] | 11b65e844c | |
renovate | 5c23343172 | |
renovate | 01a4335c7c | |
Frederick [Bot] | 4a2ecf5fe7 | |
renovate | 0235b14997 | |
renovate | 8eafa23269 | |
kolaente | 5c95a721f4 | |
kolaente | a6eb804fae | |
Frederick [Bot] | 09ffd9414b | |
kolaente | b126a7f7ff | |
kolaente | f256fc3843 | |
kolaente | e41712647d | |
Frederick [Bot] | 842e2c2811 | |
kolaente | 2d61a349ac | |
kolaente | 54c527c23f | |
kolaente | 4d8c6622d2 | |
kolaente | 3f3d4b1682 | |
kolaente | 9c46d064ac | |
kolaente | 0d3143d465 | |
kolaente | 337c3e5e3e | |
kolaente | 3bb5308141 | |
kolaente | 3fec92283b | |
kolaente | beb016400e | |
kolaente | 7746d39161 | |
kolaente | b187e8c1b6 | |
kolaente | 0ecda46af9 | |
kolaente | 59dc927b5c | |
kolaente | a13953ee14 | |
kolaente | a4b836d395 | |
kolaente | 16b46b0f4d | |
kolaente | 184110b986 | |
kolaente | 1918947c0b | |
kolaente | 4e5823183e | |
kolaente | b9e17ea870 | |
Frederick [Bot] | a8a6ec5ab0 | |
Frederick [Bot] | 3e9b872894 | |
kolaente | c4adcf4655 | |
kolaente | b1fe3fe29b | |
kolaente | 5720a86bc3 | |
kolaente | 86eff7d49e | |
kolaente | 7a9aa7771b | |
kolaente | abbc11528e | |
kolaente | 725fd1ad46 | |
kolaente | 44754fac0f | |
kolaente | 7f2d92138e | |
kolaente | 95be0d1d32 | |
kolaente | f63c39a578 | |
kolaente | 270e32290a | |
kolaente | 9cf8696b84 | |
Frederick [Bot] | b97e13b6b4 | |
konrad | 04ba1011cc | |
kolaente | 52c0efe0ce | |
kolaente | c803020537 | |
kolaente | 3373b5fc45 | |
kolaente | f6d1db3595 | |
Frederick [Bot] | ce6f099912 | |
kolaente | ed8fb71ff0 | |
konrad | 28f2551d87 | |
kolaente | cec480ad80 | |
kolaente | 830a3745ba | |
kolaente | 49104c65b6 | |
kolaente | 984978fe6d | |
kolaente | bd7b973559 | |
kolaente | 0bb85870db | |
kolaente | 021f92303d | |
kolaente | e47ad021a3 | |
kolaente | a20eef2453 | |
Frederick [Bot] | 7b57b10804 | |
Frederick [Bot] | 83a7032b6f | |
renovate | 49261a6fcc | |
kolaente | 5630c90dee | |
konrad | 47d589002c | |
kolaente | 99e2161c09 | |
kolaente | 20f61baf03 | |
kolaente | 4e6b99544e | |
kolaente | d57e1909c4 | |
kolaente | 99d8fbdfa7 | |
kolaente | 442d0342a9 | |
kolaente | a4b369470a | |
kolaente | 0ca73e0851 | |
kolaente | 9fc829115f | |
kolaente | 1e19548563 | |
kolaente | c327d86a71 | |
kolaente | 3044560759 | |
kolaente | c3f85fcb19 | |
renovate | 53434952d3 | |
renovate | e9b0640660 | |
renovate | ae57e5d314 | |
kolaente | 6e7928b2e4 | |
kolaente | 47639b00f8 | |
Frederick [Bot] | e63cecceca | |
Frederick [Bot] | 55e2e323ed | |
kolaente | f7e22c8c56 | |
kolaente | a9fb306e46 | |
kolaente | 58a1f46668 | |
kolaente | 6cbbe17bd8 | |
kolaente | c01957aae2 | |
kolaente | 1ad03877fb | |
kolaente | fc72a82a2a | |
kolaente | 63ef09b020 | |
DIMITRIOS CHRYSOCHERIS | 311b1d7594 | |
davidangel | cade3df3e9 | |
kolaente | 37975c1931 | |
renovate | 0d500182e7 | |
Frederick [Bot] | f647d6e9b4 | |
renovate | dbed4caca7 | |
renovate | 6d79c9b2ed | |
renovate | 24f0822a12 | |
renovate | f3ba778fd3 | |
renovate | 55a7255728 | |
renovate | 2b47e5faec | |
kolaente | 9f82ec4162 | |
kolaente | 64c90c7fe8 | |
renovate | 9fe3d2b2bc | |
Frederick [Bot] | 0b1ec9f287 | |
Frederick [Bot] | baff1c6fc9 | |
Frederick [Bot] | ac3f0cc266 | |
renovate | 7e1cfebf6a | |
Frederick [Bot] | 88203e8b7d | |
renovate | d466d50712 | |
renovate | cf945f2841 | |
renovate | 74df69fc94 | |
Frederick [Bot] | 563ee8f5bc | |
renovate | 026db7acad | |
renovate | 61e97bfe1c | |
renovate | 6530d26b82 | |
renovate | 1e24fe8bab | |
renovate | f786c2b8a2 | |
renovate | e596e2c3bc | |
Frederick [Bot] | a5e49d9417 | |
renovate | 668b910190 | |
renovate | 2bdc532f89 | |
renovate | 933c7d8acc | |
Frederick [Bot] | 253e716390 | |
renovate | d19a5d9714 | |
renovate | 90cad1c8dd | |
Frederick [Bot] | 057017c8eb | |
kolaente | d7ce8dd320 | |
kolaente | 25b110ce48 | |
renovate | 33fe5e4f20 | |
renovate | 129ef769a3 | |
renovate | 9030a9f7c1 | |
Frederick [Bot] | 3748a496d5 | |
renovate | 890e7e1f52 | |
renovate | 9e0f2b0249 | |
renovate | 9a34c522b2 | |
renovate | 60dd698fad | |
kolaente | 15ecafdf04 | |
kolaente | 8902c15f7e | |
kolaente | d5358793de | |
renovate | 33798b8d88 | |
renovate | c686e8677b | |
renovate | 5acc1696a9 | |
renovate | c4976b6a22 | |
renovate | d88ff594e1 | |
kolaente | 66f0df0333 | |
kolaente | b742c55287 | |
kolaente | 82c9a91d39 | |
kolaente | cd820a6cb2 | |
kolaente | 2c4da79c1b | |
renovate | c4f6465569 | |
renovate | 7d84601f6d | |
renovate | 8f94b7490c | |
renovate | cbce7cd142 | |
renovate | 28e775be42 | |
kolaente | 24ad2f892d | |
kolaente | 7c1934aad0 | |
kolaente | ae2b0f97c4 | |
renovate | 70e0696300 | |
Frederick [Bot] | 60e49468cf | |
renovate | 10e566164e | |
renovate | 3c1b54c1a3 | |
renovate | b80c6cf326 | |
kolaente | d2e6ab4505 | |
renovate | 32ed4c7da9 | |
renovate | 70ae19a903 | |
renovate | cc2e0e79d3 | |
Frederick [Bot] | 49bdd00133 | |
renovate | 43eb742352 | |
renovate | 34b6692f25 | |
renovate | 152aefd365 | |
renovate | 2297872879 | |
renovate | 75ca7ecd61 | |
Frederick [Bot] | 0909d2cfe5 | |
renovate | a60662b72b | |
Frederick [Bot] | 478b2c043e | |
renovate | 6bc54d7a92 | |
renovate | e8e664b256 | |
Frederick [Bot] | 72fd932020 | |
renovate | e5090b117f | |
renovate | 1b498a238c | |
kolaente | 2a14325f62 | |
kolaente | ac6c4cf2bc | |
renovate | 66bad4b2b1 | |
renovate | cdac38eabc | |
renovate | 79918620c3 | |
renovate | dd1ae53d00 | |
renovate | 9894490616 | |
Frederick [Bot] | a218eab609 | |
renovate | c046bb95b3 | |
renovate | 12ad0d2ed3 | |
renovate | 8e505b6f51 | |
renovate | 3589251f55 | |
renovate | b3b0d8d6e6 | |
renovate | 06572e8f0a | |
renovate | fe7e06079e | |
renovate | c2b04a2b81 | |
renovate | d2148df6c8 | |
Frederick [Bot] | 305c5b32ee | |
kolaente | a446310986 | |
kolaente | 8a22d1811e | |
Frederick [Bot] | d34a872d40 | |
kolaente | 06126de139 | |
renovate | 047435bb68 | |
renovate | 4be631888a | |
Frederick [Bot] | be11397163 | |
kolaente | f3986c710b | |
kolaente | 9c8266fb0d | |
kolaente | 7148b56eea | |
kolaente | ff6645d2ab | |
renovate | bd17afe466 | |
kolaente | 25bd26bea6 | |
kolaente | 83c0ef4e8b | |
renovate | f55c42f124 | |
renovate | 4189fdadd9 | |
renovate | 2670cecf70 | |
renovate | e54b5e88cc | |
renovate | 03c6e343c2 | |
renovate | 9acde8d017 | |
kolaente | 813d2b56a0 | |
kolaente | 1005182a50 | |
kolaente | 4eba9479b0 | |
kolaente | dbe1ad9353 | |
kolaente | b6cd424aa3 | |
kolaente | 6651adf6de | |
kolaente | 3aa502e07d | |
kolaente | 78a268ab07 | |
renovate | 4661c2e90e | |
renovate | fbfa265dbf | |
renovate | c1ad1b0639 | |
kolaente | c27661107f | |
renovate | 464cc0ed8c | |
renovate | f8e2ef210f | |
Frederick [Bot] | 61379ed4c6 | |
renovate | f1f99d065c | |
konrad | da3eaf0d35 | |
kolaente | 34182b8bbb | |
kolaente | 20660564c1 | |
kolaente | c2ffe3a9dc | |
kolaente | a33e2f6c00 | |
kolaente | 0ce150af23 | |
kolaente | 06a1ff6f4b | |
kolaente | 7c964c29d4 | |
kolaente | b9f0635d9f | |
kolaente | 61baf02e26 | |
kolaente | 59b05e9836 | |
kolaente | f68bb2625e | |
renovate | 25f9f5dceb | |
renovate | 35d68619f4 | |
kolaente | 929d4f4023 | |
kolaente | a92eb31ab3 | |
kolaente | 2006abd0a6 | |
renovate | e4504748c4 | |
kolaente | 854228034d | |
kolaente | 88ce29aa77 | |
kolaente | 0ca1b3a7f5 | |
kolaente | a118580704 | |
kolaente | 771aad5420 | |
kolaente | 411ae58e59 | |
kolaente | c3501f5060 | |
konrad | 5ca31d00ee | |
kolaente | 1e2039325f | |
kolaente | 472cca8ab8 | |
kolaente | 2fad45e016 | |
kolaente | 68a137acf9 | |
kolaente | 95ba8b8a11 | |
kolaente | 96c9407414 | |
kolaente | 653415e764 | |
Dominik Pschenitschni | 73947f0ba4 | |
renovate | 389ca1b692 | |
renovate | 9c0e140e2e | |
renovate | 51d08a1637 | |
kolaente | 35de8a40d8 | |
kolaente | 80772f7578 | |
kolaente | faa62985df | |
kolaente | 154d43a392 | |
renovate | 7fe5565654 | |
kolaente | 1fcd1cdd4b | |
kolaente | ba057f3527 | |
kolaente | dd7b77e12d | |
renovate | 3845a45934 | |
renovate | 564808bd35 | |
renovate | c0a66e4746 | |
renovate | 28242cfb23 | |
renovate | 818fb2b524 | |
Frederick [Bot] | ad95bdd039 | |
renovate | 3faed19298 | |
renovate | 9114a86813 | |
renovate | bae9a5c9be | |
renovate | fe2d6d4467 | |
Frederick [Bot] | 96acea90ed | |
renovate | 21c98d5166 | |
renovate | b3cb36c1e1 | |
kolaente | 79ceaf6a2b | |
kolaente | 5694b39489 | |
kolaente | 32e5f9f757 | |
kolaente | 928b338cf2 | |
kolaente | 1a792e0667 | |
renovate | 6407644138 | |
renovate | 2db88b583b | |
kolaente | bef25c49d5 | |
renovate | f01ea20a38 | |
kolaente | 3c9083b90d | |
konrad | 169feaaf0f | |
kolaente | 5d59392566 | |
kolaente | 6593380013 | |
kolaente | 69e94e58c4 | |
kolaente | cd8e497b24 | |
kolaente | aab2020e68 | |
kolaente | a050419fdf | |
kolaente | f0c3980700 | |
kolaente | 68597c9709 | |
kolaente | 5325f6d7d9 | |
renovate | 8c687350a0 | |
renovate | bac679caf7 | |
renovate | 4f8ff17138 | |
Frederick [Bot] | 83e7138a18 | |
renovate | 8e44b87d07 | |
kolaente | 4b0022664a | |
kolaente | d8ad934643 | |
kolaente | 77ee1bfc3e | |
renovate | 8728647f00 | |
kolaente | bd7d09c17c | |
renovate | 77bedbd1cf | |
renovate | 2773612420 | |
kolaente | 48cfdddff7 | |
konrad | 3f8e457d52 | |
kolaente | 098b5fa2b1 | |
kolaente | 5e4eb4a728 | |
kolaente | 8930f61548 | |
kolaente | 9a736cf65f | |
kolaente | 2677f6254d | |
kolaente | bfcb36e093 | |
kolaente | 9ec29cad30 | |
renovate | c4f609a0c8 | |
renovate | 7e7535b860 | |
renovate | df9181b34e | |
renovate | e6a56f2822 | |
renovate | 3633d68269 | |
kolaente | dd3a5fe6b5 | |
kolaente | 04642ae1ec | |
kolaente | eac19e28d6 | |
kolaente | 11f94e4037 | |
kolaente | 39cc7a00d8 | |
kolaente | 02da1e171e | |
kolaente | ae177c73ea | |
kolaente | e6c4c18974 | |
kolaente | 95487d7569 | |
kolaente | 7b2a688b6e | |
Frederick [Bot] | f5b3b21ce0 | |
kolaente | 979561342a | |
kolaente | ad27f588a2 | |
kolaente | c7a989d7dc | |
renovate | 0e674d8300 | |
renovate | 121fd70235 | |
renovate | d4cd90da45 | |
kolaente | c74612f24a | |
kolaente | 64f9f4fd88 | |
kolaente | b50adaf4b5 | |
renovate | 7b92028e67 | |
renovate | 08d84f7994 | |
renovate | f95b138b9f | |
Frederick [Bot] | e6aecbd8dc | |
renovate | eab0600f63 | |
kolaente | 46f5dcb4dc | |
kolaente | 0dc7e83dc4 | |
kolaente | 82c10b87c8 | |
kolaente | 5888946861 | |
renovate | e24607ed3a | |
Frederick [Bot] | d1ae6a8b84 | |
renovate | fc052cf8f5 | |
konrad | d9f608e8b4 | |
konrad | a988565227 | |
Dominik Pschenitschni | b76fffb788 | |
kolaente | 25c3b7bcbf | |
kolaente | dfa6cd777b | |
kolaente | 21ad8301f2 | |
kolaente | 7110c9a5ce | |
kolaente | a4c8fccb11 | |
Dominik Pschenitschni | c294f9d28d | |
kolaente | 422d7fc693 | |
kolaente | abb5128426 | |
kolaente | 2174608801 | |
kolaente | a6cdf6c4bd | |
kolaente | 2c9693a83e | |
kolaente | 6989558963 | |
kolaente | 7fb85dacec | |
steffeydev | 57218d1454 | |
kolaente | 9df6950d1a | |
kolaente | cd2b7fe185 | |
kolaente | 52987060b1 | |
kolaente | aeb73a374f | |
kolaente | bc416f282f | |
kolaente | f88c373742 | |
kolaente | 10ac1ff66a | |
kolaente | ae025e30c6 | |
kolaente | a1dd1d6664 | |
kolaente | 57c64bbf71 | |
kolaente | 218a19d907 | |
kolaente | 7b6a13dd52 | |
kolaente | 4ff0c81e37 | |
kolaente | 6a15489610 | |
kolaente | 59c942af73 | |
kolaente | 302ba2bec7 | |
kolaente | 34d1e4bddd | |
kolaente | 02c24a4814 | |
kolaente | 0724776ccb | |
renovate | 11979cbee0 | |
renovate | 2a490bf8ef | |
renovate | b5d3d1a7b7 | |
renovate | 554ffe3b9d | |
renovate | 0f57be107b | |
renovate | 269aa6b426 | |
renovate | b316b8f2ba | |
renovate | cad68e269c | |
kolaente | efb3407b87 | |
renovate | 6f1ff02c04 | |
renovate | 93c66b0613 | |
renovate | c14644a300 | |
renovate | 02d2300608 | |
renovate | ff918608c5 | |
renovate | aa591ee2ed | |
kolaente | f4a7943680 | |
kolaente | 68fd4698ac | |
Frederick [Bot] | dd039f31fe | |
kolaente | 6c2dc483a2 | |
Dominik Pschenitschni | 811254e6a9 | |
cernst | 85ffed4d9a | |
cernst | 5fb45afb12 | |
cernst | fb14eca634 | |
cernst | 14e2698833 | |
cernst | 0d6c0c8399 | |
cernst | 5d38b8327f | |
cernst | f747d5b2fc | |
kolaente | 8a75790453 | |
kolaente | acb212ab24 | |
kolaente | 4ba02ebbb6 | |
kolaente | 244da46e38 | |
kolaente | f40035dc79 | |
renovate | 5f71e406fc | |
Frederick [Bot] | 3d11a4f03a | |
renovate | 1dfd2dc4b7 | |
renovate | e9701660d3 | |
renovate | c8dbb4c7ef | |
renovate | 1241d90268 | |
renovate | 3de5b65977 | |
renovate | 4a353553c3 | |
renovate | 1240f31c0a | |
kolaente | 01ac84ce1e | |
kolaente | 4c969f0a42 | |
kolaente | 8e2c76a33e | |
renovate | b3666ec27e | |
renovate | 2c6862c509 | |
renovate | 9f8c43818c | |
renovate | 0debca91c8 | |
renovate | 7b6c9fcd24 | |
renovate | 55675bf41b | |
renovate | bb24b06031 | |
renovate | dbce0376d5 | |
renovate | 40db144a41 | |
kolaente | f7ba3bd08f | |
konrad | ac1d374191 | |
kolaente | 391992effb | |
kolaente | 2e9ade11c3 | |
kolaente | f11a8c543b | |
kolaente | e30a4452f2 | |
kolaente | 6cc11e64ab | |
kolaente | 7b05ed9d3d | |
Frederick [Bot] | dba35c0107 | |
Frederick [Bot] | bfbc874b1d | |
kolaente | dbccdb239a | |
kolaente | f13db9268a | |
kolaente | ed8de7e3eb | |
kolaente | b34118485c | |
kolaente | 9c3259c660 | |
kolaente | a3e289c06c | |
kolaente | 31b7c1f217 | |
kolaente | c30dcff451 | |
kolaente | 086f50d4fe | |
kolaente | 46e825820c | |
kolaente | a3e2cbeb27 | |
kolaente | a342ae67de | |
kolaente | e4d97e0520 | |
kolaente | b69a05689b | |
kolaente | 6b824a49ab | |
kolaente | 652db56d42 | |
kolaente | afaf1846ec | |
kolaente | ba452ab883 | |
kolaente | 39f699a61a | |
kolaente | 4ab547810c | |
kolaente | bbaddb9406 | |
kolaente | a2cc9ddc88 | |
kolaente | 175e31ca62 | |
kolaente | d414b65e7d | |
kolaente | 78158bcba5 | |
kolaente | 9402344b7e | |
kolaente | 3eca9f6180 | |
kolaente | 26e3d42ed5 | |
kolaente | 6e095436e9 | |
kolaente | 1344026494 | |
kolaente | 1a94496801 | |
kolaente | 48570808e5 | |
kolaente | a7440ed296 | |
kolaente | 12ebefd86a | |
kolaente | 6c9cbaadc8 | |
kolaente | 9b10693172 | |
kolaente | db1c6d6a41 | |
kolaente | c56787443f | |
kolaente | cb218ec0c3 | |
kolaente | 0dd6f82a0e | |
kolaente | 225091864f | |
kolaente | ebd9c4702e | |
kolaente | 4ad9773022 | |
kolaente | 0a17df87e9 | |
kolaente | b567146d69 | |
kolaente | 65522a57f1 | |
kolaente | 1d936618fa | |
kolaente | 76814a2d3f | |
kolaente | 4134fcbd75 | |
kolaente | 49fac7db1c | |
kolaente | e25273df48 | |
kolaente | 638f6bea24 | |
kolaente | ddcd6a17dc | |
kolaente | 4e21b463df | |
kolaente | 3db4e011d4 | |
kolaente | a0d39e6081 | |
kolaente | a803bc637e | |
kolaente | d4e452545a | |
kolaente | 9d73ac661f | |
kolaente | 55e912221b | |
kolaente | d85be26761 | |
kolaente | ac78e85e17 | |
kolaente | 131022da42 | |
kolaente | 336db56316 | |
kolaente | b5d9afd0f7 | |
kolaente | 0be83db40f | |
kolaente | 03f4d0b8bc | |
kolaente | ee8f80cc70 | |
kolaente | ce887c38f3 | |
kolaente | 799c0be830 | |
kolaente | 760efa854d | |
kolaente | 26bec05174 | |
kolaente | c32a198a34 | |
kolaente | 6a8c656dbb | |
kolaente | 63ba2982c9 | |
kolaente | 9d9fb959d8 | |
kolaente | 8ed201c83f | |
kolaente | bfb40c9166 | |
kolaente | 5ea450844c | |
kolaente | 36bec9e64f | |
kolaente | a95014dc5d | |
kolaente | 2579c33ee1 | |
kolaente | 6f1baa3219 | |
kolaente | 4dee3a90e9 | |
kolaente | 326b6eda6f | |
kolaente | 85e882cc59 | |
kolaente | e4379f0a22 | |
kolaente | 2bb7ff1803 | |
kolaente | 5dd6e9a077 | |
kolaente | f7629c28f4 | |
kolaente | be2a38b48e | |
kolaente | 3ba5f531bb | |
kolaente | 10f1e69bc3 | |
kolaente | fd7d90b017 | |
kolaente | d898316918 | |
kolaente | a6f524e7af | |
kolaente | 5e65814b8c | |
kolaente | aaa9d553d0 | |
kolaente | 5685890493 | |
kolaente | 2e336150e0 | |
kolaente | 749dcdcd70 | |
kolaente | ab94343d07 | |
kolaente | fa71cec5c8 | |
kolaente | c6f3829387 | |
kolaente | 7171b63947 | |
kolaente | 06c4c0d921 | |
kolaente | f2ca2d850d | |
kolaente | 638d187a24 | |
kolaente | b188d40d3c | |
kolaente | 3ad948305f | |
kolaente | be1f1d94c9 | |
kolaente | 06e8cdb9d2 | |
kolaente | 10311b79df | |
kolaente | ad2690b21c | |
kolaente | 1bd17d6e50 | |
kolaente | a5e710bfe5 | |
kolaente | e1bdabc8d6 | |
renovate | c6ef99dde2 | |
renovate | 49b508a783 | |
renovate | 52128925f5 | |
renovate | cf0c7f9d08 | |
renovate | 57d5140301 | |
renovate | dbd9106621 | |
renovate | e4fef0e88e | |
renovate | 7ef0074ecc | |
renovate | 17c35f6d42 | |
renovate | 3a0844adba | |
renovate | 5b5b9022e0 | |
Frederick [Bot] | 0b0bd7dff6 | |
renovate | 079e3782d1 | |
renovate | a0ae9ae54c | |
renovate | a1b9a0ec4c | |
renovate | 1fa690670d | |
renovate | 3f0a87a5ec | |
renovate | caf02f78bf | |
renovate | 9b9fd14d27 | |
renovate | 7f77efbfab | |
renovate | 53967d20cc | |
renovate | ef3411f39a | |
renovate | 66e63f1363 | |
renovate | 2fe21f6b28 | |
renovate | 67df372636 | |
renovate | df80e9da23 | |
renovate | 13ab2efd0f | |
renovate | 0ffe96cf59 | |
renovate | 1808d0971d | |
renovate | ec83a28d78 | |
renovate | f0320b3a58 | |
renovate | d93a1a4f4f | |
renovate | a9f9ddf6b9 | |
renovate | 6a8fe35fcf | |
renovate | 94661e9e09 | |
renovate | 318f63d098 | |
renovate | e2c9e83c2a | |
renovate | cd434a0e3e | |
renovate | 9f293af804 | |
renovate | b175e00cfe | |
Frederick [Bot] | 19dd82d62a | |
renovate | b3ddc9465a | |
renovate | 6b38f17d32 | |
renovate | 86449d4912 | |
renovate | 145d756251 | |
renovate | 838a11a2f6 | |
renovate | 3bfd3210b0 | |
renovate | e933bfa99e | |
renovate | f6a37a54d0 | |
primeapple | e00c9bb1af | |
kolaente | 018707c3d5 | |
renovate | 386727f6c5 | |
renovate | a29ce36d6c | |
renovate | 7aed16bd6f | |
renovate | b1f3ca6e59 | |
renovate | 4c0b8a06c5 | |
renovate | 60647c50ac | |
renovate | 59eaf1849e | |
renovate | fb57339050 | |
renovate | f9831a6ad8 | |
renovate | f25c67f80a | |
renovate | d3b0b97192 | |
renovate | fa3be219a8 | |
renovate | c22702d911 | |
renovate | b25c5ff547 | |
renovate | 2e0a097806 | |
renovate | c2083f7924 | |
renovate | 8923261e5b | |
renovate | 5391df56b0 | |
renovate | d2b1f5780e | |
renovate | 1717e968e1 | |
renovate | 2c29bb3971 | |
renovate | 37b8218a0a | |
konrad | ca7bbb5b91 | |
renovate | 2f3c008d2b | |
renovate | c2722b7c3d | |
renovate | 312abd907f | |
renovate | 1b73c1ed64 | |
renovate | d442d6653b | |
renovate | 758b8d6e2b | |
renovate | 416fd2e2a7 | |
renovate | 15a8335f1a | |
renovate | c689583669 | |
renovate | 7a43a7acc9 | |
renovate | 8843418161 | |
renovate | 7c1eab13ae | |
renovate | 5a69036da7 | |
renovate | 2ad3458873 | |
renovate | eb464343e8 | |
kolaente | 2f18d0cbad | |
renovate | 6cd463a514 | |
kolaente | 05b70632c5 | |
kolaente | ca9fe6ff21 | |
renovate | e5754300de | |
renovate | 65134048bf | |
renovate | 8339a99747 | |
renovate | 3e1ae41e70 | |
renovate | f757ba3441 | |
renovate | 6499c9cb5b | |
renovate | 28e5440d8b | |
renovate | fef8c4d0f4 | |
renovate | 99e5059c64 | |
renovate | 5df4f39d95 | |
renovate | 7ec5a70ccb | |
renovate | 72fcab6e78 | |
kolaente | 292c90425e | |
kolaente | b80f070431 | |
renovate | 03936c0403 | |
kolaente | 62825d2e64 | |
renovate | 5cd5caef45 | |
renovate | 798e8b529d | |
renovate | 0e3766c5a5 | |
renovate | 90207a4427 | |
renovate | 60993a886a | |
renovate | a6b42f9181 | |
renovate | 98fbd7c53c | |
renovate | 8d533f50e8 | |
renovate | 707459ec77 | |
renovate | faf7db649e | |
renovate | 202e71be48 | |
renovate | d6e8b418d3 | |
renovate | a9f41f6114 | |
renovate | f6f0d52518 | |
renovate | ccb9be42c2 | |
renovate | 179009bfe3 | |
renovate | 8c2bd94a9f | |
renovate | 7757166d75 | |
renovate | 7f03002972 | |
renovate | 8555006d9e | |
renovate | 713ad64658 | |
renovate | 0713d481e3 | |
renovate | ace0cf3588 | |
renovate | bba3bbfe89 | |
renovate | 754afc5496 | |
renovate | f1e8892ab5 | |
renovate | c11e192c4e | |
renovate | e9c704075d | |
renovate | 35edcb5672 | |
renovate | 4695798176 | |
renovate | 7a323fd170 | |
renovate | 1d6e4b6e32 | |
renovate | 5524aa7998 | |
renovate | 15ff2008e3 | |
renovate | 9bc2e6e165 | |
renovate | 344001856c | |
renovate | ad261fcc2f | |
renovate | 5142a0ae72 | |
renovate | 6d195f96c9 | |
Frederick [Bot] | 1917b217a8 | |
renovate | 1f6b01bc73 | |
renovate | d47a16aa8e | |
renovate | c57d00a74b | |
renovate | 77ea7fa0ee | |
kolaente | b92d780cda | |
kolaente | f14e721caf | |
renovate | 1ff6399112 | |
renovate | 503fb8da76 | |
renovate | f050cb7015 | |
renovate | 3670916f36 | |
Frederick [Bot] | 838a063eaa | |
renovate | e1b16b11d6 | |
Dominik Pschenitschni | 314cbf471f | |
Dominik Pschenitschni | a416d26f7c | |
Dominik Pschenitschni | 795b26e1dd | |
renovate | 14666cf9d8 | |
renovate | c938f31935 | |
kolaente | 35a52ef01b | |
renovate | 3b05ce3f10 | |
renovate | aec4fd7a2d | |
renovate | 2661af3a17 | |
renovate | 56f43bae3f | |
renovate | 84472d2e9c | |
renovate | c5afcd63b0 | |
renovate | 9bdb257814 | |
renovate | 5ad9891b16 | |
renovate | 7c04064917 | |
renovate | fb5383d86b | |
renovate | 68af314ec0 | |
renovate | 8b1de5ce09 | |
renovate | 724b6fe091 | |
renovate | 6648cd30c3 | |
kolaente | 8b90b45739 | |
renovate | 39be67eecf | |
Frederick [Bot] | 750f0ddeab | |
renovate | 6a5ece2f24 | |
Frederick [Bot] | 4ce33abfe6 | |
renovate | 5b7e1af87d | |
renovate | 59c6605b14 | |
Dominik Pschenitschni | 820d598ecd | |
Dominik Pschenitschni | a263ec1273 | |
renovate | b68892492c | |
renovate | 7c97695cec | |
renovate | e764f34a2d | |
renovate | 6892a28bb6 | |
renovate | 74d688b8d2 | |
renovate | ed84651046 | |
renovate | 7468ed21fa | |
renovate | d8015913c3 | |
Frederick [Bot] | 78789834f0 | |
Dominik Pschenitschni | 739fe0caa1 | |
Dominik Pschenitschni | 4703f9c4d5 | |
Dominik Pschenitschni | fd699ad777 | |
Dominik Pschenitschni | 0acf44778d | |
Dominik Pschenitschni | 8fc254d2db | |
Dominik Pschenitschni | 7d3b97d422 | |
kolaente | 4a34f245db | |
renovate | 973ea39a64 | |
renovate | f94a65ce7a | |
renovate | 432fbbea78 | |
renovate | e483f1cd2e | |
renovate | eb34f6e136 | |
Dominik Pschenitschni | 91e9eef582 | |
Dominik Pschenitschni | dea1789a00 | |
WofWca | 30adad5ae6 | |
renovate | 3ed6f939e5 | |
renovate | a337d22c1f | |
renovate | addfcf2510 | |
renovate | 303034f02c | |
renovate | 0fd44e9484 | |
renovate | 04040f20ba | |
konrad | 6c999ad148 | |
renovate | cc519e6773 | |
renovate | f9dcae4f65 | |
renovate | ade6c2cb18 | |
renovate | 4566b62a93 | |
renovate | 37d3ef24d2 | |
kolaente | 71265769ce | |
kolaente | a13c16ca03 | |
kolaente | a33fb72ef8 | |
kolaente | c5776264c0 | |
kolaente | 078d8b39a9 | |
kolaente | b77c7c2f45 | |
renovate | e369473dd0 | |
renovate | 70501f9da1 | |
renovate | 9bb7019b09 | |
renovate | df4fe7a644 | |
renovate | 2f009d0b27 | |
renovate | 70d7def7d7 | |
renovate | 0033407f96 | |
renovate | b10a2329ca | |
WofWca | 6870db4a72 | |
WofWca | 3643ffe0d0 | |
renovate | 02971f6ff9 | |
renovate | 7d3c34b004 | |
renovate | f3ea6fd4dc | |
renovate | bed6b81a58 | |
renovate | f9bf9139b8 | |
Dominik Pschenitschni | 96e2c81b7e | |
renovate | e62c00a187 | |
renovate | 611419888a | |
renovate | 5cc7e282bf | |
renovate | de0b71103c | |
renovate | 537e9e8044 | |
renovate | ac95c1fdc8 | |
renovate | b36da9e4d9 | |
renovate | e11ee3c136 | |
renovate | 887719ea24 | |
renovate | 14f1c3b26e | |
renovate | 2142729d38 | |
renovate | 9dcc2baae2 | |
renovate | 37c88d2974 | |
renovate | 36fd0deec4 | |
renovate | 4a4438d431 | |
renovate | 28a6745346 | |
renovate | 9ae0470879 | |
renovate | 927aed1161 | |
renovate | 7f3d7a656d | |
renovate | 040a8ce095 | |
Frederick [Bot] | 8974939bf2 | |
Dominik Pschenitschni | 846de369f2 | |
Frederick [Bot] | 4d865af423 | |
renovate | 62ad01fc8f | |
renovate | da0164b97d | |
kolaente | fc8711d6d8 | |
renovate | 03cef1f831 | |
WofWca | ee4974a494 | |
WofWca | bfbfd6a421 | |
renovate | 49954abbbe | |
renovate | 2f618512cb | |
Frederick [Bot] | 0086ebed0d | |
renovate | 1523ed9a47 | |
renovate | 79c7cbedcc | |
renovate | 3e128f3966 | |
renovate | fb45483ffc | |
renovate | a9bc7d7a38 | |
renovate | 78f032d678 | |
Dominik Pschenitschni | d73b71a097 | |
renovate | 2bdc6155d7 | |
Dominik Pschenitschni | f60cebf42c | |
renovate | 2e4c6673d4 | |
kolaente | 6e3d64d6ef | |
kolaente | 2deb66855b | |
kolaente | a64c0c19e5 | |
kolaente | 24b4576c00 | |
kolaente | 34ad889d90 | |
Dominik Pschenitschni | af523cfcd7 | |
Dominik Pschenitschni | 842f204123 | |
Dominik Pschenitschni | 9162002e55 | |
Dominik Pschenitschni | 985f998a82 | |
Dominik Pschenitschni | b93639e14e | |
Dominik Pschenitschni | a4be973e29 | |
Dominik Pschenitschni | 060a573fe9 | |
Dominik Pschenitschni | 7c43b7385d | |
kolaente | befa6f27bb | |
Dominik Pschenitschni | b9d3b5c756 | |
renovate | ee732684bc | |
renovate | 360b530dd5 | |
renovate | 713c3a1a08 | |
renovate | 81b1e4035d | |
renovate | 2cde9341d4 | |
renovate | cdf0690da6 | |
renovate | 80335e7b95 | |
renovate | dbc2de14c9 | |
renovate | c0f711d27f | |
kolaente | df24522490 | |
kolaente | 6cf2e574bf | |
kolaente | e7b89ae44f | |
renovate | 72a1aaa654 | |
renovate | c70c3b6080 | |
kolaente | 401f2cdd7e | |
konrad | 013472e899 | |
renovate | e9d48c442d | |
renovate | 6837038922 | |
renovate | 6cf7c75954 | |
Frederick [Bot] | 52d6677d93 | |
renovate | f8f8c8ac6e | |
renovate | 97bd5d77b6 | |
renovate | 5278bcbac2 | |
renovate | 194fef0dab | |
renovate | 97b6ba06dd | |
Frederick [Bot] | 559cfde8da | |
WofWca | 9db3aedde9 | |
WofWca | 0eb78e32f9 | |
WofWca | b4dd23b85d | |
renovate | 2262b49aaf | |
renovate | c887990bad | |
renovate | 37c5a88744 | |
Dominik Pschenitschni | 9b7770ade4 | |
Dominik Pschenitschni | af4a039502 | |
Dan Stewart | 1b06112db4 | |
renovate | 0952f059c0 | |
WofWca | 0f97ba6ec9 | |
renovate | d4c9edb55d | |
renovate | 394f056cf4 | |
renovate | 7672676b6e | |
renovate | 51b33fd67e | |
renovate | 0ed3ebda94 | |
danstewart | 7b6f76d1b4 | |
renovate | ad0029789d | |
renovate | e13f57c30a | |
WofWca | 6a3518dace | |
renovate | f1ec554d09 | |
WofWca | 6aa02e29b1 | |
WofWca | 5f9485414b | |
WofWca | 149ceaf2e5 | |
renovate | 9b3e185dd4 | |
renovate | 779fe3e323 | |
renovate | a27b77f24e | |
renovate | 41f22a1035 | |
renovate | 28d01c5ba0 | |
Frederick [Bot] | e272dd8e64 | |
kolaente | c002275e7f | |
renovate | 1392d7f101 | |
renovate | e5758e21c7 | |
Dominik Pschenitschni | e0f06999be | |
renovate | 3b72acff27 | |
renovate | df1c44aabe | |
kolaente | fe764a46e9 | |
renovate | 000e3080a5 | |
renovate | f4c568e961 | |
renovate | ef70ead3f0 | |
renovate | 7a326d6e03 | |
renovate | afb6383a85 | |
renovate | e49969dcad | |
renovate | 81e1d70847 | |
renovate | 5226517954 | |
renovate | d5d0f9a8e2 | |
renovate | 2337b6c9f3 | |
renovate | 289802b13d | |
renovate | 5de9a2880f | |
Frederick [Bot] | 62f6895950 | |
renovate | c198b9a164 | |
renovate | d5f5e2a412 | |
kolaente | cabee68bbb | |
kolaente | 2fd2214a2e | |
kolaente | 64735e0c3d | |
kolaente | 1f40b68108 | |
renovate | 4033c28a67 | |
renovate | be20a01dd6 | |
renovate | 8f5a628e54 | |
renovate | e0c00b306e | |
renovate | 10eaacc552 | |
renovate | 4b1465955a | |
renovate | 1711318212 | |
renovate | b042547aaa | |
renovate | ed0db956eb | |
renovate | a66f8a6484 | |
renovate | 47e895149e | |
renovate | 8e00014feb | |
renovate | 6146340034 | |
renovate | c7b761b0eb | |
renovate | a1e84b3460 | |
renovate | 038debaa22 | |
renovate | 88faf04251 | |
renovate | 04be2b9745 | |
renovate | 815e8cce0e | |
renovate | d12f9247ff | |
renovate | 85e7a17934 | |
renovate | 59c5d43348 | |
renovate | c011f9aa52 | |
renovate | b9f5319a4f | |
renovate | f120ba4169 | |
renovate | b2b70f4a9d | |
renovate | 9facffe3e9 | |
renovate | c31aff1d88 | |
renovate | 60dea80462 | |
renovate | cd10ccfbc0 | |
renovate | 8647402038 | |
renovate | 990fd46302 | |
renovate | cf0aafd9e6 | |
renovate | 70d2535e93 | |
Frederick [Bot] | 0c6f1a4083 | |
renovate | 29eb42932a | |
renovate | 736e9051d8 | |
renovate | 4a4c401558 | |
renovate | 9198abe24d | |
Dominik Pschenitschni | 97c8970dd6 | |
renovate | 5303b6bc97 | |
renovate | 24a0a8f5eb | |
Dominik Pschenitschni | d07ad495e2 | |
renovate | 8465afe421 | |
kolaente | d40729cbe7 | |
kolaente | fa0e46a399 | |
renovate | b78481f9f6 | |
renovate | cbc9cf6f7f | |
renovate | 62fd9a656e | |
renovate | 85269b4524 | |
renovate | 536d709961 | |
renovate | 59d6d7e786 | |
renovate | ae86d0d42a | |
renovate | 9a20b7a853 | |
renovate | 5687b66ea5 | |
renovate | 1da411e1f6 | |
renovate | e8a6d3f31b | |
renovate | a25a795276 | |
renovate | 57f6abd99f | |
renovate | 84d205f90b | |
renovate | de91e7c9ae | |
renovate | 2cf9c35acb | |
konrad | db525db6eb | |
konrad | 88525ae7c8 | |
renovate | 957bfdc8f1 | |
renovate | c52ae83b75 | |
renovate | df40c4e475 | |
renovate | 3f41e9a3a6 | |
renovate | 1da510b5dd | |
renovate | 536db3fd46 | |
kolaente | cefa5250c5 | |
kolaente | f697640636 | |
renovate | 09b7595b68 | |
renovate | 6b7f73f724 | |
Dominik Pschenitschni | d6b55c7570 | |
Yurii Vlasov | 3f4b08b8be | |
kolaente | 791c61cabb | |
konrad | e3dd4ef78a | |
renovate | 830d0887b9 | |
Dominik Pschenitschni | e8db2c2b45 | |
renovate | 706a13242e | |
renovate | 13fab10584 | |
renovate | 4b0c8aa66b | |
renovate | bfaf9401f4 | |
renovate | 13607124a6 | |
renovate | 9fc3d0a965 | |
renovate | 4d6286451e | |
renovate | 0479d17e69 | |
renovate | 5ca272959d | |
Dominik Pschenitschni | c502f9b840 | |
renovate | a3a313a21f | |
renovate | c58d1ffd2e | |
David Angel | 99dc5cf34f | |
David Angel | 3604cb3ec7 | |
David Angel | aa01a92278 | |
Dominik Pschenitschni | 7b96397e3b | |
renovate | b45a4e1aaf | |
Frederick [Bot] | d3365d6add | |
renovate | 49cb2b9e6f | |
renovate | d4ce10e79a | |
renovate | 345c5e3588 | |
renovate | 7ff84bcd29 | |
renovate | d1633ef622 | |
renovate | 7e92bc63ac | |
renovate | be076b65cf |
77
.drone.yml
77
.drone.yml
|
@ -15,6 +15,7 @@ trigger:
|
|||
services:
|
||||
- name: api
|
||||
image: vikunja/api:unstable
|
||||
pull: always
|
||||
environment:
|
||||
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
|
||||
VIKUNJA_LOG_LEVEL: DEBUG
|
||||
|
@ -41,11 +42,12 @@ steps:
|
|||
# - .cache
|
||||
|
||||
- name: dependencies
|
||||
image: node:18-alpine
|
||||
image: node:20.9-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
CYPRESS_CACHE_FOLDER: .cache/cypress
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
commands:
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
- pnpm install --fetch-timeout 100000
|
||||
|
@ -53,7 +55,7 @@ steps:
|
|||
# - restore-cache
|
||||
|
||||
- name: lint
|
||||
image: node:18-alpine
|
||||
image: node:20.9-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -64,7 +66,7 @@ steps:
|
|||
- dependencies
|
||||
|
||||
- name: build-prod
|
||||
image: node:18-alpine
|
||||
image: node:20.9-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -75,7 +77,7 @@ steps:
|
|||
- dependencies
|
||||
|
||||
- name: test-unit
|
||||
image: node:18-alpine
|
||||
image: node:20.9-alpine
|
||||
pull: always
|
||||
commands:
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
|
@ -85,7 +87,7 @@ steps:
|
|||
|
||||
- name: typecheck
|
||||
failure: ignore
|
||||
image: node:18-alpine
|
||||
image: node:20.9-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
|
@ -135,8 +137,9 @@ steps:
|
|||
# - dependencies
|
||||
|
||||
- name: deploy-preview
|
||||
image: node:18-alpine
|
||||
image: williamjackson/netlify-cli
|
||||
pull: always
|
||||
user: root # The rest runs as root and thus the permissions wouldn't work
|
||||
environment:
|
||||
NETLIFY_AUTH_TOKEN:
|
||||
from_secret: netlify_auth_token
|
||||
|
@ -199,10 +202,15 @@ steps:
|
|||
# - .cache
|
||||
|
||||
- name: build
|
||||
image: node:18-alpine
|
||||
image: node:20.9-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
SENTRY_AUTH_TOKEN:
|
||||
from_secret: sentry_auth_token
|
||||
SENTRY_ORG: vikunja
|
||||
SENTRY_PROJECT: frontend-oss
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
commands:
|
||||
- apk add git
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
|
@ -218,6 +226,7 @@ steps:
|
|||
image: kolaente/zip
|
||||
pull: always
|
||||
commands:
|
||||
- cp src/version.json dist
|
||||
- cd dist
|
||||
- zip -r ../vikunja-frontend-unstable.zip *
|
||||
- cd ..
|
||||
|
@ -276,10 +285,14 @@ steps:
|
|||
# - .cache
|
||||
|
||||
- name: build
|
||||
image: node:18-alpine
|
||||
image: node:20.9-alpine
|
||||
pull: always
|
||||
environment:
|
||||
PNPM_CACHE_FOLDER: .cache/pnpm
|
||||
SENTRY_AUTH_TOKEN:
|
||||
from_secret: sentry_auth_token
|
||||
SENTRY_ORG: vikunja
|
||||
SENTRY_PROJECT: frontend-oss
|
||||
commands:
|
||||
- apk add git
|
||||
- corepack enable && pnpm config set store-dir .cache/pnpm
|
||||
|
@ -295,6 +308,7 @@ steps:
|
|||
image: kolaente/zip
|
||||
pull: always
|
||||
commands:
|
||||
- cp src/version.json dist
|
||||
- cd dist
|
||||
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
|
||||
- cd ..
|
||||
|
@ -346,8 +360,7 @@ type: docker
|
|||
name: docker-release
|
||||
|
||||
depends_on:
|
||||
- release-latest
|
||||
- release-version
|
||||
- build
|
||||
|
||||
trigger:
|
||||
ref:
|
||||
|
@ -375,8 +388,7 @@ steps:
|
|||
repo: vikunja/frontend
|
||||
tags: unstable
|
||||
build_args:
|
||||
- USE_RELEASE=true
|
||||
- RELEASE_VERSION=unstable
|
||||
- USE_RELEASE=false
|
||||
platforms:
|
||||
- linux/386
|
||||
- linux/amd64
|
||||
|
@ -410,8 +422,7 @@ steps:
|
|||
from_secret: docker_password
|
||||
repo: vikunja/frontend
|
||||
build_args:
|
||||
- USE_RELEASE=true
|
||||
- RELEASE_VERSION=${DRONE_TAG##v}
|
||||
- USE_RELEASE=false
|
||||
platforms:
|
||||
- linux/386
|
||||
- linux/amd64
|
||||
|
@ -464,24 +475,25 @@ name: update-translations
|
|||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
include:
|
||||
- main
|
||||
event:
|
||||
- cron
|
||||
include:
|
||||
- cron
|
||||
cron:
|
||||
- update_translations
|
||||
|
||||
steps:
|
||||
- name: download
|
||||
pull: always
|
||||
image: jonasfranz/crowdin
|
||||
image: git.lcomrade.su/root/drone-crowdin-v2
|
||||
settings:
|
||||
download: true
|
||||
export_dir: src/i18n/lang/
|
||||
ignore_branch: true
|
||||
project_identifier: vikunja
|
||||
environment:
|
||||
CROWDIN_KEY:
|
||||
crowdin_key:
|
||||
from_secret: crowdin_key
|
||||
project_id: 462614
|
||||
target: download
|
||||
download_to: src/i18n/lang/
|
||||
download_export_approved_only: true
|
||||
|
||||
- name: move-files
|
||||
pull: always
|
||||
|
@ -504,23 +516,22 @@ steps:
|
|||
commit_message: "[skip ci] Updated translations via Crowdin"
|
||||
remote: "ssh://git@kolaente.dev:9022/vikunja/frontend.git"
|
||||
ssh_key:
|
||||
from_secret: translation_git_push_ssh_key
|
||||
from_secret: git_push_ssh_key
|
||||
|
||||
- name: upload
|
||||
pull: always
|
||||
image: jonasfranz/crowdin
|
||||
image: git.lcomrade.su/root/drone-crowdin-v2
|
||||
depends_on:
|
||||
- clone
|
||||
settings:
|
||||
files:
|
||||
en.json: src/i18n/lang/en.json
|
||||
ignore_branch: true
|
||||
project_identifier: vikunja
|
||||
environment:
|
||||
CROWDIN_KEY:
|
||||
from_secret: crowdin_key
|
||||
crowdin_key:
|
||||
from_secret: crowdin_key
|
||||
project_id: 462614
|
||||
target: upload
|
||||
upload_files:
|
||||
src/i18n/lang/en.json: en.json
|
||||
---
|
||||
kind: signature
|
||||
hmac: 971875b90c7bb1649d1b00d022d0b594ba9b68f927bf8f0dbe840190816d676b
|
||||
hmac: dab902060979f246df77641c995c843ea39f86dba2de9003da7e593ce6f6f08a
|
||||
|
||||
...
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
name: 'Repo Lockdown'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: opened
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/repo-lockdown@v3
|
||||
with:
|
||||
pr-comment: 'Hi! Thank you for your contribution.
|
||||
|
||||
This repo is only a mirror and unfortunately we can''t accept PRs made here. Please re-submit your changes to [our Gitea instance](https://kolaente.dev/vikunja/frontend/pulls).
|
||||
|
||||
Also check out the [contribution guidelines](https://vikunja.io/docs/development/#pull-requests).
|
||||
|
||||
Thank you for your understanding.'
|
14
.npmrc
14
.npmrc
|
@ -1,2 +1,14 @@
|
|||
fetch-timeout=100000
|
||||
|
||||
# pnpm settings
|
||||
# The following settings prepare for the new default value of pnpm 8
|
||||
# they can be removed directly after having moved to pnpm 8
|
||||
auto-install-peers=true
|
||||
fetch-timeout=100000
|
||||
dedupe-peer-dependents=true
|
||||
resolve-peers-from-workspace-root=true
|
||||
save-workspace-protocol=rolling
|
||||
resolution-mode=lowest-direct
|
||||
publishConfig.linkDirectory=true
|
||||
|
||||
# remove some time after having moved to pnpm 8
|
||||
use-lockfile-v6=true
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"lokalise.i18n-ally",
|
||||
"mgmcdermott.vscode-language-babel",
|
||||
"mikestead.dotenv",
|
||||
"Syler.sass-indented"
|
||||
"Syler.sass-indented",
|
||||
"zixuanchen.vitest-explorer"
|
||||
]
|
||||
}
|
|
@ -18,6 +18,12 @@
|
|||
"javascriptreact",
|
||||
"vue"
|
||||
],
|
||||
|
||||
"volar.completion.preferredTagNameCase": "pascal",
|
||||
|
||||
// disable vetur in case it is installed
|
||||
"vetur.validation.template": false,
|
||||
|
||||
// i18n ally
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/i18n/lang"
|
||||
|
|
1011
CHANGELOG.md
1011
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
16
Dockerfile
16
Dockerfile
|
@ -3,16 +3,18 @@
|
|||
# │─││ │││ │ │
|
||||
# ┘─┘┘─┘┘┘─┘┘─┘
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:18-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:20.9-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
ARG USE_RELEASE=false
|
||||
ARG RELEASE_VERSION=main
|
||||
ARG RELEASE_VERSION=unstable
|
||||
ENV PNPM_CACHE_FOLDER .cache/pnpm/
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD true
|
||||
|
||||
COPY package.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
COPY patches ./patches/
|
||||
|
||||
RUN if [ "$USE_RELEASE" != true ]; then \
|
||||
# https://pnpm.io/installation#using-corepack
|
||||
|
@ -51,9 +53,12 @@ LABEL maintainer="maintainers@vikunja.io"
|
|||
ENV VIKUNJA_HTTP_PORT 80
|
||||
ENV VIKUNJA_HTTP2_PORT 81
|
||||
ENV VIKUNJA_LOG_FORMAT main
|
||||
ENV VIKUNJA_API_URL http://localhost:3456/api/v1
|
||||
ENV VIKUNJA_API_URL /api/v1
|
||||
ENV VIKUNJA_SENTRY_ENABLED false
|
||||
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
|
||||
ENV VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED false
|
||||
ENV VIKUNJA_ALLOW_ICON_CHANGES true
|
||||
ENV VIKUNJA_CUSTOM_LOGO_URL "''"
|
||||
|
||||
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
|
||||
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
|
||||
|
@ -64,6 +69,5 @@ COPY --from=builder /build/dist ./
|
|||
# manage permissions
|
||||
RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
|
||||
chmod -R 0644 /etc/nginx/nginx.conf && \
|
||||
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates
|
||||
# unprivileged user
|
||||
USER nginx
|
||||
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \
|
||||
rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.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.20.3-brightgreen.svg)](https://dl.vikunja.io)
|
||||
[![Download](https://img.shields.io/badge/download-v0.21.0-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.
|
||||
|
@ -50,7 +50,3 @@ pnpm run build
|
|||
```shell
|
||||
pnpm run lint
|
||||
```
|
||||
|
||||
## Sponsors
|
||||
|
||||
[![Relm](https://vikunja.io/images/sponsors/relm.png)](https://relm.us)
|
||||
|
|
|
@ -24,4 +24,5 @@ export default defineConfig({
|
|||
},
|
||||
viewportWidth: 1600,
|
||||
viewportHeight: 900,
|
||||
experimentalMemoryManagement: true,
|
||||
})
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {prepareLists} from './prepareLists'
|
||||
|
||||
describe('List History', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareLists()
|
||||
|
||||
it('should show a list history on the home page', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
|
||||
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
|
||||
|
||||
const lists = ListFactory.create(6)
|
||||
|
||||
cy.visit('/')
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.get('body')
|
||||
.should('not.contain', 'Last viewed')
|
||||
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[1].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[2].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[3].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[4].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
cy.visit(`/lists/${lists[5].id}`)
|
||||
cy.wait('@loadNamespaces')
|
||||
cy.wait('@loadList')
|
||||
|
||||
// cy.visit('/')
|
||||
// cy.wait('@loadNamespaces')
|
||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||
cy.get('nav.menu.top-menu a')
|
||||
.contains('Overview')
|
||||
.click()
|
||||
|
||||
cy.get('body')
|
||||
.should('contain', 'Last viewed')
|
||||
cy.get('[data-cy="listCardGrid"]')
|
||||
.should('not.contain', lists[0].title)
|
||||
.should('contain', lists[1].title)
|
||||
.should('contain', lists[2].title)
|
||||
.should('contain', lists[3].title)
|
||||
.should('contain', lists[4].title)
|
||||
.should('contain', lists[5].title)
|
||||
})
|
||||
})
|
|
@ -1,122 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareLists} from './prepareLists'
|
||||
|
||||
describe('Lists', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let lists
|
||||
prepareLists((newLists) => (lists = newLists))
|
||||
|
||||
it('Should create a new list', () => {
|
||||
cy.visit('/')
|
||||
cy.get('.namespace-title .dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-title .dropdown .dropdown-item')
|
||||
.contains('New list')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/lists/new/1')
|
||||
cy.get('.card-header-title')
|
||||
.contains('New list')
|
||||
cy.get('input.input')
|
||||
.type('New List')
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
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/')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', 'New List')
|
||||
})
|
||||
|
||||
it('Should redirect to a specific list view after visited', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.url()
|
||||
.should('contain', '/lists/1/kanban')
|
||||
cy.visit('/lists/1')
|
||||
cy.url()
|
||||
.should('contain', '/lists/1/kanban')
|
||||
})
|
||||
|
||||
it('Should rename the list in all places', () => {
|
||||
TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
})
|
||||
const newListName = 'New list name'
|
||||
|
||||
cy.visit('/lists/1')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', 'First List')
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.get('#title')
|
||||
.type(`{selectall}${newListName}`)
|
||||
cy.get('footer.card-footer .button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.visit('/')
|
||||
cy.get('.card-content')
|
||||
.should('contain', newListName)
|
||||
.should('not.contain', lists[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a list', () => {
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.location('pathname')
|
||||
.should('equal', '/')
|
||||
})
|
||||
|
||||
it('Should archive a list', () => {
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
|
||||
cy.get('.list-title .dropdown')
|
||||
.click()
|
||||
cy.get('.list-title .dropdown .dropdown-menu .dropdown-item')
|
||||
.contains('Archive')
|
||||
.click()
|
||||
cy.get('.modal-content')
|
||||
.should('contain.text', 'Archive this list')
|
||||
cy.get('.modal-content [data-cy=modalPrimary]')
|
||||
.click()
|
||||
|
||||
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
|
||||
.should('not.contain', lists[0].title)
|
||||
cy.get('main.app-content')
|
||||
.should('contain.text', 'This list is archived. It is not possible to create new or edit tasks for it.')
|
||||
})
|
||||
})
|
|
@ -1,145 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
|
||||
describe('Namepaces', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let namespaces
|
||||
|
||||
beforeEach(() => {
|
||||
namespaces = NamespaceFactory.create(1)
|
||||
ListFactory.create(1)
|
||||
})
|
||||
|
||||
it('Should be all there', () => {
|
||||
cy.visit('/namespaces')
|
||||
cy.get('[data-cy="namespace-title"]')
|
||||
.should('contain', namespaces[0].title)
|
||||
})
|
||||
|
||||
it('Should create a new Namespace', () => {
|
||||
const newNamespaceTitle = 'New Namespace'
|
||||
|
||||
cy.visit('/namespaces')
|
||||
cy.get('[data-cy="new-namespace"]')
|
||||
.should('contain', 'New namespace')
|
||||
.click()
|
||||
|
||||
cy.url()
|
||||
.should('contain', '/namespaces/new')
|
||||
cy.get('.card-header-title')
|
||||
.should('contain', 'New namespace')
|
||||
cy.get('input.input')
|
||||
.type(newNamespaceTitle)
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container')
|
||||
.should('contain', newNamespaceTitle)
|
||||
cy.url()
|
||||
.should('contain', '/namespaces')
|
||||
})
|
||||
|
||||
it('Should rename the namespace all places', () => {
|
||||
const newNamespaces = NamespaceFactory.create(5)
|
||||
const newNamespaceName = 'New namespace name'
|
||||
|
||||
cy.visit('/namespaces')
|
||||
|
||||
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/edit')
|
||||
cy.get('#namespacetext')
|
||||
.invoke('val')
|
||||
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
|
||||
cy.get('#namespacetext')
|
||||
.type(`{selectall}${newNamespaceName}`)
|
||||
cy.get('footer.card-footer .button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
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('[data-cy="namespaces-list"]')
|
||||
.should('contain', newNamespaceName)
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a namespace when deleting it', () => {
|
||||
const newNamespaces = NamespaceFactory.create(5)
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
|
||||
.click()
|
||||
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.namespace-container .menu.namespaces-lists')
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
})
|
||||
|
||||
it('Should not show archived lists & namespaces if the filter is not checked', () => {
|
||||
const n = NamespaceFactory.create(1, {
|
||||
id: 2,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
ListFactory.create(1, {
|
||||
id: 2,
|
||||
namespace_id: n[0].id,
|
||||
}, false)
|
||||
|
||||
ListFactory.create(1, {
|
||||
id: 3,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
|
||||
// Initial
|
||||
cy.visit('/namespaces')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
|
||||
// Show archived
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('be.checked')
|
||||
cy.get('.namespace')
|
||||
.should('contain', 'Archived')
|
||||
|
||||
// Don't show archived
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
|
||||
// Second time visiting after unchecking
|
||||
cy.visit('/namespaces')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
})
|
||||
})
|
|
@ -1,19 +0,0 @@
|
|||
import {ListFactory} from '../../factories/list'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
export function createLists() {
|
||||
NamespaceFactory.create(1)
|
||||
const lists = ListFactory.create(1, {
|
||||
title: 'First List'
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
return lists
|
||||
}
|
||||
|
||||
export function prepareLists(setLists = () => {}) {
|
||||
beforeEach(() => {
|
||||
const lists = createLists()
|
||||
setLists(lists)
|
||||
})
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {UserListFactory} from '../../factories/users_list'
|
||||
|
||||
describe('Editor', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
beforeEach(() => {
|
||||
NamespaceFactory.create(1)
|
||||
ListFactory.create(1)
|
||||
TaskFactory.truncate()
|
||||
UserListFactory.truncate()
|
||||
})
|
||||
|
||||
it('Has a preview with checkable checkboxes', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
description: `# Test Heading
|
||||
* Bullet 1
|
||||
* Bullet 2
|
||||
|
||||
* [ ] Checklist
|
||||
* [x] Checklist checked
|
||||
`,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
cy.get('input[type=checkbox][data-checkbox-num=0]')
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
|
||||
.contains('Saved!')
|
||||
.should('exist')
|
||||
cy.get('.preview.content')
|
||||
.should('contain', 'Test Heading')
|
||||
})
|
||||
})
|
|
@ -8,20 +8,20 @@ describe('The Menu', () => {
|
|||
})
|
||||
|
||||
it('Is visible by default on desktop', () => {
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('have.class', 'is-active')
|
||||
})
|
||||
|
||||
it('Can be hidden on desktop', () => {
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('not.have.class', 'is-active')
|
||||
})
|
||||
|
||||
it('Is hidden by default on mobile', () => {
|
||||
cy.viewport('iphone-8')
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('not.have.class', 'is-active')
|
||||
})
|
||||
|
||||
|
@ -29,7 +29,7 @@ describe('The Menu', () => {
|
|||
cy.viewport('iphone-8')
|
||||
cy.get('button.menu-show-button:visible')
|
||||
.click()
|
||||
cy.get('.namespace-container')
|
||||
cy.get('.menu-container')
|
||||
.should('have.class', 'is-active')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
export function createProjects() {
|
||||
const projects = ProjectFactory.create(1, {
|
||||
title: 'First Project'
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
return projects
|
||||
}
|
||||
|
||||
export function prepareProjects(setProjects = (...args: any[]) => {}) {
|
||||
beforeEach(() => {
|
||||
const projects = createProjects()
|
||||
setProjects(projects)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('Project History', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareProjects()
|
||||
|
||||
it('should show a project history on the home page', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects*').as('loadProjectArray')
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
|
||||
|
||||
const projects = ProjectFactory.create(6)
|
||||
|
||||
cy.visit('/')
|
||||
cy.wait('@loadProjectArray')
|
||||
cy.get('body')
|
||||
.should('not.contain', 'Last viewed')
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[1].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[2].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[3].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[4].id}`)
|
||||
cy.wait('@loadProject')
|
||||
cy.visit(`/projects/${projects[5].id}`)
|
||||
cy.wait('@loadProject')
|
||||
|
||||
// cy.visit('/')
|
||||
// Not using cy.visit here to work around the redirect issue fixed in #1337
|
||||
cy.get('nav.menu.top-menu a')
|
||||
.contains('Overview')
|
||||
.click()
|
||||
|
||||
cy.get('body')
|
||||
.should('contain', 'Last viewed')
|
||||
cy.get('[data-cy="projectCardGrid"]')
|
||||
.should('not.contain', projects[0].title)
|
||||
.should('contain', projects[1].title)
|
||||
.should('contain', projects[2].title)
|
||||
.should('contain', projects[3].title)
|
||||
.should('contain', projects[4].title)
|
||||
.should('contain', projects[5].title)
|
||||
})
|
||||
})
|
|
@ -3,15 +3,15 @@ import {formatISO, format} from 'date-fns'
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareLists} from './prepareLists'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('List View Gantt', () => {
|
||||
describe('Project View Gantt', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareLists()
|
||||
prepareProjects()
|
||||
|
||||
it('Hides tasks with no dates', () => {
|
||||
const tasks = TaskFactory.create(1)
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.g-gantt-rows-container')
|
||||
.should('not.contain', tasks[0].title)
|
||||
|
@ -25,7 +25,7 @@ describe('List View Gantt', () => {
|
|||
nextMonth.setDate(1)
|
||||
nextMonth.setMonth(9)
|
||||
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.g-timeunits-container')
|
||||
.should('contain', format(now, 'MMMM'))
|
||||
|
@ -38,7 +38,7 @@ describe('List View Gantt', () => {
|
|||
start_date: now.toISOString(),
|
||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
|
||||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.g-gantt-rows-container')
|
||||
.should('not.be.empty')
|
||||
|
@ -50,7 +50,7 @@ describe('List View Gantt', () => {
|
|||
start_date: null,
|
||||
end_date: null,
|
||||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.gantt-options .fancycheckbox')
|
||||
.contains('Show tasks which don\'t have dates set')
|
||||
|
@ -69,7 +69,7 @@ describe('List View Gantt', () => {
|
|||
start_date: now.toISOString(),
|
||||
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
|
||||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
|
||||
.first()
|
||||
|
@ -83,9 +83,9 @@ describe('List View Gantt', () => {
|
|||
const now = Date.UTC(2022, 10, 9)
|
||||
cy.clock(now, ['Date'])
|
||||
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
|
||||
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
|
||||
.click()
|
||||
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
|
||||
.first()
|
||||
|
@ -99,13 +99,13 @@ describe('List View Gantt', () => {
|
|||
})
|
||||
|
||||
it('Should change the date range based on date query parameters', () => {
|
||||
cy.visit('/lists/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
|
||||
cy.visit('/projects/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
|
||||
|
||||
cy.get('.g-timeunits-container')
|
||||
.should('contain', 'September 2022')
|
||||
.should('contain', 'October 2022')
|
||||
.should('contain', 'November 2022')
|
||||
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
|
||||
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
|
||||
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
|
||||
})
|
||||
|
||||
|
@ -115,7 +115,7 @@ describe('List View Gantt', () => {
|
|||
start_date: formatISO(now),
|
||||
end_date: formatISO(now.setDate(now.getDate() + 4)),
|
||||
})
|
||||
cy.visit('/lists/1/gantt')
|
||||
cy.visit('/projects/1/gantt')
|
||||
|
||||
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
|
||||
.dblclick()
|
|
@ -1,13 +1,13 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {prepareLists} from './prepareLists'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('List View Kanban', () => {
|
||||
describe('Project View Kanban', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareLists()
|
||||
prepareProjects()
|
||||
|
||||
let buckets
|
||||
beforeEach(() => {
|
||||
|
@ -16,10 +16,10 @@ describe('List View Kanban', () => {
|
|||
|
||||
it('Shows all buckets with their tasks', () => {
|
||||
const data = TaskFactory.create(10, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .title')
|
||||
.contains(buckets[0].title)
|
||||
|
@ -34,10 +34,10 @@ describe('List View Kanban', () => {
|
|||
|
||||
it('Can add a new task to a bucket', () => {
|
||||
TaskFactory.create(2, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket')
|
||||
.contains(buckets[0].title)
|
||||
|
@ -55,7 +55,7 @@ describe('List View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can create a new bucket', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket.new-bucket .button')
|
||||
.click()
|
||||
|
@ -69,7 +69,7 @@ describe('List View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can set a bucket limit', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
.first()
|
||||
|
@ -90,7 +90,7 @@ describe('List View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can rename a bucket', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .title')
|
||||
.first()
|
||||
|
@ -101,7 +101,7 @@ describe('List View Kanban', () => {
|
|||
})
|
||||
|
||||
it('Can delete a bucket', () => {
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
|
||||
.first()
|
||||
|
@ -125,10 +125,10 @@ describe('List View Kanban', () => {
|
|||
|
||||
it('Can drag tasks around', () => {
|
||||
const tasks = TaskFactory.create(2, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -144,10 +144,10 @@ describe('List View Kanban', () => {
|
|||
it('Should navigate to the task when the task card is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -158,18 +158,18 @@ describe('List View Kanban', () => {
|
|||
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
|
||||
})
|
||||
|
||||
it('Should remove a task from the kanban board when moving it to another list', () => {
|
||||
const lists = ListFactory.create(2)
|
||||
it('Should remove a task from the kanban board when moving it to another project', () => {
|
||||
const projects = ProjectFactory.create(2)
|
||||
BucketFactory.create(2, {
|
||||
list_id: '{increment}',
|
||||
project_id: '{increment}',
|
||||
})
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
const task = tasks[0]
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(task.title)
|
||||
|
@ -180,7 +180,7 @@ describe('List View Kanban', () => {
|
|||
.contains('Move')
|
||||
.click()
|
||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
||||
.type(`${lists[1].title}{enter}`)
|
||||
.type(`${projects[1].title}{enter}`)
|
||||
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
|
||||
// presses enter and we can't simulate pressing on enter to select the item.
|
||||
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
||||
|
@ -197,26 +197,26 @@ describe('List View Kanban', () => {
|
|||
|
||||
it('Shows a button to filter the kanban board', () => {
|
||||
const data = TaskFactory.create(10, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.list-kanban .filter-container .base-button')
|
||||
cy.get('.project-kanban .filter-container .base-button')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Should remove a task from the board when deleting it', () => {
|
||||
const lists = ListFactory.create(1)
|
||||
const projects = ProjectFactory.create(1)
|
||||
const buckets = BucketFactory.create(2, {
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
const tasks = TaskFactory.create(5, {
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
const task = tasks[0]
|
||||
cy.visit('/lists/1/kanban')
|
||||
cy.visit('/projects/1/kanban')
|
||||
|
||||
cy.get('.kanban .bucket .tasks .task')
|
||||
.contains(task.title)
|
|
@ -1,32 +1,32 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {UserListFactory} from '../../factories/users_list'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {prepareLists} from './prepareLists'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('List View List', () => {
|
||||
describe('Project View Project', () => {
|
||||
createFakeUserAndLogin()
|
||||
prepareLists()
|
||||
prepareProjects()
|
||||
|
||||
it('Should be an empty list', () => {
|
||||
cy.visit('/lists/1')
|
||||
it('Should be an empty project', () => {
|
||||
cy.visit('/projects/1')
|
||||
cy.url()
|
||||
.should('contain', '/lists/1/list')
|
||||
cy.get('.list-title h1')
|
||||
.should('contain', 'First List')
|
||||
cy.get('.list-title .dropdown')
|
||||
.should('contain', '/projects/1/list')
|
||||
cy.get('.project-title')
|
||||
.should('contain', 'First Project')
|
||||
cy.get('.project-title-dropdown')
|
||||
.should('exist')
|
||||
cy.get('p')
|
||||
.contains('This list is currently empty.')
|
||||
.contains('This project is currently empty.')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it('Should create a new task', () => {
|
||||
const newTaskTitle = 'New task'
|
||||
|
||||
cy.visit('/lists/1')
|
||||
cy.visit('/projects/1')
|
||||
cy.get('.task-add textarea')
|
||||
.type(newTaskTitle+'{enter}')
|
||||
cy.get('.tasks')
|
||||
|
@ -36,9 +36,9 @@ describe('List View List', () => {
|
|||
it('Should navigate to the task when the title is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/list')
|
||||
cy.visit('/projects/1/list')
|
||||
|
||||
cy.get('.tasks .task .tasktext')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -49,33 +49,32 @@ describe('List View List', () => {
|
|||
.should('contain', `/tasks/${tasks[0].id}`)
|
||||
})
|
||||
|
||||
it('Should not see any elements for a list which is shared read only', () => {
|
||||
it('Should not see any elements for a project which is shared read only', () => {
|
||||
UserFactory.create(2)
|
||||
UserListFactory.create(1, {
|
||||
list_id: 2,
|
||||
UserProjectFactory.create(1, {
|
||||
project_id: 2,
|
||||
user_id: 1,
|
||||
right: 0,
|
||||
})
|
||||
const lists = ListFactory.create(2, {
|
||||
const projects = ProjectFactory.create(2, {
|
||||
owner_id: '{increment}',
|
||||
namespace_id: '{increment}',
|
||||
})
|
||||
cy.visit(`/lists/${lists[1].id}/`)
|
||||
cy.visit(`/projects/${projects[1].id}/`)
|
||||
|
||||
cy.get('.list-title .icon')
|
||||
cy.get('.project-title-wrapper .icon')
|
||||
.should('not.exist')
|
||||
cy.get('input.input[placeholder="Add a new task..."')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
||||
it('Should only show the color of a list in the navigation and not in the list view', () => {
|
||||
const lists = ListFactory.create(1, {
|
||||
it('Should only show the color of a project in the navigation and not in the list view', () => {
|
||||
const projects = ProjectFactory.create(1, {
|
||||
hex_color: '00db60',
|
||||
})
|
||||
TaskFactory.create(10, {
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
cy.visit(`/lists/${lists[0].id}/`)
|
||||
cy.visit(`/projects/${projects[0].id}/`)
|
||||
|
||||
cy.get('.menu-list li .list-menu-link .color-bubble')
|
||||
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
|
||||
|
@ -87,9 +86,9 @@ describe('List View List', () => {
|
|||
const tasks = TaskFactory.create(100, {
|
||||
id: '{increment}',
|
||||
title: i => `task${i}`,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/list')
|
||||
cy.visit('/projects/1/list')
|
||||
|
||||
cy.get('.tasks')
|
||||
.should('contain', tasks[1].title)
|
|
@ -2,37 +2,37 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
|||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
describe('List View Table', () => {
|
||||
describe('Project View Table', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
it('Should show a table with tasks', () => {
|
||||
const tasks = TaskFactory.create(1)
|
||||
cy.visit('/lists/1/table')
|
||||
cy.visit('/projects/1/table')
|
||||
|
||||
cy.get('.list-table table.table')
|
||||
cy.get('.project-table table.table')
|
||||
.should('exist')
|
||||
cy.get('.list-table table.table')
|
||||
cy.get('.project-table table.table')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
|
||||
it('Should have working column switches', () => {
|
||||
TaskFactory.create(1)
|
||||
cy.visit('/lists/1/table')
|
||||
cy.visit('/projects/1/table')
|
||||
|
||||
cy.get('.list-table .filter-container .items .button')
|
||||
cy.get('.project-table .filter-container .items .button')
|
||||
.contains('Columns')
|
||||
.click()
|
||||
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
|
||||
.contains('Priority')
|
||||
.click()
|
||||
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
|
||||
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox')
|
||||
.contains('Done')
|
||||
.click()
|
||||
|
||||
cy.get('.list-table table.table th')
|
||||
cy.get('.project-table table.table th')
|
||||
.contains('Priority')
|
||||
.should('exist')
|
||||
cy.get('.list-table table.table th')
|
||||
cy.get('.project-table table.table th')
|
||||
.contains('Done')
|
||||
.should('not.exist')
|
||||
})
|
||||
|
@ -40,11 +40,11 @@ describe('List View Table', () => {
|
|||
it('Should navigate to the task when the title is clicked', () => {
|
||||
const tasks = TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
cy.visit('/lists/1/table')
|
||||
cy.visit('/projects/1/table')
|
||||
|
||||
cy.get('.list-table table.table')
|
||||
cy.get('.project-table table.table')
|
||||
.contains(tasks[0].title)
|
||||
.click()
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {prepareProjects} from './prepareProjects'
|
||||
|
||||
describe('Projects', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let projects
|
||||
prepareProjects((newProjects) => (projects = newProjects))
|
||||
|
||||
it('Should create a new project', () => {
|
||||
cy.visit('/projects')
|
||||
cy.get('.project-header [data-cy=new-project]')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/projects/new')
|
||||
cy.get('.card-header-title')
|
||||
.contains('New project')
|
||||
cy.get('input[name=projectTitle]')
|
||||
.type('New Project')
|
||||
cy.get('.button')
|
||||
.contains('Create')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', {timeout: 1000}) // Waiting until the request to create the new project is done
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', '/projects/')
|
||||
cy.get('.project-title')
|
||||
.should('contain', 'New Project')
|
||||
})
|
||||
|
||||
it('Should redirect to a specific project view after visited', () => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/projects/*/buckets*').as('loadBuckets')
|
||||
cy.visit('/projects/1/kanban')
|
||||
cy.url()
|
||||
.should('contain', '/projects/1/kanban')
|
||||
cy.wait('@loadBuckets')
|
||||
cy.visit('/projects/1')
|
||||
cy.url()
|
||||
.should('contain', '/projects/1/kanban')
|
||||
})
|
||||
|
||||
it('Should rename the project in all places', () => {
|
||||
TaskFactory.create(5, {
|
||||
id: '{increment}',
|
||||
project_id: 1,
|
||||
})
|
||||
const newProjectName = 'New project name'
|
||||
|
||||
cy.visit('/projects/1')
|
||||
cy.get('.project-title')
|
||||
.should('contain', 'First Project')
|
||||
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Edit')
|
||||
.click()
|
||||
cy.get('#title')
|
||||
.type(`{selectall}${newProjectName}`)
|
||||
cy.get('footer.card-footer .button')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.project-title')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.get('.menu-container .menu-list li:first-child')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.visit('/')
|
||||
cy.get('.project-grid')
|
||||
.should('contain', newProjectName)
|
||||
.should('not.contain', projects[0].title)
|
||||
})
|
||||
|
||||
it('Should remove a project when deleting it', () => {
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.menu-container .menu-list li:first-child .dropdown .dropdown-content')
|
||||
.contains('Delete')
|
||||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.menu-container .menu-list')
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.location('pathname')
|
||||
.should('equal', '/')
|
||||
})
|
||||
|
||||
it('Should archive a project', () => {
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
|
||||
cy.get('.project-title-dropdown')
|
||||
.click()
|
||||
cy.get('.project-title-dropdown .dropdown-menu .dropdown-item')
|
||||
.contains('Archive')
|
||||
.click()
|
||||
cy.get('.modal-content')
|
||||
.should('contain.text', 'Archive this project')
|
||||
cy.get('.modal-content [data-cy=modalPrimary]')
|
||||
.click()
|
||||
|
||||
cy.get('.menu-container .menu-list')
|
||||
.should('not.contain', projects[0].title)
|
||||
cy.get('main.app-content')
|
||||
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
|
||||
})
|
||||
|
||||
it('Should show all projects on the projects page', () => {
|
||||
const projects = ProjectFactory.create(10)
|
||||
|
||||
cy.visit('/projects')
|
||||
|
||||
projects.forEach(p => {
|
||||
cy.get('[data-cy="projects-list"]')
|
||||
.should('contain', p.title)
|
||||
})
|
||||
})
|
||||
|
||||
it('Should not show archived projects if the filter is not checked', () => {
|
||||
ProjectFactory.create(1, {
|
||||
id: 2,
|
||||
}, false)
|
||||
ProjectFactory.create(1, {
|
||||
id: 3,
|
||||
is_archived: true,
|
||||
}, false)
|
||||
|
||||
// Initial
|
||||
cy.visit('/projects')
|
||||
cy.get('.project-grid')
|
||||
.should('not.contain', 'Archived')
|
||||
|
||||
// Show archived
|
||||
cy.get('[data-cy="show-archived-check"] label span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('be.checked')
|
||||
cy.get('.project-grid')
|
||||
.should('contain', 'Archived')
|
||||
|
||||
// Don't show archived
|
||||
cy.get('[data-cy="show-archived-check"] label span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
|
||||
// Second time visiting after unchecking
|
||||
cy.visit('/projects')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
cy.get('.project-grid')
|
||||
.should('not.contain', 'Archived')
|
||||
})
|
||||
})
|
|
@ -1,25 +1,59 @@
|
|||
import {LinkShareFactory} from '../../factories/link_sharing'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
|
||||
function prepareLinkShare() {
|
||||
const projects = ProjectFactory.create(1)
|
||||
const tasks = TaskFactory.create(10, {
|
||||
project_id: projects[0].id
|
||||
})
|
||||
const linkShares = LinkShareFactory.create(1, {
|
||||
project_id: projects[0].id,
|
||||
right: 0,
|
||||
})
|
||||
|
||||
return {
|
||||
share: linkShares[0],
|
||||
project: projects[0],
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Link shares', () => {
|
||||
it('Can view a link share', () => {
|
||||
const lists = ListFactory.create(1)
|
||||
const tasks = TaskFactory.create(10, {
|
||||
list_id: lists[0].id
|
||||
})
|
||||
const linkShares = LinkShareFactory.create(1, {
|
||||
list_id: lists[0].id,
|
||||
right: 0,
|
||||
})
|
||||
const {share, project, tasks} = prepareLinkShare()
|
||||
|
||||
cy.visit(`/share/${linkShares[0].hash}/auth`)
|
||||
cy.visit(`/share/${share.hash}/auth`)
|
||||
|
||||
cy.get('h1.title')
|
||||
.should('contain', lists[0].title)
|
||||
.should('contain', project.title)
|
||||
cy.get('input.input[placeholder="Add a new task..."')
|
||||
.should('not.exist')
|
||||
cy.get('.tasks')
|
||||
.should('contain', tasks[0].title)
|
||||
|
||||
cy.url().should('contain', `/projects/${project.id}/list#share-auth-token=${share.hash}`)
|
||||
})
|
||||
|
||||
it('Should work when directly viewing a project with share hash present', () => {
|
||||
const {share, project, tasks} = prepareLinkShare()
|
||||
|
||||
cy.visit(`/projects/${project.id}/list#share-auth-token=${share.hash}`)
|
||||
|
||||
cy.get('h1.title')
|
||||
.should('contain', project.title)
|
||||
cy.get('input.input[placeholder="Add a new task..."')
|
||||
.should('not.exist')
|
||||
cy.get('.tasks')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
|
||||
it('Should work when directly viewing a task with share hash present', () => {
|
||||
const {share, project, tasks} = prepareLinkShare()
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}#share-auth-token=${share.hash}`)
|
||||
|
||||
cy.get('h1.title')
|
||||
.should('contain', tasks[0].title)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {seed} from '../../support/seed'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {updateUserSettings} from '../../support/updateUserSettings'
|
||||
|
||||
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
||||
NamespaceFactory.create(1)
|
||||
const list = ListFactory.create()[0]
|
||||
const project = ProjectFactory.create()[0]
|
||||
BucketFactory.create(1, {
|
||||
list_id: list.id,
|
||||
project_id: project.id,
|
||||
})
|
||||
const tasks = []
|
||||
let dueDate = startDueDate
|
||||
|
@ -20,7 +18,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
|||
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
|
||||
tasks.push({
|
||||
id: i + 1,
|
||||
list_id: list.id,
|
||||
project_id: project.id,
|
||||
done: false,
|
||||
created_by_id: 1,
|
||||
title: 'Test Task ' + i,
|
||||
|
@ -31,7 +29,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
|
|||
})
|
||||
}
|
||||
seed(TaskFactory.table, tasks)
|
||||
return {tasks, list}
|
||||
return {tasks, project}
|
||||
}
|
||||
|
||||
describe('Home Page Task Overview', () => {
|
||||
|
@ -73,7 +71,7 @@ describe('Home Page Task Overview', () => {
|
|||
due_date: new Date().toISOString(),
|
||||
}, false)
|
||||
|
||||
cy.visit(`/lists/${tasks[0].list_id}/list`)
|
||||
cy.visit(`/projects/${tasks[0].project_id}/list`)
|
||||
cy.get('.tasks .task')
|
||||
.first()
|
||||
.should('contain.text', newTaskTitle)
|
||||
|
@ -90,7 +88,7 @@ describe('Home Page Task Overview', () => {
|
|||
|
||||
cy.visit('/')
|
||||
|
||||
cy.visit(`/lists/${tasks[0].list_id}/list`)
|
||||
cy.visit(`/projects/${tasks[0].project_id}/list`)
|
||||
cy.get('.task-add textarea')
|
||||
.type(newTaskTitle+'{enter}')
|
||||
cy.visit('/')
|
||||
|
@ -113,10 +111,10 @@ describe('Home Page Task Overview', () => {
|
|||
.should('contain.text', newTaskTitle)
|
||||
})
|
||||
|
||||
it('Should show a task without a due date added via default list at the bottom', () => {
|
||||
const {list} = seedTasks(40)
|
||||
it('Should show a task without a due date added via default project at the bottom', () => {
|
||||
const {project} = seedTasks(40)
|
||||
updateUserSettings({
|
||||
default_list_id: list.id,
|
||||
default_project_id: project.id,
|
||||
overdue_tasks_reminders_time: '9:00',
|
||||
})
|
||||
|
||||
|
@ -131,23 +129,22 @@ describe('Home Page Task Overview', () => {
|
|||
.should('contain.text', newTaskTitle)
|
||||
})
|
||||
|
||||
it('Should show the cta buttons for new list when there are no tasks', () => {
|
||||
it('Should show the cta buttons for new project when there are no tasks', () => {
|
||||
TaskFactory.truncate()
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get('.home.app-content .content')
|
||||
.should('contain.text', 'You can create a new list for your new tasks:')
|
||||
.should('contain.text', 'Or import your lists and tasks from other services into Vikunja:')
|
||||
.should('contain.text', 'Import your projects and tasks from other services into Vikunja:')
|
||||
})
|
||||
|
||||
it('Should not show the cta buttons for new list when there are tasks', () => {
|
||||
it('Should not show the cta buttons for new project when there are tasks', () => {
|
||||
seedTasks()
|
||||
|
||||
cy.visit('/')
|
||||
|
||||
cy.get('.home.app-content .content')
|
||||
.should('not.contain.text', 'You can create a new list for your new tasks:')
|
||||
.should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
|
||||
.should('not.contain.text', 'You can create a new project for your new tasks:')
|
||||
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {ListFactory} from '../../factories/list'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskCommentFactory} from '../../factories/task_comment'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {NamespaceFactory} from '../../factories/namespace'
|
||||
import {UserListFactory} from '../../factories/users_list'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
import {TaskAssigneeFactory} from '../../factories/task_assignee'
|
||||
import {LabelFactory} from '../../factories/labels'
|
||||
import {LabelTaskFactory} from '../../factories/label_task'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
|
||||
import {TaskAttachmentFactory} from '../../factories/task_attachments'
|
||||
import {TaskReminderFactory} from '../../factories/task_reminders'
|
||||
|
||||
function addLabelToTaskAndVerify(labelTitle: string) {
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
|
@ -24,7 +24,7 @@ function addLabelToTaskAndVerify(labelTitle: string) {
|
|||
.first()
|
||||
.click()
|
||||
|
||||
cy.get('.global-notification', { timeout: 4000 })
|
||||
cy.get('.global-notification', {timeout: 4000})
|
||||
.should('contain', 'Success')
|
||||
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
|
||||
.should('exist')
|
||||
|
@ -47,23 +47,21 @@ function uploadAttachmentAndVerify(taskId: number) {
|
|||
describe('Task', () => {
|
||||
createFakeUserAndLogin()
|
||||
|
||||
let namespaces
|
||||
let lists
|
||||
let projects
|
||||
let buckets
|
||||
|
||||
beforeEach(() => {
|
||||
// UserFactory.create(1)
|
||||
namespaces = NamespaceFactory.create(1)
|
||||
lists = ListFactory.create(1)
|
||||
projects = ProjectFactory.create(1)
|
||||
buckets = BucketFactory.create(1, {
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
TaskFactory.truncate()
|
||||
UserListFactory.truncate()
|
||||
UserProjectFactory.truncate()
|
||||
})
|
||||
|
||||
it('Should be created new', () => {
|
||||
cy.visit('/lists/1/list')
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.input[placeholder="Add a new task…"')
|
||||
.type('New Task')
|
||||
cy.get('.button')
|
||||
|
@ -74,11 +72,11 @@ describe('Task', () => {
|
|||
.should('contain', 'New Task')
|
||||
})
|
||||
|
||||
it('Inserts new tasks at the top of the list', () => {
|
||||
it('Inserts new tasks at the top of the project', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/lists/1/list')
|
||||
cy.get('.list-is-empty-notice')
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.project-is-empty-notice')
|
||||
.should('not.exist')
|
||||
cy.get('.input[placeholder="Add a new task…"')
|
||||
.type('New Task')
|
||||
|
@ -95,8 +93,8 @@ describe('Task', () => {
|
|||
it('Marks a task as done', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/lists/1/list')
|
||||
cy.get('.tasks .task .fancycheckbox label.check')
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.tasks .task .fancycheckbox')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.global-notification')
|
||||
|
@ -106,11 +104,11 @@ describe('Task', () => {
|
|||
it('Can add a task to favorites', () => {
|
||||
TaskFactory.create(1)
|
||||
|
||||
cy.visit('/lists/1/list')
|
||||
cy.visit('/projects/1/list')
|
||||
cy.get('.tasks .task .favorite')
|
||||
.first()
|
||||
.click()
|
||||
cy.get('.menu.namespaces-lists')
|
||||
cy.get('.menu-container')
|
||||
.should('contain', 'Favorites')
|
||||
})
|
||||
|
||||
|
@ -124,7 +122,7 @@ describe('Task', () => {
|
|||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
index: 1,
|
||||
description: 'Lorem ipsum dolor sit amet.'
|
||||
description: 'Lorem ipsum dolor sit amet.',
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -133,8 +131,7 @@ describe('Task', () => {
|
|||
cy.get('.task-view h1.title.task-id')
|
||||
.should('contain', '#1')
|
||||
cy.get('.task-view h6.subtitle')
|
||||
.should('contain', namespaces[0].title)
|
||||
.should('contain', lists[0].title)
|
||||
.should('contain', projects[0].title)
|
||||
cy.get('.task-view .details.content.description')
|
||||
.should('contain', tasks[0].description)
|
||||
cy.get('.task-view .action-buttons p.created')
|
||||
|
@ -146,7 +143,7 @@ describe('Task', () => {
|
|||
id: 1,
|
||||
index: 1,
|
||||
done: true,
|
||||
done_at: new Date().toISOString()
|
||||
done_at: new Date().toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -179,33 +176,33 @@ describe('Task', () => {
|
|||
.should('contain', 'Mark as undone')
|
||||
})
|
||||
|
||||
it('Shows a task identifier since the list has one', () => {
|
||||
const lists = ListFactory.create(1, {
|
||||
it('Shows a task identifier since the project has one', () => {
|
||||
const projects = ProjectFactory.create(1, {
|
||||
id: 1,
|
||||
identifier: 'TEST',
|
||||
})
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
index: 1,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view h1.title.task-id')
|
||||
.should('contain', `${lists[0].identifier}-${tasks[0].index}`)
|
||||
.should('contain', `${projects[0].identifier}-${tasks[0].index}`)
|
||||
})
|
||||
|
||||
it('Can edit the description', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: 'Lorem ipsum dolor sit amet.'
|
||||
description: 'Lorem ipsum dolor sit amet.',
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .details.content.description .editor button')
|
||||
cy.get('.task-view .details.content.description .tiptap button.done-edit')
|
||||
.click()
|
||||
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
||||
cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
|
||||
.type('{selectall}New Description')
|
||||
cy.get('[data-cy="saveEditor"]')
|
||||
.contains('Save')
|
||||
|
@ -222,7 +219,7 @@ describe('Task', () => {
|
|||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .comments .media.comment .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
||||
cy.get('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror')
|
||||
.should('be.visible')
|
||||
.type('{selectall}New Comment')
|
||||
cy.get('.task-view .comments .media.comment .button:not([disabled])')
|
||||
|
@ -230,20 +227,20 @@ describe('Task', () => {
|
|||
.should('be.visible')
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .comments .media.comment .editor')
|
||||
cy.get('.task-view .comments .media.comment .tiptap__editor')
|
||||
.should('contain', 'New Comment')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Can move a task to another list', () => {
|
||||
const lists = ListFactory.create(2)
|
||||
it('Can move a task to another project', () => {
|
||||
const projects = ProjectFactory.create(2)
|
||||
BucketFactory.create(2, {
|
||||
list_id: '{increment}'
|
||||
project_id: '{increment}',
|
||||
})
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -251,7 +248,7 @@ describe('Task', () => {
|
|||
.contains('Move')
|
||||
.click()
|
||||
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
||||
.type(`${lists[1].title}{enter}`)
|
||||
.type(`${projects[1].title}{enter}`)
|
||||
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
|
||||
// presses enter and we can't simulate pressing on enter to select the item.
|
||||
cy.get('.task-view .content.details .field .multiselect.control .search-results')
|
||||
|
@ -260,8 +257,7 @@ describe('Task', () => {
|
|||
.click()
|
||||
|
||||
cy.get('.task-view h6.subtitle')
|
||||
.should('contain', namespaces[0].title)
|
||||
.should('contain', lists[1].title)
|
||||
.should('contain', projects[1].title)
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
@ -269,7 +265,7 @@ describe('Task', () => {
|
|||
it('Can delete a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
@ -286,17 +282,17 @@ describe('Task', () => {
|
|||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.url()
|
||||
.should('contain', `/lists/${tasks[0].list_id}/`)
|
||||
.should('contain', `/projects/${tasks[0].project_id}/`)
|
||||
})
|
||||
|
||||
it('Can add an assignee to a task', () => {
|
||||
const users = UserFactory.create(5)
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
UserListFactory.create(5, {
|
||||
list_id: 1,
|
||||
UserProjectFactory.create(5, {
|
||||
project_id: 1,
|
||||
user_id: '{increment}',
|
||||
})
|
||||
|
||||
|
@ -321,10 +317,10 @@ describe('Task', () => {
|
|||
const users = UserFactory.create(2)
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
UserListFactory.create(5, {
|
||||
list_id: 1,
|
||||
UserProjectFactory.create(5, {
|
||||
project_id: 1,
|
||||
user_id: '{increment}',
|
||||
})
|
||||
TaskAssigneeFactory.create(1, {
|
||||
|
@ -347,7 +343,7 @@ describe('Task', () => {
|
|||
it('Can add a new label to a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
LabelFactory.truncate()
|
||||
const newLabelText = 'some new label'
|
||||
|
@ -375,7 +371,7 @@ describe('Task', () => {
|
|||
it('Can add an existing label to a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
@ -384,27 +380,27 @@ describe('Task', () => {
|
|||
|
||||
addLabelToTaskAndVerify(labels[0].title)
|
||||
})
|
||||
|
||||
|
||||
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
||||
cy.visit(`/lists/${lists[0].id}/kanban`)
|
||||
|
||||
|
||||
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.contains(tasks[0].title)
|
||||
.click()
|
||||
|
||||
|
||||
addLabelToTaskAndVerify(labels[0].title)
|
||||
|
||||
|
||||
cy.get('.modal-content .close')
|
||||
.click()
|
||||
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.should('contain.text', labels[0].title)
|
||||
})
|
||||
|
@ -412,7 +408,7 @@ describe('Task', () => {
|
|||
it('Can remove a label from a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.create(1, {
|
||||
|
@ -465,7 +461,155 @@ describe('Task', () => {
|
|||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
|
||||
it('Can set a reminder', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.contains('Tomorrow')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Allows to set a relative reminder when the task already has a due date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
due_date: (new Date()).toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.should('not.exist')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.should('contain', '1 day before Due Date')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.contains('1 day before Due Date')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Allows to set a relative reminder when the task already has a start date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
start_date: (new Date()).toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.should('not.exist')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.should('contain', '1 day before Start Date')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.contains('1 day before Start Date')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Allows to set a custom relative reminder when the task already has a due date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
due_date: (new Date()).toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.should('not.exist')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.contains('Custom')
|
||||
.click()
|
||||
cy.get('.reminder-options-popup .card-content .reminder-period input')
|
||||
.first()
|
||||
.type('10')
|
||||
cy.get('.reminder-options-popup .card-content .reminder-period select')
|
||||
.first()
|
||||
.select('days')
|
||||
cy.get('.reminder-options-popup .card-content button')
|
||||
.contains('Confirm')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Allows to set a fixed reminder when the task already has a due date', () => {
|
||||
TaskReminderFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
due_date: (new Date()).toISOString(),
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Set Reminders')
|
||||
.click()
|
||||
cy.get('.task-view .columns.details .column button')
|
||||
.contains('Add a new reminder')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.should('not.exist')
|
||||
cy.get('.reminder-options-popup .card-content')
|
||||
.contains('Date and time')
|
||||
.click()
|
||||
cy.get('.datepicker__quick-select-date')
|
||||
.contains('Tomorrow')
|
||||
.click()
|
||||
|
||||
cy.get('.reminder-options-popup')
|
||||
.should('not.be.visible')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
})
|
||||
|
||||
it('Can set a priority for a task', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
|
@ -503,7 +647,7 @@ describe('Task', () => {
|
|||
.select('50%')
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
|
||||
|
||||
cy.wait(200)
|
||||
|
||||
cy.get('.task-view .columns.details .column')
|
||||
|
@ -512,7 +656,7 @@ describe('Task', () => {
|
|||
.should('be.visible')
|
||||
.should('have.value', '0.5')
|
||||
})
|
||||
|
||||
|
||||
it('Can add an attachment to a task', () => {
|
||||
TaskAttachmentFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
|
@ -527,13 +671,13 @@ describe('Task', () => {
|
|||
TaskAttachmentFactory.truncate()
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
list_id: lists[0].id,
|
||||
project_id: projects[0].id,
|
||||
bucket_id: buckets[0].id,
|
||||
})
|
||||
const labels = LabelFactory.create(1)
|
||||
LabelTaskFactory.truncate()
|
||||
|
||||
cy.visit(`/lists/${lists[0].id}/kanban`)
|
||||
cy.visit(`/projects/${projects[0].id}/kanban`)
|
||||
|
||||
cy.get('.bucket .task')
|
||||
.contains(tasks[0].title)
|
||||
|
@ -547,35 +691,119 @@ describe('Task', () => {
|
|||
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
|
||||
it('Can check items off a checklist', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `
|
||||
This is a checklist:
|
||||
|
||||
* [ ] one item
|
||||
* [ ] another item
|
||||
* [ ] third item
|
||||
* [ ] fourth item
|
||||
* [x] and this one is already done
|
||||
`,
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>First Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Second Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Third Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Fourth Item</p></div>
|
||||
</li>
|
||||
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Fifth Item</p></div>
|
||||
</li>
|
||||
</ul>`,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
|
||||
cy.get('.task-view .checklist-summary')
|
||||
.should('contain.text', '1 of 5 tasks')
|
||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
||||
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||
.eq(2)
|
||||
.click()
|
||||
|
||||
cy.get('.editor .content ul > li input[type=checkbox]')
|
||||
|
||||
cy.get('.task-view .details.content.description h3 span.is-small.has-text-success')
|
||||
.contains('Saved!')
|
||||
.should('exist')
|
||||
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||
.eq(2)
|
||||
.should('be.checked')
|
||||
cy.get('.editor .content input[type=checkbox]')
|
||||
cy.get('.tiptap__editor input[type=checkbox]')
|
||||
.should('have.length', 5)
|
||||
cy.get('.task-view .checklist-summary')
|
||||
.should('contain.text', '2 of 5 tasks')
|
||||
})
|
||||
|
||||
it('Should use the editor to render description', () => {
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `
|
||||
<h1>Lorem Ipsum</h1>
|
||||
<p>Dolor sit amet</p>
|
||||
<ul data-type="taskList">
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>First Item</p></div>
|
||||
</li>
|
||||
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
||||
<div><p>Second Item</p></div>
|
||||
</li>
|
||||
</ul>`,
|
||||
})
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.tiptap__editor ul > li input[type=checkbox]')
|
||||
.should('exist')
|
||||
cy.get('.tiptap__editor h1')
|
||||
.contains('Lorem Ipsum')
|
||||
.should('exist')
|
||||
cy.get('.tiptap__editor p')
|
||||
.contains('Dolor sit amet')
|
||||
.should('exist')
|
||||
})
|
||||
|
||||
it.only('Should render an image from attachment', async () => {
|
||||
|
||||
TaskAttachmentFactory.truncate()
|
||||
|
||||
const tasks = TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: '',
|
||||
})
|
||||
|
||||
cy.readFile('cypress/fixtures/image.jpg', null).then(file => {
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('files', new Blob([file]), 'image.jpg')
|
||||
|
||||
cy.request({
|
||||
method: 'PUT',
|
||||
url: `${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments`,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.localStorage.getItem('token')}`,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
.then(({body}) => {
|
||||
const dec = new TextDecoder('utf-8')
|
||||
const {success} = JSON.parse(dec.decode(body))
|
||||
|
||||
TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: `<img src="${Cypress.env('API_URL')}/tasks/${tasks[0].id}/attachments/${success[0].id}" alt="test image">`,
|
||||
})
|
||||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.tiptap__editor img')
|
||||
.should('be.visible')
|
||||
.and(($img) => {
|
||||
// "naturalWidth" and "naturalHeight" are set when the image loads
|
||||
expect($img[0].naturalWidth).to.be.greaterThan(0)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["./**/*", "../support/**/*", "../factories/**/*"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"isolatedModules": false,
|
||||
"target": "ES2015",
|
||||
"lib": ["ESNext", "dom"],
|
||||
"types": ["cypress"]
|
||||
"types": ["cypress"],
|
||||
"ignoreDeprecations": "5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,36 +1,41 @@
|
|||
import {UserFactory} from '../../factories/user'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
|
||||
const testAndAssertFailed = fixture => {
|
||||
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
|
||||
|
||||
cy.visit('/login')
|
||||
cy.get('input[id=username]').type(fixture.username)
|
||||
cy.get('input[id=password]').type(fixture.password)
|
||||
cy.get('.button').contains('Login').click()
|
||||
|
||||
cy.wait(5000) // It can take waaaayy too long to log the user in
|
||||
cy.wait('@login')
|
||||
cy.url().should('include', '/')
|
||||
cy.get('div.message.danger').contains('Wrong username or password.')
|
||||
}
|
||||
|
||||
const username = 'test'
|
||||
const credentials = {
|
||||
username: 'test',
|
||||
password: '1234',
|
||||
}
|
||||
|
||||
function login() {
|
||||
cy.get('input[id=username]').type(credentials.username)
|
||||
cy.get('input[id=password]').type(credentials.password)
|
||||
cy.get('.button').contains('Login').click()
|
||||
cy.url().should('include', '/')
|
||||
}
|
||||
|
||||
context('Login', () => {
|
||||
beforeEach(() => {
|
||||
UserFactory.create(1, {username})
|
||||
UserFactory.create(1, {username: credentials.username})
|
||||
})
|
||||
|
||||
it('Should log in with the right credentials', () => {
|
||||
const fixture = {
|
||||
username: 'test',
|
||||
password: '1234',
|
||||
}
|
||||
|
||||
cy.visit('/login')
|
||||
cy.get('input[id=username]').type(fixture.username)
|
||||
cy.get('input[id=password]').type(fixture.password)
|
||||
cy.get('.button').contains('Login').click()
|
||||
cy.url().should('include', '/')
|
||||
login()
|
||||
cy.clock(1625656161057) // 13:00
|
||||
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
||||
cy.get('h2').should('contain', `Hi ${credentials.username}!`)
|
||||
})
|
||||
|
||||
it('Should fail with a bad password', () => {
|
||||
|
@ -55,4 +60,15 @@ context('Login', () => {
|
|||
cy.visit('/')
|
||||
cy.url().should('include', '/login')
|
||||
})
|
||||
|
||||
it('Should redirect to the previous route after logging in', () => {
|
||||
const projects = ProjectFactory.create(1)
|
||||
cy.visit(`/projects/${projects[0].id}/list`)
|
||||
|
||||
cy.url().should('include', '/login')
|
||||
|
||||
login()
|
||||
|
||||
cy.url().should('include', `/projects/${projects[0].id}/list`)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import {createFakeUserAndLogin} from '../../support/authenticateUser'
|
||||
import {createLists} from '../list/prepareLists'
|
||||
import {createProjects} from '../project/prepareProjects'
|
||||
|
||||
function logout() {
|
||||
cy.get('.navbar .user .username')
|
||||
cy.get('.navbar .username-dropdown-trigger')
|
||||
.click()
|
||||
cy.get('.navbar .user .dropdown-menu .dropdown-item')
|
||||
cy.get('.navbar .dropdown-item')
|
||||
.contains('Logout')
|
||||
.click()
|
||||
}
|
||||
|
@ -26,21 +26,21 @@ describe('Log out', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it.skip('Should clear the list history after logging the user out', () => {
|
||||
const lists = createLists()
|
||||
cy.visit(`/lists/${lists[0].id}`)
|
||||
it.skip('Should clear the project history after logging the user out', () => {
|
||||
const projects = createProjects()
|
||||
cy.visit(`/projects/${projects[0].id}`)
|
||||
.then(() => {
|
||||
expect(localStorage.getItem('listHistory')).to.not.eq(null)
|
||||
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
|
||||
})
|
||||
|
||||
logout()
|
||||
|
||||
cy.wait(1000) // This makes re-loading of the list and associated entities (and the resulting error) visible
|
||||
cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible
|
||||
|
||||
cy.url()
|
||||
.should('contain', '/login')
|
||||
.then(() => {
|
||||
expect(localStorage.getItem('listHistory')).to.eq(null)
|
||||
expect(localStorage.getItem('projectHistory')).to.eq(null)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -17,7 +17,7 @@ context('Registration', () => {
|
|||
it('Should work without issues', () => {
|
||||
const fixture = {
|
||||
username: 'testuser',
|
||||
password: '123456',
|
||||
password: '12345678',
|
||||
email: 'testuser@example.com',
|
||||
}
|
||||
|
||||
|
@ -31,10 +31,10 @@ context('Registration', () => {
|
|||
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
||||
})
|
||||
|
||||
it.only('Should fail', () => {
|
||||
it('Should fail', () => {
|
||||
const fixture = {
|
||||
username: 'test',
|
||||
password: '123456',
|
||||
password: '12345678',
|
||||
email: 'testuser@example.com',
|
||||
}
|
||||
|
||||
|
|
|
@ -37,7 +37,7 @@ describe('User Settings', () => {
|
|||
|
||||
cy.get('.global-notification')
|
||||
.should('contain', 'Success')
|
||||
cy.get('.navbar .user .username')
|
||||
cy.get('.navbar .username-dropdown-trigger .username')
|
||||
.should('contain', 'Lorem Ipsum')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -10,7 +10,7 @@ export class BucketFactory extends Factory {
|
|||
return {
|
||||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
created_by_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
|
|
|
@ -10,7 +10,7 @@ export class LinkShareFactory extends Factory {
|
|||
return {
|
||||
id: '{increment}',
|
||||
hash: faker.random.word(32),
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
right: 0,
|
||||
sharing_type: 0,
|
||||
shared_by_id: 1,
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import {Factory} from '../support/factory'
|
||||
import {faker} from '@faker-js/faker'
|
||||
|
||||
export class ListFactory extends Factory {
|
||||
static table = 'lists'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
owner_id: 1,
|
||||
namespace_id: 1,
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import {faker} from '@faker-js/faker'
|
||||
import {Factory} from '../support/factory'
|
||||
import {faker} from '@faker-js/faker'
|
||||
|
||||
export class NamespaceFactory extends Factory {
|
||||
static table = 'namespaces'
|
||||
export class ProjectFactory extends Factory {
|
||||
static table = 'projects'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
@ -15,4 +15,4 @@ export class NamespaceFactory extends Factory {
|
|||
updated: now.toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,7 +11,7 @@ export class TaskFactory extends Factory {
|
|||
id: '{increment}',
|
||||
title: faker.lorem.words(3),
|
||||
done: false,
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
created_by_id: 1,
|
||||
index: '{increment}',
|
||||
position: '{increment}',
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import {Factory} from '../support/factory'
|
||||
|
||||
export class TaskReminderFactory extends Factory {
|
||||
static table = 'task_reminders'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
task_id: 1,
|
||||
reminder: now.toISOString(),
|
||||
created: now.toISOString(),
|
||||
relative_to: '',
|
||||
relative_period: 0,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,14 +1,14 @@
|
|||
import {Factory} from '../support/factory'
|
||||
|
||||
export class UserListFactory extends Factory {
|
||||
static table = 'users_lists'
|
||||
export class UserProjectFactory extends Factory {
|
||||
static table = 'users_projects'
|
||||
|
||||
static factory() {
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
list_id: 1,
|
||||
project_id: 1,
|
||||
user_id: 1,
|
||||
right: 0,
|
||||
created: now.toISOString(),
|
|
@ -4,7 +4,7 @@ import {seed} from './seed'
|
|||
* A factory makes it easy to seed the database with data.
|
||||
*/
|
||||
export class Factory {
|
||||
static table = null
|
||||
static table: string | null = null
|
||||
|
||||
static factory() {
|
||||
return {}
|
||||
|
|
|
@ -11,5 +11,8 @@ VIKUNJA_SENTRY_DSN="$(echo "$VIKUNJA_SENTRY_DSN" | sed -r 's/([:;])/\\\1/g')"
|
|||
sed -ri "s:^(\s*window.API_URL\s*=)\s*.+:\1 '${VIKUNJA_API_URL}':g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.PROJECT_INFINITE_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED}':g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.ALLOW_ICON_CHANGES\s*=)\s*.+:\1 ${VIKUNJA_ALLOW_ICON_CHANGES}:g" /usr/share/nginx/html/index.html
|
||||
sed -ri "s:^(\s*window.CUSTOM_LOGO_URL\s*=)\s*.+:\1 ${VIKUNJA_CUSTOM_LOGO_URL}:g" /usr/share/nginx/html/index.html
|
||||
|
||||
date -uIseconds | xargs echo 'info: started at'
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
#!/usr/bin/env sh
|
||||
set -e
|
||||
|
||||
DEFAULT_CONF_FILE="etc/nginx/conf.d/default.conf"
|
||||
|
||||
if [ -f "/proc/net/if_inet6" ]; then
|
||||
echo "info: IPv6 available."
|
||||
exit 0
|
||||
if [ ! -f "/proc/net/if_inet6" ]; then
|
||||
echo "info: IPv6 is not available! Removing IPv6 listen configuration"
|
||||
find /etc/nginx/conf.d -name '*.conf' -type f | \
|
||||
while IFS= read -r CONFIG; do
|
||||
sed -r '/^\s*listen\s*\[::\]:.+$/d' "$CONFIG" > "$CONFIG.temp"
|
||||
if ! diff -U 5 "$CONFIG" "$CONFIG.temp" > "$CONFIG.diff"; then
|
||||
echo "info: Removing IPv6 lines from $CONFIG" | \
|
||||
cat - "$CONFIG.diff"
|
||||
echo "# IPv6 is disabled because /proc/net/if_inet6 was not found" | \
|
||||
cat - "$CONFIG.temp" > "$CONFIG"
|
||||
else
|
||||
echo "info: Skipping $CONFIG because it does not have IPv6 listen"
|
||||
fi
|
||||
rm -f "$CONFIG.temp" "$CONFIG.diff"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "info: IPv6 not available!"
|
||||
echo "info: Removing IPv6 lines from /$DEFAULT_CONF_FILE"
|
||||
sed -i 's/\(listen\s*\[::\].*\)$/#\1 # Disabled IPv6/' /${DEFAULT_CONF_FILE}
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
pid /tmp/nginx.pid;
|
||||
worker_processes auto;
|
||||
worker_rlimit_nofile 65535;
|
||||
|
||||
events {
|
||||
multi_accept on;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
server {
|
||||
listen ${VIKUNJA_HTTP_PORT};
|
||||
listen [::]:${VIKUNJA_HTTP_PORT};
|
||||
## Needed when behind HAProxy with SSL termination + HTTP/2 support
|
||||
## Needed when behind HAProxy with SSL termination + HTTP/2 support
|
||||
listen ${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
|
||||
listen [::]:${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
|
||||
|
||||
|
@ -28,6 +28,20 @@ server {
|
|||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
try_files $uri /index.html =404;
|
||||
}
|
||||
|
||||
# Disable caching for sw
|
||||
location = /sw.js {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Disable caching for webmanifest
|
||||
location = /manifest.webmanifest {
|
||||
autoindex off;
|
||||
expires off;
|
||||
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
|
||||
}
|
||||
|
||||
# favicon.ico
|
||||
location = /favicon.ico {
|
||||
|
|
|
@ -30,21 +30,21 @@ A basic service can look like this:
|
|||
|
||||
```javascript
|
||||
import AbstractService from './abstractService'
|
||||
import ListModel from '../models/list'
|
||||
import ProjectModel from '../models/project'
|
||||
|
||||
export default class ListService extends AbstractService {
|
||||
export default class ProjectService extends AbstractService {
|
||||
constructor() {
|
||||
super({
|
||||
getAll: '/lists',
|
||||
get: '/lists/{id}',
|
||||
create: '/namespaces/{namespaceID}/lists',
|
||||
update: '/lists/{id}',
|
||||
delete: '/lists/{id}',
|
||||
getAll: '/projects',
|
||||
get: '/projects/{id}',
|
||||
create: '/namespaces/{namespaceID}/projects',
|
||||
update: '/projects/{id}',
|
||||
delete: '/projects/{id}',
|
||||
})
|
||||
}
|
||||
|
||||
modelFactory(data) {
|
||||
return new ListModel(data)
|
||||
return new ProjectModel(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
@ -132,7 +132,7 @@ import AbstractModel from './abstractModel'
|
|||
import TaskModel from './task'
|
||||
import UserModel from './user'
|
||||
|
||||
export default class ListModel extends AbstractModel {
|
||||
export default class ProjectModel extends AbstractModel {
|
||||
|
||||
constructor(data) {
|
||||
// The constructor of AbstractModel handles all the default parsing.
|
||||
|
|
|
@ -3,10 +3,32 @@
|
|||
/// <reference types="cypress" />
|
||||
/// <reference types="@histoire/plugin-vue/components" />
|
||||
|
||||
declare module 'postcss-focus-within/browser' {
|
||||
import focusWithinInit from 'postcss-focus-within/browser'
|
||||
export default focusWithinInit
|
||||
}
|
||||
|
||||
declare module 'css-has-pseudo/browser' {
|
||||
import cssHasPseudo from 'css-has-pseudo/browser'
|
||||
export default cssHasPseudo
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_IS_ONLINE: boolean
|
||||
readonly VIKUNJA_API_URL?: string
|
||||
readonly VIKUNJA_HTTP_PORT?: number
|
||||
readonly VIKUNJA_HTTPS_PORT?: number
|
||||
|
||||
readonly VIKUNJA_SENTRY_ENABLED?: boolean
|
||||
readonly VIKUNJA_SENTRY_DSN?: string
|
||||
|
||||
readonly SENTRY_AUTH_TOKEN?: string
|
||||
readonly SENTRY_ORG?: string
|
||||
readonly SENTRY_PROJECT?: string
|
||||
|
||||
readonly VITE_WORKBOX_DEBUG?: boolean
|
||||
readonly VITE_IS_ONLINE: boolean
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1664753041,
|
||||
"narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
|
||||
"lastModified": 1697730408,
|
||||
"narHash": "sha256-Ww//zzukdTrwTrCUkaJA/NsaLEfUfQpWZXBdXBYfhak=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
|
||||
"rev": "ff0a5a776b56e0ca32d47a4a47695452ec7f7d80",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
@ -28,7 +28,7 @@ export default defineConfig({
|
|||
// light: './img/light.png',
|
||||
// dark: './img/dark.png',
|
||||
// },
|
||||
// logoHref: 'https://acme.com',
|
||||
logoHref: 'https://vikunja.io',
|
||||
// favicon: './favicon.ico',
|
||||
},
|
||||
})
|
11
index.html
11
index.html
|
@ -18,15 +18,22 @@
|
|||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script>
|
||||
//
|
||||
//
|
||||
// This variable points the frontend to the api.
|
||||
// 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
|
||||
// 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'
|
||||
// If enabled, allows the user to nest projects infinitely, instead of the default 2 levels.
|
||||
// This setting might change in the future or be removed completely.
|
||||
window.PROJECT_INFINITE_NESTING_ENABLED = false
|
||||
// Allow changing the logo and other icons based on various occasions throughout the year.
|
||||
window.ALLOW_ICON_CHANGES = true
|
||||
// Allow using a custom logo via external URL.
|
||||
window.CUSTOM_LOGO_URL = ''
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
Binary file not shown.
Binary file not shown.
205
package.json
205
package.json
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"homepage": "https://vikunja.io/",
|
||||
"funding": "https://opencollective.com/vikunja",
|
||||
"packageManager": "pnpm@7.26.3",
|
||||
"packageManager": "pnpm@8.10.5",
|
||||
"keywords": [
|
||||
"todo",
|
||||
"productivity",
|
||||
|
@ -45,103 +45,142 @@
|
|||
"story:preview": "histoire preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "6.2.1",
|
||||
"@fortawesome/free-regular-svg-icons": "6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "6.2.1",
|
||||
"@fortawesome/vue-fontawesome": "3.0.3",
|
||||
"@github/hotkey": "2.0.1",
|
||||
"@infectoone/vue-ganttastic": "2.1.4",
|
||||
"@intlify/unplugin-vue-i18n": "0.8.1",
|
||||
"@kyvg/vue3-notification": "2.8.0",
|
||||
"@sentry/tracing": "7.36.0",
|
||||
"@sentry/vue": "7.36.0",
|
||||
"@types/is-touch-device": "1.0.0",
|
||||
"@types/lodash.clonedeep": "4.5.7",
|
||||
"@types/sortablejs": "1.15.0",
|
||||
"@vueuse/core": "9.12.0",
|
||||
"axios": "1.3.2",
|
||||
"blurhash": "2.0.4",
|
||||
"@fortawesome/fontawesome-svg-core": "6.4.2",
|
||||
"@fortawesome/free-regular-svg-icons": "6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.4.2",
|
||||
"@fortawesome/vue-fontawesome": "3.0.5",
|
||||
"@github/hotkey": "2.3.0",
|
||||
"@infectoone/vue-ganttastic": "2.2.0",
|
||||
"@intlify/unplugin-vue-i18n": "1.5.0",
|
||||
"@kyvg/vue3-notification": "3.0.2",
|
||||
"@sentry/tracing": "7.80.1",
|
||||
"@sentry/vue": "7.80.1",
|
||||
"@tiptap/core": "2.1.12",
|
||||
"@tiptap/extension-blockquote": "2.1.12",
|
||||
"@tiptap/extension-bold": "2.1.12",
|
||||
"@tiptap/extension-bullet-list": "2.1.12",
|
||||
"@tiptap/extension-code": "2.1.12",
|
||||
"@tiptap/extension-code-block-lowlight": "2.1.12",
|
||||
"@tiptap/extension-document": "2.1.12",
|
||||
"@tiptap/extension-dropcursor": "2.1.12",
|
||||
"@tiptap/extension-gapcursor": "2.1.12",
|
||||
"@tiptap/extension-hard-break": "2.1.12",
|
||||
"@tiptap/extension-heading": "2.1.12",
|
||||
"@tiptap/extension-history": "2.1.12",
|
||||
"@tiptap/extension-horizontal-rule": "2.1.12",
|
||||
"@tiptap/extension-image": "2.1.12",
|
||||
"@tiptap/extension-italic": "2.1.12",
|
||||
"@tiptap/extension-link": "2.1.12",
|
||||
"@tiptap/extension-list-item": "2.1.12",
|
||||
"@tiptap/extension-ordered-list": "2.1.12",
|
||||
"@tiptap/extension-paragraph": "2.1.12",
|
||||
"@tiptap/extension-placeholder": "2.1.12",
|
||||
"@tiptap/extension-strike": "2.1.12",
|
||||
"@tiptap/extension-table": "2.1.12",
|
||||
"@tiptap/extension-table-cell": "2.1.12",
|
||||
"@tiptap/extension-table-header": "2.1.12",
|
||||
"@tiptap/extension-table-row": "2.1.12",
|
||||
"@tiptap/extension-task-item": "2.1.12",
|
||||
"@tiptap/extension-task-list": "2.1.12",
|
||||
"@tiptap/extension-text": "2.1.12",
|
||||
"@tiptap/extension-typography": "2.1.12",
|
||||
"@tiptap/extension-underline": "2.1.12",
|
||||
"@tiptap/pm": "2.1.12",
|
||||
"@tiptap/suggestion": "2.1.12",
|
||||
"@tiptap/vue-3": "2.1.12",
|
||||
"@types/is-touch-device": "1.0.2",
|
||||
"@types/lodash.clonedeep": "4.5.9",
|
||||
"@types/sortablejs": "1.15.5",
|
||||
"@vueuse/core": "10.5.0",
|
||||
"@vueuse/router": "10.5.0",
|
||||
"axios": "1.6.0",
|
||||
"blurhash": "2.0.5",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"camel-case": "4.1.2",
|
||||
"codemirror": "5.65.11",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"dompurify": "2.4.3",
|
||||
"easymde": "2.18.0",
|
||||
"date-fns": "2.30.0",
|
||||
"dayjs": "1.11.10",
|
||||
"dompurify": "3.0.6",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"flatpickr": "4.6.13",
|
||||
"flexsearch": "0.7.21",
|
||||
"floating-vue": "2.0.0-beta.20",
|
||||
"focus-within": "3.0.2",
|
||||
"highlight.js": "11.7.0",
|
||||
"flexsearch": "0.7.31",
|
||||
"floating-vue": "2.0.0-beta.24",
|
||||
"is-touch-device": "1.0.1",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"klona": "2.0.6",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"marked": "4.2.12",
|
||||
"minimist": "1.2.7",
|
||||
"pinia": "2.0.30",
|
||||
"lowlight": "2.9.0",
|
||||
"pinia": "2.1.7",
|
||||
"register-service-worker": "1.7.2",
|
||||
"snake-case": "3.0.4",
|
||||
"sortablejs": "1.15.0",
|
||||
"ufo": "1.0.1",
|
||||
"vue": "3.2.47",
|
||||
"tippy.js": "6.3.7",
|
||||
"ufo": "1.3.2",
|
||||
"vue": "3.3.8",
|
||||
"vue-advanced-cropper": "2.8.8",
|
||||
"vue-flatpickr-component": "11.0.1",
|
||||
"vue-i18n": "9.2.2",
|
||||
"vue-router": "4.1.6",
|
||||
"workbox-precaching": "6.5.4",
|
||||
"vue-flatpickr-component": "11.0.3",
|
||||
"vue-i18n": "9.7.0",
|
||||
"vue-router": "4.2.5",
|
||||
"workbox-precaching": "7.0.0",
|
||||
"zhyswan-vuedraggable": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@4tw/cypress-drag-drop": "2.2.3",
|
||||
"@cypress/vite-dev-server": "5.0.2",
|
||||
"@cypress/vue": "5.0.3",
|
||||
"@faker-js/faker": "7.6.0",
|
||||
"@histoire/plugin-screenshot": "0.13.0",
|
||||
"@histoire/plugin-vue": "0.12.4",
|
||||
"@rushstack/eslint-patch": "1.2.0",
|
||||
"@types/codemirror": "5.60.7",
|
||||
"@types/dompurify": "2.4.0",
|
||||
"@types/flexsearch": "0.7.3",
|
||||
"@types/focus-within": "1.0.1",
|
||||
"@types/lodash.debounce": "4.0.7",
|
||||
"@types/marked": "4.0.8",
|
||||
"@types/node": "18.11.19",
|
||||
"@4tw/cypress-drag-drop": "2.2.5",
|
||||
"@cypress/vite-dev-server": "5.0.6",
|
||||
"@cypress/vue": "6.0.0",
|
||||
"@faker-js/faker": "8.3.1",
|
||||
"@histoire/plugin-screenshot": "0.17.0",
|
||||
"@histoire/plugin-vue": "0.17.5",
|
||||
"@rushstack/eslint-patch": "1.5.1",
|
||||
"@tsconfig/node18": "18.2.2",
|
||||
"@types/codemirror": "5.60.13",
|
||||
"@types/dompurify": "3.0.5",
|
||||
"@types/flexsearch": "0.7.6",
|
||||
"@types/is-touch-device": "1.0.2",
|
||||
"@types/lodash.debounce": "4.0.9",
|
||||
"@types/marked": "5.0.2",
|
||||
"@types/node": "20.9.1",
|
||||
"@types/postcss-preset-env": "7.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.50.0",
|
||||
"@typescript-eslint/parser": "5.50.0",
|
||||
"@vitejs/plugin-legacy": "4.0.1",
|
||||
"@vitejs/plugin-vue": "4.0.0",
|
||||
"@vue/eslint-config-typescript": "11.0.2",
|
||||
"@vue/test-utils": "2.2.10",
|
||||
"@vue/tsconfig": "0.1.3",
|
||||
"autoprefixer": "10.4.13",
|
||||
"browserslist": "4.21.4",
|
||||
"caniuse-lite": "1.0.30001449",
|
||||
"csstype": "3.1.1",
|
||||
"cypress": "12.5.1",
|
||||
"esbuild": "0.17.5",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-plugin-vue": "9.9.0",
|
||||
"happy-dom": "8.2.0",
|
||||
"histoire": "0.12.4",
|
||||
"netlify-cli": "12.10.0",
|
||||
"postcss": "8.4.21",
|
||||
"@types/sortablejs": "1.15.5",
|
||||
"@typescript-eslint/eslint-plugin": "6.11.0",
|
||||
"@typescript-eslint/parser": "6.11.0",
|
||||
"@vitejs/plugin-legacy": "4.1.1",
|
||||
"@vitejs/plugin-vue": "4.5.0",
|
||||
"@vue/eslint-config-typescript": "12.0.0",
|
||||
"@vue/test-utils": "2.4.2",
|
||||
"@vue/tsconfig": "0.4.0",
|
||||
"autoprefixer": "10.4.16",
|
||||
"browserslist": "4.22.1",
|
||||
"caniuse-lite": "1.0.30001563",
|
||||
"css-has-pseudo": "6.0.0",
|
||||
"csstype": "3.1.2",
|
||||
"cypress": "13.5.1",
|
||||
"esbuild": "0.19.5",
|
||||
"eslint": "8.53.0",
|
||||
"eslint-plugin-vue": "9.18.1",
|
||||
"happy-dom": "12.10.3",
|
||||
"histoire": "0.17.5",
|
||||
"postcss": "8.4.31",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-easings": "3.0.1",
|
||||
"postcss-preset-env": "8.0.1",
|
||||
"rollup": "3.14.0",
|
||||
"rollup-plugin-visualizer": "5.9.0",
|
||||
"sass": "1.58.0",
|
||||
"start-server-and-test": "1.15.3",
|
||||
"typescript": "4.9.5",
|
||||
"vite": "4.1.1",
|
||||
"vite-plugin-inject-preload": "1.2.0",
|
||||
"vite-plugin-pwa": "0.14.1",
|
||||
"postcss-easings": "4.0.0",
|
||||
"postcss-focus-within": "8.0.0",
|
||||
"postcss-preset-env": "9.3.0",
|
||||
"rollup": "4.4.1",
|
||||
"rollup-plugin-visualizer": "5.9.2",
|
||||
"sass": "1.69.5",
|
||||
"start-server-and-test": "2.0.3",
|
||||
"typescript": "5.2.2",
|
||||
"vite": "4.5.0",
|
||||
"vite-plugin-inject-preload": "1.3.3",
|
||||
"vite-plugin-pwa": "0.16.7",
|
||||
"vite-plugin-sentry": "1.3.0",
|
||||
"vite-svg-loader": "4.0.0",
|
||||
"vitest": "0.28.4",
|
||||
"vue-tsc": "1.0.24",
|
||||
"wait-on": "7.0.1",
|
||||
"workbox-cli": "6.5.4"
|
||||
"vitest": "0.34.6",
|
||||
"vue-tsc": "1.8.22",
|
||||
"wait-on": "7.2.0",
|
||||
"workbox-cli": "7.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"flexsearch@0.7.31": "patches/flexsearch@0.7.31.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
diff --git a/index.d.ts b/index.d.ts
|
||||
deleted file mode 100644
|
||||
index 9f39f41073864b83968bdaa242ac4e3c3149685a..0000000000000000000000000000000000000000
|
||||
diff --git a/package.json b/package.json
|
||||
index 8968f5bf8010ff194240591c8b83299f7328e79d..6d84b6f590a841b129ed8b3860cb786df5a185c0 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -22,8 +22,6 @@
|
||||
},
|
||||
"main": "dist/flexsearch.bundle.js",
|
||||
"browser": "dist/flexsearch.bundle.js",
|
||||
- "module": "dist/module/index.js",
|
||||
- "types": "./index.d.ts",
|
||||
"preferGlobal": false,
|
||||
"repository": {
|
||||
"type": "git",
|
15973
pnpm-lock.yaml
15973
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -2,11 +2,11 @@
|
|||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"labels": ["dependencies"],
|
||||
"extends": [
|
||||
"config:base"
|
||||
"config:js-app"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["netlify-cli", "happy-dom"],
|
||||
"matchPackageNames": ["happy-dom"],
|
||||
"extends": ["schedule:weekly"]
|
||||
},
|
||||
{
|
||||
|
@ -20,11 +20,24 @@
|
|||
"@vueuse/"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "histoire",
|
||||
"matchPackagePrefixes": [
|
||||
"@histoire/",
|
||||
"histoire"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "tiptap",
|
||||
"matchPackagePrefixes": [
|
||||
"@tiptap/",
|
||||
"tiptap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matchDepTypes": ["devDependencies"],
|
||||
"automerge": true,
|
||||
"automergeStrategy": "squash",
|
||||
"automergeType": "pr"
|
||||
"groupName": "dev-dependencies",
|
||||
"extends": ["schedule:daily"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -33,9 +33,9 @@ const promiseExec = cmd => {
|
|||
}
|
||||
|
||||
(async function () {
|
||||
let stdout = await promiseExec(`./node_modules/.bin/netlify link --id ${siteId}`)
|
||||
let stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify link --id ${siteId}`)
|
||||
console.log(stdout)
|
||||
stdout = await promiseExec(`./node_modules/.bin/netlify deploy --alias ${alias}`)
|
||||
stdout = await promiseExec(`/home/node/docker-netlify-cli/node_modules/.bin/netlify deploy --alias ${alias}`)
|
||||
console.log(stdout)
|
||||
|
||||
const data = await fetch(prIssueCommentsUrl).then(response => response.json())
|
||||
|
|
|
@ -1 +1 @@
|
|||
57af69409e66bc87f4f2fc5822dd8d3c2eb47c601f81af1ac4a56f3e2d80837b1a2de06f4ff57695ec379b7c15b881e3 ./scripts/deploy-preview-netlify.mjs
|
||||
4a7c1293c7b12e9ab476cdf35251a407c6a1cd005d22c06df994222cccfb25cde5f47d15866a098c9d739778fee4dc19 ./scripts/deploy-preview-netlify.mjs
|
||||
|
|
|
@ -12,8 +12,10 @@
|
|||
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
|
||||
|
||||
<Teleport to="body">
|
||||
<AddToHomeScreen/>
|
||||
<UpdateNotification/>
|
||||
<Notification/>
|
||||
<DemoMode/>
|
||||
</Teleport>
|
||||
</ready>
|
||||
</template>
|
||||
|
@ -43,6 +45,8 @@ import {useBaseStore} from '@/stores/base'
|
|||
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
import {useBodyClass} from '@/composables/useBodyClass'
|
||||
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
||||
import DemoMode from '@/components/home/DemoMode.vue'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const authStore = useAuthStore()
|
||||
|
@ -92,7 +96,7 @@ watch(userEmailConfirm, (userEmailConfirm) => {
|
|||
router.push({name: 'user.login'})
|
||||
}, { immediate: true })
|
||||
|
||||
setLanguage()
|
||||
setLanguage(authStore.settings.language)
|
||||
useColorScheme()
|
||||
</script>
|
||||
|
||||
|
|
Binary file not shown.
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="18px" height="18px" viewBox="0 0 18 18" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z" stroke-dasharray="60"></path>
|
||||
<polyline points="1 9 7 14 15 4" stroke-dasharray="22" stroke-dashoffset="66"></polyline>
|
||||
</svg>
|
After Width: | Height: | Size: 420 B |
Binary file not shown.
Before Width: | Height: | Size: 519 KiB After Width: | Height: | Size: 313 KiB |
|
@ -63,7 +63,7 @@ import {unrefElement} from '@vueuse/core'
|
|||
import {ref, type HTMLAttributes} from 'vue'
|
||||
import type {RouteLocationRaw} from 'vue-router'
|
||||
|
||||
export interface BaseButtonProps extends HTMLAttributes {
|
||||
export interface BaseButtonProps extends /* @vue-ignore */ HTMLAttributes {
|
||||
type?: BaseButtonTypes
|
||||
disabled?: boolean
|
||||
to?: RouteLocationRaw
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
<template>
|
||||
<div class="base-checkbox" v-cy="'checkbox'">
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="checkboxId"
|
||||
class="is-sr-only"
|
||||
:checked="modelValue"
|
||||
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
|
||||
:disabled="disabled || undefined"
|
||||
/>
|
||||
|
||||
<slot name="label" :checkboxId="checkboxId">
|
||||
<label :for="checkboxId" class="base-checkbox__label">
|
||||
<slot/>
|
||||
</label>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const checkboxId = ref(`fancycheckbox_${createRandomID()}`)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.base-checkbox__label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.base-checkbox:has(input:disabled) .base-checkbox__label {
|
||||
cursor:not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
|
@ -32,7 +32,7 @@ import {computed, ref} from 'vue'
|
|||
import {getInheritedBackgroundColor} from '@/helpers/getInheritedBackgroundColor'
|
||||
|
||||
const props = defineProps({
|
||||
/** Wheather the Expandable is open or not */
|
||||
/** Whether the Expandable is open or not */
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import datemathHelp from './datemathHelp.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story>
|
||||
<Variant title="Default">
|
||||
<datemathHelp />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
|
@ -1,7 +1,8 @@
|
|||
<template>
|
||||
<card
|
||||
class="has-no-shadow how-it-works-modal"
|
||||
:title="$t('input.datemathHelp.title')">
|
||||
:title="$t('input.datemathHelp.title')"
|
||||
>
|
||||
<p>
|
||||
{{ $t('input.datemathHelp.intro') }}
|
||||
</p>
|
||||
|
@ -27,11 +28,11 @@
|
|||
</p>
|
||||
<p>{{ $t('misc.forExample') }}</p>
|
||||
<ul>
|
||||
<li><code>+1d</code>{{ $t('input.datemathHelp.add1Day') }}</li>
|
||||
<li><code>-1d</code>{{ $t('input.datemathHelp.minus1Day') }}</li>
|
||||
<li><code>/d</code>{{ $t('input.datemathHelp.roundDay') }}</li>
|
||||
<li><code>+1d</code> {{ $t('input.datemathHelp.add1Day') }}</li>
|
||||
<li><code>-1d</code> {{ $t('input.datemathHelp.minus1Day') }}</li>
|
||||
<li><code>/d</code> {{ $t('input.datemathHelp.roundDay') }}</li>
|
||||
</ul>
|
||||
<p>{{ $t('input.datemathHelp.supportedUnits') }}</p>
|
||||
<h3>{{ $t('input.datemathHelp.supportedUnits') }}</h3>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
|
@ -69,7 +70,7 @@
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>{{ $t('input.datemathHelp.someExamples') }}</p>
|
||||
<h3>{{ $t('input.datemathHelp.someExamples') }}</h3>
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
|
@ -100,7 +101,7 @@
|
|||
<td><code>{{ exampleDate }}||+1M/d</code></td>
|
||||
<td>
|
||||
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth" scope="global">
|
||||
<code>{{ exampleDate }}</code>
|
||||
<strong>{{ exampleDate }}</strong>
|
||||
</i18n-t>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -110,13 +111,15 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const exampleDate = formatDate(new Date(), 'yyyy-MM-dd')
|
||||
const exampleDate = formatDateShort(new Date())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// FIXME: Remove style overwrites
|
||||
.how-it-works-modal {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
|
|
@ -75,6 +75,7 @@ import {useI18n} from 'vue-i18n'
|
|||
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
||||
|
||||
import Popup from '@/components/misc/popup.vue'
|
||||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||
|
@ -120,9 +121,9 @@ watch(
|
|||
to.value = newValue.dateTo
|
||||
// Only set the date back to flatpickr when it's an actual date.
|
||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||
const dateFrom = new Date(from.value)
|
||||
const dateTo = new Date(to.value)
|
||||
if (dateTo.getTime() === dateTo.getTime() && dateFrom.getTime() === dateFrom.getTime()) {
|
||||
const dateFrom = parseDateOrString(from.value, false)
|
||||
const dateTo = parseDateOrString(to.value, false)
|
||||
if (dateFrom instanceof Date && dateTo instanceof Date) {
|
||||
flatpickrRange.value = `${from.value} to ${to.value}`
|
||||
}
|
||||
},
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="shouldShowMessage"
|
||||
class="add-to-home-screen"
|
||||
:class="{'has-update-available': hasUpdateAvailable}"
|
||||
>
|
||||
<icon icon="arrow-up-from-bracket" class="add-icon"/>
|
||||
<p>
|
||||
{{ $t('home.addToHomeScreen') }}
|
||||
</p>
|
||||
<BaseButton @click="() => hideMessage = true" class="hide-button">
|
||||
<icon icon="x"/>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {useLocalStorage} from '@vueuse/core'
|
||||
import {computed} from 'vue'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
|
||||
const hideMessage = useLocalStorage('hideAddToHomeScreenMessage', false)
|
||||
const hasUpdateAvailable = computed(() => baseStore.updateAvailable)
|
||||
|
||||
const shouldShowMessage = computed(() => {
|
||||
if (hideMessage.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.add-to-home-screen {
|
||||
position: fixed;
|
||||
// FIXME: We should prevent usage of z-index or
|
||||
// at least define it centrally
|
||||
// the highest z-index of a modal is .hint-modal with 4500
|
||||
z-index: 5000;
|
||||
bottom: 1rem;
|
||||
inset-inline: 1rem;
|
||||
max-width: max-content;
|
||||
margin-inline: auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: .5rem 1rem;
|
||||
background: var(--grey-900);
|
||||
border-radius: $radius;
|
||||
font-size: .9rem;
|
||||
color: var(--grey-200);
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.has-update-available {
|
||||
bottom: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.hide-button {
|
||||
padding: .25rem .5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const hide = ref(false)
|
||||
const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="enabled"
|
||||
class="demo-mode-banner"
|
||||
>
|
||||
<p>
|
||||
{{ $t('demo.title') }}
|
||||
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
||||
</p>
|
||||
<BaseButton @click="() => hide = true" class="hide-button">
|
||||
<icon icon="times"/>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.demo-mode-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--danger);
|
||||
z-index: 100;
|
||||
padding: .5rem;
|
||||
text-align: center;
|
||||
|
||||
&, strong {
|
||||
color: hsl(220, 13%, 91%) !important; // --grey-200 in light mode, hardcoded because the color should not change
|
||||
}
|
||||
}
|
||||
|
||||
.hide-button {
|
||||
padding: .25rem .5rem;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: .5rem;
|
||||
top: .25rem;
|
||||
}
|
||||
</style>
|
|
@ -4,17 +4,26 @@ import { useNow } from '@vueuse/core'
|
|||
|
||||
import LogoFull from '@/assets/logo-full.svg?component'
|
||||
import LogoFullPride from '@/assets/logo-full-pride.svg?component'
|
||||
import {MILLISECONDS_A_HOUR} from '@/constants/date'
|
||||
|
||||
const now = useNow()
|
||||
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
|
||||
const now = useNow({
|
||||
interval: MILLISECONDS_A_HOUR,
|
||||
})
|
||||
const Logo = computed(() => window.ALLOW_ICON_CHANGES && now.value.getMonth() === 6 ? LogoFullPride : LogoFull)
|
||||
const CustomLogo = computed(() => window.CUSTOM_LOGO_URL)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Logo alt="Vikunja" class="logo" />
|
||||
<div>
|
||||
<Logo v-if="!CustomLogo" alt="Vikunja" class="logo" />
|
||||
<img v-show="CustomLogo" :src="CustomLogo" alt="Vikunja" class="logo" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
color: var(--logo-text-color);
|
||||
max-width: 168px;
|
||||
max-height: 48px;
|
||||
}
|
||||
</style>
|
|
@ -3,7 +3,7 @@
|
|||
class="menu-show-button"
|
||||
@click="baseStore.toggleMenu()"
|
||||
@shortkey="() => baseStore.toggleMenu()"
|
||||
v-shortcut="'Control+e'"
|
||||
v-shortcut="'Mod+e'"
|
||||
:title="$t('keyboardShortcuts.toggleMenu')"
|
||||
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<draggable
|
||||
v-model="availableProjects"
|
||||
animation="100"
|
||||
ghostClass="ghost"
|
||||
group="projects"
|
||||
@start="() => drag = true"
|
||||
@end="saveProjectPosition"
|
||||
handle=".handle"
|
||||
tag="menu"
|
||||
item-key="id"
|
||||
:disabled="!canEditOrder"
|
||||
filter=".drag-disabled"
|
||||
:component-data="{
|
||||
type: 'transition-group',
|
||||
name: !drag ? 'flip-list' : null,
|
||||
class: [
|
||||
'menu-list can-be-hidden',
|
||||
{ 'dragging-disabled': !canEditOrder }
|
||||
],
|
||||
}"
|
||||
>
|
||||
<template #item="{element: project}">
|
||||
<ProjectsNavigationItem
|
||||
:class="{'drag-disabled': project.id < 0}"
|
||||
:project="project"
|
||||
:is-loading="projectUpdating[project.id]"
|
||||
:can-collapse="canCollapse"
|
||||
:level="level"
|
||||
:data-project-id="project.id"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import type {SortableEvent} from 'sortablejs'
|
||||
|
||||
import ProjectsNavigationItem from '@/components/home/ProjectsNavigationItem.vue'
|
||||
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: IProject[],
|
||||
canEditOrder: boolean,
|
||||
canCollapse?: boolean,
|
||||
level?: number,
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', projects: IProject[]): void
|
||||
}>()
|
||||
|
||||
const drag = ref(false)
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// Vue draggable will modify the projects list as it changes their position which will not work on a prop.
|
||||
// Hence, we'll clone the prop and work on the clone.
|
||||
const availableProjects = ref<IProject[]>([])
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
projects => {
|
||||
availableProjects.value = projects || []
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const projectUpdating = ref<{ [id: IProject['id']]: boolean }>({})
|
||||
|
||||
async function saveProjectPosition(e: SortableEvent) {
|
||||
if (!e.newIndex && e.newIndex !== 0) return
|
||||
|
||||
const projectsActive = availableProjects.value
|
||||
// If the project was dragged to the last position, Safari will report e.newIndex as the size of the projectsActive
|
||||
// array instead of using the position. Because the index is wrong in that case, dragging the project will fail.
|
||||
// To work around that we're explicitly checking that case here and decrease the index.
|
||||
const newIndex = e.newIndex === projectsActive.length ? e.newIndex - 1 : e.newIndex
|
||||
|
||||
const projectId = parseInt(e.item.dataset.projectId)
|
||||
const project = projectStore.projects[projectId]
|
||||
|
||||
const parentProjectId = e.to.parentNode.dataset.projectId ? parseInt(e.to.parentNode.dataset.projectId) : 0
|
||||
const projectBefore = projectsActive[newIndex - 1] ?? null
|
||||
const projectAfter = projectsActive[newIndex + 1] ?? null
|
||||
projectUpdating.value[project.id] = true
|
||||
|
||||
const position = calculateItemPosition(
|
||||
projectBefore !== null ? projectBefore.position : null,
|
||||
projectAfter !== null ? projectAfter.position : null,
|
||||
)
|
||||
|
||||
try {
|
||||
// create a copy of the project in order to not violate pinia manipulation
|
||||
await projectStore.updateProject({
|
||||
...project,
|
||||
position,
|
||||
parentProjectId,
|
||||
})
|
||||
emit('update:modelValue', availableProjects.value)
|
||||
} finally {
|
||||
projectUpdating.value[project.id] = false
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,186 @@
|
|||
<template>
|
||||
<li
|
||||
class="list-menu loader-container is-loading-small"
|
||||
:class="{'is-loading': isLoading}"
|
||||
>
|
||||
<div>
|
||||
<BaseButton
|
||||
v-if="canCollapse && childProjects?.length > 0"
|
||||
@click="childProjectsOpen = !childProjectsOpen"
|
||||
class="collapse-project-button"
|
||||
>
|
||||
<icon icon="chevron-down" :class="{ 'project-is-collapsed': !childProjectsOpen }"/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:to="{ name: 'project.index', params: { projectId: project.id} }"
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentProject?.id === project.id}"
|
||||
>
|
||||
<span
|
||||
v-if="!canCollapse || childProjects?.length === 0"
|
||||
class="collapse-project-button-placeholder"
|
||||
></span>
|
||||
<div class="color-bubble-handle-wrapper" :class="{'is-draggable': project.id > 0}">
|
||||
<ColorBubble
|
||||
v-if="project.hexColor !== ''"
|
||||
:color="project.hexColor"
|
||||
/>
|
||||
<span v-else-if="project.id < -1" class="saved-filter-icon icon menu-item-icon">
|
||||
<icon icon="filter"/>
|
||||
</span>
|
||||
<span
|
||||
v-if="project.id > 0"
|
||||
class="icon menu-item-icon handle lines-handle"
|
||||
:class="{'has-color-bubble': project.hexColor !== ''}"
|
||||
>
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
</div>
|
||||
<span class="project-menu-title">{{ getProjectTitle(project) }}</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="project.id > 0"
|
||||
class="favorite"
|
||||
:class="{'is-favorite': project.isFavorite}"
|
||||
@click="projectStore.toggleProjectFavorite(project)"
|
||||
>
|
||||
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']"/>
|
||||
</BaseButton>
|
||||
<ProjectSettingsDropdown
|
||||
v-if="project.id > 0"
|
||||
class="menu-list-dropdown"
|
||||
:project="project"
|
||||
:level="level"
|
||||
>
|
||||
<template #trigger="{toggleOpen}">
|
||||
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon"/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</ProjectSettingsDropdown>
|
||||
<span class="list-setting-spacer" v-else></span>
|
||||
</div>
|
||||
<ProjectsNavigation
|
||||
v-if="canNestDeeper && childProjectsOpen && canCollapse"
|
||||
:model-value="childProjects"
|
||||
:can-edit-order="true"
|
||||
:can-collapse="canCollapse"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from 'vue'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
import {canNestProjectDeeper} from '@/helpers/canNestProjectDeeper'
|
||||
|
||||
const {
|
||||
project,
|
||||
isLoading,
|
||||
canCollapse,
|
||||
level = 0,
|
||||
} = defineProps<{
|
||||
project: IProject,
|
||||
isLoading?: boolean,
|
||||
canCollapse?: boolean,
|
||||
level?: number,
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const baseStore = useBaseStore()
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
|
||||
const childProjectsOpen = ref(true)
|
||||
|
||||
const childProjects = computed(() => {
|
||||
if (!canNestDeeper.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return projectStore.getChildProjects(project.id)
|
||||
.filter(p => !p.isArchived)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
})
|
||||
|
||||
const canNestDeeper = computed(() => canNestProjectDeeper(level))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-setting-spacer {
|
||||
width: 5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-is-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-favorite {
|
||||
opacity: 1;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.list-menu:hover > div > .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-menu:hover > div > a > .color-bubble-handle-wrapper.is-draggable > {
|
||||
.saved-filter-icon,
|
||||
.color-bubble {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.is-touch .color-bubble {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.color-bubble-handle-wrapper {
|
||||
position: relative;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-right: .25rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
.color-bubble, .icon {
|
||||
transition: all $transition;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.project-menu-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.saved-filter-icon {
|
||||
color: var(--grey-300) !important;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.is-touch .handle.has-color-bubble {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
|
@ -1,117 +1,94 @@
|
|||
<template>
|
||||
<header
|
||||
:class="{'has-background': background, 'menu-active': menuActive}"
|
||||
aria-label="main navigation"
|
||||
class="navbar main-theme is-fixed-top d-print-none"
|
||||
>
|
||||
<router-link :to="{name: 'home'}" class="logo-link">
|
||||
<Logo width="164" height="48"/>
|
||||
<header :class="{ 'has-background': background, 'menu-active': menuActive }" aria-label="main navigation"
|
||||
class="navbar d-print-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' }"
|
||||
class="title">
|
||||
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
|
||||
</h1>
|
||||
|
||||
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="info-button">
|
||||
<icon icon="circle-info"/>
|
||||
</BaseButton>
|
||||
<MenuButton class="menu-button" />
|
||||
|
||||
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
|
||||
</template>
|
||||
<div v-if="currentProject?.id" class="project-title-wrapper">
|
||||
<h1 class="project-title">
|
||||
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
|
||||
</h1>
|
||||
|
||||
<BaseButton :to="{ name: 'project.info', params: { projectId: currentProject.id } }" class="project-title-button">
|
||||
<icon icon="circle-info" />
|
||||
</BaseButton>
|
||||
|
||||
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1"
|
||||
class="project-title-dropdown" :project="currentProject">
|
||||
<template #trigger="{ toggleOpen }">
|
||||
<BaseButton class="project-title-button" @click="toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
</project-settings-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<BaseButton
|
||||
@click="openQuickActions"
|
||||
class="trigger-button pr-0"
|
||||
v-shortcut="'Control+k'"
|
||||
:title="$t('keyboardShortcuts.quickSearch')"
|
||||
>
|
||||
<icon icon="search"/>
|
||||
</BaseButton>
|
||||
<notifications/>
|
||||
<div class="user">
|
||||
<dropdown class="is-right" ref="usernameDropdown">
|
||||
<template #trigger="{toggleOpen}">
|
||||
<x-button
|
||||
class="username-dropdown-trigger"
|
||||
@click="toggleOpen()"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
>
|
||||
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
|
||||
<span class="username">{{ authStore.userDisplayName }}</span>
|
||||
<span class="icon is-small">
|
||||
<icon icon="chevron-down"/>
|
||||
</span>
|
||||
</x-button>
|
||||
</template>
|
||||
<OpenQuickActions/>
|
||||
<Notifications />
|
||||
<dropdown>
|
||||
<template #trigger="{ toggleOpen, open }">
|
||||
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false">
|
||||
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40" />
|
||||
<span class="username">{{ authStore.userDisplayName }}</span>
|
||||
<span class="icon is-small" :style="{
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0)',
|
||||
}">
|
||||
<icon icon="chevron-down" />
|
||||
</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<dropdown-item
|
||||
:to="{name: 'user.settings'}"
|
||||
>
|
||||
{{ $t('user.settings.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
v-if="imprintUrl"
|
||||
:href="imprintUrl"
|
||||
>
|
||||
{{ $t('navigation.imprint') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
v-if="privacyPolicyUrl"
|
||||
:href="privacyPolicyUrl"
|
||||
>
|
||||
{{ $t('navigation.privacy') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
@click="baseStore.setKeyboardShortcutsActive(true)"
|
||||
>
|
||||
{{ $t('keyboardShortcuts.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
:to="{name: 'about'}"
|
||||
>
|
||||
{{ $t('about.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item
|
||||
@click="authStore.logout()"
|
||||
>
|
||||
{{ $t('user.auth.logout') }}
|
||||
</dropdown-item>
|
||||
</dropdown>
|
||||
</div>
|
||||
<dropdown-item :to="{ name: 'user.settings' }">
|
||||
{{ $t('user.settings.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item v-if="imprintUrl" :href="imprintUrl">
|
||||
{{ $t('navigation.imprint') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item v-if="privacyPolicyUrl" :href="privacyPolicyUrl">
|
||||
{{ $t('navigation.privacy') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item @click="baseStore.setKeyboardShortcutsActive(true)">
|
||||
{{ $t('keyboardShortcuts.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item :to="{ name: 'about' }">
|
||||
{{ $t('about.title') }}
|
||||
</dropdown-item>
|
||||
<dropdown-item @click="authStore.logout()">
|
||||
{{ $t('user.auth.logout') }}
|
||||
</dropdown-item>
|
||||
</dropdown>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, nextTick} from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {RIGHTS as Rights} from '@/constants/rights'
|
||||
import { RIGHTS as Rights } from '@/constants/rights'
|
||||
|
||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import Notifications from '@/components/notifications/notifications.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
import OpenQuickActions from '@/components/misc/OpenQuickActions.vue'
|
||||
|
||||
import {getListTitle} from '@/helpers/getListTitle'
|
||||
import { getProjectTitle } from '@/helpers/getProjectTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import { useBaseStore } from '@/stores/base'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const currentList = computed(() => baseStore.currentList)
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const background = computed(() => baseStore.background)
|
||||
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
|
||||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxRight > Rights.READ)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
@ -119,183 +96,149 @@ const authStore = useAuthStore()
|
|||
const configStore = useConfigStore()
|
||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||
|
||||
const usernameDropdown = ref()
|
||||
const listTitle = ref()
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (typeof usernameDropdown.value === 'undefined' || typeof listTitle.value === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const usernameWidth = usernameDropdown.value.$el.clientWidth
|
||||
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
|
||||
})
|
||||
|
||||
function openQuickActions() {
|
||||
baseStore.setQuickActionsActive(true)
|
||||
}
|
||||
</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 {
|
||||
--navbar-button-min-width: 40px;
|
||||
--navbar-gap-width: 1rem;
|
||||
--navbar-icon-size: 1.25rem;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--navbar-gap-width);
|
||||
|
||||
background: var(--site-background);
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
padding-right: .5rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
padding-left: 2rem;
|
||||
padding-right: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&.menu-active {
|
||||
@media screen and (max-width: $tablet) {
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
|
||||
:deep() {
|
||||
.trigger-button {
|
||||
color: var(--grey-400);
|
||||
font-size: var(--navbar-icon-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
align-self: stretch;
|
||||
margin-right: auto;
|
||||
align-self: stretch;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: $hamburger-menu-icon-spacing;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar.main-theme {
|
||||
background: var(--site-background);
|
||||
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) {
|
||||
&.menu-active {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.user {
|
||||
width: $user-dropdown-width-mobile;
|
||||
|
||||
.username-dropdown-trigger {
|
||||
line-height: 1;
|
||||
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;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
.username-dropdown-trigger {
|
||||
background: none;
|
||||
|
||||
&:focus:not(:active), &:active {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-title {
|
||||
.project-title-wrapper {
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
$edit-icon-width: 1rem;
|
||||
// this makes the truncated text of the project title work
|
||||
// inside the flexbox parent
|
||||
min-width: 0;
|
||||
|
||||
@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));
|
||||
padding-inline: var(--navbar-gap-width);
|
||||
}
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 1rem;
|
||||
// We need the following for overflowing ellipsis to work
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-dropdown {
|
||||
align-self: stretch;
|
||||
|
||||
.project-title-button {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title-button {
|
||||
align-self: stretch;
|
||||
min-width: var(--navbar-button-min-width);
|
||||
display: flex;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--navbar-icon-size);
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
.navbar-end {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
>* {
|
||||
min-width: var(--navbar-button-min-width);
|
||||
}
|
||||
}
|
||||
|
||||
.username-dropdown-trigger {
|
||||
padding-left: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
font-size: .85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: $vikunja-font;
|
||||
|
||||
@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: .5rem;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.info-button {
|
||||
text-align: center;
|
||||
height: 1.25rem;
|
||||
line-height: 1.25rem;
|
||||
width: 2rem;
|
||||
margin-top: .25rem;
|
||||
padding: 0 .5rem;
|
||||
color: var(--grey-400);
|
||||
margin-left: .5rem;
|
||||
.avatar {
|
||||
border-radius: 100%;
|
||||
vertical-align: middle;
|
||||
height: 40px;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
</style>
|
|
@ -12,9 +12,12 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref} from 'vue'
|
||||
import {computed, ref} from 'vue'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
const updateAvailable = ref(false)
|
||||
const baseStore = useBaseStore()
|
||||
|
||||
const updateAvailable = computed(() => baseStore.updateAvailable)
|
||||
const registration = ref(null)
|
||||
const refreshing = ref(false)
|
||||
|
||||
|
@ -31,11 +34,11 @@ navigator?.serviceWorker?.addEventListener(
|
|||
function showRefreshUI(e: Event) {
|
||||
console.log('recieved refresh event', e)
|
||||
registration.value = e.detail
|
||||
updateAvailable.value = true
|
||||
baseStore.setUpdateAvailable(true)
|
||||
}
|
||||
|
||||
function refreshApp() {
|
||||
updateAvailable.value = false
|
||||
baseStore.setUpdateAvailable(false)
|
||||
if (!registration.value || !registration.value.waiting) {
|
||||
return
|
||||
}
|
||||
|
@ -60,12 +63,11 @@ function refreshApp() {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: .5rem;
|
||||
padding: .5rem .5rem .5rem 1rem;
|
||||
background: $warning;
|
||||
border-radius: $radius;
|
||||
font-size: .9rem;
|
||||
color: var(--grey-900);
|
||||
|
||||
color: hsl(220.9, 39.3%, 11%); // color copied to avoid it changing in dark mode
|
||||
}
|
||||
|
||||
.update-notification__message {
|
||||
|
|
|
@ -33,13 +33,13 @@
|
|||
<quick-actions/>
|
||||
|
||||
<router-view :route="routeWithModal" v-slot="{ Component }">
|
||||
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
|
||||
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
|
||||
<component :is="Component"/>
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
|
||||
<modal
|
||||
:enabled="Boolean(currentModal)"
|
||||
:enabled="typeof currentModal !== 'undefined'"
|
||||
@close="closeModal()"
|
||||
variant="scrolling"
|
||||
class="task-detail-view-modal"
|
||||
|
@ -69,6 +69,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useLabelStore} from '@/stores/labels'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
||||
|
@ -86,30 +87,26 @@ function showKeyboardShortcuts() {
|
|||
|
||||
const route = useRoute()
|
||||
|
||||
// hide menu on mobile
|
||||
watch(() => route.fullPath, () => window.innerWidth < 769 && baseStore.setMenuActive(false))
|
||||
|
||||
// FIXME: this is really error prone
|
||||
// Reset the current list highlight in menu if the current route is not list related.
|
||||
// Reset the current project highlight in menu if the current route is not project 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',
|
||||
'projects.index',
|
||||
].includes(routeName) ||
|
||||
routeName.startsWith('user.settings')
|
||||
)
|
||||
) {
|
||||
baseStore.handleSetCurrentList({list: null})
|
||||
baseStore.handleSetCurrentProject({project: null})
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -119,6 +116,9 @@ useRenewTokenOnFocus()
|
|||
|
||||
const labelStore = useLabelStore()
|
||||
labelStore.loadAllLabels()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
projectStore.loadProjects()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -159,6 +159,8 @@ labelStore.loadAllLabels()
|
|||
z-index: 10;
|
||||
position: relative;
|
||||
padding: 1.5rem 0.5rem 1rem;
|
||||
// TODO refactor: DRY `transition-timing-function` with `./navigation.vue`.
|
||||
transition: margin-left $transition-duration;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
margin-left: 0;
|
||||
|
|
|
@ -9,9 +9,9 @@
|
|||
<Logo class="logo" v-if="logoVisible"/>
|
||||
<h1
|
||||
:class="{'m-0': !logoVisible}"
|
||||
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
|
||||
:style="{ 'opacity': currentProject?.title === '' ? '0': '1' }"
|
||||
class="title">
|
||||
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
|
||||
{{ currentProject?.title === '' ? $t('misc.loading') : currentProject?.title }}
|
||||
</h1>
|
||||
<div class="box has-text-left view">
|
||||
<router-view/>
|
||||
|
@ -31,7 +31,7 @@ import Logo from '@/components/home/Logo.vue'
|
|||
import PoweredByLink from './PoweredByLink.vue'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const currentList = computed(() => baseStore.currentList)
|
||||
const currentProject = computed(() => baseStore.currentProject)
|
||||
const background = computed(() => baseStore.background)
|
||||
const logoVisible = computed(() => baseStore.logoVisible)
|
||||
</script>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<template>
|
||||
<aside :class="{'is-active': menuActive}" class="namespace-container">
|
||||
<aside :class="{'is-active': baseStore.menuActive}" class="menu-container">
|
||||
<nav class="menu top-menu">
|
||||
<router-link :to="{name: 'home'}" class="logo">
|
||||
<Logo width="164" height="48"/>
|
||||
</router-link>
|
||||
<ul class="menu-list">
|
||||
<menu class="menu-list other-menu-items">
|
||||
<li>
|
||||
<router-link :to="{ name: 'home'}" v-shortcut="'g o'">
|
||||
<span class="menu-item-icon icon">
|
||||
|
@ -22,11 +22,11 @@
|
|||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
|
||||
<router-link :to="{ name: 'projects.index'}" v-shortcut="'g p'">
|
||||
<span class="menu-item-icon icon">
|
||||
<icon icon="layer-group"/>
|
||||
</span>
|
||||
{{ $t('namespace.title') }}
|
||||
{{ $t('project.projects') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
|
@ -45,247 +45,64 @@
|
|||
{{ $t('team.title') }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</menu>
|
||||
</nav>
|
||||
|
||||
<nav 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}">
|
||||
<BaseButton
|
||||
@click="toggleLists(n.id)"
|
||||
class="menu-label"
|
||||
v-tooltip="namespaceTitles[nk]"
|
||||
>
|
||||
<ColorBubble
|
||||
v-if="n.hexColor !== ''"
|
||||
:color="n.hexColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
<span class="name">{{ namespaceTitles[nk] }}</span>
|
||||
<div
|
||||
class="icon menu-item-icon is-small toggle-lists-icon pl-2"
|
||||
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
|
||||
>
|
||||
<icon icon="chevron-down"/>
|
||||
</div>
|
||||
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
|
||||
({{ namespaceListsCount[nk] }})
|
||||
</span>
|
||||
</BaseButton>
|
||||
<namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/>
|
||||
</div>
|
||||
<!--
|
||||
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
|
||||
triggered by the change needs to have access to the current namespace
|
||||
-->
|
||||
<draggable
|
||||
v-if="listsVisible[n.id] ?? true"
|
||||
v-bind="dragOptions"
|
||||
:modelValue="activeLists[nk]"
|
||||
@update:modelValue="(lists) => updateActiveLists(n, lists)"
|
||||
group="namespace-lists"
|
||||
@start="() => drag = true"
|
||||
@end="saveListPosition"
|
||||
handle=".handle"
|
||||
:disabled="n.id < 0 || undefined"
|
||||
tag="ul"
|
||||
item-key="id"
|
||||
:data-namespace-id="n.id"
|
||||
:data-namespace-index="nk"
|
||||
:component-data="{
|
||||
type: 'transition-group',
|
||||
name: !drag ? 'flip-list' : null,
|
||||
class: [
|
||||
'menu-list can-be-hidden',
|
||||
{ 'dragging-disabled': n.id < 0 }
|
||||
]
|
||||
}"
|
||||
>
|
||||
<template #item="{element: l}">
|
||||
<li
|
||||
class="list-menu loader-container is-loading-small"
|
||||
:class="{'is-loading': listUpdating[l.id]}"
|
||||
>
|
||||
<BaseButton
|
||||
:to="{ name: 'list.index', params: { listId: l.id} }"
|
||||
class="list-menu-link"
|
||||
:class="{'router-link-exact-active': currentList.id === l.id}"
|
||||
>
|
||||
<span class="icon menu-item-icon handle">
|
||||
<icon icon="grip-lines"/>
|
||||
</span>
|
||||
<ColorBubble
|
||||
v-if="l.hexColor !== ''"
|
||||
:color="l.hexColor"
|
||||
class="mr-1"
|
||||
/>
|
||||
<span class="list-menu-title">{{ getListTitle(l) }}</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="favorite"
|
||||
:class="{'is-favorite': l.isFavorite}"
|
||||
@click="listStore.toggleListFavorite(l)"
|
||||
>
|
||||
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
|
||||
</BaseButton>
|
||||
<list-settings-dropdown class="menu-list-dropdown" :list="l" v-if="l.id > 0">
|
||||
<template #trigger="{toggleOpen}">
|
||||
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
|
||||
<icon icon="ellipsis-h" class="icon"/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
</list-settings-dropdown>
|
||||
<span class="list-setting-spacer" v-else></span>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
</nav>
|
||||
<Loading
|
||||
v-if="projectStore.isLoading"
|
||||
variant="small"
|
||||
/>
|
||||
<template v-else>
|
||||
<nav class="menu" v-if="favoriteProjects">
|
||||
<ProjectsNavigation
|
||||
:model-value="favoriteProjects"
|
||||
:can-edit-order="false"
|
||||
:can-collapse="false"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<nav class="menu" v-if="savedFilterProjects">
|
||||
<ProjectsNavigation
|
||||
:model-value="savedFilterProjects"
|
||||
:can-edit-order="false"
|
||||
:can-collapse="false"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<nav class="menu">
|
||||
<ProjectsNavigation
|
||||
:model-value="projects"
|
||||
:can-edit-order="true"
|
||||
:can-collapse="true"
|
||||
:level="1"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<PoweredByLink/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, onBeforeMount} from 'vue'
|
||||
import draggable from 'zhyswan-vuedraggable'
|
||||
import type {SortableEvent} from 'sortablejs'
|
||||
import {computed} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
|
||||
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
|
||||
import PoweredByLink from '@/components/home/PoweredByLink.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
|
||||
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
|
||||
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
|
||||
import {getListTitle} from '@/helpers/getListTitle'
|
||||
import {useEventListener} from '@vueuse/core'
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
import ColorBubble from '@/components/misc/colorBubble.vue'
|
||||
import Loading from '@/components/misc/loading.vue'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
import {useNamespaceStore} from '@/stores/namespaces'
|
||||
|
||||
const drag = ref(false)
|
||||
const dragOptions = {
|
||||
animation: 100,
|
||||
ghostClass: 'ghost',
|
||||
}
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const namespaceStore = useNamespaceStore()
|
||||
const currentList = computed(() => baseStore.currentList)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
const loading = computed(() => namespaceStore.isLoading)
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
|
||||
const namespaces = computed(() => {
|
||||
return namespaceStore.namespaces.filter(n => !n.isArchived)
|
||||
})
|
||||
const activeLists = computed(() => {
|
||||
return namespaces.value.map(({lists}) => {
|
||||
return lists?.filter(item => {
|
||||
return typeof item !== 'undefined' && !item.isArchived
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const namespaceTitles = computed(() => {
|
||||
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
|
||||
})
|
||||
|
||||
const namespaceListsCount = computed(() => {
|
||||
return namespaces.value.map((_, index) => activeLists.value[index]?.length ?? 0)
|
||||
})
|
||||
|
||||
|
||||
useEventListener('resize', resize)
|
||||
onMounted(() => resize())
|
||||
|
||||
const listStore = useListStore()
|
||||
|
||||
function resize() {
|
||||
// Hide the menu by default on mobile
|
||||
baseStore.setMenuActive(window.innerWidth >= 770)
|
||||
}
|
||||
|
||||
function toggleLists(namespaceId: INamespace['id']) {
|
||||
listsVisible.value[namespaceId] = !listsVisible.value[namespaceId]
|
||||
}
|
||||
|
||||
const listsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
|
||||
// FIXME: async action will be unfinished when component mounts
|
||||
onBeforeMount(async () => {
|
||||
const namespaces = await namespaceStore.loadNamespaces()
|
||||
namespaces.forEach(n => {
|
||||
if (typeof listsVisible.value[n.id] === 'undefined') {
|
||||
listsVisible.value[n.id] = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function updateActiveLists(namespace: INamespace, activeLists: IList[]) {
|
||||
// This is a bit hacky: since we do have to filter out the archived items from the list
|
||||
// for vue draggable updating it is not as simple as replacing it.
|
||||
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order
|
||||
// because now all archived lists are sorted after the active ones. This is fine because they are sorted
|
||||
// later when showing them anyway, and it makes the merging happening here a lot easier.
|
||||
const lists = [
|
||||
...activeLists,
|
||||
...namespace.lists.filter(l => l.isArchived),
|
||||
]
|
||||
|
||||
namespaceStore.setNamespaceById({
|
||||
...namespace,
|
||||
lists,
|
||||
})
|
||||
}
|
||||
|
||||
const listUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
|
||||
|
||||
async function saveListPosition(e: SortableEvent) {
|
||||
if (!e.newIndex && e.newIndex !== 0) return
|
||||
|
||||
const namespaceId = parseInt(e.to.dataset.namespaceId as string)
|
||||
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string)
|
||||
|
||||
const listsActive = activeLists.value[newNamespaceIndex]
|
||||
// If the list was dragged to the last position, Safari will report e.newIndex as the size of the listsActive
|
||||
// array instead of using the position. Because the index is wrong in that case, dragging the list will fail.
|
||||
// To work around that we're explicitly checking that case here and decrease the index.
|
||||
const newIndex = e.newIndex === listsActive.length ? e.newIndex - 1 : e.newIndex
|
||||
|
||||
const list = listsActive[newIndex]
|
||||
const listBefore = listsActive[newIndex - 1] ?? null
|
||||
const listAfter = listsActive[newIndex + 1] ?? null
|
||||
listUpdating.value[list.id] = true
|
||||
|
||||
const position = calculateItemPosition(
|
||||
listBefore !== null ? listBefore.position : null,
|
||||
listAfter !== null ? listAfter.position : null,
|
||||
)
|
||||
|
||||
try {
|
||||
// create a copy of the list in order to not violate pinia manipulation
|
||||
await listStore.updateList({
|
||||
...list,
|
||||
position,
|
||||
namespaceId,
|
||||
})
|
||||
} finally {
|
||||
listUpdating.value[list.id] = false
|
||||
}
|
||||
}
|
||||
const projects = computed(() => projectStore.notArchivedRootProjects)
|
||||
const favoriteProjects = computed(() => projectStore.favoriteProjects)
|
||||
const savedFilterProjects = computed(() => projectStore.savedFilterProjects)
|
||||
</script>
|
||||
|
||||
<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;
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
|
||||
|
@ -298,10 +115,10 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
}
|
||||
}
|
||||
|
||||
.namespace-container {
|
||||
background: $vikunja-nav-background;
|
||||
.menu-container {
|
||||
background: var(--site-background);
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 0 1rem;
|
||||
padding: 1rem 0;
|
||||
transition: transform $transition-duration ease-in;
|
||||
position: fixed;
|
||||
top: $navbar-height;
|
||||
|
@ -323,235 +140,24 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
}
|
||||
}
|
||||
|
||||
// these are general menu styles
|
||||
// should be in own components
|
||||
.menu {
|
||||
.menu-label,
|
||||
.menu-list .list-menu-link,
|
||||
.menu-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
|
||||
.color-bubble {
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.menu-list-dropdown {
|
||||
opacity: 0;
|
||||
transition: $transition;
|
||||
}
|
||||
|
||||
&:hover .menu-list-dropdown {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item-icon {
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
.menu-list-dropdown-trigger {
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform $transition-duration;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: var(--grey-200);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
color: $vikunja-nav-color;
|
||||
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;
|
||||
|
||||
&:hover {
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: var(--primary);
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
vertical-align: middle;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
&.router-link-exact-active .icon:not(.handle) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.handle {
|
||||
opacity: 0;
|
||||
transition: opacity $transition;
|
||||
margin-right: .25rem;
|
||||
cursor: grab;
|
||||
}
|
||||
&:hover .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-menu {
|
||||
margin-top: math.div($navbar-padding, 2);
|
||||
|
||||
.menu-list {
|
||||
li {
|
||||
font-weight: 600;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.namespaces-lists {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
|
||||
.menu-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-weight: bold;
|
||||
font-family: $vikunja-font;
|
||||
color: $vikunja-nav-color;
|
||||
.top-menu .menu-list {
|
||||
li {
|
||||
font-weight: 600;
|
||||
min-height: 2.5rem;
|
||||
padding-top: 0;
|
||||
padding-left: $navbar-padding;
|
||||
font-family: $vikunja-font;
|
||||
}
|
||||
|
||||
overflow: hidden;
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 auto;
|
||||
.list-menu-link,
|
||||
li > a {
|
||||
padding-left: 2rem;
|
||||
display: inline-block;
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: auto;
|
||||
.icon {
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--grey-500);
|
||||
margin-right: .5rem;
|
||||
// align brackets with number
|
||||
font-feature-settings: "case";
|
||||
}
|
||||
}
|
||||
|
||||
.favorite {
|
||||
margin-left: .25rem;
|
||||
transition: opacity $transition, color $transition;
|
||||
opacity: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-favorite {
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
.favorite.is-favorite,
|
||||
.list-menu:hover .favorite {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-menu-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-bubble {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.is-archived {
|
||||
min-width: 85px;
|
||||
}
|
||||
}
|
||||
|
||||
.namespace-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: $vikunja-nav-color;
|
||||
padding: 0 .25rem;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.list-setting-spacer {
|
||||
width: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.namespaces-list.loader-container.is-loading {
|
||||
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
|
||||
.menu + .menu {
|
||||
padding-top: math.div($navbar-padding, 2);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {createAsyncComponent} from '@/helpers/createAsyncComponent'
|
||||
|
||||
export default createAsyncComponent(() => import('@/components/input/editor.vue'))
|
||||
const TipTap = createAsyncComponent(() => import('@/components/input/editor/TipTap.vue'))
|
||||
|
||||
export default TipTap
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, toRef, watch} from 'vue'
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import XButton from '@/components/input/button.vue'
|
||||
|
||||
|
@ -53,22 +53,16 @@ const lastChangeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
|||
const defaultColors = ref(DEFAULT_COLORS)
|
||||
const colorListID = ref(createRandomID())
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
menuPosition: {
|
||||
type: String,
|
||||
default: 'top',
|
||||
},
|
||||
})
|
||||
const {
|
||||
modelValue,
|
||||
} = defineProps<{
|
||||
modelValue: string,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const modelValue = toRef(props, 'modelValue')
|
||||
watch(
|
||||
modelValue,
|
||||
() => modelValue,
|
||||
(newValue) => {
|
||||
color.value = newValue
|
||||
},
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
<template>
|
||||
<multiselect
|
||||
v-model="selectedLists"
|
||||
:search-results="foundLists"
|
||||
:loading="listService.loading"
|
||||
:multiple="true"
|
||||
:placeholder="$t('list.search')"
|
||||
label="title"
|
||||
@search="findLists"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import type {IList} from '@/modelTypes/IList'
|
||||
|
||||
import ListService from '@/services/list'
|
||||
import {includesById} from '@/helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<IList[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: IList[]): void
|
||||
}>()
|
||||
|
||||
const lists = ref<IList[]>([])
|
||||
|
||||
watchEffect(() => {
|
||||
lists.value = props.modelValue
|
||||
})
|
||||
|
||||
const selectedLists = computed({
|
||||
get() {
|
||||
return lists.value
|
||||
},
|
||||
set: (value) => {
|
||||
lists.value = value
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const listService = shallowReactive(new ListService())
|
||||
const foundLists = ref<IList[]>([])
|
||||
|
||||
async function findLists(query: string) {
|
||||
if (query === '') {
|
||||
foundLists.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await listService.getAll({}, {s: query}) as IList[]
|
||||
|
||||
// Filter selected items from the results
|
||||
foundLists.value = response.filter(({id}) => !includesById(lists.value, id))
|
||||
}
|
||||
</script>
|
|
@ -1,63 +0,0 @@
|
|||
<template>
|
||||
<multiselect
|
||||
v-model="selectedNamespaces"
|
||||
:search-results="foundNamespaces"
|
||||
:loading="namespaceService.loading"
|
||||
:multiple="true"
|
||||
:placeholder="$t('namespace.search')"
|
||||
label="namespace"
|
||||
@search="findNamespaces"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import type {INamespace} from '@/modelTypes/INamespace'
|
||||
|
||||
import NamespaceService from '@/services/namespace'
|
||||
import {includesById} from '@/helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<INamespace[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: INamespace[]): void
|
||||
}>()
|
||||
|
||||
const namespaces = ref<INamespace[]>([])
|
||||
|
||||
watchEffect(() => {
|
||||
namespaces.value = props.modelValue
|
||||
})
|
||||
|
||||
const selectedNamespaces = computed({
|
||||
get() {
|
||||
return namespaces.value
|
||||
},
|
||||
set: (value) => {
|
||||
namespaces.value = value
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const namespaceService = shallowReactive(new NamespaceService())
|
||||
const foundNamespaces = ref<INamespace[]>([])
|
||||
|
||||
async function findNamespaces(query: string) {
|
||||
if (query === '') {
|
||||
foundNamespaces.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await namespaceService.getAll({}, {s: query}) as INamespace[]
|
||||
|
||||
// Filter selected items from the results
|
||||
foundNamespaces.value = response.filter(({id}) => !includesById(namespaces.value, id))
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<multiselect
|
||||
v-model="selectedProjects"
|
||||
:search-results="foundProjects"
|
||||
:loading="projectService.loading"
|
||||
:multiple="true"
|
||||
:placeholder="$t('project.search')"
|
||||
label="title"
|
||||
@search="findProjects"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
|
||||
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
import ProjectService from '@/services/project'
|
||||
import {includesById} from '@/helpers/utils'
|
||||
|
||||
type ProjectFilterFunc = (p: IProject) => boolean
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<IProject[]>,
|
||||
default: () => [],
|
||||
},
|
||||
projectFilter: {
|
||||
type: Function as PropType<ProjectFilterFunc>,
|
||||
default: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
return (_: IProject) => true
|
||||
},
|
||||
},
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: IProject[]): void
|
||||
}>()
|
||||
|
||||
const projects = ref<IProject[]>([])
|
||||
|
||||
watchEffect(() => {
|
||||
projects.value = props.modelValue
|
||||
})
|
||||
|
||||
const selectedProjects = computed({
|
||||
get() {
|
||||
return projects.value
|
||||
},
|
||||
set: (value) => {
|
||||
projects.value = value
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const projectService = shallowReactive(new ProjectService())
|
||||
const foundProjects = ref<IProject[]>([])
|
||||
|
||||
async function findProjects(query: string) {
|
||||
if (query === '') {
|
||||
foundProjects.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const response = await projectService.getAll({}, {s: query}) as IProject[]
|
||||
|
||||
// Filter selected items from the results
|
||||
foundProjects.value = response
|
||||
.filter(({id}) => !includesById(projects.value, id))
|
||||
.filter(props.projectFilter)
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,26 @@
|
|||
<template>
|
||||
<BaseButton class="simple-button">
|
||||
<slot/>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.simple-button {
|
||||
color: var(--text);
|
||||
padding: .25rem .5rem;
|
||||
transition: background-color $transition;
|
||||
border-radius: $radius;
|
||||
display: block;
|
||||
margin: .1rem 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--white);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -47,7 +47,7 @@ import BaseButton, {type BaseButtonProps} from '@/components/base/BaseButton.vue
|
|||
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
// extending the props of the BaseButton
|
||||
export interface ButtonProps extends BaseButtonProps {
|
||||
export interface ButtonProps extends /* @vue-ignore */ BaseButtonProps {
|
||||
variant?: ButtonTypes
|
||||
icon?: IconProp
|
||||
iconColor?: string
|
||||
|
@ -72,7 +72,7 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:where(.button) {
|
||||
.button {
|
||||
transition: all $transition;
|
||||
border: 0;
|
||||
text-transform: uppercase;
|
||||
|
|
|
@ -1,78 +1,15 @@
|
|||
<template>
|
||||
<div class="datepicker">
|
||||
<BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
|
||||
<SimpleButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
|
||||
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
|
||||
</BaseButton>
|
||||
</SimpleButton>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
|
||||
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'sun']"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><icon icon="coffee"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><icon icon="cocktail"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="chess-knight"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="forward"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<flat-pickr
|
||||
:config="flatPickerConfig"
|
||||
class="input"
|
||||
v-model="flatPickrDate"
|
||||
<DatepickerInline
|
||||
v-model="date"
|
||||
@update:model-value="updateData"
|
||||
/>
|
||||
|
||||
<x-button
|
||||
|
@ -89,19 +26,15 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted, onBeforeUnmount, toRef, watch, computed, type PropType} from 'vue'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import {ref, onMounted, onBeforeUnmount, toRef, watch, type PropType} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
import DatepickerInline from '@/components/input/datepickerInline.vue'
|
||||
import SimpleButton from '@/components/input/SimpleButton.vue'
|
||||
|
||||
import {formatDate, formatDateShort} from '@/helpers/time/formatDate'
|
||||
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
|
||||
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
|
||||
import {formatDateShort} from '@/helpers/time/formatDate'
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
|
@ -125,8 +58,6 @@ const props = defineProps({
|
|||
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'close-on-change'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const date = ref<Date | null>()
|
||||
const show = ref(false)
|
||||
const changed = ref(false)
|
||||
|
@ -141,37 +72,6 @@ watch(
|
|||
{immediate: true},
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const weekStart = computed(() => authStore.settings.weekStart)
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
inline: true,
|
||||
locale: {
|
||||
firstDayOfWeek: weekStart.value,
|
||||
},
|
||||
}))
|
||||
|
||||
// Since flatpickr dates are strings, we need to convert them to native date objects.
|
||||
// To make that work, we need a separate variable since flatpickr does not have a change event.
|
||||
const flatPickrDate = computed({
|
||||
set(newValue: string | Date) {
|
||||
date.value = createDateFromString(newValue)
|
||||
updateData()
|
||||
},
|
||||
get() {
|
||||
if (!date.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatDate(date.value, 'yyy-LL-dd H:mm')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
function setDateValue(dateString: string | Date | null) {
|
||||
if (dateString === null) {
|
||||
date.value = null
|
||||
|
@ -212,29 +112,6 @@ function close() {
|
|||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function setDate(dateString: string) {
|
||||
if (date.value === null) {
|
||||
date.value = new Date()
|
||||
}
|
||||
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
newDate.setHours(calculateNearestHours(newDate))
|
||||
newDate.setMinutes(0)
|
||||
newDate.setSeconds(0)
|
||||
date.value = newDate
|
||||
flatPickrDate.value = newDate
|
||||
updateData()
|
||||
}
|
||||
|
||||
function getWeekdayFromStringInterval(dateString: string) {
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
return formatDate(newDate, 'E')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -257,42 +134,6 @@ function getWeekdayFromStringInterval(dateString: string) {
|
|||
}
|
||||
}
|
||||
|
||||
.datepicker__quick-select-date {
|
||||
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(--grey-100);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker__close-button {
|
||||
margin: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
|
|
|
@ -0,0 +1,228 @@
|
|||
<template>
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'calendar-alt']"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><icon :icon="['far', 'sun']"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><icon icon="coffee"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><icon icon="cocktail"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="chess-knight"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><icon icon="forward"/></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<div class="flatpickr-container">
|
||||
<flat-pickr
|
||||
:config="flatPickerConfig"
|
||||
v-model="flatPickrDate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, toRef, watch, computed, type PropType} from 'vue'
|
||||
import flatPickr from 'vue-flatpickr-component'
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
|
||||
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
|
||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Date, null, String] as PropType<Date | null | string>,
|
||||
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close-on-change'])
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
|
||||
const date = ref<Date | null>()
|
||||
const changed = ref(false)
|
||||
|
||||
const modelValue = toRef(props, 'modelValue')
|
||||
watch(
|
||||
modelValue,
|
||||
setDateValue,
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const weekStart = computed(() => authStore.settings.weekStart)
|
||||
const flatPickerConfig = computed(() => ({
|
||||
altFormat: t('date.altFormatLong'),
|
||||
altInput: true,
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
inline: true,
|
||||
locale: {
|
||||
firstDayOfWeek: weekStart.value,
|
||||
},
|
||||
}))
|
||||
|
||||
// Since flatpickr dates are strings, we need to convert them to native date objects.
|
||||
// To make that work, we need a separate variable since flatpickr does not have a change event.
|
||||
const flatPickrDate = computed({
|
||||
set(newValue: string | Date | null) {
|
||||
if (newValue === null) {
|
||||
date.value = null
|
||||
return
|
||||
}
|
||||
|
||||
date.value = createDateFromString(newValue)
|
||||
updateData()
|
||||
},
|
||||
get() {
|
||||
if (!date.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatDate(date.value, 'yyy-LL-dd H:mm')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
function setDateValue(dateString: string | Date | null) {
|
||||
if (dateString === null) {
|
||||
date.value = null
|
||||
return
|
||||
}
|
||||
date.value = createDateFromString(dateString)
|
||||
}
|
||||
|
||||
function updateData() {
|
||||
changed.value = true
|
||||
emit('update:modelValue', date.value)
|
||||
}
|
||||
|
||||
function setDate(dateString: string) {
|
||||
if (date.value === null) {
|
||||
date.value = new Date()
|
||||
}
|
||||
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
newDate.setHours(calculateNearestHours(newDate))
|
||||
newDate.setMinutes(0)
|
||||
newDate.setSeconds(0)
|
||||
date.value = newDate
|
||||
flatPickrDate.value = newDate
|
||||
updateData()
|
||||
}
|
||||
|
||||
function getWeekdayFromStringInterval(dateString: string) {
|
||||
const interval = calculateDayInterval(dateString)
|
||||
const newDate = new Date()
|
||||
newDate.setDate(newDate.getDate() + interval)
|
||||
return formatDate(newDate, 'E')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.datepicker__quick-select-date {
|
||||
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(--grey-100);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.flatpickr-container {
|
||||
:deep(.flatpickr-calendar) {
|
||||
margin: 0 auto 8px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.input) {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,446 +0,0 @@
|
|||
<template>
|
||||
<div class="editor">
|
||||
<div class="clear"></div>
|
||||
|
||||
<vue-easymde
|
||||
:configs="config"
|
||||
@change="() => bubble()"
|
||||
@update:modelValue="handleInput"
|
||||
class="content"
|
||||
v-if="isEditActive"
|
||||
v-model="text"/>
|
||||
|
||||
<div class="preview content" v-html="preview" v-if="isPreviewActive && text !== ''">
|
||||
</div>
|
||||
|
||||
<p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText">
|
||||
{{ emptyText }}
|
||||
<template v-if="isEditEnabled">
|
||||
<ButtonLink
|
||||
@click="toggleEdit"
|
||||
v-shortcut="editShortcut"
|
||||
class="d-print-none">
|
||||
{{ $t('input.editor.edit') }}
|
||||
</ButtonLink>.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<ul class="actions d-print-none" v-if="bottomActions.length > 0">
|
||||
<li v-if="isEditEnabled && !showPreviewText && showSave">
|
||||
<BaseButton
|
||||
v-if="showEditButton"
|
||||
@click="toggleEdit"
|
||||
v-shortcut="editShortcut">
|
||||
{{ $t('input.editor.edit') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-else-if="isEditActive"
|
||||
@click="toggleEdit"
|
||||
class="done-edit">
|
||||
{{ $t('misc.save') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
<li v-for="(action, k) in bottomActions" :key="k">
|
||||
<BaseButton @click="action.action">{{ action.title }}</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<template v-else-if="isEditEnabled && showSave">
|
||||
<ul v-if="showEditButton" class="actions d-print-none">
|
||||
<li>
|
||||
<BaseButton
|
||||
@click="toggleEdit"
|
||||
v-shortcut="editShortcut">
|
||||
{{ $t('input.editor.edit') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<x-button
|
||||
v-else-if="isEditActive"
|
||||
@click="toggleEdit"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
v-cy="'saveEditor'">
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onMounted, ref, toRefs, watch} from 'vue'
|
||||
|
||||
import VueEasymde from './vue-easymde.vue'
|
||||
import {marked} from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import {createEasyMDEConfig} from './editorConfig'
|
||||
|
||||
import AttachmentModel from '@/models/attachment'
|
||||
import AttachmentService from '@/services/attachment'
|
||||
|
||||
import {setupMarkdownRenderer} from '@/helpers/markdownRenderer'
|
||||
import {findCheckboxesInText} from '@/helpers/checklistFromText'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import ButtonLink from '@/components/misc/ButtonLink.vue'
|
||||
import type { IAttachment } from '@/modelTypes/IAttachment'
|
||||
import type { ITask } from '@/modelTypes/ITask'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
uploadEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
uploadCallback: {
|
||||
type: Function,
|
||||
},
|
||||
hasPreview: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
previewIsDefault: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isEditEnabled: {
|
||||
default: true,
|
||||
},
|
||||
bottomActions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showSave: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// If a key is passed the editor will go in "edit" mode when the key is pressed.
|
||||
// Disabled if an empty string is passed.
|
||||
editShortcut: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const text = ref('')
|
||||
const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isEditActive = ref(false)
|
||||
const isPreviewActive = ref(true)
|
||||
|
||||
const showPreviewText = computed(() => isPreviewActive.value && text.value === '' && props.emptyText !== '')
|
||||
const showEditButton = computed(() => !isEditActive.value && text.value !== '')
|
||||
|
||||
const preview = ref('')
|
||||
const attachmentService = new AttachmentService()
|
||||
|
||||
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
|
||||
const loadedAttachments = ref<{[key: CacheKey]: string}>({})
|
||||
const config = ref(createEasyMDEConfig({
|
||||
placeholder: props.placeholder,
|
||||
uploadImage: props.uploadEnabled,
|
||||
imageUploadFunction: props.uploadCallback,
|
||||
}))
|
||||
|
||||
const checkboxId = ref(createRandomID())
|
||||
|
||||
const {modelValue} = toRefs(props)
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
async (value) => {
|
||||
text.value = value
|
||||
await nextTick()
|
||||
renderPreview()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
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 === '' && text.value === modelValue.value) {
|
||||
return
|
||||
}
|
||||
bubble()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
if (modelValue.value !== '') {
|
||||
text.value = modelValue.value
|
||||
}
|
||||
|
||||
if (props.previewIsDefault && props.hasPreview) {
|
||||
nextTick(() => renderPreview())
|
||||
return
|
||||
}
|
||||
|
||||
isPreviewActive.value = false
|
||||
isEditActive.value = true
|
||||
})
|
||||
|
||||
|
||||
// This gets triggered when only pasting content into the editor.
|
||||
// A change event would not get generated by that, an input event does.
|
||||
// Therefore, we're using this handler to catch paste events.
|
||||
// But because this also gets triggered when typing into the editor, we give
|
||||
// it a higher timeout to make the timouts cancel each other in that case so
|
||||
// that in the end, only one change event is triggered to the outside per change.
|
||||
function handleInput(val: string) {
|
||||
// Don't bubble if the text is up to date
|
||||
if (val === text.value) {
|
||||
return
|
||||
}
|
||||
|
||||
text.value = val
|
||||
bubble(1000)
|
||||
}
|
||||
|
||||
function bubble(timeout = 500) {
|
||||
if (changeTimeout.value !== null) {
|
||||
clearTimeout(changeTimeout.value)
|
||||
}
|
||||
|
||||
changeTimeout.value = setTimeout(() => {
|
||||
emit('update:modelValue', text.value)
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
function replaceAt(str: string, index: number, replacement: string) {
|
||||
return str.slice(0, index) + replacement + str.slice(index + replacement.length)
|
||||
}
|
||||
|
||||
function findNthIndex(str: string, n: number) {
|
||||
const checkboxes = findCheckboxesInText(str)
|
||||
return checkboxes[n]
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
setupMarkdownRenderer(checkboxId.value)
|
||||
|
||||
preview.value = DOMPurify.sanitize(marked(text.value), {ADD_ATTR: ['target']})
|
||||
|
||||
// Since the render function is synchronous, we can't do async http requests in it.
|
||||
// Therefore, we can't resolve the blob url at (markdown) compile time.
|
||||
// To work around this, we modify the url after rendering it in the vue component.
|
||||
// We're doing the whole thing in the next tick to ensure the image elements are available in the
|
||||
// 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
|
||||
nextTick().then(async () => {
|
||||
const attachmentImage = document.querySelectorAll<HTMLImageElement>('.attachment-image')
|
||||
if (attachmentImage) {
|
||||
Array.from(attachmentImage).forEach(async (img) => {
|
||||
// The url is something like /tasks/<id>/attachments/<id>
|
||||
const parts = img.dataset.src?.slice(window.API_URL.length + 1).split('/')
|
||||
const taskId = Number(parts[1])
|
||||
const attachmentId = Number(parts[3])
|
||||
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
|
||||
|
||||
if (typeof loadedAttachments.value[cacheKey] !== 'undefined') {
|
||||
img.src = loadedAttachments.value[cacheKey]
|
||||
return
|
||||
}
|
||||
|
||||
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
|
||||
|
||||
const url = await attachmentService.getBlobUrl(attachment)
|
||||
img.src = url
|
||||
loadedAttachments.value[cacheKey] = url
|
||||
})
|
||||
}
|
||||
|
||||
const textCheckbox = document.querySelectorAll<HTMLInputElement>(`.text-checkbox-${checkboxId.value}`)
|
||||
if (textCheckbox) {
|
||||
Array.from(textCheckbox).forEach(check => {
|
||||
check.removeEventListener('change', handleCheckboxClick)
|
||||
check.addEventListener('change', handleCheckboxClick)
|
||||
check.parentElement?.classList.add('has-checkbox')
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleCheckboxClick(e: Event) {
|
||||
// Find the original markdown checkbox this is targeting
|
||||
const checked = (e.target as HTMLInputElement).checked
|
||||
const numMarkdownCheck = Number((e.target as HTMLInputElement).dataset.checkboxNum)
|
||||
|
||||
const index = findNthIndex(text.value, numMarkdownCheck)
|
||||
if (index < 0 || typeof index === 'undefined') {
|
||||
console.debug('no index found')
|
||||
return
|
||||
}
|
||||
const listPrefix = text.value.substring(index, index + 1)
|
||||
|
||||
console.debug({index, listPrefix, checked, text: text.value})
|
||||
|
||||
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
|
||||
bubble()
|
||||
renderPreview()
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (isEditActive.value) {
|
||||
isPreviewActive.value = true
|
||||
isEditActive.value = false
|
||||
renderPreview()
|
||||
bubble(0) // save instantly
|
||||
} else {
|
||||
isPreviewActive.value = false
|
||||
isEditActive.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'codemirror/lib/codemirror.css';
|
||||
@import 'highlight.js/scss/base16/equilibrium-gray-light';
|
||||
|
||||
.editor {
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.preview.content {
|
||||
margin-bottom: .5rem;
|
||||
overflow-wrap: anywhere; // Safari does not understand "break-word" so we put that first to make sure it at least is able to show it somewhat properly there.
|
||||
overflow-wrap: break-word;
|
||||
|
||||
ul li {
|
||||
input[type="checkbox"] {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
&.has-checkbox {
|
||||
margin-left: -1.25rem;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
padding: .5rem;
|
||||
border: 1px solid var(--grey-200) !important;
|
||||
background: var(--white);
|
||||
|
||||
&-lines pre {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
&-placeholder {
|
||||
color: var(--grey-400) !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&-cursor {
|
||||
border-color: var(--grey-700);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
padding: 0;
|
||||
|
||||
&-side {
|
||||
padding: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
background: var(--grey-50);
|
||||
border: 1px solid var(--grey-200);
|
||||
border-bottom: none;
|
||||
|
||||
button {
|
||||
color: var(--grey-700);
|
||||
|
||||
&.active {
|
||||
background: var(--grey-200);
|
||||
}
|
||||
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
|
||||
&, rect {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-200);
|
||||
border-color: var(--grey-300);
|
||||
}
|
||||
}
|
||||
|
||||
i.separator {
|
||||
border-color: var(--grey-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
pre.CodeMirror-line {
|
||||
margin-bottom: 0 !important;
|
||||
color: var(--grey-700) !important;
|
||||
}
|
||||
|
||||
.cm-header {
|
||||
font-family: $vikunja-font;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
ul.actions {
|
||||
font-size: .8rem;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
|
||||
&::after {
|
||||
content: '·';
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&, a {
|
||||
color: var(--grey-500);
|
||||
|
||||
&.done-edit {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-easymde.content {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,145 @@
|
|||
<template>
|
||||
<div class="items">
|
||||
<template v-if="items.length">
|
||||
<button
|
||||
class="item"
|
||||
:class="{ 'is-selected': index === selectedIndex }"
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
@click="selectItem(index)"
|
||||
>
|
||||
<icon :icon="item.icon"/>
|
||||
<div class="description">
|
||||
<p>{{ item.title }}</p>
|
||||
<p>{{ item.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<div class="item" v-else>
|
||||
No result
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
command: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: 0,
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
items() {
|
||||
this.selectedIndex = 0
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onKeyDown({event}) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
this.upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
this.downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
this.enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
upHandler() {
|
||||
this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length
|
||||
},
|
||||
|
||||
downHandler() {
|
||||
this.selectedIndex = (this.selectedIndex + 1) % this.items.length
|
||||
},
|
||||
|
||||
enterHandler() {
|
||||
this.selectItem(this.selectedIndex)
|
||||
},
|
||||
|
||||
selectItem(index) {
|
||||
const item = this.items[index]
|
||||
|
||||
if (item) {
|
||||
this.command(item)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.items {
|
||||
padding: 0.2rem;
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--white);
|
||||
color: var(--grey-900);
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-radius: $radius;
|
||||
border: 0;
|
||||
padding: 0.2rem 0.4rem;
|
||||
transition: background-color $transition;
|
||||
|
||||
&.is-selected, &:hover {
|
||||
background: var(--grey-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> svg {
|
||||
box-sizing: border-box;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--grey-300);
|
||||
padding: .5rem;
|
||||
margin-right: .5rem;
|
||||
border-radius: $radius;
|
||||
color: var(--grey-700);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: .9rem;
|
||||
color: var(--grey-800);
|
||||
|
||||
p:last-child {
|
||||
font-size: .75rem;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,439 @@
|
|||
<template>
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||||
v-tooltip="$t('input.editor.heading1')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-header']"/>
|
||||
<span class="icon__lower-text">1</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||||
v-tooltip="$t('input.editor.heading2')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-header']"/>
|
||||
<span class="icon__lower-text">2</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||||
v-tooltip="$t('input.editor.heading3')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-header']"/>
|
||||
<span class="icon__lower-text">3</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
v-tooltip="$t('input.editor.bold')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-bold']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
:class="{ 'is-active': editor.isActive('italic') }"
|
||||
v-tooltip="$t('input.editor.italic')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-italic']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleUnderline().run()"
|
||||
:class="{ 'is-active': editor.isActive('underline') }"
|
||||
v-tooltip="$t('input.editor.underline')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-underline']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
:class="{ 'is-active': editor.isActive('strike') }"
|
||||
v-tooltip="$t('input.editor.strikethrough')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-strikethrough']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleCodeBlock().run()"
|
||||
:class="{ 'is-active': editor.isActive('codeBlock') }"
|
||||
v-tooltip="$t('input.editor.code')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-code']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||||
v-tooltip="$t('input.editor.quote')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-quote-right']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleBulletList().run()"
|
||||
:class="{ 'is-active': editor.isActive('bulletList') }"
|
||||
v-tooltip="$t('input.editor.bulletList')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-list-ol']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleOrderedList().run()"
|
||||
:class="{ 'is-active': editor.isActive('orderedList') }"
|
||||
v-tooltip="$t('input.editor.orderedList')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-list-ul']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleTaskList().run()"
|
||||
:class="{ 'is-active': editor.isActive('taskList') }"
|
||||
v-tooltip="$t('input.editor.taskList')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon icon="fa-list-check"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="openImagePicker"
|
||||
v-tooltip="$t('input.editor.image')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon icon="fa-image"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="setLink"
|
||||
:class="{ 'is-active': editor.isActive('link') }"
|
||||
title="set link"
|
||||
v-tooltip="$t('input.editor.link')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-link']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().setParagraph().run()"
|
||||
:class="{ 'is-active': editor.isActive('paragraph') }"
|
||||
title="paragraph"
|
||||
v-tooltip="$t('input.editor.text')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-paragraph']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().setHorizontalRule().run()"
|
||||
v-tooltip="$t('input.editor.horizontalRule')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-ruler-horizontal']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().undo().run()"
|
||||
v-tooltip="$t('input.editor.undo')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-undo']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().redo().run()"
|
||||
v-tooltip="$t('input.editor.redo')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-redo']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="editor-toolbar__segment">
|
||||
<!-- table -->
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="toggleTableMode"
|
||||
:class="{ 'is-active': editor.isActive('table') }"
|
||||
v-tooltip="$t('input.editor.table.title')"
|
||||
>
|
||||
<span class="icon">
|
||||
<icon :icon="['fa', 'fa-table']"/>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<div v-if="tableMode" class="editor-toolbar__table-buttons">
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run()
|
||||
"
|
||||
>
|
||||
{{ $t('input.editor.table.insert') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().addColumnBefore().run()"
|
||||
:disabled="!editor.can().addColumnBefore"
|
||||
>
|
||||
{{ $t('input.editor.table.addColumnBefore') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().addColumnAfter().run()"
|
||||
:disabled="!editor.can().addColumnAfter"
|
||||
>
|
||||
{{ $t('input.editor.table.addColumnAfter') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().deleteColumn().run()"
|
||||
:disabled="!editor.can().deleteColumn"
|
||||
>
|
||||
{{ $t('input.editor.table.deleteColumn') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().addRowBefore().run()"
|
||||
:disabled="!editor.can().addRowBefore"
|
||||
>
|
||||
{{ $t('input.editor.table.addRowBefore') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().addRowAfter().run()"
|
||||
:disabled="!editor.can().addRowAfter"
|
||||
>
|
||||
{{ $t('input.editor.table.addRowAfter') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().deleteRow().run()"
|
||||
:disabled="!editor.can().deleteRow"
|
||||
>
|
||||
{{ $t('input.editor.table.deleteRow') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().deleteTable().run()"
|
||||
:disabled="!editor.can().deleteTable"
|
||||
>
|
||||
{{ $t('input.editor.table.deleteTable') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().mergeCells().run()"
|
||||
:disabled="!editor.can().mergeCells"
|
||||
>
|
||||
{{ $t('input.editor.table.mergeCells') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().splitCell().run()"
|
||||
:disabled="!editor.can().splitCell"
|
||||
>
|
||||
{{ $t('input.editor.table.splitCell') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeaderColumn().run()"
|
||||
:disabled="!editor.can().toggleHeaderColumn"
|
||||
>
|
||||
{{ $t('input.editor.table.toggleHeaderColumn') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeaderRow().run()"
|
||||
:disabled="!editor.can().toggleHeaderRow"
|
||||
>
|
||||
{{ $t('input.editor.table.toggleHeaderRow') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().toggleHeaderCell().run()"
|
||||
:disabled="!editor.can().toggleHeaderCell"
|
||||
>
|
||||
{{ $t('input.editor.table.toggleHeaderCell') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().mergeOrSplit().run()"
|
||||
:disabled="!editor.can().mergeOrSplit"
|
||||
>
|
||||
{{ $t('input.editor.table.mergeOrSplit') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-toolbar__button"
|
||||
@click="editor.chain().focus().fixTables().run()"
|
||||
:disabled="!editor.can().fixTables"
|
||||
>
|
||||
{{ $t('input.editor.table.fixTables') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
import {Editor} from '@tiptap/vue-3'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const {
|
||||
editor = null,
|
||||
} = defineProps<{
|
||||
editor: Editor,
|
||||
}>()
|
||||
|
||||
const tableMode = ref(false)
|
||||
|
||||
function toggleTableMode() {
|
||||
tableMode.value = !tableMode.value
|
||||
}
|
||||
|
||||
function openImagePicker() {
|
||||
document.getElementById('tiptap__image-upload').click()
|
||||
}
|
||||
|
||||
function setLink() {
|
||||
const previousUrl = editor.getAttributes('link').href
|
||||
const url = window.prompt('URL', previousUrl)
|
||||
|
||||
// cancelled
|
||||
if (url === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// update link
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({href: url, target: '_blank'})
|
||||
.run()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.editor-toolbar {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--grey-200);
|
||||
user-select: none;
|
||||
padding: .5rem;
|
||||
border-radius: $radius;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> * + * {
|
||||
border-left: 1px solid var(--grey-200);
|
||||
margin-left: 6px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolbar__button {
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: $radius;
|
||||
border: 1px solid transparent;
|
||||
color: var(--grey-700);
|
||||
transition: all $transition;
|
||||
background: transparent;
|
||||
margin-right: .25rem;
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-100);
|
||||
border-color: var(--grey-200);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
|
||||
.icon__lower-text {
|
||||
font-size: .75rem;
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
right: -2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-toolbar__table-buttons {
|
||||
margin-top: .5rem;
|
||||
|
||||
> .editor-toolbar__button {
|
||||
margin-right: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
padding: 0 .25rem;
|
||||
border: 1px solid var(--grey-400);
|
||||
font-size: .75rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,842 @@
|
|||
<template>
|
||||
<div class="tiptap">
|
||||
<EditorToolbar
|
||||
v-if="editor && isEditing"
|
||||
:editor="editor"
|
||||
:upload-callback="uploadCallback"
|
||||
/>
|
||||
<BubbleMenu
|
||||
v-if="editor && isEditing"
|
||||
:editor="editor"
|
||||
class="editor-bubble__wrapper"
|
||||
>
|
||||
<BaseButton
|
||||
class="editor-bubble__button"
|
||||
@click="editor.chain().focus().toggleBold().run()"
|
||||
:class="{ 'is-active': editor.isActive('bold') }"
|
||||
v-tooltip="$t('input.editor.bold')"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-bold']"/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-bubble__button"
|
||||
@click="editor.chain().focus().toggleItalic().run()"
|
||||
:class="{ 'is-active': editor.isActive('italic') }"
|
||||
v-tooltip="$t('input.editor.italic')"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-italic']"/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-bubble__button"
|
||||
@click="editor.chain().focus().toggleUnderline().run()"
|
||||
:class="{ 'is-active': editor.isActive('underline') }"
|
||||
v-tooltip="$t('input.editor.underline')"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-underline']"/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-bubble__button"
|
||||
@click="editor.chain().focus().toggleStrike().run()"
|
||||
:class="{ 'is-active': editor.isActive('strike') }"
|
||||
v-tooltip="$t('input.editor.strikethrough')"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-strikethrough']"/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-bubble__button"
|
||||
@click="editor.chain().focus().toggleCode().run()"
|
||||
:class="{ 'is-active': editor.isActive('code') }"
|
||||
v-tooltip="$t('input.editor.code')"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-code']"/>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="editor-bubble__button"
|
||||
@click="setLink"
|
||||
:class="{ 'is-active': editor.isActive('link') }"
|
||||
v-tooltip="$t('input.editor.link')"
|
||||
>
|
||||
<icon :icon="['fa', 'fa-link']"/>
|
||||
</BaseButton>
|
||||
</BubbleMenu>
|
||||
|
||||
<editor-content
|
||||
class="tiptap__editor"
|
||||
:class="{'tiptap__editor-is-empty': isEmpty, 'tiptap__editor-is-edit-enabled': isEditing}"
|
||||
:editor="editor"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-if="isEditing"
|
||||
type="file"
|
||||
id="tiptap__image-upload"
|
||||
class="is-hidden"
|
||||
ref="uploadInputRef"
|
||||
@change="addImage"
|
||||
/>
|
||||
|
||||
<ul class="tiptap__editor-actions d-print-none" v-if="bottomActions.length === 0 && !isEditing">
|
||||
<li>
|
||||
<BaseButton
|
||||
@click="setEdit"
|
||||
class="done-edit">
|
||||
{{ $t('input.editor.edit') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="tiptap__editor-actions d-print-none" v-if="bottomActions.length > 0">
|
||||
<li v-if="isEditing && showSave">
|
||||
<BaseButton
|
||||
@click="bubbleSave"
|
||||
class="done-edit">
|
||||
{{ $t('misc.save') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
<li v-if="!isEditing">
|
||||
<BaseButton
|
||||
@click="setEdit"
|
||||
class="done-edit">
|
||||
{{ $t('input.editor.edit') }}
|
||||
</BaseButton>
|
||||
</li>
|
||||
<li v-for="(action, k) in bottomActions" :key="k">
|
||||
<BaseButton @click="action.action">{{ action.title }}</BaseButton>
|
||||
</li>
|
||||
</ul>
|
||||
<x-button
|
||||
v-else-if="isEditing && showSave"
|
||||
class="mt-4"
|
||||
@click="bubbleSave"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
v-cy="'saveEditor'"
|
||||
>
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, onBeforeUnmount, nextTick, onMounted, computed} from 'vue'
|
||||
import {refDebounced} from '@vueuse/core'
|
||||
|
||||
import EditorToolbar from './EditorToolbar.vue'
|
||||
|
||||
import Link from '@tiptap/extension-link'
|
||||
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
|
||||
import {Blockquote} from '@tiptap/extension-blockquote'
|
||||
import {Bold} from '@tiptap/extension-bold'
|
||||
import {BulletList} from '@tiptap/extension-bullet-list'
|
||||
import {Code} from '@tiptap/extension-code'
|
||||
import {Document} from '@tiptap/extension-document'
|
||||
import {Dropcursor} from '@tiptap/extension-dropcursor'
|
||||
import {Gapcursor} from '@tiptap/extension-gapcursor'
|
||||
import {HardBreak} from '@tiptap/extension-hard-break'
|
||||
import {Heading} from '@tiptap/extension-heading'
|
||||
import {History} from '@tiptap/extension-history'
|
||||
import {HorizontalRule} from '@tiptap/extension-horizontal-rule'
|
||||
import {Italic} from '@tiptap/extension-italic'
|
||||
import {ListItem} from '@tiptap/extension-list-item'
|
||||
import {OrderedList} from '@tiptap/extension-ordered-list'
|
||||
import {Paragraph} from '@tiptap/extension-paragraph'
|
||||
import {Strike} from '@tiptap/extension-strike'
|
||||
import {Text} from '@tiptap/extension-text'
|
||||
import {BubbleMenu, EditorContent, useEditor} from '@tiptap/vue-3'
|
||||
import {Node} from '@tiptap/pm/model'
|
||||
|
||||
import Commands from './commands'
|
||||
import suggestionSetup from './suggestion'
|
||||
|
||||
import {lowlight} from 'lowlight'
|
||||
|
||||
import type {BottomAction, UploadCallback} from './types'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IAttachment} from '@/modelTypes/IAttachment'
|
||||
import AttachmentModel from '@/models/attachment'
|
||||
import AttachmentService from '@/services/attachment'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import XButton from '@/components/input/button.vue'
|
||||
import {Placeholder} from '@tiptap/extension-placeholder'
|
||||
import {eventToHotkeyString} from '@github/hotkey'
|
||||
import {mergeAttributes} from '@tiptap/core'
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const CustomTableCell = TableCell.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
// extend the existing attributes …
|
||||
...this.parent?.(),
|
||||
|
||||
// and add a new one …
|
||||
backgroundColor: {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute('data-background-color'),
|
||||
renderHTML: (attributes) => {
|
||||
return {
|
||||
'data-background-color': attributes.backgroundColor,
|
||||
style: `background-color: ${attributes.backgroundColor}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
|
||||
const loadedAttachments = ref<{ [key: CacheKey]: string }>({})
|
||||
|
||||
const CustomImage = Image.extend({
|
||||
renderHTML({HTMLAttributes}) {
|
||||
if (HTMLAttributes.src?.startsWith(window.API_URL)) {
|
||||
|
||||
const id = 'tiptap-image-' + createRandomID()
|
||||
nextTick(async () => {
|
||||
|
||||
const img = document.getElementById(id)
|
||||
|
||||
if (!img) return
|
||||
|
||||
// The url is something like /tasks/<id>/attachments/<id>
|
||||
const parts = img.dataset?.src.slice(window.API_URL.length + 1).split('/')
|
||||
const taskId = Number(parts[1])
|
||||
const attachmentId = Number(parts[3])
|
||||
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
|
||||
|
||||
if (typeof loadedAttachments.value[cacheKey] === 'undefined') {
|
||||
|
||||
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
|
||||
|
||||
const attachmentService = new AttachmentService()
|
||||
const url = await attachmentService.getBlobUrl(attachment)
|
||||
loadedAttachments.value[cacheKey] = url
|
||||
}
|
||||
|
||||
img.src = loadedAttachments.value[cacheKey]
|
||||
})
|
||||
|
||||
return ['img', mergeAttributes(this.options.HTMLAttributes, {
|
||||
'data-src': HTMLAttributes.src,
|
||||
src: '#',
|
||||
alt: HTMLAttributes.alt,
|
||||
title: HTMLAttributes.title,
|
||||
id,
|
||||
})]
|
||||
}
|
||||
|
||||
return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]
|
||||
},
|
||||
})
|
||||
|
||||
type Mode = 'edit' | 'preview'
|
||||
|
||||
const {
|
||||
modelValue,
|
||||
uploadCallback,
|
||||
isEditEnabled = true,
|
||||
bottomActions = [],
|
||||
showSave = false,
|
||||
placeholder = '',
|
||||
editShortcut = '',
|
||||
initialMode = 'edit',
|
||||
} = defineProps<{
|
||||
modelValue: string,
|
||||
uploadCallback?: UploadCallback,
|
||||
isEditEnabled?: boolean,
|
||||
bottomActions?: BottomAction[],
|
||||
showSave?: boolean,
|
||||
placeholder?: string,
|
||||
editShortcut?: string,
|
||||
initialMode?: Mode,
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save'])
|
||||
|
||||
const inputHTML = ref('')
|
||||
const isEmpty = computed(() => isEditorContentEmpty(inputHTML.value))
|
||||
const internalMode = ref<Mode>(initialMode)
|
||||
const isEditing = computed(() => internalMode.value === 'edit' && isEditEnabled)
|
||||
|
||||
const editor = useEditor({
|
||||
content: modelValue,
|
||||
editable: isEditing.value,
|
||||
extensions: [
|
||||
// Starterkit:
|
||||
Blockquote,
|
||||
Bold,
|
||||
BulletList,
|
||||
Code,
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
Document,
|
||||
Dropcursor,
|
||||
Gapcursor,
|
||||
HardBreak.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Mod-Enter': () => {
|
||||
bubbleSave()
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
Heading,
|
||||
History,
|
||||
HorizontalRule,
|
||||
Italic,
|
||||
ListItem,
|
||||
OrderedList,
|
||||
Paragraph,
|
||||
Strike,
|
||||
Text,
|
||||
|
||||
Placeholder.configure({
|
||||
placeholder: ({editor}) => {
|
||||
if (!isEditing.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (editor.getText() !== '' && !editor.isFocused) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return placeholder !== ''
|
||||
? placeholder
|
||||
: t('input.editor.placeholder')
|
||||
},
|
||||
}),
|
||||
Typography,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
validate: (href: string) => /^https?:\/\//.test(href),
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
// Custom TableCell with backgroundColor attribute
|
||||
CustomTableCell,
|
||||
|
||||
CustomImage,
|
||||
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
onReadOnlyChecked: (node: Node, checked: boolean): boolean => {
|
||||
if (isEditEnabled) {
|
||||
node.attrs.checked = checked
|
||||
inputHTML.value = editor.value?.getHTML()
|
||||
bubbleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
}),
|
||||
|
||||
Commands.configure({
|
||||
suggestion: suggestionSetup(t),
|
||||
}),
|
||||
BubbleMenu,
|
||||
],
|
||||
onUpdate: () => {
|
||||
inputHTML.value = editor.value!.getHTML()
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => modelValue,
|
||||
value => {
|
||||
inputHTML.value = value
|
||||
|
||||
if (!editor?.value) return
|
||||
|
||||
if (editor.value.getHTML() === value) {
|
||||
return
|
||||
}
|
||||
|
||||
editor.value.commands.setContent(value, false)
|
||||
},
|
||||
)
|
||||
|
||||
const debouncedInputHTML = refDebounced(inputHTML, 1000)
|
||||
watch(debouncedInputHTML, () => bubbleNow())
|
||||
|
||||
function bubbleNow() {
|
||||
emit('update:modelValue', inputHTML.value)
|
||||
}
|
||||
|
||||
function bubbleSave() {
|
||||
bubbleNow()
|
||||
emit('save', inputHTML.value)
|
||||
if (isEditing.value) {
|
||||
internalMode.value = 'preview'
|
||||
}
|
||||
}
|
||||
|
||||
function setEdit() {
|
||||
internalMode.value = 'edit'
|
||||
editor.value?.commands.focus()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isEditing.value,
|
||||
() => {
|
||||
editor.value?.setEditable(isEditing.value)
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => editor.value?.destroy())
|
||||
|
||||
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function uploadAndInsertFiles(files: File[] | FileList) {
|
||||
uploadCallback(files).then(urls => {
|
||||
urls?.forEach(url => {
|
||||
editor.value
|
||||
?.chain()
|
||||
.focus()
|
||||
.setImage({src: url})
|
||||
.run()
|
||||
})
|
||||
bubbleSave()
|
||||
})
|
||||
}
|
||||
|
||||
function addImage() {
|
||||
|
||||
if (typeof uploadCallback !== 'undefined') {
|
||||
const files = uploadInputRef.value?.files
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadAndInsertFiles(files)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const url = window.prompt('URL')
|
||||
|
||||
if (url) {
|
||||
editor.value?.chain().focus().setImage({src: url}).run()
|
||||
bubbleSave()
|
||||
}
|
||||
}
|
||||
|
||||
function setLink() {
|
||||
const previousUrl = editor.value?.getAttributes('link').href
|
||||
const url = window.prompt('URL', previousUrl)
|
||||
|
||||
// cancelled
|
||||
if (url === null) {
|
||||
return
|
||||
}
|
||||
|
||||
// empty
|
||||
if (url === '') {
|
||||
editor.value
|
||||
?.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.unsetLink()
|
||||
.run()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// update link
|
||||
editor.value
|
||||
?.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({href: url, target: '_blank'})
|
||||
.run()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
internalMode.value = initialMode
|
||||
document.addEventListener('paste', handleImagePaste)
|
||||
if (editShortcut !== '') {
|
||||
document.addEventListener('keydown', setFocusToEditor)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('paste', handleImagePaste)
|
||||
if (editShortcut !== '') {
|
||||
document.removeEventListener('keydown', setFocusToEditor)
|
||||
}
|
||||
})
|
||||
|
||||
function handleImagePaste(event) {
|
||||
event.preventDefault()
|
||||
event?.clipboardData?.items?.forEach(i => {
|
||||
if (i.kind === 'file' && i.type.startsWith('image/')) {
|
||||
uploadAndInsertFiles([i.getAsFile()])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
||||
function setFocusToEditor(event) {
|
||||
const hotkeyString = eventToHotkeyString(event)
|
||||
if (!hotkeyString) return
|
||||
if (hotkeyString !== editShortcut ||
|
||||
event.target.tagName.toLowerCase() === 'input' ||
|
||||
event.target.tagName.toLowerCase() === 'textarea' ||
|
||||
event.target.contentEditable === 'true') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
|
||||
if (initialMode === 'preview' && isEditEnabled && !isEditing.value) {
|
||||
internalMode.value = 'edit'
|
||||
}
|
||||
|
||||
editor.value?.commands.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.tiptap__editor {
|
||||
&.tiptap__editor-is-edit-enabled {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
transition: box-shadow $transition;
|
||||
border-radius: $radius;
|
||||
|
||||
&:focus-within, &:focus {
|
||||
box-shadow: 0 0 0 2px hsla(var(--primary-hsl), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.tiptap p.is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--grey-400);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
// Basic editor styles
|
||||
.ProseMirror {
|
||||
padding: .5rem;
|
||||
|
||||
&:focus-within, &:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #68cef8;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(#616161, 0.1);
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0d0d0d;
|
||||
color: #fff;
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0d0d0d;
|
||||
color: #fff;
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
.hljs-link,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #f98181;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-meta,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params {
|
||||
color: #fbbc88;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #b9f18d;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #faf594;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: #70cff8;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid #68cef8;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(#0d0d0d, 0.1);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(#0d0d0d, 0.1);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
/* Table-specific styling */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
|
||||
td,
|
||||
th {
|
||||
min-width: 1em;
|
||||
border: 2px solid #ced4da;
|
||||
padding: 3px 5px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.selectedCell:after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: '';
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(200, 200, 255, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 4px;
|
||||
background-color: #adf;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Lists
|
||||
ul {
|
||||
margin-left: .5rem;
|
||||
margin-top: 0 !important;
|
||||
|
||||
li {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
// tasklist
|
||||
ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
|
||||
> label {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 0.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-bubble__wrapper {
|
||||
background: var(--white);
|
||||
border-radius: $radius;
|
||||
border: 1px solid var(--grey-200);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-bubble__button {
|
||||
color: var(--grey-700);
|
||||
transition: all $transition;
|
||||
background: transparent;
|
||||
|
||||
svg {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: .5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--grey-200);
|
||||
}
|
||||
}
|
||||
|
||||
ul.tiptap__editor-actions {
|
||||
font-size: .8rem;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
|
||||
&::after {
|
||||
content: '·';
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
&, a {
|
||||
color: var(--grey-500);
|
||||
|
||||
&.done-edit {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,28 @@
|
|||
import {Extension} from '@tiptap/core'
|
||||
import Suggestion from '@tiptap/suggestion'
|
||||
|
||||
// Copied and adjusted from https://github.com/ueberdosis/tiptap/tree/252acb32d27a0f9af14813eeed83d8a50059a43a/demos/src/Experiments/Commands/Vue
|
||||
|
||||
export default Extension.create({
|
||||
name: 'slash-menu-commands',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: '/',
|
||||
command: ({editor, range, props}) => {
|
||||
props.command({editor, range})
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
|
@ -0,0 +1,214 @@
|
|||
import {VueRenderer} from '@tiptap/vue-3'
|
||||
import tippy from 'tippy.js'
|
||||
|
||||
import CommandsList from './CommandsList.vue'
|
||||
|
||||
export default function suggestionSetup(t) {
|
||||
return {
|
||||
items: ({query}: { query: string }) => {
|
||||
return [
|
||||
{
|
||||
title: t('input.editor.text'),
|
||||
description: t('input.editor.textTooltip'),
|
||||
icon: 'fa-font',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('paragraph', {level: 1})
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.heading1'),
|
||||
description: t('input.editor.heading1Tooltip'),
|
||||
icon: 'fa-header',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('heading', {level: 1})
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.heading2'),
|
||||
description: t('input.editor.heading2Tooltip'),
|
||||
icon: 'fa-header',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('heading', {level: 2})
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.heading3'),
|
||||
description: t('input.editor.heading3Tooltip'),
|
||||
icon: 'fa-header',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode('heading', {level: 2})
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.bulletList'),
|
||||
description: t('input.editor.bulletListTooltip'),
|
||||
icon: 'fa-list-ul',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleBulletList()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.orderedList'),
|
||||
description: t('input.editor.orderedListTooltip'),
|
||||
icon: 'fa-list-ol',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleOrderedList()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.taskList'),
|
||||
description: t('input.editor.taskListTooltip'),
|
||||
icon: 'fa-list-check',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleTaskList()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.quote'),
|
||||
description: t('input.editor.quoteTooltip'),
|
||||
icon: 'fa-quote-right',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleBlockquote()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.code'),
|
||||
description: t('input.editor.codeTooltip'),
|
||||
icon: 'fa-code',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleCodeBlock()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.image'),
|
||||
description: t('input.editor.imageTooltip'),
|
||||
icon: 'fa-image',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.run()
|
||||
document.getElementById('tiptap__image-upload').click()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('input.editor.horizontalRule'),
|
||||
description: t('input.editor.horizontalRuleTooltip'),
|
||||
icon: 'fa-ruler-horizontal',
|
||||
command: ({editor, range}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setHorizontalRule()
|
||||
.run()
|
||||
},
|
||||
},
|
||||
].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()))
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: VueRenderer
|
||||
let popup
|
||||
|
||||
return {
|
||||
onStart: props => {
|
||||
component = new VueRenderer(CommandsList, {
|
||||
// using vue 2:
|
||||
// parent: this,
|
||||
// propsData: props,
|
||||
props,
|
||||
editor: props.editor,
|
||||
})
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
})
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
})
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props)
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export type UploadCallback = (files: File[] | FileList) => Promise<string[]>
|
||||
|
||||
export interface BottomAction {
|
||||
title: string
|
||||
action: () => void,
|
||||
}
|
|
@ -1,135 +0,0 @@
|
|||
import EasyMDE from 'easymde'
|
||||
import {i18n} from '@/i18n'
|
||||
|
||||
export function createEasyMDEConfig({ placeholder, uploadImage, imageUploadFunction }) {
|
||||
return {
|
||||
autoDownloadFontAwesome: false,
|
||||
spellChecker: false,
|
||||
placeholder,
|
||||
uploadImage,
|
||||
imageUploadFunction,
|
||||
minHeight: '150px',
|
||||
sideBySideFullscreen: false,
|
||||
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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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="currentColor" 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>',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts" setup>
|
||||
import {ref} from 'vue'
|
||||
import {logEvent} from 'histoire/client'
|
||||
import FancyCheckbox from './fancycheckbox.vue'
|
||||
|
||||
const isDisabled = ref<boolean | undefined>()
|
||||
|
||||
const isChecked = ref(false)
|
||||
|
||||
const isCheckedInitiallyEnabled = ref(true)
|
||||
|
||||
const isCheckedDisabled = ref(false)
|
||||
|
||||
const withoutInitialState = ref<boolean | undefined>()
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<Story :layout="{ type: 'grid', width: '200px' }">
|
||||
<Variant title="Default">
|
||||
<FancyCheckbox
|
||||
v-model="isChecked"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
This is probably not important
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input type="checkbox" v-model="isChecked">
|
||||
{{ isChecked }}
|
||||
</Variant>
|
||||
<Variant title="Enabled Initially">
|
||||
<FancyCheckbox
|
||||
:disabled="isDisabled"
|
||||
v-model="isCheckedInitiallyEnabled"
|
||||
>
|
||||
We want you to use this option
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input type="checkbox" v-model="isCheckedInitiallyEnabled">
|
||||
{{ isCheckedInitiallyEnabled }}
|
||||
</Variant>
|
||||
<Variant title="Disabled">
|
||||
<FancyCheckbox
|
||||
disabled
|
||||
:modelValue="isCheckedDisabled"
|
||||
@update:model-value="logEvent('Setting disabled: This should never happen', $event)"
|
||||
>
|
||||
You can't change this
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input type="checkbox" v-model="isCheckedDisabled" disabled>
|
||||
{{ isCheckedDisabled }}
|
||||
</Variant>
|
||||
|
||||
<Variant title="Undefined initial State">
|
||||
<FancyCheckbox
|
||||
v-model="withoutInitialState"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
Not sure what the value should be
|
||||
</FancyCheckbox>
|
||||
|
||||
Visualisation
|
||||
<input type="checkbox" v-model="withoutInitialState" disabled>
|
||||
{{ withoutInitialState }}
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
|
@ -1,61 +1,42 @@
|
|||
<template>
|
||||
<div :class="{'is-disabled': disabled}" class="fancycheckbox">
|
||||
<input
|
||||
:checked="checked"
|
||||
:disabled="disabled || undefined"
|
||||
:id="checkBoxId"
|
||||
@change="(event: Event) => updateData((event.target as HTMLInputElement).checked)"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label :for="checkBoxId" class="check">
|
||||
<svg height="18px" viewBox="0 0 18 18" width="18px">
|
||||
<path
|
||||
d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
|
||||
<polyline points="1 9 7 14 15 4"></polyline>
|
||||
</svg>
|
||||
<span>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<BaseCheckbox
|
||||
class="fancycheckbox"
|
||||
:class="{
|
||||
'is-disabled': disabled,
|
||||
'is-block': isBlock,
|
||||
}"
|
||||
:disabled="disabled"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="value => emit('update:modelValue', value)"
|
||||
>
|
||||
<CheckboxIcon class="fancycheckbox__icon" />
|
||||
<span v-if="$slots.default" class="fancycheckbox__content">
|
||||
<slot/>
|
||||
</span>
|
||||
</BaseCheckbox>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, toRef, watch} from 'vue'
|
||||
import CheckboxIcon from '@/assets/checkbox.svg?component'
|
||||
|
||||
import {createRandomID} from '@/helpers/randomId'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
|
||||
const checked = ref(false)
|
||||
const checkBoxId = `fancycheckbox_${createRandomID()}`
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
isBlock: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const modelValue = toRef(props, 'modelValue')
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
checked.value = newValue
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
function updateData(newChecked: boolean) {
|
||||
checked.value = newChecked
|
||||
emit('update:modelValue', newChecked)
|
||||
emit('change', newChecked)
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
|
@ -65,75 +46,54 @@ function updateData(newChecked: boolean) {
|
|||
padding-right: 5px;
|
||||
padding-top: 3px;
|
||||
|
||||
// FIXME: should be a prop
|
||||
&.is-block {
|
||||
display: 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 {
|
||||
.fancycheckbox__content {
|
||||
font-size: 0.8rem;
|
||||
vertical-align: top;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
.fancycheckbox__icon:deep() {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke: #c8ccd4;
|
||||
stroke-width: 1.5;
|
||||
stroke: var(--stroke-color, #c8ccd4);
|
||||
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,
|
||||
polyline {
|
||||
transition: all 0.2s linear, color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.fancycheckbox:not(:has(input:disabled)):hover .fancycheckbox__icon,
|
||||
.fancycheckbox:has(input:checked) .fancycheckbox__icon {
|
||||
--stroke-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// Since css-has-pseudo doesn't work with deep classes,
|
||||
// the following rules can't be scoped
|
||||
|
||||
.fancycheckbox:has(:not(input:checked)) .fancycheckbox__icon {
|
||||
path {
|
||||
transition-delay: 0.05s;
|
||||
}
|
||||
}
|
||||
|
||||
.fancycheckbox:has(input:checked) .fancycheckbox__icon {
|
||||
path {
|
||||
stroke-dashoffset: 60;
|
||||
transition: all 0.3s linear;
|
||||
}
|
||||
|
||||
polyline {
|
||||
stroke-dashoffset: 42;
|
||||
transition: all 0.2s linear;
|
||||
transition-delay: 0.15s;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,19 +9,24 @@
|
|||
<div class="control" :class="{'is-loading': loading || localLoading}">
|
||||
<div
|
||||
class="input-wrapper input"
|
||||
:class="{'has-multiple': hasMultiple}"
|
||||
:class="{'has-multiple': hasMultiple, 'has-removal-button': removalAvailable}"
|
||||
>
|
||||
<template v-if="Array.isArray(internalValue)">
|
||||
<slot
|
||||
v-if="Array.isArray(internalValue)"
|
||||
name="items"
|
||||
:items="internalValue"
|
||||
:remove="remove"
|
||||
>
|
||||
<template v-for="(item, key) in internalValue">
|
||||
<slot name="tag" :item="item">
|
||||
<span :key="`item${key}`" class="tag ml-2 mt-2">
|
||||
{{ label !== '' ? item[label] : item }}
|
||||
<BaseButton @click="() => remove(item)" class="delete is-small"></BaseButton>
|
||||
</span>
|
||||
<span :key="`item${key}`" class="tag ml-2 mt-2">
|
||||
{{ label !== '' ? item[label] : item }}
|
||||
<BaseButton @click="() => remove(item)" class="delete is-small"></BaseButton>
|
||||
</span>
|
||||
</slot>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
</slot>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
|
@ -32,7 +37,16 @@
|
|||
@keydown.down.exact.prevent="() => preSelect(0)"
|
||||
ref="searchInput"
|
||||
@focus="handleFocus"
|
||||
:autocomplete="autocompleteEnabled ? undefined : 'off'"
|
||||
:spellcheck="autocompleteEnabled ? undefined : 'false'"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="removalAvailable"
|
||||
class="removal-button"
|
||||
@click="resetSelectedValue"
|
||||
>
|
||||
<icon icon="times"/>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -83,7 +97,9 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance, type PropType} from 'vue'
|
||||
import {
|
||||
computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance, type PropType,
|
||||
} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
||||
|
@ -91,6 +107,7 @@ import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
|||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function elementInResults(elem: string | any, label: string, query: string): boolean {
|
||||
// Don't make create available if we have an exact match in our search results.
|
||||
if (label !== '') {
|
||||
|
@ -119,7 +136,8 @@ const props = defineProps({
|
|||
* The search results where the @search listener needs to put the results into
|
||||
*/
|
||||
searchResults: {
|
||||
type: Array as PropType<{[id: string]: any}>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: Array as PropType<{ [id: string]: any }>,
|
||||
default: () => [],
|
||||
},
|
||||
/**
|
||||
|
@ -134,7 +152,8 @@ const props = defineProps({
|
|||
* The object with the value, updated every time an entry is selected.
|
||||
*/
|
||||
modelValue: {
|
||||
type: [Object] as PropType<{[key: string]: any}>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: [Object] as PropType<{ [key: string]: any }>,
|
||||
default: null,
|
||||
},
|
||||
/**
|
||||
|
@ -150,7 +169,7 @@ const props = defineProps({
|
|||
createPlaceholder: {
|
||||
type: String,
|
||||
default() {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
return t('input.multiselect.createPlaceholder')
|
||||
},
|
||||
},
|
||||
|
@ -160,7 +179,7 @@ const props = defineProps({
|
|||
selectPlaceholder: {
|
||||
type: String,
|
||||
default() {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
return t('input.multiselect.selectPlaceholder')
|
||||
},
|
||||
},
|
||||
|
@ -196,33 +215,43 @@ const props = defineProps({
|
|||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/**
|
||||
* If false, the search input will get the autocomplete="off" attributes attached to it.
|
||||
*/
|
||||
autocompleteEnabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: null): void
|
||||
(e: 'update:modelValue', value: null): void
|
||||
/**
|
||||
* Triggered every time the search query input changes
|
||||
*/
|
||||
(e: 'search', query: string): void
|
||||
(e: 'search', query: string): void
|
||||
/**
|
||||
* Triggered every time an option from the search results is selected. Also triggers a change in v-model.
|
||||
*/
|
||||
(e: 'select', value: {[key: string]: any}): void
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(e: 'select', value: { [key: string]: any }): void
|
||||
/**
|
||||
* If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
|
||||
*/
|
||||
(e: 'create', query: string): void
|
||||
(e: 'create', query: string): void
|
||||
/**
|
||||
* If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
|
||||
*/
|
||||
(e: 'remove', value: null): void
|
||||
(e: 'remove', value: null): void
|
||||
}>()
|
||||
|
||||
const query = ref<string | {[key: string]: any}>('')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const query = ref<string | { [key: string]: any }>('')
|
||||
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const localLoading = ref(false)
|
||||
const showSearchResults = ref(false)
|
||||
const internalValue = ref<string | {[key: string]: any} | any[] | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const internalValue = ref<string | { [key: string]: any } | any[] | null>(null)
|
||||
|
||||
onMounted(() => document.addEventListener('click', hideSearchResultsHandler))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', hideSearchResultsHandler))
|
||||
|
@ -250,17 +279,19 @@ const searchResultsVisible = computed(() => {
|
|||
})
|
||||
|
||||
const creatableAvailable = computed(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasResult = filteredSearchResults.value.some((elem: any) => elementInResults(elem, props.label, query.value))
|
||||
const hasQueryAlreadyAdded = Array.isArray(internalValue.value) && internalValue.value.some(elem => elementInResults(elem, props.label, query.value))
|
||||
|
||||
return props.creatable
|
||||
&& query.value !== ''
|
||||
return props.creatable
|
||||
&& query.value !== ''
|
||||
&& !(hasResult || hasQueryAlreadyAdded)
|
||||
})
|
||||
|
||||
const filteredSearchResults = computed(() => {
|
||||
const currentInternal = internalValue.value
|
||||
if (props.multiple && currentInternal !== null && Array.isArray(currentInternal)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return searchResults.value.filter((item: any) => !currentInternal.some(e => e === item))
|
||||
}
|
||||
|
||||
|
@ -271,7 +302,13 @@ const hasMultiple = computed(() => {
|
|||
return props.multiple && Array.isArray(internalValue.value) && internalValue.value.length > 0
|
||||
})
|
||||
|
||||
const removalAvailable = computed(() => !props.multiple && internalValue.value !== null && query.value !== '')
|
||||
function resetSelectedValue() {
|
||||
select(null)
|
||||
}
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
|
||||
function search() {
|
||||
|
||||
|
@ -296,6 +333,7 @@ function search() {
|
|||
}
|
||||
|
||||
const multiselectRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
function hideSearchResultsHandler(e: MouseEvent) {
|
||||
closeWhenClickedOutside(e, multiselectRoot.value, closeSearchResults)
|
||||
}
|
||||
|
@ -312,12 +350,14 @@ function handleFocus() {
|
|||
}, 10)
|
||||
}
|
||||
|
||||
function select(object: {[key: string]: any}) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function select(object: { [key: string]: any } | null) {
|
||||
if (props.multiple) {
|
||||
if (internalValue.value === null) {
|
||||
internalValue.value = []
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(internalValue.value as any[]).push(object)
|
||||
} else {
|
||||
internalValue.value = object
|
||||
|
@ -331,7 +371,8 @@ function select(object: {[key: string]: any}) {
|
|||
}
|
||||
}
|
||||
|
||||
function setSelectedObject(object: string | {[id: string]: any} | null, resetOnly = false) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function setSelectedObject(object: string | { [id: string]: any } | null, resetOnly = false) {
|
||||
internalValue.value = object
|
||||
|
||||
// We assume we're getting an array when multiple is enabled and can therefore leave the query
|
||||
|
@ -354,6 +395,7 @@ function setSelectedObject(object: string | {[id: string]: any} | null, resetOnl
|
|||
}
|
||||
|
||||
const results = ref<(Element | ComponentPublicInstance)[]>([])
|
||||
|
||||
function setResult(el: Element | ComponentPublicInstance | null, index: number) {
|
||||
if (el === null) {
|
||||
delete results.value[index]
|
||||
|
@ -399,8 +441,9 @@ function createOrSelectOnEnter() {
|
|||
|
||||
if (!creatableAvailable.value) {
|
||||
// Check if there's an exact match for our search term
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const exactMatch = filteredSearchResults.value.find((elem: any) => elementInResults(elem, props.label, query.value))
|
||||
if(exactMatch) {
|
||||
if (exactMatch) {
|
||||
select(exactMatch)
|
||||
}
|
||||
|
||||
|
@ -410,6 +453,7 @@ function createOrSelectOnEnter() {
|
|||
create()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function remove(item: any) {
|
||||
for (const ind in internalValue.value) {
|
||||
if (internalValue.value[ind] === item) {
|
||||
|
@ -556,4 +600,14 @@ function focus() {
|
|||
transition: color $transition;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
.has-removal-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.removal-button {
|
||||
position: absolute;
|
||||
right: .5rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
|
@ -1,522 +0,0 @@
|
|||
<template>
|
||||
<div class="vue-easymde" ref="easymdeRef">
|
||||
<textarea
|
||||
class="vue-simplemde-textarea"
|
||||
:name="name"
|
||||
:value="modelValue"
|
||||
@input="handleInput(($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, onMounted, onDeactivated, onBeforeUnmount, nextTick, shallowReactive, type ShallowReactive, type PropType} from 'vue'
|
||||
import EasyMDE, {toggleFullScreen} from 'easymde'
|
||||
import {marked} from 'marked'
|
||||
import type CodeMirror from 'codemirror'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
},
|
||||
previewClass: {
|
||||
type: String,
|
||||
},
|
||||
autoinit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
highlight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sanitize: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
configs: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
previewRender: {
|
||||
type: Function as PropType<EasyMDE.Options['previewRender']>,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'blur', 'initialized'])
|
||||
|
||||
const isValueUpdateFromInner = ref(false)
|
||||
let easymde: ShallowReactive<EasyMDE> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoinit) initialize()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
if (easymde === undefined) return
|
||||
if (easymde.isFullscreenActive()) toggleFullScreen(easymde)
|
||||
easymde.toTextArea
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (easymde) {
|
||||
easymde.toTextArea()
|
||||
easymde.cleanup()
|
||||
easymde = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const easymdeRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function initialize() {
|
||||
const configs: EasyMDE.Options = Object.assign({
|
||||
element: easymdeRef.value?.firstElementChild as HTMLElement,
|
||||
initialValue: props.modelValue,
|
||||
previewRender: props.previewRender,
|
||||
renderingConfig: {},
|
||||
}, props.configs)
|
||||
|
||||
// Synchronize the values of value and initialValue
|
||||
if (configs.initialValue) {
|
||||
emit('update:modelValue', configs.initialValue)
|
||||
}
|
||||
|
||||
// Determine whether to enable code highlighting
|
||||
if (props.highlight) {
|
||||
configs.renderingConfig!.codeSyntaxHighlighting = true
|
||||
}
|
||||
|
||||
// Set whether to render the input html
|
||||
marked.setOptions({ sanitize: props.sanitize })
|
||||
|
||||
// Instantiated editor
|
||||
easymde = shallowReactive(new EasyMDE(configs))
|
||||
|
||||
// Add a custom previewClass
|
||||
const className = props.previewClass || ''
|
||||
addPreviewClass(easymde, className)
|
||||
|
||||
// Binding event
|
||||
easymde.codemirror.on('change', handleCodemirrorInput)
|
||||
easymde.codemirror.on('blur', handleCodemirrorBlur)
|
||||
|
||||
nextTick(() => emit('initialized', easymde))
|
||||
}
|
||||
|
||||
function addPreviewClass(easymde: EasyMDE, className: string) {
|
||||
const wrapper = easymde.codemirror.getWrapperElement()
|
||||
const preview = document.createElement('div')
|
||||
wrapper.nextSibling.className += ` ${className}`
|
||||
preview.className = `editor-preview ${className}`
|
||||
wrapper.appendChild(preview)
|
||||
}
|
||||
|
||||
function handleInput(val: string) {
|
||||
isValueUpdateFromInner.value = true
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
function handleCodemirrorInput(instance: CodeMirror.Editor, changeObj: CodeMirror.EditorChange) {
|
||||
if (changeObj.origin === 'setValue' || easymde === undefined) {
|
||||
return
|
||||
}
|
||||
handleInput(easymde.value())
|
||||
}
|
||||
|
||||
function handleCodemirrorBlur() {
|
||||
if (easymde === undefined) {
|
||||
return
|
||||
}
|
||||
isValueUpdateFromInner.value = true
|
||||
emit('blur', easymde.value())
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (isValueUpdateFromInner.value) {
|
||||
isValueUpdateFromInner.value = false
|
||||
} else {
|
||||
easymde?.value(val)
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.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;
|
||||
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: 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: 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
|
@ -1,200 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
|
||||
class="loader-container"
|
||||
>
|
||||
<div class="switch-view-container">
|
||||
<div class="switch-view">
|
||||
<BaseButton
|
||||
v-shortcut="'g l'"
|
||||
:title="$t('keyboardShortcuts.list.switchToListView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'list'}"
|
||||
:to="{ name: 'list.list', params: { listId } }"
|
||||
>
|
||||
{{ $t('list.list.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g g'"
|
||||
:title="$t('keyboardShortcuts.list.switchToGanttView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'gantt'}"
|
||||
:to="{ name: 'list.gantt', params: { listId } }"
|
||||
>
|
||||
{{ $t('list.gantt.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g t'"
|
||||
:title="$t('keyboardShortcuts.list.switchToTableView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'table'}"
|
||||
:to="{ name: 'list.table', params: { listId } }"
|
||||
>
|
||||
{{ $t('list.table.title') }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-shortcut="'g k'"
|
||||
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
|
||||
class="switch-view-button"
|
||||
:class="{'is-active': viewName === 'kanban'}"
|
||||
:to="{ name: 'list.kanban', params: { listId } }"
|
||||
>
|
||||
{{ $t('list.kanban.title') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<CustomTransition name="fade">
|
||||
<Message variant="warning" v-if="currentList.isArchived" class="mb-4">
|
||||
{{ $t('list.archived') }}
|
||||
</Message>
|
||||
</CustomTransition>
|
||||
|
||||
<slot v-if="loadedListId"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||
|
||||
import ListModel from '@/models/list'
|
||||
import ListService from '@/services/list'
|
||||
|
||||
import {getListTitle} from '@/helpers/getListTitle'
|
||||
import {saveListToHistory} from '@/modules/listHistory'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useListStore} from '@/stores/lists'
|
||||
|
||||
const props = defineProps({
|
||||
listId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
viewName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const listStore = useListStore()
|
||||
const listService = ref(new ListService())
|
||||
const loadedListId = ref(0)
|
||||
|
||||
const currentList = computed(() => {
|
||||
return typeof baseStore.currentList === 'undefined' ? {
|
||||
id: 0,
|
||||
title: '',
|
||||
isArchived: false,
|
||||
maxRight: null,
|
||||
} : baseStore.currentList
|
||||
})
|
||||
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
|
||||
|
||||
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
|
||||
// This resulted in loading and setting the list multiple times, even when navigating away from it.
|
||||
// This caused wired bugs where the list background would be set on the home page but only right after setting a new
|
||||
// list background and then navigating to home. It also highlighted the list in the menu and didn't allow changing any
|
||||
// of it, most likely due to the rights not being properly populated.
|
||||
watch(
|
||||
() => props.listId,
|
||||
// loadList
|
||||
async (listIdToLoad: number) => {
|
||||
const listData = {id: listIdToLoad}
|
||||
saveListToHistory(listData)
|
||||
|
||||
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
|
||||
// the currently loaded list has the right set.
|
||||
if (
|
||||
(
|
||||
listIdToLoad === loadedListId.value ||
|
||||
typeof listIdToLoad === 'undefined' ||
|
||||
listIdToLoad === currentList.value.id
|
||||
)
|
||||
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
|
||||
) {
|
||||
loadedListId.value = props.listId
|
||||
return
|
||||
}
|
||||
|
||||
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
|
||||
|
||||
// Set the current list to the one we're about to load so that the title is already shown at the top
|
||||
loadedListId.value = 0
|
||||
const listFromStore = listStore.getListById(listData.id)
|
||||
if (listFromStore !== null) {
|
||||
baseStore.setBackground(null)
|
||||
baseStore.setBlurHash(null)
|
||||
baseStore.handleSetCurrentList({list: listFromStore})
|
||||
}
|
||||
|
||||
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
|
||||
const list = new ListModel(listData)
|
||||
try {
|
||||
const loadedList = await listService.value.get(list)
|
||||
baseStore.handleSetCurrentList({list: loadedList})
|
||||
} finally {
|
||||
loadedListId.value = props.listId
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.switch-view-container {
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-view {
|
||||
background: var(--white);
|
||||
display: inline-flex;
|
||||
border-radius: $radius;
|
||||
font-size: .75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
height: $switch-view-height;
|
||||
margin: 0 auto 1rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.switch-view-button {
|
||||
padding: .25rem .5rem;
|
||||
display: block;
|
||||
border-radius: $radius;
|
||||
transition: all 100ms;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--switch-view-color);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: var(--switch-view-color);
|
||||
background: var(--primary);
|
||||
font-weight: bold;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: this should be in notification and set via a prop
|
||||
.is-archived .notification.is-warning {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue