Compare commits
751 Commits
claude/inv
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
076cd214fe | |
|
|
8a4a1c1af7 | |
|
|
655f553bdb | |
|
|
911c9dd3d0 | |
|
|
3aab4ab51a | |
|
|
5bd55e0322 | |
|
|
a74bb408ee | |
|
|
5d368b849a | |
|
|
9657cff19a | |
|
|
e1bff274c7 | |
|
|
756ecb3ec0 | |
|
|
0d1f44cb2a | |
|
|
03a9056d8f | |
|
|
73f68f61c1 | |
|
|
9f9711cdfe | |
|
|
8f68b3f396 | |
|
|
988dfa0b3a | |
|
|
01a851ca72 | |
|
|
65a498dd50 | |
|
|
d9804c3e00 | |
|
|
83f353aee9 | |
|
|
bb0055293b | |
|
|
b947e892d0 | |
|
|
d2fcd2efa5 | |
|
|
8ae1ee0645 | |
|
|
bfead87452 | |
|
|
8f429ac643 | |
|
|
90d57f4b38 | |
|
|
07c872eb2b | |
|
|
e1afa039cb | |
|
|
ee8c759f0b | |
|
|
b866ba3f58 | |
|
|
b0bbfa677a | |
|
|
82f03d94b6 | |
|
|
59ef240a4d | |
|
|
421c45e60b | |
|
|
837339b894 | |
|
|
7f687236d4 | |
|
|
fa0c9a8584 | |
|
|
71639a3dc5 | |
|
|
8d10e053d4 | |
|
|
a728e50796 | |
|
|
9015bad65c | |
|
|
3459158b99 | |
|
|
3abe8d650a | |
|
|
a2063a27a8 | |
|
|
cf1273c1d9 | |
|
|
8d0814e460 | |
|
|
2690b7153e | |
|
|
c55ee0b742 | |
|
|
c72cfdf50d | |
|
|
c6b3c7cddc | |
|
|
12952516cf | |
|
|
9946ca9031 | |
|
|
a73761f4c5 | |
|
|
ac9811826e | |
|
|
0369b61001 | |
|
|
59da1d9514 | |
|
|
d374c8e6f9 | |
|
|
aa8c5974ae | |
|
|
0dba563a03 | |
|
|
dab2ac473f | |
|
|
57b6d530f3 | |
|
|
ba5c09f962 | |
|
|
eed762097a | |
|
|
f6baa7d472 | |
|
|
07d39b4290 | |
|
|
0efae572cd | |
|
|
9e880e98a5 | |
|
|
e25ca7ab9a | |
|
|
18ee92f227 | |
|
|
96452f0b71 | |
|
|
3f8ce93636 | |
|
|
626e1e267e | |
|
|
f18813f3ff | |
|
|
f7ac69d01a | |
|
|
98b3613247 | |
|
|
18a0df505b | |
|
|
7b5b8ecad2 | |
|
|
4b18d08993 | |
|
|
7c9b9e3352 | |
|
|
08890895de | |
|
|
330b94c3c4 | |
|
|
7691f282cf | |
|
|
6cee626383 | |
|
|
0d043e80e4 | |
|
|
9390199ce0 | |
|
|
f8eacca7c8 | |
|
|
7a182817ee | |
|
|
aaa2428f6c | |
|
|
0f3a8a7e39 | |
|
|
f4bbe80144 | |
|
|
02d46944ac | |
|
|
0e17556a16 | |
|
|
84dc57c562 | |
|
|
82dae774f1 | |
|
|
63b7f32379 | |
|
|
81791fd346 | |
|
|
b6af132845 | |
|
|
ab927aa772 | |
|
|
764e4efa18 | |
|
|
7208694960 | |
|
|
54fbc79a52 | |
|
|
767ce3bc7e | |
|
|
adf031128e | |
|
|
6e1b15e344 | |
|
|
822fde2594 | |
|
|
f3c6312a9e | |
|
|
bf175dde6d | |
|
|
5edc7b5160 | |
|
|
9d18ba236f | |
|
|
1e1e733c36 | |
|
|
5236e0c306 | |
|
|
80bb9aadc1 | |
|
|
86ec62d10b | |
|
|
37a34cc5cf | |
|
|
aac4dd845e | |
|
|
7f53be4105 | |
|
|
7b7c850dd8 | |
|
|
99d025399c | |
|
|
647f1f4def | |
|
|
a61e594952 | |
|
|
9cad4f388c | |
|
|
40f2900e9d | |
|
|
4614e18e7a | |
|
|
1a4f03bbc8 | |
|
|
7c11c2dc29 | |
|
|
20d8d23474 | |
|
|
2cc7c0b6f0 | |
|
|
5b7924b1f6 | |
|
|
a32d8d6492 | |
|
|
9aa0687288 | |
|
|
422d504a07 | |
|
|
d4ab438073 | |
|
|
78f79accb5 | |
|
|
59a5a2c1e7 | |
|
|
434b5d9fe3 | |
|
|
55ca06ca3d | |
|
|
02e7a134cc | |
|
|
4b92f23329 | |
|
|
ee8dbf82ba | |
|
|
8c72e83a4d | |
|
|
ac5e94252b | |
|
|
ca4e747bed | |
|
|
cf456fb223 | |
|
|
8a255cbff6 | |
|
|
c4819631e2 | |
|
|
13f1a13367 | |
|
|
4737114b12 | |
|
|
5555950f03 | |
|
|
ffcf92936a | |
|
|
c5d615843d | |
|
|
5ccbd0d74e | |
|
|
8bec654595 | |
|
|
ea4bb09679 | |
|
|
a8bce2ef0b | |
|
|
f851e6f959 | |
|
|
e13d3f537c | |
|
|
9cc47a3da4 | |
|
|
d054fb7a5b | |
|
|
be5858aafe | |
|
|
340be305f8 | |
|
|
460e8f3ab1 | |
|
|
652f61da50 | |
|
|
b42a7fdcc4 | |
|
|
1d6d332c18 | |
|
|
85b820fa7c | |
|
|
35bcb7ed26 | |
|
|
a8a53c9581 | |
|
|
77416d32e4 | |
|
|
a21822fcec | |
|
|
a881246e80 | |
|
|
3af5eb8208 | |
|
|
8381f7543f | |
|
|
5e00fcbbb8 | |
|
|
acdc2a07f2 | |
|
|
0eb39fae9a | |
|
|
f0eff52949 | |
|
|
b3bcab1f72 | |
|
|
f33cde82e2 | |
|
|
3291556821 | |
|
|
5d7812a093 | |
|
|
1071755625 | |
|
|
2e0e8e9582 | |
|
|
b86710903b | |
|
|
9da51f5096 | |
|
|
fc831719cd | |
|
|
dbdf4a04cb | |
|
|
869bec38b5 | |
|
|
5f4a21a4c5 | |
|
|
eea2ecbc72 | |
|
|
f308fd830a | |
|
|
95084087a5 | |
|
|
48f7dafce3 | |
|
|
2bbe77c141 | |
|
|
d8ad9d64f5 | |
|
|
56a516045b | |
|
|
dc4c3a6a17 | |
|
|
37a174b99e | |
|
|
eac1fa2726 | |
|
|
8ff4696786 | |
|
|
f819b685d8 | |
|
|
89ee1ef507 | |
|
|
e5055d720c | |
|
|
5807f2e7b4 | |
|
|
5dcc501d54 | |
|
|
3312716afd | |
|
|
56b1ba47ec | |
|
|
6f3dab53cb | |
|
|
ea0c9fbe94 | |
|
|
1cf10b563a | |
|
|
adc8070ff9 | |
|
|
53d1fa0735 | |
|
|
5b3ee89edd | |
|
|
5579daa452 | |
|
|
e25f997281 | |
|
|
9c3c1047ac | |
|
|
809ac118f9 | |
|
|
3bd75acabf | |
|
|
3a84c491ae | |
|
|
070ce19286 | |
|
|
a88aef0e47 | |
|
|
05b10e34d8 | |
|
|
28af57bc93 | |
|
|
46b07a019c | |
|
|
154a96674d | |
|
|
b8894ac1c1 | |
|
|
a610ccbbac | |
|
|
190fab8e6d | |
|
|
4afcfa4441 | |
|
|
a562f69f02 | |
|
|
da3bf0e7cd | |
|
|
e271f75cad | |
|
|
3462e24ec7 | |
|
|
a221a15ec3 | |
|
|
a1621fec37 | |
|
|
dc935f263c | |
|
|
cec74717fc | |
|
|
5cdc785b49 | |
|
|
0a879e56a8 | |
|
|
4316554b27 | |
|
|
328de89c0b | |
|
|
0e0ececa2d | |
|
|
25a294d7bc | |
|
|
a6a073329f | |
|
|
e16d120236 | |
|
|
6d505e360b | |
|
|
8502c541a6 | |
|
|
12f290905a | |
|
|
1e82c62ff7 | |
|
|
f5e7e9ddde | |
|
|
2e02fe11ac | |
|
|
da76d393d9 | |
|
|
5c960fccd5 | |
|
|
711545e9f2 | |
|
|
1aa9493bc3 | |
|
|
d5bcbe39b4 | |
|
|
51e5c86f69 | |
|
|
9eca20fe43 | |
|
|
aa144b9a39 | |
|
|
bf2a65dcaf | |
|
|
732cd115a5 | |
|
|
cb0d24dae1 | |
|
|
c9c2c58c16 | |
|
|
e1512b6b53 | |
|
|
df6a56b195 | |
|
|
9e181bfc55 | |
|
|
00bbdbf95b | |
|
|
0bd7f956f5 | |
|
|
4390af4773 | |
|
|
2d334e56c7 | |
|
|
7c021dd663 | |
|
|
e948b191b0 | |
|
|
2ef898e89d | |
|
|
8febfac742 | |
|
|
212d891fa1 | |
|
|
1832d0d3ee | |
|
|
bb4f19da27 | |
|
|
ab8189e927 | |
|
|
8c34676946 | |
|
|
e4b0a487fc | |
|
|
8839c296a2 | |
|
|
27bb80d11a | |
|
|
43d0203358 | |
|
|
80c21e6f40 | |
|
|
565bf97294 | |
|
|
4a558fc57a | |
|
|
74510bb00a | |
|
|
2858b8b827 | |
|
|
b8b376c53a | |
|
|
aef584c9fa | |
|
|
cf22f08974 | |
|
|
e197b1912f | |
|
|
0c5a0a99ec | |
|
|
9454cd3ec5 | |
|
|
4bd6a6c4f7 | |
|
|
42795518e9 | |
|
|
26c067cc38 | |
|
|
6387d8138a | |
|
|
8ff97a61de | |
|
|
89ed627800 | |
|
|
c2e1b078ce | |
|
|
627cd0a6f4 | |
|
|
a2be36b5fe | |
|
|
c2d1e48c8c | |
|
|
ef256273e0 | |
|
|
ed4ae0cd43 | |
|
|
a52ee1593a | |
|
|
9cddc137c5 | |
|
|
2c0608e47b | |
|
|
7158334699 | |
|
|
604e5850bc | |
|
|
1ca5367f27 | |
|
|
05c9c07e19 | |
|
|
fb4bca34dd | |
|
|
1b47932916 | |
|
|
67bc3ff4f1 | |
|
|
24188480c4 | |
|
|
45e05a5d27 | |
|
|
5855ccc1d4 | |
|
|
aac0322975 | |
|
|
98741d8171 | |
|
|
cf1f7c3309 | |
|
|
3647551a79 | |
|
|
d76c009808 | |
|
|
43bbeed1c8 | |
|
|
f90868c595 | |
|
|
43d6e14289 | |
|
|
a35518a099 | |
|
|
8e09b69fb3 | |
|
|
380e0afb86 | |
|
|
bcade97fa4 | |
|
|
b107685063 | |
|
|
4e5751ebfe | |
|
|
cae89caef2 | |
|
|
9e234911f2 | |
|
|
c3c648f060 | |
|
|
413006e9ba | |
|
|
171d14d7b8 | |
|
|
6bbb700f36 | |
|
|
a763fed573 | |
|
|
58d882d36d | |
|
|
184384b68c | |
|
|
2fc11630b4 | |
|
|
9a184fdfab | |
|
|
62979ff342 | |
|
|
33b9aa6292 | |
|
|
bec991288b | |
|
|
25665f887f | |
|
|
a3370a9a49 | |
|
|
2f68a3fae4 | |
|
|
0a7750ee3d | |
|
|
fd2f005a3b | |
|
|
af2482aab2 | |
|
|
e22e169fb9 | |
|
|
6836903c5f | |
|
|
72445c4d2f | |
|
|
e39885682c | |
|
|
d604d8d443 | |
|
|
c9f8b87263 | |
|
|
984a2633cc | |
|
|
88832a3e8b | |
|
|
4d404e376a | |
|
|
808ef2534e | |
|
|
3271a1e1af | |
|
|
67aca34124 | |
|
|
58bc03d712 | |
|
|
5c05a1a289 | |
|
|
8dada8b298 | |
|
|
c0392e42ac | |
|
|
cdb1db855b | |
|
|
dd32e3e496 | |
|
|
3233dff545 | |
|
|
dab6ac620d | |
|
|
2cb0fdcded | |
|
|
58b2aaa74e | |
|
|
ceb2b4f161 | |
|
|
5257922f3b | |
|
|
b04d4d269c | |
|
|
220af19a39 | |
|
|
b18e051ab3 | |
|
|
d2319e1257 | |
|
|
2f4e3ecb91 | |
|
|
cfac0773d7 | |
|
|
782c17c01d | |
|
|
e81ccb3486 | |
|
|
a4a0af91ff | |
|
|
774d884f5c | |
|
|
17bef4f599 | |
|
|
730932be13 | |
|
|
2e8bd6724b | |
|
|
82ad23c135 | |
|
|
6076102d21 | |
|
|
4fc4125546 | |
|
|
0f50dc047d | |
|
|
738bcd0c77 | |
|
|
9858792123 | |
|
|
1d7d67541f | |
|
|
5ddc9d8ff0 | |
|
|
c7e7f8dca3 | |
|
|
3d6608cac7 | |
|
|
fd10300597 | |
|
|
ebb89ba4f3 | |
|
|
e1c9ab5939 | |
|
|
fb6f16adde | |
|
|
9bf19e4dc5 | |
|
|
5bbc5aa1ea | |
|
|
833e8e817c | |
|
|
2488478f69 | |
|
|
78ca1904b5 | |
|
|
451bd5a8d6 | |
|
|
2602f723c3 | |
|
|
152bbd2ac4 | |
|
|
3347180f31 | |
|
|
43e910025a | |
|
|
8532016a2d | |
|
|
e257823cef | |
|
|
14446e3c41 | |
|
|
d2a3186b3e | |
|
|
057b2e5439 | |
|
|
00b42234e9 | |
|
|
21194e61b0 | |
|
|
a2156e7231 | |
|
|
b52a451db4 | |
|
|
c6c57d9d15 | |
|
|
fb9119c98d | |
|
|
132f973486 | |
|
|
4125fd47c3 | |
|
|
b56a74d6a7 | |
|
|
8a4f5cbe11 | |
|
|
15d8ac5f49 | |
|
|
47b2d1043b | |
|
|
5fefa88577 | |
|
|
5fa6d66c41 | |
|
|
137f31bb20 | |
|
|
e31d73b3df | |
|
|
9a810f7632 | |
|
|
71dcb096be | |
|
|
054050b1e2 | |
|
|
0248bdf5e7 | |
|
|
9e3e884dac | |
|
|
72a231620d | |
|
|
06000b7a03 | |
|
|
051f734f3d | |
|
|
ec2f154e10 | |
|
|
1af6d7763b | |
|
|
e0fa2bbed4 | |
|
|
9456223556 | |
|
|
f7921238e6 | |
|
|
069685f2a7 | |
|
|
50bece8cdb | |
|
|
7d1372ece3 | |
|
|
2395239f0b | |
|
|
1e1fcaafbc | |
|
|
4f8a44de89 | |
|
|
e053d3172f | |
|
|
be7eabb9b3 | |
|
|
ed9df9064c | |
|
|
304fe55da7 | |
|
|
e903b72b9e | |
|
|
95f65c4712 | |
|
|
c690f74d75 | |
|
|
8313d032ea | |
|
|
f39cf00290 | |
|
|
5f00fca166 | |
|
|
d5ab54941f | |
|
|
e08f05119d | |
|
|
7be5026113 | |
|
|
98affb265a | |
|
|
964fdb71d1 | |
|
|
4cda019336 | |
|
|
9b95d05811 | |
|
|
c715520ab9 | |
|
|
f04930137e | |
|
|
ba6615f378 | |
|
|
75e546f0c1 | |
|
|
c4a0575305 | |
|
|
9b8ad4d027 | |
|
|
814b2a635f | |
|
|
1bc3afa430 | |
|
|
4ac89741e3 | |
|
|
cd7cc113a1 | |
|
|
632579b304 | |
|
|
c1d5272afe | |
|
|
b18762171d | |
|
|
952ad89a8b | |
|
|
202a5f60b0 | |
|
|
8ef796f016 | |
|
|
35aa486eb5 | |
|
|
d0c77ad6fe | |
|
|
950d41df91 | |
|
|
4c3d449a35 | |
|
|
3a7bcb2a50 | |
|
|
df7a60d137 | |
|
|
2e2393121b | |
|
|
e8cdfcf023 | |
|
|
b9551d55ba | |
|
|
6ebe25bfbc | |
|
|
6b756d92c3 | |
|
|
2425d9923e | |
|
|
e88427ca3c | |
|
|
5e80c17281 | |
|
|
081373bb48 | |
|
|
81f4845a6b | |
|
|
37b6ff538b | |
|
|
d2c3f3244d | |
|
|
1f5abaa6fb | |
|
|
6b48a37710 | |
|
|
36fb0f0ace | |
|
|
878233f758 | |
|
|
f05fc60777 | |
|
|
4b6b8fca78 | |
|
|
3eec756863 | |
|
|
87c312fb2b | |
|
|
e4c4837805 | |
|
|
3d0039df2d | |
|
|
6abf6c6012 | |
|
|
b8cabcd825 | |
|
|
b6a02cb6a5 | |
|
|
e0fb3ed732 | |
|
|
dc85d2e3cb | |
|
|
56b82b23d8 | |
|
|
8a1b2252e2 | |
|
|
4a21b2a998 | |
|
|
20e04f4fcb | |
|
|
102db344b3 | |
|
|
9dfa6fbf89 | |
|
|
f05ef2df94 | |
|
|
d417a30802 | |
|
|
82975f9bd2 | |
|
|
46dbeb5784 | |
|
|
6fc36cb700 | |
|
|
a1f81524ab | |
|
|
4351ebf411 | |
|
|
995aad3d53 | |
|
|
bc7c2059aa | |
|
|
612628a657 | |
|
|
44db02ab56 | |
|
|
3d6e5b5f6b | |
|
|
553613163f | |
|
|
1fd1427fed | |
|
|
a5dc85b5d3 | |
|
|
25e1c93a23 | |
|
|
5fda2182c7 | |
|
|
2fca6a46e5 | |
|
|
fa6e1f8e49 | |
|
|
3c048223c3 | |
|
|
15badb382a | |
|
|
c6fa7991d6 | |
|
|
04148e14db | |
|
|
466d39e6de | |
|
|
21ce33f8fd | |
|
|
c761ab9761 | |
|
|
a79517a79a | |
|
|
fee2d2ea58 | |
|
|
faeeebe661 | |
|
|
ad457488fd | |
|
|
f349b6360e | |
|
|
941f6bb1be | |
|
|
52f3dd6806 | |
|
|
dbccbd64ef | |
|
|
4a16df8af1 | |
|
|
d4e186a024 | |
|
|
b9e3bb95fa | |
|
|
6b14307896 | |
|
|
fc373ae963 | |
|
|
70393f38d2 | |
|
|
c371ca7196 | |
|
|
bc7e41c2b0 | |
|
|
2b38c2a196 | |
|
|
7caaa9a16a | |
|
|
2ad7efb669 | |
|
|
57a0b8fee4 | |
|
|
f495a792b2 | |
|
|
572edd431d | |
|
|
c19b310b22 | |
|
|
aa1956e1aa | |
|
|
c6bda7a2dd | |
|
|
812fa11b9b | |
|
|
beaf4e9e65 | |
|
|
7800102f93 | |
|
|
fc9a9a6c71 | |
|
|
4754230ef0 | |
|
|
3d594db725 | |
|
|
9bea92bb6f | |
|
|
1ea5675e1b | |
|
|
469ee8f364 | |
|
|
926e163089 | |
|
|
7ed0e3ecd6 | |
|
|
65a6fc7b4b | |
|
|
459dbe71ca | |
|
|
6a604dd949 | |
|
|
55e96018f3 | |
|
|
d9a5958bb8 | |
|
|
0f1bf6fab2 | |
|
|
935c950942 | |
|
|
0adf85dc2d | |
|
|
22d82e292b | |
|
|
999e28435e | |
|
|
d467a06e72 | |
|
|
c4e5f55b6d | |
|
|
05acc2b660 | |
|
|
3415981d1c | |
|
|
74af7af2e3 | |
|
|
2e6bcec72a | |
|
|
8d3ac47605 | |
|
|
1637ecd0c7 | |
|
|
506bfa2549 | |
|
|
a262c6a848 | |
|
|
c239834070 | |
|
|
83c5190c9b | |
|
|
4c3f0231e9 | |
|
|
3d75ca049b | |
|
|
01fff665c6 | |
|
|
12f07529e5 | |
|
|
304ff5a4aa | |
|
|
e97b629d6c | |
|
|
9852aff4ee | |
|
|
519b65b96e | |
|
|
ff2bab3d1f | |
|
|
811e5efe20 | |
|
|
6ed4e759b0 | |
|
|
07adf65e39 | |
|
|
5cfb03a29e | |
|
|
96e08fcbdb | |
|
|
cbd5bf8d94 | |
|
|
d2cac283c7 | |
|
|
4add8abaa1 | |
|
|
2b76a6b3fe | |
|
|
67ad31c9c8 | |
|
|
6bf586e928 | |
|
|
879f839729 | |
|
|
8daa244e52 | |
|
|
bb37a57c79 | |
|
|
0cccaf6e5a | |
|
|
6779e48906 | |
|
|
d67c586c9b | |
|
|
1d637a4ac6 | |
|
|
edd83f5e92 | |
|
|
d2ba697686 | |
|
|
1f871d4dbd | |
|
|
138a545523 | |
|
|
0035be3c12 | |
|
|
5c7d2a5e7a | |
|
|
82a4f1f0d2 | |
|
|
71c2e01366 | |
|
|
49ac0348e4 | |
|
|
413e3dec1c | |
|
|
0b2b5b580d | |
|
|
2829a851df | |
|
|
fd1a329f5d | |
|
|
6f85a7fb6b | |
|
|
c1f74ae9dc | |
|
|
4618f3491b | |
|
|
a0d0379e95 | |
|
|
a34c247611 | |
|
|
21b7ae3f9f | |
|
|
40ff558540 | |
|
|
11ffb530be | |
|
|
732b65ba7c | |
|
|
eb441f8b0c | |
|
|
9d25864b25 | |
|
|
b90e67d7ca | |
|
|
be28ec70d8 | |
|
|
c0101afb59 | |
|
|
c3b86b2102 | |
|
|
17e0dde7d3 | |
|
|
19d3b9c4bb | |
|
|
f20267164f | |
|
|
be225fd4d3 | |
|
|
01b71577d7 | |
|
|
268c5daf8b | |
|
|
37d7f90acf | |
|
|
637d810ff7 | |
|
|
c93f644363 | |
|
|
f2eee5d8a1 | |
|
|
05432d3993 | |
|
|
db634093e0 | |
|
|
425889b879 | |
|
|
5a93149849 | |
|
|
2f2aafadfd | |
|
|
8bcdc314b1 | |
|
|
a9f8fbaba8 | |
|
|
2a5e4f2b84 | |
|
|
0902c009f6 | |
|
|
76055b622b | |
|
|
cf9d0a26ab | |
|
|
7145440fe6 | |
|
|
3dfbcae4d5 | |
|
|
cd9d2a2245 | |
|
|
912d6a134f | |
|
|
96685fdc5b | |
|
|
5266392bb7 | |
|
|
3b7c098c84 | |
|
|
3816349258 | |
|
|
3271c8600a | |
|
|
fff7f80994 | |
|
|
726a4df539 | |
|
|
2fc6f033f2 | |
|
|
939381fb12 | |
|
|
1f4471c38f | |
|
|
0e800b4936 | |
|
|
9ec5c2672f | |
|
|
11c9137080 | |
|
|
73a597345a | |
|
|
f7dc50faf7 | |
|
|
5a1db90103 | |
|
|
5d3e34e870 | |
|
|
af8beb5758 | |
|
|
73a0f691ec | |
|
|
c8893f4533 | |
|
|
d64ca0c777 | |
|
|
e8b777d3be | |
|
|
db3f5d2daf | |
|
|
c7b088ac18 | |
|
|
7e4bf83fa0 | |
|
|
4e805d182a | |
|
|
f90ebbf0f4 | |
|
|
d5f4928034 | |
|
|
9ad9a1e987 | |
|
|
d24b96b99c | |
|
|
7df5f127ca | |
|
|
23c82bd5fa | |
|
|
c9b3d4775c | |
|
|
825e45b4c8 | |
|
|
3498dfe7fb | |
|
|
d32dcf3a78 | |
|
|
803f625ed7 | |
|
|
128c0abf59 | |
|
|
4a7cb6a7bf | |
|
|
e7fcbff827 | |
|
|
ec1833dbeb | |
|
|
d208629909 | |
|
|
3b3bc4c775 | |
|
|
87a06d6cb9 | |
|
|
7c7e060d16 | |
|
|
deccc9d29b | |
|
|
736773ea77 | |
|
|
2d2dbf67a0 | |
|
|
44122bfe6b | |
|
|
57e2a33dc6 | |
|
|
7710e2549e | |
|
|
e31c45c44e | |
|
|
3a5ba17ca0 | |
|
|
fb0d0cb32c | |
|
|
e8615efe8e | |
|
|
c027d7ef40 | |
|
|
b241c293d0 | |
|
|
8f64836999 | |
|
|
5ea7853dd6 | |
|
|
326874d94c |
|
|
@ -0,0 +1,186 @@
|
||||||
|
---
|
||||||
|
name: api-v2-routes
|
||||||
|
description: Use when adding or changing a resource on the Huma-backed /api/v2 API (new endpoints, porting a v1 resource, editing pkg/routes/api/v2/). Covers per-operation Huma handlers, the shared envelopes, error/auth bridging, REST verb conventions, and what's automatic.
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Adding /api/v2 routes for a CRUDable resource
|
||||||
|
|
||||||
|
`/api/v2` is served by [Huma v2](https://github.com/danielgtaylor/huma) mounted on an Echo group via the vendored `pkg/modules/humaecho5` adapter. Unlike v1's generic `WebHandler`, each operation is a typed Huma handler registered explicitly. The handlers are thin: they pull auth off the context, call the same `pkg/web/handler.Do*` functions v1 uses, and translate domain errors into RFC 9457 responses.
|
||||||
|
|
||||||
|
**Reference implementation:** `pkg/routes/api/v2/labels.go` is the canonical example — copy its shape. Shared envelopes live in `pkg/routes/api/v2/types.go`; the auth/error bridge in `pkg/routes/api/v2/errors.go`; config in `pkg/routes/api/v2/huma.go`.
|
||||||
|
|
||||||
|
## Prerequisite: the model must be CRUDable
|
||||||
|
|
||||||
|
v2 handlers call `handler.DoReadAll/DoReadOne/DoCreate/DoUpdate/DoDelete`, which invoke the model's `Can*` methods. If the model isn't already a working v1 resource, do the model work first — invoke the **`crudable`** skill. Permissions are enforced at the model level; **never** re-check them in a v2 handler.
|
||||||
|
|
||||||
|
**Every exposed model field needs a `doc:` tag.** v2's schema is reflected from struct tags at runtime; Huma cannot read the Go doc comments swaggo uses for v1. A field without `doc:"..."` ships with no description in the spec. Add the tag alongside the existing comment (keep both — swaggo still reads the comment for v1, and they should stay in sync):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// The title of the label. You'll see this one on tasks associated with it.
|
||||||
|
Title string `json:"title" minLength:"1" maxLength:"250" doc:"The title of the label. You'll see this one on tasks associated with it."`
|
||||||
|
```
|
||||||
|
|
||||||
|
These model edits are safe for v1 — swaggo, XORM, and govalidator all ignore the `doc` tag. (Huma *does* read validation tags like `minLength`/`maxLength`/`enum`/`format`, so those carry over without a `doc` tag.) As with operations, a `doc` tag earns its place when it says something the field name and type don't: a format hint ("hex, 6 chars"), a read-only note ("set by the server; ignored on write"), units, or allowed values. "The label description." on a `Description` field is filler. See `pkg/models/label.go` for the reference.
|
||||||
|
|
||||||
|
**Mark server-controlled fields `readOnly:"true"`.** Because the same model struct is the request body *and* the response, fields the client can never set — `id`, `created`, `updated`, `created_by`, and similar server-derived relations/IDs — should carry `readOnly:"true"`. Huma reflects this into the OpenAPI schema (`readOnly: true`), so docs and client generators present the field as response-only and drop it from request examples:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ID int64 `json:"id" readOnly:"true" doc:"The unique, numeric id of this label."`
|
||||||
|
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this label."`
|
||||||
|
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this label was created. You cannot change this value."`
|
||||||
|
```
|
||||||
|
|
||||||
|
The tag is **documentation only** — Huma does *not* reject these fields if a client sends them on create/update. Actual immutability still comes from the model layer (XORM-managed `created`/`updated`, `created_by` being `xorm:"-"` and set server-side). It's also harmless on v1 (swaggo/XORM/govalidator ignore it). Don't bother tagging fields that are already `json:"-"` (absent from the schema entirely), and skip it on response-only structs like the error model — there it's cosmetic since they never appear as a request body. See `pkg/models/label.go` and `pkg/user/user.go`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Create `pkg/routes/api/v2/<resource>.go`
|
||||||
|
|
||||||
|
Define the list-response body, a `Register<Resource>Routes(api huma.API)` function, and one handler per operation. Mirror `labels.go` exactly:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Element type matches what models.<Model>.ReadAll returns; extra fields
|
||||||
|
// tagged json:"-" keep the wire shape identical to the plain model.
|
||||||
|
type fooListBody struct {
|
||||||
|
Body Paginated[*models.Foo]
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterFooRoutes(api huma.API) {
|
||||||
|
tags := []string{"foos"}
|
||||||
|
|
||||||
|
Register(api, huma.Operation{
|
||||||
|
OperationID: "foos-list",
|
||||||
|
Summary: "List foos",
|
||||||
|
Description: "Returns the foos the authenticated user has access to, paginated.",
|
||||||
|
Method: http.MethodGet, Path: "/foos", Tags: tags,
|
||||||
|
}, foosList)
|
||||||
|
Register(api, huma.Operation{OperationID: "foos-read", Summary: "Get a foo", Description: "...", Method: http.MethodGet, Path: "/foos/{id}", Tags: tags}, foosRead)
|
||||||
|
Register(api, huma.Operation{OperationID: "foos-create", Summary: "Create a foo", Description: "...", Method: http.MethodPost, Path: "/foos", Tags: tags}, foosCreate)
|
||||||
|
Register(api, huma.Operation{OperationID: "foos-update", Summary: "Update a foo", Description: "...", Method: http.MethodPut, Path: "/foos/{id}", Tags: tags}, foosUpdate)
|
||||||
|
Register(api, huma.Operation{OperationID: "foos-delete", Summary: "Delete a foo", Description: "...", Method: http.MethodDelete, Path: "/foos/{id}", Tags: tags}, foosDelete)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the package's `Register` wrapper, **not** `huma.Register` directly — it sets `DefaultStatus` from the verb (POST → 201, DELETE → 204). Don't spell out `DefaultStatus` unless you need a non-default code. Don't set `Security:` per operation — it's applied globally in `NewAPI`.
|
||||||
|
|
||||||
|
**Every operation needs a `Summary` and `Description`.** v2's OpenAPI spec is generated from these `Operation` fields at runtime — unlike v1's swaggo, Huma cannot read Go doc comments, so anything you don't put in the `Operation` (or in a `doc:` tag, see below) is simply absent from the spec and the docs UI. An operation without them ships undocumented.
|
||||||
|
|
||||||
|
**Make the description document the non-obvious — don't restate the verb+noun.** "Deletes a label" adds nothing over `DELETE /labels/{id}`. Spend the description on what a consumer *can't* infer from the method/path/schema: permission scope ("only the owner may delete it"; "returns only labels you can see, not a global list"), full-replace vs partial (PUT replaces, PATCH merges), read-only/conditional behavior (ETag → `If-None-Match` → 304), side effects (create sets ownership), non-obvious status codes. If the honest description is just the verb+noun, a short summary alone is fine — don't pad. See `labels.go` for the calibration.
|
||||||
|
|
||||||
|
### 2. Write the handlers
|
||||||
|
|
||||||
|
Every handler: pull auth with `authFromCtx(ctx)`, call the matching `handler.Do*`, wrap returned errors in `translateDomainError`. Use the shared envelopes from `types.go` (`singleBody`, `singleReadBody`, `emptyBody`, `ListParams`, `Paginated`/`NewPaginated`).
|
||||||
|
|
||||||
|
- **List** takes `*ListParams` (gives you `page`/`per_page`/`q` for free, already `doc:`-tagged in `types.go` — no need to re-document them) and returns `*fooListBody`. **You must type-assert the `DoReadAll` result to the concrete slice** — `result` is `any`, and a blind cast or a generic wrapper silently serialises `[]` (the "generic-any silent-empty trap"). Return a hard error on mismatch:
|
||||||
|
```go
|
||||||
|
items, ok := result.([]*models.Foo)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("foos.ReadAll returned unexpected type %T", result)
|
||||||
|
}
|
||||||
|
return &fooListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
|
||||||
|
```
|
||||||
|
- **Extra query params go *directly* on the handler's input struct — not in a shared/embedded helper.** Beyond `ListParams`, if an operation needs its own query params (`expand`, `order_by`, `include_public`, …), declare each as a direct field with its own `query:"…"` tag on that operation's input struct, then bind it onto the model. A shared or embedded struct of query fields silently **fails to bind** under Huma when combined with other query params/embeds — the field arrives empty (hit while implementing Project's `expand`). Flatten them into the input struct.
|
||||||
|
- **Read** embeds `conditional.Params` in its input. To surface the caller's permission, define a small per-resource response struct that **embeds the model by value** and adds the permission: `type fooReadBody struct { models.Foo; MaxPermission models.Permission \`json:"max_permission" readOnly:"true" doc:"..."\` }`. Go and Huma both promote the embedded model's fields, so the wire shape is flat (model fields + `max_permission`) with no custom marshaler and nothing added to the shared model struct. Capture `DoReadOne`'s returned max permission (it is `0`/`1`/`2` on success — **never discard it as `_`**), build the body, and `return conditionalReadResponse(&in.Params, body, foo.Updated, maxPermission)`. The shared helper (in `types.go`) folds the permission into the ETag (so a share/role change invalidates the cache), applies the conditional precondition (304/412), and returns `*singleReadBody[fooReadBody]`. See `labels.go`/`project_views.go`. (A generic `struct{ T; ... }` is impossible — Go forbids embedding a type parameter — so the per-resource struct is the price of a flat shape without a marshaler.)
|
||||||
|
- **Create / Update** return `*singleBody[Model]` and set the model's `ID` from the path (URL wins over body). **Update's request body must be the same `fooReadBody` the read returns, not the bare model** — AutoPatch's GET→PUT round trip echoes the read body (max_permission included) into the PUT, and because `max_permission` is a declared `readOnly` property of `fooReadBody`'s schema, Huma accepts and ignores it on write rather than rejecting it. Take `&in.Body.Foo` (the embedded model — value-embedded, so never nil) and ignore the embedded `MaxPermission`. Create stays a bare `Body Model` (AutoPatch only round-trips into PUT).
|
||||||
|
- **Delete** returns `*emptyBody`.
|
||||||
|
|
||||||
|
### 3. Self-register the resource
|
||||||
|
|
||||||
|
Resources self-register — **you do not edit `pkg/routes/routes.go`**. In your resource file, add an `init()` that hands your registrar to `AddRouteRegistrar`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func init() { AddRouteRegistrar(RegisterFooRoutes) }
|
||||||
|
|
||||||
|
func RegisterFooRoutes(api huma.API) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
`registerAPIRoutesV2` in `routes.go` calls `apiv2.RegisterAll(api)`, which runs every registered registrar (in init/filename order — route order is irrelevant) and then `EnableAutoPatch`. New resources touch zero shared lines, so they never conflict on `routes.go`.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- **Give each registrar a DISTINCT name.** They share package `apiv2`, so two resources both exporting `RegisterAvatarRoutes` collide and won't compile — that actually happened and the upload one had to be renamed (`RegisterAvatarRoutes` for the binary endpoint vs `RegisterAvatarUploadRoutes` for the upload). Name yours after the specific resource.
|
||||||
|
- **Config-gated resources check the flag inside the registrar.** `RegisterAll` runs at request-router-setup time, after config is loaded, so a `RegisterFooRoutes` may early-return (or skip individual `Register` calls) based on `config.FooEnabled.GetBool()`. Don't try to gate at `init()` time — config isn't loaded yet.
|
||||||
|
- **AutoPatch is automatic.** `RegisterAll` calls `EnableAutoPatch` after all registrars — don't call it yourself, and don't register a manual PATCH (see "What's automatic").
|
||||||
|
|
||||||
|
## REST verb conventions (v2 inverts v1)
|
||||||
|
|
||||||
|
| Operation | v1 | v2 |
|
||||||
|
|---|---|---|
|
||||||
|
| create | PUT | **POST** |
|
||||||
|
| update | POST | **PUT** (and PATCH) |
|
||||||
|
| read / read-all / delete | GET / GET / DELETE | same |
|
||||||
|
|
||||||
|
## Non-CRUDable / custom routes
|
||||||
|
|
||||||
|
Not everything is plain CRUD — bulk operations, custom actions (`POST /tasks/{id}/duplicate`), sub-resource toggles, RPC-ish endpoints. These still go through Huma and reuse most of the machinery, but two responsibilities move **into your handler** because there's no `handler.Do*` doing them for you:
|
||||||
|
|
||||||
|
1. **Permission enforcement is now yours.** This is the one place the "never check permissions in the handler" rule inverts. With no generic `Do*` to call the model's `Can*`, the handler must do it explicitly — load the relevant entity and call its permission method, then refuse on denial. Mirror the v1 custom-handler shape (`pkg/routes/api/v1/task_attachment.go`):
|
||||||
|
```go
|
||||||
|
func tasksDuplicate(ctx context.Context, in *struct{ ID int64 `path:"id"` }) (*singleBody[models.Task], error) {
|
||||||
|
a, err := authFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
t := &models.Task{ID: in.ID}
|
||||||
|
can, err := t.CanUpdate(s, a) // or whichever Can* gates this action
|
||||||
|
if err != nil {
|
||||||
|
_ = s.Rollback()
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
if !can {
|
||||||
|
return nil, huma.Error403Forbidden("forbidden")
|
||||||
|
}
|
||||||
|
// ... do the work against s ...
|
||||||
|
if err := s.Commit(); err != nil {
|
||||||
|
return nil, translateDomainError(err)
|
||||||
|
}
|
||||||
|
return &singleBody[models.Task]{Body: t}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. **Session / transaction management is now yours.** The `Do*` helpers open and commit their own `xorm.Session`; custom handlers open one with `db.NewSession()`, `defer s.Close()`, and `Commit`/`Rollback` explicitly for anything that writes.
|
||||||
|
|
||||||
|
Otherwise the same rules apply: register with the `Register` wrapper, pull auth via `authFromCtx`, route every error through `translateDomainError`, and reuse the `types.go` envelopes — or define a small body struct when none fits (don't bend a custom response into `singleBody` if it's awkward).
|
||||||
|
|
||||||
|
**Verb choice:** pick by semantics, not the CRUD table. Non-idempotent actions are `POST`. AutoPatch only synthesises PATCH for GET+PUT *pairs*, so standalone custom routes are never touched.
|
||||||
|
|
||||||
|
**Token permissions still automatic, but mind the derived name:** `collectRoutesForAPITokens` keys a route off its prefix-stripped path, so `POST /api/v2/tasks/{id}/duplicate` lands under the `tasks` group as a `duplicate` permission. Single-segment custom paths fall into the `other` group. Name the path so the derived `(group, permission)` reads sensibly — that string is what users grant tokens against.
|
||||||
|
|
||||||
|
## What's automatic — do NOT hand-roll
|
||||||
|
|
||||||
|
- **PATCH** — `EnableAutoPatch` synthesises a JSON-Merge-Patch PATCH for every GET+PUT pair. `RegisterAll` invokes it after all registrars, so it's automatic — don't call `EnableAutoPatch` and don't register PATCH yourself.
|
||||||
|
- **API token permissions** — `collectRoutesForAPITokens` walks the Echo router after registration, so your new routes land in the v2 token table automatically under the same `(group, permission)` keys as their v1 names. PATCH is intentionally not stored; `CanDoAPIRoute` accepts it as an alias for the stored PUT (see `pkg/models/api_routes.go`).
|
||||||
|
- **Security schemes** — `JWTKeyAuth` + `APITokenAuth` are declared globally in `NewAPI`. For a public endpoint, set `Security: []map[string][]string{}` on that operation and add its path to `unauthenticatedAPIPaths` in `routes.go`.
|
||||||
|
- **Error shape** — `translateDomainError` maps any `web.HTTPErrorProcessor` (e.g. `ErrFooDoesNotExist`) onto Huma's status error, producing RFC 9457 `application/problem+json`. Errors without HTTP semantics become 500.
|
||||||
|
- **OpenAPI spec / Scalar docs / `$schema` URLs** — handled in `huma.go`. Leave `Servers` alone (the relative entry must stay at index 0).
|
||||||
|
|
||||||
|
## Anti-patterns (these get flagged)
|
||||||
|
|
||||||
|
- Re-checking permissions in the handler instead of trusting `handler.Do*` → the model's `Can*`.
|
||||||
|
- Blind `result.([]*models.Foo)` without the `ok` check, or returning the `any` straight into the envelope — silent empty lists.
|
||||||
|
- `huma.Register` instead of the package `Register` wrapper (loses the verb-based status).
|
||||||
|
- Per-operation `Security:` lines (now global) or registering a manual PATCH (AutoPatch does it).
|
||||||
|
- Returning a raw model error instead of routing it through `translateDomainError` → leaks a 500 instead of the right code.
|
||||||
|
- Unquoted ETag in the response header.
|
||||||
|
- Operations without `Summary`/`Description`, or model fields without `doc:` tags — they ship undocumented because Huma can't read Go comments.
|
||||||
|
- Server-controlled fields (`id`, `created`, `updated`, `created_by`) on a shared input/output model left without `readOnly:"true"` — the docs then present them as writable request fields.
|
||||||
|
|
||||||
|
## Tests (mandatory)
|
||||||
|
|
||||||
|
Mirror the v1 webtest shape so v2 parity is readable side-by-side. Use the `webHandlerTestV2` harness in `pkg/webtests/integrations.go` — it takes the same `urlParams` map as v1's `webHandlerTest`. See `pkg/webtests/huma_label_test.go`:
|
||||||
|
|
||||||
|
- One `Test<Resource>` covering list/read/create/update/delete, positive + negative (forbidden, nonexistent), mirroring the v1 model test.
|
||||||
|
- v2-only behaviour (ETag/304, PATCH merge-patch) goes in separate top-level `Test<Resource>_*` funcs using the `humaRequest`/`humaTokenFor` helpers in `pkg/webtests/huma_helpers_test.go`.
|
||||||
|
- The RFC 9457 error-body shape is asserted **once** globally in `TestHuma_ErrorShapeIsRFC9457` — don't re-assert the full problem+json shape per resource, just the status code.
|
||||||
|
|
||||||
|
Run with `mage test:filter Test<Resource>` while iterating. **Caveat:** `mage test:filter` injects `-short`, which makes `pkg/webtests` skip entirely (the suite short-circuits in short mode), so it silently reports success without running your webtest. To actually exercise a single webtest, run it directly: `go test -run '<Name>' ./pkg/webtests/`. Save output to a file per the project test-output rule.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `crudable` skill — the model-layer prerequisite
|
||||||
|
- `pkg/routes/api/v2/labels.go` — reference resource
|
||||||
|
- `pkg/routes/api/v2/{types,errors,huma}.go` — shared envelopes, bridge, config
|
||||||
|
- `pkg/web/handler/core.go` — the `Do*` functions handlers call
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
name: crudable
|
||||||
|
description: Use when adding or modifying a model in pkg/models/ that needs CRUD operations or permission checks. Covers Can* method placement, CRUDable interface, and required test coverage.
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# CRUDable + Permissions
|
||||||
|
|
||||||
|
Models in `pkg/models/` that expose CRUD operations must implement the `CRUDable` interface **and** the permission methods. Permissions are enforced at the **model level** via `Can*` methods — never re-checked in route handlers.
|
||||||
|
|
||||||
|
**Reference docs:** read `pkg/web/readme.md` for the full interface definitions, DB session semantics, and call order. The interface lives at `pkg/web/web.go`. This skill is a checklist of what the review feedback surfaces on top of that.
|
||||||
|
|
||||||
|
## Before writing CRUD or route code
|
||||||
|
|
||||||
|
1. Decide which operations the model needs: Read / ReadAll / Create / Update / Delete.
|
||||||
|
2. Implement the matching permission methods on the model. Typical signatures:
|
||||||
|
- `CanRead(s *xorm.Session, a web.Auth) (bool, int, error)`
|
||||||
|
- `CanCreate(s *xorm.Session, a web.Auth) (bool, error)`
|
||||||
|
- `CanUpdate(s *xorm.Session, a web.Auth) (bool, error)`
|
||||||
|
- `CanDelete(s *xorm.Session, a web.Auth) (bool, error)`
|
||||||
|
3. If a handler or service needs to check access, call the `Can*` method. Do **not** re-implement the check inline or duplicate the logic in `pkg/routes/`.
|
||||||
|
4. Do not implement empty stub methods just to satisfy the interface, instead embed the interface in the struct. Check existing models to see how that's done.
|
||||||
|
|
||||||
|
Look at `pkg/models/project.go` or `pkg/models/task.go` for reference implementations.
|
||||||
|
|
||||||
|
The initial querying of the data should happen in the Can* function. Because we're operating on a pointer, the function that does the work should not need to re-query the model data.
|
||||||
|
|
||||||
|
## Anti-patterns (these get flagged every time)
|
||||||
|
|
||||||
|
- Permission logic inlined in `pkg/routes/` handlers instead of on the model.
|
||||||
|
- Shipping `Create` but forgetting `CanUpdate` / `CanDelete` because "only create is new right now".
|
||||||
|
- Re-querying the DB in the handler to decide access — that work belongs in `CanRead`.
|
||||||
|
- Copy-pasting permission logic across `CanUpdate` and `CanDelete` — extract a helper.
|
||||||
|
- Adding a handler that bypasses the generic CRUD handler in `pkg/web/handler/` without a clear reason (the generic handler already invokes the `Can*` methods for you).
|
||||||
|
|
||||||
|
## Tests (mandatory)
|
||||||
|
|
||||||
|
Every `Can*` method needs both positive and negative coverage. Run with `mage test:filter <TestName>` while iterating.
|
||||||
|
|
||||||
|
- User with direct permission → passes
|
||||||
|
- User without permission → denied
|
||||||
|
- Permission inherited via parent (e.g., project → task, team → project) → still passes
|
||||||
|
- Shared access edge cases (link shares, team membership) if the model supports them
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Generic CRUD handler: `pkg/web/handler/`
|
||||||
|
- Permission type definitions: `pkg/web/auth.go`, `pkg/models/permissions.go`
|
||||||
|
- After the model is stable, register the routes in `pkg/routes/api/v1/` and add Swagger annotations. Do not edit `pkg/swagger/` directly — it's generated.
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
---
|
||||||
|
name: migration
|
||||||
|
description: Use when creating or editing files in pkg/migration/. Covers cross-DB type safety across MySQL/PostgreSQL/SQLite, DDL error handling, time-column conventions, and path sanitization.
|
||||||
|
user-invocable: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
Migrations are **irreversible in production**. Vikunja supports MySQL, PostgreSQL, and SQLite — every migration must work on all three.
|
||||||
|
|
||||||
|
## Before writing
|
||||||
|
|
||||||
|
1. Generate the skeleton: `mage dev:make-migration <StructName>`.
|
||||||
|
2. The migration struct must mirror the model in `pkg/models/` exactly (field names, types, xorm tags).
|
||||||
|
3. Use `time.Time` for time columns. Never use `string`, `varchar`, or `text` for times.
|
||||||
|
4. For renames or type changes, verify the conversion is safe on all three DBs:
|
||||||
|
- MySQL will silently coerce `VARCHAR` → `BIGINT` during `ALTER`. Don't rely on that — migrate data explicitly.
|
||||||
|
- SQLite has limited `ALTER TABLE`; prefer `xorm` migration helpers over raw SQL when possible.
|
||||||
|
- PostgreSQL is strict about types; explicit casts are often required.
|
||||||
|
|
||||||
|
## Error handling on DDL
|
||||||
|
|
||||||
|
Every error from `tx.Exec`, `session.Exec`, or xorm calls must be handled. Silent discards are the most commonly flagged bug in migration reviews.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// WRONG — silently drops errors; migration reports success even on failure
|
||||||
|
_, _ = tx.Exec("CREATE INDEX idx_foo ON bar(baz)")
|
||||||
|
|
||||||
|
// RIGHT — error is returned so the migration rolls back cleanly
|
||||||
|
if _, err := tx.Exec("CREATE INDEX idx_foo ON bar(baz)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If you **must** discard a DB error (e.g., idempotent best-effort cleanup where the index might already exist), write a one-line comment explaining why. No comment = reviewer will flag it.
|
||||||
|
|
||||||
|
## Path and user input
|
||||||
|
|
||||||
|
If the migration touches user-supplied paths, filenames, or import blobs (restore, dump, import modules under `pkg/modules/migration/`), sanitize before use. Never `filepath.Join` raw input. Watch for `..` traversal in archive entry names.
|
||||||
|
|
||||||
|
## Model and frontend sync
|
||||||
|
|
||||||
|
- If the migration adds or changes a field, update the struct in `pkg/models/` with matching xorm tags.
|
||||||
|
- Update the TypeScript interface in `frontend/src/modelTypes/` to match the Go struct shape. Frontend services must match backend model structure exactly.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Migrations don't have dedicated unit tests, but the model's feature tests must pass against the new schema. Run `mage test:feature` (uses SQLite by default).
|
||||||
|
- If you suspect DB-specific behavior, flag it in the PR description so reviewers know to verify against MySQL/PostgreSQL.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Existing examples: browse `pkg/migration/` for patterns; recent files are usually the cleanest references.
|
||||||
|
- Never edit `pkg/swagger/` (generated).
|
||||||
|
- Never commit `config.yml.sample` (generated by `mage generate:config-yaml`).
|
||||||
3
.envrc
3
.envrc
|
|
@ -1,3 +0,0 @@
|
||||||
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
|
|
||||||
|
|
||||||
use devenv
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
self-hosted-runner:
|
||||||
|
# Custom labels from third-party runner providers used in our workflows.
|
||||||
|
# Listed here so actionlint doesn't flag them as unknown.
|
||||||
|
labels:
|
||||||
|
- namespace-profile-default
|
||||||
|
- blacksmith-8vcpu-ubuntu-2204
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
name: Release binaries
|
||||||
|
description: |
|
||||||
|
Build, sign, and publish release binaries for a Vikunja sub-project.
|
||||||
|
|
||||||
|
Derives every per-project path, cache key, artifact name, and S3 target
|
||||||
|
from the `project` input. Callers only need to provide the project name,
|
||||||
|
the raw `git describe` value, and pass through the GPG/S3 secrets as
|
||||||
|
inputs (composite actions can't read the `secrets` context directly).
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
project:
|
||||||
|
description: 'Which project to build: "vikunja" or "veans".'
|
||||||
|
required: true
|
||||||
|
release-version:
|
||||||
|
description: |
|
||||||
|
Raw git describe value (e.g. v1.2.3 or v2.3.0-408-ge053d317). Always
|
||||||
|
passed through to the build so the binary embeds the precise commit.
|
||||||
|
Filenames and the S3 directory use "unstable" instead whenever
|
||||||
|
github.ref_type isn't "tag".
|
||||||
|
required: true
|
||||||
|
# Secrets — composite actions can't read the `secrets` context directly, so
|
||||||
|
# the caller threads them through as inputs.
|
||||||
|
gpg-passphrase:
|
||||||
|
required: true
|
||||||
|
gpg-sign-key:
|
||||||
|
required: true
|
||||||
|
s3-access-key-id:
|
||||||
|
required: true
|
||||||
|
s3-secret-access-key:
|
||||||
|
required: true
|
||||||
|
s3-endpoint:
|
||||||
|
required: true
|
||||||
|
s3-bucket:
|
||||||
|
required: true
|
||||||
|
s3-region:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Set project paths
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PROJECT: ${{ inputs.project }}
|
||||||
|
RELEASE_VERSION_INPUT: ${{ inputs.release-version }}
|
||||||
|
VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
case "$PROJECT" in
|
||||||
|
vikunja|veans) ;;
|
||||||
|
*)
|
||||||
|
echo "::error::Unknown project '$PROJECT'. Expected 'vikunja' or 'veans'." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$PROJECT" in
|
||||||
|
vikunja)
|
||||||
|
output_dir="."
|
||||||
|
dist_prefix="dist"
|
||||||
|
;;
|
||||||
|
veans)
|
||||||
|
output_dir="veans"
|
||||||
|
dist_prefix="veans/dist"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "PROJECT=$PROJECT"
|
||||||
|
echo "RELEASE_VERSION=$RELEASE_VERSION_INPUT"
|
||||||
|
echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE"
|
||||||
|
echo "XGO_OUT_NAME=${PROJECT}-${VERSION_OR_UNSTABLE}"
|
||||||
|
echo "OUTPUT_DIR=$output_dir"
|
||||||
|
echo "DIST_PREFIX=$dist_prefix"
|
||||||
|
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}"
|
||||||
|
echo "ARTIFACT_BINARIES_NAME=${PROJECT}_bins"
|
||||||
|
echo "ARTIFACT_ZIPS_NAME=${PROJECT}_bin_packages"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Download Mage binary
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
name: mage_bin
|
||||||
|
|
||||||
|
- name: Make mage-static executable
|
||||||
|
shell: bash
|
||||||
|
run: chmod +x ./mage-static
|
||||||
|
|
||||||
|
- name: Download frontend dist (vikunja only)
|
||||||
|
if: inputs.project == 'vikunja'
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
name: frontend_dist
|
||||||
|
path: frontend/dist
|
||||||
|
|
||||||
|
- name: Generate config.yml.sample (vikunja only)
|
||||||
|
if: inputs.project == 'vikunja'
|
||||||
|
shell: bash
|
||||||
|
run: ./mage-static generate:config-yaml 1
|
||||||
|
|
||||||
|
- name: Install upx
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
wget -q https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz
|
||||||
|
echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||||
|
sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256
|
||||||
|
tar xf upx-5.0.0-amd64_linux.tar.xz
|
||||||
|
sudo mv upx-5.0.0-amd64_linux/upx /usr/local/bin
|
||||||
|
|
||||||
|
- name: Setup xgo cache
|
||||||
|
uses: useblacksmith/cache@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.1.0
|
||||||
|
with:
|
||||||
|
path: /home/runner/.xgo-cache
|
||||||
|
key: xgo-${{ inputs.project }}-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
xgo-${{ inputs.project }}-
|
||||||
|
|
||||||
|
- name: Install mage for the build module
|
||||||
|
shell: bash
|
||||||
|
run: go install github.com/magefile/mage@v1.17.2
|
||||||
|
|
||||||
|
- name: Build release artifacts
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ env.RELEASE_VERSION }}
|
||||||
|
XGO_OUT_NAME: ${{ env.XGO_OUT_NAME }}
|
||||||
|
PROJECT: ${{ env.PROJECT }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="$PATH:$(go env GOPATH)/bin"
|
||||||
|
cd build && mage release:build "$PROJECT"
|
||||||
|
|
||||||
|
- name: GPG setup
|
||||||
|
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||||
|
with:
|
||||||
|
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||||
|
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||||
|
|
||||||
|
- name: Sign zips
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DIST_PREFIX: ${{ env.DIST_PREFIX }}
|
||||||
|
RELEASE_GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
zip_dir="${DIST_PREFIX}/zip"
|
||||||
|
echo "=== GPG agent status ==="
|
||||||
|
gpg-connect-agent 'keyinfo --list' /bye || true
|
||||||
|
echo "=== GPG secret keys ==="
|
||||||
|
gpg -K --with-keygrip
|
||||||
|
echo "=== GPG public keys ==="
|
||||||
|
gpg --list-keys
|
||||||
|
echo "=== Signing files in $zip_dir ==="
|
||||||
|
ls -hal "$zip_dir"/*
|
||||||
|
for file in "$zip_dir"/*; do
|
||||||
|
gpg -v \
|
||||||
|
--default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
|
||||||
|
-b --batch --yes \
|
||||||
|
--passphrase "$RELEASE_GPG_PASSPHRASE" \
|
||||||
|
--pinentry-mode loopback \
|
||||||
|
--sign "$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload zips to S3
|
||||||
|
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||||
|
with:
|
||||||
|
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||||
|
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||||
|
s3-endpoint: ${{ inputs.s3-endpoint }}
|
||||||
|
s3-bucket: ${{ inputs.s3-bucket }}
|
||||||
|
s3-region: ${{ inputs.s3-region }}
|
||||||
|
target-path: ${{ env.S3_TARGET_PATH }}
|
||||||
|
files: ${{ env.DIST_PREFIX }}/zip/*
|
||||||
|
strip-path-prefix: ${{ env.DIST_PREFIX }}/zip/
|
||||||
|
|
||||||
|
- name: Store binaries
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
|
with:
|
||||||
|
name: ${{ env.ARTIFACT_BINARIES_NAME }}
|
||||||
|
path: ./${{ env.DIST_PREFIX }}/binaries/*
|
||||||
|
|
||||||
|
- name: Store binary packages
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
|
with:
|
||||||
|
name: ${{ env.ARTIFACT_ZIPS_NAME }}
|
||||||
|
path: ./${{ env.DIST_PREFIX }}/zip/*
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
name: Release OS package
|
||||||
|
description: >
|
||||||
|
Build a single deb/rpm/apk/archlinux package for the given project + arch
|
||||||
|
via nfpm, optionally GPG-sign it (archlinux is signed inline; rpm is signed
|
||||||
|
by nfpm itself), upload it to S3, and store it as a workflow artifact.
|
||||||
|
|
||||||
|
Most paths and names are derived from `project`; the matrix only needs to
|
||||||
|
supply the per-arch and per-format inputs.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
project:
|
||||||
|
description: 'Project name (vikunja | veans). Drives all derived paths.'
|
||||||
|
required: true
|
||||||
|
release-version:
|
||||||
|
description: |
|
||||||
|
RELEASE_VERSION env value — the same version that ended up in the
|
||||||
|
binaries artifact. Always embedded in the package metadata via
|
||||||
|
nfpm; filenames and the S3 directory use "unstable" instead
|
||||||
|
whenever github.ref_type isn't "tag".
|
||||||
|
required: true
|
||||||
|
packager:
|
||||||
|
description: 'nfpm packager: rpm | deb | apk | archlinux.'
|
||||||
|
required: true
|
||||||
|
nfpm-arch:
|
||||||
|
description: 'nfpm arch field (amd64 | arm64 | arm7).'
|
||||||
|
required: true
|
||||||
|
pkg-arch:
|
||||||
|
description: 'Package-format arch used in the output filename (x86_64 | aarch64 | armv7).'
|
||||||
|
required: true
|
||||||
|
go-name:
|
||||||
|
description: 'Go-style arch token used in the binary filename (linux-amd64 | linux-arm64 | linux-arm-7).'
|
||||||
|
required: true
|
||||||
|
# Secrets — composite actions can't read `${{ secrets.* }}` directly, so the
|
||||||
|
# caller threads them through as inputs.
|
||||||
|
gpg-passphrase:
|
||||||
|
required: true
|
||||||
|
gpg-sign-key:
|
||||||
|
required: true
|
||||||
|
s3-access-key-id:
|
||||||
|
required: true
|
||||||
|
s3-secret-access-key:
|
||||||
|
required: true
|
||||||
|
s3-endpoint:
|
||||||
|
required: true
|
||||||
|
s3-bucket:
|
||||||
|
required: true
|
||||||
|
s3-region:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Set project paths
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PROJECT: ${{ inputs.project }}
|
||||||
|
RELEASE_VERSION: ${{ inputs.release-version }}
|
||||||
|
VERSION_OR_UNSTABLE: ${{ github.ref_type == 'tag' && inputs.release-version || 'unstable' }}
|
||||||
|
PACKAGER: ${{ inputs.packager }}
|
||||||
|
PKG_ARCH: ${{ inputs.pkg-arch }}
|
||||||
|
GO_NAME: ${{ inputs.go-name }}
|
||||||
|
run: |
|
||||||
|
case "$PROJECT" in
|
||||||
|
vikunja)
|
||||||
|
echo "BINARIES_DOWNLOAD_PATH=." >> "$GITHUB_ENV"
|
||||||
|
echo "STAGED_BINARY_PATH=./vikunja" >> "$GITHUB_ENV"
|
||||||
|
echo "NFPM_BIN_PATH=" >> "$GITHUB_ENV"
|
||||||
|
echo "NFPM_CONFIG_PATH=./nfpm.yaml" >> "$GITHUB_ENV"
|
||||||
|
# No leading "./" — the s3-action's strip-path-prefix must
|
||||||
|
# match the glob output exactly, and the glob doesn't emit it.
|
||||||
|
echo "PACKAGE_OUTPUT_DIR=dist/os-packages" >> "$GITHUB_ENV"
|
||||||
|
;;
|
||||||
|
veans)
|
||||||
|
echo "BINARIES_DOWNLOAD_PATH=./veans-binaries" >> "$GITHUB_ENV"
|
||||||
|
echo "STAGED_BINARY_PATH=./veans/veans-bin" >> "$GITHUB_ENV"
|
||||||
|
echo "NFPM_BIN_PATH=./veans/veans-bin" >> "$GITHUB_ENV"
|
||||||
|
echo "NFPM_CONFIG_PATH=./veans/nfpm.yaml" >> "$GITHUB_ENV"
|
||||||
|
echo "PACKAGE_OUTPUT_DIR=veans/dist/os-packages" >> "$GITHUB_ENV"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::error::unknown project '$PROJECT' (expected vikunja|veans)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "VERSION_OR_UNSTABLE=$VERSION_OR_UNSTABLE" >> "$GITHUB_ENV"
|
||||||
|
echo "BINARIES_ARTIFACT_NAME=${PROJECT}_bins" >> "$GITHUB_ENV"
|
||||||
|
echo "BINARY_GLOB=${PROJECT}-*-${GO_NAME}" >> "$GITHUB_ENV"
|
||||||
|
echo "PACKAGE_FILENAME=${PROJECT}-${VERSION_OR_UNSTABLE}-${PKG_ARCH}.${PACKAGER}" >> "$GITHUB_ENV"
|
||||||
|
echo "ARTIFACT_NAME=${PROJECT}_os_package_${PACKAGER}_${PKG_ARCH}" >> "$GITHUB_ENV"
|
||||||
|
echo "S3_TARGET_PATH=/${PROJECT}/${VERSION_OR_UNSTABLE}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Download project binaries
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
name: ${{ env.BINARIES_ARTIFACT_NAME }}
|
||||||
|
path: ${{ env.BINARIES_DOWNLOAD_PATH }}
|
||||||
|
|
||||||
|
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
|
with:
|
||||||
|
go-version: stable
|
||||||
|
|
||||||
|
- name: Install mage
|
||||||
|
shell: bash
|
||||||
|
run: go install github.com/magefile/mage@v1.17.2
|
||||||
|
|
||||||
|
- name: Generate config.yml.sample (vikunja only)
|
||||||
|
# vikunja's nfpm.yaml ships ./config.yml.sample as /etc/vikunja/config.yml.
|
||||||
|
# release-binaries generates it for the zip bundles, but this job runs on a
|
||||||
|
# fresh runner, so we regenerate it here before nfpm packs it.
|
||||||
|
if: inputs.project == 'vikunja'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export PATH=$PATH:$GOPATH/bin
|
||||||
|
mage generate:config-yaml 1
|
||||||
|
|
||||||
|
- name: Write GPG key for nfpm
|
||||||
|
if: inputs.packager == 'rpm'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RELEASE_GPG_SIGN_KEY: ${{ inputs.gpg-sign-key }}
|
||||||
|
run: printf '%s' "$RELEASE_GPG_SIGN_KEY" > /tmp/nfpm-signing-key.gpg
|
||||||
|
|
||||||
|
- name: GPG setup for archlinux signing
|
||||||
|
if: inputs.packager == 'archlinux'
|
||||||
|
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||||
|
with:
|
||||||
|
gpg-passphrase: ${{ inputs.gpg-passphrase }}
|
||||||
|
gpg-sign-key: ${{ inputs.gpg-sign-key }}
|
||||||
|
|
||||||
|
- name: Prepare nfpm config
|
||||||
|
shell: bash
|
||||||
|
working-directory: build
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ inputs.release-version }}
|
||||||
|
NFPM_ARCH: ${{ inputs.nfpm-arch }}
|
||||||
|
NFPM_BIN_PATH: ${{ env.NFPM_BIN_PATH }}
|
||||||
|
PROJECT: ${{ inputs.project }}
|
||||||
|
run: |
|
||||||
|
export PATH=$PATH:$GOPATH/bin
|
||||||
|
mage release:prepare-nfpm-config "$PROJECT" "$NFPM_ARCH"
|
||||||
|
|
||||||
|
- name: Stage binary
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Resolve the single matching binary and mv it into place.
|
||||||
|
matched=()
|
||||||
|
for f in $BINARIES_DOWNLOAD_PATH/$BINARY_GLOB; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
matched+=("$f")
|
||||||
|
done
|
||||||
|
if [ ${#matched[@]} -ne 1 ]; then
|
||||||
|
echo "::error::expected exactly 1 binary matching '$BINARIES_DOWNLOAD_PATH/$BINARY_GLOB', found ${#matched[@]}"
|
||||||
|
ls -la "$BINARIES_DOWNLOAD_PATH" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$STAGED_BINARY_PATH")"
|
||||||
|
mv "${matched[0]}" "$STAGED_BINARY_PATH"
|
||||||
|
chmod +x "$STAGED_BINARY_PATH"
|
||||||
|
|
||||||
|
- name: Ensure package output dir exists
|
||||||
|
shell: bash
|
||||||
|
run: mkdir -p "$PACKAGE_OUTPUT_DIR"
|
||||||
|
|
||||||
|
- name: Create package
|
||||||
|
uses: kolaente/action-gh-nfpm@08460c16ce3baaa48eaf94d51eea0e653b15d955 # master
|
||||||
|
with:
|
||||||
|
packager: ${{ inputs.packager }}
|
||||||
|
target: ${{ env.PACKAGE_OUTPUT_DIR }}/${{ env.PACKAGE_FILENAME }}
|
||||||
|
config: ${{ env.NFPM_CONFIG_PATH }}
|
||||||
|
env:
|
||||||
|
NFPM_GPG_KEY_FILE: ${{ inputs.packager == 'rpm' && '/tmp/nfpm-signing-key.gpg' || '' }}
|
||||||
|
NFPM_PASSPHRASE: ${{ inputs.packager == 'rpm' && inputs.gpg-passphrase || '' }}
|
||||||
|
|
||||||
|
- name: Sign archlinux package
|
||||||
|
if: inputs.packager == 'archlinux'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GPG_PASSPHRASE: ${{ inputs.gpg-passphrase }}
|
||||||
|
run: |
|
||||||
|
gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
|
||||||
|
--batch --yes \
|
||||||
|
--passphrase "$GPG_PASSPHRASE" \
|
||||||
|
--pinentry-mode loopback \
|
||||||
|
--detach-sign \
|
||||||
|
"$PACKAGE_OUTPUT_DIR/$PACKAGE_FILENAME"
|
||||||
|
|
||||||
|
- name: Upload to S3
|
||||||
|
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||||
|
with:
|
||||||
|
s3-access-key-id: ${{ inputs.s3-access-key-id }}
|
||||||
|
s3-secret-access-key: ${{ inputs.s3-secret-access-key }}
|
||||||
|
s3-endpoint: ${{ inputs.s3-endpoint }}
|
||||||
|
s3-bucket: ${{ inputs.s3-bucket }}
|
||||||
|
s3-region: ${{ inputs.s3-region }}
|
||||||
|
target-path: ${{ env.S3_TARGET_PATH }}
|
||||||
|
files: ${{ env.PACKAGE_OUTPUT_DIR }}/*
|
||||||
|
strip-path-prefix: ${{ env.PACKAGE_OUTPUT_DIR }}/
|
||||||
|
|
||||||
|
- name: Store OS package
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
|
with:
|
||||||
|
name: ${{ env.ARTIFACT_NAME }}
|
||||||
|
path: ${{ env.PACKAGE_OUTPUT_DIR }}/*
|
||||||
|
|
@ -16,11 +16,11 @@ runs:
|
||||||
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
|
echo "CYPRESS_INSTALL_BINARY=0" >> $GITHUB_ENV
|
||||||
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
|
echo "PUPPETEER_SKIP_DOWNLOAD=true" >> $GITHUB_ENV
|
||||||
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $GITHUB_ENV
|
echo "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1" >> $GITHUB_ENV
|
||||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
package_json_file: frontend/package.json
|
package_json_file: frontend/package.json
|
||||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: frontend/.nvmrc
|
node-version-file: frontend/.nvmrc
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (for prompt template)
|
- name: Checkout (for prompt template)
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
sparse-checkout: |
|
sparse-checkout: |
|
||||||
.github/workflows/auto-label.prompt.md
|
.github/workflows/auto-label.prompt.md
|
||||||
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
|
|
||||||
- name: Render system prompt from live labels
|
- name: Render system prompt from live labels
|
||||||
id: render
|
id: render
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
env:
|
env:
|
||||||
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
|
PROMPT_TEMPLATE_PATH: .github/workflows/auto-label.prompt.md
|
||||||
with:
|
with:
|
||||||
|
|
@ -122,7 +122,7 @@ jobs:
|
||||||
|
|
||||||
- name: Classify with AI
|
- name: Classify with AI
|
||||||
id: classify
|
id: classify
|
||||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||||
with:
|
with:
|
||||||
model: openai/gpt-4.1-mini
|
model: openai/gpt-4.1-mini
|
||||||
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
|
# GPT-5 is a reasoning model: output tokens include reasoning, so budget generously.
|
||||||
|
|
@ -132,7 +132,7 @@ jobs:
|
||||||
prompt-file: ${{ steps.prep.outputs.prompt_path }}
|
prompt-file: ${{ steps.prep.outputs.prompt_path }}
|
||||||
|
|
||||||
- name: Apply labels
|
- name: Apply labels
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
env:
|
env:
|
||||||
AI_RESPONSE: ${{ steps.classify.outputs.response }}
|
AI_RESPONSE: ${{ steps.classify.outputs.response }}
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,19 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
- name: push source files
|
- name: push source files
|
||||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||||
with:
|
with:
|
||||||
command: 'push'
|
command: 'push'
|
||||||
env:
|
env:
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
- name: pull translations
|
- name: pull translations
|
||||||
uses: crowdin/github-action@b4b468cffefb50bdd99dd83e5d2eaeb63c880380 # v2
|
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||||
with:
|
with:
|
||||||
command: 'download'
|
command: 'download'
|
||||||
command_args: '--export-only-approved --skip-untranslated-strings'
|
command_args: '--export-only-approved --skip-untranslated-strings'
|
||||||
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: frontend/.nvmrc
|
node-version-file: frontend/.nvmrc
|
||||||
- name: Ensure file permissions
|
- name: Ensure file permissions
|
||||||
|
|
@ -41,7 +41,7 @@ jobs:
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
id: check_changes
|
id: check_changes
|
||||||
run: |
|
run: |
|
||||||
if git diff --quiet; then
|
if [ -z "$(git status --porcelain pkg/i18n/lang frontend/src/i18n/lang)" ]; then
|
||||||
echo "changes_exist=0" >> "$GITHUB_OUTPUT"
|
echo "changes_exist=0" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "changes_exist=1" >> "$GITHUB_OUTPUT"
|
echo "changes_exist=1" >> "$GITHUB_OUTPUT"
|
||||||
|
|
@ -51,10 +51,11 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "bot@vikunja.io"
|
git config --local user.email "bot@vikunja.io"
|
||||||
git config --local user.name "Frederick [Bot]"
|
git config --local user.name "Frederick [Bot]"
|
||||||
git commit -am "chore(i18n): update translations via Crowdin"
|
git add pkg/i18n/lang frontend/src/i18n/lang
|
||||||
|
git commit -m "chore(i18n): update translations via Crowdin"
|
||||||
- name: Push changes
|
- name: Push changes
|
||||||
if: steps.check_changes.outputs.changes_exist != '0'
|
if: steps.check_changes.outputs.changes_exist != '0'
|
||||||
uses: ad-m/github-push-action@master
|
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||||
with:
|
with:
|
||||||
ssh: true
|
ssh: true
|
||||||
branch: ${{ github.ref }}
|
branch: ${{ github.ref }}
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@ jobs:
|
||||||
directory: [frontend, desktop]
|
directory: [frontend, desktop]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Create Diff
|
- name: Create Diff
|
||||||
uses: e18e/action-dependency-diff@v1
|
uses: e18e/action-dependency-diff@8e9b8c1957ab066d36235a43f4c1ff1522e1bdbc # v1.6.1
|
||||||
with:
|
with:
|
||||||
working-directory: ${{ matrix.directory }}
|
working-directory: ${{ matrix.directory }}
|
||||||
|
|
||||||
|
|
@ -33,11 +33,11 @@ jobs:
|
||||||
directory: [frontend, desktop]
|
directory: [frontend, desktop]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Check provenance downgrades
|
- name: Check provenance downgrades
|
||||||
uses: danielroe/provenance-action@main
|
uses: danielroe/provenance-action@81568f71211c1839d6d3583c6a93037f5348c816 # main
|
||||||
with:
|
with:
|
||||||
workspace-path: ${{ matrix.directory }}
|
workspace-path: ${{ matrix.directory }}
|
||||||
fail-on-provenance-change: true
|
fail-on-provenance-change: true
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Generate GitHub App token
|
- name: Generate GitHub App token
|
||||||
id: generate-token
|
id: generate-token
|
||||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
|
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.BOT_APP_ID }}
|
app-id: ${{ secrets.BOT_APP_ID }}
|
||||||
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
- name: Find closing PR or commit
|
- name: Find closing PR or commit
|
||||||
id: find-closer
|
id: find-closer
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
|
|
@ -82,7 +82,7 @@ jobs:
|
||||||
|
|
||||||
- name: Comment on issue
|
- name: Comment on issue
|
||||||
if: steps.find-closer.outputs.closed_by_code == 'true'
|
if: steps.find-closer.outputs.closed_by_code == 'true'
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
docker-images: false
|
docker-images: false
|
||||||
swap-storage: false
|
swap-storage: false
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
# For pull_request_target, we need to explicitly fetch the PR ref from forks
|
# For pull_request_target, we need to explicitly fetch the PR ref from forks
|
||||||
# since the PR's commit SHA is not reachable in the base repository.
|
# since the PR's commit SHA is not reachable in the base repository.
|
||||||
|
|
@ -34,27 +34,27 @@ jobs:
|
||||||
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
ref: refs/pull/${{ github.event.pull_request.number }}/head
|
||||||
- name: Git describe
|
- name: Git describe
|
||||||
id: ghd
|
id: ghd
|
||||||
uses: proudust/gh-describe@v2
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
images: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=pr
|
type=ref,event=pr
|
||||||
type=sha,format=long
|
type=sha,format=long
|
||||||
- name: Build and push PR image
|
- name: Build and push PR image
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|
@ -66,7 +66,7 @@ jobs:
|
||||||
build-args: |
|
build-args: |
|
||||||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||||
- name: Comment on PR
|
- name: Comment on PR
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
env:
|
env:
|
||||||
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
|
DOCKER_META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,53 @@ on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
build-mage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: prepare-build-mage
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
|
with:
|
||||||
|
go-version: stable
|
||||||
|
- name: Cache build mage
|
||||||
|
id: cache-build-mage
|
||||||
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
key: ${{ runner.os }}-build-mage-build-${{ hashFiles('build/magefile.go') }}
|
||||||
|
path: |
|
||||||
|
./build/build-mage-static
|
||||||
|
# Statically compile build/magefile.go so publish-repos can run repo
|
||||||
|
# metadata targets inside ubuntu/fedora/archlinux containers without
|
||||||
|
# needing a Go toolchain available there.
|
||||||
|
- name: Install mage
|
||||||
|
if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }}
|
||||||
|
run: go install github.com/magefile/mage@v1.17.2
|
||||||
|
- name: Compile build mage
|
||||||
|
if: ${{ steps.cache-build-mage.outputs.cache-hit != 'true' }}
|
||||||
|
working-directory: build
|
||||||
|
run: |
|
||||||
|
export PATH=$PATH:$GOPATH/bin
|
||||||
|
mage -compile ./build-mage-static
|
||||||
|
- name: Store build mage binary
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
|
with:
|
||||||
|
name: build_mage_bin
|
||||||
|
path: ./build/build-mage-static
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
runs-on: namespace-profile-default
|
runs-on: namespace-profile-default
|
||||||
steps:
|
steps:
|
||||||
- name: Git describe
|
- name: Git describe
|
||||||
id: ghd
|
id: ghd
|
||||||
uses: proudust/gh-describe@v2
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
|
|
@ -24,7 +58,7 @@ jobs:
|
||||||
- name: Docker meta version
|
- name: Docker meta version
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
vikunja/vikunja
|
vikunja/vikunja
|
||||||
|
|
@ -36,7 +70,7 @@ jobs:
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
- name: Build and push unstable
|
- name: Build and push unstable
|
||||||
if: ${{ github.ref_type != 'tag' }}
|
if: ${{ github.ref_type != 'tag' }}
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||||
push: true
|
push: true
|
||||||
|
|
@ -47,7 +81,7 @@ jobs:
|
||||||
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
RELEASE_VERSION=${{ steps.ghd.outputs.describe }}
|
||||||
- name: Build and push version
|
- name: Build and push version
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8
|
||||||
push: true
|
push: true
|
||||||
|
|
@ -59,87 +93,40 @@ jobs:
|
||||||
binaries:
|
binaries:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Git describe
|
- name: Git describe
|
||||||
id: ghd
|
id: ghd
|
||||||
uses: proudust/gh-describe@v2
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
- uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6
|
- uses: ./.github/actions/release-binaries
|
||||||
with:
|
|
||||||
go-version: stable
|
|
||||||
- name: Download Mage Binary
|
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
||||||
with:
|
|
||||||
name: mage_bin
|
|
||||||
- name: get frontend
|
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
||||||
with:
|
|
||||||
name: frontend_dist
|
|
||||||
path: frontend/dist
|
|
||||||
- run: chmod +x ./mage-static
|
|
||||||
- name: install upx
|
|
||||||
run: |
|
|
||||||
wget https://github.com/upx/upx/releases/download/v5.0.0/upx-5.0.0-amd64_linux.tar.xz
|
|
||||||
echo 'b32abf118d721358a50f1aa60eacdbf3298df379c431c3a86f139173ab8289a1 upx-5.0.0-amd64_linux.tar.xz' > upx-5.0.0-amd64_linux.tar.xz.sha256
|
|
||||||
sha256sum -c upx-5.0.0-amd64_linux.tar.xz.sha256
|
|
||||||
tar xf upx-5.0.0-amd64_linux.tar.xz
|
|
||||||
mv upx-5.0.0-amd64_linux/upx /usr/local/bin
|
|
||||||
- name: setup xgo cache
|
|
||||||
uses: useblacksmith/cache@71c7c918062ba3861252d84b07fe5ab2a6b467a6 # v5
|
|
||||||
with:
|
|
||||||
path: /home/runner/.xgo-cache
|
|
||||||
key: ${{ hashFiles('**/go.sum') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-go-
|
|
||||||
- name: build and release
|
|
||||||
env:
|
|
||||||
RELEASE_VERSION: ${{ steps.ghd.outputs.describe }}
|
|
||||||
XGO_OUT_NAME: vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
|
|
||||||
run: |
|
|
||||||
export PATH=$PATH:$GOPATH/bin
|
|
||||||
./mage-static release
|
|
||||||
- name: GPG setup
|
|
||||||
uses: kolaente/action-gpg@main
|
|
||||||
with:
|
|
||||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
|
||||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
|
||||||
- name: sign
|
|
||||||
run: |
|
|
||||||
echo "=== GPG agent status ==="
|
|
||||||
gpg-connect-agent 'keyinfo --list' /bye || true
|
|
||||||
echo "=== GPG secret keys ==="
|
|
||||||
gpg -K --with-keygrip
|
|
||||||
echo "=== GPG public keys ==="
|
|
||||||
gpg --list-keys
|
|
||||||
echo "=== GNUPG directory contents ==="
|
|
||||||
ls -la ~/.gnupg/
|
|
||||||
ls -la ~/.gnupg/private-keys-v1.d/ || true
|
|
||||||
echo "=== Signing files ==="
|
|
||||||
ls -hal dist/zip/*
|
|
||||||
for file in dist/zip/*; do
|
|
||||||
gpg -v --default-key 7D061A4AA61436B40713D42EFF054DACD908493A -b --batch --yes --passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" --pinentry-mode loopback --sign "$file"
|
|
||||||
done
|
|
||||||
- name: Upload
|
|
||||||
uses: kolaente/s3-action@main
|
|
||||||
with:
|
with:
|
||||||
|
project: vikunja
|
||||||
|
release-version: ${{ steps.ghd.outputs.describe }}
|
||||||
|
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||||
|
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||||
s3-region: ${{ secrets.S3_REGION }}
|
s3-region: ${{ secrets.S3_REGION }}
|
||||||
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
|
|
||||||
files: "dist/zip/*"
|
veans-binaries:
|
||||||
strip-path-prefix: dist/zip/
|
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||||
- name: Store Binaries
|
steps:
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
- name: Git describe
|
||||||
|
id: ghd
|
||||||
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
|
- uses: ./.github/actions/release-binaries
|
||||||
with:
|
with:
|
||||||
name: vikunja_bins
|
project: veans
|
||||||
path: ./dist/binaries/*
|
release-version: ${{ steps.ghd.outputs.describe }}
|
||||||
- name: Store Binary Packages
|
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
with:
|
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||||
name: vikunja_bin_packages
|
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||||
path: ./dist/zip/*
|
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||||
|
s3-region: ${{ secrets.S3_REGION }}
|
||||||
|
|
||||||
os-package:
|
os-package:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -147,11 +134,7 @@ jobs:
|
||||||
- binaries
|
- binaries
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
package:
|
package: [rpm, deb, apk, archlinux]
|
||||||
- rpm
|
|
||||||
- deb
|
|
||||||
- apk
|
|
||||||
- archlinux
|
|
||||||
arch:
|
arch:
|
||||||
- go_name: linux-amd64
|
- go_name: linux-amd64
|
||||||
nfpm: amd64
|
nfpm: amd64
|
||||||
|
|
@ -164,77 +147,71 @@ jobs:
|
||||||
pkg: armv7
|
pkg: armv7
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Download Vikunja Binary
|
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
||||||
with:
|
|
||||||
name: vikunja_bins
|
|
||||||
- name: Git describe
|
- name: Git describe
|
||||||
id: ghd
|
id: ghd
|
||||||
uses: proudust/gh-describe@v2
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
- name: Download Mage Binary
|
- uses: ./.github/actions/release-os-package
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
|
||||||
with:
|
|
||||||
name: mage_bin
|
|
||||||
- name: Write GPG key for nfpm
|
|
||||||
if: matrix.package == 'rpm'
|
|
||||||
run: echo -n "${{ secrets.RELEASE_GPG_SIGN_KEY }}" > /tmp/nfpm-signing-key.gpg
|
|
||||||
- name: GPG setup for package signing
|
|
||||||
if: matrix.package == 'archlinux'
|
|
||||||
uses: kolaente/action-gpg@main
|
|
||||||
with:
|
|
||||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
|
||||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
|
||||||
- name: Prepare
|
|
||||||
env:
|
|
||||||
RELEASE_VERSION: ${{ steps.ghd.outputs.describe }}
|
|
||||||
NFPM_ARCH: ${{ matrix.arch.nfpm }}
|
|
||||||
run: |
|
|
||||||
chmod +x ./mage-static
|
|
||||||
./mage-static release:prepare-nfpm-config
|
|
||||||
mkdir -p ./dist/os-packages
|
|
||||||
mv ./vikunja-*-${{ matrix.arch.go_name }} ./vikunja
|
|
||||||
chmod +x ./vikunja
|
|
||||||
- name: Create package
|
|
||||||
id: nfpm
|
|
||||||
uses: kolaente/action-gh-nfpm@master
|
|
||||||
with:
|
with:
|
||||||
|
project: vikunja
|
||||||
|
release-version: ${{ steps.ghd.outputs.describe }}
|
||||||
packager: ${{ matrix.package }}
|
packager: ${{ matrix.package }}
|
||||||
target: ./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }}
|
nfpm-arch: ${{ matrix.arch.nfpm }}
|
||||||
config: ./nfpm.yaml
|
pkg-arch: ${{ matrix.arch.pkg }}
|
||||||
env:
|
go-name: ${{ matrix.arch.go_name }}
|
||||||
NFPM_GPG_KEY_FILE: ${{ (matrix.package == 'rpm') && '/tmp/nfpm-signing-key.gpg' || '' }}
|
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||||
NFPM_PASSPHRASE: ${{ (matrix.package == 'rpm') && secrets.RELEASE_GPG_PASSPHRASE || '' }}
|
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||||
- name: Sign package
|
|
||||||
if: matrix.package == 'archlinux'
|
|
||||||
run: |
|
|
||||||
gpg --default-key 7D061A4AA61436B40713D42EFF054DACD908493A \
|
|
||||||
--batch --yes \
|
|
||||||
--passphrase "${{ secrets.RELEASE_GPG_PASSPHRASE }}" \
|
|
||||||
--pinentry-mode loopback \
|
|
||||||
--detach-sign \
|
|
||||||
./dist/os-packages/vikunja-${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}-${{ matrix.arch.pkg }}.${{ matrix.package }}
|
|
||||||
- name: Upload
|
|
||||||
uses: kolaente/s3-action@main
|
|
||||||
with:
|
|
||||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||||
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||||
s3-bucket: ${{ secrets.S3_BUCKET }}
|
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||||
s3-region: ${{ secrets.S3_REGION }}
|
s3-region: ${{ secrets.S3_REGION }}
|
||||||
target-path: /vikunja/${{ github.ref_type == 'tag' && steps.ghd.outputs.describe || 'unstable' }}
|
|
||||||
files: "dist/os-packages/*"
|
veans-os-package:
|
||||||
strip-path-prefix: dist/os-packages/
|
runs-on: ubuntu-latest
|
||||||
- name: Store OS Packages
|
needs:
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
- veans-binaries
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
package: [rpm, deb, apk, archlinux]
|
||||||
|
arch:
|
||||||
|
- go_name: linux-amd64
|
||||||
|
nfpm: amd64
|
||||||
|
pkg: x86_64
|
||||||
|
- go_name: linux-arm64
|
||||||
|
nfpm: arm64
|
||||||
|
pkg: aarch64
|
||||||
|
- go_name: linux-arm-7
|
||||||
|
nfpm: arm7
|
||||||
|
pkg: armv7
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
- name: Git describe
|
||||||
|
id: ghd
|
||||||
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
|
- uses: ./.github/actions/release-os-package
|
||||||
with:
|
with:
|
||||||
name: vikunja_os_package_${{ matrix.package }}_${{ matrix.arch.pkg }}
|
project: veans
|
||||||
path: ./dist/os-packages/*
|
release-version: ${{ steps.ghd.outputs.describe }}
|
||||||
|
packager: ${{ matrix.package }}
|
||||||
|
nfpm-arch: ${{ matrix.arch.nfpm }}
|
||||||
|
pkg-arch: ${{ matrix.arch.pkg }}
|
||||||
|
go-name: ${{ matrix.arch.go_name }}
|
||||||
|
gpg-passphrase: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||||
|
gpg-sign-key: ${{ secrets.RELEASE_GPG_SIGN_KEY }}
|
||||||
|
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
|
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||||
|
s3-endpoint: ${{ secrets.S3_ENDPOINT }}
|
||||||
|
s3-bucket: ${{ secrets.S3_BUCKET }}
|
||||||
|
s3-region: ${{ secrets.S3_REGION }}
|
||||||
|
|
||||||
publish-repos:
|
publish-repos:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
|
- build-mage
|
||||||
- os-package
|
- os-package
|
||||||
|
- veans-os-package
|
||||||
- desktop
|
- desktop
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
|
@ -258,22 +235,36 @@ jobs:
|
||||||
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
|
REPO_SUITE: ${{ github.ref_type == 'tag' && 'stable' || 'unstable' }}
|
||||||
RELEASE_VERSION: unstable
|
RELEASE_VERSION: unstable
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Download Mage Binary
|
- name: Download build mage binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
# Statically compiled in test.yml's build-mage job so it runs inside
|
||||||
|
# ubuntu/fedora/archlinux containers without a Go toolchain.
|
||||||
|
if: matrix.format != 'apk'
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: build_mage_bin
|
||||||
|
path: build
|
||||||
|
|
||||||
- name: Download all server OS packages
|
- name: Download all server OS packages
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
pattern: vikunja_os_package_*
|
pattern: vikunja_os_package_*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
path: dist/repo-work/incoming
|
path: dist/repo-work/incoming
|
||||||
|
|
||||||
|
- name: Download all veans OS packages
|
||||||
|
# Merged into the same incoming dir so reprepro / createrepo_c /
|
||||||
|
# repo-add / the apk loop pick them up alongside vikunja's packages
|
||||||
|
# — same suite, same arch fan-out, no extra source entry for users.
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
pattern: veans_os_package_*
|
||||||
|
merge-multiple: true
|
||||||
|
path: dist/repo-work/incoming
|
||||||
|
|
||||||
- name: Download desktop packages (Linux)
|
- name: Download desktop packages (Linux)
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: vikunja_desktop_packages_ubuntu-latest
|
name: vikunja_desktop_packages_ubuntu-latest
|
||||||
path: dist/repo-work/incoming-desktop
|
path: dist/repo-work/incoming-desktop
|
||||||
|
|
@ -318,7 +309,7 @@ jobs:
|
||||||
|
|
||||||
- name: GPG setup
|
- name: GPG setup
|
||||||
if: matrix.format != 'apk'
|
if: matrix.format != 'apk'
|
||||||
uses: kolaente/action-gpg@main
|
uses: kolaente/action-gpg@eb0fd8f16fe9b499f060f659092c470cb9f76eb7 # main
|
||||||
with:
|
with:
|
||||||
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
gpg-passphrase: "${{ secrets.RELEASE_GPG_PASSPHRASE }}"
|
||||||
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
gpg-sign-key: "${{ secrets.RELEASE_GPG_SIGN_KEY }}"
|
||||||
|
|
@ -338,12 +329,13 @@ jobs:
|
||||||
|
|
||||||
- name: Generate repo metadata
|
- name: Generate repo metadata
|
||||||
if: matrix.format != 'apk'
|
if: matrix.format != 'apk'
|
||||||
|
working-directory: build
|
||||||
env:
|
env:
|
||||||
RELEASE_GPG_KEY: 7D061A4AA61436B40713D42EFF054DACD908493A
|
RELEASE_GPG_KEY: 7D061A4AA61436B40713D42EFF054DACD908493A
|
||||||
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
RELEASE_GPG_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./mage-static
|
chmod +x ./build-mage-static
|
||||||
./mage-static ${{ matrix.mage_target }}
|
./build-mage-static ${{ matrix.mage_target }}
|
||||||
|
|
||||||
- name: Generate APK repo metadata
|
- name: Generate APK repo metadata
|
||||||
if: matrix.format == 'apk'
|
if: matrix.format == 'apk'
|
||||||
|
|
@ -392,7 +384,7 @@ jobs:
|
||||||
find dist/repo-output -type d -empty -delete 2>/dev/null || true
|
find dist/repo-output -type d -empty -delete 2>/dev/null || true
|
||||||
|
|
||||||
- name: Upload to R2
|
- name: Upload to R2
|
||||||
uses: kolaente/s3-action@main
|
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||||
with:
|
with:
|
||||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||||
|
|
@ -406,12 +398,12 @@ jobs:
|
||||||
config-yaml:
|
config-yaml:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Git describe
|
- name: Git describe
|
||||||
id: ghd
|
id: ghd
|
||||||
uses: proudust/gh-describe@v2
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
- name: Download Mage Binary
|
- name: Download Mage Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: mage_bin
|
||||||
- name: generate
|
- name: generate
|
||||||
|
|
@ -419,7 +411,7 @@ jobs:
|
||||||
chmod +x ./mage-static
|
chmod +x ./mage-static
|
||||||
./mage-static generate:config-yaml 1
|
./mage-static generate:config-yaml 1
|
||||||
- name: Upload to S3
|
- name: Upload to S3
|
||||||
uses: kolaente/s3-action@main
|
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||||
with:
|
with:
|
||||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||||
|
|
@ -439,16 +431,16 @@ jobs:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Git describe
|
- name: Git describe
|
||||||
id: ghd
|
id: ghd
|
||||||
uses: proudust/gh-describe@v2
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||||
with:
|
with:
|
||||||
package_json_file: desktop/package.json
|
package_json_file: desktop/package.json
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version-file: frontend/.nvmrc
|
node-version-file: frontend/.nvmrc
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -459,7 +451,7 @@ jobs:
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools
|
sudo apt-get install --no-install-recommends -y libopenjp2-tools rpm libarchive-tools
|
||||||
- name: get frontend
|
- name: get frontend
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: frontend_dist
|
name: frontend_dist
|
||||||
path: frontend/dist
|
path: frontend/dist
|
||||||
|
|
@ -469,7 +461,7 @@ jobs:
|
||||||
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
|
pnpm install --frozen-lockfile --prefer-offline --fetch-timeout 100000
|
||||||
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
|
node build.js "${{ steps.ghd.outputs.describe }}" ${{ github.ref_type == 'tag' }}
|
||||||
- name: Upload to S3
|
- name: Upload to S3
|
||||||
uses: kolaente/s3-action@main
|
uses: kolaente/s3-action@7f58dddd682b2f93a6c6799c9f68e7a38f2da558 # main
|
||||||
with:
|
with:
|
||||||
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
s3-access-key-id: ${{ secrets.S3_ACCESS_KEY }}
|
||||||
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
s3-secret-access-key: ${{ secrets.S3_SECRET_KEY }}
|
||||||
|
|
@ -481,7 +473,7 @@ jobs:
|
||||||
strip-path-prefix: desktop/dist/
|
strip-path-prefix: desktop/dist/
|
||||||
exclude: "desktop/dist/*.blockmap"
|
exclude: "desktop/dist/*.blockmap"
|
||||||
- name: Store Desktop Package
|
- name: Store Desktop Package
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: vikunja_desktop_packages_${{ matrix.os }}
|
name: vikunja_desktop_packages_${{ matrix.os }}
|
||||||
path: |
|
path: |
|
||||||
|
|
@ -494,16 +486,16 @@ jobs:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
- name: Download Mage Binary
|
- name: Download Mage Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: mage_bin
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
- name: generate
|
- name: generate
|
||||||
|
|
@ -528,7 +520,7 @@ jobs:
|
||||||
git commit -am "[skip ci] Updated swagger docs"
|
git commit -am "[skip ci] Updated swagger docs"
|
||||||
- name: Push changes
|
- name: Push changes
|
||||||
if: steps.check_changes.outputs.changes_exist != '0'
|
if: steps.check_changes.outputs.changes_exist != '0'
|
||||||
uses: ad-m/github-push-action@master
|
uses: ad-m/github-push-action@881a6320fdb16eb5318c5054f31c218aec2b324c # master
|
||||||
with:
|
with:
|
||||||
ssh: true
|
ssh: true
|
||||||
branch: ${{ github.ref }}
|
branch: ${{ github.ref }}
|
||||||
|
|
@ -538,6 +530,8 @@ jobs:
|
||||||
needs:
|
needs:
|
||||||
- binaries
|
- binaries
|
||||||
- os-package
|
- os-package
|
||||||
|
- veans-binaries
|
||||||
|
- veans-os-package
|
||||||
- desktop
|
- desktop
|
||||||
- publish-repos
|
- publish-repos
|
||||||
if: ${{ github.ref_type == 'tag' }}
|
if: ${{ github.ref_type == 'tag' }}
|
||||||
|
|
@ -545,33 +539,44 @@ jobs:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Download Binaries
|
- name: Download Binaries
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: vikunja_bin_packages
|
name: vikunja_bin_packages
|
||||||
|
|
||||||
- name: Download OS Packages
|
- name: Download OS Packages
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
pattern: vikunja_os_package_*
|
pattern: vikunja_os_package_*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Download Veans Binaries
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
name: veans_bin_packages
|
||||||
|
|
||||||
|
- name: Download Veans OS Packages
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
pattern: veans_os_package_*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Download Desktop Package Linux
|
- name: Download Desktop Package Linux
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: vikunja_desktop_packages_ubuntu-latest
|
name: vikunja_desktop_packages_ubuntu-latest
|
||||||
|
|
||||||
- name: Download Desktop Package MacOS
|
- name: Download Desktop Package MacOS
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: vikunja_desktop_packages_macos-latest
|
name: vikunja_desktop_packages_macos-latest
|
||||||
|
|
||||||
- name: Download Desktop Package Windows
|
- name: Download Desktop Package Windows
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: vikunja_desktop_packages_windows-latest
|
name: vikunja_desktop_packages_windows-latest
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||||
if: github.ref_type == 'tag'
|
if: github.ref_type == 'tag'
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
|
|
@ -581,4 +586,9 @@ jobs:
|
||||||
vikunja*.deb
|
vikunja*.deb
|
||||||
vikunja*.apk
|
vikunja*.apk
|
||||||
vikunja*.archlinux
|
vikunja*.archlinux
|
||||||
|
veans*.zip
|
||||||
|
veans*.rpm
|
||||||
|
veans*.deb
|
||||||
|
veans*.apk
|
||||||
|
veans*.archlinux
|
||||||
Vikunja Desktop*
|
Vikunja Desktop*
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,19 @@ jobs:
|
||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||||
with:
|
with:
|
||||||
only-labels: 'waiting for reply'
|
only-labels: 'waiting for reply'
|
||||||
days-before-issue-stale: 30
|
days-before-issue-stale: 30
|
||||||
days-before-issue-close: 30
|
days-before-issue-close: 30
|
||||||
stale-issue-label: 'waiting for reply'
|
stale-issue-label: 'waiting for reply'
|
||||||
remove-stale-when-updated: false
|
remove-stale-when-updated: true
|
||||||
close-issue-message: >
|
close-issue-message: >
|
||||||
Closing this for now since we haven't heard back on the follow-up
|
Closing this for now since we haven't heard back on the follow-up
|
||||||
questions. If you're still seeing this on a recent version, just
|
questions. If you're still seeing this on a recent version, just
|
||||||
drop a comment with the requested info and we'll reopen. Thanks
|
drop a comment with the requested info and we'll reopen. Thanks
|
||||||
for the report!
|
for the report!
|
||||||
days-before-pr-stale: -1
|
stale-pr-label: 'waiting for reply'
|
||||||
|
days-before-pr-stale: 30
|
||||||
days-before-pr-close: -1
|
days-before-pr-close: -1
|
||||||
operations-per-run: 100
|
operations-per-run: 100
|
||||||
|
|
|
||||||
|
|
@ -8,26 +8,26 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: prepare-mage
|
name: prepare-mage
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
- name: Cache Mage
|
- name: Cache Mage
|
||||||
id: cache-mage
|
id: cache-mage
|
||||||
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }}
|
key: ${{ runner.os }}-build-mage-${{ hashFiles('magefile.go') }}
|
||||||
path: |
|
path: |
|
||||||
./mage-static
|
./mage-static
|
||||||
- name: Compile Mage
|
- name: Compile Mage
|
||||||
if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }}
|
if: ${{ steps.cache-mage.outputs.cache-hit != 'true' }}
|
||||||
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3
|
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
args: -compile ./mage-static
|
args: -compile ./mage-static
|
||||||
- name: Store Mage Binary
|
- name: Store Mage Binary
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: mage_bin
|
||||||
path: ./mage-static
|
path: ./mage-static
|
||||||
|
|
@ -36,16 +36,16 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: mage
|
needs: mage
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Download Mage Binary
|
- name: Download Mage Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: mage_bin
|
||||||
- name: Git describe
|
- name: Git describe
|
||||||
id: ghd
|
id: ghd
|
||||||
uses: proudust/gh-describe@v2
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|
@ -57,7 +57,7 @@ jobs:
|
||||||
chmod +x ./mage-static
|
chmod +x ./mage-static
|
||||||
./mage-static build
|
./mage-static build
|
||||||
- name: Store Vikunja Binary
|
- name: Store Vikunja Binary
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: vikunja_bin
|
name: vikunja_bin
|
||||||
path: ./vikunja
|
path: ./vikunja
|
||||||
|
|
@ -65,8 +65,8 @@ jobs:
|
||||||
api-lint:
|
api-lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
- name: prepare frontend files
|
- name: prepare frontend files
|
||||||
|
|
@ -74,17 +74,50 @@ jobs:
|
||||||
mkdir -p frontend/dist
|
mkdir -p frontend/dist
|
||||||
touch frontend/dist/index.html
|
touch frontend/dist/index.html
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
|
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.10.1
|
||||||
|
|
||||||
api-check-translations:
|
veans-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
|
with:
|
||||||
|
go-version: stable
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
|
||||||
|
with:
|
||||||
|
version: v2.10.1
|
||||||
|
working-directory: veans
|
||||||
|
|
||||||
|
veans-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
|
with:
|
||||||
|
go-version: stable
|
||||||
|
- name: Install mage
|
||||||
|
# The cached mage-static artifact has the parent magefile compiled
|
||||||
|
# in — we need a generic mage binary to pick up veans/magefile.go.
|
||||||
|
run: go install github.com/magefile/mage@v1.17.2
|
||||||
|
- name: Run unit tests
|
||||||
|
# `mage test` is the Aliases entry for Test.All which passes
|
||||||
|
# `-short` — the e2e package's TestMain skips under -short,
|
||||||
|
# mirroring the parent monorepo's pkg/webtests convention. The
|
||||||
|
# heavier test-veans-e2e job runs the full suite against the
|
||||||
|
# api-build artifact.
|
||||||
|
working-directory: veans
|
||||||
|
run: mage test
|
||||||
|
|
||||||
|
check-translations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: mage
|
needs: mage
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Download Mage Binary
|
- name: Download Mage Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: mage_bin
|
||||||
- name: Check
|
- name: Check
|
||||||
|
|
@ -119,7 +152,7 @@ jobs:
|
||||||
ports:
|
ports:
|
||||||
- 3306:3306
|
- 3306:3306
|
||||||
migration-smoke-db-postgres:
|
migration-smoke-db-postgres:
|
||||||
image: postgres:18@sha256:5773fe724c49c42a7a9ca70202e11e1dff21fb7235b335a73f39297d200b73a2
|
image: postgres:18@sha256:4aabea78cf39b90e834caf3af7d602a18565f6fe2508705c8d01aa63245c2e20
|
||||||
env:
|
env:
|
||||||
POSTGRES_PASSWORD: vikunjatest
|
POSTGRES_PASSWORD: vikunjatest
|
||||||
POSTGRES_DB: vikunjatest
|
POSTGRES_DB: vikunjatest
|
||||||
|
|
@ -131,7 +164,7 @@ jobs:
|
||||||
wget https://dl.vikunja.io/vikunja/unstable/vikunja-unstable-linux-amd64-full.zip -q -O vikunja-latest.zip
|
wget https://dl.vikunja.io/vikunja/unstable/vikunja-unstable-linux-amd64-full.zip -q -O vikunja-latest.zip
|
||||||
unzip vikunja-latest.zip vikunja-unstable-linux-amd64
|
unzip vikunja-latest.zip vikunja-unstable-linux-amd64
|
||||||
- name: Download Vikunja Binary
|
- name: Download Vikunja Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: vikunja_bin
|
name: vikunja_bin
|
||||||
- name: run migration
|
- name: run migration
|
||||||
|
|
@ -221,13 +254,13 @@ jobs:
|
||||||
ports:
|
ports:
|
||||||
- 389:389
|
- 389:389
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Download Mage Binary
|
- name: Download Mage Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: mage_bin
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
- name: Configure Postgres for faster tests
|
- name: Configure Postgres for faster tests
|
||||||
|
|
@ -267,13 +300,13 @@ jobs:
|
||||||
needs:
|
needs:
|
||||||
- mage
|
- mage
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Download Mage Binary
|
- name: Download Mage Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: mage_bin
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
- name: test
|
- name: test
|
||||||
|
|
@ -288,13 +321,13 @@ jobs:
|
||||||
needs:
|
needs:
|
||||||
- mage
|
- mage
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Download Mage Binary
|
- name: Download Mage Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: mage_bin
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
- name: test
|
- name: test
|
||||||
|
|
@ -318,13 +351,13 @@ jobs:
|
||||||
ports:
|
ports:
|
||||||
- 9000:9000
|
- 9000:9000
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Download Mage Binary
|
- name: Download Mage Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: mage_bin
|
name: mage_bin
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version: stable
|
||||||
- name: test S3 file storage integration
|
- name: test S3 file storage integration
|
||||||
|
|
@ -349,7 +382,7 @@ jobs:
|
||||||
frontend-lint:
|
frontend-lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: ./.github/actions/setup-frontend
|
- uses: ./.github/actions/setup-frontend
|
||||||
- name: Lint
|
- name: Lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
@ -358,7 +391,7 @@ jobs:
|
||||||
frontend-stylelint:
|
frontend-stylelint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: ./.github/actions/setup-frontend
|
- uses: ./.github/actions/setup-frontend
|
||||||
- name: Lint styles
|
- name: Lint styles
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
@ -367,7 +400,7 @@ jobs:
|
||||||
frontend-typecheck:
|
frontend-typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: ./.github/actions/setup-frontend
|
- uses: ./.github/actions/setup-frontend
|
||||||
- name: Typecheck
|
- name: Typecheck
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
@ -377,7 +410,7 @@ jobs:
|
||||||
test-frontend-unit:
|
test-frontend-unit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: ./.github/actions/setup-frontend
|
- uses: ./.github/actions/setup-frontend
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
@ -386,11 +419,11 @@ jobs:
|
||||||
frontend-build:
|
frontend-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: ./.github/actions/setup-frontend
|
- uses: ./.github/actions/setup-frontend
|
||||||
- name: Git describe
|
- name: Git describe
|
||||||
id: ghd
|
id: ghd
|
||||||
uses: proudust/gh-describe@v2
|
uses: proudust/gh-describe@80412be8ce0e77d8afba6b340e34790bc772aa45 # v2.2.0
|
||||||
- name: Inject frontend version
|
- name: Inject frontend version
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -399,11 +432,81 @@ jobs:
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
- name: Store Frontend
|
- name: Store Frontend
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: frontend_dist
|
name: frontend_dist
|
||||||
path: ./frontend/dist
|
path: ./frontend/dist
|
||||||
|
|
||||||
|
test-veans-e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- api-build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
- name: Download Vikunja Binary
|
||||||
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
|
with:
|
||||||
|
name: vikunja_bin
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
|
with:
|
||||||
|
go-version: stable
|
||||||
|
- name: Install mage
|
||||||
|
# The cached mage-static artifact has the parent magefile compiled
|
||||||
|
# in — we need a generic mage binary to pick up veans/magefile.go.
|
||||||
|
run: go install github.com/magefile/mage@v1.17.2
|
||||||
|
- run: chmod +x ./vikunja
|
||||||
|
- name: Run veans e2e against ephemeral Vikunja
|
||||||
|
env:
|
||||||
|
VIKUNJA_SERVICE_INTERFACE: ":3456"
|
||||||
|
VIKUNJA_SERVICE_PUBLICURL: "http://127.0.0.1:3456/"
|
||||||
|
VIKUNJA_SERVICE_JWTSECRET: "veans-e2e-jwt-secret-do-not-use-in-production"
|
||||||
|
# Enables PATCH /api/v1/test/{table} — the e2e suite seeds its
|
||||||
|
# own admin via this endpoint (see veans/e2e/helpers.go), same
|
||||||
|
# mechanism the playwright suite uses.
|
||||||
|
VIKUNJA_SERVICE_TESTINGTOKEN: averyLongSecretToSe33dtheDB
|
||||||
|
VIKUNJA_DATABASE_TYPE: sqlite
|
||||||
|
VIKUNJA_DATABASE_PATH: memory
|
||||||
|
VIKUNJA_LOG_LEVEL: WARNING
|
||||||
|
VIKUNJA_MAILER_ENABLED: "false"
|
||||||
|
VIKUNJA_REDIS_ENABLED: "false"
|
||||||
|
VIKUNJA_RATELIMIT_NOAUTHLIMIT: "1000"
|
||||||
|
VEANS_E2E_API_URL: http://127.0.0.1:3456
|
||||||
|
# Same value as VIKUNJA_SERVICE_TESTINGTOKEN above — pass-through
|
||||||
|
# so the test harness can authenticate against /api/v1/test/.
|
||||||
|
VEANS_E2E_TESTING_TOKEN: averyLongSecretToSe33dtheDB
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
# Boot the prebuilt API and tests in one shell — backgrounded
|
||||||
|
# processes don't survive step boundaries on GH runners.
|
||||||
|
nohup ./vikunja web > /tmp/vikunja.log 2>&1 &
|
||||||
|
API_PID=$!
|
||||||
|
trap "kill $API_PID 2>/dev/null || true" EXIT
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null 2>&1; then
|
||||||
|
echo "API ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if ! curl -sf http://127.0.0.1:3456/api/v1/info >/dev/null; then
|
||||||
|
echo "::error::API failed to start; log:"
|
||||||
|
cat /tmp/vikunja.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# `mage test:e2e` builds the binary once and exports VEANS_BINARY
|
||||||
|
# so each subtest reuses it (plain `mage test` would rebuild per
|
||||||
|
# test via buildOrLocate()). The suite seeds its own admin
|
||||||
|
# internally — no curl seeding here.
|
||||||
|
(cd veans && mage test:e2e)
|
||||||
|
- name: Upload API log on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
|
with:
|
||||||
|
name: veans-e2e-vikunja-log
|
||||||
|
path: /tmp/vikunja.log
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
test-frontend-e2e-playwright:
|
test-frontend-e2e-playwright:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
|
|
@ -420,19 +523,19 @@ jobs:
|
||||||
ports:
|
ports:
|
||||||
- 5556:5556
|
- 5556:5556
|
||||||
container:
|
container:
|
||||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy@sha256:4698a73749c5848d3f5fcd42a2174d172fcad2b2283e087843b115424303a565
|
image: mcr.microsoft.com/playwright:v1.61.1-jammy@sha256:7b86926fff94374389e8e1f4fdc5c76d050d4a06a7886bb537bf412b20e2b71e
|
||||||
options: --user 1001
|
options: --user 1001
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Download Vikunja Binary
|
- name: Download Vikunja Binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: vikunja_bin
|
name: vikunja_bin
|
||||||
- uses: ./.github/actions/setup-frontend
|
- uses: ./.github/actions/setup-frontend
|
||||||
with:
|
with:
|
||||||
install-e2e-binaries: false # Playwright browsers already in container
|
install-e2e-binaries: false # Playwright browsers already in container
|
||||||
- name: Download Frontend
|
- name: Download Frontend
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: frontend_dist
|
name: frontend_dist
|
||||||
path: ./frontend/dist
|
path: ./frontend/dist
|
||||||
|
|
@ -467,14 +570,14 @@ jobs:
|
||||||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
|
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTID: vikunja
|
||||||
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
|
VIKUNJA_AUTH_OPENID_PROVIDERS_DEX_CLIENTSECRET: secret
|
||||||
- name: Upload Playwright Report
|
- name: Upload Playwright Report
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-report-${{ matrix.shard }}
|
name: playwright-report-${{ matrix.shard }}
|
||||||
path: frontend/playwright-report/
|
path: frontend/playwright-report/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
name: playwright-test-results-${{ matrix.shard }}
|
name: playwright-test-results-${{ matrix.shard }}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ docs/resources/
|
||||||
pkg/static/templates_vfsdata.go
|
pkg/static/templates_vfsdata.go
|
||||||
files/
|
files/
|
||||||
!pkg/files/
|
!pkg/files/
|
||||||
|
!pkg/web/files/
|
||||||
vikunja-dump*
|
vikunja-dump*
|
||||||
vendor/
|
vendor/
|
||||||
os-packages/
|
os-packages/
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,13 @@ linters:
|
||||||
- revive
|
- revive
|
||||||
path: pkg/utils/*
|
path: pkg/utils/*
|
||||||
text: 'var-naming: avoid meaningless package names'
|
text: 'var-naming: avoid meaningless package names'
|
||||||
|
- linters:
|
||||||
|
- revive
|
||||||
|
path: pkg/routes/api/shared/*
|
||||||
|
text: 'var-naming: avoid meaningless package names'
|
||||||
|
- linters:
|
||||||
|
- contextcheck
|
||||||
|
path: pkg/routes/api/v2/backgrounds.go # the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context
|
||||||
- linters:
|
- linters:
|
||||||
- revive
|
- revive
|
||||||
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
||||||
|
|
|
||||||
41
AGENTS.md
41
AGENTS.md
|
|
@ -11,6 +11,25 @@ The project consists of:
|
||||||
- `desktop/` – Electron wrapper application
|
- `desktop/` – Electron wrapper application
|
||||||
- `docs/` – Documentation website
|
- `docs/` – Documentation website
|
||||||
|
|
||||||
|
## API Version Policy — new work goes to /api/v2
|
||||||
|
|
||||||
|
**`/api/v1` is effectively deprecated and frozen.** It still runs and is fully supported for existing clients, but it should not grow.
|
||||||
|
|
||||||
|
- **Every new route goes on `/api/v2`** (the Huma-backed API in `pkg/routes/api/v2/`). This includes new CRUDable entities, new custom/non-CRUD endpoints, and new actions on existing resources.
|
||||||
|
- **Before adding any v2 route, invoke the `api-v2-routes` skill** — it covers both CRUD and non-CRUD shapes.
|
||||||
|
- **Touch `/api/v1` only to:** fix a bug, or port an existing resource to v2. Do not add net-new functionality there.
|
||||||
|
- Models in `pkg/models/` are shared by both APIs — a new entity still gets its model + `Can*` methods (invoke `crudable`); only the HTTP surface differs (v2, not v1).
|
||||||
|
|
||||||
|
If a task says "add an endpoint for X" without naming a version, it means v2.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
Before writing code in these areas, invoke the matching skill with the `Skill` tool. They are short checklists derived from recurring review feedback — loading them up front avoids rework.
|
||||||
|
|
||||||
|
- Adding or modifying a model in `pkg/models/` (new CRUD, new or changed `Can*` methods, anything touching permissions): invoke `crudable`.
|
||||||
|
- Creating or editing any file under `pkg/migration/`: invoke `migration`.
|
||||||
|
- Adding **any** new API route (new entity, custom action, or porting from v1) — all new routes go on the Huma-backed `/api/v2`, editing `pkg/routes/api/v2/`: invoke `api-v2-routes`. See the API Version Policy above.
|
||||||
|
|
||||||
## Plans and Worktrees
|
## Plans and Worktrees
|
||||||
|
|
||||||
When the user asks you to create a plan to fix or implement something:
|
When the user asks you to create a plan to fix or implement something:
|
||||||
|
|
@ -165,11 +184,10 @@ Modern Vue 3 composition API application with TypeScript:
|
||||||
### Adding New Features
|
### Adding New Features
|
||||||
|
|
||||||
**Backend Changes:**
|
**Backend Changes:**
|
||||||
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required
|
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required (invoke the `crudable` skill)
|
||||||
2. Add database migration if needed: `mage dev:make-migration <StructName>`
|
2. Add database migration if needed: `mage dev:make-migration <StructName>` (invoke the `migration` skill)
|
||||||
3. Create/update services in `pkg/services/` for complex business logic
|
3. Create/update services in `pkg/services/` for complex business logic
|
||||||
4. Add API routes in `pkg/routes/api/v1/` following existing patterns
|
4. Add API routes on **`/api/v2`** in `pkg/routes/api/v2/` — invoke the `api-v2-routes` skill. Do **not** add new routes to `/api/v1`; it is frozen (see API Version Policy above)
|
||||||
5. Update Swagger annotations
|
|
||||||
|
|
||||||
**Frontend Changes:**
|
**Frontend Changes:**
|
||||||
1. Create TypeScript interfaces in `src/modelTypes/` matching backend models
|
1. Create TypeScript interfaces in `src/modelTypes/` matching backend models
|
||||||
|
|
@ -185,10 +203,11 @@ Modern Vue 3 composition API application with TypeScript:
|
||||||
4. Update TypeScript interfaces in frontend `src/modelTypes/`
|
4. Update TypeScript interfaces in frontend `src/modelTypes/`
|
||||||
|
|
||||||
### API Development
|
### API Development
|
||||||
- All API endpoints follow RESTful conventions under `/api/v1/`
|
- **New endpoints go on `/api/v2`** (Huma-backed, `pkg/routes/api/v2/`). `/api/v1` is frozen — see the API Version Policy near the top. Invoke the `api-v2-routes` skill before writing v2 routes.
|
||||||
- Use generic web handlers in `pkg/web/handler/` for standard CRUD operations
|
- v2 verb conventions differ from v1: POST creates, PUT/PATCH update (v1 used PUT to create, POST to update).
|
||||||
- Implement proper permissions checking using the Permissions interface
|
- Both versions reuse the generic `pkg/web/handler/` `Do*` functions for standard CRUD, which enforce permissions via the model's `Can*` methods.
|
||||||
- Add Swagger annotations for automatic documentation generation
|
- Implement permission checks at the model level via the Permissions interface — never in the route handler (the exception: non-CRUD v2 actions must call `Can*` explicitly; the skill covers this).
|
||||||
|
- v2 generates its OpenAPI spec from Go types automatically — no Swagger annotations. v1's swaggo annotations stay as-is but no new ones are needed.
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
- Backend: Feature tests alongside source files, web tests in `pkg/webtests/`
|
- Backend: Feature tests alongside source files, web tests in `pkg/webtests/`
|
||||||
|
|
@ -243,6 +262,8 @@ In the frontend, all translation strings live in `frontend/src/i18n/lang`. For t
|
||||||
You only need to adjust the `en.json` file with the source string. The actual translation happens elsewhere.
|
You only need to adjust the `en.json` file with the source string. The actual translation happens elsewhere.
|
||||||
After adjusting the source string, you need to call the respective translation library with the key. Both are similar, check the existing code to figure it out.
|
After adjusting the source string, you need to call the respective translation library with the key. Both are similar, check the existing code to figure it out.
|
||||||
|
|
||||||
|
**Do not add a new language from scratch or translate strings into other languages yourself.** Translations are managed through a dedicated workflow. If you are asked to add a new language, translate existing strings, or update translations for non-English locales, point the user to the translation guide instead: https://vikunja.io/docs/translations/
|
||||||
|
|
||||||
## Key Files and Conventions
|
## Key Files and Conventions
|
||||||
|
|
||||||
**Configuration:**
|
**Configuration:**
|
||||||
|
|
@ -254,11 +275,13 @@ After adjusting the source string, you need to call the respective translation l
|
||||||
- Go: golangci-lint per `.golangci.yml`; use goimports; wrap errors with `fmt.Errorf("...: %w", err)`; enforce permissions checks in models; never log secrets; do not edit generated `pkg/swagger/*`
|
- Go: golangci-lint per `.golangci.yml`; use goimports; wrap errors with `fmt.Errorf("...: %w", err)`; enforce permissions checks in models; never log secrets; do not edit generated `pkg/swagger/*`
|
||||||
- Vue: ESLint + TS; single quotes, trailing commas, no semicolons, tab indent; script setup + lang ts; keep services/models in sync with backend
|
- Vue: ESLint + TS; single quotes, trailing commas, no semicolons, tab indent; script setup + lang ts; keep services/models in sync with backend
|
||||||
- Follow existing patterns for consistency
|
- Follow existing patterns for consistency
|
||||||
|
- **Comments: document the *why*, not the *what* — default to no comment.** Don't write comments that restate the code, a function/struct/field name, or a signature; they're noise the reader skips past (a comment that takes longer to read than the code it describes should be deleted). Only comment a genuinely non-obvious *why* — a gotcha, an invariant, a rejected alternative, a cross-file constraint — in one tight line. Be aggressive about cutting on the first pass, not just when asked.
|
||||||
|
- Before creating a new file, function, or helper, search the codebase (`grep` / `rg`) for existing code that does the same thing. Prefer extending an existing helper over duplicating it. If logic overlaps an existing function significantly, reuse it.
|
||||||
|
|
||||||
**Naming Conventions:**
|
**Naming Conventions:**
|
||||||
- Go: Standard Go conventions (PascalCase for exports, camelCase for private)
|
- Go: Standard Go conventions (PascalCase for exports, camelCase for private)
|
||||||
- Vue: PascalCase for components, camelCase for composables
|
- Vue: PascalCase for components, camelCase for composables
|
||||||
- API endpoints: kebab-case in URLs, camelCase in JSON
|
- API endpoints: kebab-case in URLs, snake_case in JSON
|
||||||
|
|
||||||
**Permissions and Permissions:**
|
**Permissions and Permissions:**
|
||||||
- Always implement Permissions interface for new models
|
- Always implement Permissions interface for new models
|
||||||
|
|
|
||||||
10
Dockerfile
10
Dockerfile
|
|
@ -1,5 +1,5 @@
|
||||||
# syntax=docker/dockerfile:1@sha256:b6afd42430b15f2d2a4c5a02b919e98a525b785b1aaff16747d2f623364e39b6
|
# syntax=docker/dockerfile:1@sha256:87999aa3d42bdc6bea60565083ee17e86d1f3339802f543c0d03998580f9cb89
|
||||||
FROM --platform=$BUILDPLATFORM node:24.13.0-alpine@sha256:931d7d57f8c1fd0e2179dbff7cc7da4c9dd100998bc2b32afc85142d8efbc213 AS frontendbuilder
|
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
|
@ -14,7 +14,7 @@ COPY frontend/ ./
|
||||||
ARG RELEASE_VERSION=dev
|
ARG RELEASE_VERSION=dev
|
||||||
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build
|
RUN echo "{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.25.x@sha256:11ac5e6cb8767caea0c62c420e053cb69554638ec255f9bbef8ed411e70c9eec AS apibuilder
|
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.26.x@sha256:57c62857168cee9213045d65044e990d8b181ed6df30ba7097d2dcddd42b9908 AS apibuilder
|
||||||
|
|
||||||
RUN go install github.com/magefile/mage@latest && \
|
RUN go install github.com/magefile/mage@latest && \
|
||||||
mv /go/bin/mage /usr/local/go/bin
|
mv /go/bin/mage /usr/local/go/bin
|
||||||
|
|
@ -28,7 +28,7 @@ ENV RELEASE_VERSION=$RELEASE_VERSION
|
||||||
|
|
||||||
RUN export PATH=$PATH:$GOPATH/bin && \
|
RUN export PATH=$PATH:$GOPATH/bin && \
|
||||||
mage build:clean && \
|
mage build:clean && \
|
||||||
mage release:xgo "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}"
|
(cd build && mage release:xgo vikunja "${TARGETOS}/${TARGETARCH}/${TARGETVARIANT}")
|
||||||
|
|
||||||
RUN mkdir -p /tmp && chmod 1777 /tmp
|
RUN mkdir -p /tmp && chmod 1777 /tmp
|
||||||
|
|
||||||
|
|
@ -50,7 +50,7 @@ WORKDIR /app/vikunja
|
||||||
ENTRYPOINT [ "/app/vikunja/vikunja" ]
|
ENTRYPOINT [ "/app/vikunja/vikunja" ]
|
||||||
EXPOSE 3456
|
EXPOSE 3456
|
||||||
|
|
||||||
COPY --from=apibuilder --chown=1000:1000 /tmp /tmp
|
COPY --from=apibuilder --chown=1000:1000 --chmod=1777 /tmp /tmp
|
||||||
|
|
||||||
USER 1000
|
USER 1000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
rc-update add vikunja default
|
rc-update add vikunja default
|
||||||
|
|
||||||
# Fix the config to contain proper values
|
# Fix the config to contain proper values
|
||||||
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||||
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
|
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
|
||||||
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
|
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
|
||||||
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml
|
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
systemctl enable vikunja.service
|
systemctl enable vikunja.service
|
||||||
|
|
||||||
# Fix the config to contain proper values
|
# Fix the config to contain proper values
|
||||||
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||||
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
|
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
|
||||||
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
|
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
|
||||||
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml
|
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
module code.vikunja.io/build
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require github.com/magefile/mage v1.17.2
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
|
||||||
|
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||||
|
|
@ -0,0 +1,757 @@
|
||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build mage
|
||||||
|
|
||||||
|
// Centralized release pipeline for every Go binary in this monorepo.
|
||||||
|
//
|
||||||
|
// Both vikunja and veans cross-compile through the same code: xgo for the full
|
||||||
|
// OS/arch matrix, upx where the binary supports it, sha256 alongside each
|
||||||
|
// artifact, per-target zip bundle, and nfpm.yaml templating for deb/rpm/apk/
|
||||||
|
// archlinux packaging. Repository-metadata targets (apt/rpm/pacman) consume
|
||||||
|
// the merged ../dist/repo-work/incoming/ tree the CI populates from both
|
||||||
|
// projects' packages.
|
||||||
|
//
|
||||||
|
// The module is intentionally separate from the project magefiles so the
|
||||||
|
// release tooling can evolve without touching them. The small filesystem
|
||||||
|
// helpers (copyFile, moveFile, sha256File) are duplicated rather than
|
||||||
|
// imported — this magefile depends on nothing but stdlib + mage.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/magefile/mage/mg"
|
||||||
|
"github.com/magefile/mage/sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// project definitions
|
||||||
|
|
||||||
|
// project describes one releasable Go binary in this monorepo. Adding a new
|
||||||
|
// project means adding an entry to projectByName plus a constructor below.
|
||||||
|
type project struct {
|
||||||
|
// Name is the short identifier used on the CLI: `mage release:build <name>`.
|
||||||
|
Name string
|
||||||
|
// Root is the project root, relative to this build/ directory.
|
||||||
|
Root string
|
||||||
|
// BuildPath is the Go package to build, relative to Root (e.g. "." or "./cmd/foo").
|
||||||
|
BuildPath string
|
||||||
|
// Executable is the output binary name (sans -<os>-<arch> suffix).
|
||||||
|
Executable string
|
||||||
|
// BuildTags are the base build tags applied to every cross-compile.
|
||||||
|
BuildTags string
|
||||||
|
// Ldflags returns the full -X flag string for the given version.
|
||||||
|
Ldflags func(version string) string
|
||||||
|
// NfpmConfigPath is the nfpm.yaml location, relative to Root.
|
||||||
|
NfpmConfigPath string
|
||||||
|
// NfpmBinPathDefault is the default <binlocation> substitution. Empty
|
||||||
|
// means use the Executable name as-is.
|
||||||
|
NfpmBinPathDefault string
|
||||||
|
// OsPackageExtras hook copies any extra files (LICENSE, sample config…)
|
||||||
|
// into each per-target bundle folder. Called once per binary.
|
||||||
|
OsPackageExtras func(folder string, p *project) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectByName(name string) (*project, error) {
|
||||||
|
switch name {
|
||||||
|
case "vikunja":
|
||||||
|
return vikunjaProject(), nil
|
||||||
|
case "veans":
|
||||||
|
return veansProject(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown project %q (known: vikunja, veans)", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func vikunjaProject() *project {
|
||||||
|
return &project{
|
||||||
|
Name: "vikunja",
|
||||||
|
Root: "../",
|
||||||
|
BuildPath: ".",
|
||||||
|
Executable: "vikunja",
|
||||||
|
BuildTags: "osusergo netgo",
|
||||||
|
Ldflags: func(v string) string {
|
||||||
|
// Matches the parent magefile's pre-refactor ldflags. The
|
||||||
|
// main.Tags value is the literal build-tag string baked in
|
||||||
|
// for `vikunja info` to report.
|
||||||
|
return fmt.Sprintf(`-X "code.vikunja.io/api/pkg/version.Version=%s" -X "main.Tags=osusergo netgo"`, v)
|
||||||
|
},
|
||||||
|
NfpmConfigPath: "nfpm.yaml",
|
||||||
|
NfpmBinPathDefault: "vikunja",
|
||||||
|
OsPackageExtras: func(folder string, p *project) error {
|
||||||
|
// config.yml.sample must be generated by the CI (or local dev)
|
||||||
|
// before this runs — we don't want to vendor the
|
||||||
|
// config-raw.json→YAML logic. The workflow does
|
||||||
|
// `mage generate:config-yaml 1` in the project root before
|
||||||
|
// invoking release:build.
|
||||||
|
if err := copyFile(filepath.Join(p.Root, "config.yml.sample"), filepath.Join(folder, "config.yml.sample")); err != nil {
|
||||||
|
return fmt.Errorf("copy config.yml.sample (run `mage generate:config-yaml 1` first): %w", err)
|
||||||
|
}
|
||||||
|
return copyFile(filepath.Join(p.Root, "LICENSE"), filepath.Join(folder, "LICENSE"))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func veansProject() *project {
|
||||||
|
return &project{
|
||||||
|
Name: "veans",
|
||||||
|
Root: "../veans/",
|
||||||
|
BuildPath: "./cmd/veans",
|
||||||
|
Executable: "veans",
|
||||||
|
BuildTags: "osusergo netgo",
|
||||||
|
Ldflags: func(v string) string {
|
||||||
|
return fmt.Sprintf(`-X main.version=%s`, v)
|
||||||
|
},
|
||||||
|
NfpmConfigPath: "nfpm.yaml",
|
||||||
|
NfpmBinPathDefault: "./veans",
|
||||||
|
OsPackageExtras: func(folder string, _ *project) error {
|
||||||
|
// veans intentionally doesn't carry its own LICENSE — the
|
||||||
|
// AGPLv3 at the repo root applies to both.
|
||||||
|
return copyFile("../LICENSE", filepath.Join(folder, "LICENSE"))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// version resolution
|
||||||
|
|
||||||
|
func releaseVersion(ctx context.Context) (string, error) {
|
||||||
|
if v := os.Getenv("RELEASE_VERSION"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--always", "--abbrev=10").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("git describe: %w", err)
|
||||||
|
}
|
||||||
|
return strings.Replace(strings.TrimSpace(string(out)), "-g", "-", 1), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func versionTagOrUnstable(v string) string {
|
||||||
|
switch v {
|
||||||
|
case "", "main":
|
||||||
|
return "unstable"
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Release namespace
|
||||||
|
|
||||||
|
type Release mg.Namespace
|
||||||
|
|
||||||
|
// Build runs the full release pipeline for the named project: dirs → xgo
|
||||||
|
// (windows/linux/darwin in parallel) → upx → copy → sha256 → per-target
|
||||||
|
// bundle dir → zip.
|
||||||
|
func (Release) Build(ctx context.Context, name string) error {
|
||||||
|
p, err := projectByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version, err := releaseVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := releaseDirs(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := prepareXgo(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := xgoAllOS(ctx, p, version); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := compressBinaries(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := copyBinaries(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeChecksums(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bundleOsPackages(p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return zipBundles(ctx, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Xgo cross-compiles a single os/arch[/variant] target for the named project.
|
||||||
|
// Variant follows the parent magefile convention: `linux/arm/7` → arm-7.
|
||||||
|
//
|
||||||
|
// Unlike Release.Build, this skips prepareXgo on purpose: the only caller
|
||||||
|
// that hits this path in CI is the Dockerfile, which runs inside the xgo
|
||||||
|
// image (xgo binary already present, docker daemon not available). Local
|
||||||
|
// users invoking `mage release:xgo` need to install xgo themselves.
|
||||||
|
func (Release) Xgo(ctx context.Context, name, target string) error {
|
||||||
|
p, err := projectByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version, err := releaseVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parts := strings.Split(target, "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fmt.Errorf("invalid target %q (expected os/arch[/variant])", target)
|
||||||
|
}
|
||||||
|
variant := ""
|
||||||
|
if len(parts) > 2 && parts[2] != "" {
|
||||||
|
variant = "-" + strings.ReplaceAll(parts[2], "v", "")
|
||||||
|
}
|
||||||
|
return runXgo(ctx, p, version, parts[0]+"/"+parts[1]+variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareNFPMConfig templates the named project's nfpm.yaml in place for the
|
||||||
|
// given nfpm arch (amd64|arm64|arm7|386). Destructive — CI checks out a fresh
|
||||||
|
// copy per matrix shard so the trampling is fine.
|
||||||
|
func (Release) PrepareNFPMConfig(ctx context.Context, name, arch string) error {
|
||||||
|
p, err := projectByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version, err := releaseVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfgPath := filepath.Join(p.Root, p.NfpmConfigPath)
|
||||||
|
raw, err := os.ReadFile(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
binLocation := os.Getenv("NFPM_BIN_PATH")
|
||||||
|
if binLocation == "" {
|
||||||
|
binLocation = p.NfpmBinPathDefault
|
||||||
|
if binLocation == "" {
|
||||||
|
binLocation = p.Executable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.ReplaceAll(string(raw), "<version>", version)
|
||||||
|
out = strings.ReplaceAll(out, "<arch>", arch)
|
||||||
|
out = strings.ReplaceAll(out, "<binlocation>", binLocation)
|
||||||
|
return os.WriteFile(cfgPath, []byte(out), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Repo-metadata targets — project-agnostic; operate on the merged tree at
|
||||||
|
// ../dist/repo-work/incoming and ../dist/repo-output.
|
||||||
|
|
||||||
|
// RepoApt generates an APT repository (reprepro) for every .deb in the
|
||||||
|
// incoming tree. REPO_SUITE (stable|unstable) selects the target suite;
|
||||||
|
// RELEASE_GPG_KEY + RELEASE_GPG_PASSPHRASE drive the Release file signing.
|
||||||
|
func (Release) RepoApt(ctx context.Context) error {
|
||||||
|
suite := repoSuite()
|
||||||
|
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
|
||||||
|
outputBase := filepath.Join(repoRootDist, "repo-output", "apt")
|
||||||
|
confDir := filepath.Join(outputBase, "conf")
|
||||||
|
if err := os.MkdirAll(confDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("creating reprepro conf dir: %w", err)
|
||||||
|
}
|
||||||
|
distConf, err := os.ReadFile("reprepro-dist-conf")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading reprepro-dist-conf: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(confDir, "distributions"), distConf, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("writing distributions config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
debs, err := filepath.Glob(filepath.Join(incomingDir, "*.deb"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, deb := range debs {
|
||||||
|
abs, _ := filepath.Abs(deb)
|
||||||
|
if err := sh.RunV("reprepro", "-b", outputBase, "includedeb", suite, abs); err != nil {
|
||||||
|
return fmt.Errorf("reprepro includedeb %s: %w", filepath.Base(deb), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||||
|
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||||
|
releaseFile := filepath.Join(outputBase, "dists", suite, "Release")
|
||||||
|
if _, err := os.Stat(releaseFile); err == nil {
|
||||||
|
if err := sh.RunV("gpg",
|
||||||
|
"--default-key", gpgKey,
|
||||||
|
"--batch", "--yes",
|
||||||
|
"--passphrase", gpgPassphrase,
|
||||||
|
"--pinentry-mode", "loopback",
|
||||||
|
"--detach-sign", "--armor",
|
||||||
|
"-o", releaseFile+".gpg",
|
||||||
|
releaseFile,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("signing Release (detached): %w", err)
|
||||||
|
}
|
||||||
|
if err := sh.RunV("gpg",
|
||||||
|
"--default-key", gpgKey,
|
||||||
|
"--batch", "--yes",
|
||||||
|
"--passphrase", gpgPassphrase,
|
||||||
|
"--pinentry-mode", "loopback",
|
||||||
|
"--clearsign",
|
||||||
|
"-o", filepath.Join(filepath.Dir(releaseFile), "InRelease"),
|
||||||
|
releaseFile,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("signing Release (clearsign): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("APT repo metadata generated in", outputBase)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoRpm generates an RPM repository (createrepo_c) per arch in
|
||||||
|
// ../dist/repo-work/incoming/.
|
||||||
|
func (Release) RepoRpm(ctx context.Context) error {
|
||||||
|
suite := repoSuite()
|
||||||
|
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
|
||||||
|
outputBase := filepath.Join(repoRootDist, "repo-output", "rpm", suite)
|
||||||
|
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||||
|
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||||
|
|
||||||
|
for _, arch := range []string{"x86_64", "aarch64", "armv7"} {
|
||||||
|
repoDir := filepath.Join(outputBase, arch)
|
||||||
|
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rpms, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".rpm"))
|
||||||
|
if len(rpms) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, rpm := range rpms {
|
||||||
|
abs, _ := filepath.Abs(rpm)
|
||||||
|
dst := filepath.Join(repoDir, filepath.Base(rpm))
|
||||||
|
_ = os.Remove(dst)
|
||||||
|
if err := os.Symlink(abs, dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := []string{repoDir}
|
||||||
|
if _, err := os.Stat(filepath.Join(repoDir, "repodata")); err == nil {
|
||||||
|
args = []string{"--update", repoDir}
|
||||||
|
}
|
||||||
|
if err := sh.RunV("createrepo_c", args...); err != nil {
|
||||||
|
return fmt.Errorf("createrepo_c for %s: %w", arch, err)
|
||||||
|
}
|
||||||
|
if err := sh.RunV("gpg",
|
||||||
|
"--default-key", gpgKey,
|
||||||
|
"--batch", "--yes",
|
||||||
|
"--passphrase", gpgPassphrase,
|
||||||
|
"--pinentry-mode", "loopback",
|
||||||
|
"--detach-sign", "--armor",
|
||||||
|
"-o", filepath.Join(repoDir, "repodata", "repomd.xml.asc"),
|
||||||
|
filepath.Join(repoDir, "repodata", "repomd.xml"),
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("signing repomd.xml for %s: %w", arch, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("RPM repo metadata generated in", outputBase)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoPacman generates a Pacman repository (repo-add) per arch.
|
||||||
|
func (Release) RepoPacman(ctx context.Context) error {
|
||||||
|
suite := repoSuite()
|
||||||
|
incomingDir := filepath.Join(repoRootDist, "repo-work", "incoming")
|
||||||
|
outputBase := filepath.Join(repoRootDist, "repo-output", "pacman", suite)
|
||||||
|
gpgKey := os.Getenv("RELEASE_GPG_KEY")
|
||||||
|
gpgPassphrase := os.Getenv("RELEASE_GPG_PASSPHRASE")
|
||||||
|
|
||||||
|
for _, arch := range []string{"x86_64", "aarch64", "armv7"} {
|
||||||
|
repoDir := filepath.Join(outputBase, arch)
|
||||||
|
if err := os.MkdirAll(repoDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pkgs, _ := filepath.Glob(filepath.Join(incomingDir, "*-"+arch+".archlinux"))
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
abs, _ := filepath.Abs(pkg)
|
||||||
|
dst := filepath.Join(repoDir, filepath.Base(pkg))
|
||||||
|
_ = os.Remove(dst)
|
||||||
|
if err := os.Symlink(abs, dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbPath := filepath.Join(repoDir, "vikunja.db.tar.gz")
|
||||||
|
repoPkgs, _ := filepath.Glob(filepath.Join(repoDir, "*.archlinux"))
|
||||||
|
repoAddArgs := append([]string{dbPath}, repoPkgs...)
|
||||||
|
if err := sh.RunV("repo-add", repoAddArgs...); err != nil {
|
||||||
|
return fmt.Errorf("repo-add for %s: %w", arch, err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"vikunja.db", "vikunja.files"} {
|
||||||
|
link := filepath.Join(repoDir, name)
|
||||||
|
_ = os.Remove(link)
|
||||||
|
if err := os.Symlink(name+".tar.gz", link); err != nil {
|
||||||
|
return fmt.Errorf("creating symlink %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sh.RunV("gpg",
|
||||||
|
"--default-key", gpgKey,
|
||||||
|
"--batch", "--yes",
|
||||||
|
"--passphrase", gpgPassphrase,
|
||||||
|
"--pinentry-mode", "loopback",
|
||||||
|
"--detach-sign",
|
||||||
|
"-o", filepath.Join(repoDir, "vikunja.db.sig"),
|
||||||
|
dbPath,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("signing db for %s: %w", arch, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("Pacman repo metadata generated in", outputBase)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// pipeline internals
|
||||||
|
|
||||||
|
const (
|
||||||
|
distSubdir = "dist"
|
||||||
|
subBin = "binaries"
|
||||||
|
subRelease = "release"
|
||||||
|
subZip = "zip"
|
||||||
|
|
||||||
|
// repoRootDist is where the repo-publish targets read and write — it's
|
||||||
|
// the dist/ directory at the repo root, not under build/. The CI
|
||||||
|
// populates dist/repo-work/incoming with packages from every project.
|
||||||
|
repoRootDist = "../dist"
|
||||||
|
)
|
||||||
|
|
||||||
|
func projectDist(p *project, sub string) string {
|
||||||
|
return filepath.Join(p.Root, distSubdir, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseDirs(p *project) error {
|
||||||
|
for _, d := range []string{subBin, subRelease, subZip} {
|
||||||
|
if err := os.MkdirAll(projectDist(p, d), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareXgo(_ context.Context) error {
|
||||||
|
if _, err := exec.LookPath("xgo"); err != nil {
|
||||||
|
fmt.Println("xgo not found, installing src.techknowlogick.com/xgo...")
|
||||||
|
if err := sh.RunV("go", "install", "src.techknowlogick.com/xgo@latest"); err != nil {
|
||||||
|
return fmt.Errorf("installing xgo: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("Pulling latest xgo docker image...")
|
||||||
|
return sh.RunV("docker", "pull", "ghcr.io/techknowlogick/xgo:latest")
|
||||||
|
}
|
||||||
|
|
||||||
|
func xgoOutName(p *project, version string) string {
|
||||||
|
if v := os.Getenv("XGO_OUT_NAME"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return p.Executable + "-" + versionTagOrUnstable(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runXgo(ctx context.Context, p *project, version, targets string) error {
|
||||||
|
extraLdflags := `-linkmode external -extldflags "-static" `
|
||||||
|
// xgo's darwin builds can't use the static external linker.
|
||||||
|
if strings.HasPrefix(targets, "darwin") {
|
||||||
|
extraLdflags = ""
|
||||||
|
}
|
||||||
|
// xgo resolves its last arg as a Go package path. Running it from build/
|
||||||
|
// with `../` confuses the module resolution (it tries to find a package
|
||||||
|
// inside this build module). Invoke xgo from the project root so we can
|
||||||
|
// pass p.BuildPath ("." or "./cmd/veans") just like the original
|
||||||
|
// per-project magefiles did.
|
||||||
|
absRoot, err := filepath.Abs(p.Root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve project root: %w", err)
|
||||||
|
}
|
||||||
|
absDest, err := filepath.Abs(projectDist(p, subBin))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve dest dir: %w", err)
|
||||||
|
}
|
||||||
|
//nolint:gosec // mage helper; args are derived from the static project table above.
|
||||||
|
cmd := exec.CommandContext(ctx, "xgo",
|
||||||
|
"-dest", absDest,
|
||||||
|
"-tags", p.BuildTags,
|
||||||
|
"-ldflags", extraLdflags+p.Ldflags(version),
|
||||||
|
"-targets", targets,
|
||||||
|
"-out", xgoOutName(p, version),
|
||||||
|
p.BuildPath,
|
||||||
|
)
|
||||||
|
cmd.Dir = absRoot
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func xgoAllOS(ctx context.Context, p *project, version string) error {
|
||||||
|
groups := []string{
|
||||||
|
"windows/*",
|
||||||
|
strings.Join([]string{
|
||||||
|
"linux/amd64",
|
||||||
|
"linux/arm-5",
|
||||||
|
"linux/arm-6",
|
||||||
|
"linux/arm-7",
|
||||||
|
"linux/arm64",
|
||||||
|
"linux/mips",
|
||||||
|
"linux/mipsle",
|
||||||
|
"linux/mips64",
|
||||||
|
"linux/mips64le",
|
||||||
|
"linux/riscv64",
|
||||||
|
}, ","),
|
||||||
|
"darwin-10.15/*",
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
firstErr error
|
||||||
|
)
|
||||||
|
record := func(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
for _, targets := range groups {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(t string) {
|
||||||
|
defer wg.Done()
|
||||||
|
record(runXgo(ctx, p, version, t))
|
||||||
|
}(targets)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// compressBinaries runs upx -9 over each binary that upx can handle. The skip
|
||||||
|
// list matches the parent magefile's behavior.
|
||||||
|
func compressBinaries(p *project) error {
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
mu sync.Mutex
|
||||||
|
firstErr error
|
||||||
|
)
|
||||||
|
record := func(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
walkErr := filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := info.Name()
|
||||||
|
if !strings.Contains(name, p.Executable) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.Contains(name, "mips") ||
|
||||||
|
strings.Contains(name, "s390x") ||
|
||||||
|
strings.Contains(name, "riscv64") ||
|
||||||
|
strings.Contains(name, "darwin") ||
|
||||||
|
(strings.Contains(name, "windows") && strings.Contains(name, "arm64")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(pp string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := sh.RunV("chmod", "+x", pp); err != nil {
|
||||||
|
record(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record(sh.RunV("upx", "-9", pp))
|
||||||
|
}(path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyBinaries(p *project) error {
|
||||||
|
return filepath.Walk(projectDist(p, subBin), func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.Contains(info.Name(), p.Executable) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return copyFile(path, filepath.Join(projectDist(p, subRelease), info.Name()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeChecksums(p *project) error {
|
||||||
|
release := projectDist(p, subRelease)
|
||||||
|
return filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(info.Name(), ".sha256") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sum, err := sha256File(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path+".sha256", []byte(sum+" "+info.Name()+"\n"), 0o644)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func bundleOsPackages(p *project) error {
|
||||||
|
release := projectDist(p, subRelease)
|
||||||
|
bins := map[string]os.FileInfo{}
|
||||||
|
if err := filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(info.Name(), ".sha256") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
bins[path] = info
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for binPath, info := range bins {
|
||||||
|
folder := filepath.Join(release, info.Name()+"-full")
|
||||||
|
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := moveFile(binPath+".sha256", filepath.Join(folder, info.Name()+".sha256")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := moveFile(binPath, filepath.Join(folder, info.Name())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.OsPackageExtras != nil {
|
||||||
|
if err := p.OsPackageExtras(folder, p); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func zipBundles(ctx context.Context, p *project) error {
|
||||||
|
zipDirAbs, err := filepath.Abs(projectDist(p, subZip))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
release := projectDist(p, subRelease)
|
||||||
|
return filepath.Walk(release, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() || filepath.Base(path) == subRelease {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("Zipping %s...\n", info.Name())
|
||||||
|
zipFile := filepath.Join(zipDirAbs, info.Name()+".zip")
|
||||||
|
//nolint:gosec // mage helper; args derive from the local filesystem walk above.
|
||||||
|
c := exec.CommandContext(ctx, "zip", "-r", zipFile, ".", "-i", "*")
|
||||||
|
c.Dir = path
|
||||||
|
c.Stdout, c.Stderr = os.Stdout, os.Stderr
|
||||||
|
return c.Run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoSuite validates the REPO_SUITE env var; defaults to "stable". Limiting
|
||||||
|
// the values prevents path traversal via the suite name flowing into a
|
||||||
|
// filesystem path.
|
||||||
|
func repoSuite() string {
|
||||||
|
switch os.Getenv("REPO_SUITE") {
|
||||||
|
case "stable", "unstable":
|
||||||
|
return os.Getenv("REPO_SUITE")
|
||||||
|
default:
|
||||||
|
return "stable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// helpers — duplicated from the project magefiles so this module depends on
|
||||||
|
// nothing but stdlib + mage. Don't import these from elsewhere; rewrite them
|
||||||
|
// here if they need to change.
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
si, err := os.Stat(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(dst, si.Mode()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveFile(src, dst string) error {
|
||||||
|
if err := copyFile(src, dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Remove(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256File(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aliases for kebab-case spelling at the CLI.
|
||||||
|
var Aliases = map[string]any{
|
||||||
|
"release": Release.Build,
|
||||||
|
"release:build": Release.Build,
|
||||||
|
"release:xgo": Release.Xgo,
|
||||||
|
"release:prepare-nfpm-config": Release.PrepareNFPMConfig,
|
||||||
|
"release:repo-apt": Release.RepoApt,
|
||||||
|
"release:repo-rpm": Release.RepoRpm,
|
||||||
|
"release:repo-pacman": Release.RepoPacman,
|
||||||
|
}
|
||||||
|
|
@ -849,6 +849,11 @@
|
||||||
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))",
|
"default_value": "(&(objectclass=*)(|(objectclass=group)(objectclass=groupOfNames)))",
|
||||||
"comment": "The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`."
|
"comment": "The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "groupsyncuseserviceaccount",
|
||||||
|
"default_value": "false",
|
||||||
|
"comment": "If true, Vikunja re-binds as the service account (binddn/bindpassword) before searching for groups during group sync. Enable this when the authenticating user does not have sufficient rights to enumerate group membership in the directory."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "avatarsyncattribute",
|
"key": "avatarsyncattribute",
|
||||||
"default_value": "",
|
"default_value": "",
|
||||||
|
|
@ -997,6 +1002,37 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "audit",
|
||||||
|
"comment": "Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"key": "enabled",
|
||||||
|
"default_value": "false",
|
||||||
|
"comment": "Whether to enable audit logging."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "logfile",
|
||||||
|
"default_value": "",
|
||||||
|
"comment": "The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "rotation",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"key": "maxsizemb",
|
||||||
|
"default_value": "100",
|
||||||
|
"comment": "Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "maxage",
|
||||||
|
"default_value": "30",
|
||||||
|
"comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "outgoingrequests",
|
"key": "outgoingrequests",
|
||||||
"children": [
|
"children": [
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
|
|
@ -10,6 +10,7 @@ const {
|
||||||
screen,
|
screen,
|
||||||
} = require('electron')
|
} = require('electron')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const portInUse = require('./portInUse.js')
|
const portInUse = require('./portInUse.js')
|
||||||
const oauth = require('./oauth.js')
|
const oauth = require('./oauth.js')
|
||||||
|
|
@ -24,6 +25,9 @@ const SAFE_PROTOCOLS = new Set([
|
||||||
const QUICK_ENTRY_WIDTH = 680
|
const QUICK_ENTRY_WIDTH = 680
|
||||||
const QUICK_ENTRY_COLLAPSED_HEIGHT = 56
|
const QUICK_ENTRY_COLLAPSED_HEIGHT = 56
|
||||||
|
|
||||||
|
const ZOOM_STEP = 0.5
|
||||||
|
const ZOOM_CONFIG_FILE = 'zoom.json'
|
||||||
|
|
||||||
const BASE_WEB_PREFERENCES = {
|
const BASE_WEB_PREFERENCES = {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
|
@ -52,6 +56,7 @@ let isQuitting = false
|
||||||
let pendingDeepLinkUrl = null
|
let pendingDeepLinkUrl = null
|
||||||
let pendingApiUrl = null
|
let pendingApiUrl = null
|
||||||
let currentShortcut = null
|
let currentShortcut = null
|
||||||
|
let zoomLevel = 0
|
||||||
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CmdOrCtrl+Shift+A'
|
const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CmdOrCtrl+Shift+A'
|
||||||
const launchedWithQuickEntry = process.argv.includes('--quick-entry')
|
const launchedWithQuickEntry = process.argv.includes('--quick-entry')
|
||||||
|
|
||||||
|
|
@ -95,10 +100,15 @@ app.on('second-instance', (_event, argv) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus the main window
|
// Reveal the main window. It may be hidden in the tray (not just minimized),
|
||||||
|
// so show() is required — focus() alone won't surface a hidden window, which
|
||||||
|
// made the app look dead when relaunched while running in the tray.
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
|
mainWindow.show()
|
||||||
mainWindow.focus()
|
mainWindow.focus()
|
||||||
|
} else if (serverPort) {
|
||||||
|
createMainWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the deep link URL in argv
|
// Find the deep link URL in argv
|
||||||
|
|
@ -172,11 +182,70 @@ function startServer(callback) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Zoom ────────────────────────────────────────────────────────────
|
||||||
|
function zoomConfigPath() {
|
||||||
|
return path.join(app.getPath('userData'), ZOOM_CONFIG_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadZoomLevel() {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(zoomConfigPath(), 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (typeof parsed.zoomLevel === 'number' && Number.isFinite(parsed.zoomLevel)) {
|
||||||
|
return parsed.zoomLevel
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// First run or unreadable file, fall back to default
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveZoomLevel(level) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(zoomConfigPath(), JSON.stringify({zoomLevel: level}))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to persist zoom level:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom(webContents, level) {
|
||||||
|
zoomLevel = level
|
||||||
|
webContents.setZoomLevel(level)
|
||||||
|
saveZoomLevel(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireZoomHandlers(win) {
|
||||||
|
win.webContents.on('before-input-event', (event, input) => {
|
||||||
|
if (input.type !== 'keyDown' || !input.control || input.alt || input.meta) return
|
||||||
|
const key = input.key
|
||||||
|
if (key === '=' || key === '+') {
|
||||||
|
applyZoom(win.webContents, zoomLevel + ZOOM_STEP)
|
||||||
|
event.preventDefault()
|
||||||
|
} else if (key === '-') {
|
||||||
|
applyZoom(win.webContents, zoomLevel - ZOOM_STEP)
|
||||||
|
event.preventDefault()
|
||||||
|
} else if (key === '0') {
|
||||||
|
applyZoom(win.webContents, 0)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
win.webContents.on('zoom-changed', (_event, direction) => {
|
||||||
|
const delta = direction === 'in' ? ZOOM_STEP : -ZOOM_STEP
|
||||||
|
applyZoom(win.webContents, zoomLevel + delta)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Main window ─────────────────────────────────────────────────────
|
// ─── Main window ─────────────────────────────────────────────────────
|
||||||
function createMainWindow() {
|
function createMainWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1680,
|
width: 1680,
|
||||||
height: 960,
|
height: 960,
|
||||||
|
// Without an explicit window icon, X11/XWayland compositors (e.g. KDE
|
||||||
|
// Plasma) fall back to a generic placeholder when WM_CLASS doesn't match
|
||||||
|
// an installed .desktop file. icon.png lives at the app root because
|
||||||
|
// build/ is electron-builder's buildResources dir and isn't packaged.
|
||||||
|
icon: path.join(__dirname, 'icon.png'),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
...BASE_WEB_PREFERENCES,
|
...BASE_WEB_PREFERENCES,
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
|
@ -213,6 +282,11 @@ function createMainWindow() {
|
||||||
|
|
||||||
mainWindow.loadURL(`http://127.0.0.1:${serverPort}`)
|
mainWindow.loadURL(`http://127.0.0.1:${serverPort}`)
|
||||||
|
|
||||||
|
wireZoomHandlers(mainWindow)
|
||||||
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
|
mainWindow.webContents.setZoomLevel(zoomLevel)
|
||||||
|
})
|
||||||
|
|
||||||
// Process any deep link that arrived before the page was ready,
|
// Process any deep link that arrived before the page was ready,
|
||||||
// either buffered from open-url or passed via process.argv on first launch
|
// either buffered from open-url or passed via process.argv on first launch
|
||||||
mainWindow.webContents.once('did-finish-load', () => {
|
mainWindow.webContents.once('did-finish-load', () => {
|
||||||
|
|
@ -333,7 +407,11 @@ function toggleQuickEntry() {
|
||||||
// ─── System tray ─────────────────────────────────────────────────────
|
// ─── System tray ─────────────────────────────────────────────────────
|
||||||
function setupTray() {
|
function setupTray() {
|
||||||
if (!tray) {
|
if (!tray) {
|
||||||
const iconPath = path.join(__dirname, 'build', 'icon.png')
|
// NOTE: load the icon from the app root, not build/. The build/ directory is
|
||||||
|
// electron-builder's buildResources dir and is NOT packaged into the app, so
|
||||||
|
// referencing build/icon.png here works in dev but yields an empty tray icon
|
||||||
|
// in packaged releases (see issue #2668).
|
||||||
|
const iconPath = path.join(__dirname, 'icon.png')
|
||||||
const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
|
const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
|
||||||
tray = new Tray(icon)
|
tray = new Tray(icon)
|
||||||
tray.setToolTip('Vikunja')
|
tray.setToolTip('Vikunja')
|
||||||
|
|
@ -434,6 +512,8 @@ ipcMain.on('desktop:update-quick-entry-shortcut', (_event, shortcut) => {
|
||||||
|
|
||||||
// ─── App lifecycle ───────────────────────────────────────────────────
|
// ─── App lifecycle ───────────────────────────────────────────────────
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
zoomLevel = loadZoomLevel()
|
||||||
|
|
||||||
startServer(() => {
|
startServer(() => {
|
||||||
createMainWindow()
|
createMainWindow()
|
||||||
createQuickEntryWindow()
|
createQuickEntryWindow()
|
||||||
|
|
@ -473,3 +553,14 @@ app.on('window-all-closed', () => {
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Quit on termination signals (DE/systemd shutdown, `kill`). Without an explicit
|
||||||
|
// handler the app ignores SIGTERM because the tray and express server keep the
|
||||||
|
// event loop alive — leaving users to `kill -9`. isQuitting must be set first so
|
||||||
|
// the hide-to-tray close handler doesn't swallow the quit.
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
isQuitting = true
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"repository": "https://code.vikunja.io/desktop",
|
"repository": "https://code.vikunja.io/desktop",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"packageManager": "pnpm@10.28.1",
|
"packageManager": "pnpm@10.34.4",
|
||||||
"author": {
|
"author": {
|
||||||
"email": "maintainers@vikunja.io",
|
"email": "maintainers@vikunja.io",
|
||||||
"name": "Vikunja Team"
|
"name": "Vikunja Team"
|
||||||
|
|
@ -61,9 +61,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "40.9.1",
|
"electron": "40.10.5",
|
||||||
"electron-builder": "26.8.1",
|
"electron-builder": "26.15.3",
|
||||||
"unzipper": "0.12.3"
|
"unzipper": "0.12.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "5.2.1"
|
"express": "5.2.1"
|
||||||
|
|
@ -73,10 +73,16 @@
|
||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"minimatch": "^10.2.3",
|
"minimatch": "10.2.5",
|
||||||
"tar": "^7.5.11",
|
"tar": "7.5.17",
|
||||||
"@tootallnate/once": "^3.0.1",
|
"@tootallnate/once": "3.0.1",
|
||||||
"picomatch": ">=4.0.4"
|
"picomatch": "4.0.4",
|
||||||
|
"tmp": "0.2.7",
|
||||||
|
"ip-address": "10.2.0",
|
||||||
|
"form-data": "4.0.6",
|
||||||
|
"js-yaml": "5.2.0",
|
||||||
|
"undici@6": "6.27.0",
|
||||||
|
"undici@7": "7.28.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
85
devenv.lock
85
devenv.lock
|
|
@ -3,10 +3,11 @@
|
||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1773012232,
|
"lastModified": 1782492839,
|
||||||
|
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
|
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -16,71 +17,16 @@
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"flake-compat": {
|
|
||||||
"flake": false,
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1767039857,
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "flake-compat",
|
|
||||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "flake-compat",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"git-hooks": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-compat": "flake-compat",
|
|
||||||
"gitignore": "gitignore",
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1772893680,
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "git-hooks.nix",
|
|
||||||
"rev": "8baab586afc9c9b57645a734c820e4ac0a604af9",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "cachix",
|
|
||||||
"repo": "git-hooks.nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gitignore": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"git-hooks",
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1762808025,
|
|
||||||
"owner": "hercules-ci",
|
|
||||||
"repo": "gitignore.nix",
|
|
||||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "hercules-ci",
|
|
||||||
"repo": "gitignore.nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-src": "nixpkgs-src"
|
"nixpkgs-src": "nixpkgs-src"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772749504,
|
"lastModified": 1782132010,
|
||||||
|
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv-nixpkgs",
|
"repo": "devenv-nixpkgs",
|
||||||
"rev": "08543693199362c1fddb8f52126030d0d374ba2e",
|
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -93,11 +39,11 @@
|
||||||
"nixpkgs-src": {
|
"nixpkgs-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1769922788,
|
"lastModified": 1781607440,
|
||||||
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
|
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
|
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -109,10 +55,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs-unstable": {
|
"nixpkgs-unstable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772773019,
|
"lastModified": 1782467914,
|
||||||
|
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
|
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -125,15 +72,11 @@
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"devenv": "devenv",
|
"devenv": "devenv",
|
||||||
"git-hooks": "git-hooks",
|
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"nixpkgs-unstable": "nixpkgs-unstable",
|
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||||
"pre-commit-hooks": [
|
|
||||||
"git-hooks"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
"version": 7
|
"version": 7
|
||||||
}
|
}
|
||||||
|
|
@ -1 +1 @@
|
||||||
24.13.0
|
24.18.0
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@
|
||||||
// It has to be the full url, including the last /api/v1 part and port.
|
// 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.
|
// You can change this if your api is not reachable on the same port as the frontend.
|
||||||
window.API_URL = '/api/v1'
|
window.API_URL = '/api/v1'
|
||||||
window.ALLOW_ICON_CHANGES = true
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://vikunja.io/",
|
"homepage": "https://vikunja.io/",
|
||||||
"funding": "https://opencollective.com/vikunja",
|
"funding": "https://opencollective.com/vikunja",
|
||||||
"packageManager": "pnpm@10.28.1",
|
"packageManager": "pnpm@10.34.4",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -51,111 +51,114 @@
|
||||||
"story:preview": "histoire preview"
|
"story:preview": "histoire preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "1.7.4",
|
"@floating-ui/dom": "1.7.6",
|
||||||
"@fortawesome/fontawesome-svg-core": "7.1.0",
|
"@fortawesome/fontawesome-svg-core": "7.3.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "7.1.0",
|
"@fortawesome/free-regular-svg-icons": "7.3.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "7.1.0",
|
"@fortawesome/free-solid-svg-icons": "7.3.0",
|
||||||
"@fortawesome/vue-fontawesome": "3.1.3",
|
"@fortawesome/vue-fontawesome": "3.3.0",
|
||||||
"@intlify/unplugin-vue-i18n": "11.0.3",
|
"@intlify/unplugin-vue-i18n": "11.2.4",
|
||||||
"@kyvg/vue3-notification": "3.4.2",
|
"@kyvg/vue3-notification": "3.4.2",
|
||||||
"@sentry/vue": "10.36.0",
|
"@sentry/vue": "10.62.0",
|
||||||
"@tiptap/core": "3.17.0",
|
"@tiptap/core": "3.27.1",
|
||||||
"@tiptap/extension-code-block-lowlight": "3.17.0",
|
"@tiptap/extension-blockquote": "3.27.1",
|
||||||
"@tiptap/extension-hard-break": "3.17.0",
|
"@tiptap/extension-code-block-lowlight": "3.27.1",
|
||||||
"@tiptap/extension-image": "3.17.0",
|
"@tiptap/extension-hard-break": "3.27.1",
|
||||||
"@tiptap/extension-link": "3.17.0",
|
"@tiptap/extension-image": "3.27.1",
|
||||||
"@tiptap/extension-list": "3.17.0",
|
"@tiptap/extension-link": "3.27.1",
|
||||||
"@tiptap/extension-mention": "3.17.0",
|
"@tiptap/extension-list": "3.27.1",
|
||||||
"@tiptap/extension-table": "3.17.0",
|
"@tiptap/extension-mention": "3.27.1",
|
||||||
"@tiptap/extension-typography": "3.17.0",
|
"@tiptap/extension-table": "3.27.1",
|
||||||
"@tiptap/extension-underline": "3.17.0",
|
"@tiptap/extension-typography": "3.27.1",
|
||||||
"@tiptap/extensions": "3.17.0",
|
"@tiptap/extension-underline": "3.27.1",
|
||||||
"@tiptap/pm": "3.17.0",
|
"@tiptap/extensions": "3.27.1",
|
||||||
"@tiptap/starter-kit": "3.17.0",
|
"@tiptap/pm": "3.27.1",
|
||||||
"@tiptap/suggestion": "3.17.0",
|
"@tiptap/starter-kit": "3.27.1",
|
||||||
"@tiptap/vue-3": "3.17.0",
|
"@tiptap/suggestion": "3.27.1",
|
||||||
"@vueuse/core": "14.1.0",
|
"@tiptap/vue-3": "3.27.1",
|
||||||
"@vueuse/router": "14.1.0",
|
"@vueuse/core": "14.3.0",
|
||||||
"axios": "1.15.0",
|
"@vueuse/router": "14.3.0",
|
||||||
|
"axios": "1.18.1",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"bulma-css-variables": "0.9.33",
|
"bulma-css-variables": "0.9.33",
|
||||||
"change-case": "5.4.4",
|
"change-case": "5.4.4",
|
||||||
"dayjs": "1.11.19",
|
"dayjs": "1.11.21",
|
||||||
"dompurify": "3.4.0",
|
"dompurify": "3.4.11",
|
||||||
"fast-deep-equal": "3.1.3",
|
"fast-deep-equal": "3.1.3",
|
||||||
"flatpickr": "4.6.13",
|
"flatpickr": "4.6.13",
|
||||||
"floating-vue": "5.2.2",
|
"floating-vue": "5.2.2",
|
||||||
"is-touch-device": "1.0.1",
|
"is-touch-device": "1.0.1",
|
||||||
"klona": "2.0.6",
|
"klona": "2.0.6",
|
||||||
"lowlight": "3.3.0",
|
"lowlight": "3.3.0",
|
||||||
"marked": "17.0.1",
|
"marked": "17.0.6",
|
||||||
"nanoid": "5.1.6",
|
"nanoid": "5.1.16",
|
||||||
"pinia": "3.0.4",
|
"pinia": "3.0.4",
|
||||||
"register-service-worker": "1.7.2",
|
"register-service-worker": "1.7.2",
|
||||||
"sortablejs": "1.15.6",
|
"sortablejs": "1.15.7",
|
||||||
"ufo": "1.6.3",
|
"ufo": "1.6.4",
|
||||||
"vue": "3.5.27",
|
"vue": "3.5.39",
|
||||||
"vue-advanced-cropper": "2.8.9",
|
"vue-advanced-cropper": "2.8.9",
|
||||||
"vue-flatpickr-component": "11.0.5",
|
"vue-flatpickr-component": "11.0.5",
|
||||||
"vue-i18n": "11.2.8",
|
"vue-i18n": "11.4.6",
|
||||||
"vue-router": "4.6.4",
|
"vue-router": "4.6.4",
|
||||||
"vuemoji-picker": "0.3.2",
|
"vuemoji-picker": "0.3.2",
|
||||||
"workbox-precaching": "7.4.0",
|
"workbox-precaching": "7.4.1",
|
||||||
"zhyswan-vuedraggable": "4.1.3"
|
"zhyswan-vuedraggable": "4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "10.4.0",
|
"@faker-js/faker": "10.5.0",
|
||||||
"@histoire/plugin-screenshot": "1.0.0-beta.1",
|
"@histoire/plugin-screenshot": "1.0.0-beta.1",
|
||||||
"@histoire/plugin-vue": "1.0.0-beta.1",
|
"@histoire/plugin-vue": "1.0.0-beta.1",
|
||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.61.1",
|
||||||
"@sentry/vite-plugin": "3.6.1",
|
"@sentry/vite-plugin": "3.6.1",
|
||||||
"@tailwindcss/vite": "4.2.2",
|
"@tailwindcss/vite": "4.3.1",
|
||||||
"@tsconfig/node24": "24.0.4",
|
"@tsconfig/node24": "24.0.4",
|
||||||
"@types/codemirror": "5.60.17",
|
"@types/codemirror": "5.60.17",
|
||||||
"@types/is-touch-device": "1.0.3",
|
"@types/is-touch-device": "1.0.3",
|
||||||
"@types/node": "24.12.2",
|
"@types/node": "24.13.2",
|
||||||
"@types/sortablejs": "1.15.9",
|
"@types/sortablejs": "1.15.9",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
"@typescript-eslint/eslint-plugin": "8.62.0",
|
||||||
"@typescript-eslint/parser": "8.58.2",
|
"@typescript-eslint/parser": "8.62.0",
|
||||||
"@vitejs/plugin-vue": "6.0.6",
|
"@vitejs/plugin-vue": "6.0.7",
|
||||||
"@vue/eslint-config-typescript": "14.7.0",
|
"@vue/eslint-config-typescript": "14.9.0",
|
||||||
"@vue/test-utils": "2.4.6",
|
"@vue/test-utils": "2.4.11",
|
||||||
"@vue/tsconfig": "0.9.1",
|
"@vue/tsconfig": "0.9.1",
|
||||||
"@vueuse/shared": "14.2.1",
|
"@vueuse/shared": "14.3.0",
|
||||||
"autoprefixer": "10.5.0",
|
"autoprefixer": "10.5.2",
|
||||||
"browserslist": "4.28.2",
|
"browserslist": "4.28.4",
|
||||||
"caniuse-lite": "1.0.30001788",
|
"caniuse-lite": "1.0.30001799",
|
||||||
"csstype": "3.2.3",
|
"csstype": "3.2.3",
|
||||||
"esbuild": "0.28.0",
|
"esbuild": "0.28.1",
|
||||||
"eslint": "9.39.4",
|
"eslint": "9.39.4",
|
||||||
"eslint-plugin-depend": "1.5.0",
|
"eslint-plugin-depend": "1.5.0",
|
||||||
"eslint-plugin-vue": "10.8.0",
|
"eslint-plugin-vue": "10.9.2",
|
||||||
"happy-dom": "20.9.0",
|
"happy-dom": "20.10.6",
|
||||||
"histoire": "1.0.0-beta.1",
|
"histoire": "1.0.0-beta.1",
|
||||||
"postcss": "8.5.10",
|
"otplib": "12.0.1",
|
||||||
|
"postcss": "8.5.15",
|
||||||
"postcss-easing-gradients": "3.0.1",
|
"postcss-easing-gradients": "3.0.1",
|
||||||
"postcss-preset-env": "11.2.1",
|
"postcss-html": "1.8.1",
|
||||||
"rollup": "4.60.1",
|
"postcss-preset-env": "11.3.1",
|
||||||
|
"rollup": "4.62.2",
|
||||||
"rollup-plugin-visualizer": "6.0.11",
|
"rollup-plugin-visualizer": "6.0.11",
|
||||||
"sass-embedded": "1.99.0",
|
"sass-embedded": "1.100.0",
|
||||||
"stylelint": "17.8.0",
|
"stylelint": "17.13.0",
|
||||||
"stylelint-config-property-sort-order-smacss": "10.0.0",
|
"stylelint-config-property-sort-order-smacss": "10.0.0",
|
||||||
"stylelint-config-recommended-vue": "1.6.1",
|
"stylelint-config-recommended-vue": "1.6.1",
|
||||||
"stylelint-config-standard-scss": "17.0.0",
|
"stylelint-config-standard-scss": "17.0.0",
|
||||||
"stylelint-use-logical": "2.1.3",
|
"stylelint-use-logical": "2.1.3",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.3.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"unplugin-inject-preload": "3.0.0",
|
"unplugin-inject-preload": "3.0.0",
|
||||||
"vite": "7.3.2",
|
"vite": "7.3.6",
|
||||||
"vite-plugin-pwa": "1.2.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vite-plugin-vue-devtools": "8.1.1",
|
"vite-plugin-vue-devtools": "8.1.4",
|
||||||
"vite-svg-loader": "5.1.1",
|
"vite-svg-loader": "5.1.1",
|
||||||
"vitest": "4.1.4",
|
"vitest": "4.1.9",
|
||||||
"vue-tsc": "3.2.6",
|
"vue-tsc": "3.3.5",
|
||||||
"wait-on": "9.0.5",
|
"wait-on": "9.0.10",
|
||||||
"workbox-cli": "7.4.0",
|
"workbox-cli": "7.4.1",
|
||||||
"ws": "8.20.0"
|
"ws": "8.21.0"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|
@ -166,11 +169,20 @@
|
||||||
"vue-demi"
|
"vue-demi"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"minimatch": "^10.2.3",
|
"minimatch": "10.2.5",
|
||||||
"rollup": "$rollup",
|
"rollup": "$rollup",
|
||||||
"basic-ftp": ">=5.2.2",
|
"basic-ftp": "6.0.1",
|
||||||
"serialize-javascript": "^7.0.5",
|
"serialize-javascript": "7.0.6",
|
||||||
"flatted": "^3.4.1"
|
"flatted": "3.4.2",
|
||||||
|
"ip-address": "10.2.0",
|
||||||
|
"postcss": "8.5.15",
|
||||||
|
"tmp": "0.2.7",
|
||||||
|
"esbuild": "0.28.1",
|
||||||
|
"form-data": "4.0.6",
|
||||||
|
"markdown-it": "14.2.0",
|
||||||
|
"launch-editor": "2.14.1",
|
||||||
|
"@babel/core": "8.0.1",
|
||||||
|
"js-yaml@4": "5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
|
|
@ -9,6 +9,12 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
class="skip-to-content"
|
||||||
|
>
|
||||||
|
{{ $t('misc.skipToContent') }}
|
||||||
|
</a>
|
||||||
<template v-if="showAuthLayout">
|
<template v-if="showAuthLayout">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<ContentAuth />
|
<ContentAuth />
|
||||||
|
|
@ -55,6 +61,7 @@ import {useAuthStore} from '@/stores/auth'
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
|
|
||||||
import {useColorScheme} from '@/composables/useColorScheme'
|
import {useColorScheme} from '@/composables/useColorScheme'
|
||||||
|
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
|
||||||
import {useBodyClass} from '@/composables/useBodyClass'
|
import {useBodyClass} from '@/composables/useBodyClass'
|
||||||
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
|
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
|
||||||
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
|
||||||
|
|
@ -101,6 +108,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
||||||
|
|
||||||
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
|
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
|
||||||
useColorScheme()
|
useColorScheme()
|
||||||
|
useTimeTrackingFavicon()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="@/styles/tailwind.css" />
|
<style src="@/styles/tailwind.css" />
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
class="is-sr-only"
|
class="is-sr-only"
|
||||||
:checked="modelValue"
|
:checked="modelValue"
|
||||||
:disabled="disabled || undefined"
|
:disabled="disabled || undefined"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
|
@change="(event) => emit('update:modelValue', (event.target as HTMLInputElement).checked)"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
@ -22,8 +23,10 @@
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
modelValue?: boolean,
|
modelValue?: boolean,
|
||||||
disabled: boolean,
|
disabled: boolean,
|
||||||
|
ariaLabel?: string,
|
||||||
}>(), {
|
}>(), {
|
||||||
modelValue: false,
|
modelValue: false,
|
||||||
|
ariaLabel: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ function createPagination(totalPages: number, currentPage: number): PaginationPa
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pages.push({
|
pages.push({
|
||||||
number: i + 1,
|
number: i + 1,
|
||||||
isEllipsis: false,
|
isEllipsis: false,
|
||||||
|
|
@ -82,22 +82,92 @@ const pages = computed(() => createPagination(props.totalPages, props.currentPag
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.pagination {
|
// Layout/scaffold rules ported from bulma-css-variables/sass/components/pagination.sass.
|
||||||
padding-block-end: 1rem;
|
// BasePagination only owns .pagination / .pagination-list / .pagination-ellipsis —
|
||||||
}
|
// the actual pagination items (.pagination-previous / -next / -link) and their
|
||||||
|
// styles live in PaginationItem.vue.
|
||||||
|
|
||||||
.pagination-previous,
|
.pagination {
|
||||||
.pagination-next {
|
align-items: center;
|
||||||
&:not(:disabled):hover {
|
display: flex;
|
||||||
background: $scheme-main;
|
font-size: $size-normal;
|
||||||
cursor: pointer;
|
justify-content: center;
|
||||||
}
|
margin: -0.25rem;
|
||||||
|
padding-block-end: 1rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-list {
|
.pagination-list {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
&, & li {
|
&, & li {
|
||||||
margin-block-start: 0;
|
margin-block-start: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-ellipsis {
|
||||||
|
appearance: none;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: $radius;
|
||||||
|
box-shadow: none;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 1em;
|
||||||
|
block-size: 2.5em;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0.25rem;
|
||||||
|
padding: calc(0.5em - 1px) 0.5em;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
color: var(--grey-light);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: $tablet - 1px) {
|
||||||
|
.pagination {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-list li {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: $tablet), print {
|
||||||
|
.pagination-list {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-ellipsis {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-block: 0;
|
||||||
|
|
||||||
|
&.is-centered {
|
||||||
|
.pagination-list {
|
||||||
|
justify-content: center;
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,18 @@ describe('DatepickerWithRange predefined ranges', () => {
|
||||||
const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
|
const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
|
||||||
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'})
|
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A cleared range (the Custom option) comes back as null via v-model; the
|
||||||
|
// modelValue watcher must coerce it, not call null.toISOString().
|
||||||
|
it('accepts a null modelValue without crashing', async () => {
|
||||||
|
const wrapper = mountPicker()
|
||||||
|
await wrapper.setProps({modelValue: {dateFrom: 'now/w', dateTo: 'now/w+1w'}})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect((wrapper.vm as any).from).toBe('now/w')
|
||||||
|
|
||||||
|
await wrapper.setProps({modelValue: {dateFrom: null, dateTo: null}})
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect((wrapper.vm as any).from).toBe('')
|
||||||
|
expect((wrapper.vm as any).to).toBe('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -114,16 +114,17 @@ import DatemathHelp from '@/components/date/DatemathHelp.vue'
|
||||||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
// null for a side that's been cleared (the Custom option) — emitted, so accepted too.
|
||||||
modelValue: {
|
modelValue: {
|
||||||
dateFrom: Date | string,
|
dateFrom: Date | string | null,
|
||||||
dateTo: Date | string,
|
dateTo: Date | string | null,
|
||||||
},
|
},
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: {
|
'update:modelValue': [value: {
|
||||||
dateFrom: Date | string,
|
dateFrom: Date | string | null,
|
||||||
dateTo: Date | string
|
dateTo: Date | string | null
|
||||||
}]
|
}]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|
@ -149,8 +150,8 @@ const to = ref('')
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
newValue => {
|
newValue => {
|
||||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : newValue.dateFrom.toISOString()
|
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : (newValue.dateFrom?.toISOString() ?? '')
|
||||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : newValue.dateTo.toISOString()
|
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : (newValue.dateTo?.toISOString() ?? '')
|
||||||
// Only set the date back to flatpickr when it's an actual date.
|
// 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.
|
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||||
const dateFrom = parseDateOrString(from.value, false)
|
const dateFrom = parseDateOrString(from.value, false)
|
||||||
|
|
@ -208,14 +209,22 @@ const customRangeActive = computed<boolean>(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const buttonText = computed<string>(() => {
|
const buttonText = computed<string>(() => {
|
||||||
if (from.value !== '' && to.value !== '') {
|
if (from.value === '' || to.value === '') {
|
||||||
return t('input.datepickerRange.fromto', {
|
return t('task.show.select')
|
||||||
from: from.value,
|
|
||||||
to: to.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return t('task.show.select')
|
// Show the preset's name when the range matches one, rather than the raw datemath.
|
||||||
|
const preset = Object.entries(DATE_RANGES).find(
|
||||||
|
([, range]) => from.value === range[0] && to.value === range[1],
|
||||||
|
)
|
||||||
|
if (preset) {
|
||||||
|
return t(`input.datepickerRange.ranges.${preset[0]}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('input.datepickerRange.fromto', {
|
||||||
|
from: from.value,
|
||||||
|
to: to.value,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export const DATE_RANGES = {
|
||||||
// Key is the title, as a translation string, the first entry of the value array
|
// Key is the title, as a translation string, the first entry of the value array
|
||||||
// is the "from" date, the second one is the "to" date.
|
// is the "from" date, the second one is the "to" date.
|
||||||
'today': ['now/d', 'now/d+1d'],
|
'today': ['now/d', 'now/d+1d'],
|
||||||
|
'tomorrow': ['now/d+1d', 'now/d+2d'],
|
||||||
|
|
||||||
'lastWeek': ['now/w-1w', 'now/w'],
|
'lastWeek': ['now/w-1w', 'now/w'],
|
||||||
'thisWeek': ['now/w', 'now/w+1w'],
|
'thisWeek': ['now/w', 'now/w+1w'],
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,14 @@
|
||||||
<div class="gantt-chart-wrapper">
|
<div class="gantt-chart-wrapper">
|
||||||
<GanttTimelineHeader
|
<GanttTimelineHeader
|
||||||
:timeline-data="timelineData"
|
:timeline-data="timelineData"
|
||||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
:day-width-pixels="dayWidthPixels"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GanttVerticalGridLines
|
<GanttVerticalGridLines
|
||||||
:timeline-data="timelineData"
|
:timeline-data="timelineData"
|
||||||
:total-width="totalWidth"
|
:total-width="totalWidth"
|
||||||
:height="ganttRows.length * 40"
|
:height="ganttRows.length * 40"
|
||||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
:day-width-pixels="dayWidthPixels"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GanttChartBody
|
<GanttChartBody
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
:total-width="totalWidth"
|
:total-width="totalWidth"
|
||||||
:date-from-date="dateFromDate"
|
:date-from-date="dateFromDate"
|
||||||
:date-to-date="dateToDate"
|
:date-to-date="dateToDate"
|
||||||
:day-width-pixels="DAY_WIDTH_PIXELS"
|
:day-width-pixels="dayWidthPixels"
|
||||||
:is-dragging="isDragging"
|
:is-dragging="isDragging"
|
||||||
:is-resizing="isResizing"
|
:is-resizing="isResizing"
|
||||||
:drag-state="dragState"
|
:drag-state="dragState"
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
|
import {computed, ref, watch, toRefs, nextTick, onMounted, onBeforeUnmount, onUnmounted} from 'vue'
|
||||||
import {useRouter} from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
|
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
|
||||||
|
|
@ -126,7 +126,9 @@ const emit = defineEmits<{
|
||||||
(e: 'update:task', task: ITaskPartialWithId): void
|
(e: 'update:task', task: ITaskPartialWithId): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const DAY_WIDTH_PIXELS = 30
|
const DAY_WIDTH_PIXELS_MIN = 30
|
||||||
|
const dayWidthPixels = ref(0)
|
||||||
|
let resizeObserver: ResizeObserver
|
||||||
|
|
||||||
const {tasks, filters} = toRefs(props)
|
const {tasks, filters} = toRefs(props)
|
||||||
|
|
||||||
|
|
@ -158,7 +160,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
|
||||||
|
|
||||||
const totalWidth = computed(() => {
|
const totalWidth = computed(() => {
|
||||||
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
|
||||||
return dateDiff * DAY_WIDTH_PIXELS
|
return dateDiff * dayWidthPixels.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const timelineData = computed(() => {
|
const timelineData = computed(() => {
|
||||||
|
|
@ -297,6 +299,55 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateDayWidthPixels() {
|
||||||
|
const node = ganttContainer.value
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
const rect = node.getBoundingClientRect()
|
||||||
|
const styles = window.getComputedStyle(node)
|
||||||
|
|
||||||
|
const marginLeft = parseFloat(styles.marginLeft) || 0
|
||||||
|
const marginRight = parseFloat(styles.marginRight) || 0
|
||||||
|
|
||||||
|
// max width without overflow
|
||||||
|
const maxWidth = rect.width - marginLeft - marginRight
|
||||||
|
|
||||||
|
const dayCount = Math.ceil(
|
||||||
|
(dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
dayWidthPixels.value = Math.max(
|
||||||
|
maxWidth / dayCount,
|
||||||
|
DAY_WIDTH_PIXELS_MIN,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
updateDayWidthPixels()
|
||||||
|
|
||||||
|
if (ganttContainer.value) {
|
||||||
|
resizeObserver = new ResizeObserver(updateDayWidthPixels)
|
||||||
|
resizeObserver.observe(ganttContainer.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateDayWidthPixels)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
window.removeEventListener('resize', updateDayWidthPixels)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[dateFromDate, dateToDate],
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
updateDayWidthPixels()
|
||||||
|
},
|
||||||
|
{flush: 'post'},
|
||||||
|
)
|
||||||
|
|
||||||
// Build the task tree when tasks change
|
// Build the task tree when tasks change
|
||||||
watch(
|
watch(
|
||||||
[tasks, filters],
|
[tasks, filters],
|
||||||
|
|
@ -351,7 +402,7 @@ const ROW_HEIGHT = 40
|
||||||
const barPositions = computed(() => {
|
const barPositions = computed(() => {
|
||||||
const positions = new Map<number, GanttBarPosition>()
|
const positions = new Map<number, GanttBarPosition>()
|
||||||
const ds = dragState.value
|
const ds = dragState.value
|
||||||
const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
|
const dragPixelOffset = ds ? ds.currentDays * dayWidthPixels.value : 0
|
||||||
|
|
||||||
ganttBars.value.forEach((rowBars, rowIndex) => {
|
ganttBars.value.forEach((rowBars, rowIndex) => {
|
||||||
for (const bar of rowBars) {
|
for (const bar of rowBars) {
|
||||||
|
|
@ -386,7 +437,7 @@ function computeBarX(date: Date): number {
|
||||||
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
|
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
|
||||||
MILLISECONDS_A_DAY,
|
MILLISECONDS_A_DAY,
|
||||||
)
|
)
|
||||||
return diff * DAY_WIDTH_PIXELS
|
return diff * dayWidthPixels.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeBarWidth(bar: GanttBarModel): number {
|
function computeBarWidth(bar: GanttBarModel): number {
|
||||||
|
|
@ -394,7 +445,7 @@ function computeBarWidth(bar: GanttBarModel): number {
|
||||||
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
|
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
|
||||||
MILLISECONDS_A_DAY,
|
MILLISECONDS_A_DAY,
|
||||||
)
|
)
|
||||||
return diff * DAY_WIDTH_PIXELS
|
return diff * dayWidthPixels.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute relation arrows
|
// Compute relation arrows
|
||||||
|
|
@ -590,7 +641,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
|
||||||
if (!dragState.value || !isDragging.value) return
|
if (!dragState.value || !isDragging.value) return
|
||||||
|
|
||||||
const diff = e.clientX - dragState.value.startX
|
const diff = e.clientX - dragState.value.startX
|
||||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
const days = Math.round(diff / dayWidthPixels.value)
|
||||||
|
|
||||||
if (days !== dragState.value.currentDays) {
|
if (days !== dragState.value.currentDays) {
|
||||||
dragState.value.currentDays = days
|
dragState.value.currentDays = days
|
||||||
|
|
@ -652,7 +703,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
|
||||||
if (!dragState.value || !isResizing.value) return
|
if (!dragState.value || !isResizing.value) return
|
||||||
|
|
||||||
const diff = e.clientX - dragState.value.startX
|
const diff = e.clientX - dragState.value.startX
|
||||||
const days = Math.round(diff / DAY_WIDTH_PIXELS)
|
const days = Math.round(diff / dayWidthPixels.value)
|
||||||
|
|
||||||
if (edge === 'start') {
|
if (edge === 'start') {
|
||||||
const newStart = new Date(dragState.value.originalStart)
|
const newStart = new Date(dragState.value.originalStart)
|
||||||
|
|
@ -730,7 +781,7 @@ function focusTaskBar(rowId: string) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
|
const taskBarElement = document.querySelector(`[data-row-id="${rowId}"] [role="slider"]`) as HTMLElement
|
||||||
if (taskBarElement) {
|
if (taskBarElement) {
|
||||||
taskBarElement.focus()
|
taskBarElement.focus({preventScroll: true})
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
{{ $t('home.addToHomeScreen') }}
|
{{ $t('home.addToHomeScreen') }}
|
||||||
</p>
|
</p>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
:aria-label="$t('misc.closeBanner')"
|
||||||
class="hide-button"
|
class="hide-button"
|
||||||
@click="() => hideMessage = true"
|
@click="() => hideMessage = true"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ name: 'home' }"
|
:to="{ name: 'home' }"
|
||||||
class="logo-link"
|
class="logo-link"
|
||||||
:aria-label="$t('navigation.overview')"
|
:aria-label="$t('navigation.home')"
|
||||||
>
|
>
|
||||||
<Logo
|
<Logo
|
||||||
width="164"
|
width="164"
|
||||||
|
|
@ -21,9 +21,9 @@
|
||||||
v-if="currentProject?.id"
|
v-if="currentProject?.id"
|
||||||
class="project-title-wrapper"
|
class="project-title-wrapper"
|
||||||
>
|
>
|
||||||
<h1 class="project-title">
|
<span class="project-title">
|
||||||
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
|
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
|
||||||
</h1>
|
</span>
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="!isEditorContentEmpty(currentProject.description)"
|
v-if="!isEditorContentEmpty(currentProject.description)"
|
||||||
|
|
@ -54,7 +54,15 @@
|
||||||
</ProjectSettingsDropdown>
|
</ProjectSettingsDropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="pageTitle"
|
||||||
|
class="project-title-wrapper"
|
||||||
|
>
|
||||||
|
<span class="project-title">{{ pageTitle }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
|
<TimerBadge />
|
||||||
<OpenQuickActions />
|
<OpenQuickActions />
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
|
|
@ -87,6 +95,12 @@
|
||||||
<DropdownItem :to="{ name: 'user.settings' }">
|
<DropdownItem :to="{ name: 'user.settings' }">
|
||||||
{{ $t('user.settings.title') }}
|
{{ $t('user.settings.title') }}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
v-if="adminPanelEnabled && authStore.info?.isAdmin"
|
||||||
|
:to="{ name: 'admin.overview' }"
|
||||||
|
>
|
||||||
|
{{ $t('admin.title') }}
|
||||||
|
</DropdownItem>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-if="imprintUrl"
|
v-if="imprintUrl"
|
||||||
:href="imprintUrl"
|
:href="imprintUrl"
|
||||||
|
|
@ -115,13 +129,17 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { PERMISSIONS as Permissions } from '@/constants/permissions'
|
import { PERMISSIONS as Permissions } from '@/constants/permissions'
|
||||||
|
import { PRO_FEATURE } from '@/constants/proFeatures'
|
||||||
|
|
||||||
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
||||||
import Dropdown from '@/components/misc/Dropdown.vue'
|
import Dropdown from '@/components/misc/Dropdown.vue'
|
||||||
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
||||||
import Notifications from '@/components/notifications/Notifications.vue'
|
import Notifications from '@/components/notifications/Notifications.vue'
|
||||||
|
import TimerBadge from '@/components/time-tracking/TimerBadge.vue'
|
||||||
import Logo from '@/components/home/Logo.vue'
|
import Logo from '@/components/home/Logo.vue'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import MenuButton from '@/components/home/MenuButton.vue'
|
import MenuButton from '@/components/home/MenuButton.vue'
|
||||||
|
|
@ -145,11 +163,20 @@ const background = computed(() => baseStore.background)
|
||||||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
|
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
|
||||||
const menuActive = computed(() => baseStore.menuActive)
|
const menuActive = computed(() => baseStore.menuActive)
|
||||||
|
|
||||||
|
// Standalone pages (no project) surface their route's title in the header.
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
const title = route.meta.title as string | undefined
|
||||||
|
return title ? t(title) : ''
|
||||||
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||||
|
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<div class="content-auth">
|
<div class="content-auth">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-show="menuActive"
|
v-show="menuActive"
|
||||||
|
:aria-label="$t('navigation.closeSidebar')"
|
||||||
class="menu-hide-button d-print-none"
|
class="menu-hide-button d-print-none"
|
||||||
@click="baseStore.setMenuActive(false)"
|
@click="baseStore.setMenuActive(false)"
|
||||||
>
|
>
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
/>
|
/>
|
||||||
<Navigation class="d-print-none" />
|
<Navigation class="d-print-none" />
|
||||||
<main
|
<main
|
||||||
|
id="main-content"
|
||||||
class="app-content"
|
class="app-content"
|
||||||
:class="[
|
:class="[
|
||||||
{ 'is-menu-enabled': menuActive },
|
{ 'is-menu-enabled': menuActive },
|
||||||
|
|
@ -31,6 +33,7 @@
|
||||||
>
|
>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-show="menuActive"
|
v-show="menuActive"
|
||||||
|
:aria-label="$t('navigation.closeSidebar')"
|
||||||
class="mobile-overlay d-print-none"
|
class="mobile-overlay d-print-none"
|
||||||
@click="baseStore.setMenuActive(false)"
|
@click="baseStore.setMenuActive(false)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -50,6 +53,7 @@
|
||||||
:enabled="typeof currentModal !== 'undefined'"
|
:enabled="typeof currentModal !== 'undefined'"
|
||||||
variant="scrolling"
|
variant="scrolling"
|
||||||
class="task-detail-view-modal"
|
class="task-detail-view-modal"
|
||||||
|
:aria-label="$t('task.detail.title')"
|
||||||
@close="closeModal()"
|
@close="closeModal()"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
|
||||||
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
:aria-label="$t('misc.closeBanner')"
|
||||||
class="hide-button"
|
class="hide-button"
|
||||||
@click="() => hide = true"
|
@click="() => hide = true"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useNow } from '@vueuse/core'
|
import { useNow } from '@vueuse/core'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { useColorScheme } from '@/composables/useColorScheme'
|
import { useColorScheme } from '@/composables/useColorScheme'
|
||||||
|
|
||||||
import LogoFull from '@/assets/logo-full.svg?component'
|
import LogoFull from '@/assets/logo-full.svg?component'
|
||||||
|
|
@ -13,9 +14,10 @@ const now = useNow({
|
||||||
})
|
})
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
const { isDark } = useColorScheme()
|
const { isDark } = useColorScheme()
|
||||||
|
|
||||||
const Logo = computed(() => window.ALLOW_ICON_CHANGES
|
const Logo = computed(() => configStore.allowIconChanges
|
||||||
&& authStore.settings.frontendSettings.allowIconChanges
|
&& authStore.settings.frontendSettings.allowIconChanges
|
||||||
&& now.value.getMonth() === 5
|
&& now.value.getMonth() === 5
|
||||||
? LogoFullPride
|
? LogoFullPride
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{name: 'home'}"
|
:to="{name: 'home'}"
|
||||||
class="logo"
|
class="logo"
|
||||||
:aria-label="$t('navigation.overview')"
|
:aria-label="$t('navigation.home')"
|
||||||
>
|
>
|
||||||
<Logo
|
<Logo
|
||||||
width="164"
|
width="164"
|
||||||
|
|
@ -71,6 +71,14 @@
|
||||||
{{ $t('team.title') }}
|
{{ $t('team.title') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="timeTrackingEnabled">
|
||||||
|
<RouterLink :to="{ name: 'time-tracking'}">
|
||||||
|
<span class="menu-item-icon icon">
|
||||||
|
<Icon :icon="['far', 'clock']" />
|
||||||
|
</span>
|
||||||
|
{{ $t('timeTracking.title') }}
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
</menu>
|
</menu>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -133,12 +141,17 @@ import Loading from '@/components/misc/Loading.vue'
|
||||||
|
|
||||||
import {useBaseStore} from '@/stores/base'
|
import {useBaseStore} from '@/stores/base'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
import {useConfigStore} from '@/stores/config'
|
||||||
|
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
import {useSidebarResize} from '@/composables/useSidebarResize'
|
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
|
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
|
||||||
|
|
||||||
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
type="color"
|
type="color"
|
||||||
:list="colorListID"
|
:list="colorListID"
|
||||||
:class="{'is-empty': isEmpty}"
|
:class="{'is-empty': isEmpty}"
|
||||||
|
:aria-label="$t('input.projectColor')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
v-show="isEmpty"
|
v-show="isEmpty"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@
|
||||||
:disabled="disabled || undefined"
|
:disabled="disabled || undefined"
|
||||||
@click.stop="toggleDatePopup"
|
@click.stop="toggleDatePopup"
|
||||||
>
|
>
|
||||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
<i v-if="date === null && emptyLabel !== ''">{{ emptyLabel }}</i>
|
||||||
|
<template v-else>
|
||||||
|
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||||
|
</template>
|
||||||
</SimpleButton>
|
</SimpleButton>
|
||||||
|
|
||||||
<CustomTransition name="fade">
|
<CustomTransition name="fade">
|
||||||
|
|
@ -16,6 +19,7 @@
|
||||||
>
|
>
|
||||||
<DatepickerInline
|
<DatepickerInline
|
||||||
v-model="date"
|
v-model="date"
|
||||||
|
:show-shortcuts="showShortcuts"
|
||||||
@update:modelValue="updateData"
|
@update:modelValue="updateData"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -48,12 +52,17 @@ const props = withDefaults(defineProps<{
|
||||||
modelValue: Date | null | string,
|
modelValue: Date | null | string,
|
||||||
chooseDateLabel?: string,
|
chooseDateLabel?: string,
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
|
showShortcuts?: boolean,
|
||||||
|
// When the value is null, show this (italic) instead of chooseDateLabel.
|
||||||
|
emptyLabel?: string,
|
||||||
}>(), {
|
}>(), {
|
||||||
chooseDateLabel: () => {
|
chooseDateLabel: () => {
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
return t('input.datepicker.chooseDate')
|
return t('input.datepicker.chooseDate')
|
||||||
},
|
},
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
showShortcuts: true,
|
||||||
|
emptyLabel: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,68 @@
|
||||||
<template>
|
<template>
|
||||||
<BaseButton
|
<template v-if="showShortcuts">
|
||||||
v-if="(new Date()).getHours() < 21"
|
<BaseButton
|
||||||
class="datepicker__quick-select-date"
|
v-if="(new Date()).getHours() < 21"
|
||||||
@click.stop="setDate('today')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('today')"
|
||||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||||
<span>{{ $t('input.datepicker.today') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
<span>{{ $t('input.datepicker.today') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
class="datepicker__quick-select-date"
|
<BaseButton
|
||||||
@click.stop="setDate('tomorrow')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('tomorrow')"
|
||||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
class="datepicker__quick-select-date"
|
<BaseButton
|
||||||
@click.stop="setDate('nextMonday')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('nextMonday')"
|
||||||
<span class="icon"><Icon icon="coffee" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon icon="coffee" /></span>
|
||||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
<BaseButton
|
||||||
class="datepicker__quick-select-date"
|
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||||
@click.stop="setDate('thisWeekend')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('thisWeekend')"
|
||||||
<span class="icon"><Icon icon="cocktail" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon icon="cocktail" /></span>
|
||||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
class="datepicker__quick-select-date"
|
<BaseButton
|
||||||
@click.stop="setDate('laterThisWeek')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('laterThisWeek')"
|
||||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
<BaseButton
|
</BaseButton>
|
||||||
class="datepicker__quick-select-date"
|
<BaseButton
|
||||||
@click.stop="setDate('nextWeek')"
|
class="datepicker__quick-select-date"
|
||||||
>
|
@click.stop="setDate('nextWeek')"
|
||||||
<span class="icon"><Icon icon="forward" /></span>
|
>
|
||||||
<span class="text">
|
<span class="icon"><Icon icon="forward" /></span>
|
||||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
<span class="text">
|
||||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||||
</span>
|
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||||
</BaseButton>
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flatpickr-container">
|
<div class="flatpickr-container">
|
||||||
<flat-pickr
|
<flat-pickr
|
||||||
|
|
@ -84,16 +86,22 @@ import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
|
||||||
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
import {createDateFromString} from '@/helpers/time/createDateFromString'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||||
|
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||||
|
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelValue: Date | null | string
|
modelValue: Date | null | string
|
||||||
}>()
|
showShortcuts?: boolean
|
||||||
|
}>(), {
|
||||||
|
showShortcuts: true,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [Date | null],
|
'update:modelValue': [Date | null],
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
const {store: timeFormat} = useTimeFormat()
|
||||||
|
|
||||||
const date = ref<Date | null>(null)
|
const date = ref<Date | null>(null)
|
||||||
const changed = ref(false)
|
const changed = ref(false)
|
||||||
|
|
@ -111,7 +119,7 @@ const flatPickerConfig = computed(() => ({
|
||||||
altInput: true,
|
altInput: true,
|
||||||
dateFormat: 'Y-m-d H:i',
|
dateFormat: 'Y-m-d H:i',
|
||||||
enableTime: true,
|
enableTime: true,
|
||||||
time_24hr: true,
|
time_24hr: timeFormat.value === TIME_FORMAT.HOURS_24,
|
||||||
inline: true,
|
inline: true,
|
||||||
locale: useFlatpickrLanguage().value,
|
locale: useFlatpickrLanguage().value,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
}"
|
}"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
@update:modelValue="value => emit('update:modelValue', value)"
|
@update:modelValue="value => emit('update:modelValue', value)"
|
||||||
>
|
>
|
||||||
<CheckboxIcon class="fancy-checkbox__icon" />
|
<CheckboxIcon class="fancy-checkbox__icon" />
|
||||||
|
|
@ -26,10 +27,12 @@ import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
modelValue: boolean,
|
modelValue: boolean,
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
isBlock?: boolean
|
isBlock?: boolean,
|
||||||
|
ariaLabel?: string,
|
||||||
}>(), {
|
}>(), {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
isBlock: false,
|
isBlock: false,
|
||||||
|
ariaLabel: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,33 @@ function handleChange(event: Event) {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
// Ported from bulma-css-variables/sass/form/checkbox-radio.sass
|
||||||
|
// (the %checkbox-radio placeholder, scoped to .checkbox since this
|
||||||
|
// component is the sole consumer of that class).
|
||||||
label.checkbox {
|
label.checkbox {
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.25;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
inline-size: fit-content;
|
inline-size: fit-content;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--input-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled],
|
||||||
|
input[disabled] {
|
||||||
|
color: var(--input-disabled-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
margin-block-end: .75rem;
|
margin-block-end: .75rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -233,8 +233,7 @@ describe('FormField', () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const label = wrapper.find('label.two-col')
|
const label = wrapper.find('label.two-col')
|
||||||
// for would point to a different id than the slotted control generates,
|
// for="" would mismatch the slotted control's id; rely on the label wrapping instead.
|
||||||
// so omit it entirely and rely on the label wrapping the control.
|
|
||||||
expect(label.attributes('for')).toBeUndefined()
|
expect(label.attributes('for')).toBeUndefined()
|
||||||
expect(label.find('input').exists()).toBe(true)
|
expect(label.find('input').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ const slots = useSlots()
|
||||||
const generatedId = useId()
|
const generatedId = useId()
|
||||||
|
|
||||||
const inputId = computed(() => props.id ?? generatedId)
|
const inputId = computed(() => props.id ?? generatedId)
|
||||||
|
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
|
||||||
const hasAddon = computed(() => !!slots.addon)
|
const hasAddon = computed(() => !!slots.addon)
|
||||||
|
|
||||||
const fieldClasses = computed(() => [
|
const fieldClasses = computed(() => [
|
||||||
|
|
@ -82,13 +83,18 @@ defineExpose({
|
||||||
class="two-col"
|
class="two-col"
|
||||||
>
|
>
|
||||||
<span>{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
<slot :id="inputId">
|
<slot
|
||||||
|
:id="inputId"
|
||||||
|
:error-id="errorId"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-bind="{ ...$attrs, ...inputBindings }"
|
v-bind="{ ...$attrs, ...inputBindings }"
|
||||||
:class="inputClasses"
|
:class="inputClasses"
|
||||||
:disabled="disabled || undefined"
|
:disabled="disabled || undefined"
|
||||||
|
:aria-invalid="error ? true : undefined"
|
||||||
|
:aria-describedby="errorId"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
>
|
>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
@ -109,13 +115,18 @@ defineExpose({
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</label>
|
</label>
|
||||||
<div :class="controlClasses">
|
<div :class="controlClasses">
|
||||||
<slot :id="inputId">
|
<slot
|
||||||
|
:id="inputId"
|
||||||
|
:error-id="errorId"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-bind="{ ...$attrs, ...inputBindings }"
|
v-bind="{ ...$attrs, ...inputBindings }"
|
||||||
:class="inputClasses"
|
:class="inputClasses"
|
||||||
:disabled="disabled || undefined"
|
:disabled="disabled || undefined"
|
||||||
|
:aria-invalid="error ? true : undefined"
|
||||||
|
:aria-describedby="errorId"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
>
|
>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
@ -129,7 +140,9 @@ defineExpose({
|
||||||
</template>
|
</template>
|
||||||
<p
|
<p
|
||||||
v-if="error"
|
v-if="error"
|
||||||
|
:id="errorId"
|
||||||
class="help is-danger"
|
class="help is-danger"
|
||||||
|
role="alert"
|
||||||
>
|
>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ defineOptions({inheritAttrs: false})
|
||||||
|
|
||||||
const fallbackId = useId()
|
const fallbackId = useId()
|
||||||
const inputId = computed(() => props.id ?? fallbackId)
|
const inputId = computed(() => props.id ?? fallbackId)
|
||||||
|
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
|
||||||
|
|
||||||
const inputClasses = computed(() => [
|
const inputClasses = computed(() => [
|
||||||
'input',
|
'input',
|
||||||
|
|
@ -67,11 +68,15 @@ defineExpose({
|
||||||
v-bind="{ ...$attrs, ...inputBindings }"
|
v-bind="{ ...$attrs, ...inputBindings }"
|
||||||
:class="inputClasses"
|
:class="inputClasses"
|
||||||
:disabled="disabled || undefined"
|
:disabled="disabled || undefined"
|
||||||
|
:aria-invalid="error ? true : undefined"
|
||||||
|
:aria-describedby="errorId"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
v-if="error"
|
v-if="error"
|
||||||
|
:id="errorId"
|
||||||
class="help is-danger"
|
class="help is-danger"
|
||||||
|
role="alert"
|
||||||
>
|
>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -155,9 +155,7 @@ describe('FormSelect', () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const select = wrapper.find('select')
|
const select = wrapper.find('select')
|
||||||
// Without an explicit value binding, the native select defaults to the
|
// Forcing :value="undefined" would break the native default-to-first-option behavior.
|
||||||
// first option. If the component forced :value="undefined" that default
|
|
||||||
// would be broken.
|
|
||||||
expect((select.element as HTMLSelectElement).value).toBe('')
|
expect((select.element as HTMLSelectElement).value).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ defineOptions({inheritAttrs: false})
|
||||||
|
|
||||||
const fallbackId = useId()
|
const fallbackId = useId()
|
||||||
const selectId = computed(() => props.id ?? fallbackId)
|
const selectId = computed(() => props.id ?? fallbackId)
|
||||||
|
const errorId = computed(() => props.error ? `${selectId.value}-error` : undefined)
|
||||||
|
|
||||||
const wrapperClasses = computed(() => [
|
const wrapperClasses = computed(() => [
|
||||||
'select',
|
'select',
|
||||||
|
|
@ -70,6 +71,8 @@ function handleChange(event: Event) {
|
||||||
:id="selectId"
|
:id="selectId"
|
||||||
v-bind="{ ...$attrs, ...selectBindings }"
|
v-bind="{ ...$attrs, ...selectBindings }"
|
||||||
:disabled="disabled || undefined"
|
:disabled="disabled || undefined"
|
||||||
|
:aria-invalid="error ? true : undefined"
|
||||||
|
:aria-describedby="errorId"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
>
|
>
|
||||||
<template v-if="normalizedOptions">
|
<template v-if="normalizedOptions">
|
||||||
|
|
@ -87,7 +90,9 @@ function handleChange(event: Event) {
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="error"
|
v-if="error"
|
||||||
|
:id="errorId"
|
||||||
class="help is-danger"
|
class="help is-danger"
|
||||||
|
role="alert"
|
||||||
>
|
>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@
|
||||||
:placeholder="$t('user.auth.passwordPlaceholder')"
|
:placeholder="$t('user.auth.passwordPlaceholder')"
|
||||||
required
|
required
|
||||||
:type="passwordFieldType"
|
:type="passwordFieldType"
|
||||||
autocomplete="current-password"
|
:autocomplete="autocomplete"
|
||||||
:tabindex="tabindex"
|
:tabindex="tabindex"
|
||||||
|
:aria-invalid="isValid !== true ? true : undefined"
|
||||||
|
:aria-describedby="errorId"
|
||||||
@keyup.enter="e => $emit('submit', e)"
|
@keyup.enter="e => $emit('submit', e)"
|
||||||
@focusout="() => {validate(); validateAfterFirst = true}"
|
@focusout="() => {validate(); validateAfterFirst = true}"
|
||||||
@keyup="() => {validateAfterFirst ? validate() : null}"
|
@keyup="() => {validateAfterFirst ? validate() : null}"
|
||||||
|
|
@ -25,14 +27,16 @@
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="isValid !== true"
|
v-if="isValid !== true"
|
||||||
|
:id="errorId"
|
||||||
class="help is-danger"
|
class="help is-danger"
|
||||||
|
role="alert"
|
||||||
>
|
>
|
||||||
{{ isValid }}
|
{{ isValid }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {ref, watchEffect} from 'vue'
|
import {computed, ref, watchEffect} from 'vue'
|
||||||
import {useDebounceFn} from '@vueuse/core'
|
import {useDebounceFn} from '@vueuse/core'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
@ -44,9 +48,11 @@ const props = withDefaults(defineProps<{
|
||||||
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
|
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
|
||||||
validateInitially?: boolean,
|
validateInitially?: boolean,
|
||||||
validateMinLength?: boolean,
|
validateMinLength?: boolean,
|
||||||
|
autocomplete?: string,
|
||||||
}>(), {
|
}>(), {
|
||||||
tabindex: undefined,
|
tabindex: undefined,
|
||||||
validateMinLength: true,
|
validateMinLength: true,
|
||||||
|
autocomplete: 'current-password',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -59,6 +65,7 @@ const password = ref('')
|
||||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||||
const isValid = ref<true | string>(props.validateInitially === true ? true : '')
|
const isValid = ref<true | string>(props.validateInitially === true ? true : '')
|
||||||
const validateAfterFirst = ref(false)
|
const validateAfterFirst = ref(false)
|
||||||
|
const errorId = computed(() => isValid.value !== true ? 'password-error' : undefined)
|
||||||
|
|
||||||
const validate = useDebounceFn(() => {
|
const validate = useDebounceFn(() => {
|
||||||
const valid = validatePassword(password.value, props.validateMinLength)
|
const valid = validatePassword(password.value, props.validateMinLength)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
<template>
|
||||||
|
<NodeViewWrapper
|
||||||
|
as="blockquote"
|
||||||
|
class="comment-quote"
|
||||||
|
:class="{'comment-quote--has-parent': hasParent}"
|
||||||
|
:data-comment-id="commentId === null ? null : String(commentId)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="commentId !== null && ctx"
|
||||||
|
contenteditable="false"
|
||||||
|
class="comment-quote__header"
|
||||||
|
>
|
||||||
|
<template v-if="parent">
|
||||||
|
<img
|
||||||
|
v-if="avatarUrl"
|
||||||
|
:src="avatarUrl"
|
||||||
|
alt=""
|
||||||
|
class="comment-quote__avatar"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
>
|
||||||
|
<span class="comment-quote__author">{{ authorName }}</span>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="t('task.comment.jumpToOriginal')"
|
||||||
|
class="comment-quote__jump"
|
||||||
|
:aria-label="t('task.comment.jumpToOriginal')"
|
||||||
|
@click="onJump"
|
||||||
|
>
|
||||||
|
<Icon icon="angle-right" />
|
||||||
|
</BaseButton>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="comment-quote__author comment-quote__author--missing"
|
||||||
|
>
|
||||||
|
{{ t('task.comment.deletedComment') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<NodeViewContent class="comment-quote__body" />
|
||||||
|
</NodeViewWrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {computed, inject, ref, watch} from 'vue'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
import {nodeViewProps, NodeViewWrapper, NodeViewContent} from '@tiptap/vue-3'
|
||||||
|
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
|
||||||
|
import {commentReplyContextKey} from '@/components/tasks/partials/commentReplyContext'
|
||||||
|
|
||||||
|
const props = defineProps(nodeViewProps)
|
||||||
|
|
||||||
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
|
const ctx = inject(commentReplyContextKey, null)
|
||||||
|
|
||||||
|
const commentId = computed<number | null>(() => {
|
||||||
|
const raw = props.node.attrs.commentId
|
||||||
|
if (raw === null || raw === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const id = Number(raw)
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const parent = computed(() => {
|
||||||
|
if (commentId.value === null || !ctx) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return ctx.findComment(commentId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasParent = computed(() => parent.value !== undefined)
|
||||||
|
|
||||||
|
const authorName = computed(() => {
|
||||||
|
const p = parent.value
|
||||||
|
return p ? getDisplayName(p.author) : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const avatarUrl = ref('')
|
||||||
|
|
||||||
|
// Bumped on every parent change so stale avatar fetches (older parent)
|
||||||
|
// don't overwrite a newer one if the user navigates between comments
|
||||||
|
// while fetches are still in flight.
|
||||||
|
let avatarFetchToken = 0
|
||||||
|
|
||||||
|
watch(parent, (p) => {
|
||||||
|
avatarUrl.value = ''
|
||||||
|
const token = ++avatarFetchToken
|
||||||
|
if (!p?.author) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetchAvatarBlobUrl(p.author, 20)
|
||||||
|
.then((url) => {
|
||||||
|
if (token === avatarFetchToken) {
|
||||||
|
avatarUrl.value = (url as string) ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Swallow — a missing avatar isn't worth a user-visible error;
|
||||||
|
// the header still renders with the author name.
|
||||||
|
})
|
||||||
|
}, {immediate: true})
|
||||||
|
|
||||||
|
function onJump() {
|
||||||
|
if (commentId.value !== null && ctx) {
|
||||||
|
ctx.scrollToComment(commentId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.tiptap blockquote.comment-quote {
|
||||||
|
margin-block: .5rem;
|
||||||
|
|
||||||
|
.comment-quote__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding-block-end: .25rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
color: var(--grey-600);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-quote__avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-quote__author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--grey-700);
|
||||||
|
|
||||||
|
&--missing {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--grey-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-quote__jump {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--grey-500);
|
||||||
|
padding: .15rem .25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: background-color $transition, color $transition;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--grey-800);
|
||||||
|
background: var(--grey-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-quote__body > :first-child {
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -166,6 +166,7 @@ import Mention from '@tiptap/extension-mention'
|
||||||
|
|
||||||
import {TaskList} from '@tiptap/extension-list'
|
import {TaskList} from '@tiptap/extension-list'
|
||||||
import {TaskItemWithId} from './taskItemWithId'
|
import {TaskItemWithId} from './taskItemWithId'
|
||||||
|
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
|
||||||
import HardBreak from '@tiptap/extension-hard-break'
|
import HardBreak from '@tiptap/extension-hard-break'
|
||||||
|
|
||||||
import Commands from './commands'
|
import Commands from './commands'
|
||||||
|
|
@ -417,7 +418,9 @@ const extensions : Extensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
hardBreak: false,
|
hardBreak: false,
|
||||||
|
blockquote: false,
|
||||||
}),
|
}),
|
||||||
|
BlockquoteWithCommentId,
|
||||||
|
|
||||||
CodeBlockLowlight.configure({
|
CodeBlockLowlight.configure({
|
||||||
lowlight: createLowlight(common),
|
lowlight: createLowlight(common),
|
||||||
|
|
@ -719,7 +722,7 @@ async function addImage(event: Event) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await inputPrompt(event.target.getBoundingClientRect())
|
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
editor.value?.chain().focus().setImage({src: url}).run()
|
editor.value?.chain().focus().setImage({src: url}).run()
|
||||||
|
|
@ -775,6 +778,24 @@ function setModeAndValue(value: string) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace the editor content with a reply draft (prefilled blockquote + empty
|
||||||
|
// paragraph) and enter edit mode immediately so the user can start typing.
|
||||||
|
// Returns synchronously after the next tick to let DOM updates settle.
|
||||||
|
async function setReplyContent(value: string) {
|
||||||
|
if (!editor.value) return
|
||||||
|
editor.value.commands.setContent(value, {
|
||||||
|
...defaultSetContentOptions,
|
||||||
|
emitUpdate: false,
|
||||||
|
})
|
||||||
|
internalMode.value = 'edit'
|
||||||
|
modelValue.value = editor.value.getHTML()
|
||||||
|
contentHasChanged.value = true
|
||||||
|
await nextTick()
|
||||||
|
editor.value.commands.focus('end')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({setReplyContent})
|
||||||
|
|
||||||
|
|
||||||
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
// See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660
|
||||||
function setFocusToEditor(event: KeyboardEvent) {
|
function setFocusToEditor(event: KeyboardEvent) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import {describe, it, expect} from 'vitest'
|
||||||
|
import {Editor} from '@tiptap/core'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import {BlockquoteWithCommentId} from './blockquoteWithCommentId'
|
||||||
|
|
||||||
|
describe('BlockquoteWithCommentId extension', () => {
|
||||||
|
const createEditor = (content: string = '') => {
|
||||||
|
return new Editor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({blockquote: false}),
|
||||||
|
BlockquoteWithCommentId,
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preserves data-comment-id through setContent → getHTML round-trip', () => {
|
||||||
|
const editor = createEditor('<blockquote data-comment-id="42"><p>hi</p></blockquote>')
|
||||||
|
|
||||||
|
const html = editor.getHTML()
|
||||||
|
expect(html).toContain('data-comment-id="42"')
|
||||||
|
|
||||||
|
editor.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a plain blockquote (no attribute) unchanged', () => {
|
||||||
|
const editor = createEditor('<blockquote><p>just a quote</p></blockquote>')
|
||||||
|
|
||||||
|
const html = editor.getHTML()
|
||||||
|
expect(html).toContain('<blockquote>')
|
||||||
|
expect(html).not.toContain('data-comment-id')
|
||||||
|
|
||||||
|
editor.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves nested rich content inside the blockquote', () => {
|
||||||
|
const editor = createEditor(
|
||||||
|
'<blockquote data-comment-id="7"><p>this is <strong>bold</strong> text</p></blockquote>',
|
||||||
|
)
|
||||||
|
|
||||||
|
const html = editor.getHTML()
|
||||||
|
expect(html).toContain('data-comment-id="7"')
|
||||||
|
expect(html).toContain('<strong>bold</strong>')
|
||||||
|
|
||||||
|
editor.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a malformed data-comment-id (non-integer)', () => {
|
||||||
|
const editor = createEditor('<blockquote data-comment-id="abc"><p>x</p></blockquote>')
|
||||||
|
|
||||||
|
const html = editor.getHTML()
|
||||||
|
expect(html).not.toContain('data-comment-id')
|
||||||
|
|
||||||
|
editor.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a non-positive data-comment-id', () => {
|
||||||
|
const editor = createEditor('<blockquote data-comment-id="0"><p>x</p></blockquote>')
|
||||||
|
|
||||||
|
const html = editor.getHTML()
|
||||||
|
expect(html).not.toContain('data-comment-id')
|
||||||
|
|
||||||
|
editor.destroy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import Blockquote from '@tiptap/extension-blockquote'
|
||||||
|
import {VueNodeViewRenderer} from '@tiptap/vue-3'
|
||||||
|
|
||||||
|
import BlockquoteCommentView from './BlockquoteCommentView.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blockquote extension that preserves `data-comment-id` across parse/serialize.
|
||||||
|
* Used as the canonical reply marker: a comment that quotes another comment
|
||||||
|
* stores the referenced comment's id on the wrapping blockquote, so both the
|
||||||
|
* backend (for implicit-mention notifications) and the frontend (for the
|
||||||
|
* jump-to-original chevron) can find it without a separate schema field.
|
||||||
|
*
|
||||||
|
* A Vue NodeView renders the in-app header + chevron when the surrounding
|
||||||
|
* component (Comments.vue) provides a `commentReplyContext`. Outside that
|
||||||
|
* context (task descriptions, etc.) the NodeView falls back to a plain
|
||||||
|
* blockquote.
|
||||||
|
*/
|
||||||
|
export const BlockquoteWithCommentId = Blockquote.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
commentId: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element: HTMLElement) => {
|
||||||
|
const raw = element.getAttribute('data-comment-id')
|
||||||
|
if (raw === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const id = Number(raw)
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
},
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (attributes.commentId === null || attributes.commentId === undefined) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'data-comment-id': String(attributes.commentId),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return VueNodeViewRenderer(BlockquoteCommentView)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -5,6 +5,7 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
|
||||||
|
|
||||||
import EmojiList from './EmojiList.vue'
|
import EmojiList from './EmojiList.vue'
|
||||||
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
|
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
|
||||||
|
import {getPopupContainer} from '../popupContainer'
|
||||||
|
|
||||||
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
|
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
|
||||||
|
|
||||||
|
|
@ -78,7 +79,7 @@ export default function emojiSuggestionSetup() {
|
||||||
popupElement.style.left = '0'
|
popupElement.style.left = '0'
|
||||||
popupElement.style.zIndex = '4700'
|
popupElement.style.zIndex = '4700'
|
||||||
popupElement.appendChild(component.element!)
|
popupElement.appendChild(component.element!)
|
||||||
document.body.appendChild(popupElement)
|
getPopupContainer(props.editor).appendChild(popupElement)
|
||||||
|
|
||||||
const rect = props.clientRect()
|
const rect = props.clientRect()
|
||||||
if (!rect) {
|
if (!rect) {
|
||||||
|
|
@ -108,7 +109,7 @@ export default function emojiSuggestionSetup() {
|
||||||
cleanupFloating = null
|
cleanupFloating = null
|
||||||
}
|
}
|
||||||
if (popupElement) {
|
if (popupElement) {
|
||||||
document.body.removeChild(popupElement)
|
popupElement.remove()
|
||||||
popupElement = null
|
popupElement = null
|
||||||
}
|
}
|
||||||
component?.destroy()
|
component?.destroy()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
|
||||||
|
|
||||||
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
|
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
|
||||||
const previousUrl = editor?.getAttributes('link').href || ''
|
const previousUrl = editor?.getAttributes('link').href || ''
|
||||||
const url = await inputPrompt(pos, previousUrl)
|
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
|
||||||
|
|
||||||
// empty
|
// empty
|
||||||
if (url === '') {
|
if (url === '') {
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ defineExpose({
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0.375rem 0.5rem;
|
padding: 0.375rem 0.5rem;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<Modal
|
<Modal
|
||||||
:overflow="true"
|
:overflow="true"
|
||||||
:wide="wide"
|
:wide="wide"
|
||||||
|
:aria-label="title"
|
||||||
@close="$router.back()"
|
@close="$router.back()"
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
>
|
>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
class="dropdown-trigger is-flex"
|
class="dropdown-trigger is-flex"
|
||||||
|
:aria-label="triggerLabel"
|
||||||
@click="toggleOpen"
|
@click="toggleOpen"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
|
|
@ -49,8 +50,10 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
triggerIcon?: IconProp
|
triggerIcon?: IconProp
|
||||||
|
triggerLabel?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
triggerIcon: 'ellipsis-h',
|
triggerIcon: 'ellipsis-h',
|
||||||
|
triggerLabel: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {library} from '@fortawesome/fontawesome-svg-core'
|
import {library} from '@fortawesome/fontawesome-svg-core'
|
||||||
import {
|
import {
|
||||||
faAlignLeft,
|
faAlignLeft,
|
||||||
|
faAngleLeft,
|
||||||
faAngleRight,
|
faAngleRight,
|
||||||
faAnglesUp,
|
faAnglesUp,
|
||||||
faArchive,
|
faArchive,
|
||||||
|
|
@ -58,6 +59,7 @@ import {
|
||||||
faPlay,
|
faPlay,
|
||||||
faPlus,
|
faPlus,
|
||||||
faPowerOff,
|
faPowerOff,
|
||||||
|
faRss,
|
||||||
faSearch,
|
faSearch,
|
||||||
faShareAlt,
|
faShareAlt,
|
||||||
faSignOutAlt,
|
faSignOutAlt,
|
||||||
|
|
@ -73,6 +75,7 @@ import {
|
||||||
faTimes,
|
faTimes,
|
||||||
faTrashAlt,
|
faTrashAlt,
|
||||||
faUser,
|
faUser,
|
||||||
|
faUserEdit,
|
||||||
faUsers,
|
faUsers,
|
||||||
faQuoteRight,
|
faQuoteRight,
|
||||||
faListUl,
|
faListUl,
|
||||||
|
|
@ -119,6 +122,7 @@ library.add(faCode)
|
||||||
library.add(faQuoteRight)
|
library.add(faQuoteRight)
|
||||||
library.add(faListUl)
|
library.add(faListUl)
|
||||||
library.add(faAlignLeft)
|
library.add(faAlignLeft)
|
||||||
|
library.add(faAngleLeft)
|
||||||
library.add(faAngleRight)
|
library.add(faAngleRight)
|
||||||
library.add(faArchive)
|
library.add(faArchive)
|
||||||
library.add(faArrowLeft)
|
library.add(faArrowLeft)
|
||||||
|
|
@ -167,6 +171,7 @@ library.add(faPercent)
|
||||||
library.add(faPlay)
|
library.add(faPlay)
|
||||||
library.add(faPlus)
|
library.add(faPlus)
|
||||||
library.add(faPowerOff)
|
library.add(faPowerOff)
|
||||||
|
library.add(faRss)
|
||||||
library.add(faSave)
|
library.add(faSave)
|
||||||
library.add(faSearch)
|
library.add(faSearch)
|
||||||
library.add(faShareAlt)
|
library.add(faShareAlt)
|
||||||
|
|
@ -186,6 +191,7 @@ library.add(faTimes)
|
||||||
library.add(faTimesCircle)
|
library.add(faTimesCircle)
|
||||||
library.add(faTrashAlt)
|
library.add(faTrashAlt)
|
||||||
library.add(faUser)
|
library.add(faUser)
|
||||||
|
library.add(faUserEdit)
|
||||||
library.add(faUsers)
|
library.add(faUsers)
|
||||||
library.add(faArrowDownShortWide)
|
library.add(faArrowDownShortWide)
|
||||||
library.add(faArrowUpFromBracket)
|
library.add(faArrowUpFromBracket)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@ import {mount, flushPromises} from '@vue/test-utils'
|
||||||
import {nextTick} from 'vue'
|
import {nextTick} from 'vue'
|
||||||
import Modal from './Modal.vue'
|
import Modal from './Modal.vue'
|
||||||
|
|
||||||
|
const globalMocks = {
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (key: string) => key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// jsdom does not implement HTMLDialogElement.showModal/close.
|
// jsdom does not implement HTMLDialogElement.showModal/close.
|
||||||
// Provide stubs so that the [open] attribute — which CSS and our tests
|
// Provide stubs so that the [open] attribute — which CSS and our tests
|
||||||
// check — is flipped the same way the real browser would.
|
// check — is flipped the same way the real browser would.
|
||||||
|
|
@ -50,6 +58,7 @@ afterEach(() => {
|
||||||
describe('Modal.vue — open race condition (#2590)', () => {
|
describe('Modal.vue — open race condition (#2590)', () => {
|
||||||
it('opens the dialog when enabled flips false → true', async () => {
|
it('opens the dialog when enabled flips false → true', async () => {
|
||||||
const wrapper = mount(Modal, {
|
const wrapper = mount(Modal, {
|
||||||
|
...globalMocks,
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
props: {enabled: false},
|
props: {enabled: false},
|
||||||
slots: {default: '<p class="test-body">hi</p>'},
|
slots: {default: '<p class="test-body">hi</p>'},
|
||||||
|
|
@ -84,6 +93,7 @@ describe('Modal.vue — open race condition (#2590)', () => {
|
||||||
// resolves after the first state change, the dialog must already have
|
// resolves after the first state change, the dialog must already have
|
||||||
// [open] set — no additional flushPromises or extra ticks required.
|
// [open] set — no additional flushPromises or extra ticks required.
|
||||||
const wrapper = mount(Modal, {
|
const wrapper = mount(Modal, {
|
||||||
|
...globalMocks,
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
props: {enabled: false},
|
props: {enabled: false},
|
||||||
slots: {default: '<p class="test-body">hi</p>'},
|
slots: {default: '<p class="test-body">hi</p>'},
|
||||||
|
|
@ -111,6 +121,7 @@ describe('Modal.vue — open race condition (#2590)', () => {
|
||||||
// nextTick callback whose timing could fire before the dialog mounted,
|
// nextTick callback whose timing could fire before the dialog mounted,
|
||||||
// skipping the showModal() call entirely and leaving .open === false.
|
// skipping the showModal() call entirely and leaving .open === false.
|
||||||
const wrapper = mount(Modal, {
|
const wrapper = mount(Modal, {
|
||||||
|
...globalMocks,
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
props: {enabled: true},
|
props: {enabled: true},
|
||||||
slots: {default: '<p class="test-body">hi</p>'},
|
slots: {default: '<p class="test-body">hi</p>'},
|
||||||
|
|
@ -132,6 +143,7 @@ describe('Modal.vue — open race condition (#2590)', () => {
|
||||||
|
|
||||||
it('closes the dialog when enabled flips true → false', async () => {
|
it('closes the dialog when enabled flips true → false', async () => {
|
||||||
const wrapper = mount(Modal, {
|
const wrapper = mount(Modal, {
|
||||||
|
...globalMocks,
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
props: {enabled: true},
|
props: {enabled: true},
|
||||||
slots: {default: '<p class="test-body">hi</p>'},
|
slots: {default: '<p class="test-body">hi</p>'},
|
||||||
|
|
@ -159,6 +171,7 @@ describe('Modal.vue — open race condition (#2590)', () => {
|
||||||
// element mounts. If props.enabled has flipped back to false by the
|
// element mounts. If props.enabled has flipped back to false by the
|
||||||
// time the mount happens, the watcher must not call showModal().
|
// time the mount happens, the watcher must not call showModal().
|
||||||
const wrapper = mount(Modal, {
|
const wrapper = mount(Modal, {
|
||||||
|
...globalMocks,
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
props: {enabled: false},
|
props: {enabled: false},
|
||||||
slots: {default: '<p class="test-body">hi</p>'},
|
slots: {default: '<p class="test-body">hi</p>'},
|
||||||
|
|
@ -189,6 +202,7 @@ describe('Modal.vue — open race condition (#2590)', () => {
|
||||||
// sure openDialog() clears the leftover data-closing flag itself;
|
// sure openDialog() clears the leftover data-closing flag itself;
|
||||||
// otherwise the dialog stays stuck at opacity 0.
|
// otherwise the dialog stays stuck at opacity 0.
|
||||||
const wrapper = mount(Modal, {
|
const wrapper = mount(Modal, {
|
||||||
|
...globalMocks,
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
props: {enabled: true},
|
props: {enabled: true},
|
||||||
slots: {default: '<p class="test-body">hi</p>'},
|
slots: {default: '<p class="test-body">hi</p>'},
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
@mousedown.self.prevent.stop="$emit('close')"
|
@mousedown.self.prevent.stop="$emit('close')"
|
||||||
>
|
>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
class="close"
|
:aria-label="$t('misc.closeDialog')"
|
||||||
|
class="close d-print-none"
|
||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
<Icon icon="times" />
|
<Icon icon="times" />
|
||||||
|
|
@ -61,13 +62,13 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import {ref, useAttrs, watch, onBeforeUnmount} from 'vue'
|
import {ref, useAttrs, watch, onBeforeUnmount, onMounted} from 'vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
enabled?: boolean,
|
enabled?: boolean,
|
||||||
overflow?: boolean,
|
overflow?: boolean,
|
||||||
wide?: boolean,
|
wide?: boolean,
|
||||||
variant?: 'default' | 'hint-modal' | 'scrolling',
|
variant?: 'default' | 'hint-modal' | 'scrolling' | 'top',
|
||||||
}>(), {
|
}>(), {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
overflow: false,
|
overflow: false,
|
||||||
|
|
@ -157,6 +158,37 @@ watch(dialogRef, (dialog) => {
|
||||||
dialog.showModal()
|
dialog.showModal()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A <dialog> opened with showModal() lives in the browser's top layer, which
|
||||||
|
// renders only the first page during print (top-layer elements are
|
||||||
|
// viewport-anchored and don't paginate). Temporarily swap to a non-modal
|
||||||
|
// dialog for the duration of the print so the content flows in normal
|
||||||
|
// document order and can break across pages.
|
||||||
|
let wasModalBeforePrint = false
|
||||||
|
|
||||||
|
function handleBeforePrint() {
|
||||||
|
const dialog = dialogRef.value
|
||||||
|
if (dialog && dialog.matches(':modal')) {
|
||||||
|
wasModalBeforePrint = true
|
||||||
|
dialog.close()
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAfterPrint() {
|
||||||
|
if (!wasModalBeforePrint) return
|
||||||
|
wasModalBeforePrint = false
|
||||||
|
const dialog = dialogRef.value
|
||||||
|
if (dialog && dialog.open) {
|
||||||
|
dialog.close()
|
||||||
|
dialog.showModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('beforeprint', handleBeforePrint)
|
||||||
|
window.addEventListener('afterprint', handleAfterPrint)
|
||||||
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (closeTimer) {
|
if (closeTimer) {
|
||||||
clearTimeout(closeTimer)
|
clearTimeout(closeTimer)
|
||||||
|
|
@ -166,6 +198,8 @@ onBeforeUnmount(() => {
|
||||||
if (previouslyFocused.value instanceof HTMLElement) {
|
if (previouslyFocused.value instanceof HTMLElement) {
|
||||||
previouslyFocused.value.focus()
|
previouslyFocused.value.focus()
|
||||||
}
|
}
|
||||||
|
window.removeEventListener('beforeprint', handleBeforePrint)
|
||||||
|
window.removeEventListener('afterprint', handleAfterPrint)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -177,7 +211,13 @@ $modal-width: 1024px;
|
||||||
// Reset UA dialog styles
|
// Reset UA dialog styles
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
// The scrim lives on the dialog element, not on ::backdrop: Chromium
|
||||||
|
// intermittently stops painting a styled ::backdrop (e.g. after the
|
||||||
|
// dialog's subtree re-renders, or while display is transitioned) even
|
||||||
|
// though getComputedStyle still reports the color. The dialog fills the
|
||||||
|
// viewport anyway, and its opacity transition fades the scrim with it —
|
||||||
|
// same as the old div-based .modal-mask.
|
||||||
|
background: rgba(0, 0, 0, .8);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
// Fill viewport
|
// Fill viewport
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -187,10 +227,12 @@ $modal-width: 1024px;
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
max-block-size: 100%;
|
max-block-size: 100%;
|
||||||
|
|
||||||
// Transitions
|
// Transitions. No display/allow-discrete transition needed: the close
|
||||||
|
// fade runs while the dialog is still [open] (data-closing + timer in
|
||||||
|
// closeDialog), and transitioning display triggers the Chromium paint
|
||||||
|
// bug above.
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 150ms ease,
|
transition: opacity 150ms ease;
|
||||||
display 150ms ease allow-discrete;
|
|
||||||
|
|
||||||
&[open]:not([data-closing]) {
|
&[open]:not([data-closing]) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
@ -202,16 +244,11 @@ $modal-width: 1024px;
|
||||||
|
|
||||||
&::backdrop {
|
&::backdrop {
|
||||||
background-color: rgba(0, 0, 0, 0);
|
background-color: rgba(0, 0, 0, 0);
|
||||||
transition: background-color 150ms ease,
|
|
||||||
display 150ms ease allow-discrete;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&[open]:not([data-closing])::backdrop {
|
// in quick-add mode the Electron window itself is the overlay — no scrim
|
||||||
background-color: rgba(0, 0, 0, .8);
|
&:has(.is-quick-add-mode) {
|
||||||
|
background: transparent;
|
||||||
@starting-style {
|
|
||||||
background-color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,13 +264,20 @@ $modal-width: 1024px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.default .modal-content,
|
.default .modal-content,
|
||||||
.hint-modal .modal-content {
|
.hint-modal .modal-content,
|
||||||
|
.top .modal-content {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
// fine to use top/left since we're only using this to position it centered
|
// fine to use top/left since we're only using this to position it centered
|
||||||
inset-block-start: 50%;
|
inset-block-start: 50%;
|
||||||
inset-inline-start: 50%;
|
inset-inline-start: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
// Cap centered content to the viewport and scroll inside it. Without this a
|
||||||
|
// taller-than-viewport modal centres its top edge above the viewport, where
|
||||||
|
// the container's overflow can't scroll to it (the .top variant overrides
|
||||||
|
// both values below).
|
||||||
|
max-block-size: calc(100dvh - 2rem);
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
[dir="rtl"] & {
|
[dir="rtl"] & {
|
||||||
transform: translate(50%, -50%);
|
transform: translate(50%, -50%);
|
||||||
|
|
@ -243,6 +287,9 @@ $modal-width: 1024px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: static;
|
position: static;
|
||||||
transform: none;
|
transform: none;
|
||||||
|
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||||
|
max-block-size: none;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
|
|
@ -255,6 +302,40 @@ $modal-width: 1024px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anchored below the top edge instead of centered, used for QuickActions
|
||||||
|
.top .modal-content {
|
||||||
|
inset-block-start: 3rem;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
max-block-size: calc(100dvh - 6rem);
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
[dir="rtl"] & {
|
||||||
|
transform: translate(50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the fullscreen mobile layout flows and scrolls in .modal-container
|
||||||
|
@media screen and (max-width: $tablet) {
|
||||||
|
transform: none;
|
||||||
|
max-block-size: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default width for centered modals. Scoped with :not(.is-wide) so the
|
||||||
|
// `wide` prop can still expand the modal (the .is-wide rule below would
|
||||||
|
// otherwise be outranked by .default .modal-content's specificity).
|
||||||
|
.default .modal-content:not(.is-wide),
|
||||||
|
.hint-modal .modal-content:not(.is-wide),
|
||||||
|
.top .modal-content:not(.is-wide) {
|
||||||
|
inline-size: calc(100% - 2rem);
|
||||||
|
max-inline-size: 640px;
|
||||||
|
|
||||||
|
@media screen and (max-width: $tablet) {
|
||||||
|
inline-size: 100%;
|
||||||
|
max-inline-size: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// scrolling-content
|
// scrolling-content
|
||||||
// used e.g. for <TaskDetailViewModal>
|
// used e.g. for <TaskDetailViewModal>
|
||||||
.scrolling .modal-content {
|
.scrolling .modal-content {
|
||||||
|
|
@ -346,6 +427,32 @@ $modal-width: 1024px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unconstrain the native <dialog> so the full modal content flows onto the
|
||||||
|
// printed page instead of being clipped to the viewport-sized top layer.
|
||||||
|
@media print {
|
||||||
|
.modal-dialog {
|
||||||
|
position: static;
|
||||||
|
inline-size: auto;
|
||||||
|
block-size: auto;
|
||||||
|
max-inline-size: none;
|
||||||
|
max-block-size: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&::backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
overflow: visible;
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.card) {
|
||||||
|
min-block-size: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content:has(.modal-header) {
|
.modal-content:has(.modal-header) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@
|
||||||
{{ $t("misc.welcomeBack") }}
|
{{ $t("misc.welcomeBack") }}
|
||||||
</h2>
|
</h2>
|
||||||
</section>
|
</section>
|
||||||
<section class="content">
|
<main
|
||||||
|
id="main-content"
|
||||||
|
class="content"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2
|
<h2
|
||||||
v-if="title"
|
v-if="title"
|
||||||
|
|
@ -35,7 +38,7 @@
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<Legal />
|
<Legal />
|
||||||
</section>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,96 @@
|
||||||
<template>
|
<template>
|
||||||
<Notifications
|
<Teleport :to="teleportTarget">
|
||||||
position="bottom left"
|
<Notifications
|
||||||
:max="2"
|
position="bottom left"
|
||||||
:ignore-duplicates="true"
|
:max="2"
|
||||||
class="global-notification"
|
:ignore-duplicates="true"
|
||||||
>
|
class="global-notification"
|
||||||
<template #body="{ item, close }">
|
role="status"
|
||||||
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
|
aria-live="polite"
|
||||||
<div
|
>
|
||||||
class="vue-notification-template vue-notification"
|
<template #body="{ item, close }">
|
||||||
:class="[
|
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
|
||||||
item.type,
|
|
||||||
]"
|
|
||||||
@click="close()"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="item.title"
|
class="vue-notification-template vue-notification"
|
||||||
class="notification-title"
|
:class="[
|
||||||
|
item.type,
|
||||||
|
]"
|
||||||
|
@click="close()"
|
||||||
>
|
>
|
||||||
{{ item.title }}
|
<div
|
||||||
</div>
|
v-if="item.title"
|
||||||
<div class="notification-content">
|
class="notification-title"
|
||||||
<template v-if="Array.isArray(item.text)">
|
>
|
||||||
<template
|
{{ item.title }}
|
||||||
v-for="(t, k) in item.text"
|
</div>
|
||||||
:key="k"
|
<div class="notification-content">
|
||||||
>
|
<template v-if="Array.isArray(item.text)">
|
||||||
{{ t }}<br>
|
<template
|
||||||
|
v-for="(t, k) in item.text"
|
||||||
|
:key="k"
|
||||||
|
>
|
||||||
|
{{ t }}<br>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
<template v-else>
|
||||||
<template v-else>
|
{{ item.text }}
|
||||||
{{ item.text }}
|
</template>
|
||||||
</template>
|
<span
|
||||||
<span
|
v-if="item.duplicates > 0"
|
||||||
v-if="item.duplicates > 0"
|
class="tw:text-xs tw:font-bold tw:ml-1"
|
||||||
class="tw:text-xs tw:font-bold tw:ml-1"
|
>
|
||||||
|
×{{ item.duplicates + 1 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="item.data?.actions?.length > 0"
|
||||||
|
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
|
||||||
>
|
>
|
||||||
×{{ item.duplicates + 1 }}
|
<XButton
|
||||||
</span>
|
v-for="(action, i) in item.data.actions"
|
||||||
|
:key="'action_' + i"
|
||||||
|
:shadow="false"
|
||||||
|
class="is-small"
|
||||||
|
variant="secondary"
|
||||||
|
@click="action.callback"
|
||||||
|
>
|
||||||
|
{{ action.title }}
|
||||||
|
</XButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</template>
|
||||||
v-if="item.data?.actions?.length > 0"
|
</Notifications>
|
||||||
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
|
</Teleport>
|
||||||
>
|
|
||||||
<XButton
|
|
||||||
v-for="(action, i) in item.data.actions"
|
|
||||||
:key="'action_' + i"
|
|
||||||
:shadow="false"
|
|
||||||
class="is-small"
|
|
||||||
variant="secondary"
|
|
||||||
@click="action.callback"
|
|
||||||
>
|
|
||||||
{{ action.title }}
|
|
||||||
</XButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Notifications>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {onBeforeUnmount, onMounted, ref} from 'vue'
|
||||||
|
|
||||||
|
const teleportTarget = ref<string | HTMLElement>('body')
|
||||||
|
let observer: MutationObserver | null = null
|
||||||
|
|
||||||
|
function syncTeleportTarget() {
|
||||||
|
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
|
||||||
|
teleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncTeleportTarget()
|
||||||
|
observer = new MutationObserver(syncTeleportTarget)
|
||||||
|
observer.observe(document.body, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['open'],
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
observer = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.vue-notification {
|
.vue-notification {
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
|
|
|
||||||
|
|
@ -4,38 +4,39 @@
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
>
|
>
|
||||||
<template #previous="{ disabled }">
|
<template #previous="{ disabled }">
|
||||||
<RouterLink
|
<PaginationItem
|
||||||
:disabled="disabled || undefined"
|
variant="previous"
|
||||||
:to="getRouteForPagination(currentPage - 1)"
|
:to="getRouteForPagination(currentPage - 1)"
|
||||||
class="pagination-previous"
|
:disabled="disabled"
|
||||||
>
|
>
|
||||||
{{ $t('misc.previous') }}
|
{{ $t('misc.previous') }}
|
||||||
</RouterLink>
|
</PaginationItem>
|
||||||
</template>
|
</template>
|
||||||
<template #next="{ disabled }">
|
<template #next="{ disabled }">
|
||||||
<RouterLink
|
<PaginationItem
|
||||||
:disabled="disabled || undefined"
|
variant="next"
|
||||||
:to="getRouteForPagination(currentPage + 1)"
|
:to="getRouteForPagination(currentPage + 1)"
|
||||||
class="pagination-next"
|
:disabled="disabled"
|
||||||
>
|
>
|
||||||
{{ $t('misc.next') }}
|
{{ $t('misc.next') }}
|
||||||
</RouterLink>
|
</PaginationItem>
|
||||||
</template>
|
</template>
|
||||||
<template #page-link="{ page, isCurrent }">
|
<template #page-link="{ page, isCurrent }">
|
||||||
<RouterLink
|
<PaginationItem
|
||||||
class="pagination-link"
|
variant="link"
|
||||||
:aria-label="'Goto page ' + page.number"
|
|
||||||
:class="{ 'is-current': isCurrent }"
|
|
||||||
:to="getRouteForPagination(page.number)"
|
:to="getRouteForPagination(page.number)"
|
||||||
|
:is-current="isCurrent"
|
||||||
|
:aria-label="'Goto page ' + page.number"
|
||||||
>
|
>
|
||||||
{{ page.number }}
|
{{ page.number }}
|
||||||
</RouterLink>
|
</PaginationItem>
|
||||||
</template>
|
</template>
|
||||||
</BasePagination>
|
</BasePagination>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import BasePagination from '@/components/base/BasePagination.vue'
|
import BasePagination from '@/components/base/BasePagination.vue'
|
||||||
|
import PaginationItem from '@/components/misc/PaginationItem.vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
|
|
|
||||||
|
|
@ -4,39 +4,39 @@
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
>
|
>
|
||||||
<template #previous="{ disabled }">
|
<template #previous="{ disabled }">
|
||||||
<BaseButton
|
<PaginationItem
|
||||||
|
variant="previous"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
class="pagination-previous"
|
|
||||||
@click="changePage(currentPage - 1)"
|
@click="changePage(currentPage - 1)"
|
||||||
>
|
>
|
||||||
{{ $t('misc.previous') }}
|
{{ $t('misc.previous') }}
|
||||||
</BaseButton>
|
</PaginationItem>
|
||||||
</template>
|
</template>
|
||||||
<template #next="{ disabled }">
|
<template #next="{ disabled }">
|
||||||
<BaseButton
|
<PaginationItem
|
||||||
|
variant="next"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
class="pagination-next"
|
|
||||||
@click="changePage(currentPage + 1)"
|
@click="changePage(currentPage + 1)"
|
||||||
>
|
>
|
||||||
{{ $t('misc.next') }}
|
{{ $t('misc.next') }}
|
||||||
</BaseButton>
|
</PaginationItem>
|
||||||
</template>
|
</template>
|
||||||
<template #page-link="{ page, isCurrent }">
|
<template #page-link="{ page, isCurrent }">
|
||||||
<BaseButton
|
<PaginationItem
|
||||||
class="pagination-link"
|
variant="link"
|
||||||
|
:is-current="isCurrent"
|
||||||
:aria-label="'Goto page ' + page.number"
|
:aria-label="'Goto page ' + page.number"
|
||||||
:class="{ 'is-current': isCurrent }"
|
|
||||||
@click="changePage(page.number)"
|
@click="changePage(page.number)"
|
||||||
>
|
>
|
||||||
{{ page.number }}
|
{{ page.number }}
|
||||||
</BaseButton>
|
</PaginationItem>
|
||||||
</template>
|
</template>
|
||||||
</BasePagination>
|
</BasePagination>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import BasePagination from '@/components/base/BasePagination.vue'
|
import BasePagination from '@/components/base/BasePagination.vue'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import PaginationItem from '@/components/misc/PaginationItem.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
totalPages: number,
|
totalPages: number,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
<template>
|
||||||
|
<RouterLink
|
||||||
|
v-if="to !== undefined"
|
||||||
|
:to="to"
|
||||||
|
:disabled="disabled || undefined"
|
||||||
|
:class="[`pagination-${variant}`, {'is-current': isCurrent}]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</RouterLink>
|
||||||
|
<BaseButton
|
||||||
|
v-else
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[`pagination-${variant}`, {'is-current': isCurrent}]"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</BaseButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type {RouteLocationRaw} from 'vue-router'
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
variant: 'previous' | 'next' | 'link',
|
||||||
|
isCurrent?: boolean,
|
||||||
|
disabled?: boolean,
|
||||||
|
to?: RouteLocationRaw,
|
||||||
|
}>(), {
|
||||||
|
isCurrent: false,
|
||||||
|
disabled: false,
|
||||||
|
to: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'click'): void,
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// Rules ported from bulma-css-variables/sass/components/pagination.sass.
|
||||||
|
// PaginationItem owns the .pagination-previous / .pagination-next /
|
||||||
|
// .pagination-link markup, so scoped attributes attach directly to these
|
||||||
|
// classes — no :deep() necessary.
|
||||||
|
|
||||||
|
.pagination-previous,
|
||||||
|
.pagination-next,
|
||||||
|
.pagination-link {
|
||||||
|
appearance: none;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: $radius;
|
||||||
|
box-shadow: none;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 1em;
|
||||||
|
block-size: 2.5em;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0.25rem;
|
||||||
|
padding: calc(0.5em - 1px) 0.5em;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled],
|
||||||
|
fieldset[disabled] & {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-strong);
|
||||||
|
min-inline-size: 2.5em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--link-hover-border);
|
||||||
|
color: var(--link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--link-focus-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
box-shadow: inset 0 1px 2px rgba($scheme-invert, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
background-color: var(--border);
|
||||||
|
border-color: var(--border);
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--text-light);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-previous,
|
||||||
|
.pagination-next {
|
||||||
|
padding-inline: 0.75em;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:not(:disabled):hover {
|
||||||
|
background: $scheme-main;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-link.is-current {
|
||||||
|
background-color: var(--link);
|
||||||
|
border-color: var(--link);
|
||||||
|
color: var(--link-invert);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: $tablet - 1px) {
|
||||||
|
.pagination-previous,
|
||||||
|
.pagination-next {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: $tablet), print {
|
||||||
|
.pagination-previous,
|
||||||
|
.pagination-next,
|
||||||
|
.pagination-link {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasePagination hardcodes `.is-centered`, so prev and next are flex-ordered
|
||||||
|
// around the centered page list (prev left, list middle, next right).
|
||||||
|
.pagination-previous {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-next {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// Unscoped: this rule relies on ancestors (.app-container.has-background /
|
||||||
|
// .link-share-container.has-background) that live outside PaginationItem.
|
||||||
|
// Previously lived in styles/theme/background.scss, then BasePagination.vue.
|
||||||
|
.app-container.has-background .pagination-link:not(.is-current),
|
||||||
|
.link-share-container.has-background .pagination-link:not(.is-current) {
|
||||||
|
background: var(--grey-100);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
<template>
|
||||||
|
<div class="content-widescreen">
|
||||||
|
<div class="side-nav-shell">
|
||||||
|
<nav class="navigation">
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in navigationItems"
|
||||||
|
:key="`nav-${index}`"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
v-slot="{href, navigate, isActive, isExactActive}"
|
||||||
|
:to="{name: item.routeName}"
|
||||||
|
custom
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="href"
|
||||||
|
class="navigation-link"
|
||||||
|
:class="{'is-active': (exact ? isExactActive : isActive) || isAliasActive(item)}"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</a>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
v-for="({url, text}, index) in extraLinks"
|
||||||
|
:key="`extra-${index}`"
|
||||||
|
>
|
||||||
|
<BaseButton
|
||||||
|
class="navigation-link is-flex is-align-items-center"
|
||||||
|
:href="url"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ text }}
|
||||||
|
</span>
|
||||||
|
<span class="ml-1 has-text-grey-light is-size-7">
|
||||||
|
<Icon
|
||||||
|
icon="arrow-up-right-from-square"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</BaseButton>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<section class="view">
|
||||||
|
<RouterView />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {useRoute} from 'vue-router'
|
||||||
|
|
||||||
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
||||||
|
export interface SideNavItem {
|
||||||
|
title: string
|
||||||
|
routeName: string
|
||||||
|
activeRouteNames?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SideNavExtraLink {
|
||||||
|
url: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
navigationItems: SideNavItem[]
|
||||||
|
extraLinks?: SideNavExtraLink[]
|
||||||
|
exact?: boolean
|
||||||
|
}>(), {
|
||||||
|
extraLinks: () => [],
|
||||||
|
exact: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
function isAliasActive(item: SideNavItem) {
|
||||||
|
return item.activeRouteNames?.includes(route.name as string) ?? false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.side-nav-shell {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
@media screen and (max-width: $tablet) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
inline-size: 25%;
|
||||||
|
padding-inline-end: 1rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: $tablet) {
|
||||||
|
inline-size: 100%;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation-link {
|
||||||
|
display: block;
|
||||||
|
padding: .5rem;
|
||||||
|
color: var(--text);
|
||||||
|
inline-size: 100%;
|
||||||
|
border-inline-start: 3px solid transparent;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-active {
|
||||||
|
background: var(--white);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
inline-size: 75%;
|
||||||
|
|
||||||
|
@media screen and (max-width: $tablet) {
|
||||||
|
inline-size: 100%;
|
||||||
|
padding-inline-start: 0;
|
||||||
|
padding-block-start: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<template>
|
||||||
|
<time
|
||||||
|
v-if="date"
|
||||||
|
v-tooltip="formatDateLong(date)"
|
||||||
|
:datetime="formatISO(date)"
|
||||||
|
>{{ displayText }}</time>
|
||||||
|
<span v-else-if="fallback">{{ fallback }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {computed} from 'vue'
|
||||||
|
import {formatDisplayDate, formatDateSince, formatDateLong, formatISO} from '@/helpers/time/formatDate'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
date: Date | string | null | undefined,
|
||||||
|
mode?: 'short' | 'relative',
|
||||||
|
fallback?: string,
|
||||||
|
}>(), {
|
||||||
|
mode: 'short',
|
||||||
|
fallback: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayText = computed(() => {
|
||||||
|
if (!props.date) return ''
|
||||||
|
return props.mode === 'relative'
|
||||||
|
? formatDateSince(props.date)
|
||||||
|
: formatDisplayDate(props.date)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
@ -2,15 +2,24 @@
|
||||||
<div
|
<div
|
||||||
class="user"
|
class="user"
|
||||||
:class="{'is-inline': isInline}"
|
:class="{'is-inline': isInline}"
|
||||||
|
:style="{'--avatar-size': `${avatarSize}px`}"
|
||||||
>
|
>
|
||||||
<img
|
<span class="avatar-wrapper">
|
||||||
v-tooltip="displayName"
|
<img
|
||||||
:height="avatarSize"
|
v-tooltip="displayName"
|
||||||
:src="avatarSrc"
|
:height="avatarSize"
|
||||||
:width="avatarSize"
|
:src="avatarSrc"
|
||||||
:alt="'Avatar of ' + displayName"
|
:width="avatarSize"
|
||||||
class="avatar"
|
:alt="'Avatar of ' + displayName"
|
||||||
>
|
class="avatar"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="isBot"
|
||||||
|
v-tooltip="t('user.settings.bots.badge')"
|
||||||
|
class="bot-badge"
|
||||||
|
aria-label="Bot"
|
||||||
|
>B</span>
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="showUsername"
|
v-if="showUsername"
|
||||||
class="username"
|
class="username"
|
||||||
|
|
@ -20,6 +29,7 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {computed, ref, watch} from 'vue'
|
import {computed, ref, watch} from 'vue'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
|
import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
|
||||||
import type {IUser} from '@/modelTypes/IUser'
|
import type {IUser} from '@/modelTypes/IUser'
|
||||||
|
|
@ -35,7 +45,10 @@ const props = withDefaults(defineProps<{
|
||||||
isInline: false,
|
isInline: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
const displayName = computed(() => getDisplayName(props.user))
|
const displayName = computed(() => getDisplayName(props.user))
|
||||||
|
const isBot = computed(() => ((props.user as IUser & {botOwnerId?: number}).botOwnerId ?? 0) > 0)
|
||||||
const avatarSrc = ref('')
|
const avatarSrc = ref('')
|
||||||
|
|
||||||
async function loadAvatar() {
|
async function loadAvatar() {
|
||||||
|
|
@ -55,9 +68,40 @@ watch(() => [props.user, props.avatarSize], loadAvatar, { immediate: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar-wrapper {
|
||||||
border-radius: 100%;
|
position: relative;
|
||||||
vertical-align: middle;
|
display: inline-flex;
|
||||||
margin-inline-end: .5rem;
|
margin-inline-end: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
inline-size: var(--avatar-size);
|
||||||
|
block-size: var(--avatar-size);
|
||||||
|
border-radius: 100%;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-badge {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
inline-size: 40%;
|
||||||
|
block-size: 40%;
|
||||||
|
min-inline-size: 14px;
|
||||||
|
min-block-size: 14px;
|
||||||
|
max-inline-size: 22px;
|
||||||
|
max-block-size: 22px;
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--white);
|
||||||
|
background: var(--primary);
|
||||||
|
border: 2px solid var(--white);
|
||||||
|
border-radius: 100%;
|
||||||
|
text-transform: uppercase;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ function doDelete() {
|
||||||
<template #default="{id}">
|
<template #default="{id}">
|
||||||
<FormInput
|
<FormInput
|
||||||
:id="id"
|
:id="id"
|
||||||
v-model="newWebhook.basicauthuser"
|
v-model="newWebhook.basicAuthUser"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -153,7 +153,7 @@ function doDelete() {
|
||||||
<template #default="{id}">
|
<template #default="{id}">
|
||||||
<FormInput
|
<FormInput
|
||||||
:id="id"
|
:id="id"
|
||||||
v-model="newWebhook.basicauthpassword"
|
v-model="newWebhook.basicAuthPassword"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,18 @@
|
||||||
ref="popup"
|
ref="popup"
|
||||||
class="notifications-list"
|
class="notifications-list"
|
||||||
>
|
>
|
||||||
<span class="head">{{ $t('notification.title') }}</span>
|
<div class="head">
|
||||||
|
<span>{{ $t('notification.title') }}</span>
|
||||||
|
<BaseButton
|
||||||
|
v-tooltip="$t('notification.subscribeFeed')"
|
||||||
|
class="feed-link"
|
||||||
|
:to="{name: 'user.settings.feeds'}"
|
||||||
|
@click="showNotifications = false"
|
||||||
|
>
|
||||||
|
<span class="is-sr-only">{{ $t('notification.subscribeFeed') }}</span>
|
||||||
|
<Icon icon="rss" />
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(n, index) in notifications"
|
v-for="(n, index) in notifications"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
|
|
@ -284,6 +295,19 @@ async function markAllRead() {
|
||||||
font-family: $vikunja-font;
|
font-family: $vikunja-font;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
padding: .5rem;
|
padding: .5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.feed-link {
|
||||||
|
color: var(--grey-500);
|
||||||
|
transition: color $transition;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.single-notification {
|
.single-notification {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
>
|
>
|
||||||
{{ $t('menu.views') }}
|
{{ $t('menu.views') }}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<slot name="before-delete" />
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }"
|
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }"
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
|
|
@ -109,8 +110,9 @@
|
||||||
>
|
>
|
||||||
{{ $t('menu.createProject') }}
|
{{ $t('menu.createProject') }}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<slot name="before-delete" />
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-if="project.maxPermission === PERMISSIONS.ADMIN"
|
v-if="forceAllActions || project.maxPermission === PERMISSIONS.ADMIN"
|
||||||
v-tooltip="isDefaultProject ? $t('menu.cantDeleteIsDefault') : ''"
|
v-tooltip="isDefaultProject ? $t('menu.cantDeleteIsDefault') : ''"
|
||||||
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
|
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
|
||||||
icon="trash-alt"
|
icon="trash-alt"
|
||||||
|
|
@ -139,9 +141,12 @@ import {useProjectStore} from '@/stores/projects'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {PERMISSIONS} from '@/constants/permissions'
|
import {PERMISSIONS} from '@/constants/permissions'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
project: IProject
|
project: IProject
|
||||||
}>()
|
forceAllActions?: boolean
|
||||||
|
}>(), {
|
||||||
|
forceAllActions: false,
|
||||||
|
})
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const subscription = ref<ISubscription | null>(null)
|
const subscription = ref<ISubscription | null>(null)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@
|
||||||
</p>
|
</p>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select v-model="selected">
|
<select
|
||||||
|
v-model="selected"
|
||||||
|
:aria-label="$t('misc.sortBy')"
|
||||||
|
>
|
||||||
<option
|
<option
|
||||||
v-for="o in options"
|
v-for="o in options"
|
||||||
:key="o.value"
|
:key="o.value"
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@
|
||||||
<template #default>
|
<template #default>
|
||||||
<Card :has-content="false">
|
<Card :has-content="false">
|
||||||
<div class="gantt-options">
|
<div class="gantt-options">
|
||||||
<FormField :label="$t('project.gantt.range')">
|
<FormField :label="$t('misc.dateRange')">
|
||||||
<Foo
|
<Foo
|
||||||
id="range"
|
id="range"
|
||||||
ref="flatPickerEl"
|
ref="flatPickerEl"
|
||||||
v-model="flatPickerDateRange"
|
v-model="flatPickerDateRange"
|
||||||
:config="flatPickerConfig"
|
:config="flatPickerConfig"
|
||||||
class="input"
|
class="input"
|
||||||
:placeholder="$t('project.gantt.range')"
|
:placeholder="$t('misc.dateRange')"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@
|
||||||
v-if="canWrite && !collapsedBuckets[bucket.id]"
|
v-if="canWrite && !collapsedBuckets[bucket.id]"
|
||||||
class="is-right options"
|
class="is-right options"
|
||||||
trigger-icon="ellipsis-v"
|
trigger-icon="ellipsis-v"
|
||||||
|
:trigger-label="$t('project.kanban.bucketOptions')"
|
||||||
@close="() => showSetLimitInput = false"
|
@close="() => showSetLimitInput = false"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -108,7 +109,7 @@
|
||||||
@click.stop="showSetLimitInput = true"
|
@click.stop="showSetLimitInput = true"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
|
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('misc.notSet')})
|
||||||
}}
|
}}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
|
|
@ -1093,6 +1094,7 @@ $filter-container-height: '1rem - #{$switch-view-height}';
|
||||||
.bucket-footer {
|
.bucket-footer {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
inset-block-end: 0;
|
inset-block-end: 0;
|
||||||
|
z-index: 2;
|
||||||
block-size: min-content;
|
block-size: min-content;
|
||||||
padding: .5rem;
|
padding: .5rem;
|
||||||
background-color: var(--grey-100);
|
background-color: var(--grey-100);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {useLabelStore} from '@/stores/labels'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
|
|
||||||
import XButton from '@/components/input/Button.vue'
|
import XButton from '@/components/input/Button.vue'
|
||||||
|
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||||
import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue'
|
import FilterInputDocs from '@/components/input/filter/FilterInputDocs.vue'
|
||||||
import FilterInput from '@/components/input/filter/FilterInput.vue'
|
import FilterInput from '@/components/input/filter/FilterInput.vue'
|
||||||
import FormField from '@/components/input/FormField.vue'
|
import FormField from '@/components/input/FormField.vue'
|
||||||
|
|
@ -58,6 +59,16 @@ onBeforeMount(() => {
|
||||||
filter.filter = filter.s
|
filter.filter = filter.s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AbstractModel.assignData() runs objectToCamelCase recursively on all
|
||||||
|
// nested objects, which converts filter_include_nulls to filterIncludeNulls
|
||||||
|
// inside the filter object. IFilters intentionally uses snake_case keys to
|
||||||
|
// match the API query param format. We check both key forms here to handle
|
||||||
|
// data coming from either the API response (camelCased by assignData) or
|
||||||
|
// from a freshly constructed filter object (snake_case).
|
||||||
|
filter.filter_include_nulls = filterInput.filter_include_nulls
|
||||||
|
?? (filterInput as Record<string, unknown>).filterIncludeNulls as boolean
|
||||||
|
?? false
|
||||||
|
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,16 +87,18 @@ onBeforeMount(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
const transformFilterForApi = (filterQuery: string): IFilters => {
|
const transformFilterForApi = (filterInput: IFilters): IFilters => {
|
||||||
const filterString = transformFilterStringForApi(
|
const filterString = transformFilterStringForApi(
|
||||||
filterQuery,
|
filterInput?.filter || '',
|
||||||
labelTitle => labelStore.getLabelByExactTitle(labelTitle)?.id || null,
|
labelTitle => labelStore.getLabelByExactTitle(labelTitle)?.id || null,
|
||||||
projectTitle => {
|
projectTitle => {
|
||||||
const found = projectStore.findProjectByExactname(projectTitle)
|
const found = projectStore.findProjectByExactname(projectTitle)
|
||||||
return found?.id || null
|
return found?.id || null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const filter: IFilters = {}
|
const filter: IFilters = {
|
||||||
|
filter_include_nulls: filterInput?.filter_include_nulls ?? false,
|
||||||
|
}
|
||||||
if (hasFilterQuery(filterString)) {
|
if (hasFilterQuery(filterString)) {
|
||||||
filter.filter = filterString
|
filter.filter = filterString
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -97,10 +110,10 @@ function save() {
|
||||||
|
|
||||||
emit('update:modelValue', {
|
emit('update:modelValue', {
|
||||||
...view.value,
|
...view.value,
|
||||||
filter: transformFilterForApi(view.value?.filter?.filter || ''),
|
filter: transformFilterForApi(view.value?.filter),
|
||||||
bucketConfiguration: view.value?.bucketConfiguration.map(bc => ({
|
bucketConfiguration: view.value?.bucketConfiguration.map(bc => ({
|
||||||
title: bc.title,
|
title: bc.title,
|
||||||
filter: transformFilterForApi(bc.filter?.filter || ''),
|
filter: transformFilterForApi(bc.filter),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -172,10 +185,18 @@ function handleBubbleSave() {
|
||||||
class="mbe-1"
|
class="mbe-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="is-size-7 mbe-3">
|
<div class="is-size-7 mbe-2">
|
||||||
<FilterInputDocs />
|
<FilterInputDocs />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field mbe-3">
|
||||||
|
<FancyCheckbox
|
||||||
|
v-model="view.filter.filter_include_nulls"
|
||||||
|
>
|
||||||
|
{{ $t('filters.attributes.includeNulls') }}
|
||||||
|
</FancyCheckbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="view.viewKind === 'kanban'"
|
v-if="view.viewKind === 'kanban'"
|
||||||
class="field"
|
class="field"
|
||||||
|
|
@ -245,16 +266,24 @@ function handleBubbleSave() {
|
||||||
class="mbe-2"
|
class="mbe-2"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="is-size-7">
|
<div class="is-size-7 mbe-2">
|
||||||
<FilterInputDocs />
|
<FilterInputDocs />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field mbe-3">
|
||||||
|
<FancyCheckbox
|
||||||
|
v-model="view.bucketConfiguration[index].filter.filter_include_nulls"
|
||||||
|
>
|
||||||
|
{{ $t('filters.attributes.includeNulls') }}
|
||||||
|
</FancyCheckbox>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="is-flex is-justify-content-end">
|
<div class="is-flex is-justify-content-end">
|
||||||
<XButton
|
<XButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
@click="() => view.bucketConfiguration.push({title: '', filter: {filter: ''}})"
|
@click="() => view.bucketConfiguration.push({title: '', filter: {filter: '', filter_include_nulls: false}})"
|
||||||
>
|
>
|
||||||
{{ $t('project.kanban.addBucket') }}
|
{{ $t('project.kanban.addBucket') }}
|
||||||
</XButton>
|
</XButton>
|
||||||
|
|
@ -302,4 +331,32 @@ function handleBubbleSave() {
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ported from bulma-css-variables/sass/form/checkbox-radio.sass
|
||||||
|
// (the %checkbox-radio placeholder plus the .radio + .radio sibling rule),
|
||||||
|
// scoped to this component so we can drop the global Bulma import.
|
||||||
|
label.radio {
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1.25;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--input-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled],
|
||||||
|
input[disabled] {
|
||||||
|
color: var(--input-disabled-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + .radio {
|
||||||
|
margin-inline-start: .5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<Modal
|
<Modal
|
||||||
:enabled="active"
|
:enabled="active"
|
||||||
:overflow="isNewTaskCommand"
|
:overflow="isNewTaskCommand"
|
||||||
|
variant="top"
|
||||||
@close="closeQuickActions"
|
@close="closeQuickActions"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -37,6 +38,7 @@
|
||||||
v-if="isNewTaskCommand"
|
v-if="isNewTaskCommand"
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
|
:aria-label="$t('misc.closeQuickActions')"
|
||||||
class="close"
|
class="close"
|
||||||
@click="closeQuickActions"
|
@click="closeQuickActions"
|
||||||
>
|
>
|
||||||
|
|
@ -189,12 +191,17 @@ watchEffect(() => {
|
||||||
let focusRafId: number | null = null
|
let focusRafId: number | null = null
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (active.value && isQuickAddMode) {
|
if (active.value) {
|
||||||
selectedCmd.value = commands.value.newTask
|
if (isQuickAddMode) {
|
||||||
|
selectedCmd.value = commands.value.newTask
|
||||||
|
}
|
||||||
|
|
||||||
// The input may not be focusable yet due to:
|
// The input may not be focusable yet due to:
|
||||||
// 1. Modal transition (v-if + <Transition appear>) delaying DOM readiness
|
// 1. Modal mounts the <dialog> via v-if and then calls showModal() in a
|
||||||
// 2. Electron window not yet visible (shown after did-finish-load)
|
// follow-up flush, so v-focus fires while the dialog is still closed
|
||||||
|
// and the focus() call is dropped.
|
||||||
|
// 2. In quick-add mode the Electron window isn't visible until
|
||||||
|
// did-finish-load.
|
||||||
// Retry with rAF until focus actually lands on the input.
|
// Retry with rAF until focus actually lands on the input.
|
||||||
const tryFocus = () => {
|
const tryFocus = () => {
|
||||||
if (!active.value) {
|
if (!active.value) {
|
||||||
|
|
@ -698,15 +705,16 @@ function reset() {
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.quick-actions {
|
.quick-actions {
|
||||||
|
// global Bulma .card styles are gone (ported into Card.vue, scoped),
|
||||||
|
// so this bare .card div needs its own card visuals
|
||||||
|
background-color: var(--white);
|
||||||
|
border-radius: $radius;
|
||||||
|
border: 1px solid var(--card-border-color);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
color: var(--text);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
justify-content: flex-start !important;
|
justify-content: flex-start !important;
|
||||||
|
|
||||||
// FIXME: changed position should be an option of the modal
|
|
||||||
:deep(.modal-content) {
|
|
||||||
inset-block-start: 3rem;
|
|
||||||
transform: translate(-50%, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-quick-add-mode {
|
&.is-quick-add-mode {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
rows="1"
|
rows="1"
|
||||||
@keydown="resetEmptyTitleError"
|
@keydown="resetEmptyTitleError"
|
||||||
@keydown.enter="handleEnter"
|
@keydown.enter="handleEnter"
|
||||||
|
@keydown.esc="blurTaskInput"
|
||||||
/>
|
/>
|
||||||
<QuickAddMagic
|
<QuickAddMagic
|
||||||
:highlight-hint-icon="taskAddHovered"
|
:highlight-hint-icon="taskAddHovered"
|
||||||
|
|
@ -282,6 +283,10 @@ function focusTaskInput() {
|
||||||
newTaskInput.value?.focus()
|
newTaskInput.value?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blurTaskInput() {
|
||||||
|
newTaskInput.value?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
focusTaskInput,
|
focusTaskInput,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@
|
||||||
</XButton>
|
</XButton>
|
||||||
|
|
||||||
<!-- Dropzone -->
|
<!-- Dropzone -->
|
||||||
<Teleport to="body">
|
<Teleport :to="dropzoneTeleportTarget">
|
||||||
<div
|
<div
|
||||||
v-if="editEnabled"
|
v-if="editEnabled"
|
||||||
:class="{hidden: !showDropzone}"
|
:class="{hidden: !showDropzone}"
|
||||||
|
|
@ -185,7 +185,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, shallowReactive, computed, watch} from 'vue'
|
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
|
||||||
import {useDropZone} from '@vueuse/core'
|
import {useDropZone} from '@vueuse/core'
|
||||||
|
|
||||||
import User from '@/components/misc/User.vue'
|
import User from '@/components/misc/User.vue'
|
||||||
|
|
@ -322,6 +322,34 @@ const showDropzone = computed(() =>
|
||||||
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
|
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
|
||||||
|
// the browser's top layer, so the full-screen dropzone overlay teleported to
|
||||||
|
// <body> would paint behind it regardless of z-index. Teleport it into the
|
||||||
|
// topmost open dialog instead, mirroring Notification.vue.
|
||||||
|
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
|
||||||
|
let dialogObserver: MutationObserver | null = null
|
||||||
|
|
||||||
|
function syncDropzoneTeleportTarget() {
|
||||||
|
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
|
||||||
|
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncDropzoneTeleportTarget()
|
||||||
|
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
|
||||||
|
dialogObserver.observe(document.body, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['open'],
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
dialogObserver?.disconnect()
|
||||||
|
dialogObserver = null
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => props.editEnabled, enabled => {
|
watch(() => props.editEnabled, enabled => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
resetDragState()
|
resetDragState()
|
||||||
|
|
@ -478,7 +506,7 @@ defineExpose({
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 0;
|
||||||
inset-block-end: 0;
|
inset-block-end: 0;
|
||||||
inset-inline-end: 0;
|
inset-inline-end: 0;
|
||||||
z-index: 4001; // modal z-index is 4000
|
z-index: 4001; // above app chrome when teleported to body (no modal open)
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
{{ currentBucketTitle }}
|
{{ currentBucketTitle }}
|
||||||
<Icon
|
<Icon
|
||||||
icon="pencil-alt"
|
icon="pencil-alt"
|
||||||
class="change-indicator"
|
class="change-indicator d-print-none"
|
||||||
/>
|
/>
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@
|
||||||
/>
|
/>
|
||||||
<Reactions
|
<Reactions
|
||||||
v-model="c.reactions"
|
v-model="c.reactions"
|
||||||
class="mbs-2"
|
class="mbs-2 d-print-none"
|
||||||
entity-kind="comments"
|
entity-kind="comments"
|
||||||
:entity-id="c.id"
|
:entity-id="c.id"
|
||||||
:disabled="!canWrite"
|
:disabled="!canWrite"
|
||||||
|
|
@ -173,6 +173,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<Editor
|
<Editor
|
||||||
v-if="editorActive"
|
v-if="editorActive"
|
||||||
|
ref="newCommentEditor"
|
||||||
v-model="newCommentText"
|
v-model="newCommentText"
|
||||||
:class="{
|
:class="{
|
||||||
'is-loading':
|
'is-loading':
|
||||||
|
|
@ -222,7 +223,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, reactive, computed, shallowReactive, watch} from 'vue'
|
import {ref, reactive, computed, nextTick, provide, shallowReactive, watch} from 'vue'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
|
|
@ -246,6 +247,7 @@ import {useConfigStore} from '@/stores/config'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import Reactions from '@/components/input/Reactions.vue'
|
import Reactions from '@/components/input/Reactions.vue'
|
||||||
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
||||||
|
import {commentReplyContextKey, scrollAndHighlightComment} from '@/components/tasks/partials/commentReplyContext'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
taskId: number,
|
taskId: number,
|
||||||
|
|
@ -304,15 +306,19 @@ const actions = computed(() => {
|
||||||
if (!props.canWrite) {
|
if (!props.canWrite) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
return Object.fromEntries(comments.value.map((comment) => ([
|
return Object.fromEntries(comments.value.map((comment) => {
|
||||||
comment.id,
|
const list: {action: () => void, title: string}[] = [{
|
||||||
comment.author.id === currentUserId.value
|
action: () => startReplyTo(comment),
|
||||||
? [{
|
title: t('task.comment.reply'),
|
||||||
|
}]
|
||||||
|
if (comment.author.id === currentUserId.value) {
|
||||||
|
list.push({
|
||||||
action: () => toggleDelete(comment.id),
|
action: () => toggleDelete(comment.id),
|
||||||
title: t('misc.delete'),
|
title: t('misc.delete'),
|
||||||
}]
|
})
|
||||||
: [],
|
}
|
||||||
])))
|
return [comment.id, list]
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const frontendUrl = computed(() => configStore.frontendUrl)
|
const frontendUrl = computed(() => configStore.frontendUrl)
|
||||||
|
|
@ -321,6 +327,55 @@ const commentStorageKey = computed(() => `task-comment-${props.taskId}`)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|
||||||
const commentsRef = ref<HTMLElement | null>(null)
|
const commentsRef = ref<HTMLElement | null>(null)
|
||||||
|
const newCommentEditor = ref<{setReplyContent: (html: string) => Promise<void>} | null>(null)
|
||||||
|
|
||||||
|
provide(commentReplyContextKey, {
|
||||||
|
findComment: (id: number) => comments.value.find(c => c.id === id),
|
||||||
|
scrollToComment: scrollAndHighlightComment,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Strip <mention-user> elements from a reply quote so reposting the parent
|
||||||
|
// body doesn't trigger fresh notifications for users mentioned in the
|
||||||
|
// original. The inner text is kept so the quote still reads correctly.
|
||||||
|
function stripMentionsForQuote(html: string): string {
|
||||||
|
if (!html) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html')
|
||||||
|
doc.querySelectorAll('mention-user').forEach((el) => {
|
||||||
|
const label = (el.getAttribute('data-label') ?? el.textContent ?? '').trim()
|
||||||
|
el.replaceWith(label ? `@${label.replace(/^@+/, '')}` : '')
|
||||||
|
})
|
||||||
|
return doc.body.firstElementChild?.innerHTML ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startReplyTo(parent: ITaskComment) {
|
||||||
|
const body = stripMentionsForQuote(parent.comment ?? '')
|
||||||
|
const draft = `<blockquote data-comment-id="${parent.id}">${body}</blockquote><p></p>`
|
||||||
|
if (!editorActive.value) {
|
||||||
|
editorActive.value = true
|
||||||
|
}
|
||||||
|
// Editor mounts asynchronously through defineAsyncComponent; wait until
|
||||||
|
// the ref is populated before pushing content in. Bail with a warning
|
||||||
|
// rather than fall back to `newCommentText = draft` — the modelValue
|
||||||
|
// watcher in TipTap.vue would land the editor in preview mode, leaving
|
||||||
|
// the user unable to type without clicking the editor first.
|
||||||
|
const editor = await waitForEditorRef()
|
||||||
|
if (!editor) {
|
||||||
|
console.warn('Reply editor did not mount in time; aborting reply prefill.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await editor.setReplyContent(draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForEditorRef() {
|
||||||
|
const start = performance.now()
|
||||||
|
while (!newCommentEditor.value && performance.now() - start < 2000) {
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
return newCommentEditor.value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function attachmentUpload(files: File[] | FileList): (Promise<string[]>) {
|
async function attachmentUpload(files: File[] | FileList): (Promise<string[]>) {
|
||||||
|
|
@ -506,7 +561,9 @@ async function deleteComment(commentToDelete: ITaskComment) {
|
||||||
|
|
||||||
function getCommentUrl(commentId: string) {
|
function getCommentUrl(commentId: string) {
|
||||||
const baseUrl = frontendUrl.value.endsWith('/') ? frontendUrl.value.slice(0, -1) : frontendUrl.value
|
const baseUrl = frontendUrl.value.endsWith('/') ? frontendUrl.value.slice(0, -1) : frontendUrl.value
|
||||||
return `${baseUrl}${location.pathname}${location.search}#comment-${commentId}`
|
const url = new URL(location.pathname + location.search, baseUrl)
|
||||||
|
url.hash = `comment-${commentId}`
|
||||||
|
return url.toString()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -515,11 +572,10 @@ function getCommentUrl(commentId: string) {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
text-align: inherit;
|
text-align: inherit;
|
||||||
|
padding-block-start: .5rem;
|
||||||
|
|
||||||
& + .media {
|
& + .media {
|
||||||
border-block-start: 1px solid rgba(var(--border-rgb), 0.5);
|
margin-block-start: .5rem;
|
||||||
margin-block-start: 1rem;
|
|
||||||
padding-block-start: 1rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,7 +583,7 @@ function getCommentUrl(commentId: string) {
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0 1rem !important;
|
margin: 0 .5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-info {
|
.comment-info {
|
||||||
|
|
@ -603,4 +659,15 @@ function getCommentUrl(commentId: string) {
|
||||||
.comments-container {
|
.comments-container {
|
||||||
scroll-margin-block-start: 4rem;
|
scroll-margin-block-start: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media.comment {
|
||||||
|
scroll-margin-block-start: 4rem;
|
||||||
|
transition: background-color .3s ease-out;
|
||||||
|
border-radius: $radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media.comment.comment-highlight {
|
||||||
|
background-color: hsla(var(--primary-hsl), 0.18);
|
||||||
|
transition: background-color .15s ease-in;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ import flatPickr from 'vue-flatpickr-component'
|
||||||
import TaskService from '@/services/task'
|
import TaskService from '@/services/task'
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||||
|
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||||
|
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: ITask,
|
modelValue: ITask,
|
||||||
|
|
@ -59,6 +61,7 @@ const emit = defineEmits<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
const {store: timeFormat} = useTimeFormat()
|
||||||
|
|
||||||
const taskService = shallowReactive(new TaskService())
|
const taskService = shallowReactive(new TaskService())
|
||||||
const task = ref<ITask>()
|
const task = ref<ITask>()
|
||||||
|
|
@ -103,7 +106,7 @@ const flatPickerConfig = computed(() => ({
|
||||||
altInput: true,
|
altInput: true,
|
||||||
dateFormat: 'Y-m-d H:i',
|
dateFormat: 'Y-m-d H:i',
|
||||||
enableTime: true,
|
enableTime: true,
|
||||||
time_24hr: true,
|
time_24hr: timeFormat.value === TIME_FORMAT.HOURS_24,
|
||||||
inline: true,
|
inline: true,
|
||||||
locale: useFlatpickrLanguage().value,
|
locale: useFlatpickrLanguage().value,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div
|
||||||
|
:class="{'d-print-none': isEmpty}"
|
||||||
|
>
|
||||||
<h3>
|
<h3>
|
||||||
<span class="icon is-grey">
|
<span class="icon is-grey">
|
||||||
<Icon icon="align-left" />
|
<Icon icon="align-left" />
|
||||||
|
|
@ -48,6 +50,7 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||||
import Editor from '@/components/input/AsyncEditor'
|
import Editor from '@/components/input/AsyncEditor'
|
||||||
|
|
||||||
import { clearEditorDraft } from '@/helpers/editorDraftStorage'
|
import { clearEditorDraft } from '@/helpers/editorDraftStorage'
|
||||||
|
import { isEditorContentEmpty } from '@/helpers/editorContentEmpty'
|
||||||
import type { ITask } from '@/modelTypes/ITask'
|
import type { ITask } from '@/modelTypes/ITask'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
|
||||||
|
|
@ -82,6 +85,8 @@ const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const descriptionStorageKey = computed(() => `task-description-${props.modelValue.id}`)
|
const descriptionStorageKey = computed(() => `task-description-${props.modelValue.id}`)
|
||||||
|
|
||||||
|
const isEmpty = computed(() => isEditorContentEmpty(description.value))
|
||||||
|
|
||||||
async function saveWithDelay() {
|
async function saveWithDelay() {
|
||||||
if (description.value === props.modelValue.description) {
|
if (description.value === props.modelValue.description) {
|
||||||
hasChanges.value = false
|
hasChanges.value = false
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
:color="getHexColor(task.hexColor)"
|
:color="getHexColor(task.hexColor)"
|
||||||
/>
|
/>
|
||||||
<BaseButton @click="copyUrl">
|
<BaseButton @click="copyUrl">
|
||||||
<h1 class="title task-id">
|
<span class="title task-id">
|
||||||
{{ textIdentifier }}
|
{{ textIdentifier }}
|
||||||
</h1>
|
</span>
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
<Done
|
<Done
|
||||||
|
|
@ -17,7 +17,8 @@
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="hasClose"
|
v-if="hasClose"
|
||||||
class="close"
|
:aria-label="$t('task.detail.closeTaskDetail')"
|
||||||
|
class="close d-print-none"
|
||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
<Icon icon="times" />
|
<Icon icon="times" />
|
||||||
|
|
@ -37,7 +38,8 @@
|
||||||
</h1>
|
</h1>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-if="hasClose"
|
v-if="hasClose"
|
||||||
class="close"
|
:aria-label="$t('task.detail.closeTaskDetail')"
|
||||||
|
class="close d-print-none"
|
||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
<Icon icon="times" />
|
<Icon icon="times" />
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
|
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
|
||||||
id="showRelatedTasksFormButton"
|
id="showRelatedTasksFormButton"
|
||||||
v-tooltip="$t('task.relation.add')"
|
v-tooltip="$t('task.relation.add')"
|
||||||
class="is-pulled-right add-task-relation-button d-print-none"
|
class="is-pulled-end add-task-relation-button d-print-none"
|
||||||
:class="{'is-active': showNewRelationForm}"
|
:class="{'is-active': showNewRelationForm}"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
key="field-search"
|
key="field-search"
|
||||||
class="field"
|
class="field task-relation-search-field"
|
||||||
>
|
>
|
||||||
<Multiselect
|
<Multiselect
|
||||||
v-model="newTaskRelation.task"
|
v-model="newTaskRelation.task"
|
||||||
|
|
@ -77,6 +77,7 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</Multiselect>
|
</Multiselect>
|
||||||
|
<QuickAddMagic />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
key="field-kind"
|
key="field-kind"
|
||||||
|
|
@ -200,6 +201,7 @@ import CustomTransition from '@/components/misc/CustomTransition.vue'
|
||||||
import BaseButton from '@/components/base/BaseButton.vue'
|
import BaseButton from '@/components/base/BaseButton.vue'
|
||||||
import Multiselect from '@/components/input/Multiselect.vue'
|
import Multiselect from '@/components/input/Multiselect.vue'
|
||||||
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||||
|
import QuickAddMagic from '@/components/tasks/partials/QuickAddMagic.vue'
|
||||||
|
|
||||||
import {error, success} from '@/message'
|
import {error, success} from '@/message'
|
||||||
import {useTaskStore} from '@/stores/tasks'
|
import {useTaskStore} from '@/stores/tasks'
|
||||||
|
|
@ -362,7 +364,7 @@ async function removeTaskRelation() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAndRelateTask(title: string) {
|
async function createAndRelateTask(title: string) {
|
||||||
const newTask = await taskService.create(new TaskModel({title, projectId: props.projectId}))
|
const newTask = await taskStore.createNewTask({title, projectId: props.projectId})
|
||||||
newTaskRelation.task = newTask
|
newTaskRelation.task = newTask
|
||||||
await addTaskRelation()
|
await addTaskRelation()
|
||||||
}
|
}
|
||||||
|
|
@ -459,6 +461,17 @@ async function toggleTaskDone(task: ITask) {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-relation-search-field {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
:deep(.quick-add-magic-trigger-btn) {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: .75rem;
|
||||||
|
inset-inline-end: .75rem;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME: The height of the actual checkbox in the <FancyCheckbox/> component is too much resulting in a
|
// FIXME: The height of the actual checkbox in the <FancyCheckbox/> component is too much resulting in a
|
||||||
// weired positioning of the checkbox. Setting the height here is a workaround until we fix the styling
|
// weired positioning of the checkbox. Setting the height here is a workaround until we fix the styling
|
||||||
// of the component.
|
// of the component.
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
<FancyCheckbox
|
<FancyCheckbox
|
||||||
v-model="task.done"
|
v-model="task.done"
|
||||||
:disabled="isArchived || disabled || !canMarkAsDone"
|
:disabled="isArchived || disabled || !canMarkAsDone"
|
||||||
|
:aria-label="$t('task.detail.markAsDone', {task: task.title})"
|
||||||
@update:modelValue="markAsDone"
|
@update:modelValue="markAsDone"
|
||||||
@click.stop
|
@click.stop
|
||||||
/>
|
/>
|
||||||
|
|
@ -325,9 +326,17 @@ const isOverdue = computed(() => (
|
||||||
let oldTask
|
let oldTask
|
||||||
|
|
||||||
async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
||||||
const updateFunc = async () => {
|
oldTask = {...task.value}
|
||||||
oldTask = {...task.value}
|
|
||||||
const newTask = await taskStore.update(task.value)
|
// Fire the request immediately and with the intended done value snapshotted, so a re-render or
|
||||||
|
// teardown during the animation delay can neither drop the save nor make it send a stale state.
|
||||||
|
const updatePromise = taskStore.update({
|
||||||
|
...task.value,
|
||||||
|
done: checked,
|
||||||
|
})
|
||||||
|
|
||||||
|
const finish = async () => {
|
||||||
|
const newTask = await updatePromise
|
||||||
task.value = newTask
|
task.value = newTask
|
||||||
|
|
||||||
updateDueDate()
|
updateDueDate()
|
||||||
|
|
@ -353,9 +362,9 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
|
setTimeout(finish, 300) // Delay only the follow-up to show the animation when marking a task as done
|
||||||
} else {
|
} else {
|
||||||
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
await finish() // Don't delay it when un-marking it as it doesn't have an animation the other way around
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -382,7 +391,7 @@ function hasTextSelected() {
|
||||||
|
|
||||||
function openTaskDetail(event: MouseEvent | KeyboardEvent) {
|
function openTaskDetail(event: MouseEvent | KeyboardEvent) {
|
||||||
if (event.target instanceof HTMLElement) {
|
if (event.target instanceof HTMLElement) {
|
||||||
const isInteractiveElement = event.target.closest('a, button, .favorite, [role="button"]')
|
const isInteractiveElement = event.target.closest('a, button, label, input[type="checkbox"], .favorite, [role="button"]')
|
||||||
if (isInteractiveElement || hasTextSelected()) {
|
if (isInteractiveElement || hasTextSelected()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -535,6 +544,23 @@ defineExpose({
|
||||||
span {
|
span {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extend the hit target to >=44x44 without affecting layout (WCAG 2.5.5).
|
||||||
|
.base-checkbox__label {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 50%;
|
||||||
|
inset-inline-start: 50%;
|
||||||
|
min-block-size: 44px;
|
||||||
|
min-inline-size: 44px;
|
||||||
|
block-size: 100%;
|
||||||
|
inline-size: 100%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tasktext.done {
|
.tasktext.done {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport
|
||||||
|
v-if="canHover"
|
||||||
|
to="body"
|
||||||
|
>
|
||||||
<CustomTransition name="fade">
|
<CustomTransition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="showTooltip"
|
v-if="showTooltip"
|
||||||
|
|
@ -82,6 +85,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref, computed, onUnmounted, nextTick} from 'vue'
|
import {ref, computed, onUnmounted, nextTick} from 'vue'
|
||||||
import {computePosition, flip, offset, shift} from '@floating-ui/dom'
|
import {computePosition, flip, offset, shift} from '@floating-ui/dom'
|
||||||
|
import {useMediaQuery} from '@vueuse/core'
|
||||||
|
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
import {getTaskIdentifier} from '@/models/task'
|
import {getTaskIdentifier} from '@/models/task'
|
||||||
|
|
@ -101,6 +105,9 @@ const props = defineProps<{
|
||||||
const HOVER_DELAY = 1000 // 1 second
|
const HOVER_DELAY = 1000 // 1 second
|
||||||
const MAX_DESCRIPTION_LENGTH = 150
|
const MAX_DESCRIPTION_LENGTH = 150
|
||||||
|
|
||||||
|
// Taps on touch devices emulate mouseenter, which would show the tooltip unexpectedly.
|
||||||
|
const canHover = useMediaQuery('(hover: hover) and (pointer: fine)')
|
||||||
|
|
||||||
const triggerRef = ref<HTMLElement | null>(null)
|
const triggerRef = ref<HTMLElement | null>(null)
|
||||||
const tooltipRef = ref<HTMLElement | null>(null)
|
const tooltipRef = ref<HTMLElement | null>(null)
|
||||||
const showTooltip = ref(false)
|
const showTooltip = ref(false)
|
||||||
|
|
@ -152,6 +159,10 @@ async function updatePosition() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseEnter() {
|
function handleMouseEnter() {
|
||||||
|
if (!canHover.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Clear any existing timeout
|
// Clear any existing timeout
|
||||||
if (hoverTimeout) {
|
if (hoverTimeout) {
|
||||||
clearTimeout(hoverTimeout)
|
clearTimeout(hoverTimeout)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type {InjectionKey} from 'vue'
|
||||||
|
import type {ITaskComment} from '@/modelTypes/ITaskComment'
|
||||||
|
|
||||||
|
export interface CommentReplyContext {
|
||||||
|
findComment: (id: number) => ITaskComment | undefined
|
||||||
|
scrollToComment: (id: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commentReplyContextKey: InjectionKey<CommentReplyContext> = Symbol('commentReplyContext')
|
||||||
|
|
||||||
|
const HIGHLIGHT_CLASS = 'comment-highlight'
|
||||||
|
const HIGHLIGHT_DURATION_MS = 1500
|
||||||
|
|
||||||
|
export function scrollAndHighlightComment(id: number): void {
|
||||||
|
const el = document.getElementById(`comment-${id}`)
|
||||||
|
if (!el) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
el.scrollIntoView({behavior: 'smooth', block: 'center', inline: 'nearest'})
|
||||||
|
el.classList.remove(HIGHLIGHT_CLASS)
|
||||||
|
// Re-apply on next frame so the animation restarts even if already running.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.classList.add(HIGHLIGHT_CLASS)
|
||||||
|
window.setTimeout(() => el.classList.remove(HIGHLIGHT_CLASS), HIGHLIGHT_DURATION_MS)
|
||||||
|
})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue