Compare commits
795 Commits
v0.8.9
...
shared-ins
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96782b52cd | ||
|
|
aae00e1312 | ||
|
|
24e90f0a54 | ||
|
|
f5208a85b0 | ||
|
|
8db4b3f83b | ||
|
|
33ad04d036 | ||
|
|
4bcdb3f495 | ||
|
|
70979172b0 | ||
|
|
493b9a3975 | ||
|
|
5a21a67d46 | ||
|
|
ff72c906ba | ||
|
|
907b1f67ed | ||
|
|
72cbe7f905 | ||
|
|
deb16aa7ab | ||
|
|
d0efa44c9e | ||
|
|
d321843c02 | ||
|
|
2b44b145cb | ||
|
|
9f5606889e | ||
|
|
417f2a8b91 | ||
|
|
d3a7bf967e | ||
|
|
db145cd8ad | ||
|
|
7d614f7ac5 | ||
|
|
c7a2a3e29b | ||
|
|
3b966c03ee | ||
|
|
66d943d391 | ||
|
|
f5f876e458 | ||
|
|
27c3439120 | ||
|
|
f212d04261 | ||
|
|
06f01aa85c | ||
|
|
5f48dc08a9 | ||
|
|
e81e056758 | ||
|
|
2d95ff0830 | ||
|
|
81d921d625 | ||
|
|
e988513ed7 | ||
|
|
783d4f82d9 | ||
|
|
b5011f458f | ||
|
|
5d0e4366d2 | ||
|
|
8643dc02dd | ||
|
|
7c4dcb2817 | ||
|
|
6b64fdafcb | ||
|
|
185dd47668 | ||
|
|
f165665a35 | ||
|
|
ad38749f98 | ||
|
|
7825dd64ca | ||
|
|
f6af620643 | ||
|
|
b5aeef7ebf | ||
|
|
f5a201dd94 | ||
|
|
72dab12033 | ||
|
|
ce4b4ba41d | ||
|
|
945206153d | ||
|
|
9f977d082b | ||
|
|
4a2ec0c40c | ||
|
|
70bf61a645 | ||
|
|
674f4b1095 | ||
|
|
3304070034 | ||
|
|
19ca5f08c6 | ||
|
|
5605910ac8 | ||
|
|
b1eda435a5 | ||
|
|
e88ca8430e | ||
|
|
d39db02a73 | ||
|
|
86a64ca929 | ||
|
|
5ea0f7d4c7 | ||
|
|
b3fa2fa6d2 | ||
|
|
999dc640bc | ||
|
|
9be9658ffb | ||
|
|
595d5362f6 | ||
|
|
f212fcf892 | ||
|
|
8c1c5572c0 | ||
|
|
31d151638a | ||
|
|
fb6b41630c | ||
|
|
6ab806cbde | ||
|
|
c0267f7746 | ||
|
|
a54b6dc7b9 | ||
|
|
9ec43ebe70 | ||
|
|
486cd68bf7 | ||
|
|
98c050e7e9 | ||
|
|
25fcee984b | ||
|
|
86922c4547 | ||
|
|
7bbdfd25cd | ||
|
|
b8ad22a6fb | ||
|
|
8dd955563e | ||
|
|
663ab83b08 | ||
|
|
c143929b69 | ||
|
|
1b73d248b3 | ||
|
|
cc22a92daf | ||
|
|
39f0408929 | ||
|
|
e66f46a464 | ||
|
|
9243296197 | ||
|
|
26ce83f8f1 | ||
|
|
907ef38189 | ||
|
|
a7d4001b00 | ||
|
|
e3a3379615 | ||
|
|
ff7975773e | ||
|
|
c88bfbb5f0 | ||
|
|
28b6bf8603 | ||
|
|
f7d1cd2a4f | ||
|
|
edb7e5f323 | ||
|
|
5b5599128a | ||
|
|
cb0f03ca9c | ||
|
|
2e35f3608b | ||
|
|
679ffbcce7 | ||
|
|
637a923e84 | ||
|
|
1f8d569b79 | ||
|
|
93ae24e707 | ||
|
|
7dd340f0b6 | ||
|
|
1d0d8d7fbe | ||
|
|
6de8d2684a | ||
|
|
9763a43943 | ||
|
|
60edbcd5f0 | ||
|
|
0ae1e40d79 | ||
|
|
34d931573c | ||
|
|
721365578a | ||
|
|
7be02318e0 | ||
|
|
88db79188c | ||
|
|
8a0329b23d | ||
|
|
02ebe59d2f | ||
|
|
6e8e053b88 | ||
|
|
fc3056b0e0 | ||
|
|
4274a8ed68 | ||
|
|
8b16cd1b36 | ||
|
|
5148e27448 | ||
|
|
608e55c01f | ||
|
|
b8963d272a | ||
|
|
beaaed6613 | ||
|
|
6bbd8c9b16 | ||
|
|
872ffa02ce | ||
|
|
b933202694 | ||
|
|
49cf0c8a9a | ||
|
|
83ccf4928f | ||
|
|
28b0d34bff | ||
|
|
0a0837ea02 | ||
|
|
a0aa350a08 | ||
|
|
decfcb6c27 | ||
|
|
730913bec4 | ||
|
|
f8f037196e | ||
|
|
e2ffeab8fa | ||
|
|
04d834187b | ||
|
|
33b2a94d90 | ||
|
|
ce3b024fea | ||
|
|
a02aa7586b | ||
|
|
d5107f2ef6 | ||
|
|
5b63b0b398 | ||
|
|
fc577241bd | ||
|
|
bb8a0e596c | ||
|
|
2a63b703f9 | ||
|
|
bfeff78164 | ||
|
|
4826289020 | ||
|
|
0aebf37ef8 | ||
|
|
d1a09d0b95 | ||
|
|
7b00003958 | ||
|
|
4483bb147c | ||
|
|
ef31c0c0da | ||
|
|
76c885f080 | ||
|
|
f16e93bd3a | ||
|
|
05d2a96900 | ||
|
|
9c70c35669 | ||
|
|
9d54c41a2b | ||
|
|
3464fbb2e8 | ||
|
|
d51d6517be | ||
|
|
5f6cc1281e | ||
|
|
035fc69060 | ||
|
|
34baf44534 | ||
|
|
c3448033de | ||
|
|
aee9b6a951 | ||
|
|
75e5bec962 | ||
|
|
59c269c8d0 | ||
|
|
541022cdc3 | ||
|
|
527521328f | ||
|
|
917b89e44f | ||
|
|
87862f3e23 | ||
|
|
10eed05d87 | ||
|
|
f5802fee31 | ||
|
|
cf9c8cbb4f | ||
|
|
f199ecf8e9 | ||
|
|
3bdd551d40 | ||
|
|
4a7936a51d | ||
|
|
76e00c2432 | ||
|
|
b46f3bf2c4 | ||
|
|
f7b4b782bf | ||
|
|
60c535e861 | ||
|
|
d59c522f7f | ||
|
|
9f798559cf | ||
|
|
f939e59463 | ||
|
|
50e89ad98b | ||
|
|
c0c5978028 | ||
|
|
abbeed394e | ||
|
|
f5b8c15388 | ||
|
|
f53b6b550f | ||
|
|
00e55b1874 | ||
|
|
90954dac49 | ||
|
|
6217523cc8 | ||
|
|
27ccd3dfa8 | ||
|
|
235f4f10ef | ||
|
|
945e5a2dc3 | ||
|
|
4b6a2685d0 | ||
|
|
e76b6c3bde | ||
|
|
4630d175d7 | ||
|
|
27055b96e3 | ||
|
|
0ef96c0bca | ||
|
|
b2be4a7d67 | ||
|
|
a70df067bc | ||
|
|
2d92b08404 | ||
|
|
4bbc57b0dc | ||
|
|
756c14d988 | ||
|
|
b3b55210f7 | ||
|
|
58093a9438 | ||
|
|
ed33dd2127 | ||
|
|
d4f9c97cca | ||
|
|
f731c1080d | ||
|
|
fd18185ef0 | ||
|
|
0efbbed5e2 | ||
|
|
bad350e49b | ||
|
|
172b93d07f | ||
|
|
ade8c162cd | ||
|
|
79e634316d | ||
|
|
dfba6c7c91 | ||
|
|
e06a77af28 | ||
|
|
74973e73e6 | ||
|
|
ac07ac5234 | ||
|
|
f4880d0519 | ||
|
|
375f992a0c | ||
|
|
ae1c5342f2 | ||
|
|
97ccb7df94 | ||
|
|
a818199b5a | ||
|
|
6fe1fa3455 | ||
|
|
aab95444a8 | ||
|
|
40f28be3b4 | ||
|
|
911d442340 | ||
|
|
d5594b03e3 | ||
|
|
89f1ddf4d7 | ||
|
|
6cfd4637db | ||
|
|
8803e11945 | ||
|
|
b1ca2cc2b6 | ||
|
|
9a8f3d7bad | ||
|
|
9d0e762f36 | ||
|
|
abf4cd71ba | ||
|
|
d66270eef0 | ||
|
|
07ecd13554 | ||
|
|
f1ff88f452 | ||
|
|
d92272ffa0 | ||
|
|
dfa43f3c5a | ||
|
|
259c5ef3d0 | ||
|
|
a1b59d4545 | ||
|
|
58a61051b9 | ||
|
|
51777c3f33 | ||
|
|
3767e9fae9 | ||
|
|
4bbfc8ccc5 | ||
|
|
531b8214c0 | ||
|
|
5cab618bf7 | ||
|
|
3380f4d11c | ||
|
|
4bf030993a | ||
|
|
f65f949a36 | ||
|
|
2864abd8c2 | ||
|
|
9bd2cb3c7e | ||
|
|
35cd277fcf | ||
|
|
57b1932b5e | ||
|
|
e6818023a3 | ||
|
|
35a541f99b | ||
|
|
5fb00a947c | ||
|
|
6288f679b9 | ||
|
|
e766759b8c | ||
|
|
c1d28381e8 | ||
|
|
2fa8371bae | ||
|
|
a1cfdf1a5b | ||
|
|
e9c7f5d664 | ||
|
|
c85f12fe2c | ||
|
|
13e5644c89 | ||
|
|
eac029aef4 | ||
|
|
a5195920fa | ||
|
|
5676a13290 | ||
|
|
d11f0e864e | ||
|
|
df83fcc5b9 | ||
|
|
f21c756793 | ||
|
|
4b07ee2fa8 | ||
|
|
ae3a39ee65 | ||
|
|
e9f5bd4ac1 | ||
|
|
1f4ad732fd | ||
|
|
5637d37ee1 | ||
|
|
c370da2fef | ||
|
|
d168209721 | ||
|
|
ca0468b8d5 | ||
|
|
039d26feeb | ||
|
|
10e7b66f38 | ||
|
|
4bb47d7e01 | ||
|
|
ec80c2b9db | ||
|
|
a89418e33b | ||
|
|
0d88ff8dae | ||
|
|
4bdf9bff3a | ||
|
|
7fbb8838e7 | ||
|
|
366ea63209 | ||
|
|
6c0ad7fe1a | ||
|
|
ef9c90a43a | ||
|
|
239214ef92 | ||
|
|
b0057b130e | ||
|
|
d64c043838 | ||
|
|
dd3599f5b3 | ||
|
|
0d56127758 | ||
|
|
ea043517c5 | ||
|
|
b84d9c5d55 | ||
|
|
6c628afe5d | ||
|
|
abc99c7e69 | ||
|
|
fe25cd3bec | ||
|
|
2eb51edfb6 | ||
|
|
715d564028 | ||
|
|
989b704efc | ||
|
|
0bfaeb8521 | ||
|
|
3db00534c2 | ||
|
|
b713b324f9 | ||
|
|
6512dbae1c | ||
|
|
3afda71349 | ||
|
|
568e5a9bb8 | ||
|
|
89e56ae279 | ||
|
|
339ac05443 | ||
|
|
72cfa683cf | ||
|
|
3a6b9f04f9 | ||
|
|
59f24df294 | ||
|
|
5c559af936 | ||
|
|
bb80505b76 | ||
|
|
a560f6e9f6 | ||
|
|
95ae981698 | ||
|
|
969eb67217 | ||
|
|
0dfebbad9d | ||
|
|
f87f4bd8cc | ||
|
|
352caa85da | ||
|
|
8f61e9876f | ||
|
|
fd19bb7cd5 | ||
|
|
0c2e9137a2 | ||
|
|
bf5a25a96f | ||
|
|
aa84f21fde | ||
|
|
b9de2b4b58 | ||
|
|
9754f2d1c5 | ||
|
|
f66fc06b4f | ||
|
|
79ceb56c60 | ||
|
|
7605df1bd9 | ||
|
|
b91ec48178 | ||
|
|
3c2f144795 | ||
|
|
0271337f8e | ||
|
|
630a71c46c | ||
|
|
150329dd4a | ||
|
|
59d7bce518 | ||
|
|
3c1e3cd38e | ||
|
|
a2eb0bf9fe | ||
|
|
5d48ecf86a | ||
|
|
00d09aa01e | ||
|
|
9afdc55416 | ||
|
|
2c942c8809 | ||
|
|
c15acc4ce3 | ||
|
|
b056610eaa | ||
|
|
8eb9fb1834 | ||
|
|
7ec518b41c | ||
|
|
dc15914a85 | ||
|
|
3b22f59988 | ||
|
|
afdab0300e | ||
|
|
26533c47e7 | ||
|
|
df3aeed291 | ||
|
|
867ba7b68f | ||
|
|
1679a3f844 | ||
|
|
1611049623 | ||
|
|
7d195367a8 | ||
|
|
88a4f25689 | ||
|
|
161dee89ec | ||
|
|
34af33607b | ||
|
|
5bb188a822 | ||
|
|
60bb6f105d | ||
|
|
df4680ee09 | ||
|
|
6aaab09601 | ||
|
|
5da42575fd | ||
|
|
fe256d6a62 | ||
|
|
5f175141e1 | ||
|
|
983e2df065 | ||
|
|
16d5a70c08 | ||
|
|
b7e2d7fb8e | ||
|
|
fb5f7a336d | ||
|
|
78dc5f4bf4 | ||
|
|
45dbf5393f | ||
|
|
9fed1cde25 | ||
|
|
5c7b175e90 | ||
|
|
30b29de8ce | ||
|
|
a5f9331023 | ||
|
|
d8b9d8431e | ||
|
|
91a2ce2b3f | ||
|
|
4da1871567 | ||
|
|
e809f77461 | ||
|
|
e96d23cc3f | ||
|
|
c34e2ab3e1 | ||
|
|
820519b4f7 | ||
|
|
34688852a4 | ||
|
|
151f28081a | ||
|
|
213a64b1ff | ||
|
|
f259d81249 | ||
|
|
589761bfd9 | ||
|
|
18fde86a20 | ||
|
|
ba28bc94d3 | ||
|
|
da19a07943 | ||
|
|
ecc500fc91 | ||
|
|
c22ac1e60a | ||
|
|
55d9aa2a4c | ||
|
|
1d391e68e5 | ||
|
|
0429c44d18 | ||
|
|
2c1bcaafc1 | ||
|
|
35891c74cd | ||
|
|
2ca6e67b37 | ||
|
|
6e72be54cb | ||
|
|
07edb998e4 | ||
|
|
3e52f804a7 | ||
|
|
75b7583832 | ||
|
|
d754eb74f7 | ||
|
|
60252267d5 | ||
|
|
b25af641e2 | ||
|
|
e7c3f8bf47 | ||
|
|
4c1dca73c4 | ||
|
|
0bbb6b91fe | ||
|
|
ee93d9b495 | ||
|
|
bf8ac214a1 | ||
|
|
9c7b34d5e6 | ||
|
|
76c0fa2fe2 | ||
|
|
ac3a17b178 | ||
|
|
c76b527b93 | ||
|
|
ded4f95537 | ||
|
|
8272386733 | ||
|
|
33988ed3fb | ||
|
|
411b8e3cb6 | ||
|
|
916da16523 | ||
|
|
d165c081f7 | ||
|
|
992de7d66e | ||
|
|
46ab7bbcbe | ||
|
|
b04bced37f | ||
|
|
13335cadc6 | ||
|
|
b864791fa6 | ||
|
|
6614b56298 | ||
|
|
02c3894fc9 | ||
|
|
68f7dc9512 | ||
|
|
18d1bc56fd | ||
|
|
1e4d07a52c | ||
|
|
1fc579e907 | ||
|
|
827c4e31ee | ||
|
|
c2a1ed926e | ||
|
|
4f86c117c3 | ||
|
|
93817ba92f | ||
|
|
18153e0fcc | ||
|
|
3123f6444f | ||
|
|
4e97a3b3d5 | ||
|
|
134c43ad9e | ||
|
|
e74b4b35b9 | ||
|
|
16e7194dfe | ||
|
|
932b0ccf24 | ||
|
|
3e5c7f62d0 | ||
|
|
bf19f5b9c0 | ||
|
|
08a879bbb1 | ||
|
|
cd514285d9 | ||
|
|
782bb11894 | ||
|
|
355689ed19 | ||
|
|
75614fb13c | ||
|
|
5c4a864680 | ||
|
|
eaeff891d6 | ||
|
|
f0ab40d748 | ||
|
|
e497af4c26 | ||
|
|
f860f57363 | ||
|
|
02bf5ada89 | ||
|
|
d3b578fe8f | ||
|
|
d29d910ac6 | ||
|
|
e7b41f9a4c | ||
|
|
dd0aed4614 | ||
|
|
b9b4f2bb7f | ||
|
|
3533d2a2cc | ||
|
|
26d9ef5398 | ||
|
|
a0f840bcf8 | ||
|
|
33d2a77e37 | ||
|
|
80e00a80d5 | ||
|
|
a3d5479878 | ||
|
|
a49dc04f5d | ||
|
|
d1c0c9739d | ||
|
|
7415b07586 | ||
|
|
023663b268 | ||
|
|
3883c509b9 | ||
|
|
18f34b4f83 | ||
|
|
caed86d846 | ||
|
|
459e36c027 | ||
|
|
725f8571bb | ||
|
|
b7c7c0e862 | ||
|
|
3f671b918a | ||
|
|
9492363b22 | ||
|
|
3ee144459f | ||
|
|
c0c80c0fdf | ||
|
|
7c80b61666 | ||
|
|
d128f3e14e | ||
|
|
4498b89ac4 | ||
|
|
e576a58ead | ||
|
|
eb4375258e | ||
|
|
0cbc2001e2 | ||
|
|
6bf5dbabee | ||
|
|
6a89646e66 | ||
|
|
f3234a6b5e | ||
|
|
73a8c302e9 | ||
|
|
989f2d3001 | ||
|
|
2badcfa546 | ||
|
|
384e14b32d | ||
|
|
016e743653 | ||
|
|
00adf3631a | ||
|
|
09aef18999 | ||
|
|
1d86aac338 | ||
|
|
80173634a0 | ||
|
|
c9598b674c | ||
|
|
2a588d1e9a | ||
|
|
5a6c06c8a3 | ||
|
|
b2ef4e9619 | ||
|
|
9e9d6e45b4 | ||
|
|
6752457ad8 | ||
|
|
ddcb5cd4d3 | ||
|
|
a54b2db81b | ||
|
|
6740124364 | ||
|
|
157731e4f8 | ||
|
|
2dd1496ef4 | ||
|
|
d1e4e72693 | ||
|
|
77e8143290 | ||
|
|
7f791d4919 | ||
|
|
d7e0468776 | ||
|
|
f6c611bbba | ||
|
|
0990ac4fc1 | ||
|
|
e91f8f693b | ||
|
|
2a7dbda133 | ||
|
|
793e542312 | ||
|
|
240269eb25 | ||
|
|
c744dc8cc3 | ||
|
|
d596bdb454 | ||
|
|
bec54b4283 | ||
|
|
061b88f5b5 | ||
|
|
8704eff632 | ||
|
|
fb16f25b07 | ||
|
|
e8057a5c8a | ||
|
|
3c5edb6171 | ||
|
|
e36a191240 | ||
|
|
ecdfd65f50 | ||
|
|
4294081abb | ||
|
|
5218543c58 | ||
|
|
d8332a27e5 | ||
|
|
673658dfd2 | ||
|
|
6528d3d7da | ||
|
|
16af479b83 | ||
|
|
13187de97d | ||
|
|
32850f6770 | ||
|
|
4a7f4bde4a | ||
|
|
0010119440 | ||
|
|
91065a6168 | ||
|
|
efa8d5c575 | ||
|
|
04998d0215 | ||
|
|
d0efa5d3fe | ||
|
|
c87e72e08e | ||
|
|
efb82847cb | ||
|
|
f37e267a5e | ||
|
|
69928219a3 | ||
|
|
fdf8845a2f | ||
|
|
4073a7abc3 | ||
|
|
ffd9a34cf5 | ||
|
|
07226c6d21 | ||
|
|
b1bc7c1fc2 | ||
|
|
1b33f0cea9 | ||
|
|
8ece3b00f5 | ||
|
|
c9c58b65a6 | ||
|
|
66becbc4cc | ||
|
|
5b8612c919 | ||
|
|
430c22e06e | ||
|
|
76b62eda3a | ||
|
|
bc983162f3 | ||
|
|
4922598aee | ||
|
|
b2f8bb9990 | ||
|
|
9ee92fb9e9 | ||
|
|
981bf1d56f | ||
|
|
d2c2503cfa | ||
|
|
2a4caa856e | ||
|
|
157962e42a | ||
|
|
16db28060c | ||
|
|
712424c339 | ||
|
|
15c56dfcb8 | ||
|
|
b98ad47618 | ||
|
|
4778c5f5e8 | ||
|
|
d041671dc5 | ||
|
|
5cab65d197 | ||
|
|
b5bf627fb1 | ||
|
|
6104150b77 | ||
|
|
a13bae2f39 | ||
|
|
fd80e98207 | ||
|
|
f43b95f001 | ||
|
|
38802d3522 | ||
|
|
9f7813622d | ||
|
|
75d67207aa | ||
|
|
e596a8f731 | ||
|
|
5b0cc73792 | ||
|
|
2163d4465f | ||
|
|
8becf45714 | ||
|
|
853ead26ca | ||
|
|
0ccb6cb873 | ||
|
|
e46ff3de8b | ||
|
|
a02e08a879 | ||
|
|
109d7d87bd | ||
|
|
3df740702c | ||
|
|
06bb6f7bff | ||
|
|
e33738a876 | ||
|
|
f887f5dca3 | ||
|
|
257c16690a | ||
|
|
7114e88992 | ||
|
|
de7e869ca9 | ||
|
|
eaee9c9522 | ||
|
|
bee11a6d41 | ||
|
|
647e44147f | ||
|
|
4a5d46915d | ||
|
|
8ad19585cb | ||
|
|
951a33fae9 | ||
|
|
bcf174cd1e | ||
|
|
1e82909f58 | ||
|
|
33e83b8414 | ||
|
|
592c56cb20 | ||
|
|
710528c01a | ||
|
|
03cadc2604 | ||
|
|
dd73bb95a4 | ||
|
|
40e0a55378 | ||
|
|
269884d9f3 | ||
|
|
fcd548c313 | ||
|
|
140a8b6804 | ||
|
|
b5378c1296 | ||
|
|
1445d6ea8c | ||
|
|
5385431051 | ||
|
|
f14f4498fb | ||
|
|
fc2786f5e8 | ||
|
|
174dbb5e74 | ||
|
|
68517c15f2 | ||
|
|
11ee142e4b | ||
|
|
1e1d047e07 | ||
|
|
62f1e39e6e | ||
|
|
05756da495 | ||
|
|
fa35b2a66f | ||
|
|
d2094e2b68 | ||
|
|
075b2df738 | ||
|
|
ec3c31a106 | ||
|
|
e2183c2214 | ||
|
|
4994064e6e | ||
|
|
a40b9f4054 | ||
|
|
ee8d65977d | ||
|
|
b4ea11e55e | ||
|
|
b8d2ef1eb5 | ||
|
|
0efeffeaa3 | ||
|
|
7a86d272bb | ||
|
|
4d780904d1 | ||
|
|
0a6fa65075 | ||
|
|
6440220640 | ||
|
|
4350c9a72b | ||
|
|
77feae0ab9 | ||
|
|
1261696ba2 | ||
|
|
6b35a0a527 | ||
|
|
fd33ff81c9 | ||
|
|
178b1e8bb0 | ||
|
|
9d50f03cb1 | ||
|
|
8c1688657a | ||
|
|
833cb99f41 | ||
|
|
bd5d84abcd | ||
|
|
d451ba9b6e | ||
|
|
14bd02a569 | ||
|
|
4beace1bb0 | ||
|
|
6996dfcd3b | ||
|
|
42c46d7d5c | ||
|
|
9b31ce83c5 | ||
|
|
cb5250527b | ||
|
|
f0b73fd696 | ||
|
|
df5684a9f8 | ||
|
|
b3f724c799 | ||
|
|
a7be6504a2 | ||
|
|
1da5357df6 | ||
|
|
92e1847c59 | ||
|
|
0500994def | ||
|
|
da911bfeb8 | ||
|
|
578d673a4e | ||
|
|
c8e58a1e5b | ||
|
|
d477874535 | ||
|
|
da79386cc3 | ||
|
|
a4ba6d1444 | ||
|
|
ef28459b61 | ||
|
|
1ff8c908b8 | ||
|
|
e966ef96e5 | ||
|
|
b05b38b269 | ||
|
|
8e1f1ff2e6 | ||
|
|
680d6c20ca | ||
|
|
c886e7949e | ||
|
|
e0b972f6d6 | ||
|
|
25daa9f2da | ||
|
|
d0fb5c3bd5 | ||
|
|
520b12e56b | ||
|
|
5c8ffe961e | ||
|
|
7983e82b60 | ||
|
|
77d35b61a9 | ||
|
|
285a97aaf8 | ||
|
|
ad29f2477e | ||
|
|
1072d1306b | ||
|
|
b8eda40937 | ||
|
|
2719ae5df2 | ||
|
|
68ee2bdcdc | ||
|
|
da654fdff5 | ||
|
|
d7f9d5a66f | ||
|
|
c4fb7b7928 | ||
|
|
43a791db65 | ||
|
|
217311211a | ||
|
|
ca55890ad2 | ||
|
|
d6ecf5b8a9 | ||
|
|
e52edde11f | ||
|
|
2e514735ec | ||
|
|
3d32c30d2d | ||
|
|
05235f8385 | ||
|
|
cd28a75c86 | ||
|
|
34075738ea | ||
|
|
88c0b8a8f0 | ||
|
|
e8bbc117e1 | ||
|
|
b99f45874f | ||
|
|
0dfa378e38 | ||
|
|
3da0c07bcd | ||
|
|
2196b53075 | ||
|
|
a8340f37bb | ||
|
|
7b1710ee63 | ||
|
|
38b7d9724e | ||
|
|
2b1ed49e9a | ||
|
|
017cf9e464 | ||
|
|
781f0c843e | ||
|
|
e2bf474332 | ||
|
|
7e2f1c9a8b | ||
|
|
8e798dde48 | ||
|
|
c05ae6e94c | ||
|
|
ff28ea8fa8 | ||
|
|
7914e89212 | ||
|
|
558ff90e27 | ||
|
|
ee69653a83 | ||
|
|
95339a8338 | ||
|
|
39b1435725 | ||
|
|
b1d3e258bd | ||
|
|
6ff7fa74e2 | ||
|
|
91305262f1 | ||
|
|
6d16b68f11 | ||
|
|
f22e4f1cc7 | ||
|
|
2851d12357 | ||
|
|
73968e4277 | ||
|
|
7a6ecd86c6 | ||
|
|
1ff2a08d19 | ||
|
|
d1efd62e7b | ||
|
|
366c95cd3c | ||
|
|
7e03f3958e | ||
|
|
e069184721 | ||
|
|
5182441cb3 | ||
|
|
0de55a8ff5 | ||
|
|
0900d7c764 | ||
|
|
8540e09ba7 | ||
|
|
6e301601f9 | ||
|
|
1bf0eab2d9 | ||
|
|
d560f656f4 | ||
|
|
0bc256aa23 | ||
|
|
ebc073a52e | ||
|
|
23503dc439 | ||
|
|
1f4985c7dd | ||
|
|
ed88d9e10d | ||
|
|
906196bac3 | ||
|
|
cb9751be04 | ||
|
|
09e03d579b | ||
|
|
9fcca2698e | ||
|
|
bba8cd9b58 | ||
|
|
c8bb30289a | ||
|
|
69f56c1eb7 | ||
|
|
adc8e23356 | ||
|
|
88005c6603 | ||
|
|
33ee4c36b4 | ||
|
|
1a142b7fe6 | ||
|
|
90373806c0 | ||
|
|
a47634bf49 | ||
|
|
e03e58323b | ||
|
|
ab1e31c0e7 | ||
|
|
aa5505d693 | ||
|
|
74f8f687cf | ||
|
|
d54500bad5 | ||
|
|
4966b4d58d | ||
|
|
b75a4667c2 | ||
|
|
14579a9320 | ||
|
|
1d92eff974 | ||
|
|
91274267e5 | ||
|
|
c49f0ede16 | ||
|
|
42a0f452b1 | ||
|
|
c24ab9831a | ||
|
|
506a68ee6a | ||
|
|
1291f792ab | ||
|
|
51cfb1903d | ||
|
|
1b1c15694a | ||
|
|
251f1941a9 | ||
|
|
625617bb20 | ||
|
|
f80f161886 | ||
|
|
4b4889d5f2 | ||
|
|
fee34ba257 | ||
|
|
c29ab25dd2 | ||
|
|
e0308a11c9 | ||
|
|
a738998c2c | ||
|
|
6be22c474d | ||
|
|
da19743ba5 |
43
.github/workflows/daedalus-docker.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: daedalus-docker-build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- .github/workflows/daedalus-docker.yml
|
||||
- 'apps/daedalus_client/**'
|
||||
pull_request:
|
||||
types: [ opened, synchronize ]
|
||||
paths:
|
||||
- .github/workflows/daedalus-docker.yml
|
||||
- 'apps/daedalus_client/**'
|
||||
merge_group:
|
||||
types: [ checks_requested ]
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Fetch docker metadata
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: ghcr.io/modrinth/daedalus
|
||||
-
|
||||
name: Login to GitHub Images
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
file: ./apps/daedalus_client/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
52
.github/workflows/daedalus-run.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Run Meta
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
run-docker:
|
||||
if: github.repository_owner == 'modrinth'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Pull Docker image from GHCR
|
||||
run: docker pull ghcr.io/modrinth/daedalus:main
|
||||
|
||||
- name: Run Docker container
|
||||
env:
|
||||
BASE_URL: ${{ secrets.BASE_URL }}
|
||||
S3_ACCESS_TOKEN: ${{ secrets.S3_ACCESS_TOKEN }}
|
||||
S3_SECRET: ${{ secrets.S3_SECRET }}
|
||||
S3_URL: ${{ secrets.S3_URL }}
|
||||
S3_REGION: ${{ secrets.S3_REGION }}
|
||||
S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
|
||||
CLOUDFLARE_INTEGRATION: ${{ secrets.CLOUDFLARE_INTEGRATION }}
|
||||
CLOUDFLARE_TOKEN: ${{ secrets.CLOUDFLARE_TOKEN }}
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
run: |
|
||||
docker run \
|
||||
--name daedalus \
|
||||
-e RUST_LOG=warn,daedalus_client=trace \
|
||||
-e BASE_URL=$BASE_URL \
|
||||
-e S3_ACCESS_TOKEN=$S3_ACCESS_TOKEN \
|
||||
-e S3_SECRET=$S3_SECRET \
|
||||
-e S3_URL=$S3_URL \
|
||||
-e S3_REGION=$S3_REGION \
|
||||
-e S3_BUCKET_NAME=$S3_BUCKET_NAME \
|
||||
-e CLOUDFLARE_INTEGRATION=$CLOUDFLARE_INTEGRATION \
|
||||
-e CLOUDFLARE_TOKEN=$CLOUDFLARE_TOKEN \
|
||||
-e CLOUDFLARE_ZONE_ID=$CLOUDFLARE_ZONE_ID \
|
||||
ghcr.io/modrinth/daedalus:main
|
||||
8
.github/workflows/frontend-pages.yml
vendored
@@ -1,6 +1,9 @@
|
||||
name: Deploy frontend
|
||||
name: Clear pages cache
|
||||
|
||||
on: push
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- prod
|
||||
|
||||
jobs:
|
||||
wait:
|
||||
@@ -16,7 +19,6 @@ jobs:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: '9ddae624c98677d68d93df6e524a6061'
|
||||
project: 'frontend'
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
commitHash: ${{ steps.push-changes.outputs.commit-hash }}
|
||||
- name: Purge cache
|
||||
if: github.ref == 'refs/heads/prod'
|
||||
|
||||
46
.github/workflows/labrinth-docker.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: docker-build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- .github/workflows/labrinth-docker.yml
|
||||
- 'apps/labrinth/**'
|
||||
pull_request:
|
||||
types: [ opened, synchronize ]
|
||||
paths:
|
||||
- .github/workflows/labrinth-docker.yml
|
||||
- 'apps/labrinth/**'
|
||||
merge_group:
|
||||
types: [ checks_requested ]
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./apps/labrinth
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Fetch docker metadata
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: ghcr.io/modrinth/labrinth
|
||||
-
|
||||
name: Login to GitHub Images
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: ./apps/labrinth
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
@@ -62,9 +62,19 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
|
||||
- name: Start docker compose
|
||||
run: docker compose up -d
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres
|
||||
6
.gitignore
vendored
@@ -55,3 +55,9 @@ generated
|
||||
|
||||
# app testing dir
|
||||
app-playground-data/*
|
||||
|
||||
# soley because i need the PORT to be 3002 due to WSL stuff
|
||||
.env
|
||||
apps/frontend/.env
|
||||
|
||||
.astro
|
||||
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
15
.idea/daedalus.iml
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/daedalus_client_new/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/apps/daedalus_client/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/packages/daedalus/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
7
.idea/discord.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DiscordProjectSettings">
|
||||
<option name="show" value="PROJECT_FILES" />
|
||||
<option name="description" value="" />
|
||||
</component>
|
||||
</project>
|
||||
26
.idea/libraries/KotlinJavaRuntime.xml
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
<component name="libraryTable">
|
||||
<library name="KotlinJavaRuntime" type="repository">
|
||||
<properties maven-id="org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0" />
|
||||
<CLASSES>
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib/1.8.0/kotlin-stdlib-1.8.0.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-common/1.8.0/kotlin-stdlib-common-1.8.0.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/annotations/13.0/annotations-13.0.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0.jar!/" />
|
||||
</CLASSES>
|
||||
<JAVADOC>
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0-javadoc.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib/1.8.0/kotlin-stdlib-1.8.0-javadoc.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-common/1.8.0/kotlin-stdlib-common-1.8.0-javadoc.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/annotations/13.0/annotations-13.0-javadoc.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0-javadoc.jar!/" />
|
||||
</JAVADOC>
|
||||
<SOURCES>
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.8.0/kotlin-stdlib-jdk8-1.8.0-sources.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib/1.8.0/kotlin-stdlib-1.8.0-sources.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-common/1.8.0/kotlin-stdlib-common-1.8.0-sources.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/annotations/13.0/annotations-13.0-sources.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.8.0/kotlin-stdlib-jdk7-1.8.0-sources.jar!/" />
|
||||
</SOURCES>
|
||||
</library>
|
||||
</component>
|
||||
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/daedalus.iml" filepath="$PROJECT_DIR$/.idea/daedalus.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
4424
Cargo.lock
generated
@@ -3,7 +3,10 @@ resolver = '2'
|
||||
members = [
|
||||
'./packages/app-lib',
|
||||
'./apps/app-playground',
|
||||
'./apps/app'
|
||||
'./apps/app',
|
||||
'./apps/labrinth',
|
||||
'./apps/daedalus_client',
|
||||
'./packages/daedalus',
|
||||
]
|
||||
|
||||
# Optimize for speed and reduce size on release builds
|
||||
|
||||
@@ -22,7 +22,7 @@ This repository contains two primary packages. For detailed development informat
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Before submitting any contributions, please read our [contributing guidelines](https://support.modrinth.com/en/articles/8802215-contributing-to-modrinth).
|
||||
We welcome contributions! Before submitting any contributions, please read our [contributing guidelines](https://docs.modrinth.com/contributing/getting-started/).
|
||||
|
||||
If you plan to fork this repository for your own purposes, please review our [copying guidelines](COPYING.md).
|
||||
|
||||
|
||||
@@ -473,7 +473,7 @@ async function purgeCache() {
|
||||
:min="8"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
unit="mb"
|
||||
unit="MB"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@modrinth/app-playground",
|
||||
"scripts": {
|
||||
"build": "cargo build --release",
|
||||
"lint": "cargo fmt --check && cargo clippy -- -D warnings",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
|
||||
"fix": "cargo fmt && cargo clippy --fix",
|
||||
"dev": "cargo run",
|
||||
"test": "cargo test"
|
||||
|
||||
@@ -28,7 +28,7 @@ tauri-plugin-single-instance = { version = "2.0.0-rc" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1.0"
|
||||
futures = "0.3"
|
||||
daedalus = "0.2.3"
|
||||
daedalus = { path = "../../packages/daedalus" }
|
||||
chrono = "0.4.26"
|
||||
|
||||
dirs = "5.0.1"
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"identifier": "plugins",
|
||||
"description": "",
|
||||
"local": true,
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-confirm",
|
||||
|
||||
3314
apps/app/gen/schemas/windows-schema.json
Normal file
@@ -5,7 +5,7 @@
|
||||
"tauri": "tauri",
|
||||
"dev": "tauri dev",
|
||||
"test": "cargo test",
|
||||
"lint": "cargo fmt --check && cargo clippy -- -D warnings",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings",
|
||||
"fix": "cargo fmt && cargo clippy --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modrinth/app-frontend": "workspace:*",
|
||||
"@modrinth/app-lib": "workspace:*"
|
||||
"@modrinth/app-lib": "workspace:*",
|
||||
"@modrinth/daedalus": "workspace:*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,7 +317,7 @@ fn main() {
|
||||
MessageDialog::new()
|
||||
.set_type(MessageType::Error)
|
||||
.set_title("Initialization error")
|
||||
.set_text("Your Microsoft Edge WebView2 installation is corrupt.\n\nMicrosoft Edge WebView2 is required to run Modrinth App.\n\nLearn how to repair it at https://docs.modrinth.com/faq/app/webview2")
|
||||
.set_text("Your Microsoft Edge WebView2 installation is corrupt.\n\nMicrosoft Edge WebView2 is required to run Modrinth App.\n\nLearn how to repair it at https://support.modrinth.com/en/articles/8797765-corrupted-microsoft-edge-webview2-installation")
|
||||
.show_alert()
|
||||
.unwrap();
|
||||
|
||||
|
||||
15
apps/daedalus_client/.env
Normal file
@@ -0,0 +1,15 @@
|
||||
RUST_LOG=warn,daedalus_client=trace
|
||||
|
||||
BASE_URL=http://localhost:9000/meta
|
||||
|
||||
CONCURRENCY_LIMIT=10
|
||||
|
||||
S3_ACCESS_TOKEN=none
|
||||
S3_SECRET=none
|
||||
S3_URL=http://localhost:9000
|
||||
S3_REGION=path-style
|
||||
S3_BUCKET_NAME=meta
|
||||
|
||||
CLOUDFLARE_INTEGRATION=false
|
||||
CLOUDFLARE_TOKEN=none
|
||||
CLOUDFLARE_ZONE_ID=none
|
||||
42
apps/daedalus_client/Cargo.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "daedalus_client"
|
||||
version = "0.2.2"
|
||||
authors = ["Jai A <jai@modrinth.com>"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
daedalus = { path = "../../packages/daedalus" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3.25"
|
||||
dotenvy = "0.15.6"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde-xml-rs = "0.6.0"
|
||||
lazy_static = "1.4.0"
|
||||
thiserror = "1.0"
|
||||
reqwest = { version = "0.12.5", default-features = false, features = [
|
||||
"stream",
|
||||
"json",
|
||||
"rustls-tls-native-roots",
|
||||
] }
|
||||
async_zip = { version = "0.0.17", features = ["full"] }
|
||||
semver = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
bytes = "1.6.0"
|
||||
rust-s3 = { version = "0.33.0", default-features = false, features = [
|
||||
"fail-on-err",
|
||||
"tags",
|
||||
"tokio-rustls-tls",
|
||||
"reqwest",
|
||||
] }
|
||||
dashmap = "5.5.3"
|
||||
sha1_smol = { version = "1.0.0", features = ["std"] }
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
itertools = "0.13.0"
|
||||
tracing-error = "0.2.0"
|
||||
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-futures = { version = "0.2.5", features = ["futures", "tokio"] }
|
||||
21
apps/daedalus_client/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM rust:1.82.0 as build
|
||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
COPY . .
|
||||
RUN cargo build --release --package daedalus_client
|
||||
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
WORKDIR /daedalus_client
|
||||
|
||||
CMD /daedalus/daedalus_client
|
||||
7
apps/daedalus_client/LICENSE
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright © 2024 Rinth, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
9
apps/daedalus_client/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Daedalus
|
||||
|
||||
Daedalus is a powerful tool which queries and generates metadata for the Minecraft (and other games in the future!) game
|
||||
and mod loaders for:
|
||||
- Performance (Serving static files can be easily cached and is extremely quick)
|
||||
- Ease for Launcher Devs (Metadata is served in an easy to query and use format)
|
||||
- Reliability (Provides a versioning system which ensures no breakage with updates)
|
||||
|
||||
Daedalus supports the original Minecraft data and reposting for the Forge, Fabric, Quilt, and NeoForge loaders.
|
||||
17
apps/daedalus_client/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
minio:
|
||||
image: quay.io/minio/minio
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: miniosecret
|
||||
command: server /data --console-address ":9001"
|
||||
|
||||
volumes:
|
||||
minio-data:
|
||||
2880
apps/daedalus_client/library-patches.json
Normal file
13
apps/daedalus_client/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@modrinth/daedalus_client",
|
||||
"scripts": {
|
||||
"build": "cargo build --release",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
|
||||
"fix": "cargo fmt && cargo clippy --fix",
|
||||
"dev": "cargo run",
|
||||
"test": "cargo test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modrinth/daedalus": "workspace:*"
|
||||
}
|
||||
}
|
||||
63
apps/daedalus_client/src/error.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use tracing_error::InstrumentError;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ErrorKind {
|
||||
#[error("Daedalus Error: {0}")]
|
||||
Daedalus(#[from] daedalus::Error),
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("Error while managing asynchronous tasks")]
|
||||
TaskError(#[from] tokio::task::JoinError),
|
||||
#[error("Error while deserializing JSON: {0}")]
|
||||
SerdeJSON(#[from] serde_json::Error),
|
||||
#[error("Error while deserializing XML: {0}")]
|
||||
SerdeXML(#[from] serde_xml_rs::Error),
|
||||
#[error("Failed to validate file checksum at url {url} with hash {hash} after {tries} tries")]
|
||||
ChecksumFailure {
|
||||
hash: String,
|
||||
url: String,
|
||||
tries: u32,
|
||||
},
|
||||
#[error("Unable to fetch {item}")]
|
||||
Fetch { inner: reqwest::Error, item: String },
|
||||
#[error("Error while uploading file to S3: {file}")]
|
||||
S3 {
|
||||
inner: s3::error::S3Error,
|
||||
file: String,
|
||||
},
|
||||
#[error("Error acquiring semaphore: {0}")]
|
||||
Acquire(#[from] tokio::sync::AcquireError),
|
||||
#[error("Tracing error: {0}")]
|
||||
Tracing(#[from] tracing::subscriber::SetGlobalDefaultError),
|
||||
#[error("Zip error: {0}")]
|
||||
Zip(#[from] async_zip::error::ZipError),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub source: tracing_error::TracedError<ErrorKind>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(fmt, "{}", self.source)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Into<ErrorKind>> From<E> for Error {
|
||||
fn from(source: E) -> Self {
|
||||
let error = Into::<ErrorKind>::into(source);
|
||||
|
||||
Self {
|
||||
source: error.in_current_span(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorKind {
|
||||
pub fn as_error(self) -> Error {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
301
apps/daedalus_client/src/fabric.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
use crate::util::{download_file, fetch_json, format_url};
|
||||
use crate::{insert_mirrored_artifact, Error, MirrorArtifact, UploadFile};
|
||||
use daedalus::modded::{Manifest, PartialVersionInfo, DUMMY_REPLACE_STRING};
|
||||
use dashmap::DashMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[tracing::instrument(skip(semaphore, upload_files, mirror_artifacts))]
|
||||
pub async fn fetch_fabric(
|
||||
semaphore: Arc<Semaphore>,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<(), Error> {
|
||||
fetch(
|
||||
daedalus::modded::CURRENT_FABRIC_FORMAT_VERSION,
|
||||
"fabric",
|
||||
"https://meta.fabricmc.net/v2",
|
||||
"https://maven.fabricmc.net/",
|
||||
&[],
|
||||
semaphore,
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(semaphore, upload_files, mirror_artifacts))]
|
||||
pub async fn fetch_quilt(
|
||||
semaphore: Arc<Semaphore>,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<(), Error> {
|
||||
fetch(
|
||||
daedalus::modded::CURRENT_QUILT_FORMAT_VERSION,
|
||||
"quilt",
|
||||
"https://meta.quiltmc.org/v3",
|
||||
"https://maven.quiltmc.org/repository/release/",
|
||||
&[
|
||||
// This version is broken as it contains invalid library coordinates
|
||||
"0.17.5-beta.4",
|
||||
],
|
||||
semaphore,
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[tracing::instrument(skip(semaphore, upload_files, mirror_artifacts))]
|
||||
async fn fetch(
|
||||
format_version: usize,
|
||||
mod_loader: &str,
|
||||
meta_url: &str,
|
||||
maven_url: &str,
|
||||
skip_versions: &[&str],
|
||||
semaphore: Arc<Semaphore>,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<(), Error> {
|
||||
let modrinth_manifest = fetch_json::<Manifest>(
|
||||
&format_url(&format!("{mod_loader}/v{format_version}/manifest.json",)),
|
||||
&semaphore,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
let fabric_manifest = fetch_json::<FabricVersions>(
|
||||
&format!("{meta_url}/versions"),
|
||||
&semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// We check Modrinth's fabric version manifest and compare if the fabric version exists in Modrinth's database
|
||||
// We also check intermediary versions that are newly added to query
|
||||
let (fetch_fabric_versions, fetch_intermediary_versions) =
|
||||
if let Some(modrinth_manifest) = modrinth_manifest {
|
||||
let (mut fetch_versions, mut fetch_intermediary_versions) =
|
||||
(Vec::new(), Vec::new());
|
||||
|
||||
for version in &fabric_manifest.loader {
|
||||
if !modrinth_manifest
|
||||
.game_versions
|
||||
.iter()
|
||||
.any(|x| x.loaders.iter().any(|x| x.id == version.version))
|
||||
&& !skip_versions.contains(&&*version.version)
|
||||
{
|
||||
fetch_versions.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
for version in &fabric_manifest.intermediary {
|
||||
if !modrinth_manifest
|
||||
.game_versions
|
||||
.iter()
|
||||
.any(|x| x.id == version.version)
|
||||
&& fabric_manifest
|
||||
.game
|
||||
.iter()
|
||||
.any(|x| x.version == version.version)
|
||||
{
|
||||
fetch_intermediary_versions.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
(fetch_versions, fetch_intermediary_versions)
|
||||
} else {
|
||||
(
|
||||
fabric_manifest
|
||||
.loader
|
||||
.iter()
|
||||
.filter(|x| !skip_versions.contains(&&*x.version))
|
||||
.collect(),
|
||||
fabric_manifest.intermediary.iter().collect(),
|
||||
)
|
||||
};
|
||||
|
||||
const DUMMY_GAME_VERSION: &str = "1.21";
|
||||
|
||||
if !fetch_intermediary_versions.is_empty() {
|
||||
for x in &fetch_intermediary_versions {
|
||||
insert_mirrored_artifact(
|
||||
&x.maven,
|
||||
None,
|
||||
vec![maven_url.to_string()],
|
||||
false,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
if !fetch_fabric_versions.is_empty() {
|
||||
let fabric_version_manifest_urls = fetch_fabric_versions
|
||||
.iter()
|
||||
.map(|x| {
|
||||
format!(
|
||||
"{}/versions/loader/{}/{}/profile/json",
|
||||
meta_url, DUMMY_GAME_VERSION, x.version
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let fabric_version_manifests = futures::future::try_join_all(
|
||||
fabric_version_manifest_urls
|
||||
.iter()
|
||||
.map(|x| download_file(x, None, &semaphore)),
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|x| serde_json::from_slice(&x))
|
||||
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
|
||||
|
||||
let patched_version_manifests = fabric_version_manifests
|
||||
.into_iter()
|
||||
.map(|mut version_info| {
|
||||
for lib in &mut version_info.libraries {
|
||||
let new_name = lib
|
||||
.name
|
||||
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
|
||||
|
||||
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
|
||||
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
|
||||
lib.url = Some(
|
||||
"https://libraries.minecraft.net/".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// If a library is not intermediary, we add it to mirror artifacts to be mirrored
|
||||
if lib.name == new_name {
|
||||
insert_mirrored_artifact(
|
||||
&new_name,
|
||||
None,
|
||||
vec![lib
|
||||
.url
|
||||
.clone()
|
||||
.unwrap_or_else(|| maven_url.to_string())],
|
||||
false,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
} else {
|
||||
lib.name = new_name;
|
||||
}
|
||||
|
||||
lib.url = Some(format_url("maven/"));
|
||||
}
|
||||
|
||||
version_info.id = version_info
|
||||
.id
|
||||
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
|
||||
version_info.inherits_from = version_info
|
||||
.inherits_from
|
||||
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
|
||||
|
||||
Ok(version_info)
|
||||
})
|
||||
.collect::<Result<Vec<_>, Error>>()?;
|
||||
let serialized_version_manifests = patched_version_manifests
|
||||
.iter()
|
||||
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?;
|
||||
|
||||
serialized_version_manifests
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, bytes)| {
|
||||
let loader = fetch_fabric_versions[index];
|
||||
|
||||
let version_path = format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
loader.version
|
||||
);
|
||||
|
||||
upload_files.insert(
|
||||
version_path,
|
||||
UploadFile {
|
||||
file: bytes,
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if !fetch_fabric_versions.is_empty()
|
||||
|| !fetch_intermediary_versions.is_empty()
|
||||
{
|
||||
let fabric_manifest_path =
|
||||
format!("{mod_loader}/v{format_version}/manifest.json",);
|
||||
|
||||
let loader_versions = daedalus::modded::Version {
|
||||
id: DUMMY_REPLACE_STRING.to_string(),
|
||||
stable: true,
|
||||
loaders: fabric_manifest
|
||||
.loader
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
let version_path = format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
x.version,
|
||||
);
|
||||
|
||||
daedalus::modded::LoaderVersion {
|
||||
id: x.version,
|
||||
url: format_url(&version_path),
|
||||
stable: x.stable,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let manifest = daedalus::modded::Manifest {
|
||||
game_versions: std::iter::once(loader_versions)
|
||||
.chain(fabric_manifest.game.into_iter().map(|x| {
|
||||
daedalus::modded::Version {
|
||||
id: x.version,
|
||||
stable: x.stable,
|
||||
loaders: vec![],
|
||||
}
|
||||
}))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
upload_files.insert(
|
||||
fabric_manifest_path,
|
||||
UploadFile {
|
||||
file: bytes::Bytes::from(serde_json::to_vec(&manifest)?),
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct FabricVersions {
|
||||
pub loader: Vec<FabricLoaderVersion>,
|
||||
pub game: Vec<FabricGameVersion>,
|
||||
#[serde(alias = "hashed")]
|
||||
pub intermediary: Vec<FabricIntermediaryVersion>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct FabricLoaderVersion {
|
||||
// pub separator: String,
|
||||
// pub build: u32,
|
||||
// pub maven: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub stable: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct FabricIntermediaryVersion {
|
||||
pub maven: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct FabricGameVersion {
|
||||
pub version: String,
|
||||
pub stable: bool,
|
||||
}
|
||||
792
apps/daedalus_client/src/forge.rs
Normal file
@@ -0,0 +1,792 @@
|
||||
use crate::util::{download_file, fetch_json, fetch_xml, format_url};
|
||||
use crate::{insert_mirrored_artifact, Error, MirrorArtifact, UploadFile};
|
||||
use chrono::{DateTime, Utc};
|
||||
use daedalus::get_path_from_artifact;
|
||||
use daedalus::modded::PartialVersionInfo;
|
||||
use dashmap::DashMap;
|
||||
use futures::io::Cursor;
|
||||
use indexmap::IndexMap;
|
||||
use itertools::Itertools;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[tracing::instrument(skip(semaphore, upload_files, mirror_artifacts))]
|
||||
pub async fn fetch_forge(
|
||||
semaphore: Arc<Semaphore>,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<(), Error> {
|
||||
let forge_manifest = fetch_json::<IndexMap<String, Vec<String>>>(
|
||||
"https://files.minecraftforge.net/net/minecraftforge/forge/maven-metadata.json",
|
||||
&semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut format_version = 0;
|
||||
|
||||
let forge_versions = forge_manifest.into_iter().flat_map(|(game_version, versions)| versions.into_iter().map(|loader_version| {
|
||||
// Forge versions can be in these specific formats:
|
||||
// 1.10.2-12.18.1.2016-failtests
|
||||
// 1.9-12.16.0.1886
|
||||
// 1.9-12.16.0.1880-1.9
|
||||
// 1.14.4-28.1.30
|
||||
// This parses them to get the actual Forge version. Ex: 1.15.2-31.1.87 -> 31.1.87
|
||||
let version_split = loader_version.split('-').nth(1).unwrap_or(&loader_version).to_string();
|
||||
|
||||
// Forge has 3 installer formats:
|
||||
// - Format 0 (Unsupported ATM): Forge Legacy (pre-1.5.2). Uses Binary Patch method to install
|
||||
// To install: Download patch, download minecraft client JAR. Combine patch and client JAR and delete META-INF/.
|
||||
// (pre-1.3-2) Client URL: https://maven.minecraftforge.net/net/minecraftforge/forge/{version}/forge-{version}-client.zip
|
||||
// (pre-1.3-2) Server URL: https://maven.minecraftforge.net/net/minecraftforge/forge/{version}/forge-{version}-server.zip
|
||||
// (1.3-2-onwards) Universal URL: https://maven.minecraftforge.net/net/minecraftforge/forge/{version}/forge-{version}-universal.zip
|
||||
// - Format 1: Forge Installer Legacy (1.5.2-1.12.2ish)
|
||||
// To install: Extract install_profile.json from archive. "versionInfo" is the profile's version info. Convert it to the modern format
|
||||
// Extract forge library from archive. Path is at "install"."path".
|
||||
// - Format 2: Forge Installer Modern
|
||||
// To install: Extract install_profile.json from archive. Extract version.json from archive. Combine the two and extract all libraries
|
||||
// which are embedded into the installer JAR.
|
||||
// Then upload. The launcher will need to run processors!
|
||||
if format_version != 1 && &*version_split == "7.8.0.684" {
|
||||
format_version = 1;
|
||||
} else if format_version != 2 && &*version_split == "14.23.5.2851" {
|
||||
format_version = 2;
|
||||
}
|
||||
|
||||
ForgeVersion {
|
||||
format_version,
|
||||
installer_url: format!("https://maven.minecraftforge.net/net/minecraftforge/forge/{0}/forge-{0}-installer.jar", loader_version),
|
||||
raw: loader_version,
|
||||
loader_version: version_split,
|
||||
game_version: game_version.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
// TODO: support format version 0 (see above)
|
||||
.filter(|x| x.format_version != 0)
|
||||
.filter(|x| {
|
||||
// These following Forge versions are broken and cannot be installed
|
||||
const BLACKLIST : &[&str] = &[
|
||||
// Not supported due to `data` field being `[]` even though the type is a map
|
||||
"1.12.2-14.23.5.2851",
|
||||
// Malformed Archives
|
||||
"1.6.1-8.9.0.749",
|
||||
"1.6.1-8.9.0.751",
|
||||
"1.6.4-9.11.1.960",
|
||||
"1.6.4-9.11.1.961",
|
||||
"1.6.4-9.11.1.963",
|
||||
"1.6.4-9.11.1.964",
|
||||
];
|
||||
|
||||
!BLACKLIST.contains(&&*x.raw)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
fetch(
|
||||
daedalus::modded::CURRENT_FORGE_FORMAT_VERSION,
|
||||
"forge",
|
||||
"https://maven.minecraftforge.net/",
|
||||
forge_versions,
|
||||
semaphore,
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(semaphore, upload_files, mirror_artifacts))]
|
||||
pub async fn fetch_neo(
|
||||
semaphore: Arc<Semaphore>,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<(), Error> {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Metadata {
|
||||
versioning: Versioning,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Versioning {
|
||||
versions: Versions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Versions {
|
||||
version: Vec<String>,
|
||||
}
|
||||
|
||||
let forge_versions = fetch_xml::<Metadata>(
|
||||
"https://maven.neoforged.net/net/neoforged/forge/maven-metadata.xml",
|
||||
&semaphore,
|
||||
)
|
||||
.await?;
|
||||
let neo_versions = fetch_xml::<Metadata>(
|
||||
"https://maven.neoforged.net/net/neoforged/neoforge/maven-metadata.xml",
|
||||
&semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let parsed_versions = forge_versions.versioning.versions.version.into_iter().map(|loader_version| {
|
||||
// NeoForge Forge versions can be in these specific formats:
|
||||
// 1.20.1-47.1.74
|
||||
// 47.1.82
|
||||
// This parses them to get the actual Forge version. Ex: 1.20.1-47.1.74 -> 47.1.74
|
||||
let version_split = loader_version.split('-').nth(1).unwrap_or(&loader_version).to_string();
|
||||
|
||||
Ok(ForgeVersion {
|
||||
format_version: 2,
|
||||
installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{0}/forge-{0}-installer.jar", loader_version),
|
||||
raw: loader_version,
|
||||
loader_version: version_split,
|
||||
game_version: "1.20.1".to_string(), // All NeoForge Forge versions are for 1.20.1
|
||||
})
|
||||
}).chain(neo_versions.versioning.versions.version.into_iter().map(|loader_version| {
|
||||
let mut parts = loader_version.split('.');
|
||||
|
||||
// NeoForge Forge versions are in this format: 20.2.29-beta, 20.6.119
|
||||
// Where the first number is the major MC version, the second is the minor MC version, and the third is the NeoForge version
|
||||
let major = parts.next().ok_or_else(
|
||||
|| crate::ErrorKind::InvalidInput(format!("Unable to find major game version for NeoForge {loader_version}"))
|
||||
)?;
|
||||
|
||||
let minor = parts.next().ok_or_else(
|
||||
|| crate::ErrorKind::InvalidInput(format!("Unable to find minor game version for NeoForge {loader_version}"))
|
||||
)?;
|
||||
|
||||
let game_version = if minor == "0" {
|
||||
format!("1.{major}")
|
||||
} else {
|
||||
format!("1.{major}.{minor}")
|
||||
};
|
||||
|
||||
Ok(ForgeVersion {
|
||||
format_version: 2,
|
||||
installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{0}/neoforge-{0}-installer.jar", loader_version),
|
||||
loader_version: loader_version.clone(),
|
||||
raw: loader_version,
|
||||
game_version,
|
||||
})
|
||||
}))
|
||||
.collect::<Result<Vec<_>, Error>>()?
|
||||
.into_iter()
|
||||
.filter(|x| {
|
||||
// These following Forge versions are broken and cannot be installed
|
||||
const BLACKLIST : &[&str] = &[
|
||||
// Unreachable / 404
|
||||
"1.20.1-47.1.7",
|
||||
"47.1.82",
|
||||
];
|
||||
|
||||
!BLACKLIST.contains(&&*x.raw)
|
||||
}).collect();
|
||||
|
||||
fetch(
|
||||
daedalus::modded::CURRENT_NEOFORGE_FORMAT_VERSION,
|
||||
"neo",
|
||||
"https://maven.neoforged.net/",
|
||||
parsed_versions,
|
||||
semaphore,
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(
|
||||
forge_versions,
|
||||
semaphore,
|
||||
upload_files,
|
||||
mirror_artifacts
|
||||
))]
|
||||
async fn fetch(
|
||||
format_version: usize,
|
||||
mod_loader: &str,
|
||||
maven_url: &str,
|
||||
forge_versions: Vec<ForgeVersion>,
|
||||
semaphore: Arc<Semaphore>,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<(), Error> {
|
||||
let modrinth_manifest = fetch_json::<daedalus::modded::Manifest>(
|
||||
&format_url(&format!("{mod_loader}/v{format_version}/manifest.json",)),
|
||||
&semaphore,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let fetch_versions = if let Some(modrinth_manifest) = modrinth_manifest {
|
||||
let mut fetch_versions = Vec::new();
|
||||
|
||||
for version in &forge_versions {
|
||||
if !modrinth_manifest.game_versions.iter().any(|x| {
|
||||
x.id == version.game_version
|
||||
&& x.loaders.iter().any(|x| x.id == version.loader_version)
|
||||
}) {
|
||||
fetch_versions.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
fetch_versions
|
||||
} else {
|
||||
forge_versions.iter().collect()
|
||||
};
|
||||
|
||||
if !fetch_versions.is_empty() {
|
||||
let forge_installers = futures::future::try_join_all(
|
||||
fetch_versions
|
||||
.iter()
|
||||
.map(|x| download_file(&x.installer_url, None, &semaphore)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[tracing::instrument(skip(raw, upload_files, mirror_artifacts))]
|
||||
async fn read_forge_installer(
|
||||
raw: bytes::Bytes,
|
||||
loader: &ForgeVersion,
|
||||
maven_url: &str,
|
||||
mod_loader: &str,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<PartialVersionInfo, Error> {
|
||||
tracing::trace!(
|
||||
"Reading forge installer for {}",
|
||||
loader.loader_version
|
||||
);
|
||||
type ZipFileReader = async_zip::base::read::seek::ZipFileReader<
|
||||
Cursor<bytes::Bytes>,
|
||||
>;
|
||||
|
||||
let cursor = Cursor::new(raw);
|
||||
let mut zip = ZipFileReader::new(cursor).await?;
|
||||
|
||||
#[tracing::instrument(skip(zip))]
|
||||
async fn read_file(
|
||||
zip: &mut ZipFileReader,
|
||||
file_name: &str,
|
||||
) -> Result<Option<Vec<u8>>, Error> {
|
||||
let zip_index_option =
|
||||
zip.file().entries().iter().position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == file_name
|
||||
});
|
||||
|
||||
if let Some(zip_index) = zip_index_option {
|
||||
let mut buffer = Vec::new();
|
||||
let mut reader = zip.reader_with_entry(zip_index).await?;
|
||||
reader.read_to_end_checked(&mut buffer).await?;
|
||||
|
||||
Ok(Some(buffer))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(zip))]
|
||||
async fn read_json<T: DeserializeOwned>(
|
||||
zip: &mut ZipFileReader,
|
||||
file_name: &str,
|
||||
) -> Result<Option<T>, Error> {
|
||||
if let Some(file) = read_file(zip, file_name).await? {
|
||||
Ok(Some(serde_json::from_slice(&file)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
if loader.format_version == 1 {
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ForgeInstallerProfileInstallDataV1 {
|
||||
// pub mirror_list: String,
|
||||
// pub target: String,
|
||||
/// Path to the Forge universal library
|
||||
pub file_path: String,
|
||||
// pub logo: String,
|
||||
// pub welcome: String,
|
||||
// pub version: String,
|
||||
/// Maven coordinates of the Forge universal library
|
||||
pub path: String,
|
||||
// pub profile_name: String,
|
||||
pub minecraft: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ForgeInstallerProfileManifestV1 {
|
||||
pub id: String,
|
||||
pub libraries: Vec<daedalus::minecraft::Library>,
|
||||
pub main_class: Option<String>,
|
||||
pub minecraft_arguments: Option<String>,
|
||||
pub release_time: DateTime<Utc>,
|
||||
pub time: DateTime<Utc>,
|
||||
pub type_: daedalus::minecraft::VersionType,
|
||||
// pub assets: Option<String>,
|
||||
// pub inherits_from: Option<String>,
|
||||
// pub jar: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ForgeInstallerProfileV1 {
|
||||
pub install: ForgeInstallerProfileInstallDataV1,
|
||||
pub version_info: ForgeInstallerProfileManifestV1,
|
||||
}
|
||||
|
||||
let install_profile = read_json::<ForgeInstallerProfileV1>(
|
||||
&mut zip,
|
||||
"install_profile.json",
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"No install_profile.json present for loader {}",
|
||||
loader.installer_url
|
||||
))
|
||||
})?;
|
||||
|
||||
let forge_library =
|
||||
read_file(&mut zip, &install_profile.install.file_path)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"No forge library present for loader {}",
|
||||
loader.installer_url
|
||||
))
|
||||
})?;
|
||||
|
||||
upload_files.insert(
|
||||
format!(
|
||||
"maven/{}",
|
||||
get_path_from_artifact(&install_profile.install.path)?
|
||||
),
|
||||
UploadFile {
|
||||
file: bytes::Bytes::from(forge_library),
|
||||
content_type: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(PartialVersionInfo {
|
||||
id: install_profile.version_info.id,
|
||||
inherits_from: install_profile.install.minecraft,
|
||||
release_time: install_profile.version_info.release_time,
|
||||
time: install_profile.version_info.time,
|
||||
main_class: install_profile.version_info.main_class,
|
||||
minecraft_arguments: install_profile
|
||||
.version_info
|
||||
.minecraft_arguments
|
||||
.clone(),
|
||||
arguments: install_profile
|
||||
.version_info
|
||||
.minecraft_arguments
|
||||
.map(|x| {
|
||||
[(
|
||||
daedalus::minecraft::ArgumentType::Game,
|
||||
x.split(' ')
|
||||
.map(|x| {
|
||||
daedalus::minecraft::Argument::Normal(
|
||||
x.to_string(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)]
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect()
|
||||
}),
|
||||
libraries: install_profile
|
||||
.version_info
|
||||
.libraries
|
||||
.into_iter()
|
||||
.map(|mut lib| {
|
||||
// For all libraries besides the forge lib extracted, we mirror them from maven servers
|
||||
// unless the URL is empty/null or available on Minecraft's servers
|
||||
if let Some(ref url) = lib.url {
|
||||
if lib.name == install_profile.install.path {
|
||||
lib.url = Some(format_url("maven/"));
|
||||
} else if !url.is_empty()
|
||||
&& !url.contains(
|
||||
"https://libraries.minecraft.net/",
|
||||
)
|
||||
{
|
||||
insert_mirrored_artifact(
|
||||
&lib.name,
|
||||
None,
|
||||
vec![
|
||||
url.clone(),
|
||||
"https://maven.creeperhost.net/"
|
||||
.to_string(),
|
||||
maven_url.to_string(),
|
||||
],
|
||||
false,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
|
||||
lib.url = Some(format_url("maven/"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lib)
|
||||
})
|
||||
.collect::<Result<Vec<_>, Error>>()?,
|
||||
type_: install_profile.version_info.type_,
|
||||
data: None,
|
||||
processors: None,
|
||||
})
|
||||
} else if loader.format_version == 2 {
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ForgeInstallerProfileV2 {
|
||||
// pub spec: i32,
|
||||
// pub profile: String,
|
||||
// pub version: String,
|
||||
// pub json: String,
|
||||
// pub path: Option<String>,
|
||||
// pub minecraft: String,
|
||||
pub data: HashMap<String, daedalus::modded::SidedDataEntry>,
|
||||
pub libraries: Vec<daedalus::minecraft::Library>,
|
||||
pub processors: Vec<daedalus::modded::Processor>,
|
||||
}
|
||||
|
||||
let install_profile = read_json::<ForgeInstallerProfileV2>(
|
||||
&mut zip,
|
||||
"install_profile.json",
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"No install_profile.json present for loader {}",
|
||||
loader.installer_url
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut version_info =
|
||||
read_json::<PartialVersionInfo>(&mut zip, "version.json")
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"No version.json present for loader {}",
|
||||
loader.installer_url
|
||||
))
|
||||
})?;
|
||||
|
||||
version_info.processors = Some(install_profile.processors);
|
||||
version_info.libraries.extend(
|
||||
install_profile.libraries.into_iter().map(|mut x| {
|
||||
x.include_in_classpath = false;
|
||||
|
||||
x
|
||||
}),
|
||||
);
|
||||
|
||||
async fn mirror_forge_library(
|
||||
mut zip: ZipFileReader,
|
||||
mut lib: daedalus::minecraft::Library,
|
||||
maven_url: &str,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<daedalus::minecraft::Library, Error>
|
||||
{
|
||||
let artifact_path = get_path_from_artifact(&lib.name)?;
|
||||
|
||||
if let Some(ref mut artifact) =
|
||||
lib.downloads.as_mut().and_then(|x| x.artifact.as_mut())
|
||||
{
|
||||
if !artifact.url.is_empty() {
|
||||
insert_mirrored_artifact(
|
||||
&lib.name,
|
||||
Some(artifact.sha1.clone()),
|
||||
vec![artifact.url.clone()],
|
||||
true,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
|
||||
artifact.url =
|
||||
format_url(&format!("maven/{}", artifact_path));
|
||||
|
||||
return Ok(lib);
|
||||
}
|
||||
} else if let Some(url) = &lib.url {
|
||||
if !url.is_empty() {
|
||||
insert_mirrored_artifact(
|
||||
&lib.name,
|
||||
None,
|
||||
vec![
|
||||
url.clone(),
|
||||
"https://libraries.minecraft.net/"
|
||||
.to_string(),
|
||||
"https://maven.creeperhost.net/"
|
||||
.to_string(),
|
||||
maven_url.to_string(),
|
||||
],
|
||||
false,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
|
||||
lib.url = Some(format_url("maven/"));
|
||||
|
||||
return Ok(lib);
|
||||
}
|
||||
}
|
||||
|
||||
// Other libraries are generally available in the "maven" directory of the installer. If they are
|
||||
// not present here, they will be generated by Forge processors.
|
||||
let extract_path = format!("maven/{artifact_path}");
|
||||
if let Some(file) =
|
||||
read_file(&mut zip, &extract_path).await?
|
||||
{
|
||||
upload_files.insert(
|
||||
extract_path,
|
||||
UploadFile {
|
||||
file: bytes::Bytes::from(file),
|
||||
content_type: None,
|
||||
},
|
||||
);
|
||||
|
||||
lib.url = Some(format_url("maven/"));
|
||||
} else {
|
||||
lib.downloadable = false;
|
||||
}
|
||||
|
||||
Ok(lib)
|
||||
}
|
||||
|
||||
version_info.libraries = futures::future::try_join_all(
|
||||
version_info.libraries.into_iter().map(|lib| {
|
||||
mirror_forge_library(
|
||||
zip.clone(),
|
||||
lib,
|
||||
maven_url,
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// In Minecraft Forge modern installers, processors are run during the install process. Some processors
|
||||
// are extracted from the installer JAR. This function finds these files, extracts them, and uploads them
|
||||
// and registers them as libraries instead.
|
||||
// Ex:
|
||||
// "BINPATCH": {
|
||||
// "client": "/data/client.lzma",
|
||||
// "server": "/data/server.lzma"
|
||||
// },
|
||||
// Becomes:
|
||||
// "BINPATCH": {
|
||||
// "client": "[net.minecraftforge:forge:1.20.3-49.0.1:shim:client@lzma]",
|
||||
// "server": "[net.minecraftforge:forge:1.20.3-49.0.1:shim:server@lzma]"
|
||||
// },
|
||||
// And the resulting library is added to the profile's libraries
|
||||
let mut new_data = HashMap::new();
|
||||
for (key, entry) in install_profile.data {
|
||||
async fn extract_data(
|
||||
zip: &mut ZipFileReader,
|
||||
key: &str,
|
||||
value: &str,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
libs: &mut Vec<daedalus::minecraft::Library>,
|
||||
mod_loader: &str,
|
||||
version: &ForgeVersion,
|
||||
) -> Result<String, Error> {
|
||||
let extract_file =
|
||||
read_file(zip, &value[1..value.len()])
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"Unable reading data key {key} at path {value}",
|
||||
))
|
||||
})?;
|
||||
|
||||
let file_name = value.split('/').last()
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"Unable reading filename for data key {key} at path {value}",
|
||||
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut file = file_name.split('.');
|
||||
let file_name = file.next()
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"Unable reading filename only for data key {key} at path {value}",
|
||||
))
|
||||
})?;
|
||||
let ext = file.next()
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"Unable reading extension only for data key {key} at path {value}",
|
||||
))
|
||||
})?;
|
||||
|
||||
let path = format!(
|
||||
"com.modrinth.daedalus:{}-installer-extracts:{}:{}@{}",
|
||||
mod_loader,
|
||||
version.raw,
|
||||
file_name,
|
||||
ext
|
||||
);
|
||||
|
||||
upload_files.insert(
|
||||
format!("maven/{}", get_path_from_artifact(&path)?),
|
||||
UploadFile {
|
||||
file: bytes::Bytes::from(extract_file),
|
||||
content_type: None,
|
||||
},
|
||||
);
|
||||
|
||||
libs.push(daedalus::minecraft::Library {
|
||||
downloads: None,
|
||||
extract: None,
|
||||
name: path.clone(),
|
||||
url: Some(format_url("maven/")),
|
||||
natives: None,
|
||||
rules: None,
|
||||
checksums: None,
|
||||
include_in_classpath: false,
|
||||
downloadable: true,
|
||||
});
|
||||
|
||||
Ok(format!("[{path}]"))
|
||||
}
|
||||
|
||||
let client = if entry.client.starts_with('/') {
|
||||
extract_data(
|
||||
&mut zip,
|
||||
&key,
|
||||
&entry.client,
|
||||
upload_files,
|
||||
&mut version_info.libraries,
|
||||
mod_loader,
|
||||
loader,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
entry.client.clone()
|
||||
};
|
||||
|
||||
let server = if entry.server.starts_with('/') {
|
||||
extract_data(
|
||||
&mut zip,
|
||||
&key,
|
||||
&entry.server,
|
||||
upload_files,
|
||||
&mut version_info.libraries,
|
||||
mod_loader,
|
||||
loader,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
entry.server.clone()
|
||||
};
|
||||
|
||||
new_data.insert(
|
||||
key.clone(),
|
||||
daedalus::modded::SidedDataEntry { client, server },
|
||||
);
|
||||
}
|
||||
|
||||
version_info.data = Some(new_data);
|
||||
|
||||
Ok(version_info)
|
||||
} else {
|
||||
Err(crate::ErrorKind::InvalidInput(format!(
|
||||
"Unknown format version {} for loader {}",
|
||||
loader.format_version, loader.installer_url
|
||||
))
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
let forge_version_infos = futures::future::try_join_all(
|
||||
forge_installers
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, raw)| {
|
||||
let loader = fetch_versions[index];
|
||||
|
||||
read_forge_installer(
|
||||
raw,
|
||||
loader,
|
||||
maven_url,
|
||||
mod_loader,
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let serialized_version_manifests = forge_version_infos
|
||||
.iter()
|
||||
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?;
|
||||
|
||||
serialized_version_manifests
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, bytes)| {
|
||||
let loader = fetch_versions[index];
|
||||
|
||||
let version_path = format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
loader.loader_version
|
||||
);
|
||||
|
||||
upload_files.insert(
|
||||
version_path,
|
||||
UploadFile {
|
||||
file: bytes,
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let forge_manifest_path =
|
||||
format!("{mod_loader}/v{format_version}/manifest.json",);
|
||||
|
||||
let manifest = daedalus::modded::Manifest {
|
||||
game_versions: forge_versions
|
||||
.into_iter()
|
||||
.sorted_by(|a, b| b.game_version.cmp(&a.game_version))
|
||||
.rev()
|
||||
.chunk_by(|x| x.game_version.clone())
|
||||
.into_iter()
|
||||
.map(|(game_version, loaders)| daedalus::modded::Version {
|
||||
id: game_version,
|
||||
stable: true,
|
||||
loaders: loaders
|
||||
.map(|x| daedalus::modded::LoaderVersion {
|
||||
url: format_url(&format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
x.loader_version
|
||||
)),
|
||||
id: x.loader_version,
|
||||
stable: false,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
upload_files.insert(
|
||||
forge_manifest_path,
|
||||
UploadFile {
|
||||
file: bytes::Bytes::from(serde_json::to_vec(&manifest)?),
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ForgeVersion {
|
||||
pub format_version: usize,
|
||||
pub raw: String,
|
||||
pub loader_version: String,
|
||||
pub game_version: String,
|
||||
pub installer_url: String,
|
||||
}
|
||||
218
apps/daedalus_client/src/main.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use crate::util::{
|
||||
format_url, upload_file_to_bucket, upload_url_to_bucket_mirrors,
|
||||
REQWEST_CLIENT,
|
||||
};
|
||||
use daedalus::get_path_from_artifact;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
mod error;
|
||||
mod fabric;
|
||||
mod forge;
|
||||
mod minecraft;
|
||||
pub mod util;
|
||||
|
||||
pub use error::{Error, ErrorKind, Result};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(fmt::layer())
|
||||
.with(EnvFilter::from_default_env())
|
||||
.with(ErrorLayer::default());
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber)?;
|
||||
|
||||
tracing::info!("Initialized tracing. Starting Daedalus!");
|
||||
|
||||
if check_env_vars() {
|
||||
tracing::error!("Some environment variables are missing!");
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let semaphore = Arc::new(Semaphore::new(
|
||||
dotenvy::var("CONCURRENCY_LIMIT")
|
||||
.ok()
|
||||
.and_then(|x| x.parse().ok())
|
||||
.unwrap_or(10),
|
||||
));
|
||||
|
||||
// path, upload file
|
||||
let upload_files: DashMap<String, UploadFile> = DashMap::new();
|
||||
// path, mirror artifact
|
||||
let mirror_artifacts: DashMap<String, MirrorArtifact> = DashMap::new();
|
||||
|
||||
minecraft::fetch(semaphore.clone(), &upload_files, &mirror_artifacts)
|
||||
.await?;
|
||||
fabric::fetch_fabric(semaphore.clone(), &upload_files, &mirror_artifacts)
|
||||
.await?;
|
||||
fabric::fetch_quilt(semaphore.clone(), &upload_files, &mirror_artifacts)
|
||||
.await?;
|
||||
forge::fetch_neo(semaphore.clone(), &upload_files, &mirror_artifacts)
|
||||
.await?;
|
||||
forge::fetch_forge(semaphore.clone(), &upload_files, &mirror_artifacts)
|
||||
.await?;
|
||||
|
||||
futures::future::try_join_all(upload_files.iter().map(|x| {
|
||||
upload_file_to_bucket(
|
||||
x.key().clone(),
|
||||
x.value().file.clone(),
|
||||
x.value().content_type.clone(),
|
||||
&semaphore,
|
||||
)
|
||||
}))
|
||||
.await?;
|
||||
|
||||
futures::future::try_join_all(mirror_artifacts.iter().map(|x| {
|
||||
upload_url_to_bucket_mirrors(
|
||||
format!("maven/{}", x.key()),
|
||||
x.value()
|
||||
.mirrors
|
||||
.iter()
|
||||
.map(|mirror| {
|
||||
if mirror.entire_url {
|
||||
mirror.path.clone()
|
||||
} else {
|
||||
format!("{}{}", mirror.path, x.key())
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
x.sha1.clone(),
|
||||
&semaphore,
|
||||
)
|
||||
}))
|
||||
.await?;
|
||||
|
||||
if dotenvy::var("CLOUDFLARE_INTEGRATION")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Ok(token) = dotenvy::var("CLOUDFLARE_TOKEN") {
|
||||
if let Ok(zone_id) = dotenvy::var("CLOUDFLARE_ZONE_ID") {
|
||||
let cache_clears = upload_files
|
||||
.into_iter()
|
||||
.map(|x| format_url(&x.0))
|
||||
.chain(
|
||||
mirror_artifacts
|
||||
.into_iter()
|
||||
.map(|x| format_url(&format!("maven/{}", x.0))),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Cloudflare ratelimits cache clears to 500 files per request
|
||||
for chunk in cache_clears.chunks(500) {
|
||||
REQWEST_CLIENT.post(format!("https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache"))
|
||||
.bearer_auth(&token)
|
||||
.json(&serde_json::json!({
|
||||
"files": chunk
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
ErrorKind::Fetch {
|
||||
inner: err,
|
||||
item: "cloudflare clear cache".to_string(),
|
||||
}
|
||||
})?
|
||||
.error_for_status()
|
||||
.map_err(|err| {
|
||||
ErrorKind::Fetch {
|
||||
inner: err,
|
||||
item: "cloudflare clear cache".to_string(),
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct UploadFile {
|
||||
file: bytes::Bytes,
|
||||
content_type: Option<String>,
|
||||
}
|
||||
|
||||
pub struct MirrorArtifact {
|
||||
pub sha1: Option<String>,
|
||||
pub mirrors: DashSet<Mirror>,
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Hash)]
|
||||
pub struct Mirror {
|
||||
path: String,
|
||||
entire_url: bool,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(mirror_artifacts))]
|
||||
pub fn insert_mirrored_artifact(
|
||||
artifact: &str,
|
||||
sha1: Option<String>,
|
||||
mirrors: Vec<String>,
|
||||
entire_url: bool,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<()> {
|
||||
let val = mirror_artifacts
|
||||
.entry(get_path_from_artifact(artifact)?)
|
||||
.or_insert(MirrorArtifact {
|
||||
sha1,
|
||||
mirrors: DashSet::new(),
|
||||
});
|
||||
|
||||
for mirror in mirrors {
|
||||
val.mirrors.insert(Mirror {
|
||||
path: mirror,
|
||||
entire_url,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_env_vars() -> bool {
|
||||
let mut failed = false;
|
||||
|
||||
fn check_var<T: std::str::FromStr>(var: &str) -> bool {
|
||||
if dotenvy::var(var)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<T>().ok())
|
||||
.is_none()
|
||||
{
|
||||
tracing::warn!(
|
||||
"Variable `{}` missing in dotenvy or not of type `{}`",
|
||||
var,
|
||||
std::any::type_name::<T>()
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
failed |= check_var::<String>("BASE_URL");
|
||||
|
||||
failed |= check_var::<String>("S3_ACCESS_TOKEN");
|
||||
failed |= check_var::<String>("S3_SECRET");
|
||||
failed |= check_var::<String>("S3_URL");
|
||||
failed |= check_var::<String>("S3_REGION");
|
||||
failed |= check_var::<String>("S3_BUCKET_NAME");
|
||||
|
||||
if dotenvy::var("CLOUDFLARE_INTEGRATION")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
failed |= check_var::<String>("CLOUDFLARE_TOKEN");
|
||||
failed |= check_var::<String>("CLOUDFLARE_ZONE_ID");
|
||||
}
|
||||
|
||||
failed
|
||||
}
|
||||
230
apps/daedalus_client/src/minecraft.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use crate::util::fetch_json;
|
||||
use crate::{
|
||||
util::download_file, util::format_url, util::sha1_async, Error,
|
||||
MirrorArtifact, UploadFile,
|
||||
};
|
||||
use daedalus::minecraft::{
|
||||
merge_partial_library, Library, PartialLibrary, VersionInfo,
|
||||
VersionManifest, VERSION_MANIFEST_URL,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[tracing::instrument(skip(semaphore, upload_files, _mirror_artifacts))]
|
||||
pub async fn fetch(
|
||||
semaphore: Arc<Semaphore>,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
_mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<(), Error> {
|
||||
let modrinth_manifest = fetch_json::<VersionManifest>(
|
||||
&format_url(&format!(
|
||||
"minecraft/v{}/manifest.json",
|
||||
daedalus::minecraft::CURRENT_FORMAT_VERSION
|
||||
)),
|
||||
&semaphore,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
let mojang_manifest =
|
||||
fetch_json::<VersionManifest>(VERSION_MANIFEST_URL, &semaphore).await?;
|
||||
|
||||
// TODO: experimental snapshots: https://github.com/PrismLauncher/meta/blob/main/meta/common/mojang-minecraft-experiments.json
|
||||
// TODO: old snapshots: https://github.com/PrismLauncher/meta/blob/main/meta/common/mojang-minecraft-old-snapshots.json
|
||||
|
||||
// We check Modrinth's version manifest and compare if the version 1) exists in Modrinth's database and 2) is unchanged
|
||||
// If they are not, we will fetch them
|
||||
let (fetch_versions, existing_versions) =
|
||||
if let Some(mut modrinth_manifest) = modrinth_manifest {
|
||||
let (mut fetch_versions, mut existing_versions) =
|
||||
(Vec::new(), Vec::new());
|
||||
|
||||
for version in mojang_manifest.versions {
|
||||
if let Some(index) = modrinth_manifest
|
||||
.versions
|
||||
.iter()
|
||||
.position(|x| x.id == version.id)
|
||||
{
|
||||
let modrinth_version =
|
||||
modrinth_manifest.versions.remove(index);
|
||||
|
||||
if modrinth_version
|
||||
.original_sha1
|
||||
.as_ref()
|
||||
.map(|x| x == &version.sha1)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
existing_versions.push(modrinth_version);
|
||||
} else {
|
||||
fetch_versions.push(version);
|
||||
}
|
||||
} else {
|
||||
fetch_versions.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
(fetch_versions, existing_versions)
|
||||
} else {
|
||||
(mojang_manifest.versions, Vec::new())
|
||||
};
|
||||
|
||||
if !fetch_versions.is_empty() {
|
||||
let version_manifests = futures::future::try_join_all(
|
||||
fetch_versions
|
||||
.iter()
|
||||
.map(|x| download_file(&x.url, Some(&x.sha1), &semaphore)),
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|x| serde_json::from_slice(&x))
|
||||
.collect::<Result<Vec<VersionInfo>, serde_json::Error>>()?;
|
||||
|
||||
// Patch libraries of Minecraft versions for M-series Mac Support, Better Linux Compatibility, etc
|
||||
let library_patches = fetch_library_patches()?;
|
||||
let patched_version_manifests = version_manifests
|
||||
.into_iter()
|
||||
.map(|mut x| {
|
||||
if !library_patches.is_empty() {
|
||||
let mut new_libraries = Vec::new();
|
||||
for library in x.libraries {
|
||||
let mut libs = patch_library(&library_patches, library);
|
||||
new_libraries.append(&mut libs)
|
||||
}
|
||||
x.libraries = new_libraries
|
||||
}
|
||||
|
||||
x
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// serialize + compute hashes
|
||||
let serialized_version_manifests = patched_version_manifests
|
||||
.iter()
|
||||
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?;
|
||||
let hashes_version_manifests = futures::future::try_join_all(
|
||||
serialized_version_manifests
|
||||
.iter()
|
||||
.map(|x| sha1_async(x.clone())),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// We upload the new version manifests and add them to the versions list
|
||||
let mut new_versions = patched_version_manifests
|
||||
.into_iter()
|
||||
.zip(serialized_version_manifests.into_iter())
|
||||
.zip(hashes_version_manifests.into_iter())
|
||||
.map(|((version, bytes), hash)| {
|
||||
let version_path = format!(
|
||||
"minecraft/v{}/versions/{}.json",
|
||||
daedalus::minecraft::CURRENT_FORMAT_VERSION,
|
||||
version.id
|
||||
);
|
||||
|
||||
let url = format_url(&version_path);
|
||||
upload_files.insert(
|
||||
version_path,
|
||||
UploadFile {
|
||||
file: bytes,
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
daedalus::minecraft::Version {
|
||||
original_sha1: fetch_versions
|
||||
.iter()
|
||||
.find(|x| x.id == version.id)
|
||||
.map(|x| x.sha1.clone()),
|
||||
id: version.id,
|
||||
type_: version.type_,
|
||||
url,
|
||||
time: version.time,
|
||||
release_time: version.release_time,
|
||||
sha1: hash,
|
||||
compliance_level: 1,
|
||||
}
|
||||
})
|
||||
.chain(existing_versions.into_iter())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
new_versions.sort_by(|a, b| b.release_time.cmp(&a.release_time));
|
||||
|
||||
// create and upload the new manifest
|
||||
let version_manifest_path = format!(
|
||||
"minecraft/v{}/manifest.json",
|
||||
daedalus::minecraft::CURRENT_FORMAT_VERSION
|
||||
);
|
||||
|
||||
let new_manifest = VersionManifest {
|
||||
latest: mojang_manifest.latest,
|
||||
versions: new_versions,
|
||||
};
|
||||
|
||||
upload_files.insert(
|
||||
version_manifest_path,
|
||||
UploadFile {
|
||||
file: bytes::Bytes::from(serde_json::to_vec(&new_manifest)?),
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryPatch {
|
||||
#[serde(rename = "_comment")]
|
||||
pub _comment: String,
|
||||
#[serde(rename = "match")]
|
||||
pub match_: Vec<String>,
|
||||
pub additional_libraries: Option<Vec<Library>>,
|
||||
#[serde(rename = "override")]
|
||||
pub override_: Option<PartialLibrary>,
|
||||
pub patch_additional_libraries: Option<bool>,
|
||||
}
|
||||
|
||||
fn fetch_library_patches() -> Result<Vec<LibraryPatch>, Error> {
|
||||
let patches = include_bytes!("../library-patches.json");
|
||||
Ok(serde_json::from_slice(patches)?)
|
||||
}
|
||||
|
||||
pub fn patch_library(
|
||||
patches: &Vec<LibraryPatch>,
|
||||
mut library: Library,
|
||||
) -> Vec<Library> {
|
||||
let mut val = Vec::new();
|
||||
|
||||
let actual_patches = patches
|
||||
.iter()
|
||||
.filter(|x| x.match_.contains(&library.name))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !actual_patches.is_empty() {
|
||||
for patch in actual_patches {
|
||||
if let Some(override_) = &patch.override_ {
|
||||
library = merge_partial_library(override_.clone(), library);
|
||||
}
|
||||
|
||||
if let Some(additional_libraries) = &patch.additional_libraries {
|
||||
for additional_library in additional_libraries {
|
||||
if patch.patch_additional_libraries.unwrap_or(false) {
|
||||
let mut libs =
|
||||
patch_library(patches, additional_library.clone());
|
||||
val.append(&mut libs)
|
||||
} else {
|
||||
val.push(additional_library.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val.push(library);
|
||||
} else {
|
||||
val.push(library);
|
||||
}
|
||||
|
||||
val
|
||||
}
|
||||
234
apps/daedalus_client/src/util.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
use crate::{Error, ErrorKind};
|
||||
use bytes::Bytes;
|
||||
use s3::creds::Credentials;
|
||||
use s3::{Bucket, Region};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref BUCKET : Bucket = {
|
||||
let region = dotenvy::var("S3_REGION").unwrap();
|
||||
let b = Bucket::new(
|
||||
&dotenvy::var("S3_BUCKET_NAME").unwrap(),
|
||||
if &*region == "r2" {
|
||||
Region::R2 {
|
||||
account_id: dotenvy::var("S3_URL").unwrap(),
|
||||
}
|
||||
} else {
|
||||
Region::Custom {
|
||||
region: region.clone(),
|
||||
endpoint: dotenvy::var("S3_URL").unwrap(),
|
||||
}
|
||||
},
|
||||
Credentials::new(
|
||||
Some(&*dotenvy::var("S3_ACCESS_TOKEN").unwrap()),
|
||||
Some(&*dotenvy::var("S3_SECRET").unwrap()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
).unwrap(),
|
||||
).unwrap();
|
||||
|
||||
if region == "path-style" {
|
||||
b.with_path_style()
|
||||
} else {
|
||||
b
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref REQWEST_CLIENT: reqwest::Client = {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if let Ok(header) = reqwest::header::HeaderValue::from_str(&format!(
|
||||
"modrinth/daedalus/{} (support@modrinth.com)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)) {
|
||||
headers.insert(reqwest::header::USER_AGENT, header);
|
||||
}
|
||||
|
||||
reqwest::Client::builder()
|
||||
.tcp_keepalive(Some(std::time::Duration::from_secs(10)))
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(bytes, semaphore))]
|
||||
pub async fn upload_file_to_bucket(
|
||||
path: String,
|
||||
bytes: Bytes,
|
||||
content_type: Option<String>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<(), Error> {
|
||||
let _permit = semaphore.acquire().await?;
|
||||
let key = path.clone();
|
||||
|
||||
const RETRIES: i32 = 3;
|
||||
for attempt in 1..=(RETRIES + 1) {
|
||||
tracing::trace!("Attempting file upload, attempt {attempt}");
|
||||
let result = if let Some(ref content_type) = content_type {
|
||||
BUCKET
|
||||
.put_object_with_content_type(key.clone(), &bytes, content_type)
|
||||
.await
|
||||
} else {
|
||||
BUCKET.put_object(key.clone(), &bytes).await
|
||||
}
|
||||
.map_err(|err| ErrorKind::S3 {
|
||||
inner: err,
|
||||
file: path.clone(),
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) if attempt <= RETRIES => continue,
|
||||
Err(_) => {
|
||||
result?;
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub async fn upload_url_to_bucket_mirrors(
|
||||
upload_path: String,
|
||||
mirrors: Vec<String>,
|
||||
sha1: Option<String>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<(), Error> {
|
||||
if mirrors.is_empty() {
|
||||
return Err(ErrorKind::InvalidInput(
|
||||
"No mirrors provided!".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
for (index, mirror) in mirrors.iter().enumerate() {
|
||||
let result = upload_url_to_bucket(
|
||||
upload_path.clone(),
|
||||
mirror.clone(),
|
||||
sha1.clone(),
|
||||
semaphore,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(semaphore))]
|
||||
pub async fn upload_url_to_bucket(
|
||||
path: String,
|
||||
url: String,
|
||||
sha1: Option<String>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<(), Error> {
|
||||
let data = download_file(&url, sha1.as_deref(), semaphore).await?;
|
||||
|
||||
upload_file_to_bucket(path, data, None, semaphore).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(bytes))]
|
||||
pub async fn sha1_async(bytes: Bytes) -> Result<String, Error> {
|
||||
let hash = tokio::task::spawn_blocking(move || {
|
||||
sha1_smol::Sha1::from(bytes).hexdigest()
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(semaphore))]
|
||||
pub async fn download_file(
|
||||
url: &str,
|
||||
sha1: Option<&str>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<bytes::Bytes, crate::Error> {
|
||||
let _permit = semaphore.acquire().await?;
|
||||
tracing::trace!("Starting file download");
|
||||
|
||||
const RETRIES: u32 = 10;
|
||||
for attempt in 1..=(RETRIES + 1) {
|
||||
let result = REQWEST_CLIENT
|
||||
.get(url.replace("http://", "https://"))
|
||||
.send()
|
||||
.await
|
||||
.and_then(|x| x.error_for_status());
|
||||
|
||||
match result {
|
||||
Ok(x) => {
|
||||
let bytes = x.bytes().await;
|
||||
|
||||
if let Ok(bytes) = bytes {
|
||||
if let Some(sha1) = sha1 {
|
||||
if &*sha1_async(bytes.clone()).await? != sha1 {
|
||||
if attempt <= 3 {
|
||||
continue;
|
||||
} else {
|
||||
return Err(
|
||||
crate::ErrorKind::ChecksumFailure {
|
||||
hash: sha1.to_string(),
|
||||
url: url.to_string(),
|
||||
tries: attempt,
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(bytes);
|
||||
} else if attempt <= RETRIES {
|
||||
continue;
|
||||
} else if let Err(err) = bytes {
|
||||
return Err(crate::ErrorKind::Fetch {
|
||||
inner: err,
|
||||
item: url.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
Err(_) if attempt <= RETRIES => continue,
|
||||
Err(err) => {
|
||||
return Err(crate::ErrorKind::Fetch {
|
||||
inner: err,
|
||||
item: url.to_string(),
|
||||
}
|
||||
.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub async fn fetch_json<T: DeserializeOwned>(
|
||||
url: &str,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<T, Error> {
|
||||
Ok(serde_json::from_slice(
|
||||
&download_file(url, None, semaphore).await?,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub async fn fetch_xml<T: DeserializeOwned>(
|
||||
url: &str,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<T, Error> {
|
||||
Ok(serde_xml_rs::from_reader(
|
||||
&*download_file(url, None, semaphore).await?,
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn format_url(path: &str) -> String {
|
||||
format!("{}/{}", &*dotenvy::var("BASE_URL").unwrap(), path)
|
||||
}
|
||||
21
apps/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
4
apps/docs/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
apps/docs/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
34
apps/docs/LICENSE
Normal file
@@ -0,0 +1,34 @@
|
||||
Creative Commons Legal Code
|
||||
CC0 1.0 Universal
|
||||
Official translations of this legal tool are available
|
||||
|
||||
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER.
|
||||
|
||||
Statement of Purpose
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work").
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following:
|
||||
|
||||
the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
|
||||
moral rights retained by the original author(s) and/or performer(s);
|
||||
publicity and privacy rights pertaining to a person's image or likeness depicted in a Work;
|
||||
rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below;
|
||||
rights protecting the extraction, dissemination, use and reuse of data in a Work;
|
||||
database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
|
||||
other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
|
||||
No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
|
||||
Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
|
||||
Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
|
||||
Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
|
||||
23
apps/docs/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Modrinth Documentation
|
||||
|
||||
Welcome to the Modrinth documentation!
|
||||
|
||||
## Development
|
||||
|
||||
### Pre-requisites
|
||||
|
||||
Before you begin, ensure you have the following installed on your machine:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/)
|
||||
- [pnpm](https://pnpm.io/)
|
||||
|
||||
### Setup
|
||||
|
||||
Follow these steps to set up your development environment:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm docs:dev
|
||||
```
|
||||
|
||||
You should now have a development build of the documentation site running with hot-reloading enabled. Any changes you make to the code will automatically refresh the browser.
|
||||
52
apps/docs/astro.config.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import starlight from '@astrojs/starlight'
|
||||
import { defineConfig } from 'astro/config'
|
||||
import starlightOpenAPI, { openAPISidebarGroups } from 'starlight-openapi'
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://docs.modrinth.com',
|
||||
integrations: [
|
||||
starlight({
|
||||
title: 'Modrinth Documentation',
|
||||
favicon: '/favicon.ico',
|
||||
editLink: {
|
||||
baseUrl: 'https://github.com/modrinth/code/edit/main/apps/docs/',
|
||||
},
|
||||
social: {
|
||||
github: 'https://github.com/modrinth/code',
|
||||
discord: 'https://discord.modrinth.com',
|
||||
'x.com': 'https://x.com/modrinth',
|
||||
mastodon: 'https://floss.social/@modrinth',
|
||||
threads: 'https://threads.net/@modrinth',
|
||||
},
|
||||
logo: {
|
||||
light: './src/assets/light-logo.svg',
|
||||
dark: './src/assets/dark-logo.svg',
|
||||
replacesTitle: true,
|
||||
},
|
||||
customCss: [
|
||||
'@modrinth/assets/styles/variables.scss',
|
||||
'@modrinth/assets/styles/inter.scss',
|
||||
'./src/styles/modrinth.css',
|
||||
],
|
||||
plugins: [
|
||||
// Generate the OpenAPI documentation pages.
|
||||
starlightOpenAPI([
|
||||
{
|
||||
base: 'api',
|
||||
label: 'Modrinth API',
|
||||
schema: './public/openapi.yaml',
|
||||
},
|
||||
]),
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
label: 'Contributing to Modrinth',
|
||||
autogenerate: { directory: 'contributing' },
|
||||
},
|
||||
// Add the generated sidebar group to the sidebar.
|
||||
...openAPISidebarGroups,
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
21
apps/docs/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@modrinth/docs",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.3",
|
||||
"@astrojs/starlight": "^0.26.3",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"astro": "^4.10.2",
|
||||
"sharp": "^0.32.5",
|
||||
"starlight-openapi": "^0.7.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
BIN
apps/docs/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 24 KiB |
3882
apps/docs/public/openapi.yaml
Normal file
79
apps/docs/public/welcome-channel.yaml
Normal file
@@ -0,0 +1,79 @@
|
||||
- type: text
|
||||
text: https://cdn.discordapp.com/attachments/734084240408444949/975414177550200902/welcome-channel.png
|
||||
|
||||
- type: embed
|
||||
embeds:
|
||||
- title: __Welcome to Modrinth's Discord server!__
|
||||
url: https://modrinth.com
|
||||
color: 0x1bd96a
|
||||
description: "Modrinth is the place for Minecraft mods, plugins, data packs, shaders, resource packs, and
|
||||
modpacks. Discover, play, and share Minecraft content through our open-source platform built for the community."
|
||||
|
||||
- type: embed
|
||||
embeds:
|
||||
- title: "**:scroll: __Rules__**"
|
||||
color: 0x4f9cff
|
||||
description: "Modrinth's rules are easy to follow. Despite this, please keep in mind that this is not an entirely
|
||||
open forum. First and foremost, this Discord server is intended to facilitate the development of Modrinth and
|
||||
for communication regarding Modrinth. Ultimately, it is up to the discretion of the moderators whether your
|
||||
messages are in violation of our rules.\n\n
|
||||
Modrinth's rules are split up into two categories: the **__DOs__** and the **__DO NOTs__**."
|
||||
- title: ":white_check_mark: Do:"
|
||||
color: 0x1bd96a
|
||||
description: >-
|
||||
1. Treat every user with respect and consider the opinions and viewpoints of others
|
||||
|
||||
2. Stay on-topic in all channels; all channels are only for discussion of **Modrinth itself** with the
|
||||
exceptions of <#783091855616901200>, <#1109517383074328686>, and <#1061855024252207167>
|
||||
|
||||
3. Follow Discord's rules, including the [Community Guidelines](https://discord.com/guidelines) and the [Terms
|
||||
of Service](https://discord.com/terms) (this also means that discussions regarding "cracked" launchers and
|
||||
Discord client modifications are **prohibited under all circumstances**)
|
||||
|
||||
4. Contact the moderators at any time via the <@&895382919772766219> ping
|
||||
|
||||
5. Respect the use of accessibility and self-identity tools such as [PluralKit](https://pluralkit.me)
|
||||
- title: ":no_entry: Do not:"
|
||||
color: 0xff496e
|
||||
description: >-
|
||||
6. Harass, bother, provoke, or insult anyone, including by sending unsolicited DMs or friend requests
|
||||
|
||||
7. Cause problems or impede Modrinth's development
|
||||
|
||||
8. Discuss drama from other places, including bashing or hating on other websites and platforms (though
|
||||
constructive criticism for the betterment of Modrinth is encouraged)
|
||||
|
||||
9. Report Modrinth content in the Discord (use the Report button on the website)
|
||||
|
||||
10. Assume staff member's opinions reflect those of Modrinth
|
||||
- title: ":pencil2: Nickname policy:"
|
||||
color: 0xffa347
|
||||
description: >-
|
||||
We want to keep this server clean and therefore require that display names of all members on the server are
|
||||
readable, accessible, and free of attention-seeking elements, which includes, but is not limited to, display
|
||||
names that begin with hoisting characters, have an excessive number of emojis in them, or use "fancy fonts",
|
||||
"glitch effects" and any other Unicode characters, which are either very inaccessible to screen readers or cause
|
||||
annoyance to other members.
|
||||
|
||||
When we find that your display name does not adhere to this policy, we will try to correct it by changing your
|
||||
nickname on the server. Repetitive attempts to revert to a violating display name may result in your removal
|
||||
from the server. We will also permanently remove any users whose profiles contain inappropriate content.
|
||||
- type: links
|
||||
color: 0x4f9cff
|
||||
title: "**:link: __Links__**"
|
||||
links:
|
||||
Website: https://modrinth.com
|
||||
Support: https://support.modrinth.com
|
||||
Status page: https://status.modrinth.com
|
||||
Roadmap: https://roadmap.modrinth.com
|
||||
Blog and newsletter: https://blog.modrinth.com/subscribe?utm_medium=social&utm_source=discord&utm_campaign=welcome
|
||||
API documentation: https://docs.modrinth.com
|
||||
Modrinth source code: https://github.com/modrinth
|
||||
Help translate Modrinth: https://crowdin.com/project/modrinth
|
||||
Follow Modrinth on Mastodon: https://floss.social/@modrinth
|
||||
Follow Modrinth on Twitter: https://twitter.com/modrinth
|
||||
|
||||
- type: text
|
||||
text: |
|
||||
**Main server:** <https://discord.modrinth.com>
|
||||
**Testing server:** <https://discord.modrinth.com/labs>
|
||||
21
apps/docs/src/assets/dark-logo.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1558 207.2">
|
||||
<!-- Generator: Adobe Illustrator 28.6.0, SVG Export Plug-In . SVG Version: 1.2.0 Build 709) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g id="icon" fill="#1bd96a">
|
||||
<path d="M146.1,109.8l-14.4,17.6-24.1,7.6-10.8-12-61.9,37.1c-2.9-3.8-6.4-9.1-8.5-14.3l61.7-37-5.6-14.9,17.6-18.1,22.3-4.8,6.4,7.9-10.3,10.4-8.9,2.8-6.4,6.6,3.1,8.7,6.4,6.8,9-2.4,6.4-7,13.9-4.4s4.1,9.3,4.1,9.3Z" fill-rule="evenodd"/>
|
||||
<path d="M208.6,86.6l-48.9,13.2c-.8-4.7-1.6-8.9-4.3-16.1l49-13.2c2.1,5.5,3.5,10.9,4.2,16.1Z" fill-rule="evenodd"/>
|
||||
<path d="M33.7,110.5c3.5,41,37.9,73.2,79.7,73.2s59-18.4,72-45.1l15.9,5.5c-15.3,33.2-48.9,56.3-87.9,56.3S20.3,160.7,16.8,110.5c0,0,16.8,0,16.8,0ZM17.1,93.8C22,45,63.3,6.8,113.4,6.8s96.8,43.4,96.8,96.8-1.1,16.9-3.2,24.8l-15.9-5.5c1.6-6.3,2.4-12.8,2.3-19.3,0-44.2-35.9-80-80-80S38.8,54.3,34,93.8h-16.9,0Z" fill-rule="evenodd"/>
|
||||
<path d="M113.1,57c-25.7,0-46.6,20.9-46.6,46.7s20.9,46.7,46.7,46.7,2.6,0,3.9-.2l4.7,16.3c-2.8.4-5.7.6-8.5.6-35,0-63.4-28.4-63.4-63.4s28.4-63.4,63.4-63.4,1.7,0,2.6,0c0,0-2.6,16.7-2.6,16.7ZM132.2,43.2c25.7,8.1,44.4,32.1,44.4,60.5s-16,48.8-38.7,58.4l-4.6-16.2c15.8-7.5,26.7-23.6,26.7-42.2s-12.6-37.1-30.3-43.7l2.7-16.8h0Z" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<path id="modrinth" fill="#fff" d="M362,74.2c11.7,0,21.1,3.3,27.9,10.1,6.8,7,10.3,17.1,10.3,30.6v53.3h-21.8v-50.5c0-8.2-1.7-14.3-5.4-18.4-3.6-4-8.7-6.1-15.5-6.1s-13.2,2.4-17.6,7.1c-4.4,4.9-6.6,11.8-6.6,20.7v47.2h-21.8v-50.5c0-8.2-1.7-14.3-5.4-18.4-3.6-4-8.7-6.1-15.5-6.1s-13.2,2.4-17.6,7.1c-4.4,4.7-6.6,11.7-6.6,20.7v47.2h-21.8v-93h20.7v11.8c3.5-4.2,7.8-7.3,13.1-9.6,5.2-2.3,11-3.3,17.4-3.3s13.2,1.2,18.6,3.8c5.4,2.8,9.6,6.6,12.7,11.7,3.8-4.9,8.7-8.7,14.8-11.5,6.1-2.6,12.7-4,20-4h0ZM470.1,169.4c-9.4,0-17.9-2.1-25.4-6.1-7.4-3.9-13.5-9.8-17.6-17.1-4.4-7.1-6.4-15.3-6.4-24.6s2.1-17.4,6.4-24.5c4.2-7.2,10.3-13,17.6-16.9,7.5-4,16-6.1,25.4-6.1s18.1,2.1,25.6,6.1c7.5,4,13.4,9.8,17.8,16.9,4.2,7.1,6.3,15.3,6.3,24.5s-2.1,17.4-6.3,24.6c-4.4,7.3-10.3,13.1-17.8,17.1-7.5,4-16,6.1-25.6,6.1h0ZM470.1,150.8c8,0,14.6-2.6,19.9-8s7.8-12.4,7.8-21.1-2.6-15.7-7.8-21.1-11.8-8-19.9-8-14.6,2.6-19.7,8c-5.2,5.4-7.8,12.4-7.8,21.1s2.6,15.7,7.8,21.1c5,5.4,11.7,8,19.7,8ZM631.2,39v129.2h-20.9v-12c-3.6,4.3-8.1,7.7-13.2,9.9-5.4,2.3-11.1,3.3-17.6,3.3s-16.9-1.9-24-5.9-12.9-9.6-16.9-16.9c-4-7.1-6.1-15.5-6.1-24.9s2.1-17.8,6.1-24.9c4-7.1,9.8-12.7,16.9-16.7s15.1-5.9,24-5.9,11.8,1.1,16.9,3.1c5,2.1,9.5,5.3,13.1,9.4v-47.7h21.8,0ZM582.1,150.8c5.2,0,9.9-1.2,14.1-3.7,4.2-2.3,7.5-5.7,9.9-10.1,2.4-4.4,3.7-9.6,3.7-15.3s-1.2-11-3.7-15.3c-2.4-4.3-5.8-7.8-9.9-10.3-4.2-2.3-8.9-3.5-14.1-3.5s-9.9,1.2-14.1,3.5c-4.2,2.4-7.5,5.9-9.9,10.3-2.4,4.3-3.7,9.6-3.7,15.3s1.2,11,3.7,15.3c2.4,4.4,5.8,7.8,9.9,10.1,4.2,2.4,8.9,3.7,14.1,3.7ZM679.9,88.8c6.3-9.8,17.2-14.6,33.1-14.6v20.7c-1.7-.3-3.3-.5-5-.5-8.5,0-15.2,2.4-19.8,7.3-4.7,5.1-7.1,12.2-7.1,21.4v45.1h-21.8v-93h20.7v13.6h0ZM730.9,75.2h21.8v93h-21.8s0-93,0-93ZM741.9,59.9c-4,0-7.3-1.2-9.9-3.8-2.6-2.4-4-5.7-4-9.2,0-3.7,1.4-6.8,4-9.4,2.6-2.4,5.9-3.7,9.9-3.7s7.3,1.2,9.9,3.5c2.6,2.4,4,5.4,4,9s-1.2,7-3.8,9.6c-2.6,2.6-6.1,4-10.1,4ZM833.5,74.2c11.7,0,21.1,3.5,28.2,10.3s10.6,17.1,10.6,30.5v53.3h-21.8v-50.5c0-8.2-1.9-14.3-5.7-18.4-3.8-4-9.2-6.1-16.4-6.1s-14.3,2.4-19,7.1c-4.7,4.9-7,11.8-7,20.9v47h-21.8v-93h20.7v12c3.7-4.3,8.2-7.5,13.6-9.8s11.7-3.3,18.5-3.3h0ZM955.3,163.2c-2.7,2.2-5.9,3.8-9.2,4.7-3.8,1.1-7.7,1.6-11.7,1.6-10.3,0-18.1-2.6-23.7-8-5.6-5.4-8.4-13.1-8.4-23.3v-44.8h-15.3v-17.4h15.3v-21.2h21.8v21.2h24.9v17.4h-24.9v44.2c0,4.5,1,8,3.3,10.3,2.3,2.4,5.4,3.6,9.6,3.6s8.9-1.2,12.2-3.8l6.1,15.5h0ZM1025.5,74.2c11.7,0,21.1,3.5,28.2,10.3s10.6,17.1,10.6,30.5v53.3h-21.8v-50.5c0-8.2-1.9-14.3-5.8-18.4-3.8-4-9.2-6.1-16.4-6.1s-14.3,2.4-19,7.1c-4.7,4.9-7,11.8-7,20.9v47h-21.8V39h21.8v47c3.7-3.8,8-6.8,13.4-8.9,5.2-1.9,11.1-3,17.6-3h0Z"/>
|
||||
<g id="docs" fill="#1bd96a">
|
||||
<path d="M1177.8,169.7c-8.9,0-16.9-2-24-6-7.1-4-12.7-9.5-16.7-16.6-4-7.1-6.1-15.4-6.1-25s2-17.9,6.1-25c4-7.1,9.6-12.6,16.7-16.5,7.1-3.9,15.1-5.9,24-5.9s14.7,1.7,20.8,5.1c6.1,3.4,11,8.6,14.6,15.5,3.6,6.9,5.4,15.9,5.4,26.7s-1.7,19.6-5.2,26.6c-3.5,7-8.3,12.2-14.4,15.7-6.1,3.5-13.2,5.2-21.2,5.2ZM1180.4,151.2c5.2,0,9.9-1.2,14-3.5,4.1-2.3,7.4-5.7,9.9-10.1,2.5-4.4,3.7-9.5,3.7-15.4s-1.2-11.2-3.7-15.5c-2.5-4.3-5.8-7.7-9.9-10-4.1-2.3-8.8-3.5-14-3.5s-9.9,1.2-14,3.5c-4.1,2.3-7.4,5.6-9.9,10-2.5,4.3-3.7,9.5-3.7,15.5s1.2,11,3.7,15.4c2.5,4.4,5.8,7.8,9.9,10.1,4.1,2.3,8.8,3.5,14,3.5ZM1208.6,168.5v-21.9l.9-24.6-1.7-24.6v-57.6h21.5v128.8h-20.7Z"/>
|
||||
<path d="M1299.5,169.7c-9.5,0-17.9-2.1-25.3-6.2-7.4-4.1-13.2-9.7-17.5-16.9-4.3-7.2-6.4-15.3-6.4-24.5s2.1-17.4,6.4-24.6c4.3-7.1,10.1-12.7,17.5-16.7,7.4-4,15.8-6.1,25.3-6.1s18.1,2,25.6,6.1c7.5,4.1,13.3,9.6,17.5,16.7,4.2,7.1,6.3,15.3,6.3,24.6s-2.1,17.3-6.3,24.5c-4.2,7.2-10.1,12.8-17.5,16.9-7.5,4.1-16,6.2-25.6,6.2ZM1299.5,151.2c5.3,0,10.1-1.2,14.2-3.5,4.2-2.3,7.4-5.7,9.8-10.1,2.4-4.4,3.6-9.5,3.6-15.4s-1.2-11.2-3.6-15.5c-2.4-4.3-5.6-7.7-9.8-10-4.2-2.3-8.8-3.5-14.1-3.5s-10,1.2-14.1,3.5c-4.1,2.3-7.4,5.6-9.8,10-2.4,4.3-3.6,9.5-3.6,15.5s1.2,11,3.6,15.4c2.4,4.4,5.7,7.8,9.8,10.1,4.1,2.3,8.8,3.5,14,3.5Z"/>
|
||||
<path d="M1412.3,169.7c-9.7,0-18.4-2.1-25.9-6.2-7.6-4.1-13.5-9.7-17.8-16.9-4.3-7.2-6.4-15.3-6.4-24.5s2.1-17.4,6.4-24.6c4.3-7.1,10.2-12.7,17.8-16.7,7.6-4,16.2-6.1,25.9-6.1s17,1.8,23.9,5.5c6.9,3.6,12.1,9,15.7,16.1l-16.7,9.7c-2.8-4.4-6.2-7.6-10.2-9.7-4-2.1-8.3-3.1-12.9-3.1s-10.1,1.2-14.4,3.5c-4.3,2.3-7.6,5.6-10.1,10-2.4,4.3-3.6,9.5-3.6,15.5s1.2,11.2,3.6,15.5c2.4,4.3,5.8,7.7,10.1,10,4.3,2.3,9.1,3.5,14.4,3.5s8.9-1,12.9-3.1c4-2.1,7.4-5.3,10.2-9.7l16.7,9.7c-3.6,6.9-8.8,12.3-15.7,16.1-6.9,3.8-14.8,5.6-23.9,5.6Z"/>
|
||||
<path d="M1499.2,169.7c-7.8,0-15.2-1-22.3-3-7.1-2-12.8-4.5-16.9-7.4l8.3-16.5c4.2,2.7,9.1,4.9,14.9,6.6,5.8,1.7,11.6,2.6,17.4,2.6s11.8-.9,14.8-2.8c3.1-1.8,4.6-4.3,4.6-7.5s-1-4.5-3.1-5.8c-2.1-1.3-4.8-2.3-8.2-3-3.4-.7-7.1-1.3-11.2-1.9-4.1-.6-8.2-1.4-12.3-2.3-4.1-1-7.8-2.4-11.2-4.3-3.4-1.9-6.1-4.5-8.2-7.7-2.1-3.2-3.1-7.5-3.1-12.8s1.7-11,5-15.4c3.4-4.3,8.1-7.7,14.1-10.1,6.1-2.4,13.3-3.6,21.6-3.6s12.6.7,18.9,2.1c6.4,1.4,11.6,3.4,15.8,5.9l-8.3,16.5c-4.4-2.7-8.8-4.5-13.3-5.5-4.5-1-8.9-1.5-13.3-1.5-6.6,0-11.5,1-14.7,2.9-3.2,2-4.8,4.5-4.8,7.5s1,4.9,3.1,6.2c2.1,1.4,4.8,2.5,8.2,3.3,3.4.8,7.1,1.5,11.2,2,4.1.5,8.2,1.3,12.2,2.3,4,1,7.8,2.5,11.2,4.3,3.4,1.8,6.2,4.3,8.2,7.5,2.1,3.2,3.1,7.5,3.1,12.7s-1.7,10.8-5.1,15.1c-3.4,4.3-8.2,7.6-14.5,10-6.2,2.4-13.7,3.6-22.4,3.6Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
21
apps/docs/src/assets/light-logo.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1558 207.2">
|
||||
<!-- Generator: Adobe Illustrator 28.6.0, SVG Export Plug-In . SVG Version: 1.2.0 Build 709) -->
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g id="icon" fill="#00af5c">
|
||||
<path d="M146.1,109.8l-14.4,17.6-24.1,7.6-10.8-12-61.9,37.1c-2.9-3.8-6.4-9.1-8.5-14.3l61.7-37-5.6-14.9,17.6-18.1,22.3-4.8,6.4,7.9-10.3,10.4-8.9,2.8-6.4,6.6,3.1,8.7,6.4,6.8,9-2.4,6.4-7,13.9-4.4s4.1,9.3,4.1,9.3Z" fill-rule="evenodd"/>
|
||||
<path d="M208.6,86.6l-48.9,13.2c-.8-4.7-1.6-8.9-4.3-16.1l49-13.2c2.1,5.5,3.5,10.9,4.2,16.1Z" fill-rule="evenodd"/>
|
||||
<path d="M33.7,110.5c3.5,41,37.9,73.2,79.7,73.2s59-18.4,72-45.1l15.9,5.5c-15.3,33.2-48.9,56.3-87.9,56.3S20.3,160.7,16.8,110.5c0,0,16.8,0,16.8,0ZM17.1,93.8C22,45,63.3,6.8,113.4,6.8s96.8,43.4,96.8,96.8-1.1,16.9-3.2,24.8l-15.9-5.5c1.6-6.3,2.4-12.8,2.3-19.3,0-44.2-35.9-80-80-80S38.8,54.3,34,93.8h-16.9,0Z" fill-rule="evenodd"/>
|
||||
<path d="M113.1,57c-25.7,0-46.6,20.9-46.6,46.7s20.9,46.7,46.7,46.7,2.6,0,3.9-.2l4.7,16.3c-2.8.4-5.7.6-8.5.6-35,0-63.4-28.4-63.4-63.4s28.4-63.4,63.4-63.4,1.7,0,2.6,0c0,0-2.6,16.7-2.6,16.7ZM132.2,43.2c25.7,8.1,44.4,32.1,44.4,60.5s-16,48.8-38.7,58.4l-4.6-16.2c15.8-7.5,26.7-23.6,26.7-42.2s-12.6-37.1-30.3-43.7l2.7-16.8h0Z" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<path id="modrinth" fill="#000" d="M362,74.2c11.7,0,21.1,3.3,27.9,10.1,6.8,7,10.3,17.1,10.3,30.6v53.3h-21.8v-50.5c0-8.2-1.7-14.3-5.4-18.4-3.6-4-8.7-6.1-15.5-6.1s-13.2,2.4-17.6,7.1c-4.4,4.9-6.6,11.8-6.6,20.7v47.2h-21.8v-50.5c0-8.2-1.7-14.3-5.4-18.4-3.6-4-8.7-6.1-15.5-6.1s-13.2,2.4-17.6,7.1c-4.4,4.7-6.6,11.7-6.6,20.7v47.2h-21.8v-93h20.7v11.8c3.5-4.2,7.8-7.3,13.1-9.6,5.2-2.3,11-3.3,17.4-3.3s13.2,1.2,18.6,3.8c5.4,2.8,9.6,6.6,12.7,11.7,3.8-4.9,8.7-8.7,14.8-11.5,6.1-2.6,12.7-4,20-4h0ZM470.1,169.4c-9.4,0-17.9-2.1-25.4-6.1-7.4-3.9-13.5-9.8-17.6-17.1-4.4-7.1-6.4-15.3-6.4-24.6s2.1-17.4,6.4-24.5c4.2-7.2,10.3-13,17.6-16.9,7.5-4,16-6.1,25.4-6.1s18.1,2.1,25.6,6.1c7.5,4,13.4,9.8,17.8,16.9,4.2,7.1,6.3,15.3,6.3,24.5s-2.1,17.4-6.3,24.6c-4.4,7.3-10.3,13.1-17.8,17.1-7.5,4-16,6.1-25.6,6.1h0ZM470.1,150.8c8,0,14.6-2.6,19.9-8s7.8-12.4,7.8-21.1-2.6-15.7-7.8-21.1-11.8-8-19.9-8-14.6,2.6-19.7,8c-5.2,5.4-7.8,12.4-7.8,21.1s2.6,15.7,7.8,21.1c5,5.4,11.7,8,19.7,8ZM631.2,39v129.2h-20.9v-12c-3.6,4.3-8.1,7.7-13.2,9.9-5.4,2.3-11.1,3.3-17.6,3.3s-16.9-1.9-24-5.9-12.9-9.6-16.9-16.9c-4-7.1-6.1-15.5-6.1-24.9s2.1-17.8,6.1-24.9c4-7.1,9.8-12.7,16.9-16.7s15.1-5.9,24-5.9,11.8,1.1,16.9,3.1c5,2.1,9.5,5.3,13.1,9.4v-47.7h21.8,0ZM582.1,150.8c5.2,0,9.9-1.2,14.1-3.7,4.2-2.3,7.5-5.7,9.9-10.1,2.4-4.4,3.7-9.6,3.7-15.3s-1.2-11-3.7-15.3c-2.4-4.3-5.8-7.8-9.9-10.3-4.2-2.3-8.9-3.5-14.1-3.5s-9.9,1.2-14.1,3.5c-4.2,2.4-7.5,5.9-9.9,10.3-2.4,4.3-3.7,9.6-3.7,15.3s1.2,11,3.7,15.3c2.4,4.4,5.8,7.8,9.9,10.1,4.2,2.4,8.9,3.7,14.1,3.7ZM679.9,88.8c6.3-9.8,17.2-14.6,33.1-14.6v20.7c-1.7-.3-3.3-.5-5-.5-8.5,0-15.2,2.4-19.8,7.3-4.7,5.1-7.1,12.2-7.1,21.4v45.1h-21.8v-93h20.7v13.6h0ZM730.9,75.2h21.8v93h-21.8s0-93,0-93ZM741.9,59.9c-4,0-7.3-1.2-9.9-3.8-2.6-2.4-4-5.7-4-9.2,0-3.7,1.4-6.8,4-9.4,2.6-2.4,5.9-3.7,9.9-3.7s7.3,1.2,9.9,3.5c2.6,2.4,4,5.4,4,9s-1.2,7-3.8,9.6c-2.6,2.6-6.1,4-10.1,4ZM833.5,74.2c11.7,0,21.1,3.5,28.2,10.3s10.6,17.1,10.6,30.5v53.3h-21.8v-50.5c0-8.2-1.9-14.3-5.7-18.4-3.8-4-9.2-6.1-16.4-6.1s-14.3,2.4-19,7.1c-4.7,4.9-7,11.8-7,20.9v47h-21.8v-93h20.7v12c3.7-4.3,8.2-7.5,13.6-9.8s11.7-3.3,18.5-3.3h0ZM955.3,163.2c-2.7,2.2-5.9,3.8-9.2,4.7-3.8,1.1-7.7,1.6-11.7,1.6-10.3,0-18.1-2.6-23.7-8-5.6-5.4-8.4-13.1-8.4-23.3v-44.8h-15.3v-17.4h15.3v-21.2h21.8v21.2h24.9v17.4h-24.9v44.2c0,4.5,1,8,3.3,10.3,2.3,2.4,5.4,3.6,9.6,3.6s8.9-1.2,12.2-3.8l6.1,15.5h0ZM1025.5,74.2c11.7,0,21.1,3.5,28.2,10.3s10.6,17.1,10.6,30.5v53.3h-21.8v-50.5c0-8.2-1.9-14.3-5.8-18.4-3.8-4-9.2-6.1-16.4-6.1s-14.3,2.4-19,7.1c-4.7,4.9-7,11.8-7,20.9v47h-21.8V39h21.8v47c3.7-3.8,8-6.8,13.4-8.9,5.2-1.9,11.1-3,17.6-3h0Z"/>
|
||||
<g id="docs" fill="#00af5c">
|
||||
<path d="M1177.8,169.7c-8.9,0-16.9-2-24-6-7.1-4-12.7-9.5-16.7-16.6-4-7.1-6.1-15.4-6.1-25s2-17.9,6.1-25c4-7.1,9.6-12.6,16.7-16.5,7.1-3.9,15.1-5.9,24-5.9s14.7,1.7,20.8,5.1c6.1,3.4,11,8.6,14.6,15.5,3.6,6.9,5.4,15.9,5.4,26.7s-1.7,19.6-5.2,26.6c-3.5,7-8.3,12.2-14.4,15.7-6.1,3.5-13.2,5.2-21.2,5.2ZM1180.4,151.2c5.2,0,9.9-1.2,14-3.5,4.1-2.3,7.4-5.7,9.9-10.1,2.5-4.4,3.7-9.5,3.7-15.4s-1.2-11.2-3.7-15.5c-2.5-4.3-5.8-7.7-9.9-10-4.1-2.3-8.8-3.5-14-3.5s-9.9,1.2-14,3.5c-4.1,2.3-7.4,5.6-9.9,10-2.5,4.3-3.7,9.5-3.7,15.5s1.2,11,3.7,15.4c2.5,4.4,5.8,7.8,9.9,10.1,4.1,2.3,8.8,3.5,14,3.5ZM1208.6,168.5v-21.9l.9-24.6-1.7-24.6v-57.6h21.5v128.8h-20.7Z"/>
|
||||
<path d="M1299.5,169.7c-9.5,0-17.9-2.1-25.3-6.2-7.4-4.1-13.2-9.7-17.5-16.9-4.3-7.2-6.4-15.3-6.4-24.5s2.1-17.4,6.4-24.6c4.3-7.1,10.1-12.7,17.5-16.7,7.4-4,15.8-6.1,25.3-6.1s18.1,2,25.6,6.1c7.5,4.1,13.3,9.6,17.5,16.7,4.2,7.1,6.3,15.3,6.3,24.6s-2.1,17.3-6.3,24.5c-4.2,7.2-10.1,12.8-17.5,16.9-7.5,4.1-16,6.2-25.6,6.2ZM1299.5,151.2c5.3,0,10.1-1.2,14.2-3.5,4.2-2.3,7.4-5.7,9.8-10.1,2.4-4.4,3.6-9.5,3.6-15.4s-1.2-11.2-3.6-15.5c-2.4-4.3-5.6-7.7-9.8-10-4.2-2.3-8.8-3.5-14.1-3.5s-10,1.2-14.1,3.5c-4.1,2.3-7.4,5.6-9.8,10-2.4,4.3-3.6,9.5-3.6,15.5s1.2,11,3.6,15.4c2.4,4.4,5.7,7.8,9.8,10.1,4.1,2.3,8.8,3.5,14,3.5Z"/>
|
||||
<path d="M1412.3,169.7c-9.7,0-18.4-2.1-25.9-6.2-7.6-4.1-13.5-9.7-17.8-16.9-4.3-7.2-6.4-15.3-6.4-24.5s2.1-17.4,6.4-24.6c4.3-7.1,10.2-12.7,17.8-16.7,7.6-4,16.2-6.1,25.9-6.1s17,1.8,23.9,5.5c6.9,3.6,12.1,9,15.7,16.1l-16.7,9.7c-2.8-4.4-6.2-7.6-10.2-9.7-4-2.1-8.3-3.1-12.9-3.1s-10.1,1.2-14.4,3.5c-4.3,2.3-7.6,5.6-10.1,10-2.4,4.3-3.6,9.5-3.6,15.5s1.2,11.2,3.6,15.5c2.4,4.3,5.8,7.7,10.1,10,4.3,2.3,9.1,3.5,14.4,3.5s8.9-1,12.9-3.1c4-2.1,7.4-5.3,10.2-9.7l16.7,9.7c-3.6,6.9-8.8,12.3-15.7,16.1-6.9,3.8-14.8,5.6-23.9,5.6Z"/>
|
||||
<path d="M1499.2,169.7c-7.8,0-15.2-1-22.3-3-7.1-2-12.8-4.5-16.9-7.4l8.3-16.5c4.2,2.7,9.1,4.9,14.9,6.6,5.8,1.7,11.6,2.6,17.4,2.6s11.8-.9,14.8-2.8c3.1-1.8,4.6-4.3,4.6-7.5s-1-4.5-3.1-5.8c-2.1-1.3-4.8-2.3-8.2-3-3.4-.7-7.1-1.3-11.2-1.9-4.1-.6-8.2-1.4-12.3-2.3-4.1-1-7.8-2.4-11.2-4.3-3.4-1.9-6.1-4.5-8.2-7.7-2.1-3.2-3.1-7.5-3.1-12.8s1.7-11,5-15.4c3.4-4.3,8.1-7.7,14.1-10.1,6.1-2.4,13.3-3.6,21.6-3.6s12.6.7,18.9,2.1c6.4,1.4,11.6,3.4,15.8,5.9l-8.3,16.5c-4.4-2.7-8.8-4.5-13.3-5.5-4.5-1-8.9-1.5-13.3-1.5-6.6,0-11.5,1-14.7,2.9-3.2,2-4.8,4.5-4.8,7.5s1,4.9,3.1,6.2c2.1,1.4,4.8,2.5,8.2,3.3,3.4.8,7.1,1.5,11.2,2,4.1.5,8.2,1.3,12.2,2.3,4,1,7.8,2.5,11.2,4.3,3.4,1.8,6.2,4.3,8.2,7.5,2.1,3.2,3.1,7.5,3.1,12.7s-1.7,10.8-5.1,15.1c-3.4,4.3-8.2,7.6-14.5,10-6.2,2.4-13.7,3.6-22.4,3.6Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
6
apps/docs/src/content/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ schema: docsSchema() }),
|
||||
};
|
||||
4
apps/docs/src/content/docs/contributing/daedalus.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: Daedalus (Metadata service)
|
||||
description: Guide for contributing to Modrinth's frontend
|
||||
---
|
||||
52
apps/docs/src/content/docs/contributing/getting-started.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: Getting started
|
||||
description: How can I contribute to Modrinth?
|
||||
sidebar:
|
||||
order: 1
|
||||
---
|
||||
|
||||
# Contributing to Modrinth
|
||||
|
||||
Every public-facing aspect of Modrinth, including everything from our [API/backend][labrinth] and [frontend][knossos] to our [Gradle plugin][minotaur] and [launcher][theseus], is released under free and open source licenses on [GitHub]. As such, we love contributions from community members! Before proceeding to do so, though, there are a number of things you'll want to keep in mind throughout the process, as well as some details specific to certain projects.
|
||||
|
||||
## Things to keep in mind
|
||||
|
||||
### Consult people on Discord
|
||||
|
||||
There are a number of reasons to want to consult with people on our [Discord] before making a pull request. For example, if you're not sure whether something is a good idea or not, if you're not sure how to implement something, or if you can't get something working, these would all be good opportunities to create a thread in the `#development` forum channel.
|
||||
|
||||
If you intend to work on new features, to make significant codebase changes, or to make UI/design changes, please open a discussion thread first to ensure your work is put to its best use.
|
||||
|
||||
### Don't get discouraged
|
||||
|
||||
At times, pull requests may be rejected or left unmerged for a variation of reasons. Don't take it personally, and don't get discouraged! Sometimes a contribution just isn't the right fit for the time, or it might have just been lost in the mess of other things to do. Remember, the core Modrinth team are often busy, whether it be on a specific project/task or on external factors such as offline responsibilities. It all falls back to the same thing: don't get discouraged!
|
||||
|
||||
### Code isn't the only way to contribute
|
||||
|
||||
You don't need to know how to program to contribute to Modrinth. Quality assurance, supporting the community, coming up with feature ideas, and making sure your voice is heard in public decisions are all great ways to contribute to Modrinth. If you find bugs, reporting them on the appropriate issue tracker is your responsibility; however, remember that potential security breaches and exploits must instead be reported in accordance with our [security policy](https://modrinth.com/legal/security).
|
||||
|
||||
## Project-specific details
|
||||
|
||||
If you wish to contribute code to a specific project, here's the place to start. Most of Modrinth is written in the [Rust language](https://www.rust-lang.org), but some things are written in other languages/frameworks like [Nuxt.js](https://nuxtjs.org) or Java.
|
||||
|
||||
Most of Modrinth's code is in our monorepo, which you can find [here](https://github.com/modrinth/code). Our monorepo is powered by [Turborepo](https://turborepo.org).
|
||||
|
||||
Follow the project-specific instructions below to get started:
|
||||
- [Knossos (frontend)](/contributing/knossos)
|
||||
- [Theseus (Modrinth App)](/contributing/theseus)
|
||||
- [Minotaur (Gradle plugin)](/contributing/minotaur)
|
||||
- [Labrinth (API/backend)](/contributing/labrinth)
|
||||
- [Daedalus (Metadata service)](/contributing/daedalus)
|
||||
|
||||
### Documentation
|
||||
|
||||
The [documentation](https://github.com/modrinth/docs) (which you are reading right now!) is the place to find any and all general information about Modrinth and its API. The instructions are largely the same as [knossos](#knossos-frontend), except that the docs have no lint.
|
||||
|
||||
[Discord]: https://discord.modrinth.com
|
||||
[GitHub]: https://github.com/modrinth
|
||||
[knossos]: https://github.com/modrinth/code/tree/main/apps/frontend
|
||||
[labrinth]: https://github.com/modrinth/labrinth
|
||||
[theseus]: https://github.com/modrinth/theseus
|
||||
[minotaur]: https://github.com/modrinth/minotaur
|
||||
[Rust]: https://www.rust-lang.org/tools/install
|
||||
[pnpm]: https://pnpm.io
|
||||
35
apps/docs/src/content/docs/contributing/knossos.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: Knossos (Frontend)
|
||||
description: Guide for contributing to Modrinth's frontend
|
||||
---
|
||||
|
||||
This project is our [monorepo](https://github.com/modrinth/code). You can find the frontend in the `apps/frontend` directory.
|
||||
|
||||
[knossos] is the Nuxt.js frontend. You will need to install [pnpm] and run the standard commands:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run web:dev
|
||||
```
|
||||
|
||||
Once that's done, you'll be serving knossos on `localhost:3000` with hot reloading. You can replace the `dev` in `pnpm run dev` with `build` to build for a production server and `start` to start the server. You can also use `pnpm run lint` to find any eslint problems, and `pnpm run fix` to try automatically fixing those problems.
|
||||
|
||||
<details>
|
||||
<summary>.env variables & command line options</summary>
|
||||
|
||||
#### Basic configuration
|
||||
|
||||
`SITE_URL`: The URL of the site (used for auth redirects). Default: `http://localhost:3000`
|
||||
`BASE_URL`: The base URL for the API. Default: `https://staging-api.modrinth.com/v2/`
|
||||
`BROWSER_BASE_URL`: The base URL for the API used in the browser. Default: `https://staging-api.modrinth.com/v2/`
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready to open a PR?
|
||||
|
||||
If you're prepared to contribute by submitting a pull request, ensure you have met the following criteria:
|
||||
|
||||
- `pnpm run fix` has been run.
|
||||
|
||||
[knossos]: https://github.com/modrinth/code/tree/main/apps/frontend
|
||||
[pnpm]: https://pnpm.io
|
||||
115
apps/docs/src/content/docs/contributing/labrinth.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Labrinth (API)
|
||||
description: Guide for contributing to Modrinth's backend
|
||||
---
|
||||
|
||||
|
||||
[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
|
||||
|
||||
Now, you'll have to install the sqlx CLI, which can be done with cargo:
|
||||
|
||||
```bash
|
||||
cargo install --git https://github.com/launchbadge/sqlx sqlx-cli --no-default-features --features postgres,rustls
|
||||
```
|
||||
|
||||
From there, you can create the database and perform all database migrations with one simple command:
|
||||
|
||||
```bash
|
||||
sqlx database setup
|
||||
```
|
||||
|
||||
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
|
||||
|
||||
To enable labrinth to create a project, you need to add two things.
|
||||
|
||||
1. An entry in the `loaders` table.
|
||||
2. An entry in the `loaders_project_types` table.
|
||||
|
||||
A minimal setup can be done from the command line with [psql](https://www.postgresql.org/docs/current/app-psql.html):
|
||||
|
||||
```bash
|
||||
psql --host=localhost --port=5432 -U <username, default is labrinth> -W
|
||||
```
|
||||
|
||||
The default password for the database is `labrinth`. Once you've connected, run
|
||||
|
||||
```sql
|
||||
INSERT INTO loaders VALUES (0, 'placeholder_loader');
|
||||
INSERT INTO loaders_project_types VALUES (0, 1); -- modloader id, supported type id
|
||||
INSERT INTO categories VALUES (0, 'placeholder_category', 1); -- category id, category, project type id
|
||||
```
|
||||
|
||||
This will initialize your database with a modloader called 'placeholder_loader', with id 0, and marked as supporting mods only. It will also create a category called 'placeholder_category' that is marked as supporting mods only
|
||||
If you would like 'placeholder_loader' to be marked as supporting modpacks too, run
|
||||
|
||||
```sql
|
||||
INSERT INTO loaders_project_types VALUES (0, 2); -- modloader id, supported type id
|
||||
```
|
||||
|
||||
If you would like 'placeholder_category' to be marked as supporting modpacks too, run
|
||||
|
||||
```sql
|
||||
INSERT INTO categories VALUES (0, 'placeholder_category', 2); -- modloader id, supported type id
|
||||
```
|
||||
|
||||
The majority of configuration is done at runtime using [dotenvy](https://crates.io/crates/dotenvy) and the `.env` file. Each of the variables and what they do can be found in the dropdown below. Additionally, there are three command line options that can be used to specify to MeiliSearch what you want to do.
|
||||
|
||||
<details>
|
||||
<summary>.env variables & command line options</summary>
|
||||
|
||||
#### Basic configuration
|
||||
|
||||
`DEBUG`: Whether debugging tools should be enabled
|
||||
`RUST_LOG`: Specifies what information to log, from rust's [`env-logger`](https://github.com/env-logger-rs/env_logger); a reasonable default is `info,sqlx::query=warn`
|
||||
`SITE_URL`: The main URL to be used for CORS
|
||||
`CDN_URL`: The publicly accessible base URL for files uploaded to the CDN
|
||||
`MODERATION_DISCORD_WEBHOOK`: The URL for a Discord webhook where projects pending approval will be sent
|
||||
`CLOUDFLARE_INTEGRATION`: Whether labrinth should integrate with Cloudflare's spam protection
|
||||
`DATABASE_URL`: The URL for the PostgreSQL database
|
||||
`DATABASE_MIN_CONNECTIONS`: The minimum number of concurrent connections allowed to the database at the same time
|
||||
`DATABASE_MAX_CONNECTIONS`: The maximum number of concurrent connections allowed to the database at the same time
|
||||
`MEILISEARCH_ADDR`: The URL for the MeiliSearch instance used for search
|
||||
`MEILISEARCH_KEY`: The name that MeiliSearch is given
|
||||
`BIND_ADDR`: The bind address for the server. Supports both IPv4 and IPv6
|
||||
`MOCK_FILE_PATH`: The path used to store uploaded files; this has no default value and will panic if unspecified
|
||||
|
||||
#### CDN options
|
||||
|
||||
`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local`, `backblaze`, or `s3`, but defaults to `local`
|
||||
|
||||
The Backblaze and S3 configuration options are fairly self-explanatory in name, so here's simply their names:
|
||||
`BACKBLAZE_KEY_ID`, `BACKBLAZE_KEY`, `BACKBLAZE_BUCKET_ID`
|
||||
`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_BUCKET_NAME`
|
||||
|
||||
#### Search, OAuth, and miscellaneous options
|
||||
|
||||
`LOCAL_INDEX_INTERVAL`: The interval, in seconds, at which the local database is reindexed for searching. Defaults to `3600` seconds (1 hour).
|
||||
`VERSION_INDEX_INTERVAL`: The interval, in seconds, at which versions are reindexed for searching. Defaults to `1800` seconds (30 minutes).
|
||||
|
||||
The OAuth configuration options are fairly self-explanatory. For help setting up authentication, please contact us on [Discord].
|
||||
|
||||
`RATE_LIMIT_IGNORE_IPS`: An array of IPs that should have a lower rate limit factor. This can be useful for allowing the front-end to have a lower rate limit to prevent accidental timeouts.
|
||||
|
||||
#### Command line options
|
||||
|
||||
`--skip-first-index`: Skips indexing the local database on startup. This is useful to prevent doing unnecessary work when frequently restarting.
|
||||
`--reconfigure-indices`: Resets the MeiliSearch settings for the search indices and exits.
|
||||
`--reset-indices`: Resets the MeiliSearch indices and exits; this clears all previously indexed mods.
|
||||
|
||||
</details>
|
||||
|
||||
#### Ready to open a PR?
|
||||
|
||||
If you're prepared to contribute by submitting a pull request, ensure you have met the following criteria:
|
||||
|
||||
- `cargo fmt` has been run.
|
||||
- `cargo clippy` has been run.
|
||||
- `cargo check` has been run.
|
||||
- `cargo sqlx prepare` has been run.
|
||||
|
||||
> Note: If you encounter issues with `sqlx` saying 'no queries found' after running `cargo sqlx prepare`, you may need to ensure the installed version of `sqlx-cli` matches the current version of `sqlx` used [in labrinth](https://github.com/modrinth/labrinth/blob/master/Cargo.toml).
|
||||
|
||||
[Discord]: https://discord.modrinth.com
|
||||
[GitHub]: https://github.com/modrinth
|
||||
[labrinth]: https://github.com/modrinth/labrinth
|
||||
[Rust]: https://www.rust-lang.org/tools/install
|
||||
10
apps/docs/src/content/docs/contributing/minotaur.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Minotaur (Gradle plugin)
|
||||
description: Guide for contributing to Modrinth's gradle plugin
|
||||
---
|
||||
|
||||
[Minotaur][minotaur] is the Gradle plugin used to automatically publish artifacts to Modrinth. To run your copy of the plugin in a project, publish it to your local Maven with `./gradlew publishToMavenLocal` and add `mavenLocal()` to your buildscript.
|
||||
|
||||
Minotaur contains two test environments within it - one with ForgeGradle and one with Fabric Loom. You may tweak with these environments to test whatever you may be trying; just make sure that the `modrinth` task within each still functions properly. GitHub Actions will validate this if you're making a pull request, so you may want to use [`act pull_request`](https://github.com/nektos/act) to test them locally.
|
||||
|
||||
[minotaur]: https://github.com/modrinth/minotaur
|
||||
43
apps/docs/src/content/docs/contributing/theseus.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Theseus (Modrinth App)
|
||||
description: Guide for contributing to Modrinth's desktop app
|
||||
---
|
||||
|
||||
This project is our [monorepo](https://github.com/modrinth/code).
|
||||
|
||||
[theseus] is the Tauri-based launcher that lets users conveniently play any mod or modpack on Modrinth. It uses the Rust-based Tauri as the backend and Vue.js as the frontend. To get started, install [pnpm], [Rust], and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your system. Then, run the following commands:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run app:dev
|
||||
```
|
||||
|
||||
Once the commands finish, you'll be viewing a Tauri window with Nuxt.js hot reloading.
|
||||
|
||||
You can use `pnpm run lint` to find any eslint problems, and `pnpm run fix` to try automatically fixing those problems.
|
||||
|
||||
### Theseus Architecture
|
||||
|
||||
Theseus is split up into three parts:
|
||||
- `apps/app-frontend`: The Vue.JS frontend for the app
|
||||
- `packages/app-lib`: The library holding all the core logic for the desktop app
|
||||
- `apps/app`: The Tauri-based Rust app. This primarily wraps around the library with some additional logic for Tauri.
|
||||
|
||||
The app's internal database is stored in SQLite. For production builds, this is found at <APPDIR>/app.db.
|
||||
|
||||
When running SQLX commands, be sure to set the `DATABASE_URL` environment variable to the path of the database.
|
||||
|
||||
You can edit the app's data directory using the `THESEUS_CONFIG_DIR` environment variable.
|
||||
|
||||
#### Ready to open a PR?
|
||||
|
||||
If you're prepared to contribute by submitting a pull request, ensure you have met the following criteria:
|
||||
|
||||
- Run `pnpm run fix` to address any fixable issues automatically.
|
||||
- Run `cargo fmt` to format Rust-related code.
|
||||
- Run `cargo clippy` to validate Rust-related code.
|
||||
- Run `cargo sqlx prepare --package theseus` if you've changed any SQL code to validate statements.
|
||||
|
||||
[theseus]: https://github.com/modrinth/code/tree/main/apps/app
|
||||
[Rust]: https://www.rust-lang.org/tools/install
|
||||
[pnpm]: https://pnpm.io
|
||||
15
apps/docs/src/content/docs/index.mdx
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: Modrinth docs
|
||||
description: Developer documentation for Modrinth!
|
||||
template: splash
|
||||
hero:
|
||||
tagline: Developer documentation for Modrinth
|
||||
actions:
|
||||
- text: API documentation
|
||||
link: /api
|
||||
icon: right-arrow
|
||||
- text: Get support with Modrinth
|
||||
link: https://support.modrinth.com
|
||||
icon: external
|
||||
variant: minimal
|
||||
---
|
||||
2
apps/docs/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
54
apps/docs/src/styles/modrinth.css
Normal file
@@ -0,0 +1,54 @@
|
||||
:root,
|
||||
::backdrop,
|
||||
:root[data-theme='light'],
|
||||
[data-theme='light'] ::backdrop{
|
||||
--sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
|
||||
--sl-color-white: var(--color-contrast); /* “white” */
|
||||
--sl-color-gray-1: var(--color-base);
|
||||
--sl-color-gray-2: var(--color-base);
|
||||
--sl-color-gray-3: var(--color-base);
|
||||
--sl-color-gray-4: var(--color-raised-bg);
|
||||
--sl-color-gray-5: var(--color-button-bg);
|
||||
--sl-color-gray-6: var(--color-raised-bg);
|
||||
--sl-color-black: var(--color-accent-contrast);
|
||||
|
||||
--sl-color-accent-low: var(--color-green-highlight);
|
||||
--sl-color-accent: var(--color-brand);
|
||||
--sl-color-accent-high: var(--color-brand-highlight);
|
||||
|
||||
--sl-color-orange-low: var(--color-orange-highlight);
|
||||
--sl-color-orange: var(--color-orange);
|
||||
--sl-color-orange-high: var(--color-orange-highlight);
|
||||
|
||||
--sl-color-green-low: var(--color-green-highlight);
|
||||
--sl-color-green: var(--color-green);
|
||||
--sl-color-green-high: var(--color-green-highlight);
|
||||
|
||||
--sl-color-blue-low: var(--color-blue-highlight);
|
||||
--sl-color-blue: var(--color-blue);
|
||||
--sl-color-blue-high: var(--color-blue-highlight);
|
||||
|
||||
--sl-color-purple-low: var(--color-purple-highlight);
|
||||
--sl-color-purple: var(--color-purple);
|
||||
--sl-color-purple-high: var(--color-purple-highlight);
|
||||
|
||||
--sl-color-red-low: var(--color-red-highlight);
|
||||
--sl-color-red: var(--color-red);
|
||||
--sl-color-red-high: var(--color-red-highlight);
|
||||
|
||||
--sl-color-text: var(--color-base);
|
||||
--sl-color-text-accent: var(--color-brand);
|
||||
--sl-color-text-invert: var(--color-accent-contrast);
|
||||
--sl-color-bg: var(--color-bg);
|
||||
--sl-color-bg-nav: var(--color-raised-bg);
|
||||
--sl-color-bg-sidebar: var(--color-raised-bg);
|
||||
--sl-color-bg-inline-code: var(--color-button-bg);
|
||||
--sl-color-bg-accent: var(--color-brand-highlight);
|
||||
}
|
||||
|
||||
:root[data-theme='light'],
|
||||
[data-theme='light'] ::backdrop{
|
||||
--sl-color-bg: var(--color-raised-bg);
|
||||
}
|
||||
3
apps/docs/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict"
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["../../packages/eslint-config-custom/nuxt.js"],
|
||||
rules: {
|
||||
"import/no-unresolved": "off",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -176,7 +176,6 @@ export default defineNuxtConfig({
|
||||
$fetch(`${API_URL}projects_random?count=60`, headers),
|
||||
$fetch(`${API_URL}search?limit=3&query=leave&index=relevance`, headers),
|
||||
$fetch(`${API_URL}search?limit=3&query=&index=updated`, headers),
|
||||
// TODO: dehardcode
|
||||
$fetch(`${API_URL.replace("/v2/", "/_internal/")}billing/products`, headers),
|
||||
]);
|
||||
|
||||
@@ -321,8 +320,10 @@ export default defineNuxtConfig({
|
||||
apiBaseUrl: process.env.BASE_URL ?? globalThis.BASE_URL ?? getApiUrl(),
|
||||
// @ts-ignore
|
||||
rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY,
|
||||
pyroBaseUrl: process.env.PYRO_BASE_URL,
|
||||
public: {
|
||||
apiBaseUrl: getApiUrl(),
|
||||
pyroBaseUrl: process.env.PYRO_BASE_URL,
|
||||
siteUrl: getDomain(),
|
||||
production: isProduction(),
|
||||
featureFlagOverrides: getFeatureFlagOverrides(),
|
||||
@@ -361,7 +362,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
modules: ["@vintl/nuxt", "@nuxtjs/turnstile"],
|
||||
modules: ["@vintl/nuxt", "@nuxtjs/turnstile", "@pinia/nuxt"],
|
||||
vintl: {
|
||||
defaultLocale: "en-US",
|
||||
locales: [
|
||||
@@ -462,6 +463,7 @@ function getDomain() {
|
||||
return "https://modrinth.com";
|
||||
}
|
||||
} else {
|
||||
return "http://localhost:3000";
|
||||
const port = process.env.PORT || 3000;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@nuxt/devtools": "^1.3.3",
|
||||
"@nuxtjs/turnstile": "^0.8.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^20.1.0",
|
||||
"@vintl/compact-number": "^2.0.5",
|
||||
"@vintl/how-ago": "^3.0.1",
|
||||
@@ -38,8 +39,13 @@
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"dayjs": "^1.11.7",
|
||||
"dompurify": "^3.1.7",
|
||||
"floating-vue": "2.0.0-beta.20",
|
||||
"fuse.js": "^6.6.2",
|
||||
"highlight.js": "^11.7.0",
|
||||
@@ -48,9 +54,12 @@
|
||||
"jszip": "^3.10.1",
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"vue-multiselect": "3.0.0",
|
||||
"vue-multiselect": "3.0.0-alpha.2",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vue3-apexcharts": "^1.5.2",
|
||||
"xss": "^1.0.14"
|
||||
}
|
||||
|
||||
BIN
apps/frontend/src/assets/images/games/rinth.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/frontend/src/assets/images/servers/this-is-fine.gif
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
@@ -52,6 +52,14 @@ useHead({
|
||||
|
||||
onMounted(() => {
|
||||
window.tude = window.tude || { cmd: [] };
|
||||
window.Raven = window.Raven || { cmd: [] };
|
||||
|
||||
window.Raven.cmd.push(({ config }) => {
|
||||
config.setCustom({
|
||||
param1: "web",
|
||||
});
|
||||
});
|
||||
|
||||
tude.cmd.push(function () {
|
||||
tude.refreshAdsViaDivMappings([
|
||||
{
|
||||
|
||||
@@ -119,7 +119,7 @@ export default {
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--color-brand-inverted);
|
||||
color: var(--color-accent-contrast, var(--color-brand-inverted));
|
||||
stroke-width: 0.2rem;
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
|
||||
@@ -45,7 +45,7 @@ export default {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-code-bg);
|
||||
width: min-content;
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
user-select: text;
|
||||
transition:
|
||||
@@ -55,7 +55,6 @@ export default {
|
||||
outline 0.2s ease-in-out;
|
||||
|
||||
span {
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<nav
|
||||
class="experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
ref="scrollContainer"
|
||||
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
@@ -18,7 +19,9 @@
|
||||
<span class="text-nowrap">{{ link.label }}</span>
|
||||
</NuxtLink>
|
||||
<div
|
||||
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'}`"
|
||||
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'
|
||||
}`"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
top: sliderTopPx,
|
||||
@@ -32,6 +35,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
|
||||
const route = useNativeRoute();
|
||||
|
||||
interface Tab {
|
||||
@@ -47,12 +52,13 @@ const props = defineProps<{
|
||||
query?: string;
|
||||
}>();
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
const sliderLeft = ref(4);
|
||||
const sliderTop = ref(4);
|
||||
const sliderRight = ref(4);
|
||||
const sliderBottom = ref(4);
|
||||
const activeIndex = ref(-1);
|
||||
const oldIndex = ref(-1);
|
||||
const subpageSelected = ref(false);
|
||||
|
||||
const filteredLinks = computed(() =>
|
||||
@@ -63,6 +69,8 @@ const sliderTopPx = computed(() => `${sliderTop.value}px`);
|
||||
const sliderRightPx = computed(() => `${sliderRight.value}px`);
|
||||
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
|
||||
|
||||
const tabLinkElements = ref();
|
||||
|
||||
function pickLink() {
|
||||
let index = -1;
|
||||
subpageSelected.value = false;
|
||||
@@ -86,16 +94,13 @@ function pickLink() {
|
||||
if (activeIndex.value !== -1) {
|
||||
startAnimation();
|
||||
} else {
|
||||
oldIndex.value = -1;
|
||||
sliderLeft.value = 0;
|
||||
sliderRight.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const tabLinkElements = ref();
|
||||
|
||||
function startAnimation() {
|
||||
const el = tabLinkElements.value[activeIndex.value].$el;
|
||||
const el = tabLinkElements.value[activeIndex.value]?.$el;
|
||||
|
||||
if (!el || !el.offsetParent) return;
|
||||
|
||||
@@ -141,21 +146,19 @@ function startAnimation() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", pickLink);
|
||||
pickLink();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", pickLink);
|
||||
});
|
||||
|
||||
watch(route, () => pickLink());
|
||||
watch(
|
||||
() => route.path,
|
||||
() => pickLink(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
/* Delay on opacity is to hide any jankiness as the page loads */
|
||||
transition:
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -571,6 +571,10 @@ function getMessages() {
|
||||
gap: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.notification__actions .iconified-button.square-button svg {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.unknown-type {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
@@ -384,6 +384,8 @@ const submitForReview = async () => {
|
||||
}
|
||||
|
||||
.author-actions {
|
||||
margin-top: var(--spacing-card-md);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,6 @@
|
||||
:data="analytics.formattedData.value.revenue.chart.data"
|
||||
:labels="analytics.formattedData.value.revenue.chart.labels"
|
||||
is-money
|
||||
suffix="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><line x1='12' y1='2' x2='12' y2='22'></line><path d='M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6'></path></svg>"
|
||||
:colors="
|
||||
isUsingProjectColors
|
||||
? analytics.formattedData.value.revenue.chart.colors
|
||||
@@ -193,15 +192,20 @@
|
||||
class="country-value"
|
||||
>
|
||||
<div class="country-flag-container">
|
||||
<img
|
||||
:src="
|
||||
name.toLowerCase() === 'xx' || !name
|
||||
? 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
: countryCodeToFlag(name)
|
||||
"
|
||||
alt="Hidden country"
|
||||
class="country-flag"
|
||||
/>
|
||||
<template v-if="name.toLowerCase() === 'xx' || !name">
|
||||
<img
|
||||
src="https://cdn.modrinth.com/placeholder-banner.svg"
|
||||
alt="Placeholder flag"
|
||||
class="country-flag"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
:src="countryCodeToFlag(name)"
|
||||
:alt="`${countryCodeToName(name)}'s flag`"
|
||||
class="country-flag"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="country-text">
|
||||
<strong class="country-name"
|
||||
@@ -247,15 +251,20 @@
|
||||
class="country-value"
|
||||
>
|
||||
<div class="country-flag-container">
|
||||
<img
|
||||
:src="
|
||||
name.toLowerCase() === 'xx' || !name
|
||||
? 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
: countryCodeToFlag(name)
|
||||
"
|
||||
alt="Hidden country"
|
||||
class="country-flag"
|
||||
/>
|
||||
<template v-if="name.toLowerCase() === 'xx' || !name">
|
||||
<img
|
||||
src="https://cdn.modrinth.com/placeholder-banner.svg"
|
||||
alt="Placeholder flag"
|
||||
class="country-flag"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
:src="countryCodeToFlag(name)"
|
||||
:alt="`${countryCodeToName(name)}'s flag`"
|
||||
class="country-flag"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="country-text">
|
||||
<strong class="country-name">
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Creating backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. Before 1.21"
|
||||
maxlength="64"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
If left empty, the backup name will default to
|
||||
<span class="font-semibold"> Backup #{{ newBackupAmount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isCreating" @click="createBackup">
|
||||
<PlusIcon />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hideModal">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, InfoIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupCreated"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const input = ref<HTMLInputElement>();
|
||||
const isCreating = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
const backupName = ref("");
|
||||
const newBackupAmount = computed(() =>
|
||||
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
|
||||
);
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
input.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
backupName.value = "";
|
||||
};
|
||||
|
||||
const createBackup = async () => {
|
||||
if (!backupName.value.trim()) {
|
||||
backupName.value = `Backup #${newBackupAmount.value}`;
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await props.server.backups?.create(backupName.value);
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupCreated", { success: true, message: "Backup created successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupCreated", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<NewModal ref="modal" danger header="Deleting backup">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-[#0e0e0ea4] p-6">
|
||||
<div class="text-2xl font-extrabold text-contrast">
|
||||
{{ backupName }}
|
||||
</div>
|
||||
<div class="flex gap-2 font-semibold text-contrast">
|
||||
<CalendarIcon />
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-end gap-4">
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isDeleting" @click="deleteBackup">
|
||||
<TrashIcon />
|
||||
Delete backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="hideModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { TrashIcon, CalendarIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupId: string;
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupDeleted"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const isDeleting = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
});
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
const deleteBackup = async () => {
|
||||
if (!props.backupId) {
|
||||
emit("backupDeleted", { success: false, message: "No backup selected" });
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await props.server.backups?.delete(props.backupId);
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupDeleted", { success: true, message: "Backup deleted successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupDeleted", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. Before 1.21"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isRenaming" @click="renameBackup">
|
||||
<SaveIcon />
|
||||
Rename backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hideModal">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { SaveIcon, XIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
currentBackupId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupRenamed"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const input = ref<HTMLInputElement>();
|
||||
const backupName = ref("");
|
||||
const isRenaming = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
input.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hideModal = () => {
|
||||
backupName.value = "";
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
const renameBackup = async () => {
|
||||
if (!backupName.value.trim() || !props.currentBackupId) {
|
||||
emit("backupRenamed", { success: false, message: "Backup name cannot be empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
isRenaming.value = true;
|
||||
try {
|
||||
await props.server.backups?.rename(props.currentBackupId, backupName.value);
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupRenamed", { success: true, message: "Backup renamed successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupRenamed", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isRenaming.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Restoring backup">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-bg p-6">
|
||||
<div class="text-2xl font-extrabold text-contrast">
|
||||
{{ backupName }}
|
||||
</div>
|
||||
<div class="flex gap-2 font-semibold text-contrast">
|
||||
<CalendarIcon />
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-end gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isRestoring" @click="restoreBackup">Restore backup</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="hideModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { CalendarIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupId: string;
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupRestored"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const isRestoring = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
});
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
const restoreBackup = async () => {
|
||||
if (!props.backupId) {
|
||||
emit("backupRestored", { success: false, message: "No backup selected" });
|
||||
return;
|
||||
}
|
||||
|
||||
isRestoring.value = true;
|
||||
try {
|
||||
await props.server.backups?.restore(props.backupId);
|
||||
hideModal();
|
||||
emit("backupRestored", { success: true, message: "Backup restored successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupRestored", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isRestoring.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
201
apps/frontend/src/components/ui/servers/BackupSettingsModal.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Editing auto backup settings">
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Auto backup</div>
|
||||
<p class="m-0">
|
||||
Automatically create a backup of your server every
|
||||
<strong>{{ autoBackupInterval == 1 ? "hour" : `${autoBackupInterval} hours` }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingSettings" class="py-2 text-sm text-secondary">Loading settings...</div>
|
||||
<template v-else>
|
||||
<input
|
||||
id="auto-backup-toggle"
|
||||
v-model="autoBackupEnabled"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Interval</div>
|
||||
<p class="m-0">
|
||||
The amount of hours between each backup. This will only backup your server if it has
|
||||
been modified since the last backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-contrast">
|
||||
<div
|
||||
class="flex w-fit items-center rounded-xl border border-solid border-button-border bg-table-alternateRow"
|
||||
>
|
||||
<button
|
||||
class="rounded-l-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
@click="autoBackupInterval = Math.max(autoBackupInterval - 1, 1)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="-2 0 18 2">
|
||||
<path
|
||||
d="M18,12H6"
|
||||
transform="translate(-5 -11)"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
id="auto-backup-interval"
|
||||
v-model="autoBackupInterval"
|
||||
class="w-16 !appearance-none text-center [&&]:bg-transparent [&&]:focus:shadow-none"
|
||||
type="number"
|
||||
style="-moz-appearance: textfield; appearance: none"
|
||||
min="1"
|
||||
max="24"
|
||||
step="1"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="rounded-r-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
@click="autoBackupInterval = Math.min(autoBackupInterval + 1, 24)"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</div>
|
||||
{{ autoBackupInterval == 1 ? "hour" : "hours" }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!hasChanges || isSaving" @click="saveSettings">
|
||||
<SaveIcon class="h-5 w-5" />
|
||||
{{ isSaving ? "Saving..." : "Save changes" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="isSaving" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["backups"]>;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
|
||||
const autoBackupEnabled = ref(false);
|
||||
const autoBackupInterval = ref(1);
|
||||
const isLoadingSettings = ref(true);
|
||||
const isSaving = ref(false);
|
||||
|
||||
const validatedBackupInterval = computed(() => {
|
||||
const roundedValue = Math.round(autoBackupInterval.value);
|
||||
|
||||
if (roundedValue < 1) {
|
||||
return 1;
|
||||
} else if (roundedValue > 24) {
|
||||
return 24;
|
||||
}
|
||||
return roundedValue;
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (!initialSettings.value) return false;
|
||||
|
||||
return (
|
||||
autoBackupEnabled.value !== initialSettings.value.enabled ||
|
||||
autoBackupInterval.value !== initialSettings.value.interval
|
||||
);
|
||||
});
|
||||
|
||||
const fetchSettings = async () => {
|
||||
isLoadingSettings.value = true;
|
||||
try {
|
||||
const settings = await props.server.backups?.getAutoBackup();
|
||||
initialSettings.value = settings as { interval: number; enabled: boolean };
|
||||
autoBackupEnabled.value = settings?.enabled ?? false;
|
||||
autoBackupInterval.value = settings?.interval || 1;
|
||||
} catch (error) {
|
||||
console.error("Error fetching backup settings:", error);
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Error",
|
||||
text: "Failed to load backup settings",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
isLoadingSettings.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await props.server.backups?.updateAutoBackup(
|
||||
autoBackupEnabled.value ? "enable" : "disable",
|
||||
autoBackupInterval.value,
|
||||
);
|
||||
|
||||
initialSettings.value = {
|
||||
enabled: autoBackupEnabled.value,
|
||||
interval: autoBackupInterval.value,
|
||||
};
|
||||
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Success",
|
||||
text: "Backup settings updated successfully",
|
||||
type: "success",
|
||||
});
|
||||
|
||||
modal.value?.hide();
|
||||
} catch (error) {
|
||||
console.error("Error saving backup settings:", error);
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Error",
|
||||
text: "Failed to save backup settings",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(autoBackupInterval, () => {
|
||||
autoBackupInterval.value = validatedBackupInterval.value;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
show: async () => {
|
||||
await fetchSettings();
|
||||
modal.value?.show();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
</style>
|
||||
229
apps/frontend/src/components/ui/servers/FileItem.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<li
|
||||
role="button"
|
||||
data-pyro-file
|
||||
:class="containerClasses"
|
||||
tabindex="0"
|
||||
@click="selectItem"
|
||||
@contextmenu="openContextMenu"
|
||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||
>
|
||||
<div data-pyro-file-metadata class="flex w-full items-center gap-4 truncate">
|
||||
<div
|
||||
class="flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
|
||||
>
|
||||
<component :is="iconComponent" class="size-6" />
|
||||
</div>
|
||||
<div class="flex w-full flex-col truncate">
|
||||
<span
|
||||
class="w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
|
||||
>{{ name }}</span
|
||||
>
|
||||
<span class="text-xs text-secondary group-hover:text-primary">
|
||||
{{ subText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-pyro-file-actions class="flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
|
||||
<span class="w-[160px] text-nowrap text-right font-mono text-sm text-secondary">{{
|
||||
formattedDate
|
||||
}}</span>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #rename> <EditIcon /> Rename </template>
|
||||
<template #move> <RightArrowIcon /> Move </template>
|
||||
<template #download> <DownloadIcon /> Download </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
EditIcon,
|
||||
DownloadIcon,
|
||||
TrashIcon,
|
||||
FolderOpenIcon,
|
||||
FileIcon,
|
||||
RightArrowIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { computed, shallowRef, ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import {
|
||||
UiServersIconsCogFolderIcon,
|
||||
UiServersIconsEarthIcon,
|
||||
UiServersIconsCodeFileIcon,
|
||||
UiServersIconsTextFileIcon,
|
||||
UiServersIconsImageFileIcon,
|
||||
} from "#components";
|
||||
import PaletteIcon from "~/assets/icons/palette.svg?component";
|
||||
|
||||
interface FileItemProps {
|
||||
name: string;
|
||||
type: "directory" | "file";
|
||||
size?: number;
|
||||
count?: number;
|
||||
modified: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const props = defineProps<FileItemProps>();
|
||||
|
||||
const emit = defineEmits(["rename", "download", "delete", "move", "edit", "contextmenu"]);
|
||||
|
||||
const codeExtensions = Object.freeze([
|
||||
"json",
|
||||
"json5",
|
||||
"jsonc",
|
||||
"java",
|
||||
"kt",
|
||||
"kts",
|
||||
"sh",
|
||||
"bat",
|
||||
"ps1",
|
||||
"yml",
|
||||
"yaml",
|
||||
"toml",
|
||||
"js",
|
||||
"ts",
|
||||
"py",
|
||||
"rb",
|
||||
"php",
|
||||
"html",
|
||||
"css",
|
||||
"cpp",
|
||||
"c",
|
||||
"h",
|
||||
"rs",
|
||||
"go",
|
||||
]);
|
||||
|
||||
const textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
|
||||
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
|
||||
const units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
|
||||
|
||||
const route = shallowRef(useRoute());
|
||||
const router = useRouter();
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
"group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised",
|
||||
isEditableFile.value ? "cursor-pointer" : props.type === "directory" ? "cursor-pointer" : "",
|
||||
]);
|
||||
|
||||
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
id: "rename",
|
||||
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
id: "move",
|
||||
action: () => emit("move", { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
id: "download",
|
||||
action: () => emit("download", { name: props.name, type: props.type, path: props.path }),
|
||||
shown: props.type !== "directory",
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
action: () => emit("delete", { name: props.name, type: props.type, path: props.path }),
|
||||
color: "red" as const,
|
||||
},
|
||||
]);
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (props.type === "directory") {
|
||||
if (props.name === "config") return UiServersIconsCogFolderIcon;
|
||||
if (props.name === "world") return UiServersIconsEarthIcon;
|
||||
if (props.name === "resourcepacks") return PaletteIcon;
|
||||
return FolderOpenIcon;
|
||||
}
|
||||
|
||||
const ext = fileExtension.value;
|
||||
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
|
||||
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
|
||||
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
|
||||
return FileIcon;
|
||||
});
|
||||
|
||||
const subText = computed(() => {
|
||||
if (props.type === "directory") {
|
||||
return `${props.count} ${props.count === 1 ? "item" : "items"}`;
|
||||
}
|
||||
return formattedSize.value;
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const date = new Date(props.modified * 1000);
|
||||
return `${date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
})}, ${date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})}`;
|
||||
});
|
||||
|
||||
const isEditableFile = computed(() => {
|
||||
if (props.type === "file") {
|
||||
const ext = fileExtension.value;
|
||||
return (
|
||||
!props.name.includes(".") ||
|
||||
textExtensions.includes(ext) ||
|
||||
codeExtensions.includes(ext) ||
|
||||
imageExtensions.includes(ext)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
if (props.size === undefined) return "";
|
||||
const bytes = props.size;
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const size = (bytes / Math.pow(1024, exponent)).toFixed(2);
|
||||
return `${size} ${units[exponent]}`;
|
||||
});
|
||||
|
||||
const openContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
emit("contextmenu", event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const navigateToFolder = () => {
|
||||
const currentPath = route.value.query.path?.toString() || "";
|
||||
const newPath = currentPath.endsWith("/")
|
||||
? `${currentPath}${props.name}`
|
||||
: `${currentPath}/${props.name}`;
|
||||
router.push({ query: { path: newPath, page: 1 } });
|
||||
};
|
||||
|
||||
const isNavigating = ref(false);
|
||||
|
||||
const selectItem = () => {
|
||||
if (isNavigating.value) return;
|
||||
isNavigating.value = true;
|
||||
|
||||
if (props.type === "directory") {
|
||||
navigateToFolder();
|
||||
} else if (props.type === "file" && isEditableFile.value) {
|
||||
emit("edit", { name: props.name, type: props.type, path: props.path });
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigating.value = false;
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
40
apps/frontend/src/components/ui/servers/FileManagerError.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
|
||||
<FileIcon class="size-28" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-red-500 m-0 text-2xl font-bold">{{ title }}</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('refetch')">
|
||||
<UiServersIconsLoadingIcon class="h-5 w-5" />
|
||||
Try again
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('home')">
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
Go to home folder
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, HomeIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
message: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "refetch"): void;
|
||||
(e: "home"): void;
|
||||
}>();
|
||||
</script>
|
||||
120
apps/frontend/src/components/ui/servers/FileVirtualList.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div ref="listContainer" data-pyro-files-virtual-list-root class="relative w-full">
|
||||
<div
|
||||
:style="{
|
||||
position: 'relative',
|
||||
minHeight: `${totalHeight}px`,
|
||||
}"
|
||||
data-pyro-files-virtual-height-watcher
|
||||
>
|
||||
<ul
|
||||
class="list-none"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: `${visibleTop}px`,
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}"
|
||||
data-pyro-files-virtual-list
|
||||
>
|
||||
<UiServersFileItem
|
||||
v-for="item in visibleItems"
|
||||
:key="item.path"
|
||||
:count="item.count"
|
||||
:created="item.created"
|
||||
:modified="item.modified"
|
||||
:name="item.name"
|
||||
:path="item.path"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
@delete="$emit('delete', item)"
|
||||
@rename="$emit('rename', item)"
|
||||
@download="$emit('download', item)"
|
||||
@move="$emit('move', item)"
|
||||
@edit="$emit('edit', item)"
|
||||
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
items: any[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete", item: any): void;
|
||||
(e: "rename", item: any): void;
|
||||
(e: "download", item: any): void;
|
||||
(e: "move", item: any): void;
|
||||
(e: "edit", item: any): void;
|
||||
(e: "contextmenu", item: any, x: number, y: number): void;
|
||||
(e: "loadMore"): void;
|
||||
}>();
|
||||
|
||||
const ITEM_HEIGHT = 61;
|
||||
const BUFFER_SIZE = 5;
|
||||
|
||||
const listContainer = ref<HTMLElement | null>(null);
|
||||
const windowScrollY = ref(0);
|
||||
const windowHeight = ref(0);
|
||||
|
||||
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT);
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (!listContainer.value) return { start: 0, end: 0 };
|
||||
|
||||
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY;
|
||||
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop);
|
||||
|
||||
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT);
|
||||
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT);
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - BUFFER_SIZE),
|
||||
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
|
||||
};
|
||||
});
|
||||
|
||||
const visibleTop = computed(() => {
|
||||
return visibleRange.value.start * ITEM_HEIGHT;
|
||||
});
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
return props.items.slice(visibleRange.value.start, visibleRange.value.end);
|
||||
});
|
||||
|
||||
const handleScroll = () => {
|
||||
windowScrollY.value = window.scrollY;
|
||||
|
||||
if (!listContainer.value) return;
|
||||
|
||||
const containerBottom = listContainer.value.getBoundingClientRect().bottom;
|
||||
const remainingScroll = containerBottom - window.innerHeight;
|
||||
|
||||
if (remainingScroll < windowHeight.value * 0.2) {
|
||||
emit("loadMore");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
windowHeight.value = window.innerHeight;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
windowHeight.value = window.innerHeight;
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
handleScroll();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
</script>
|
||||
231
apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
Normal file
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
|
||||
<header
|
||||
:class="[
|
||||
'duration-20 h-26 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
|
||||
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
|
||||
]"
|
||||
data-pyro-files-state="browsing"
|
||||
aria-label="File navigation"
|
||||
>
|
||||
<nav
|
||||
aria-label="Breadcrumb navigation"
|
||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||
<li class="-ml-1 flex-shrink-0">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="$emit('navigate', -1)"
|
||||
>
|
||||
<span
|
||||
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
<span class="sr-only">Home</span>
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
|
||||
<ol class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0">
|
||||
<TransitionGroup
|
||||
name="breadcrumb"
|
||||
tag="span"
|
||||
class="relative flex min-w-0 flex-shrink items-center"
|
||||
>
|
||||
<li
|
||||
v-for="(segment, index) in breadcrumbSegments"
|
||||
:key="`${segment || index}-group`"
|
||||
class="relative flex min-w-0 flex-shrink items-center text-sm"
|
||||
>
|
||||
<div class="flex min-w-0 flex-shrink items-center">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="cursor-pointer truncate focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
:aria-current="
|
||||
index === breadcrumbSegments.length - 1 ? 'location' : undefined
|
||||
"
|
||||
:class="{
|
||||
'!text-contrast': index === breadcrumbSegments.length - 1,
|
||||
}"
|
||||
@click="$emit('navigate', index)"
|
||||
>
|
||||
{{ segment || "" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbSegments.length - 1"
|
||||
class="size-4 flex-shrink-0 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-1">
|
||||
<div class="flex w-full flex-row-reverse sm:flex-row">
|
||||
<ButtonStyled type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Sort files"
|
||||
:options="[
|
||||
{ id: 'normal', action: () => $emit('sort', 'default') },
|
||||
{ id: 'modified', action: () => $emit('sort', 'modified') },
|
||||
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
|
||||
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
|
||||
]"
|
||||
>
|
||||
<span class="hidden whitespace-pre text-sm font-medium sm:block">
|
||||
{{ sortMethodLabel }}
|
||||
</span>
|
||||
<SortAscendingIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #normal> Alphabetical </template>
|
||||
<template #modified> Date modified </template>
|
||||
<template #filesOnly> Files only </template>
|
||||
<template #foldersOnly> Folders only </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 w-full text-sm sm:w-40">
|
||||
<label for="search-folder" class="sr-only">Search folder</label>
|
||||
<div class="relative">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id="search-folder"
|
||||
:value="searchQuery"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-button-bg bg-transparent py-2 pl-9"
|
||||
placeholder="Search..."
|
||||
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Create new..."
|
||||
:options="[
|
||||
{ id: 'file', action: () => $emit('create', 'file') },
|
||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||
{ id: 'upload', action: () => $emit('upload') },
|
||||
]"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
||||
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
PlusIcon,
|
||||
UploadIcon,
|
||||
DropdownIcon,
|
||||
FolderOpenIcon,
|
||||
SearchIcon,
|
||||
SortAscendingIcon,
|
||||
HomeIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed } from "vue";
|
||||
import { useIntersectionObserver } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbSegments: string[];
|
||||
searchQuery: string;
|
||||
sortMethod: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "navigate", index: number): void;
|
||||
(e: "sort", method: string): void;
|
||||
(e: "create", type: "file" | "directory"): void;
|
||||
(e: "upload"): void;
|
||||
(e: "update:searchQuery", value: string): void;
|
||||
}>();
|
||||
|
||||
const pyroFilesSentinel = ref<HTMLElement | null>(null);
|
||||
const isStuck = ref(false);
|
||||
|
||||
useIntersectionObserver(
|
||||
pyroFilesSentinel,
|
||||
([{ isIntersecting }]) => {
|
||||
isStuck.value = !isIntersecting;
|
||||
},
|
||||
{ threshold: [0, 1] },
|
||||
);
|
||||
|
||||
const sortMethodLabel = computed(() => {
|
||||
switch (props.sortMethod) {
|
||||
case "modified":
|
||||
return "Date modified";
|
||||
case "filesOnly":
|
||||
return "Files only";
|
||||
case "foldersOnly":
|
||||
return "Folders only";
|
||||
default:
|
||||
return "Alphabetical";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sentinel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.breadcrumb-move,
|
||||
.breadcrumb-enter-active,
|
||||
.breadcrumb-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) scale(0.9);
|
||||
}
|
||||
|
||||
.breadcrumb-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) scale(0.8);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.breadcrumb-leave-active {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.breadcrumb-move {
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
106
apps/frontend/src/components/ui/servers/FilesContextMenu.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed"
|
||||
:style="{
|
||||
transform: `translateY(${isAtBottom ? '-100%' : '0'})`,
|
||||
top: `${y}px`,
|
||||
left: `${x}px`,
|
||||
}"
|
||||
>
|
||||
<Transition>
|
||||
<div
|
||||
v-if="item"
|
||||
id="item-context-menu"
|
||||
ref="ctxRef"
|
||||
:style="{
|
||||
border: '1px solid var(--color-button-bg)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--color-raised-bg)',
|
||||
padding: 'var(--gap-sm)',
|
||||
boxShadow: 'var(--shadow-floating)',
|
||||
gap: 'var(--gap-xs)',
|
||||
width: 'max-content',
|
||||
}"
|
||||
class="flex h-fit w-fit select-none flex-col"
|
||||
>
|
||||
<button
|
||||
class="btn btn-transparent flex !w-full items-center"
|
||||
@click="$emit('rename', item)"
|
||||
>
|
||||
<EditIcon class="h-5 w-5" />
|
||||
Rename
|
||||
</button>
|
||||
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
|
||||
<ArrowBigUpDashIcon class="h-5 w-5" />
|
||||
Move
|
||||
</button>
|
||||
<button
|
||||
v-if="item.type !== 'directory'"
|
||||
class="btn btn-transparent flex !w-full items-center"
|
||||
@click="$emit('download', item)"
|
||||
>
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-transparent btn-red flex !w-full items-center"
|
||||
@click="$emit('delete', item)"
|
||||
>
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, ArrowBigUpDashIcon, DownloadIcon, TrashIcon } from "@modrinth/assets";
|
||||
|
||||
interface FileItem {
|
||||
type: string;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
item: FileItem | null;
|
||||
x: number;
|
||||
y: number;
|
||||
isAtBottom: boolean;
|
||||
}>();
|
||||
|
||||
const ctxRef = ref<HTMLElement | null>(null);
|
||||
|
||||
defineEmits<{
|
||||
(e: "rename", item: FileItem): void;
|
||||
(e: "move", item: FileItem): void;
|
||||
(e: "download", item: FileItem): void;
|
||||
(e: "delete", item: FileItem): void;
|
||||
}>();
|
||||
|
||||
defineExpose({
|
||||
ctxRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#item-context-menu {
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
opacity 0.1s ease;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
#item-context-menu.v-enter-active,
|
||||
#item-context-menu.v-leave-active {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#item-context-menu.v-enter-from,
|
||||
#item-context-menu.v-leave-to {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Creating a ${type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="createInput"
|
||||
v-model="itemName"
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
|
||||
required
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
Create
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
type: "file" | "directory";
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "create", name: string): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const createInput = ref<HTMLInputElement | null>(null);
|
||||
const itemName = ref("");
|
||||
const submitted = ref(false);
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return "Name is required.";
|
||||
}
|
||||
if (props.type === "file") {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true;
|
||||
if (!error.value) {
|
||||
emit("create", itemName.value);
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
itemName.value = "";
|
||||
submitted.value = false;
|
||||
modal.value?.show();
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
createInput.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div
|
||||
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-[#cb224436] bg-[#f57b7b0e] p-6 shadow-md dark:border-0 dark:bg-[#0e0e0ea4]"
|
||||
>
|
||||
<div
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#3f1818a4] p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
|
||||
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
|
||||
<span
|
||||
v-if="item?.type === 'directory'"
|
||||
class="text-xs text-secondary group-hover:text-primary"
|
||||
>
|
||||
{{ item?.count }} items
|
||||
</span>
|
||||
<span v-else class="text-xs text-secondary group-hover:text-primary">
|
||||
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="red">
|
||||
<button type="submit">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
Delete {{ item?.type }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from "@modrinth/assets";
|
||||
|
||||
defineProps<{
|
||||
item: {
|
||||
name: string;
|
||||
type: string;
|
||||
count?: number;
|
||||
size?: number;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete"): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("delete");
|
||||
hide();
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
modal.value?.show();
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
140
apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<header
|
||||
data-pyro-files-state="editing"
|
||||
class="flex h-12 select-none items-center justify-between rounded-t-2xl bg-table-alternateRow p-3"
|
||||
aria-label="File editor navigation"
|
||||
>
|
||||
<nav
|
||||
aria-label="Breadcrumb navigation"
|
||||
class="m-0 flex list-none items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex list-none items-center p-0">
|
||||
<li class="-ml-1">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="goHome"
|
||||
>
|
||||
<span
|
||||
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
<span class="sr-only">Home</span>
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li class="m-0 -ml-2 p-0">
|
||||
<ol class="m-0 flex items-center p-0">
|
||||
<li
|
||||
v-for="(segment, index) in breadcrumbSegments"
|
||||
:key="index"
|
||||
class="flex items-center text-sm"
|
||||
>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
:class="{ '!text-contrast': index === breadcrumbSegments.length - 1 }"
|
||||
@click="$emit('navigate', index)"
|
||||
>
|
||||
{{ segment || "" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbSegments.length"
|
||||
class="size-4 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</li>
|
||||
<li class="flex items-center px-3 text-sm">
|
||||
<span class="font-semibold !text-contrast" aria-current="location">{{
|
||||
fileName
|
||||
}}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div v-if="!isImage" class="flex gap-2">
|
||||
<Button
|
||||
v-if="isLogFile"
|
||||
v-tooltip="'Share to mclo.gs'"
|
||||
icon-only
|
||||
transparent
|
||||
aria-label="Share to mclo.gs"
|
||||
@click="$emit('share')"
|
||||
>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
<ButtonStyled type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Save file"
|
||||
:options="[
|
||||
{ id: 'save', action: () => $emit('save') },
|
||||
{ id: 'save-as', action: () => $emit('save-as') },
|
||||
{ id: 'save&restart', action: () => $emit('save-restart') },
|
||||
]"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #save> <SaveIcon aria-hidden="true" /> Save </template>
|
||||
<template #save-as> <SaveIcon aria-hidden="true" /> Save as... </template>
|
||||
<template #save&restart>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Save & restart
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, SaveIcon, ShareIcon, HomeIcon, ChevronRightIcon } from "@modrinth/assets";
|
||||
import { Button, ButtonStyled } from "@modrinth/ui";
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbSegments: string[];
|
||||
fileName?: string;
|
||||
isImage: boolean;
|
||||
filePath?: string;
|
||||
}>();
|
||||
|
||||
const isLogFile = computed(() => {
|
||||
return props.filePath?.startsWith("logs") || props.filePath?.endsWith(".log");
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "cancel"): void;
|
||||
(e: "save"): void;
|
||||
(e: "save-as"): void;
|
||||
(e: "save-restart"): void;
|
||||
(e: "share"): void;
|
||||
(e: "navigate", index: number): void;
|
||||
}>();
|
||||
|
||||
const goHome = () => {
|
||||
emit("cancel");
|
||||
router.push({ path: "/servers/manage/" + route.params.id + "/files" });
|
||||
};
|
||||
</script>
|
||||
159
apps/frontend/src/components/ui/servers/FilesImageViewer.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center bg-bg-raised">
|
||||
<div
|
||||
ref="container"
|
||||
class="relative w-full flex-grow overflow-hidden bg-bg-raised"
|
||||
@mousedown="startPan"
|
||||
@mousemove="pan"
|
||||
@mouseup="endPan"
|
||||
@mouseleave="endPan"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<UiServersPyroLoading v-if="loading" />
|
||||
<div v-if="error" class="flex h-full w-full flex-col items-center justify-center gap-8">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-12"
|
||||
>
|
||||
<path d="M4 13c3.5-2 8-2 10 2a5.5 5.5 0 0 1 8 5" />
|
||||
<path
|
||||
d="M5.15 17.89c5.52-1.52 8.65-6.89 7-12C11.55 4 11.5 2 13 2c3.22 0 5 5.5 5 8 0 6.5-4.2 12-10.49 12C5.11 22 2 22 2 20c0-1.5 1.14-1.55 3.15-2.11Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="m-0">Invalid or empty image file.</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="!loading && !error"
|
||||
ref="image"
|
||||
:src="imageUrl"
|
||||
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
|
||||
:style="{
|
||||
transform: `translate(-50%, -50%) scale(${scale}) translate(${translateX}px, ${translateY}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.3s ease-out',
|
||||
}"
|
||||
alt="Viewed image"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!error"
|
||||
class="absolute bottom-0 mb-2 flex w-fit justify-center space-x-4 rounded-xl bg-bg p-2"
|
||||
>
|
||||
<Button icon-only transparent @click="zoomIn">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="resetZoom">
|
||||
<HomeIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="zoomOut">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { HomeIcon, ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
||||
import { Button } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps({
|
||||
imageBlob: {
|
||||
type: Blob,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const container = ref(null);
|
||||
const image = ref(null);
|
||||
const scale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const isPanning = ref(false);
|
||||
const startX = ref(0);
|
||||
const startY = ref(0);
|
||||
const imageUrl = ref("");
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
const createImageUrl = (blob) => {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value);
|
||||
}
|
||||
imageUrl.value = URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.imageBlob,
|
||||
(newBlob) => {
|
||||
if (newBlob) {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
createImageUrl(newBlob);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) {
|
||||
createImageUrl(props.imageBlob);
|
||||
}
|
||||
});
|
||||
|
||||
const onImageLoad = () => {
|
||||
loading.value = false;
|
||||
resetZoom();
|
||||
};
|
||||
|
||||
const onImageError = () => {
|
||||
loading.value = false;
|
||||
error.value = true;
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
scale.value = Math.min(scale.value * 1.2, 5);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
scale.value = Math.max(scale.value / 1.2, 0.1);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
scale.value = 0.5;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
};
|
||||
|
||||
const startPan = (e) => {
|
||||
isPanning.value = true;
|
||||
startX.value = e.clientX - translateX.value;
|
||||
startY.value = e.clientY - translateY.value;
|
||||
};
|
||||
|
||||
const pan = (e) => {
|
||||
if (isPanning.value) {
|
||||
translateX.value = e.clientX - startX.value;
|
||||
translateY.value = e.clientY - startY.value;
|
||||
}
|
||||
};
|
||||
|
||||
const endPan = () => {
|
||||
isPanning.value = false;
|
||||
};
|
||||
|
||||
const handleWheel = (e) => {
|
||||
const delta = (e.deltaY * -0.01) / 10;
|
||||
const newScale = Math.max(0.1, Math.min(scale.value + delta, 5));
|
||||
|
||||
scale.value = newScale;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Moving ${item?.name}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
ref="destinationInput"
|
||||
v-model="destination"
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. mods/modname"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-nowrap">
|
||||
New location:
|
||||
<div class="w-full rounded-lg bg-table-alternateRow p-2 font-bold text-contrast">
|
||||
<span class="text-secondary">/root</span>{{ newpath }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit">
|
||||
<ArrowBigUpDashIcon class="h-5 w-5" />
|
||||
Move
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowBigUpDashIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, nextTick } from "vue";
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const props = defineProps<{
|
||||
item: { name: string } | null;
|
||||
currentPath: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "move", destination: string): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const destination = ref("");
|
||||
const newpath = computed(() => {
|
||||
return destination.value.replace("//", "/");
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("move", destination.value);
|
||||
hide();
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
destination.value = props.currentPath;
|
||||
modal.value?.show();
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
destinationInput.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Renaming ${item?.type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="renameInput"
|
||||
v-model="itemName"
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
required
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<EditIcon class="h-5 w-5" />
|
||||
Rename
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
item: { name: string; type: string } | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "rename", newName: string): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const renameInput = ref<HTMLInputElement | null>(null);
|
||||
const itemName = ref("");
|
||||
const submitted = ref(false);
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return "Name is required.";
|
||||
}
|
||||
if (props.item?.type === "file") {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true;
|
||||
if (!error.value) {
|
||||
emit("rename", itemName.value);
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const show = (item: { name: string; type: string }) => {
|
||||
itemName.value = item.name;
|
||||
submitted.value = false;
|
||||
modal.value?.show();
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
renameInput.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
76
apps/frontend/src/components/ui/servers/LoaderSelector.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="loader in loaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
:class="isCurrentLoader(loader.name) ? '[&&]:bg-bg-green' : ''"
|
||||
>
|
||||
<UiServersIconsLoaderIcon
|
||||
:loader="loader.name"
|
||||
class="[&&]:size-6"
|
||||
:class="isCurrentLoader(loader.name) ? 'text-brand' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
|
||||
{{ loader.displayName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="isCurrentLoader(loader.name)"
|
||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||
>
|
||||
<CheckIcon class="h-4 w-4" />
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="isCurrentLoader(loader.name)" class="m-0 text-xs text-secondary">
|
||||
{{ data.loader_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<button @click="selectLoader(loader.name)">
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
{{ isCurrentLoader(loader.name) ? "Reinstall" : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
loader: string | null;
|
||||
loader_version: string | null;
|
||||
};
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "selectLoader", loader: string): void;
|
||||
}>();
|
||||
|
||||
const loaders = [
|
||||
{ name: "Vanilla" as const, displayName: "Vanilla" },
|
||||
{ name: "Fabric" as const, displayName: "Fabric" },
|
||||
{ name: "Quilt" as const, displayName: "Quilt" },
|
||||
{ name: "Forge" as const, displayName: "Forge" },
|
||||
{ name: "NeoForge" as const, displayName: "NeoForge" },
|
||||
];
|
||||
|
||||
const isCurrentLoader = (loaderName: string) => {
|
||||
return props.data.loader?.toLowerCase() === loaderName.toLowerCase();
|
||||
};
|
||||
|
||||
const selectLoader = (loader: string) => {
|
||||
emit("selectLoader", loader);
|
||||
};
|
||||
</script>
|
||||
107
apps/frontend/src/components/ui/servers/LogParser.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="parsed-log group relative w-full overflow-hidden px-6 py-1">
|
||||
<div
|
||||
ref="logContent"
|
||||
class="log-content selectable whitespace-pre-wrap selection:bg-black selection:text-white dark:selection:bg-white dark:selection:text-black"
|
||||
v-html="sanitizedLog"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import Convert from "ansi-to-html";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
const props = defineProps<{
|
||||
log: string;
|
||||
index: number;
|
||||
}>();
|
||||
|
||||
const logContent = ref<HTMLElement | null>(null);
|
||||
|
||||
const colors = {
|
||||
30: "#101010",
|
||||
31: "#EFA6A2",
|
||||
32: "#80C990",
|
||||
33: "#A69460",
|
||||
34: "#A3B8EF",
|
||||
35: "#E6A3DC",
|
||||
36: "#50CACD",
|
||||
37: "#808080",
|
||||
90: "#454545",
|
||||
91: "#E0AF85",
|
||||
92: "#5ACCAF",
|
||||
93: "#C8C874",
|
||||
94: "#CCACED",
|
||||
95: "#F2A1C2",
|
||||
96: "#74C3E4",
|
||||
97: "#C0C0C0",
|
||||
};
|
||||
|
||||
const convert = new Convert({
|
||||
fg: "#FFF",
|
||||
bg: "#000",
|
||||
newline: false,
|
||||
escapeXML: true,
|
||||
stream: false,
|
||||
colors,
|
||||
});
|
||||
|
||||
const urlRegex = /https?:\/\/[^\s]+/g;
|
||||
const usernameRegex = /<([^&]+)>/g;
|
||||
|
||||
const sanitizedLog = computed(() => {
|
||||
let html = convert.toHtml(props.log);
|
||||
html = html.replace(
|
||||
urlRegex,
|
||||
(url) =>
|
||||
`<a style="color:var(--color-link);text-decoration:underline;" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`,
|
||||
);
|
||||
html = html.replace(
|
||||
usernameRegex,
|
||||
(_, username) => `<span class="minecraft-username"><${username}></span>`,
|
||||
);
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ["span", "a"],
|
||||
ALLOWED_ATTR: ["style", "href", "target", "rel", "class"],
|
||||
ADD_ATTR: ["target"],
|
||||
RETURN_TRUSTED_TYPE: true,
|
||||
USE_PROFILES: { html: true },
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.parsed-log:hover:not(.selected) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
html.light-mode .parsed-log:hover:not(.selected) {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
html.dark-mode .parsed-log:hover:not(.selected) {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
html.oled-mode .parsed-log:hover:not(.selected) {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.minecraft-username {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
::v-deep(.log-content) {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::v-deep(.log-content.selectable) {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
::v-deep(.log-content *) {
|
||||
user-select: text;
|
||||
}
|
||||
</style>
|
||||
660
apps/frontend/src/components/ui/servers/MOTDEditor.vue
Normal file
@@ -0,0 +1,660 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full overflow-hidden">
|
||||
<div class="mb-4">
|
||||
<div
|
||||
v-for="(line, lineIndex) in motd"
|
||||
:key="lineIndex"
|
||||
class="relative mb-2 rounded bg-button-bg p-2"
|
||||
>
|
||||
<div
|
||||
class="font-minecraft text-white"
|
||||
:contenteditable="true"
|
||||
spellcheck="false"
|
||||
@input="handleInput($event, lineIndex)"
|
||||
@keydown.enter.prevent
|
||||
@paste.prevent="handlePaste($event, lineIndex)"
|
||||
@mouseup="handleSelection(lineIndex)"
|
||||
v-html="renderLine(line)"
|
||||
></div>
|
||||
<div class="text-sm text-gray-400">
|
||||
{{ motd[lineIndex].reduce((sum, segment) => sum + segment.text.length, 0) }}/45
|
||||
characters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="showPopup"
|
||||
:style="{ top: `${popupY}px`, left: `${popupX}px` }"
|
||||
class="fixed z-10 flex flex-col items-end gap-2 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<div class="rounded-xl border bg-table-alternateRow p-2 shadow-lg">
|
||||
<div class="flex space-x-2">
|
||||
<Button
|
||||
v-for="style in styles"
|
||||
:key="style.name"
|
||||
icon-only
|
||||
transparent
|
||||
@click="applyStyle({ [style.name]: !currentStyle[style.name] })"
|
||||
>
|
||||
<component :is="style.icon" class="h-4 w-4" />
|
||||
</Button>
|
||||
<div class="relative overflow-y-scroll">
|
||||
<Button icon-only transparent :class="colorPicker ?? 'hidden'" @click="pickColor">
|
||||
<PaintBrushIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="colorPicker"
|
||||
icon-only
|
||||
class="w-fit overflow-y-auto rounded-xl p-2 [&&]:bg-table-alternateRow"
|
||||
>
|
||||
<div :class="colorPicker ? `grid grid-flow-col grid-rows-4 gap-2` : '[&&]:hidden'">
|
||||
<button
|
||||
v-for="format in sortedFormatCodes()"
|
||||
:key="format.code"
|
||||
class="rounded-full p-3"
|
||||
:style="{ backgroundColor: format.color }"
|
||||
:title="format.description"
|
||||
@click="applyStyle({ color: format.color })"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ItalicIcon,
|
||||
BoldIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
PaintBrushIcon,
|
||||
ChevronLeftIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Button } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps({
|
||||
server: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formatCodes = [
|
||||
{ code: "§f", color: "white", description: "White" },
|
||||
{ code: "§7", color: "#AAAAAA", description: "Gray" },
|
||||
{ code: "§8", color: "#555555", description: "Dark Gray" },
|
||||
{ code: "§0", color: "#000000", description: "Black" },
|
||||
{ code: "§9", color: "#5555FF", description: "Blue" },
|
||||
{ code: "§1", color: "#0000AA", description: "Dark Blue" },
|
||||
{ code: "§b", color: "#55FFFF", description: "Aqua" },
|
||||
{ code: "§3", color: "#00AAAA", description: "Dark Aqua" },
|
||||
{ code: "§a", color: "#55FF55", description: "Green" },
|
||||
{ code: "§2", color: "#00AA00", description: "Dark Green" },
|
||||
{ code: "§e", color: "#FFFF55", description: "Yellow" },
|
||||
{ code: "§6", color: "#FFAA00", description: "Gold" },
|
||||
{ code: "§c", color: "#FF5555", description: "Red" },
|
||||
{ code: "§4", color: "#AA0000", description: "Dark Red" },
|
||||
{ code: "§d", color: "#FF55FF", description: "Light Purple" },
|
||||
{ code: "§5", color: "#AA00AA", description: "Dark Purple" },
|
||||
];
|
||||
|
||||
const sortedFormatCodes = () => {
|
||||
const colors = formatCodes;
|
||||
if (colors[0].description === "White") {
|
||||
colors.reverse();
|
||||
}
|
||||
return colors;
|
||||
};
|
||||
|
||||
const minecraftEmojis = [
|
||||
{ char: "☺", name: "SMILING FACE" },
|
||||
{ char: "☹", name: "FROWNING FACE" },
|
||||
{ char: "☠", name: "SKULL AND CROSSBONES" },
|
||||
{ char: "❣", name: "HEART EXCLAMATION" },
|
||||
{ char: "❤", name: "RED HEART" },
|
||||
{ char: "✌", name: "VICTORY HAND" },
|
||||
{ char: "☝", name: "INDEX POINTING UP" },
|
||||
{ char: "✍", name: "WRITING HAND" },
|
||||
{ char: "♨", name: "HOT SPRINGS" },
|
||||
{ char: "✈", name: "AIRPLANE" },
|
||||
{ char: "⌛", name: "HOURGLASS DONE" },
|
||||
{ char: "⌚", name: "WATCH" },
|
||||
{ char: "☀", name: "SUN" },
|
||||
{ char: "☁", name: "CLOUD" },
|
||||
{ char: "☂", name: "UMBRELLA" },
|
||||
{ char: "❄", name: "SNOWFLAKE" },
|
||||
{ char: "☃", name: "SNOWMAN" },
|
||||
{ char: "☄", name: "COMET" },
|
||||
{ char: "♠", name: "SPADE SUIT" },
|
||||
{ char: "♥", name: "HEART SUIT" },
|
||||
{ char: "♦", name: "DIAMOND SUIT" },
|
||||
{ char: "♣", name: "CLUB SUIT" },
|
||||
{ char: "♟", name: "CHESS PAWN" },
|
||||
{ char: "☎", name: "TELEPHONE" },
|
||||
{ char: "⌨", name: "KEYBOARD" },
|
||||
{ char: "✉", name: "ENVELOPE" },
|
||||
{ char: "✏", name: "PENCIL" },
|
||||
{ char: "✒", name: "BLACK PEN" },
|
||||
{ char: "✂", name: "SCISSORS" },
|
||||
{ char: "☢", name: "RADIOACTIVE" },
|
||||
{ char: "☣", name: "BIOHAZARD" },
|
||||
{ char: "⬆", name: "UP ARROW" },
|
||||
{ char: "⬇", name: "DOWN ARROW" },
|
||||
{ char: "➡", name: "RIGHT ARROW" },
|
||||
{ char: "⬅", name: "LEFT ARROW" },
|
||||
{ char: "↗", name: "UP-RIGHT ARROW" },
|
||||
{ char: "↘", name: "DOWN-RIGHT ARROW" },
|
||||
{ char: "↙", name: "DOWN-LEFT ARROW" },
|
||||
{ char: "↖", name: "UP-LEFT ARROW" },
|
||||
{ char: "↕", name: "UP-DOWN ARROW" },
|
||||
{ char: "↔", name: "LEFT-RIGHT ARROW" },
|
||||
{ char: "↩", name: "RIGHT ARROW CURVING LEFT" },
|
||||
{ char: "↪", name: "LEFT ARROW CURVING RIGHT" },
|
||||
{ char: "✡", name: "STAR OF DAVID" },
|
||||
{ char: "☸", name: "WHEEL OF DHARMA" },
|
||||
{ char: "☯", name: "YIN YANG" },
|
||||
{ char: "✝", name: "LATIN CROSS" },
|
||||
{ char: "☦", name: "ORTHODOX CROSS" },
|
||||
{ char: "☪", name: "STAR AND CRESCENT" },
|
||||
{ char: "☮", name: "PEACE SYMBOL" },
|
||||
{ char: "♈", name: "ARIES" },
|
||||
{ char: "♉", name: "TAURUS" },
|
||||
{ char: "♊", name: "GEMINI" },
|
||||
{ char: "♋", name: "CANCER" },
|
||||
{ char: "♌", name: "LEO" },
|
||||
{ char: "♍", name: "VIRGO" },
|
||||
{ char: "♎", name: "LIBRA" },
|
||||
{ char: "♏", name: "SCORPIO" },
|
||||
{ char: "♐", name: "SAGITTARIUS" },
|
||||
{ char: "♑", name: "CAPRICORN" },
|
||||
{ char: "♒", name: "AQUARIUS" },
|
||||
{ char: "♓", name: "PISCES" },
|
||||
{ char: "▶", name: "PLAY BUTTON" },
|
||||
{ char: "◀", name: "REVERSE BUTTON" },
|
||||
{ char: "♀", name: "FEMALE SIGN" },
|
||||
{ char: "♂", name: "MALE SIGN" },
|
||||
{ char: "✖", name: "MULTIPLY" },
|
||||
{ char: "‼", name: "DOUBLE EXCLAMATION MARK" },
|
||||
{ char: "〰", name: "WAVY DASH" },
|
||||
{ char: "☑", name: "CHECK BOX WITH CHECK" },
|
||||
{ char: "✔", name: "CHECK MARK" },
|
||||
{ char: "✳", name: "EIGHT-SPOKED ASTERISK" },
|
||||
{ char: "✴", name: "EIGHT-POINTED STAR" },
|
||||
{ char: "❇", name: "SPARKLE" },
|
||||
{ char: "©", name: "COPYRIGHT" },
|
||||
{ char: "®", name: "REGISTERED" },
|
||||
{ char: "™", name: "TRADE MARK" },
|
||||
{ char: "Ⓜ", name: "CIRCLED M" },
|
||||
{ char: "㊗", name: 'JAPANESE "CONGRATULATIONS" BUTTON' },
|
||||
{ char: "㊙", name: 'JAPANESE "SECRET" BUTTON' },
|
||||
{ char: "▪", name: "BLACK SMALL SQUARE" },
|
||||
{ char: "▫", name: "WHITE SMALL SQUARE" },
|
||||
{ char: "☷", name: "TRIGRAM FOR EARTH" },
|
||||
{ char: "☵", name: "TRIGRAM FOR WATER" },
|
||||
{ char: "☶", name: "TRIGRAM FOR MOUNTAIN" },
|
||||
{ char: "☋", name: "DESCENDING NODE" },
|
||||
{ char: "☌", name: "CONJUNCTION" },
|
||||
{ char: "♜", name: "BLACK CHESS ROOK" },
|
||||
{ char: "♕", name: "WHITE CHESS QUEEN" },
|
||||
{ char: "♡", name: "WHITE HEART SUIT" },
|
||||
{ char: "♬", name: "BEAMED SIXTEENTH NOTES" },
|
||||
{ char: "☚", name: "BLACK LEFT POINTING INDEX" },
|
||||
{ char: "♮", name: "MUSIC NATURAL SIGN" },
|
||||
{ char: "♝", name: "BLACK CHESS BISHOP" },
|
||||
{ char: "♯", name: "SHARP" },
|
||||
{ char: "☴", name: "TRIGRAM FOR WIND" },
|
||||
{ char: "♭", name: "FLAT" },
|
||||
{ char: "☓", name: "SALTIRE" },
|
||||
{ char: "☛", name: "BLACK RIGHT POINTING INDEX" },
|
||||
{ char: "☭", name: "HAMMER AND SICKLE" },
|
||||
{ char: "♢", name: "WHITE DIAMOND SUIT" },
|
||||
{ char: "✐", name: "UPPER RIGHT PENCIL" },
|
||||
{ char: "♖", name: "WHITE CHESS ROOK" },
|
||||
{ char: "☈", name: "THUNDERSTORM" },
|
||||
{ char: "☒", name: "BALLOT BOX WITH X" },
|
||||
{ char: "★", name: "BLACK STAR" },
|
||||
{ char: "♚", name: "BLACK CHESS KING" },
|
||||
{ char: "♛", name: "BLACK CHESS QUEEN" },
|
||||
{ char: "✎", name: "LOWER RIGHT PENCIL" },
|
||||
{ char: "♪", name: "EIGHTH NOTE" },
|
||||
{ char: "☰", name: "TRIGRAM FOR HEAVEN" },
|
||||
{ char: "☽", name: "FIRST QUARTER MOON" },
|
||||
{ char: "☡", name: "CAUTION SIGN" },
|
||||
{ char: "☼", name: "WHITE SUN WITH RAYS" },
|
||||
{ char: "♅", name: "URANUS" },
|
||||
{ char: "☐", name: "BALLOT BOX" },
|
||||
{ char: "☟", name: "WHITE DOWN POINTING INDEX" },
|
||||
{ char: "❦", name: "FLORAL HEART" },
|
||||
{ char: "☊", name: "ASCENDING NODE" },
|
||||
{ char: "☍", name: "OPPOSITION" },
|
||||
{ char: "☬", name: "ADI SHAKTI" },
|
||||
{ char: "♧", name: "WHITE CLUB SUIT" },
|
||||
{ char: "☫", name: "FARSI SYMBOL" },
|
||||
{ char: "☱", name: "TRIGRAM FOR LAKE" },
|
||||
{ char: "☾", name: "LAST QUARTER MOON" },
|
||||
{ char: "☤", name: "CADUCEUS" },
|
||||
{ char: "❧", name: "ROTATED FLORAL HEART BULLET" },
|
||||
{ char: "♄", name: "SATURN" },
|
||||
{ char: "♁", name: "EARTH" },
|
||||
{ char: "♔", name: "WHITE CHESS KING" },
|
||||
{ char: "❥", name: "ROTATED HEAVY BLACK HEART BULLET" },
|
||||
{ char: "☥", name: "ANKH" },
|
||||
{ char: "☻", name: "BLACK SMILING FACE" },
|
||||
{ char: "♤", name: "WHITE SPADE SUIT" },
|
||||
{ char: "♞", name: "BLACK CHESS KNIGHT" },
|
||||
{ char: "♆", name: "NEPTUNE" },
|
||||
{ char: "#", name: "HASH SIGN" },
|
||||
{ char: "♃", name: "JUPITER" },
|
||||
{ char: "♩", name: "QUARTER NOTE" },
|
||||
{ char: "☇", name: "LIGHTNING" },
|
||||
{ char: "☞", name: "WHITE RIGHT POINTING INDEX" },
|
||||
{ char: "♫", name: "BEAMED EIGHTH NOTES" },
|
||||
{ char: "☏", name: "WHITE TELEPHONE" },
|
||||
{ char: "♘", name: "WHITE CHESS KNIGHT" },
|
||||
{ char: "☧", name: "CHI RHO" },
|
||||
{ char: "☉", name: "SUN" },
|
||||
{ char: "♇", name: "PLUTO" },
|
||||
{ char: "☩", name: "CROSS OF JERUSALEM" },
|
||||
{ char: "♙", name: "WHITE CHESS PAWN" },
|
||||
{ char: "☜", name: "WHITE LEFT POINTING INDEX" },
|
||||
{ char: "☲", name: "TRIGRAM FOR FIRE" },
|
||||
{ char: "☨", name: "CROSS OF LORRAINE" },
|
||||
{ char: "♗", name: "WHITE CHESS BISHOP" },
|
||||
{ char: "☳", name: "TRIGRAM FOR THUNDER" },
|
||||
{ char: "⚔", name: "CROSSED SWORDS" },
|
||||
{ char: "⚀", name: "DICE ONE" },
|
||||
];
|
||||
|
||||
const rawMotd = ref(props.server.general?.motd ?? "");
|
||||
|
||||
const motd = computed(() => {
|
||||
const lines = rawMotd.value.split("\n");
|
||||
return lines.map((line) => {
|
||||
const segments = [];
|
||||
let currentSegment = { text: "", color: "White" };
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
if (line[i] === "§") {
|
||||
if (currentSegment.text) {
|
||||
segments.push({ ...currentSegment });
|
||||
currentSegment = { text: "", color: "White" };
|
||||
}
|
||||
const formatCode = line.substr(i, 2);
|
||||
const format = formatCodes.find((f) => f.code === formatCode);
|
||||
console.log(format);
|
||||
console.log(formatCode);
|
||||
if (format) {
|
||||
currentSegment.color = format.color;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§l") {
|
||||
currentSegment.bold = true;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§o") {
|
||||
currentSegment.italic = true;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§n") {
|
||||
currentSegment.underline = true;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§m") {
|
||||
currentSegment.strikethrough = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
currentSegment.text += line[i];
|
||||
i++;
|
||||
}
|
||||
if (currentSegment.text) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
return segments;
|
||||
});
|
||||
});
|
||||
|
||||
const styles = [
|
||||
{
|
||||
name: "bold",
|
||||
icon: BoldIcon,
|
||||
},
|
||||
{
|
||||
name: "italic",
|
||||
icon: ItalicIcon,
|
||||
},
|
||||
{
|
||||
name: "underline",
|
||||
icon: UnderlineIcon,
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
icon: StrikethroughIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const showPopup = ref(false);
|
||||
const popupX = ref(0);
|
||||
const popupY = ref(0);
|
||||
const currentLineIndex = ref(0);
|
||||
const selectionStart = ref(0);
|
||||
const selectionEnd = ref(0);
|
||||
const colorPicker = ref(false);
|
||||
|
||||
const pickColor = () => {
|
||||
colorPicker.value = !colorPicker.value;
|
||||
};
|
||||
|
||||
const totalCharacters = computed(() => {
|
||||
return motd.value.reduce((sum, line) => {
|
||||
return Math.max(
|
||||
sum,
|
||||
line.reduce((lineSum, segment) => lineSum + segment.text.length, 0),
|
||||
);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const minecraftFormat = computed(() => {
|
||||
return motd.value
|
||||
.map((line) => {
|
||||
return line
|
||||
.map((segment) => {
|
||||
let format = getColorCode(segment.color);
|
||||
if (segment.bold) format += "§l";
|
||||
if (segment.italic) format += "§o";
|
||||
if (segment.underline) format += "§n";
|
||||
if (segment.strikethrough) format += "§m";
|
||||
return format + segment.text;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
const currentStyle = computed(() => {
|
||||
const line = motd.value[currentLineIndex.value];
|
||||
if (!line) return {};
|
||||
|
||||
let start = 0;
|
||||
for (const segment of line) {
|
||||
if (start + segment.text.length > selectionStart.value) {
|
||||
return {
|
||||
color: segment.color || "White",
|
||||
bold: segment.bold || false,
|
||||
italic: segment.italic || false,
|
||||
underline: segment.underline || false,
|
||||
strikethrough: segment.strikethrough || false,
|
||||
};
|
||||
}
|
||||
start += segment.text.length;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
function getColorCode(color) {
|
||||
const format = formatCodes.find((f) => f.description === color);
|
||||
return format ? format.code : "§f";
|
||||
}
|
||||
|
||||
function renderLine(line) {
|
||||
return line
|
||||
.map((segment) => {
|
||||
let style = `color: ${segment.color};`;
|
||||
if (segment.bold) style += "font-weight: 900;";
|
||||
if (segment.italic) style += "font-style: italic;";
|
||||
if (segment.underline) style += "text-decoration: underline;";
|
||||
if (segment.strikethrough) style += "text-decoration: line-through;";
|
||||
return `<span style="${style}">${segment.text}</span>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function handleSelection(lineIndex) {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
popupX.value = rect.left;
|
||||
popupY.value = rect.bottom;
|
||||
showPopup.value = true;
|
||||
currentLineIndex.value = lineIndex;
|
||||
|
||||
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
|
||||
const rangeClone = range.cloneRange();
|
||||
rangeClone.selectNodeContents(lineElement);
|
||||
rangeClone.setEnd(range.startContainer, range.startOffset);
|
||||
selectionStart.value = rangeClone.toString().length;
|
||||
selectionEnd.value = selectionStart.value + range.toString().length;
|
||||
} else {
|
||||
showPopup.value = false;
|
||||
colorPicker.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyStyle(newStyle) {
|
||||
const line = motd.value[currentLineIndex.value];
|
||||
const newLine = [];
|
||||
let currentPos = 0;
|
||||
|
||||
for (const segment of line) {
|
||||
if (currentPos + segment.text.length <= selectionStart.value) {
|
||||
newLine.push(segment);
|
||||
} else if (currentPos >= selectionEnd.value) {
|
||||
newLine.push(segment);
|
||||
} else {
|
||||
const beforeSelection = segment.text.slice(0, Math.max(0, selectionStart.value - currentPos));
|
||||
const inSelection = segment.text.slice(
|
||||
Math.max(0, selectionStart.value - currentPos),
|
||||
Math.min(segment.text.length, selectionEnd.value - currentPos),
|
||||
);
|
||||
const afterSelection = segment.text.slice(
|
||||
Math.min(segment.text.length, selectionEnd.value - currentPos),
|
||||
);
|
||||
console.log(beforeSelection);
|
||||
console.log(inSelection);
|
||||
console.log(afterSelection);
|
||||
|
||||
if (beforeSelection) newLine.push({ ...segment, text: beforeSelection });
|
||||
if (inSelection) {
|
||||
const mergedStyle = { ...segment, ...newStyle };
|
||||
for (const key in newStyle) {
|
||||
if (newStyle[key] === false) {
|
||||
delete mergedStyle[key];
|
||||
}
|
||||
}
|
||||
newLine.push({ ...mergedStyle, text: inSelection });
|
||||
}
|
||||
if (afterSelection) newLine.push({ ...segment, text: afterSelection });
|
||||
}
|
||||
currentPos += segment.text.length;
|
||||
}
|
||||
|
||||
motd.value[currentLineIndex.value] = newLine;
|
||||
showPopup.value = false;
|
||||
colorPicker.value = false;
|
||||
|
||||
// Rerender the line to reflect the changes
|
||||
nextTick(() => {
|
||||
const lineElement = document.querySelectorAll("[contenteditable]")[currentLineIndex.value];
|
||||
lineElement.innerHTML = renderLine(newLine);
|
||||
});
|
||||
}
|
||||
|
||||
function insertEmoji() {
|
||||
const emoji = "☺";
|
||||
if (totalCharacters.value + emoji.length <= 90) {
|
||||
applyStyle({ text: emoji });
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event, lineIndex) {
|
||||
const newText = event.target.textContent;
|
||||
const oldText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
|
||||
const diff = newText.length - oldText.length;
|
||||
|
||||
if (newText.length <= 45) {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
const cursorOffset = getCursorOffset(event.target, range);
|
||||
|
||||
const newLine = [];
|
||||
let currentPos = 0;
|
||||
for (const segment of motd.value[lineIndex]) {
|
||||
const segmentEnd = currentPos + segment.text.length;
|
||||
const newSegmentText = newText.slice(currentPos, Math.min(segmentEnd, newText.length));
|
||||
if (newSegmentText) {
|
||||
newLine.push({ ...segment, text: newSegmentText });
|
||||
}
|
||||
currentPos = segmentEnd;
|
||||
if (currentPos >= newText.length) break;
|
||||
}
|
||||
if (currentPos < newText.length) {
|
||||
newLine.push({ text: newText.slice(currentPos), color: "White" });
|
||||
}
|
||||
motd.value[lineIndex] = newLine;
|
||||
|
||||
nextTick(() => {
|
||||
const lineElement = event.target;
|
||||
lineElement.innerHTML = renderLine(newLine);
|
||||
|
||||
const newRange = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
const { node, offset } = getCursorNodeAndOffset(lineElement, cursorOffset);
|
||||
|
||||
if (node) {
|
||||
newRange.setStart(node, offset);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
event.target.innerHTML = renderLine(motd.value[lineIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get cursor offset considering styled spans
|
||||
function getCursorOffset(element, range) {
|
||||
let offset = 0;
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node === range.startContainer) {
|
||||
return offset + range.startOffset;
|
||||
}
|
||||
offset += node.length;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
// Helper function to find the node and offset for cursor placement
|
||||
function getCursorNodeAndOffset(element, targetOffset) {
|
||||
let currentOffset = 0;
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (currentOffset + node.length >= targetOffset) {
|
||||
return { node, offset: targetOffset - currentOffset };
|
||||
}
|
||||
currentOffset += node.length;
|
||||
}
|
||||
|
||||
// If we've gone past the end, return the last possible position
|
||||
const lastTextNode = element.lastChild?.lastChild;
|
||||
return { node: lastTextNode, offset: lastTextNode?.length || 0 };
|
||||
}
|
||||
|
||||
function handlePaste(event, lineIndex) {
|
||||
event.preventDefault();
|
||||
const pastedText = (event.clipboardData || window.clipboardData).getData("text");
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
const startOffset = range.startOffset;
|
||||
|
||||
const currentText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
|
||||
const newText = currentText.slice(0, startOffset) + pastedText + currentText.slice(startOffset);
|
||||
|
||||
if (newText.length <= 45) {
|
||||
// Preserve existing styles by matching new text with old segments
|
||||
const newLine = [];
|
||||
let currentPos = 0;
|
||||
for (const segment of motd.value[lineIndex]) {
|
||||
if (currentPos < startOffset) {
|
||||
const segmentEnd = Math.min(currentPos + segment.text.length, startOffset);
|
||||
newLine.push({ ...segment, text: newText.slice(currentPos, segmentEnd) });
|
||||
currentPos = segmentEnd;
|
||||
} else if (currentPos >= startOffset + pastedText.length) {
|
||||
newLine.push({ ...segment, text: newText.slice(currentPos) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Insert pasted text as a new segment
|
||||
if (currentPos < startOffset + pastedText.length) {
|
||||
newLine.push({
|
||||
text: newText.slice(currentPos, startOffset + pastedText.length),
|
||||
color: "White",
|
||||
});
|
||||
}
|
||||
motd.value[lineIndex] = newLine;
|
||||
|
||||
nextTick(() => {
|
||||
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
|
||||
lineElement.innerHTML = renderLine(newLine);
|
||||
const newRange = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
newRange.setStart(lineElement.childNodes[0], startOffset + pastedText.length);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.minecraft-font {
|
||||
font-family: "Minecraft", monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[contenteditable] {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@font-face {
|
||||
font-family: "Monocraft";
|
||||
src: url("/Monocraft.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.font-minecraft {
|
||||
font-family: "Monocraft", monospace;
|
||||
}
|
||||
|
||||
.mcbg {
|
||||
background: url("@/assets/images/servers/minecraft-background-dark.png") repeat center center;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
.fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:serif="http://www.serif.com/"
|
||||
version="1.1"
|
||||
viewBox="0 0 1793 199"
|
||||
>
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g id="green" fill="var(--color-brand)">
|
||||
<path
|
||||
d="M1184.1,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
|
||||
/>
|
||||
<path
|
||||
d="M1291.1,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
|
||||
/>
|
||||
<path
|
||||
d="M1357.2,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
|
||||
/>
|
||||
<path d="M1460,165.3l-40.8-95.1h23.2l35.1,83.9h-11.4l36.3-83.9h21.4l-40.8,95.1h-23Z" />
|
||||
<path
|
||||
d="M1579.6,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
|
||||
/>
|
||||
<path
|
||||
d="M1645.7,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
|
||||
/>
|
||||
<path
|
||||
d="M1749.9,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
d="M9.8,143l63.4-38.1-5.8-15.3,18.1-18.6,22.9-4.9,6.6,8.2-10.6,10.7-9.2,2.9-6.6,6.8,3.2,9,6.5,6.9,9.2-2.5,6.6-7.2,14.3-4.5,4.3,9.6-14.8,18.1-24.8,7.8-11.1-12.4-63.6,38.2c-3-3.9-6.5-9.4-8.8-14.7ZM192.8,65.4l-50.4,13.6c2.8,7.4,3.7,11.7,4.5,16.5l50.3-13.6c-.8-5.4-2.2-10.8-4.4-16.5Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M17.3,106.5c3.6,42.1,38.9,75.2,82,75.2s60.7-18.9,74-46.3l16.4,5.7c-15.8,34.1-50.3,57.9-90.4,57.9S3.6,158.2,0,106.5h17.3ZM.3,89.4C5.3,39.2,47.8,0,99.3,0s99.5,44.6,99.5,99.5-1.1,17.4-3.3,25.5l-16.3-5.7c1.6-6.5,2.4-13.1,2.4-19.8,0-45.4-36.9-82.3-82.3-82.3S22.6,48.7,17.6,89.4H.3Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M99,51.6c-26.4,0-47.9,21.5-47.9,48s21.5,48,48,48,2.7,0,4-.2l4.8,16.8c-2.9.4-5.8.6-8.8.6-36,0-65.2-29.2-65.2-65.2S63.1,34.4,99,34.4s1.8,0,2.7,0l-2.7,17.1ZM118.6,37.4c26.4,8.3,45.6,33,45.6,62.2s-16.4,50.2-39.8,60l-4.8-16.7c16.2-7.7,27.4-24.2,27.4-43.3s-13-38.1-31.1-44.9l2.7-17.2Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="black" fill="currentColor">
|
||||
<path
|
||||
d="M354.8,69.2c12,0,21.7,3.4,28.6,10.4,7,7.2,10.6,17.5,10.6,31.5v54.8h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,5-6.8,12.2-6.8,21.3v48.5h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,4.8-6.8,12-6.8,21.3v48.5h-22.4v-95.6h21.3v12.2c3.6-4.3,8.1-7.5,13.4-9.8,5.4-2.3,11.3-3.4,17.9-3.4s13.6,1.3,19.2,3.9c5.5,2.9,9.8,6.8,13.1,12,3.9-5,8.9-8.9,15.2-11.8,6.3-2.7,13.1-4.1,20.6-4.1ZM466,167.2c-9.7,0-18.4-2.1-26.1-6.3-7.6-4-13.8-10.1-18.1-17.5-4.5-7.3-6.6-15.7-6.6-25.2s2.1-17.9,6.6-25.2c4.3-7.4,10.6-13.4,18.1-17.4,7.7-4.1,16.5-6.3,26.1-6.3s18.6,2.1,26.3,6.3c7.7,4.1,13.8,10,18.3,17.4,4.3,7.3,6.4,15.7,6.4,25.2s-2.1,17.9-6.4,25.2c-4.5,7.5-10.6,13.4-18.3,17.5-7.7,4.1-16.5,6.3-26.3,6.3h0ZM466,148c8.2,0,15-2.7,20.4-8.2,5.4-5.5,8.1-12.7,8.1-21.7s-2.7-16.1-8.1-21.7c-5.4-5.5-12.2-8.2-20.4-8.2s-15,2.7-20.2,8.2c-5.4,5.5-8.1,12.7-8.1,21.7s2.7,16.1,8.1,21.7c5.2,5.5,12,8.2,20.2,8.2ZM631.5,33.1v132.8h-21.5v-12.3c-3.7,4.4-8.3,7.9-13.6,10.2-5.5,2.3-11.5,3.4-18.1,3.4s-17.4-2-24.7-6.1c-7.3-4.1-13.2-9.8-17.4-17.4-4.1-7.3-6.3-15.9-6.3-25.6s2.1-18.3,6.3-25.6c4.1-7.3,10-13.1,17.4-17.2,7.3-4.1,15.6-6.1,24.7-6.1s12.2,1.1,17.4,3.2c5.2,2.1,9.8,5.4,13.4,9.7v-49h22.4ZM581.1,148c5.4,0,10.2-1.3,14.5-3.8,4.3-2.3,7.7-5.9,10.2-10.4,2.5-4.5,3.8-9.8,3.8-15.7s-1.3-11.3-3.8-15.7c-2.5-4.5-5.9-8.1-10.2-10.6-4.3-2.3-9.1-3.6-14.5-3.6s-10.2,1.3-14.5,3.6c-4.3,2.5-7.7,6.1-10.2,10.6-2.5,4.5-3.8,9.8-3.8,15.7s1.3,11.3,3.8,15.7c2.5,4.5,5.9,8.1,10.2,10.4,4.3,2.5,9.1,3.8,14.5,3.8ZM681.6,84.3c6.4-10,17.7-15,34-15v21.3c-1.7-.3-3.4-.5-5.2-.5-8.8,0-15.6,2.5-20.4,7.5-4.8,5.2-7.3,12.5-7.3,22v46.4h-22.4v-95.6h21.3v14h0ZM734.1,70.3h22.4v95.6h-22.4v-95.6ZM745.4,54.6c-4.1,0-7.5-1.3-10.2-3.9-2.7-2.4-4.2-5.9-4.1-9.5,0-3.8,1.4-7,4.1-9.7,2.7-2.5,6.1-3.8,10.2-3.8s7.5,1.3,10.2,3.6c2.7,2.5,4.1,5.5,4.1,9.3s-1.3,7.2-3.9,9.8c-2.7,2.7-6.3,4.1-10.4,4.1ZM839.5,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4v-95.6h21.3v12.3c3.8-4.5,8.4-7.7,14-10,5.5-2.3,12-3.4,19-3.4ZM964.8,160.7c-2.8,2.2-6,3.9-9.5,4.8-3.9,1.1-7.9,1.6-12,1.6-10.6,0-18.6-2.7-24.3-8.2-5.7-5.5-8.6-13.4-8.6-24v-46h-15.7v-17.9h15.7v-21.8h22.4v21.8h25.6v17.9h-25.6v45.5c0,4.7,1.1,8.2,3.4,10.6,2.3,2.5,5.5,3.8,9.8,3.8s9.1-1.3,12.5-3.9l6.3,15.9ZM1036.9,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4V33.1h22.4v48.3c3.8-3.9,8.2-7,13.8-9.1,5.4-2,11.5-3,18.1-3Z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
31
apps/frontend/src/components/ui/servers/PanelCopyIP.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<ButtonStyled type="standard">
|
||||
<button aria-label="Copy server IP" @click="copyText">
|
||||
<CopyIcon />
|
||||
Copy IP
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CopyIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<{
|
||||
ip: string;
|
||||
port: number;
|
||||
subdomain?: string | null;
|
||||
}>();
|
||||
|
||||
const copyText = () => {
|
||||
const text = props.subdomain ? `${props.subdomain}.modrinth.gg` : `${props.ip}:${props.port}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: `Copied IP`,
|
||||
text: `Your server's IP has been copied to your clipboard`,
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style="font-variant-numeric: tabular-nums"
|
||||
class="pointer-events-none h-full w-full select-none"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-row items-center gap-6">
|
||||
<div
|
||||
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
>
|
||||
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
|
||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
|
||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
|
||||
</div>
|
||||
<h3 class="relative z-10 text-base font-normal text-secondary">CPU usage</h3>
|
||||
</div>
|
||||
<CPUIcon class="absolute right-10 top-10" />
|
||||
</div>
|
||||
<div
|
||||
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
>
|
||||
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
|
||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
|
||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
|
||||
</div>
|
||||
<h3 class="relative z-10 text-base font-normal text-secondary">Memory usage</h3>
|
||||
</div>
|
||||
<DBIcon class="absolute right-10 top-10" />
|
||||
</div>
|
||||
<div
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0 Bytes</h2>
|
||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 0 Bytes</h3>
|
||||
</div>
|
||||
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
|
||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex h-full w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
>
|
||||
<div class="experimental-styles-within flex flex-row items-center">
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="console relative h-full min-h-[488px] w-full overflow-hidden rounded-xl bg-bg text-sm"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CPUIcon, DBIcon, FolderOpenIcon } from "@modrinth/assets";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
html.light-mode .console {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
html.dark-mode .console {
|
||||
background: black;
|
||||
}
|
||||
|
||||
html.oled-mode .console {
|
||||
background: black;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal">
|
||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
||||
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p>
|
||||
|
||||
<UiCheckbox
|
||||
v-model="powerDontAskAgainCheckbox"
|
||||
label="Don't ask me again"
|
||||
class="text-sm"
|
||||
:disabled="!currentPendingAction"
|
||||
/>
|
||||
<div class="flex flex-row gap-4">
|
||||
<ButtonStyled type="standard" color="brand" @click="confirmAction">
|
||||
<button>
|
||||
<CheckIcon class="h-5 w-5" />
|
||||
{{ currentPendingActionFriendly }} server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled @click="closePowerModal">
|
||||
<button>
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
ref="detailsModal"
|
||||
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`"
|
||||
@close="closeDetailsModal"
|
||||
>
|
||||
<UiServersServerInfoLabels
|
||||
:server-data="serverData"
|
||||
:show-game-label="true"
|
||||
:show-loader-label="true"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:column="true"
|
||||
class="mb-6 flex flex-col gap-2"
|
||||
/>
|
||||
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
|
||||
<button class="w-full">Close</button>
|
||||
</ButtonStyled>
|
||||
</NewModal>
|
||||
|
||||
<div class="flex flex-row items-center gap-2 rounded-lg">
|
||||
<ButtonStyled v-if="isInstalling" type="standard" color="brand">
|
||||
<button disabled class="flex-shrink-0">
|
||||
<UiServersPanelSpinner class="size-5" /> Installing...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-else class="contents">
|
||||
<ButtonStyled v-if="showStopButton" type="transparent">
|
||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer">
|
||||
<div class="flex gap-1">
|
||||
<StopCircleIcon class="h-5 w-5" />
|
||||
<span>{{ stopButtonText }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction">
|
||||
<div v-if="isStartingOrRestarting" class="grid place-content-center">
|
||||
<UiServersIconsLoadingIcon />
|
||||
</div>
|
||||
<div v-else class="contents">
|
||||
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" />
|
||||
</div>
|
||||
<span>
|
||||
{{ actionButtonText }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown options -->
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
:options="[
|
||||
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]),
|
||||
{ id: 'allServers', action: () => router.push('/servers/manage') },
|
||||
{ id: 'details', action: () => showDetailsModal() },
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #kill>
|
||||
<SlashIcon class="h-5 w-5" />
|
||||
<span>Kill server</span>
|
||||
</template>
|
||||
<template #allServers>
|
||||
<ServerIcon class="h-5 w-5" />
|
||||
<span>All servers</span>
|
||||
</template>
|
||||
<template #details>
|
||||
<InfoIcon class="h-5 w-5" />
|
||||
<span>Details</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import {
|
||||
PlayIcon,
|
||||
UpdatedIcon,
|
||||
StopCircleIcon,
|
||||
SlashIcon,
|
||||
MoreVerticalIcon,
|
||||
XIcon,
|
||||
CheckIcon,
|
||||
ServerIcon,
|
||||
InfoIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
isOnline: boolean;
|
||||
isActioning: boolean;
|
||||
isInstalling: boolean;
|
||||
disabled: boolean;
|
||||
serverName?: string;
|
||||
serverData: object;
|
||||
uptimeSeconds: number;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const serverId = router.currentRoute.value.params.id;
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
powerDontAskAgain: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: "start" | "restart" | "stop" | "kill"): void;
|
||||
}>();
|
||||
|
||||
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
|
||||
const ServerState = {
|
||||
Stopped: "Stopped",
|
||||
Starting: "Starting",
|
||||
Running: "Running",
|
||||
Stopping: "Stopping",
|
||||
Restarting: "Restarting",
|
||||
} as const;
|
||||
|
||||
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
|
||||
|
||||
const currentPendingAction = ref<string | null>(null);
|
||||
const currentPendingState = ref<ServerStateType | null>(null);
|
||||
const powerDontAskAgainCheckbox = ref(false);
|
||||
|
||||
const currentState = ref<ServerStateType>(
|
||||
props.isOnline ? ServerState.Running : ServerState.Stopped,
|
||||
);
|
||||
|
||||
const isStartingDelay = ref(false);
|
||||
const showStopButton = computed(
|
||||
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
|
||||
);
|
||||
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
|
||||
const canTakeAction = computed(
|
||||
() =>
|
||||
!props.isActioning &&
|
||||
!isStartingDelay.value &&
|
||||
currentState.value !== ServerState.Starting &&
|
||||
currentState.value !== ServerState.Stopping,
|
||||
);
|
||||
|
||||
const isStartingOrRestarting = computed(
|
||||
() =>
|
||||
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
|
||||
);
|
||||
|
||||
const isStopping = computed(() => currentState.value === ServerState.Stopping);
|
||||
|
||||
const actionButtonText = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case ServerState.Starting:
|
||||
return "Starting...";
|
||||
case ServerState.Restarting:
|
||||
return "Restarting...";
|
||||
case ServerState.Running:
|
||||
return "Restart";
|
||||
case ServerState.Stopping:
|
||||
return "Stopping...";
|
||||
default:
|
||||
return "Start";
|
||||
}
|
||||
});
|
||||
|
||||
const currentPendingActionFriendly = computed(() => {
|
||||
switch (currentPendingAction.value) {
|
||||
case "start":
|
||||
return "Start";
|
||||
case "restart":
|
||||
return "Restart";
|
||||
case "stop":
|
||||
return "Stop";
|
||||
case "kill":
|
||||
return "Kill";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const stopButtonText = computed(() =>
|
||||
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop",
|
||||
);
|
||||
|
||||
const createPendingAction = () => {
|
||||
if (!canTakeAction.value) return;
|
||||
if (currentState.value === ServerState.Running) {
|
||||
currentPendingAction.value = "restart";
|
||||
currentPendingState.value = ServerState.Restarting;
|
||||
showPowerModal();
|
||||
} else {
|
||||
runAction("start", ServerState.Starting);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
createPendingAction();
|
||||
};
|
||||
|
||||
const showPowerModal = () => {
|
||||
if (userPreferences.value.powerDontAskAgain) {
|
||||
runAction(
|
||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
||||
currentPendingState.value!,
|
||||
);
|
||||
} else {
|
||||
confirmActionModal.value?.show();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAction = () => {
|
||||
if (powerDontAskAgainCheckbox.value) {
|
||||
userPreferences.value.powerDontAskAgain = true;
|
||||
}
|
||||
runAction(
|
||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
||||
currentPendingState.value!,
|
||||
);
|
||||
closePowerModal();
|
||||
};
|
||||
|
||||
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
|
||||
emit("action", action);
|
||||
currentState.value = serverState;
|
||||
|
||||
if (action === "start") {
|
||||
isStartingDelay.value = true;
|
||||
setTimeout(() => {
|
||||
isStartingDelay.value = false;
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const stopServer = () => {
|
||||
if (!canTakeAction.value) return;
|
||||
currentPendingAction.value = "stop";
|
||||
currentPendingState.value = ServerState.Stopping;
|
||||
showPowerModal();
|
||||
};
|
||||
|
||||
const killServer = () => {
|
||||
currentPendingAction.value = "kill";
|
||||
currentPendingState.value = ServerState.Stopping;
|
||||
showPowerModal();
|
||||
};
|
||||
|
||||
const closePowerModal = () => {
|
||||
confirmActionModal.value?.hide();
|
||||
currentPendingAction.value = null;
|
||||
powerDontAskAgainCheckbox.value = false;
|
||||
};
|
||||
|
||||
const closeDetailsModal = () => {
|
||||
detailsModal.value?.hide();
|
||||
};
|
||||
|
||||
const showDetailsModal = () => {
|
||||
detailsModal.value?.show();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.isOnline,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
currentState.value = ServerState.Running;
|
||||
} else {
|
||||
currentState.value = ServerState.Stopped;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => {
|
||||
closeDetailsModal();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div
|
||||
:aria-label="`Server is ${getStatusText}`"
|
||||
class="relative inline-flex select-none items-center"
|
||||
@mouseenter="isExpanded = true"
|
||||
@mouseleave="isExpanded = false"
|
||||
>
|
||||
<div
|
||||
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`"
|
||||
>
|
||||
<div
|
||||
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${
|
||||
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0'
|
||||
}`"
|
||||
>
|
||||
<div class="h-3 w-3 rounded-full"></div>
|
||||
<span
|
||||
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out"
|
||||
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`"
|
||||
>
|
||||
{{ getStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import type { ServerState } from "~/types/servers";
|
||||
|
||||
const props = defineProps<{
|
||||
state: ServerState;
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const getStatusClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case "running":
|
||||
return { main: "bg-brand", bg: "bg-bg-green" };
|
||||
case "stopped":
|
||||
return { main: "", bg: "" };
|
||||
case "crashed":
|
||||
return { main: "bg-brand-red", bg: "bg-bg-red" };
|
||||
default:
|
||||
return { main: "", bg: "" };
|
||||
}
|
||||
});
|
||||
|
||||
const getStatusText = computed(() => {
|
||||
switch (props.state) {
|
||||
case "running":
|
||||
return "Running";
|
||||
case "stopped":
|
||||
return "";
|
||||
case "crashed":
|
||||
return "Crashed";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
22
apps/frontend/src/components/ui/servers/PanelSpinner.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
684
apps/frontend/src/components/ui/servers/PanelTerminal.vue
Normal file
@@ -0,0 +1,684 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-terminal
|
||||
:class="[
|
||||
'terminal-font console relative z-[1] flex h-full w-full flex-col items-center justify-between overflow-hidden rounded-t-xl px-1 text-sm transition-transform duration-300',
|
||||
{ 'scale-fullscreen screen-fixed inset-0 z-50 !rounded-none': isFullScreen },
|
||||
]"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
v-if="cosmetics.advancedRendering"
|
||||
class="progressive-gradient pointer-events-none absolute -bottom-6 left-0 z-[2] h-[10rem] w-full overflow-hidden rounded-xl"
|
||||
:style="`--transparency: ${Math.max(0, lerp(100, 0, bottomThreshold * 8))}%`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="i in progressiveBlurIterations"
|
||||
:key="i"
|
||||
aria-hidden="true"
|
||||
class="absolute left-0 top-0 h-full w-full"
|
||||
:style="getBlurStyle(i)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-[2] h-[196px] w-full"
|
||||
:style="
|
||||
bottomThreshold > 0
|
||||
? { background: 'linear-gradient(transparent 30%, var(--console-bg) 70%)' }
|
||||
: {}
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute left-0 top-0 z-[60] h-full w-full"
|
||||
:style="{
|
||||
visibility: isFullScreen ? 'hidden' : 'visible',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute -bottom-2 -right-2 h-7 w-7"
|
||||
:style="{
|
||||
background: `radial-gradient(circle at 0% 0%, transparent 50%, var(--color-raised-bg) 52%)`,
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute -bottom-2 -left-2 h-7 w-7"
|
||||
:style="{
|
||||
background: `radial-gradient(circle at 100% 0%, transparent 50%, var(--color-raised-bg) 52%)`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div data-pyro-terminal-scroll-root class="relative h-full w-full">
|
||||
<div
|
||||
ref="scrollbarTrack"
|
||||
data-pyro-terminal-scrollbar-track
|
||||
class="absolute -right-1 bottom-16 top-4 z-[4] w-4"
|
||||
@mousedown="handleTrackClick"
|
||||
>
|
||||
<div
|
||||
data-pyro-terminal-scrollbar
|
||||
class="flex h-full justify-center rounded-full transition-all"
|
||||
:style="{ opacity: bottomThreshold > 0 ? '1' : '0.5' }"
|
||||
>
|
||||
<div
|
||||
ref="scrollbarThumb"
|
||||
data-pyro-terminal-scrollbar-thumb
|
||||
class="absolute w-1.5 cursor-default rounded-full bg-button-bg"
|
||||
:style="{
|
||||
height: `${getThumbHeight()}px`,
|
||||
transform: `translateY(${getThumbPosition()}px)`,
|
||||
}"
|
||||
@mousedown="startDragging"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
data-pyro-terminal-root
|
||||
class="scrollbar-none absolute left-0 top-0 h-full w-full select-text overflow-x-auto overflow-y-auto py-6 pb-[72px]"
|
||||
@scroll="handleScrollEvent"
|
||||
>
|
||||
<div data-pyro-terminal-virtual-height-watcher :style="{ height: `${totalHeight}px` }">
|
||||
<ul
|
||||
class="m-0 list-none p-0"
|
||||
data-pyro-terminal-virtual-list
|
||||
:style="virtualListStyle"
|
||||
aria-live="polite"
|
||||
role="listbox"
|
||||
>
|
||||
<template v-for="(item, index) in visibleItems" :key="index">
|
||||
<li>
|
||||
<UiServersLogParser :log="item" :index="visibleStartIndex + index" />
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-4 z-[3] w-full"
|
||||
:style="{
|
||||
filter: `drop-shadow(0 8px 12px rgba(0, 0, 0, ${lerp(0.1, 0.5, bottomThreshold)}))`,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<button
|
||||
data-pyro-fullscreen
|
||||
:label="isFullScreen ? 'Exit full screen' : 'Enter full screen'"
|
||||
class="experimental-styles-within absolute right-4 top-4 z-[3] grid h-12 w-12 place-content-center rounded-lg border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<UiServersPanelTerminalMinimize v-if="isFullScreen" />
|
||||
<UiServersPanelTerminalFullscreen v-else />
|
||||
</button>
|
||||
|
||||
<Transition name="scroll-to-bottom">
|
||||
<button
|
||||
v-if="bottomThreshold > 0 && !isScrolledToBottom"
|
||||
data-pyro-scrolltobottom
|
||||
label="Scroll to bottom"
|
||||
class="scroll-to-bottom-btn experimental-styles-within absolute bottom-[4.5rem] right-4 z-[3] grid h-12 w-12 place-content-center rounded-lg border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<RightArrowIcon class="rotate-90" />
|
||||
<span class="sr-only">Scroll to bottom</span>
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from "@modrinth/assets";
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
|
||||
const { $cosmetics } = useNuxtApp();
|
||||
const cosmetics = $cosmetics;
|
||||
|
||||
const props = defineProps<{
|
||||
consoleOutput: string[];
|
||||
fullScreen: boolean;
|
||||
}>();
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
const itemHeights = ref<number[]>([]);
|
||||
const averageItemHeight = ref(36);
|
||||
const bottomThreshold = ref(0);
|
||||
const bufferSize = 5;
|
||||
const cachedHeights = ref<Map<string, number>>(new Map());
|
||||
const isAutoScrolling = ref(false);
|
||||
|
||||
const progressiveBlurIterations = ref(8);
|
||||
|
||||
const scrollTop = ref(0);
|
||||
const clientHeight = ref(0);
|
||||
const isFullScreen = ref(props.fullScreen);
|
||||
|
||||
const initial = ref(false);
|
||||
const userHasScrolled = ref(false);
|
||||
const isScrolledToBottom = ref(true);
|
||||
|
||||
const handleScrollEvent = () => {
|
||||
handleListScroll();
|
||||
};
|
||||
|
||||
const totalHeight = computed(
|
||||
() =>
|
||||
itemHeights.value.reduce((sum, height) => sum + height, 0) ||
|
||||
props.consoleOutput.length * averageItemHeight.value,
|
||||
);
|
||||
|
||||
watch(totalHeight, () => {
|
||||
if (isScrolledToBottom.value) {
|
||||
scrollToBottom();
|
||||
}
|
||||
if (!initial.value) {
|
||||
initial.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const lerp = (start: number, end: number, t: number) => start * (1 - t) + end * t;
|
||||
|
||||
const getBlurStyle = (i: number) => {
|
||||
const properBlurIteration = i + 1;
|
||||
const blur = lerp(0, 2 ** (properBlurIteration - 3), bottomThreshold.value);
|
||||
const singular = 100 / progressiveBlurIterations.value;
|
||||
let mask = "linear-gradient(";
|
||||
|
||||
switch (i) {
|
||||
case 0:
|
||||
mask += `rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) ${singular}%`;
|
||||
break;
|
||||
case 1:
|
||||
mask += `rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) ${singular}%, rgb(0, 0, 0) ${singular * 2}%`;
|
||||
break;
|
||||
case 2:
|
||||
mask += `rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) ${singular}%, rgba(0, 0, 0, 0) ${singular * 2}%, rgb(0, 0, 0) ${singular * 3}%`;
|
||||
break;
|
||||
default:
|
||||
mask += `rgba(0, 0, 0, 0) ${singular * (i - 3)}%, rgb(0, 0, 0) ${singular * (i + 1 - 3)}%, rgb(0, 0, 0) ${singular * (i + 2 - 3)}%, rgba(0, 0, 0, 0) ${singular * (i + 3 - 3)}%`;
|
||||
break;
|
||||
}
|
||||
|
||||
mask += `)`;
|
||||
|
||||
return {
|
||||
backdropFilter: `blur(${blur}px)`,
|
||||
mask,
|
||||
position: "absolute" as any,
|
||||
zIndex: progressiveBlurIterations.value - i,
|
||||
};
|
||||
};
|
||||
|
||||
const getItemOffset = (index: number) => {
|
||||
return itemHeights.value.slice(0, index).reduce((sum, height) => sum + height, 0);
|
||||
};
|
||||
|
||||
const visibleStartIndex = computed(() => {
|
||||
let index = 0;
|
||||
let offset = 0;
|
||||
while (
|
||||
index < props.consoleOutput.length &&
|
||||
offset < scrollTop.value - bufferSize * averageItemHeight.value
|
||||
) {
|
||||
offset += itemHeights.value[index] || averageItemHeight.value;
|
||||
index++;
|
||||
}
|
||||
return Math.max(0, index - 1);
|
||||
});
|
||||
|
||||
const visibleEndIndex = computed(() => {
|
||||
let index = visibleStartIndex.value;
|
||||
let offset = getItemOffset(index);
|
||||
while (
|
||||
index < props.consoleOutput.length &&
|
||||
offset < scrollTop.value + clientHeight.value + bufferSize * averageItemHeight.value
|
||||
) {
|
||||
offset += itemHeights.value[index] || averageItemHeight.value;
|
||||
index++;
|
||||
}
|
||||
return Math.min(props.consoleOutput.length - 1, index);
|
||||
});
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
props.consoleOutput.slice(visibleStartIndex.value, visibleEndIndex.value + 1),
|
||||
);
|
||||
|
||||
const offsetY = computed(() => getItemOffset(visibleStartIndex.value));
|
||||
|
||||
const handleListScroll = () => {
|
||||
if (!scrollContainer.value) return;
|
||||
|
||||
const container = scrollContainer.value;
|
||||
scrollTop.value = container.scrollTop;
|
||||
clientHeight.value = container.clientHeight;
|
||||
|
||||
const scrollHeight = container.scrollHeight;
|
||||
const threshold = 32;
|
||||
|
||||
isScrolledToBottom.value = scrollHeight - scrollTop.value - clientHeight.value <= threshold;
|
||||
|
||||
if (!isScrolledToBottom.value && !isAutoScrolling.value) {
|
||||
userHasScrolled.value = true;
|
||||
}
|
||||
|
||||
bottomThreshold.value = Math.min(1, (scrollHeight - scrollTop.value - clientHeight.value) / 256);
|
||||
};
|
||||
|
||||
const updateItemHeights = async () => {
|
||||
if (!scrollContainer.value) return;
|
||||
|
||||
await nextTick();
|
||||
const items =
|
||||
scrollContainer.value?.querySelectorAll("[data-pyro-terminal-virtual-list] li") || [];
|
||||
items.forEach((el, idx) => {
|
||||
const index = visibleStartIndex.value + idx;
|
||||
const height = el.getBoundingClientRect().height;
|
||||
itemHeights.value[index] = height;
|
||||
const content = props.consoleOutput[index];
|
||||
if (content) {
|
||||
cachedHeights.value.set(content, height);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateClientHeight = () => {
|
||||
if (scrollContainer.value) {
|
||||
clientHeight.value = scrollContainer.value.clientHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (!scrollContainer.value) return;
|
||||
|
||||
isAutoScrolling.value = true;
|
||||
const container = scrollContainer.value;
|
||||
|
||||
nextTick(() => {
|
||||
const maxScroll = container.scrollHeight - container.clientHeight;
|
||||
container.scrollTop = maxScroll;
|
||||
|
||||
setTimeout(() => {
|
||||
if (container.scrollTop < maxScroll) {
|
||||
container.scrollTop = maxScroll;
|
||||
}
|
||||
isAutoScrolling.value = false;
|
||||
userHasScrolled.value = false;
|
||||
isScrolledToBottom.value = true;
|
||||
handleListScroll();
|
||||
}, 50);
|
||||
});
|
||||
};
|
||||
|
||||
const scrollbarTrack = ref<HTMLElement | null>(null);
|
||||
const scrollbarThumb = ref<HTMLElement | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const startY = ref(0);
|
||||
const startScrollTop = ref(0);
|
||||
|
||||
const getThumbHeight = () => {
|
||||
if (!scrollContainer.value || !scrollbarTrack.value) return 30;
|
||||
|
||||
const contentHeight = scrollContainer.value.scrollHeight;
|
||||
const viewportHeight = scrollContainer.value.clientHeight;
|
||||
const trackHeight = scrollbarTrack.value.clientHeight;
|
||||
|
||||
const heightRatio = viewportHeight / contentHeight;
|
||||
|
||||
const minThumbHeight = Math.min(40, trackHeight / 2);
|
||||
|
||||
const proposedHeight = Math.max(heightRatio * trackHeight, minThumbHeight);
|
||||
|
||||
return Math.min(proposedHeight, trackHeight);
|
||||
};
|
||||
|
||||
const getThumbPosition = () => {
|
||||
if (!scrollContainer.value || !scrollbarTrack.value) return 0;
|
||||
|
||||
const contentHeight = scrollContainer.value.scrollHeight;
|
||||
const viewportHeight = scrollContainer.value.clientHeight;
|
||||
const trackHeight = scrollbarTrack.value.clientHeight;
|
||||
const scrollProgress = scrollTop.value / (contentHeight - viewportHeight);
|
||||
|
||||
const thumbHeight = getThumbHeight();
|
||||
const availableTrackSpace = trackHeight - thumbHeight;
|
||||
|
||||
return Math.max(0, Math.min(scrollProgress * availableTrackSpace, availableTrackSpace));
|
||||
};
|
||||
|
||||
const startDragging = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!scrollContainer.value || !scrollbarTrack.value) return;
|
||||
|
||||
isDragging.value = true;
|
||||
startY.value = event.clientY;
|
||||
startScrollTop.value = scrollContainer.value.scrollTop;
|
||||
|
||||
window.addEventListener("mousemove", handleDragging);
|
||||
window.addEventListener("mouseup", stopDragging);
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.pointerEvents = "none";
|
||||
};
|
||||
|
||||
const handleDragging = (event: MouseEvent) => {
|
||||
if (!isDragging.value || !scrollContainer.value || !scrollbarTrack.value) return;
|
||||
|
||||
const trackRect = scrollbarTrack.value.getBoundingClientRect();
|
||||
const deltaY = event.clientY - startY.value;
|
||||
|
||||
const trackHeight = trackRect.height;
|
||||
const contentHeight = scrollContainer.value.scrollHeight;
|
||||
const viewportHeight = scrollContainer.value.clientHeight;
|
||||
const maxScroll = contentHeight - viewportHeight;
|
||||
|
||||
const moveRatio = deltaY / trackHeight;
|
||||
const scrollDelta = moveRatio * maxScroll;
|
||||
|
||||
const newScrollTop = Math.max(0, Math.min(startScrollTop.value + scrollDelta, maxScroll));
|
||||
scrollContainer.value.scrollTop = newScrollTop;
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
isDragging.value = false;
|
||||
|
||||
window.removeEventListener("mousemove", handleDragging);
|
||||
window.removeEventListener("mouseup", stopDragging);
|
||||
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.pointerEvents = "";
|
||||
};
|
||||
|
||||
const handleTrackClick = (event: MouseEvent) => {
|
||||
if (!scrollContainer.value || !scrollbarTrack.value || event.target === scrollbarThumb.value)
|
||||
return;
|
||||
|
||||
const trackRect = scrollbarTrack.value.getBoundingClientRect();
|
||||
const thumbHeight = getThumbHeight();
|
||||
|
||||
const clickOffset = event.clientY - trackRect.top;
|
||||
|
||||
const currentThumbPosition = getThumbPosition();
|
||||
const thumbCenterPosition = currentThumbPosition + thumbHeight / 2;
|
||||
const scrollAmount = clientHeight.value * (clickOffset < thumbCenterPosition ? -1 : 1);
|
||||
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
scrollContainer.value.scrollTop + scrollAmount,
|
||||
scrollContainer.value.scrollHeight - scrollContainer.value.clientHeight,
|
||||
),
|
||||
);
|
||||
|
||||
scrollContainer.value.scrollTop = newScrollTop;
|
||||
};
|
||||
|
||||
const enterFullScreen = () => {
|
||||
isFullScreen.value = true;
|
||||
document.body.style.overflow = "hidden";
|
||||
nextTick(() => {
|
||||
updateClientHeight();
|
||||
updateItemHeights();
|
||||
});
|
||||
};
|
||||
|
||||
const exitFullScreen = () => {
|
||||
isFullScreen.value = false;
|
||||
document.body.style.overflow = "";
|
||||
nextTick(() => {
|
||||
updateClientHeight();
|
||||
updateItemHeights();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (isFullScreen.value) {
|
||||
exitFullScreen();
|
||||
} else {
|
||||
enterFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && isFullScreen.value) {
|
||||
exitFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
const initializeTerminal = async () => {
|
||||
if (!scrollContainer.value) return;
|
||||
|
||||
updateClientHeight();
|
||||
|
||||
const initialHeights = props.consoleOutput.map(
|
||||
(content) => cachedHeights.value.get(content) || averageItemHeight.value,
|
||||
);
|
||||
itemHeights.value = initialHeights;
|
||||
|
||||
await nextTick();
|
||||
await updateItemHeights();
|
||||
await nextTick();
|
||||
|
||||
const container = scrollContainer.value;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
handleListScroll();
|
||||
initial.value = true;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await initializeTerminal();
|
||||
|
||||
window.addEventListener("resize", updateClientHeight);
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", updateClientHeight);
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
stopDragging();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.consoleOutput,
|
||||
async (newOutput) => {
|
||||
const newItemsCount = newOutput.length - itemHeights.value.length;
|
||||
|
||||
if (newItemsCount > 0) {
|
||||
const shouldScroll = isScrolledToBottom.value || !userHasScrolled.value;
|
||||
|
||||
const newHeights = Array(newItemsCount)
|
||||
.fill(0)
|
||||
.map((_, i) => {
|
||||
const index = itemHeights.value.length + i;
|
||||
const content = newOutput[index];
|
||||
return cachedHeights.value.get(content) || averageItemHeight.value;
|
||||
});
|
||||
|
||||
itemHeights.value.push(...newHeights);
|
||||
|
||||
if (shouldScroll) {
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
|
||||
await nextTick();
|
||||
await updateItemHeights();
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
const virtualListStyle = computed(() => ({
|
||||
transform: `translateY(${offsetY.value}px)`,
|
||||
}));
|
||||
|
||||
watch([visibleStartIndex, visibleEndIndex], updateItemHeights);
|
||||
|
||||
watch(
|
||||
() => props.fullScreen,
|
||||
(newValue) => {
|
||||
isFullScreen.value = newValue;
|
||||
nextTick(() => {
|
||||
updateClientHeight();
|
||||
updateItemHeights();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
watch(isFullScreen, () => {
|
||||
nextTick(() => {
|
||||
updateClientHeight();
|
||||
updateItemHeights();
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
itemHeights,
|
||||
() => {
|
||||
const totalHeight = itemHeights.value.reduce((sum, height) => sum + height, 0);
|
||||
averageItemHeight.value = totalHeight / itemHeights.value.length || averageItemHeight.value;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:root {
|
||||
--console-bg: var(--color-bg);
|
||||
}
|
||||
|
||||
.terminal-font {
|
||||
font-family: var(--mono-font);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
html.light-mode .console {
|
||||
--console-bg: var(--color-bg);
|
||||
}
|
||||
|
||||
html.dark-mode .console {
|
||||
--console-bg: black;
|
||||
}
|
||||
|
||||
html.oled-mode .console {
|
||||
--console-bg: black;
|
||||
}
|
||||
|
||||
.console {
|
||||
background: var(--console-bg);
|
||||
}
|
||||
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-root]::-webkit-scrollbar,
|
||||
[data-pyro-terminal-root]::-webkit-scrollbar-thumb,
|
||||
[data-pyro-terminal-root]::-webkit-scrollbar-track-piece,
|
||||
[data-pyro-terminal-root]::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.screen-fixed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 50;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@keyframes scaleUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.scale-fullscreen {
|
||||
animation: scaleUp 190ms forwards;
|
||||
}
|
||||
|
||||
.progressive-gradient {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, var(--color-bg), transparent var(--transparency)) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
html.dark-mode .progressive-gradient {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, black, transparent var(--transparency)) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.scroll-to-bottom-enter-active,
|
||||
.scroll-to-bottom-leave-active {
|
||||
transition:
|
||||
opacity 300ms ease,
|
||||
transform 300ms ease;
|
||||
}
|
||||
|
||||
.scroll-to-bottom-enter-from,
|
||||
.scroll-to-bottom-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) translateY(2rem);
|
||||
}
|
||||
|
||||
[data-pyro-terminal-selected="true"] {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-selected="true"].first-selected {
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-top-right-radius: 0.5rem;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-selected="true"].last-selected {
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-root] {
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-root] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-root] * {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.selection-in-progress {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||