mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
505
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d88cd3a68 | ||
|
|
011e5d8be6 | ||
|
|
ea508eabc3 | ||
|
|
89f337997d | ||
|
|
2e7178a752 | ||
|
|
9fa7398227 | ||
|
|
dc2dc34043 | ||
|
|
2ba47b25c6 | ||
|
|
509749c937 | ||
|
|
58dd006e44 | ||
|
|
25a6a7b10d | ||
|
|
61dd6e86c5 | ||
|
|
0da18cfa86 | ||
|
|
fa6ce37446 | ||
|
|
381a0d210d | ||
|
|
43b6a5231d | ||
|
|
30a5a119f0 | ||
|
|
8262a22cce | ||
|
|
667513ee18 | ||
|
|
8372b8a792 | ||
|
|
a6a53a107a | ||
|
|
7428863965 | ||
|
|
3db890e44f | ||
|
|
785987e31d | ||
|
|
2ba34d1c2c | ||
|
|
2a379e9eae | ||
|
|
41a073f02f | ||
|
|
30acba861b | ||
|
|
0b547231c8 | ||
|
|
1289cb5286 | ||
|
|
51f3e9e1f6 | ||
|
|
44030706be | ||
|
|
4ed4560ba8 | ||
|
|
5e015ff702 | ||
|
|
7204735843 | ||
|
|
592dfae307 | ||
|
|
8ca9406cff | ||
|
|
996ce12b6b | ||
|
|
2d83881bfd | ||
|
|
e13356c239 | ||
|
|
f3e6376c11 | ||
|
|
efcd26f194 | ||
|
|
f9527921c5 | ||
|
|
da26304e7a | ||
|
|
469906a4a2 | ||
|
|
d9c9053a70 | ||
|
|
50f6225e33 | ||
|
|
6322928609 | ||
|
|
c07d2b08a9 | ||
|
|
fc03b07fb6 | ||
|
|
a6e5f9b441 | ||
|
|
2d1ebb31b7 | ||
|
|
10ddd67b73 | ||
|
|
335eb534fe | ||
|
|
4cda6343c2 | ||
|
|
bdfe7d4bb6 | ||
|
|
3ada5b10b9 | ||
|
|
015fc656df | ||
|
|
9fdea27a83 | ||
|
|
ef55e28157 | ||
|
|
73373f4c78 | ||
|
|
d65375102f | ||
|
|
f276cd7881 | ||
|
|
1fd108c6ab | ||
|
|
c49baec024 | ||
|
|
4a410fb6f7 | ||
|
|
874ee462cb | ||
|
|
108c3765b4 | ||
|
|
4680820ec8 | ||
|
|
737026c077 | ||
|
|
a84a5e4fae | ||
|
|
d327fa8f85 | ||
|
|
a97be599a2 | ||
|
|
fa1d5131b0 | ||
|
|
d29bf1dc72 | ||
|
|
b2969bab51 | ||
|
|
ef77d8a1ae | ||
|
|
f63e01f03f | ||
|
|
265f3394be | ||
|
|
3948449c60 | ||
|
|
6a488567e3 | ||
|
|
9391dc0eab | ||
|
|
f2c3cbc88a | ||
|
|
ab1d14d570 | ||
|
|
8ba581bc62 | ||
|
|
ea75b769e6 | ||
|
|
a5f5868d8f | ||
|
|
66bcaa3f1f | ||
|
|
518e3797b6 | ||
|
|
fa3c70434c | ||
|
|
1c24d16bb6 | ||
|
|
2d25e46bc6 | ||
|
|
d12c59247f | ||
|
|
820c65fc1a | ||
|
|
08fef0d79f | ||
|
|
13f5c31a5d | ||
|
|
5de8bed458 | ||
|
|
c844eabe51 | ||
|
|
a44b032db0 | ||
|
|
a39082fda7 | ||
|
|
c6288daa9d | ||
|
|
85cb988708 | ||
|
|
b7061b2ac8 | ||
|
|
af56fc0bb3 | ||
|
|
16f4ecf154 | ||
|
|
0d48962904 | ||
|
|
c63b3dacd2 | ||
|
|
b34f7e270e | ||
|
|
1821249025 | ||
|
|
0813e1ff95 | ||
|
|
6dc428c71f | ||
|
|
066a4ac1e4 | ||
|
|
2718eec6c5 | ||
|
|
bbfac2fb25 | ||
|
|
9deb97f45d | ||
|
|
e76645fc8e | ||
|
|
af4e6f9349 | ||
|
|
98243603f2 | ||
|
|
fefb446ada | ||
|
|
102a7ad7b7 | ||
|
|
4e64ded09b | ||
|
|
4d68a7f793 | ||
|
|
2464646483 | ||
|
|
468b44f1fc | ||
|
|
2a95beb0aa | ||
|
|
2b7fdf0e05 | ||
|
|
90a905be69 | ||
|
|
72de13d38b | ||
|
|
b65e0d1a03 | ||
|
|
6a97c6e2d9 | ||
|
|
f15d5d18af | ||
|
|
e7e7dd4057 | ||
|
|
7a6233e1aa | ||
|
|
1a5529c500 | ||
|
|
a311bd2648 | ||
|
|
397b3892c8 | ||
|
|
1e79d771b5 | ||
|
|
8be5d2521f | ||
|
|
dd9cacab55 | ||
|
|
dd0fa9bf33 | ||
|
|
7331101609 | ||
|
|
1cb52b86c3 | ||
|
|
cbea77f03f | ||
|
|
fe3153344b | ||
|
|
ae6d1f6782 | ||
|
|
4531705051 | ||
|
|
fcc098f919 | ||
|
|
5e1a7a8695 | ||
|
|
1c5181464a | ||
|
|
2a7916eda6 | ||
|
|
2ceb2ef02d | ||
|
|
8e504e9449 | ||
|
|
f0d1b61293 | ||
|
|
9e344e5555 | ||
|
|
edb0de4ecd | ||
|
|
dc79587a3c | ||
|
|
e51b2206ec | ||
|
|
c347dbd256 | ||
|
|
97be76d998 | ||
|
|
9d3bd50522 | ||
|
|
2c362ca7ba | ||
|
|
e312c77159 | ||
|
|
57757dbfb1 | ||
|
|
1e4dfe9d68 | ||
|
|
35b7bb3c3c | ||
|
|
5ea8a956f1 | ||
|
|
94c64c28d4 | ||
|
|
216f28850c | ||
|
|
7a0c7e1992 | ||
|
|
529a455fe3 | ||
|
|
b546bfd5b8 | ||
|
|
adddb72ba7 | ||
|
|
62f0a58cac | ||
|
|
f5387353cc | ||
|
|
d75d7a6671 | ||
|
|
54f2c3a29f | ||
|
|
a2bec70c62 | ||
|
|
969700a01f | ||
|
|
bdb5d6deac | ||
|
|
7c3ab22956 | ||
|
|
8c68de0ee0 | ||
|
|
46427f6a24 | ||
|
|
46bc137a9e | ||
|
|
e3cf5c6a0a | ||
|
|
d005efa815 | ||
|
|
a42fbb42c1 | ||
|
|
22e31f9099 | ||
|
|
4d64fd175b | ||
|
|
09290b452d | ||
|
|
c3f76bfd64 | ||
|
|
adc85f24bd | ||
|
|
3c527778a5 | ||
|
|
3182c28027 | ||
|
|
515d1b3196 | ||
|
|
194873a5e7 | ||
|
|
3acff84465 | ||
|
|
1455f71f62 | ||
|
|
49438d33a1 | ||
|
|
6d3139f254 | ||
|
|
3a14e4fd46 | ||
|
|
c3a6f4d416 | ||
|
|
df7ac297af | ||
|
|
f3be9397c9 | ||
|
|
117257bcf9 | ||
|
|
6e011b043f | ||
|
|
9aed50d50c | ||
|
|
4fd747e6aa | ||
|
|
7ce09d65be | ||
|
|
7cddcee8e1 | ||
|
|
b1c914015b | ||
|
|
884907ad0c | ||
|
|
416598bf6d | ||
|
|
e04a60e582 | ||
|
|
4f24641296 | ||
|
|
55173e9b5a | ||
|
|
dbacbdafad | ||
|
|
487e1e1bdd | ||
|
|
7d9e58d877 | ||
|
|
4e5d234701 | ||
|
|
9f814ead27 | ||
|
|
764926179d | ||
|
|
2819ade3ce | ||
|
|
955fa43b2d | ||
|
|
b0b28f90b9 | ||
|
|
9bacb4594d | ||
|
|
298644dd29 | ||
|
|
abd3862a65 | ||
|
|
bbadf272e5 | ||
|
|
6f1a06a4d8 | ||
|
|
2694206b35 | ||
|
|
6b6c49073a | ||
|
|
0ce315a1ac | ||
|
|
9195920fca | ||
|
|
f2f09b9b9c | ||
|
|
c4ebbf0575 | ||
|
|
b4b42a42d9 | ||
|
|
83714d4660 | ||
|
|
9182a09453 | ||
|
|
b5fa03b15e | ||
|
|
5b687e6449 | ||
|
|
cd4571f6c7 | ||
|
|
c1c1fc1b38 | ||
|
|
250e163bfb | ||
|
|
c7b70bac10 | ||
|
|
1315052bca | ||
|
|
e7e5e7ecf9 | ||
|
|
f0ee8aacbc | ||
|
|
038bb53757 | ||
|
|
52e3123c5b | ||
|
|
c0a2c4d5d4 | ||
|
|
89bcd7fb36 | ||
|
|
f93884ab4b | ||
|
|
22a348ca64 | ||
|
|
c135af93a0 | ||
|
|
c10be2d796 | ||
|
|
18343eea32 | ||
|
|
a985a43d6c | ||
|
|
17e1ade828 | ||
|
|
3ff6642544 | ||
|
|
8bd204915d | ||
|
|
3eba32bafe | ||
|
|
2025ad5380 | ||
|
|
80ea4e1924 | ||
|
|
217281e021 | ||
|
|
9173505fc5 | ||
|
|
e808ec975c | ||
|
|
7a5d6e94ed | ||
|
|
7833b1687f | ||
|
|
5cff50bd23 | ||
|
|
c8ace75898 | ||
|
|
c68d5ca86d | ||
|
|
3c4ef3c6ec | ||
|
|
b52ef5d8ed | ||
|
|
f1a97e37ff | ||
|
|
266f45e37d | ||
|
|
2b0ba51c62 | ||
|
|
0ea4cfed09 | ||
|
|
33c9dbeac2 | ||
|
|
80e8a61668 | ||
|
|
1b3c8cd0be | ||
|
|
3fef34c631 | ||
|
|
40c74dc2cb | ||
|
|
c3e205f006 | ||
|
|
e41665dc87 | ||
|
|
b934b79089 | ||
|
|
66b6f6eca0 | ||
|
|
a2dccf65e2 | ||
|
|
32008f9580 | ||
|
|
0cb3776520 | ||
|
|
dd70e834cb | ||
|
|
445c80fbdc | ||
|
|
fba3c49018 | ||
|
|
ffc80a4796 | ||
|
|
0d0fdab646 | ||
|
|
1e190ff7c1 | ||
|
|
19f419fcbb | ||
|
|
c9571315a4 | ||
|
|
96700ed2bf | ||
|
|
1ad4fdbd93 | ||
|
|
73e1fc4709 | ||
|
|
412f43dac7 | ||
|
|
15e83bd4e3 | ||
|
|
3fe7679a9e | ||
|
|
0dcaf6900c | ||
|
|
702e9882b6 | ||
|
|
6663a3c64f | ||
|
|
2ff9db4708 | ||
|
|
fb10ccda52 | ||
|
|
cf637561fe | ||
|
|
41207cbdef | ||
|
|
4d083c0d42 | ||
|
|
475010a50d | ||
|
|
5d48d086a5 | ||
|
|
16c9a3cfce | ||
|
|
0e231ca815 | ||
|
|
599dc90d89 | ||
|
|
5635896940 | ||
|
|
047f72856d | ||
|
|
7c5f98033d | ||
|
|
78c94e68fa | ||
|
|
5ade45373a | ||
|
|
2e2aea2107 | ||
|
|
e26df077b0 | ||
|
|
b32d6fd20a | ||
|
|
fa02aedfb4 | ||
|
|
2ed3984521 | ||
|
|
3e5a4e795f | ||
|
|
7772c9ba59 | ||
|
|
055f5525d9 | ||
|
|
1ec00e188f | ||
|
|
a5ab5bb414 | ||
|
|
de60a29c99 | ||
|
|
b347061fba | ||
|
|
436c9c5981 | ||
|
|
1820fed1cd | ||
|
|
3e55aaafef | ||
|
|
11b2ce1511 | ||
|
|
b99a460e3e | ||
|
|
1b2b5c859c | ||
|
|
af9c3d1a9d | ||
|
|
437734e3bc | ||
|
|
f664c170f8 | ||
|
|
3f0f60fbea | ||
|
|
406aa672d8 | ||
|
|
fdefb66af0 | ||
|
|
90323da310 | ||
|
|
7f7d30e65f | ||
|
|
d4eb66ee6c | ||
|
|
de3ec34aef | ||
|
|
c41ba8c3c9 | ||
|
|
9ffcbd9b5b | ||
|
|
e1971365e6 | ||
|
|
c626da819f | ||
|
|
fbdcd27520 | ||
|
|
8e2833cb2a | ||
|
|
a763f6d83a | ||
|
|
cce25833ff | ||
|
|
559275bfad | ||
|
|
b20465d077 | ||
|
|
0b4fcb4e92 | ||
|
|
4d9c53dfe0 | ||
|
|
67f9decfe1 | ||
|
|
0c4bd5b09b | ||
|
|
94bfa26dfd | ||
|
|
30a50df85a | ||
|
|
e01c2b1a14 | ||
|
|
f231466532 | ||
|
|
9ce31b8032 | ||
|
|
192de9e957 | ||
|
|
dabddacccb | ||
|
|
93d32cf2e9 | ||
|
|
22a961db72 | ||
|
|
8bddff40a7 | ||
|
|
6c0f2a985f | ||
|
|
c9f7cae4b6 | ||
|
|
c801d155a9 | ||
|
|
e1fa007384 | ||
|
|
b8e413050c | ||
|
|
15476975a2 | ||
|
|
03ac7bcda9 | ||
|
|
ec56781ebc | ||
|
|
168750d45a | ||
|
|
37f63afedb | ||
|
|
9e17cd4d33 | ||
|
|
dc676e15b6 | ||
|
|
0982d2ceb6 | ||
|
|
f81e3f36ed | ||
|
|
17145e303e | ||
|
|
fd3b562092 | ||
|
|
8bfb007038 | ||
|
|
0778e19b0e | ||
|
|
3b39d75685 | ||
|
|
40dc007394 | ||
|
|
f819ce356f | ||
|
|
904803c0c0 | ||
|
|
963d35c133 | ||
|
|
1a6ed679e4 | ||
|
|
cb49902b25 | ||
|
|
062a332247 | ||
|
|
a8bb03ff1c | ||
|
|
a10c242857 | ||
|
|
407e196c3a | ||
|
|
f7f31c0991 | ||
|
|
2ad5cd5442 | ||
|
|
cf9ca8b9be | ||
|
|
d915a32f53 | ||
|
|
66953d851c | ||
|
|
a9011576af | ||
|
|
98eeb9fe13 | ||
|
|
0c18af1213 | ||
|
|
c893dfcb22 | ||
|
|
c958091ee6 | ||
|
|
37b9610d26 | ||
|
|
7f45734c4d | ||
|
|
d48f6243a7 | ||
|
|
e5d3eff085 | ||
|
|
091a74cf84 | ||
|
|
7aeabf60e0 | ||
|
|
672a18d49f | ||
|
|
2f131d4ac7 | ||
|
|
892f8e5946 | ||
|
|
3877d40b64 | ||
|
|
87aecc267d | ||
|
|
65d0803c37 | ||
|
|
4ce81f0e8f | ||
|
|
d935a8f983 | ||
|
|
04b3488dbc | ||
|
|
acccddffc3 | ||
|
|
7e6470e361 | ||
|
|
43ab52a902 | ||
|
|
68a09b1c9c | ||
|
|
3eea154520 | ||
|
|
dd7b8fdb46 | ||
|
|
bba62c6b98 | ||
|
|
6fb15eb4c5 | ||
|
|
93e292d5a6 | ||
|
|
6c2ab769c4 | ||
|
|
2092ba85ed | ||
|
|
1032469239 | ||
|
|
a0858efbcd | ||
|
|
a8c4e2dfb1 | ||
|
|
1d7c8a0bdd | ||
|
|
e67f25166c | ||
|
|
02a9f4c722 | ||
|
|
11769df7b7 | ||
|
|
021b85928d | ||
|
|
9783cbf52b | ||
|
|
51398a1323 | ||
|
|
8392ae3513 | ||
|
|
ccee5e0ca7 | ||
|
|
b1972afb16 | ||
|
|
292b3f8e8c | ||
|
|
e63787c5dd | ||
|
|
5232929c5a | ||
|
|
bfaed9a9a5 | ||
|
|
f281a804ea | ||
|
|
50f738101e | ||
|
|
71074d8499 | ||
|
|
138b5e9971 | ||
|
|
e165180a8d | ||
|
|
61e69ac0d7 | ||
|
|
0c12fbed2f | ||
|
|
1e70473574 | ||
|
|
16f46a464e | ||
|
|
1712ed2fd2 | ||
|
|
af35e92218 | ||
|
|
4877eaee5d | ||
|
|
067b3466ee | ||
|
|
d4af422214 | ||
|
|
bd5fbb3d09 | ||
|
|
6f84acb01b | ||
|
|
ff5ed4045d | ||
|
|
144bde8976 | ||
|
|
1850de7b8d | ||
|
|
e0c871c0e4 | ||
|
|
f46a4fb4c6 | ||
|
|
db3f745562 | ||
|
|
35b962178e | ||
|
|
2e900879da | ||
|
|
7135ef4cf4 | ||
|
|
95ea6df681 | ||
|
|
51d84ca80c | ||
|
|
1591fcc795 | ||
|
|
e4536b5f66 | ||
|
|
b5a5a6a10c | ||
|
|
1501b11f7b | ||
|
|
63a90f7051 | ||
|
|
ea2431b2dd | ||
|
|
973208730e | ||
|
|
2dc3dad296 | ||
|
|
4a26d17951 | ||
|
|
74cc23054d | ||
|
|
b54276fb10 | ||
|
|
765e3fba0e | ||
|
|
6e84c922fa | ||
|
|
bd5f0364c2 | ||
|
|
847d752dae | ||
|
|
548c1f3be4 | ||
|
|
6328d4d92a | ||
|
|
93805bc7b9 | ||
|
|
6753e95922 | ||
|
|
2dcc031a52 | ||
|
|
8536ca52ea | ||
|
|
b5c69958ee | ||
|
|
6e4abf6e26 |
@@ -21,6 +21,14 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
required: false
|
||||
environment:
|
||||
description: Environment
|
||||
type: choice
|
||||
options:
|
||||
- prod
|
||||
- staging
|
||||
default: prod
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -94,12 +102,14 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
|
||||
BUILD_ENVIRONMENT="${{ inputs.environment || 'prod' }}"
|
||||
echo "Setting application version to $APP_VERSION"
|
||||
echo "Using environment $BUILD_ENVIRONMENT"
|
||||
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
|
||||
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
|
||||
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
|
||||
|
||||
cp packages/app-lib/.env.prod packages/app-lib/.env
|
||||
cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env
|
||||
|
||||
- name: Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@v1.8
|
||||
|
||||
Generated
+58
@@ -640,6 +640,19 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-minecraft-ping"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "668b459c14dd8d9ef21e296af3f2a3651ff7dc3536e092fb0b09e528daaa6d89"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
@@ -2167,6 +2180,16 @@ dependencies = [
|
||||
"darling_macro 0.21.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
@@ -2195,6 +2218,19 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
@@ -2217,6 +2253,17 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
@@ -4730,6 +4777,7 @@ dependencies = [
|
||||
"arc-swap",
|
||||
"argon2",
|
||||
"ariadne",
|
||||
"async-minecraft-ping",
|
||||
"async-stripe",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -4811,6 +4859,16 @@ dependencies = [
|
||||
"zxcvbn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "labrinth-derive"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "language-tags"
|
||||
version = "0.3.2"
|
||||
|
||||
@@ -8,6 +8,7 @@ members = [
|
||||
"packages/app-lib",
|
||||
"packages/ariadne",
|
||||
"packages/daedalus",
|
||||
"packages/labrinth-derive",
|
||||
"packages/modrinth-log",
|
||||
"packages/modrinth-maxmind",
|
||||
"packages/modrinth-util",
|
||||
@@ -32,6 +33,7 @@ arc-swap = "1.7.1"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async-compression = { version = "0.4.32", default-features = false }
|
||||
async-minecraft-ping = { version = "0.8.0" }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
@@ -58,6 +60,7 @@ color-eyre = "0.6.5"
|
||||
color-thief = "0.2.2"
|
||||
const_format = "0.2.34"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
darling = { version = "0.23" }
|
||||
dashmap = "6.1.0"
|
||||
data-url = "0.3.2"
|
||||
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
|
||||
@@ -121,9 +124,11 @@ paste = "1.0.15"
|
||||
path-util = { path = "packages/path-util" }
|
||||
phf = { version = "0.13.1", features = ["macros"] }
|
||||
png = "0.18.0"
|
||||
proc-macro2 = { version = "1.0" }
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
quick-xml = "0.38.3"
|
||||
quote = { version = "1.0" }
|
||||
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||
redis = "0.32.7"
|
||||
@@ -166,6 +171,7 @@ spdx = "0.12.0"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sqlx-tracing = { path = "packages/sqlx-tracing" }
|
||||
strum = "0.27.2"
|
||||
syn = { version = "2.0" }
|
||||
sysinfo = { version = "0.37.2", default-features = false }
|
||||
tar = "0.4.44"
|
||||
tauri = "2.8.5"
|
||||
|
||||
+152
-68
@@ -36,14 +36,16 @@ import {
|
||||
NewsArticleCard,
|
||||
NotificationPanel,
|
||||
OverflowMenu,
|
||||
PopupNotificationPanel,
|
||||
ProgressSpinner,
|
||||
provideModrinthClient,
|
||||
provideNotificationManager,
|
||||
providePageContext,
|
||||
providePopupNotificationManager,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { formatBytes, renderString } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
@@ -61,6 +63,7 @@ import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import ErrorModal from '@/components/ui/ErrorModal.vue'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
|
||||
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
|
||||
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
|
||||
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
|
||||
@@ -68,13 +71,13 @@ import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
|
||||
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import UpdateAvailableToast from '@/components/ui/UpdateAvailableToast.vue'
|
||||
import UpdateToast from '@/components/ui/UpdateToast.vue'
|
||||
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
@@ -101,13 +104,14 @@ import {
|
||||
subscribeToDownloadProgress,
|
||||
} from '@/providers/download-progress.ts'
|
||||
import { useError } from '@/store/error.js'
|
||||
import { useInstall } from '@/store/install.js'
|
||||
import { playServerProject, useInstall } from '@/store/install.js'
|
||||
import { useLoading, useTheming } from '@/store/state'
|
||||
|
||||
import { create_profile_and_install_from_file } from './helpers/pack'
|
||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||
import { AppNotificationManager } from './providers/app-notifications'
|
||||
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -115,6 +119,10 @@ const notificationManager = new AppNotificationManager()
|
||||
provideNotificationManager(notificationManager)
|
||||
const { handleError, addNotification } = notificationManager
|
||||
|
||||
const popupNotificationManager = new AppPopupNotificationManager()
|
||||
providePopupNotificationManager(popupNotificationManager)
|
||||
const { addPopupNotification } = popupNotificationManager
|
||||
|
||||
const tauriApiClient = new TauriModrinthClient({
|
||||
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
|
||||
features: [
|
||||
@@ -393,8 +401,11 @@ const minecraftAuthErrorModal = ref()
|
||||
|
||||
const install = useInstall()
|
||||
const modInstallModal = ref()
|
||||
const addServerToInstanceModal = ref()
|
||||
const installConfirmModal = ref()
|
||||
const incompatibilityWarningModal = ref()
|
||||
const installToPlayModal = ref()
|
||||
const updateToPlayModal = ref()
|
||||
|
||||
const credentials = ref()
|
||||
|
||||
@@ -473,6 +484,10 @@ onMounted(() => {
|
||||
install.setIncompatibilityWarningModal(incompatibilityWarningModal)
|
||||
install.setInstallConfirmModal(installConfirmModal)
|
||||
install.setModInstallModal(modInstallModal)
|
||||
install.setAddServerToInstanceModal(addServerToInstanceModal)
|
||||
install.setInstallToPlayModal(installToPlayModal)
|
||||
install.setUpdateToPlayModal(updateToPlayModal)
|
||||
install.setPopupNotificationManager(popupNotificationManager)
|
||||
})
|
||||
|
||||
const accounts = ref(null)
|
||||
@@ -490,6 +505,9 @@ async function handleCommand(e) {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
}
|
||||
} else if (e.event === 'InstallServer') {
|
||||
await router.push(`/project/${e.id}`)
|
||||
await playServerProject(e.id).catch(handleError)
|
||||
} else {
|
||||
// Other commands are URL-based (deep linking)
|
||||
urlModal.value.show(e)
|
||||
@@ -508,14 +526,60 @@ const downloadPercent = computed(() => Math.trunc(appUpdateDownload.progress.val
|
||||
const metered = ref(true)
|
||||
const finishedDownloading = ref(false)
|
||||
const restarting = ref(false)
|
||||
const updateToastDismissed = ref(false)
|
||||
const availableUpdate = ref(null)
|
||||
const updateSize = ref(null)
|
||||
const updatesEnabled = ref(true)
|
||||
|
||||
const updatePopupMessages = defineMessages({
|
||||
updateAvailable: {
|
||||
id: 'app.update-popup.title',
|
||||
defaultMessage: 'Update available',
|
||||
},
|
||||
downloadComplete: {
|
||||
id: 'app.update-popup.download-complete',
|
||||
defaultMessage: 'Download complete',
|
||||
},
|
||||
body: {
|
||||
id: 'app.update-popup.body',
|
||||
defaultMessage:
|
||||
'Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App.',
|
||||
},
|
||||
meteredBody: {
|
||||
id: 'app.update-popup.body.metered',
|
||||
defaultMessage: `Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it.`,
|
||||
},
|
||||
downloadedBody: {
|
||||
id: 'app.update-popup.body.download-complete',
|
||||
defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`,
|
||||
},
|
||||
linuxBody: {
|
||||
id: 'app.update-popup.body.linux',
|
||||
defaultMessage:
|
||||
'Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!',
|
||||
},
|
||||
reload: {
|
||||
id: 'app.update-popup.reload',
|
||||
defaultMessage: 'Reload',
|
||||
},
|
||||
download: {
|
||||
id: 'app.update-popup.download',
|
||||
defaultMessage: 'Download ({size})',
|
||||
},
|
||||
changelog: {
|
||||
id: 'app.update-popup.changelog',
|
||||
defaultMessage: 'Changelog',
|
||||
},
|
||||
})
|
||||
|
||||
async function checkUpdates() {
|
||||
if (!(await areUpdatesEnabled())) {
|
||||
console.log('Skipping update check as updates are disabled in this build or environment')
|
||||
updatesEnabled.value = false
|
||||
|
||||
if (os.value === 'Linux' && !isDevEnvironment.value) {
|
||||
checkLinuxUpdates()
|
||||
setInterval(checkLinuxUpdates, 5 * 60 * 1000)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -535,7 +599,6 @@ async function checkUpdates() {
|
||||
|
||||
appUpdateDownload.progress.value = 0
|
||||
finishedDownloading.value = false
|
||||
updateToastDismissed.value = false
|
||||
|
||||
console.log(`Update ${update.version} is available.`)
|
||||
|
||||
@@ -545,6 +608,28 @@ async function checkUpdates() {
|
||||
downloadUpdate(update)
|
||||
} else {
|
||||
console.log(`Metered connection detected, not auto-downloading update.`)
|
||||
getUpdateSize(update.rid).then((size) => {
|
||||
updateSize.value = size
|
||||
addPopupNotification({
|
||||
title: formatMessage(updatePopupMessages.updateAvailable),
|
||||
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
|
||||
type: 'info',
|
||||
autoCloseMs: null,
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.download, {
|
||||
size: formatBytes(updateSize.value ?? 0),
|
||||
}),
|
||||
action: () => downloadAvailableUpdate(),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.changelog),
|
||||
action: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getUpdateSize(update.rid).then((size) => (updateSize.value = size))
|
||||
@@ -561,8 +646,26 @@ async function checkUpdates() {
|
||||
)
|
||||
}
|
||||
|
||||
async function showUpdateToast() {
|
||||
updateToastDismissed.value = false
|
||||
async function checkLinuxUpdates() {
|
||||
try {
|
||||
const [response, currentVersion] = await Promise.all([
|
||||
fetch('https://launcher-files.modrinth.com/updates.json'),
|
||||
getVersion(),
|
||||
])
|
||||
const updates = await response.json()
|
||||
const latestVersion = updates?.version
|
||||
|
||||
if (latestVersion && latestVersion !== currentVersion) {
|
||||
addPopupNotification({
|
||||
title: formatMessage(updatePopupMessages.updateAvailable),
|
||||
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
|
||||
type: 'info',
|
||||
autoCloseMs: null,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check for updates:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAvailableUpdate() {
|
||||
@@ -588,6 +691,26 @@ async function downloadUpdate(versionToDownload) {
|
||||
unlistenUpdateDownload = null
|
||||
})
|
||||
console.log('Finished downloading!')
|
||||
|
||||
addPopupNotification({
|
||||
title: formatMessage(updatePopupMessages.downloadComplete),
|
||||
text: formatMessage(updatePopupMessages.downloadedBody, {
|
||||
version: versionToDownload.version,
|
||||
}),
|
||||
type: 'success',
|
||||
autoCloseMs: null,
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.reload),
|
||||
action: () => installUpdate(),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.changelog),
|
||||
action: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
unlistenUpdateDownload = await subscribeToDownloadProgress(
|
||||
appUpdateDownload,
|
||||
@@ -761,25 +884,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
class="app-grid-layout experimental-styles-within relative"
|
||||
:class="{ 'disable-advanced-rendering': !themeStore.advancedRendering }"
|
||||
>
|
||||
<Suspense>
|
||||
<Transition name="toast">
|
||||
<UpdateToast
|
||||
v-if="
|
||||
!!availableUpdate &&
|
||||
!updateToastDismissed &&
|
||||
!restarting &&
|
||||
(finishedDownloading || metered)
|
||||
"
|
||||
:version="availableUpdate.version"
|
||||
:size="updateSize"
|
||||
:metered="metered"
|
||||
@close="updateToastDismissed = true"
|
||||
@restart="installUpdate"
|
||||
@download="downloadAvailableUpdate"
|
||||
/>
|
||||
<UpdateAvailableToast v-else-if="!updatesEnabled && os === 'Linux' && !isDevEnvironment" />
|
||||
</Transition>
|
||||
</Suspense>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="restarting"
|
||||
@@ -856,14 +960,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</NavButton>
|
||||
<div class="flex flex-grow"></div>
|
||||
<Transition name="nav-button-animated">
|
||||
<div
|
||||
v-if="
|
||||
availableUpdate &&
|
||||
updateToastDismissed &&
|
||||
!restarting &&
|
||||
(finishedDownloading || metered)
|
||||
"
|
||||
>
|
||||
<div v-if="availableUpdate && !restarting && (finishedDownloading || metered)">
|
||||
<NavButton
|
||||
v-tooltip.right="
|
||||
formatMessage(
|
||||
@@ -877,13 +974,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
},
|
||||
)
|
||||
"
|
||||
:to="
|
||||
finishedDownloading
|
||||
? installUpdate
|
||||
: downloadProgress > 0 && downloadProgress < 1
|
||||
? showUpdateToast
|
||||
: downloadAvailableUpdate
|
||||
"
|
||||
:to="finishedDownloading ? installUpdate : downloadAvailableUpdate"
|
||||
>
|
||||
<ProgressSpinner
|
||||
v-if="downloadProgress > 0 && downloadProgress < 1"
|
||||
@@ -918,13 +1009,25 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
]"
|
||||
placement="right-end"
|
||||
>
|
||||
<Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
|
||||
<Avatar
|
||||
v-if="credentials.user?.avatar_url"
|
||||
:src="credentials.user.avatar_url"
|
||||
alt=""
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Signed in as
|
||||
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
|
||||
<Avatar :src="credentials.user.avatar_url" alt="" size="20px" circle />
|
||||
<Avatar
|
||||
v-if="credentials.user?.avatar_url"
|
||||
:src="credentials.user.avatar_url"
|
||||
alt=""
|
||||
size="20px"
|
||||
circle
|
||||
/>
|
||||
{{ credentials.user.username }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -1133,11 +1236,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<URLConfirmModal ref="urlModal" />
|
||||
<I18nDebugPanel />
|
||||
<NotificationPanel has-sidebar />
|
||||
<PopupNotificationPanel has-sidebar />
|
||||
<ErrorModal ref="errorModal" />
|
||||
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
|
||||
<ModInstallModal ref="modInstallModal" />
|
||||
<AddServerToInstanceModal ref="addServerToInstanceModal" />
|
||||
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
|
||||
<InstallConfirmModal ref="installConfirmModal" />
|
||||
<InstallToPlayModal ref="installToPlayModal" />
|
||||
<UpdateToPlayModal ref="updateToPlayModal" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -1366,38 +1473,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
transform: translateY(10rem) scale(0.8) scaleY(1.6);
|
||||
}
|
||||
|
||||
.toast-enter-active {
|
||||
transition: opacity 0.25s linear;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.toast-enter-active,
|
||||
.nav-button-animated-enter-active {
|
||||
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
|
||||
}
|
||||
|
||||
.toast-leave-active,
|
||||
.nav-button-animated-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
scale: 0.5;
|
||||
translate: 0 -10rem;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
scale: 0.96;
|
||||
translate: 20rem 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav-button-animated-enter-active {
|
||||
position: relative;
|
||||
}
|
||||
@@ -1461,7 +1545,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
}
|
||||
|
||||
.info-card {
|
||||
right: 8rem;
|
||||
right: 22rem;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="filteredLinks.length > 1"
|
||||
ref="scrollContainer"
|
||||
class="card-shadow experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<RouterLink
|
||||
@@ -14,13 +16,17 @@
|
||||
<span class="text-nowrap">{{ link.label }}</span>
|
||||
</RouterLink>
|
||||
<div
|
||||
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'}`"
|
||||
:class="[
|
||||
'pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1',
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
|
||||
{ 'navtabs-transition': transitionsEnabled },
|
||||
]"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
top: sliderTopPx,
|
||||
right: sliderRightPx,
|
||||
bottom: sliderBottomPx,
|
||||
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
|
||||
opacity: sliderReady && activeIndex !== -1 ? 1 : 0,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
@@ -28,7 +34,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
@@ -47,13 +53,16 @@ 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 sliderReady = ref(false)
|
||||
const transitionsEnabled = ref(false)
|
||||
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
|
||||
|
||||
const filteredLinks = computed(() =>
|
||||
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
|
||||
@@ -63,13 +72,18 @@ const sliderTopPx = computed(() => `${sliderTop.value}px`)
|
||||
const sliderRightPx = computed(() => `${sliderRight.value}px`)
|
||||
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
|
||||
|
||||
const leftDelay = computed(() => sliderDelays.value.left)
|
||||
const rightDelay = computed(() => sliderDelays.value.right)
|
||||
const topDelay = computed(() => sliderDelays.value.top)
|
||||
const bottomDelay = computed(() => sliderDelays.value.bottom)
|
||||
|
||||
function pickLink() {
|
||||
let index = -1
|
||||
subpageSelected.value = false
|
||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
||||
const link = filteredLinks.value[i]
|
||||
|
||||
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
|
||||
if (route.path === (typeof link.href === 'string' ? link.href : (link.href as any).path)) {
|
||||
index = i
|
||||
break
|
||||
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
|
||||
@@ -83,57 +97,54 @@ function pickLink() {
|
||||
if (activeIndex.value !== -1) {
|
||||
startAnimation()
|
||||
} else {
|
||||
oldIndex.value = -1
|
||||
sliderLeft.value = 0
|
||||
sliderRight.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const tabLinkElements = ref()
|
||||
function getTabElement(index: number): HTMLElement | null {
|
||||
if (index === -1) return null
|
||||
const container = scrollContainer.value
|
||||
if (!container) return null
|
||||
const tabs = container.querySelectorAll('.button-animation')
|
||||
return (tabs[index] as HTMLElement) ?? null
|
||||
}
|
||||
|
||||
function startAnimation() {
|
||||
const el = tabLinkElements.value[activeIndex.value].$el
|
||||
|
||||
if (!el || !el.offsetParent) return
|
||||
const el = getTabElement(activeIndex.value)
|
||||
if (!el?.offsetParent) return
|
||||
|
||||
const parent = el.offsetParent as HTMLElement
|
||||
const newValues = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
}
|
||||
|
||||
if (sliderLeft.value === 4 && sliderRight.value === 4) {
|
||||
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
|
||||
|
||||
if (isInitialPosition) {
|
||||
sliderLeft.value = newValues.left
|
||||
sliderRight.value = newValues.right
|
||||
sliderTop.value = newValues.top
|
||||
sliderBottom.value = newValues.bottom
|
||||
sliderReady.value = true
|
||||
requestAnimationFrame(() => {
|
||||
transitionsEnabled.value = true
|
||||
})
|
||||
} else {
|
||||
const delay = 200
|
||||
|
||||
if (newValues.left < sliderLeft.value) {
|
||||
sliderLeft.value = newValues.left
|
||||
setTimeout(() => {
|
||||
sliderRight.value = newValues.right
|
||||
}, delay)
|
||||
} else {
|
||||
sliderRight.value = newValues.right
|
||||
setTimeout(() => {
|
||||
sliderLeft.value = newValues.left
|
||||
}, delay)
|
||||
}
|
||||
|
||||
if (newValues.top < sliderTop.value) {
|
||||
sliderTop.value = newValues.top
|
||||
setTimeout(() => {
|
||||
sliderBottom.value = newValues.bottom
|
||||
}, delay)
|
||||
} else {
|
||||
sliderBottom.value = newValues.bottom
|
||||
setTimeout(() => {
|
||||
sliderTop.value = newValues.top
|
||||
}, delay)
|
||||
const STAGGER_DELAY = '200ms'
|
||||
sliderDelays.value = {
|
||||
left: newValues.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
|
||||
right: newValues.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
|
||||
top: newValues.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
|
||||
bottom: newValues.top < sliderTop.value ? STAGGER_DELAY : '0ms',
|
||||
}
|
||||
sliderLeft.value = newValues.left
|
||||
sliderRight.value = newValues.right
|
||||
sliderTop.value = newValues.top
|
||||
sliderBottom.value = newValues.bottom
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +157,17 @@ onUnmounted(() => {
|
||||
window.removeEventListener('resize', pickLink)
|
||||
})
|
||||
|
||||
watch(route, () => {
|
||||
watch(
|
||||
filteredLinks,
|
||||
async () => {
|
||||
await nextTick()
|
||||
pickLink()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(route, async () => {
|
||||
await nextTick()
|
||||
pickLink()
|
||||
})
|
||||
</script>
|
||||
@@ -154,7 +175,10 @@ watch(route, () => {
|
||||
.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,
|
||||
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
|
||||
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
|
||||
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
|
||||
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,7 +69,10 @@ onUnmounted(() => {
|
||||
<SpinnerIcon class="animate-spin w-4 h-4" />
|
||||
</div>
|
||||
</NavButton>
|
||||
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
|
||||
<div
|
||||
v-if="instances && recentInstances.length > 0"
|
||||
class="h-px w-6 mx-auto my-2 bg-divider"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -52,10 +52,12 @@
|
||||
<h3 class="info-title">
|
||||
{{ loadingBar.title }}
|
||||
</h3>
|
||||
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
|
||||
<div class="row">
|
||||
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
|
||||
{{ loadingBar.message }}
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
|
||||
<div class="row">
|
||||
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
|
||||
{{ loadingBar.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -102,7 +104,12 @@ import {
|
||||
TerminalSquareIcon,
|
||||
UnplugIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
|
||||
import {
|
||||
Button,
|
||||
ButtonStyled,
|
||||
Card,
|
||||
injectNotificationManager,
|
||||
} from '@modrinth/ui'
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -176,7 +183,7 @@ const goToTerminal = (path) => {
|
||||
const currentLoadingBars = ref([])
|
||||
|
||||
const refreshInfo = async () => {
|
||||
const currentLoadingBarCount = currentLoadingBars.value.length
|
||||
const previousBars = [...currentLoadingBars.value]
|
||||
currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError))
|
||||
.map((x) => {
|
||||
if (x.bar_type.type === 'java_download') {
|
||||
@@ -205,7 +212,7 @@ const refreshInfo = async () => {
|
||||
|
||||
if (currentLoadingBars.value.length === 0) {
|
||||
showCard.value = false
|
||||
} else if (currentLoadingBarCount < currentLoadingBars.value.length) {
|
||||
} else if (previousBars.length < currentLoadingBars.value.length) {
|
||||
showCard.value = true
|
||||
}
|
||||
}
|
||||
@@ -346,7 +353,7 @@ onBeforeUnmount(() => {
|
||||
.info-card {
|
||||
position: absolute;
|
||||
top: 3.5rem;
|
||||
right: 0.5rem;
|
||||
right: 100%;
|
||||
z-index: 9;
|
||||
width: 20rem;
|
||||
background-color: var(--color-raised-bg);
|
||||
@@ -420,7 +427,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const dismissed = ref(false)
|
||||
const availableUpdate = ref<{ version: string } | null>(null)
|
||||
|
||||
let checkInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function checkForUpdate() {
|
||||
try {
|
||||
const [response, currentVersion] = await Promise.all([
|
||||
fetch('https://launcher-files.modrinth.com/updates.json'),
|
||||
getVersion(),
|
||||
])
|
||||
const updates = await response.json()
|
||||
const latestVersion = updates?.version
|
||||
|
||||
if (latestVersion && latestVersion !== currentVersion) {
|
||||
if (latestVersion !== availableUpdate.value?.version) {
|
||||
availableUpdate.value = { version: latestVersion }
|
||||
dismissed.value = false
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check for updates:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkForUpdate()
|
||||
checkInterval = setInterval(checkForUpdate, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.update-toast.title',
|
||||
defaultMessage: 'Update available',
|
||||
},
|
||||
body: {
|
||||
id: 'app.update-toast.body.linux',
|
||||
defaultMessage:
|
||||
'Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!',
|
||||
},
|
||||
download: {
|
||||
id: 'app.update-toast.download-page',
|
||||
defaultMessage: 'Download',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="availableUpdate && !dismissed"
|
||||
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
|
||||
>
|
||||
<div class="flex min-w-[25rem] gap-4">
|
||||
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h2>
|
||||
<ButtonStyled size="small" circular>
|
||||
<button v-tooltip="formatMessage(commonMessages.closeButton)" @click="dismiss">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="text-sm mt-2 mb-0">
|
||||
{{ formatMessage(messages.body, { version: availableUpdate.version }) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,130 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, ExternalIcon, RefreshCwIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages, defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { injectAppUpdateDownloadProgress } from '@/providers/download-progress.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close' | 'restart' | 'download'): void
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
version: string
|
||||
size: number | null
|
||||
metered: boolean
|
||||
}>()
|
||||
|
||||
const downloading = ref(false)
|
||||
const { progress } = injectAppUpdateDownloadProgress()
|
||||
|
||||
function download() {
|
||||
emit('download')
|
||||
downloading.value = true
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.update-toast.title',
|
||||
defaultMessage: 'Update available',
|
||||
},
|
||||
body: {
|
||||
id: 'app.update-toast.body',
|
||||
defaultMessage:
|
||||
'Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App.',
|
||||
},
|
||||
reload: {
|
||||
id: 'app.update-toast.reload',
|
||||
defaultMessage: 'Reload',
|
||||
},
|
||||
download: {
|
||||
id: 'app.update-toast.download',
|
||||
defaultMessage: 'Download ({size})',
|
||||
},
|
||||
downloading: {
|
||||
id: 'app.update-toast.downloading',
|
||||
defaultMessage: 'Downloading...',
|
||||
},
|
||||
changelog: {
|
||||
id: 'app.update-toast.changelog',
|
||||
defaultMessage: 'Changelog',
|
||||
},
|
||||
meteredBody: {
|
||||
id: 'app.update-toast.body.metered',
|
||||
defaultMessage: `Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it.`,
|
||||
},
|
||||
downloadCompleteTitle: {
|
||||
id: 'app.update-toast.title.download-complete',
|
||||
defaultMessage: 'Download complete',
|
||||
},
|
||||
downloadedBody: {
|
||||
id: 'app.update-toast.body.download-complete',
|
||||
defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[min-content] fixed card-shadow rounded-2xl top-[--top-bar-height] mt-6 right-6 p-4 z-10 bg-bg-raised border-surface-5 border-solid border-[2px]"
|
||||
:class="{
|
||||
'download-complete': progress === 1,
|
||||
}"
|
||||
>
|
||||
<div class="flex min-w-[25rem] gap-4">
|
||||
<h2 class="whitespace-nowrap text-base text-contrast font-semibold m-0 grow">
|
||||
{{
|
||||
formatMessage(metered && progress === 1 ? messages.downloadCompleteTitle : messages.title)
|
||||
}}
|
||||
</h2>
|
||||
<ButtonStyled size="small" circular>
|
||||
<button v-tooltip="formatMessage(commonMessages.closeButton)" @click="emit('close')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="text-sm mt-2 mb-0">
|
||||
{{
|
||||
formatMessage(
|
||||
metered
|
||||
? progress === 1
|
||||
? messages.downloadedBody
|
||||
: messages.meteredBody
|
||||
: messages.body,
|
||||
{ version },
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="metered && progress < 1"
|
||||
class="text-sm text-secondary mt-2 mb-0 flex items-center gap-1"
|
||||
>
|
||||
<template v-if="progress > 0">
|
||||
<ProgressBar :progress="progress" class="max-w-[unset]" />
|
||||
</template>
|
||||
</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-if="metered && progress < 1" :disabled="downloading" @click="download">
|
||||
<SpinnerIcon v-if="downloading" class="animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
formatMessage(downloading ? messages.downloading : messages.download, {
|
||||
size: formatBytes(size ?? 0),
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
<button v-else @click="emit('restart')">
|
||||
<RefreshCwIcon /> {{ formatMessage(messages.reload) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a href="https://modrinth.com/news/changelog?filter=app">
|
||||
{{ formatMessage(messages.changelog) }} <ExternalIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Admonition, Avatar, ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { list } from '@/helpers/profile'
|
||||
import { add_server_to_profile, get_profile_worlds } from '@/helpers/worlds.ts'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const modal = ref()
|
||||
const searchFilter = ref('')
|
||||
const profiles = ref([])
|
||||
|
||||
const serverName = ref('')
|
||||
const serverAddress = ref('')
|
||||
|
||||
const shownProfiles = computed(() =>
|
||||
profiles.value.filter((profile) => {
|
||||
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
|
||||
}),
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
show: async (name, address) => {
|
||||
serverName.value = name
|
||||
serverAddress.value = address
|
||||
searchFilter.value = ''
|
||||
|
||||
const profilesVal = await list().catch(handleError)
|
||||
for (const profile of profilesVal) {
|
||||
profile.adding = false
|
||||
profile.added = false
|
||||
|
||||
try {
|
||||
const worlds = await get_profile_worlds(profile.path)
|
||||
profile.added = worlds.some(
|
||||
(w) => w.type === 'server' && w.address === serverAddress.value,
|
||||
)
|
||||
} catch {
|
||||
// Ignore - will show as not added
|
||||
}
|
||||
}
|
||||
|
||||
profiles.value = profilesVal
|
||||
modal.value.show()
|
||||
|
||||
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
|
||||
},
|
||||
})
|
||||
|
||||
async function addServer(profile) {
|
||||
profile.adding = true
|
||||
try {
|
||||
await add_server_to_profile(
|
||||
profile.path,
|
||||
serverName.value,
|
||||
serverAddress.value,
|
||||
'prompt',
|
||||
)
|
||||
profile.added = true
|
||||
|
||||
trackEvent('AddServerToInstance', {
|
||||
server_name: serverName.value,
|
||||
instance_name: profile.name,
|
||||
source: 'AddServerToInstanceModal',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
profile.adding = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="modal" header="Add server to instance">
|
||||
<div class="flex flex-col gap-4 min-w-[350px]">
|
||||
<Admonition type="warning" body="This server may not be compatible with all instances." />
|
||||
<StyledInput
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
placeholder="Search for an instance"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="max-h-[21rem] overflow-y-auto">
|
||||
<div
|
||||
v-for="profile in shownProfiles"
|
||||
:key="profile.path"
|
||||
class="flex w-full items-center justify-between gap-2 bg-bg-raised text-icon shadow-none"
|
||||
>
|
||||
<router-link
|
||||
class="btn btn-transparent p-2 text-left"
|
||||
:to="`/instance/${encodeURIComponent(profile.path)}`"
|
||||
@click="modal.hide()"
|
||||
>
|
||||
<Avatar
|
||||
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
|
||||
class="mr-2 [--size:2rem]"
|
||||
/>
|
||||
{{ profile.name }}
|
||||
</router-link>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="profile.added || profile.adding"
|
||||
@click="addServer(profile)"
|
||||
>
|
||||
<PlusIcon v-if="!profile.added && !profile.adding" />
|
||||
<CheckIcon v-else-if="profile.added" />
|
||||
{{
|
||||
profile.adding
|
||||
? 'Adding...'
|
||||
: profile.added
|
||||
? 'Added'
|
||||
: 'Add'
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<ButtonStyled>
|
||||
<button @click="modal.hide()">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3_many } from '@/helpers/cache.js'
|
||||
import {
|
||||
add_project_from_version as installMod,
|
||||
check_installed,
|
||||
@@ -81,6 +82,22 @@ defineExpose({
|
||||
handleError,
|
||||
)
|
||||
}
|
||||
|
||||
const linkedProjectIds = profilesVal
|
||||
.filter((p) => p.linked_data?.project_id)
|
||||
.map((p) => p.linked_data.project_id)
|
||||
if (linkedProjectIds.length > 0) {
|
||||
const linkedProjects = await get_project_v3_many(linkedProjectIds, 'stale_while_revalidate_skip_offline').catch(
|
||||
() => [],
|
||||
)
|
||||
const serverProjectIds = new Set(
|
||||
linkedProjects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
|
||||
)
|
||||
for (const profile of profilesVal) {
|
||||
profile.isServerInstance = serverProjectIds.has(profile.linked_data?.project_id)
|
||||
}
|
||||
}
|
||||
|
||||
profiles.value = profilesVal
|
||||
|
||||
installModal.value.show()
|
||||
|
||||
@@ -29,7 +29,7 @@ import { computed, type ComputedRef, type Ref, ref, shallowRef, watch } from 'vu
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project, get_version_many } from '@/helpers/cache'
|
||||
import { get_project, get_version, get_version_many } from '@/helpers/cache'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { edit, install, update_repair_modrinth } from '@/helpers/profile'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
@@ -110,6 +110,12 @@ if (props.instance.linked_data && props.instance.linked_data.project_id && !prop
|
||||
versions.find(
|
||||
(version: Version) => version.id === props.instance.linked_data?.version_id,
|
||||
) ?? null
|
||||
|
||||
if (!modpackVersion.value) {
|
||||
get_version(props.instance.linked_data?.version_id, 'bypass')
|
||||
.then((version: Version) => (modpackVersion.value = version ?? null))
|
||||
.catch(handleError)
|
||||
}
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
@@ -424,6 +430,18 @@ const messages = defineMessages({
|
||||
id: 'instance.settings.tabs.installation.unlink.description',
|
||||
defaultMessage: `This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack.`,
|
||||
},
|
||||
unlinkServerTitle: {
|
||||
id: 'instance.settings.tabs.installation.unlink-server.title',
|
||||
defaultMessage: 'Unlink from server',
|
||||
},
|
||||
unlinkServerDescription: {
|
||||
id: 'instance.settings.tabs.installation.unlink-server.description',
|
||||
defaultMessage: `This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server.`,
|
||||
},
|
||||
unlinkServerVanillaDescription: {
|
||||
id: 'instance.settings.tabs.installation.unlink-server-vanilla.description',
|
||||
defaultMessage: `This instance is linked to a server, which means you can't change the Minecraft version. Unlinking will permanently disconnect this instance from the server.`,
|
||||
},
|
||||
unlinkInstanceButton: {
|
||||
id: 'instance.settings.tabs.installation.unlink.button',
|
||||
defaultMessage: 'Unlink instance',
|
||||
@@ -557,17 +575,27 @@ const messages = defineMessages({
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="text-sm text-secondary leading-none">
|
||||
<span class="text-sm text-secondary leading-none capitalize">
|
||||
{{
|
||||
modpackProject
|
||||
? modpackVersion
|
||||
? modpackVersion?.version_number
|
||||
: 'Unknown version'
|
||||
: props.isMinecraftServer
|
||||
? ''
|
||||
: 'Unknown version'
|
||||
: formatLoader(formatMessage, instance.loader)
|
||||
}}
|
||||
<template v-if="instance.loader !== 'vanilla' && !modpackProject">
|
||||
{{ instance.loader_version || formatMessage(messages.unknownVersion) }}
|
||||
</template>
|
||||
<template
|
||||
v-else-if="
|
||||
instance.loader && instance.loader !== 'vanilla' && props.isMinecraftServer
|
||||
"
|
||||
>
|
||||
{{ instance.loader }}
|
||||
{{ instance.loader_version }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -599,7 +627,10 @@ const messages = defineMessages({
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="modpackProject" hover-color-fill="background">
|
||||
<ButtonStyled
|
||||
v-if="modpackProject && !props.isMinecraftServer"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
changingVersion
|
||||
@@ -754,17 +785,29 @@ const messages = defineMessages({
|
||||
<template v-else>
|
||||
<template v-if="instance.linked_data && instance.linked_data.locked">
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.unlinkInstanceTitle) }}
|
||||
{{
|
||||
formatMessage(
|
||||
props.isMinecraftServer ? messages.unlinkServerTitle : messages.unlinkInstanceTitle,
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.unlinkInstanceDescription) }}
|
||||
{{
|
||||
formatMessage(
|
||||
props.isMinecraftServer
|
||||
? instance.loader === 'vanilla'
|
||||
? messages.unlinkServerVanillaDescription
|
||||
: messages.unlinkServerDescription
|
||||
: messages.unlinkInstanceDescription,
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<button class="mt-2" @click="modalConfirmUnpair.show()">
|
||||
<UnlinkIcon /> {{ formatMessage(messages.unlinkInstanceButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template v-if="modpackProject">
|
||||
<template v-if="modpackProject && !props.isMinecraftServer">
|
||||
<div>
|
||||
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast block mt-4">
|
||||
{{ formatMessage(messages.reinstallModpackTitle) }}
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
|
||||
<div class="flex flex-col gap-6 max-w-[500px]">
|
||||
<Admonition type="info" :header="formatMessage(messages.sharedServerInstance)">
|
||||
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
|
||||
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
|
||||
{{ formatMessage(messages.serverRequiresMods) }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
|
||||
<Avatar
|
||||
v-if="sharedBy?.icon_url"
|
||||
:src="sharedBy.icon_url"
|
||||
:alt="sharedBy.name"
|
||||
size="24px"
|
||||
/>
|
||||
<span>
|
||||
<IntlFormatted :message-id="messages.sharedByToday">
|
||||
<template #~name>
|
||||
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.requiredModpack)
|
||||
}}</span>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.sharedInstance) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="openViewContents">
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.viewContents) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
|
||||
<Avatar
|
||||
:src="requiredContentProject.icon_url"
|
||||
:alt="requiredContentProject.title"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">{{ project.title }}</span>
|
||||
<span class="font-semibold text-contrast">
|
||||
<template v-if="usingCustomModpack">
|
||||
{{ modpackVersion.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ requiredContentProject.title }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
|
||||
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
|
||||
<template v-if="modCount">
|
||||
· {{ formatMessage(messages.modCount, { count: modCount }) }}
|
||||
</template>
|
||||
@@ -57,11 +62,17 @@
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="project?.title ?? ''"
|
||||
:modpack-icon-url="project?.icon_url ?? undefined"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
@@ -69,33 +80,38 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoader,
|
||||
IntlFormatted,
|
||||
ModpackContentModal,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { get_organization, get_team, get_version } from '@/helpers/cache.js'
|
||||
import { install } from '@/store/install.js'
|
||||
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
|
||||
import {
|
||||
get_organization,
|
||||
get_project,
|
||||
get_project_many,
|
||||
get_team,
|
||||
get_version,
|
||||
get_version_many,
|
||||
} from '@/helpers/cache.js'
|
||||
import { installServerProject, useInstall } from '@/store/install.js'
|
||||
|
||||
const props = defineProps<{
|
||||
project: Labrinth.Projects.v2.Project
|
||||
}>()
|
||||
import type { ContentItem } from '../../../../../../packages/ui/src/components/instances/types'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<any>(null)
|
||||
const project = ref<any>(null)
|
||||
const requiredContentProject = ref<any>(null)
|
||||
const organization = ref<any>(null)
|
||||
const teamMembers = ref<any[]>([])
|
||||
const onInstallComplete = ref<() => void>(() => {})
|
||||
const { formatMessage } = useVIntl()
|
||||
const installStore = useInstall()
|
||||
|
||||
const { data: organization } = useQuery({
|
||||
queryKey: computed(() => ['organization', props.project.organization]),
|
||||
queryFn: () => get_organization(props.project.organization!, 'must_revalidate'),
|
||||
enabled: computed(() => !!props.project.organization),
|
||||
})
|
||||
|
||||
const { data: teamMembers } = useQuery({
|
||||
queryKey: computed(() => ['team', props.project.team]),
|
||||
queryFn: () => get_team(props.project.team, 'must_revalidate'),
|
||||
enabled: computed(() => !!props.project.team && !props.project.organization),
|
||||
const usingCustomModpack = computed(() => {
|
||||
return requiredContentProject.value?.id === project.value?.id
|
||||
})
|
||||
|
||||
const sharedBy = computed(() => {
|
||||
@@ -105,7 +121,7 @@ const sharedBy = computed(() => {
|
||||
icon_url: organization.value.icon_url,
|
||||
}
|
||||
}
|
||||
if (teamMembers.value) {
|
||||
if (teamMembers.value?.length) {
|
||||
const owner = teamMembers.value.find((member: { is_owner: boolean }) => member.is_owner)
|
||||
if (owner) {
|
||||
return {
|
||||
@@ -118,26 +134,39 @@ const sharedBy = computed(() => {
|
||||
})
|
||||
|
||||
const loaderDisplay = computed(() => {
|
||||
const loader = props.project.loaders?.[0]
|
||||
const loader = requiredContentProject.value?.loaders?.[0]
|
||||
if (!loader) return ''
|
||||
return formatLoader(formatMessage, loader)
|
||||
})
|
||||
|
||||
// Fetch the most recent version to get mod count from dependencies
|
||||
const latestVersionId = computed(() => props.project.versions?.[0] ?? null)
|
||||
const { data: latestVersion } = useQuery({
|
||||
queryKey: computed(() => ['version', latestVersionId.value]),
|
||||
queryFn: () => get_version(latestVersionId.value, 'must_revalidate'),
|
||||
enabled: computed(() => !!latestVersionId.value),
|
||||
})
|
||||
const modCount = computed(() => latestVersion.value?.dependencies?.length)
|
||||
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
|
||||
|
||||
async function fetchData(versionId: string) {
|
||||
// cache is making version null for some reason so bypassing for now
|
||||
modpackVersion.value = await get_version(versionId, 'bypass')
|
||||
|
||||
if (modpackVersion.value?.project_id) {
|
||||
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
|
||||
}
|
||||
|
||||
if (project.value?.organization) {
|
||||
organization.value = await get_organization(project.value.organization, 'bypass')
|
||||
} else if (project.value?.team_id) {
|
||||
teamMembers.value = await get_team(project.value.team_id, 'bypass')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
hide()
|
||||
const serverProjectId = project.value.id
|
||||
installStore.startInstallingServer(serverProjectId)
|
||||
try {
|
||||
await install(props.project.id, null, null, 'ProjectPageInstallToPlayModal')
|
||||
await installServerProject(serverProjectId)
|
||||
onInstallComplete.value()
|
||||
} catch (error) {
|
||||
console.error('Failed to install project from InstallToPlayModal:', error)
|
||||
console.error('Failed to install server project from InstallToPlayModal:', error)
|
||||
} finally {
|
||||
installStore.stopInstallingServer(serverProjectId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +174,91 @@ function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
|
||||
|
||||
async function openViewContents() {
|
||||
modpackContentModal.value?.showLoading()
|
||||
try {
|
||||
// Ensure version data is available — the useQuery may not have resolved yet
|
||||
const versionId = modpackVersionId.value
|
||||
const version =
|
||||
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
|
||||
|
||||
const deps = version?.dependencies ?? []
|
||||
|
||||
const projectIds = deps
|
||||
.map((d: { project_id?: string }) => d.project_id)
|
||||
.filter((id: string | undefined): id is string => !!id)
|
||||
|
||||
const versionIds = deps
|
||||
.map((d: { version_id?: string }) => d.version_id)
|
||||
.filter((id: string | undefined): id is string => !!id)
|
||||
|
||||
const projects: Labrinth.Projects.v2.Project[] =
|
||||
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
|
||||
|
||||
const versions: Labrinth.Versions.v2.Version[] =
|
||||
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
|
||||
|
||||
const projectMap = new Map(projects.map((p: any) => [p.id, p]))
|
||||
|
||||
const contentItems: ContentItem[] = deps.map((dep: any): ContentItem => {
|
||||
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
|
||||
const depVersion = dep.version_id ? versions.find((v: any) => v.id === dep.version_id) : null
|
||||
|
||||
return {
|
||||
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
|
||||
project_type: depProject?.project_type ?? 'mod',
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
project: {
|
||||
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
|
||||
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
|
||||
title: depProject?.title ?? dep.file_name ?? 'Unknown',
|
||||
icon_url: depProject?.icon_url ?? null,
|
||||
},
|
||||
...(depVersion
|
||||
? {
|
||||
version: {
|
||||
id: depVersion.id,
|
||||
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
|
||||
version_number: depVersion.version_number ?? undefined,
|
||||
date_published: depVersion.date_published ?? undefined,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
modpackContentModal.value?.show(contentItems)
|
||||
} catch (err) {
|
||||
console.error('Failed to load modpack contents:', err)
|
||||
modpackContentModal.value?.show([])
|
||||
}
|
||||
}
|
||||
|
||||
async function show(
|
||||
projectVal: Labrinth.Projects.v3.Project,
|
||||
modpackVersionIdVal: string | null = null,
|
||||
callback: () => void = () => {},
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
project.value = projectVal
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
modpackVersion.value = null
|
||||
requiredContentProject.value = null
|
||||
organization.value = null
|
||||
teamMembers.value = []
|
||||
onInstallComplete.value = callback
|
||||
|
||||
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
|
||||
|
||||
hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
show_ads_window()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -162,14 +270,18 @@ const messages = defineMessages({
|
||||
id: 'app.modal.install-to-play.shared-server-instance',
|
||||
defaultMessage: 'Shared server instance',
|
||||
},
|
||||
contentRequired: {
|
||||
id: 'app.modal.install-to-play.content-required',
|
||||
defaultMessage: 'Content required',
|
||||
},
|
||||
serverRequiresMods: {
|
||||
id: 'app.modal.install-to-play.server-requires-mods',
|
||||
defaultMessage:
|
||||
'This server requires mods to play. Click install to set up the required files from Modrinth.',
|
||||
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
|
||||
},
|
||||
sharedByToday: {
|
||||
id: 'app.modal.install-to-play.shared-by-today',
|
||||
defaultMessage: '{name} shared this instance with you today.',
|
||||
requiredModpack: {
|
||||
id: 'app.modal.install-to-play.required-modpack',
|
||||
defaultMessage: 'Required modpack',
|
||||
},
|
||||
sharedInstance: {
|
||||
id: 'app.modal.install-to-play.shared-instance',
|
||||
@@ -183,6 +295,10 @@ const messages = defineMessages({
|
||||
id: 'app.modal.install-to-play.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
viewContents: {
|
||||
id: 'app.modal.install-to-play.view-contents',
|
||||
defaultMessage: 'View contents',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
|
||||
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
|
||||
@@ -24,6 +24,7 @@ import InstallationSettings from '@/components/ui/instance_settings/Installation
|
||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_project_v3 } from '@/helpers/cache'
|
||||
|
||||
import type { InstanceSettingsTabProps } from '../../../helpers/types'
|
||||
|
||||
@@ -31,7 +32,26 @@ const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<InstanceSettingsTabProps>()
|
||||
|
||||
const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
|
||||
const isMinecraftServer = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.instance,
|
||||
(instance) => {
|
||||
isMinecraftServer.value = false
|
||||
if (instance.linked_data?.project_id) {
|
||||
get_project_v3(instance.linked_data.project_id, 'must_revalidate')
|
||||
.then((project: any) => {
|
||||
if (project?.minecraft_server != null) {
|
||||
isMinecraftServer.value = true
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const tabs = computed<TabbedModalTab<InstanceSettingsTabProps>[]>(() => [
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.general',
|
||||
@@ -72,7 +92,7 @@ const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
|
||||
icon: CodeIcon,
|
||||
content: HooksSettings,
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
const modal = ref()
|
||||
|
||||
@@ -98,6 +118,10 @@ defineExpose({ show })
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
|
||||
<TabbedModal
|
||||
:tabs="
|
||||
tabs.map((tab) => ({ ...tab, props: { ...props, isMinecraftServer: isMinecraftServer } }))
|
||||
"
|
||||
/>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.updateToPlay)" :closable="true" no-padding>
|
||||
<div class="max-w-[500px]">
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.updateToPlay)"
|
||||
:closable="true"
|
||||
no-padding
|
||||
@hide="() => show_ads_window()"
|
||||
>
|
||||
<div v-if="instance" class="max-w-[500px]">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<Admonition type="warning" :header="formatMessage(messages.updateRequired)">
|
||||
<Admonition type="info" :header="formatMessage(messages.updateRequired)">
|
||||
{{ formatMessage(messages.updateRequiredDescription, { name: instance.name }) }}
|
||||
</Admonition>
|
||||
|
||||
@@ -26,18 +32,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="diffs.length" class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto">
|
||||
<div
|
||||
v-if="diffs.length"
|
||||
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
|
||||
>
|
||||
<div
|
||||
v-for="diff in diffs"
|
||||
v-for="(diff, index) in diffs"
|
||||
:key="diff.project_id"
|
||||
class="grid grid-cols-[auto_1fr_1fr_1fr] items-center min-h-10 h-10 gap-2"
|
||||
class="grid items-center min-h-10 h-10 gap-2"
|
||||
:class="diff.project?.title ? 'grid-cols-[auto_1fr_1fr_1fr]' : 'grid-cols-[auto_1fr_1fr]'"
|
||||
>
|
||||
<div class="flex flex-col justify-between items-center">
|
||||
<div class="w-[1px] h-2"></div>
|
||||
<PlusIcon v-if="diff.type === 'added'" />
|
||||
<MinusIcon v-else-if="diff.type === 'removed'" />
|
||||
<RefreshCwIcon v-else />
|
||||
<div class="bg-surface-5 w-[1px] h-2 relative top-1"></div>
|
||||
<div
|
||||
:class="index === diffs.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
|
||||
class="w-[1px] h-2 relative top-1"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 col-span-2">
|
||||
@@ -49,14 +62,28 @@
|
||||
>
|
||||
{{ diff.project.title }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="diff.fileName"
|
||||
v-tooltip="diff.fileName"
|
||||
class="text-sm text-contrast font-medium truncate"
|
||||
>
|
||||
{{ decodeURIComponent(diff.fileName) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
|
||||
v-tooltip="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
|
||||
v-if="
|
||||
diff.project?.title &&
|
||||
(getFilename(diff.newVersion) || getFilename(diff.currentVersion) || diff.fileName)
|
||||
"
|
||||
v-tooltip="getFilename(diff.newVersion) || getFilename(diff.currentVersion) || decodeURIComponent(diff.fileName || '')""
|
||||
class="text-xs truncate text-right"
|
||||
>
|
||||
{{ getFilename(diff.newVersion) || getFilename(diff.currentVersion) }}
|
||||
{{
|
||||
getFilename(diff.newVersion) ||
|
||||
getFilename(diff.currentVersion) ||
|
||||
decodeURIComponent(diff.fileName || '')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,9 +138,11 @@ import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { get_project, get_project_many, get_version_many } from '@/helpers/cache.js'
|
||||
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
|
||||
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { update_managed_modrinth_version } from '@/helpers/profile'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { useInstall } from '@/store/install.js'
|
||||
|
||||
type Dependency = Labrinth.Versions.v3.Dependency
|
||||
type Version = Labrinth.Versions.v2.Version
|
||||
@@ -129,6 +158,7 @@ interface BaseDiff {
|
||||
newVersionId?: string
|
||||
currentVersion?: Version
|
||||
newVersion?: Version
|
||||
fileName?: string
|
||||
}
|
||||
interface AddedDiff extends BaseDiff {
|
||||
type: 'added'
|
||||
@@ -151,22 +181,21 @@ type ProjectInfo = {
|
||||
slug: string
|
||||
}
|
||||
|
||||
const { instance } = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const installStore = useInstall()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const instance = ref<GameInstance | null>(null)
|
||||
const onUpdateComplete = ref<() => void>(() => {})
|
||||
const diffs = ref<DependencyDiff[]>([])
|
||||
const latestVersionId = ref<string | null>(null)
|
||||
const latestVersion = ref<Version | null>(null)
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Version | null>(null)
|
||||
|
||||
const removedCount = computed(() => diffs.value.filter((d) => d.type === 'removed').length)
|
||||
const addedCount = computed(() => diffs.value.filter((d) => d.type === 'added').length)
|
||||
const updatedCount = computed(() => diffs.value.filter((d) => d.type === 'updated').length)
|
||||
const publishedDate = computed(() =>
|
||||
latestVersion.value?.date_published ? new Date(latestVersion.value.date_published) : null,
|
||||
modpackVersion.value?.date_published ? new Date(modpackVersion.value.date_published) : null,
|
||||
)
|
||||
|
||||
function getFilename(version?: Version): string | undefined {
|
||||
@@ -177,18 +206,27 @@ async function computeDependencyDiffs(
|
||||
currentDeps: Dependency[],
|
||||
latestDeps: Dependency[],
|
||||
): Promise<DependencyDiff[]> {
|
||||
console.log('Computing dependency diffs', { currentDeps, latestDeps })
|
||||
|
||||
// Separate deps with project_id from file_name-only deps
|
||||
const currentWithProject = currentDeps.filter((d) => d.project_id)
|
||||
const latestWithProject = latestDeps.filter((d) => d.project_id)
|
||||
const currentFileOnly = currentDeps.filter((d) => !d.project_id && d.file_name)
|
||||
const latestFileOnly = latestDeps.filter((d) => !d.project_id && d.file_name)
|
||||
|
||||
const currentByProject = new Map<string, Dependency>(
|
||||
currentDeps.map((d) => [d.project_id || '', d]),
|
||||
currentWithProject.map((d) => [d.project_id!, d]),
|
||||
)
|
||||
const latestByProject = new Map<string, Dependency>(
|
||||
latestDeps.map((d) => [d.project_id || '', d]),
|
||||
latestWithProject.map((d) => [d.project_id!, d]),
|
||||
)
|
||||
const currentFilenames = new Set(currentFileOnly.map((d) => d.file_name!))
|
||||
const latestFilenames = new Set(latestFileOnly.map((d) => d.file_name!))
|
||||
|
||||
const diffs: DependencyDiff[] = []
|
||||
|
||||
// Find added and updated dependencies
|
||||
// Find added and updated dependencies (by project_id)
|
||||
latestByProject.forEach((latestDep, projectId) => {
|
||||
if (!projectId) return
|
||||
const currentDep = currentByProject.get(projectId)
|
||||
if (!currentDep && latestDep.version_id) {
|
||||
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
|
||||
@@ -206,9 +244,8 @@ async function computeDependencyDiffs(
|
||||
}
|
||||
})
|
||||
|
||||
// Find removed dependencies
|
||||
// Find removed dependencies (by project_id)
|
||||
currentByProject.forEach((currentDep, projectId) => {
|
||||
if (!projectId) return
|
||||
if (!latestByProject.has(projectId)) {
|
||||
diffs.push({
|
||||
type: 'removed',
|
||||
@@ -218,6 +255,19 @@ async function computeDependencyDiffs(
|
||||
}
|
||||
})
|
||||
|
||||
// Find added/removed file_name-only dependencies
|
||||
// ideally in future, this should use the hash of the file instead of filename, but since version dependencies don't include file hashes, we'll use filename as a best effort approach
|
||||
for (const fileName of latestFilenames) {
|
||||
if (!currentFilenames.has(fileName)) {
|
||||
diffs.push({ type: 'added', project_id: '', newVersionId: '' as string, fileName })
|
||||
}
|
||||
}
|
||||
for (const fileName of currentFilenames) {
|
||||
if (!latestFilenames.has(fileName)) {
|
||||
diffs.push({ type: 'removed', project_id: '', fileName })
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch projects and versions of diffs
|
||||
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
|
||||
const allVersionIds = [
|
||||
@@ -228,14 +278,14 @@ async function computeDependencyDiffs(
|
||||
),
|
||||
] as string[]
|
||||
const [projects, versions] = await Promise.all([
|
||||
get_project_many(allProjectIds, 'must_revalidate'),
|
||||
get_version_many(allVersionIds, 'must_revalidate'),
|
||||
get_project_many(allProjectIds, 'bypass'),
|
||||
get_version_many(allVersionIds, 'bypass'),
|
||||
])
|
||||
|
||||
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
|
||||
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
|
||||
|
||||
return diffs
|
||||
const mappedDiffs = diffs
|
||||
.map((diff) => {
|
||||
const project = projectMap.get(diff.project_id)
|
||||
return {
|
||||
@@ -255,35 +305,25 @@ async function computeDependencyDiffs(
|
||||
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
|
||||
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
|
||||
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
|
||||
})
|
||||
}).filter((d) => d.project || d.fileName) // filter out any diffs that couldn't be matched to a project or file
|
||||
return mappedDiffs
|
||||
}
|
||||
|
||||
async function checkUpdateAvailable(instance: GameInstance): Promise<DependencyDiff[] | null> {
|
||||
if (!instance.linked_data) return null
|
||||
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
|
||||
if (!inst.linked_data) return null
|
||||
|
||||
try {
|
||||
const project = await get_project(instance.linked_data.project_id, 'must_revalidate')
|
||||
if (!project || !project.versions || project.versions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const versions = await get_version_many(project.versions, 'must_revalidate')
|
||||
const sortedVersions = versions.sort(
|
||||
(a: { date_published: string }, b: { date_published: string }) =>
|
||||
dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
|
||||
)
|
||||
|
||||
latestVersion.value = sortedVersions[0]
|
||||
latestVersionId.value = latestVersion.value?.id || null
|
||||
|
||||
const currentVersionId = instance.linked_data.version_id
|
||||
const currentVersion = versions.find((v: { id: string }) => v.id === currentVersionId)
|
||||
// For server projects, linked_data.project_id is the server project but
|
||||
// linked_data.version_id references a content modpack version from a different project.
|
||||
// Detect this by comparing the version's project_id with linked_data.project_id.
|
||||
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
|
||||
const instanceModpackVersion = await get_version(inst.linked_data.version_id, 'bypass')
|
||||
|
||||
// Compute dependency diffs between current and latest version
|
||||
if (currentVersion && latestVersion.value) {
|
||||
if (instanceModpackVersion && modpackVersion.value) {
|
||||
return await computeDependencyDiffs(
|
||||
currentVersion.dependencies || [],
|
||||
latestVersion.value.dependencies || [],
|
||||
modpackVersion.value.dependencies || [],
|
||||
instanceModpackVersion.dependencies || [],
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -294,9 +334,10 @@ async function checkUpdateAvailable(instance: GameInstance): Promise<DependencyD
|
||||
}
|
||||
|
||||
watch(
|
||||
() => instance,
|
||||
async () => {
|
||||
const result = await checkUpdateAvailable(instance)
|
||||
() => instance.value,
|
||||
async (newInstance) => {
|
||||
if (!newInstance) return
|
||||
const result = await checkUpdateAvailable(newInstance)
|
||||
diffs.value = result || []
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
@@ -304,18 +345,25 @@ watch(
|
||||
|
||||
async function handleUpdate() {
|
||||
hide()
|
||||
const serverProjectId = instance.value?.linked_data?.project_id
|
||||
if (serverProjectId) installStore.startInstallingServer(serverProjectId)
|
||||
try {
|
||||
if (latestVersionId.value) {
|
||||
await update_managed_modrinth_version(instance.path, latestVersionId.value)
|
||||
if (modpackVersionId.value && instance.value) {
|
||||
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
|
||||
onUpdateComplete.value()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating instance:', error)
|
||||
} finally {
|
||||
if (serverProjectId) installStore.stopInstallingServer(serverProjectId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReport() {
|
||||
if (instance.linked_data?.project_id) {
|
||||
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.linked_data.project_id}`)
|
||||
if (instance.value?.linked_data?.project_id) {
|
||||
openUrl(
|
||||
`https://modrinth.com/report?item=project&itemID=${instance.value.linked_data.project_id}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +371,16 @@ function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
function show(
|
||||
instanceVal: GameInstance,
|
||||
modpackVersionIdVal: string | null = null,
|
||||
callback: () => void = () => {},
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
instance.value = instanceVal
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
onUpdateComplete.value = callback
|
||||
hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
@@ -378,5 +435,13 @@ const diffTypeMessages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
const hasUpdate = computed(() => {
|
||||
if (!instance.value?.linked_data) return false
|
||||
return (
|
||||
modpackVersionId.value != null &&
|
||||
modpackVersionId.value !== instance.value.linked_data.version_id
|
||||
)
|
||||
})
|
||||
|
||||
defineExpose({ show, hide, hasUpdate })
|
||||
</script>
|
||||
|
||||
@@ -44,7 +44,7 @@ import type {
|
||||
SingleplayerWorld,
|
||||
World,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { getWorldIdentifier, set_world_display_status } from '@/helpers/worlds.ts'
|
||||
import { getWorldIdentifier, isLinkedWorld, set_world_display_status } from '@/helpers/worlds.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
@@ -117,6 +117,7 @@ const serverIncompatible = computed(
|
||||
)
|
||||
|
||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||
const linked = computed(() => isLinkedWorld(props.world))
|
||||
|
||||
const messages = defineMessages({
|
||||
hardcore: {
|
||||
@@ -171,6 +172,10 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.dont_show_on_home',
|
||||
defaultMessage: `Don't show on Home`,
|
||||
},
|
||||
linkedServer: {
|
||||
id: 'instance.worlds.linked_server',
|
||||
defaultMessage: 'Managed by server project',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
@@ -396,8 +401,12 @@ const messages = defineMessages({
|
||||
id: 'edit',
|
||||
action: () => emit('edit'),
|
||||
shown: !instancePath,
|
||||
disabled: locked,
|
||||
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
|
||||
disabled: locked || linked,
|
||||
tooltip: locked
|
||||
? formatMessage(messages.worldInUse)
|
||||
: linked
|
||||
? formatMessage(messages.linkedServer)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: 'open-folder',
|
||||
@@ -432,8 +441,12 @@ const messages = defineMessages({
|
||||
hoverFilled: true,
|
||||
action: () => emit('delete'),
|
||||
shown: !instancePath,
|
||||
disabled: locked,
|
||||
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
|
||||
disabled: locked || linked,
|
||||
tooltip: locked
|
||||
? formatMessage(messages.worldInUse)
|
||||
: linked
|
||||
? formatMessage(messages.linkedServer)
|
||||
: undefined,
|
||||
},
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -8,6 +8,14 @@ export async function get_project_many(ids, cacheBehaviour) {
|
||||
return await invoke('plugin:cache|get_project_many', { ids, cacheBehaviour })
|
||||
}
|
||||
|
||||
export async function get_project_v3(id, cacheBehaviour) {
|
||||
return await invoke('plugin:cache|get_project_v3', { id, cacheBehaviour })
|
||||
}
|
||||
|
||||
export async function get_project_v3_many(ids, cacheBehaviour) {
|
||||
return await invoke('plugin:cache|get_project_v3_many', { ids, cacheBehaviour })
|
||||
}
|
||||
|
||||
export async function get_version(id, cacheBehaviour) {
|
||||
return await invoke('plugin:cache|get_version', { id, cacheBehaviour })
|
||||
}
|
||||
@@ -48,6 +56,14 @@ export async function get_search_results_many(ids, cacheBehaviour) {
|
||||
return await invoke('plugin:cache|get_search_results_many', { ids, cacheBehaviour })
|
||||
}
|
||||
|
||||
export async function get_search_results_v3(id, cacheBehaviour) {
|
||||
return await invoke('plugin:cache|get_search_results_v3', { id, cacheBehaviour })
|
||||
}
|
||||
|
||||
export async function get_search_results_v3_many(ids, cacheBehaviour) {
|
||||
return await invoke('plugin:cache|get_search_results_v3_many', { ids, cacheBehaviour })
|
||||
}
|
||||
|
||||
export async function purge_cache_types(cacheTypes) {
|
||||
return await invoke('plugin:cache|purge_cache_types', { cacheTypes })
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { install_to_existing_profile } from '@/helpers/pack.js'
|
||||
- icon is a path to an image file, which will be copied into the profile directory
|
||||
*/
|
||||
|
||||
export async function create(name, gameVersion, modloader, loaderVersion, icon, skipInstall) {
|
||||
export async function create(name, gameVersion, modloader, loaderVersion, icon, skipInstall, linkedData) {
|
||||
//Trim string name to avoid "Unable to find directory"
|
||||
name = name.trim()
|
||||
return await invoke('plugin:profile-create|profile_create', {
|
||||
@@ -28,6 +28,7 @@ export async function create(name, gameVersion, modloader, loaderVersion, icon,
|
||||
loaderVersion,
|
||||
icon,
|
||||
skipInstall,
|
||||
linkedData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,8 +175,8 @@ export async function get_pack_export_candidates(profilePath) {
|
||||
|
||||
// Run Minecraft using a pathed profile
|
||||
// Returns PID of child
|
||||
export async function run(path) {
|
||||
return await invoke('plugin:profile|profile_run', { path })
|
||||
export async function run(path, serverAddress = null) {
|
||||
return await invoke('plugin:profile|profile_run', { path, serverAddress })
|
||||
}
|
||||
|
||||
export async function kill(path) {
|
||||
|
||||
+1
@@ -139,4 +139,5 @@ type AppSettings = {
|
||||
export type InstanceSettingsTabProps = {
|
||||
instance: GameInstance
|
||||
offline?: boolean
|
||||
isMinecraftServer?: boolean
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export type ServerWorld = BaseWorld & {
|
||||
index: number
|
||||
address: string
|
||||
pack_status: ServerPackStatus
|
||||
linked_project_id?: string
|
||||
}
|
||||
|
||||
export type World = SingleplayerWorld | ServerWorld
|
||||
@@ -140,8 +141,15 @@ export async function add_server_to_profile(
|
||||
name: string,
|
||||
address: string,
|
||||
packStatus: ServerPackStatus,
|
||||
linkedProjectId?: string,
|
||||
): Promise<number> {
|
||||
return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
|
||||
return await invoke('plugin:worlds|add_server_to_profile', {
|
||||
path,
|
||||
name,
|
||||
address,
|
||||
packStatus,
|
||||
linkedProjectId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function edit_server_in_profile(
|
||||
@@ -212,6 +220,10 @@ export function isServerWorld(world: World): world is ServerWorld {
|
||||
return world.type === 'server'
|
||||
}
|
||||
|
||||
export function isLinkedWorld(world: World): boolean {
|
||||
return world.type === 'server' && !!world.linked_project_id
|
||||
}
|
||||
|
||||
export async function refreshServerData(
|
||||
serverData: ServerData,
|
||||
protocolVersion: ProtocolVersion | null,
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Cannot reach authentication servers"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Content required"
|
||||
},
|
||||
"app.modal.install-to-play.header": {
|
||||
"message": "Install to play"
|
||||
},
|
||||
@@ -14,11 +17,11 @@
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural, one {# mod} other {# mods}}"
|
||||
},
|
||||
"app.modal.install-to-play.server-requires-mods": {
|
||||
"message": "This server requires mods to play. Click install to set up the required files from Modrinth."
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Required modpack"
|
||||
},
|
||||
"app.modal.install-to-play.shared-by-today": {
|
||||
"message": "{name} shared this instance with you today."
|
||||
"app.modal.install-to-play.server-requires-mods": {
|
||||
"message": "This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server."
|
||||
},
|
||||
"app.modal.install-to-play.shared-instance": {
|
||||
"message": "Shared instance"
|
||||
@@ -26,6 +29,9 @@
|
||||
"app.modal.install-to-play.shared-server-instance": {
|
||||
"message": "Shared server instance"
|
||||
},
|
||||
"app.modal.install-to-play.view-contents": {
|
||||
"message": "View contents"
|
||||
},
|
||||
"app.modal.update-to-play.added-count": {
|
||||
"message": "{count} added"
|
||||
},
|
||||
@@ -83,39 +89,33 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Resource management"
|
||||
},
|
||||
"app.update-toast.body": {
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App."
|
||||
},
|
||||
"app.update-toast.body.download-complete": {
|
||||
"app.update-popup.body.download-complete": {
|
||||
"message": "Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App."
|
||||
},
|
||||
"app.update-toast.body.linux": {
|
||||
"app.update-popup.body.linux": {
|
||||
"message": "Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!"
|
||||
},
|
||||
"app.update-toast.body.metered": {
|
||||
"app.update-popup.body.metered": {
|
||||
"message": "Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it."
|
||||
},
|
||||
"app.update-toast.changelog": {
|
||||
"app.update-popup.changelog": {
|
||||
"message": "Changelog"
|
||||
},
|
||||
"app.update-toast.download": {
|
||||
"app.update-popup.download": {
|
||||
"message": "Download ({size})"
|
||||
},
|
||||
"app.update-toast.download-page": {
|
||||
"message": "Download"
|
||||
"app.update-popup.download-complete": {
|
||||
"message": "Download complete"
|
||||
},
|
||||
"app.update-toast.downloading": {
|
||||
"message": "Downloading..."
|
||||
},
|
||||
"app.update-toast.reload": {
|
||||
"app.update-popup.reload": {
|
||||
"message": "Reload"
|
||||
},
|
||||
"app.update-toast.title": {
|
||||
"app.update-popup.title": {
|
||||
"message": "Update available"
|
||||
},
|
||||
"app.update-toast.title.download-complete": {
|
||||
"message": "Download complete"
|
||||
},
|
||||
"app.update.complete-toast.text": {
|
||||
"message": "Click here to view the changelog."
|
||||
},
|
||||
@@ -464,6 +464,12 @@
|
||||
"instance.settings.tabs.installation.unknown-version": {
|
||||
"message": "(unknown version)"
|
||||
},
|
||||
"instance.settings.tabs.installation.unlink-server.description": {
|
||||
"message": "This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server."
|
||||
},
|
||||
"instance.settings.tabs.installation.unlink-server.title": {
|
||||
"message": "Unlink from server"
|
||||
},
|
||||
"instance.settings.tabs.installation.unlink.button": {
|
||||
"message": "Unlink instance"
|
||||
},
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, SearchIcon } from '@modrinth/assets'
|
||||
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
ExternalIcon,
|
||||
GlobeIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
StopCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { ProjectType, SortType, Tags } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
injectNotificationManager,
|
||||
LoadingIndicator,
|
||||
Pagination,
|
||||
ProjectCard,
|
||||
ProjectCardList,
|
||||
SearchFilterControl,
|
||||
SearchSidebarFilter,
|
||||
StyledInput,
|
||||
useSearch,
|
||||
useServerSearch,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, ref, shallowRef, toRaw, watch } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -26,13 +38,23 @@ import type Instance from '@/components/ui/Instance.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import SearchCard from '@/components/ui/SearchCard.vue'
|
||||
import { get_search_results } from '@/helpers/cache.js'
|
||||
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
|
||||
import { get_search_results, get_search_results_v3 } from '@/helpers/cache.js'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import {
|
||||
get as getInstance,
|
||||
get_projects as getInstanceProjects,
|
||||
kill,
|
||||
list as listInstances,
|
||||
} from '@/helpers/profile.js'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { get_server_status } from '@/helpers/worlds'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { getServerAddress, playServerProject, useInstall } from '@/store/install.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const installStore = useInstall()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -42,15 +64,21 @@ const projectTypes = computed(() => {
|
||||
})
|
||||
|
||||
const [categories, loaders, availableGameVersions] = await Promise.all([
|
||||
get_categories().catch(handleError).then(ref),
|
||||
get_loaders().catch(handleError).then(ref),
|
||||
get_game_versions().catch(handleError).then(ref),
|
||||
get_categories()
|
||||
.catch(handleError)
|
||||
.then(ref<Labrinth.Tags.v2.Category[]>),
|
||||
get_loaders()
|
||||
.catch(handleError)
|
||||
.then(ref<Labrinth.Tags.v2.Loader[]>),
|
||||
get_game_versions()
|
||||
.catch(handleError)
|
||||
.then(ref<Labrinth.Tags.v2.GameVersion[]>),
|
||||
])
|
||||
|
||||
const tags: Ref<Tags> = computed(() => ({
|
||||
gameVersions: availableGameVersions.value as GameVersion[],
|
||||
loaders: loaders.value as Platform[],
|
||||
categories: categories.value as Category[],
|
||||
gameVersions: availableGameVersions.value ?? [],
|
||||
loaders: loaders.value ?? [],
|
||||
categories: categories.value ?? [],
|
||||
}))
|
||||
|
||||
type Instance = {
|
||||
@@ -71,15 +99,18 @@ type InstanceProject = {
|
||||
const instance: Ref<Instance | null> = ref(null)
|
||||
const instanceProjects: Ref<InstanceProject[] | null> = ref(null)
|
||||
const instanceHideInstalled = ref(false)
|
||||
const newlyInstalled = ref([])
|
||||
const newlyInstalled = ref<string[]>([])
|
||||
|
||||
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
|
||||
|
||||
await updateInstanceContext()
|
||||
|
||||
watch(route, () => {
|
||||
updateInstanceContext()
|
||||
})
|
||||
watch(
|
||||
() => [route.query.i, route.query.ai, route.path],
|
||||
() => {
|
||||
updateInstanceContext()
|
||||
},
|
||||
)
|
||||
|
||||
async function updateInstanceContext() {
|
||||
if (route.query.i) {
|
||||
@@ -164,7 +195,86 @@ const {
|
||||
createPageParams,
|
||||
} = useSearch(projectTypes, tags, instanceFilters)
|
||||
|
||||
const serverHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
|
||||
const serverPings = shallowRef<Record<string, number | undefined>>({})
|
||||
const runningServerProjects = ref<Record<string, string>>({})
|
||||
|
||||
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
const packs = await listInstances()
|
||||
const newRunning: Record<string, string> = {}
|
||||
for (const hit of hits) {
|
||||
const inst = packs.find((p: any) => p.linked_data?.project_id === hit.project_id)
|
||||
if (inst) {
|
||||
const processes = await get_by_profile_path(inst.path).catch(() => [])
|
||||
if (Array.isArray(processes) && processes.length > 0) {
|
||||
newRunning[hit.project_id] = inst.path
|
||||
}
|
||||
}
|
||||
}
|
||||
runningServerProjects.value = newRunning
|
||||
}
|
||||
|
||||
async function handleStopServerProject(projectId: string) {
|
||||
const instancePath = runningServerProjects.value[projectId]
|
||||
if (!instancePath) return
|
||||
await kill(instancePath).catch(() => {})
|
||||
const { [projectId]: _, ...rest } = runningServerProjects.value
|
||||
runningServerProjects.value = rest
|
||||
}
|
||||
|
||||
async function handlePlayServerProject(projectId: string) {
|
||||
await playServerProject(projectId)
|
||||
checkServerRunningStates(serverHits.value)
|
||||
}
|
||||
|
||||
function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||
const address = getServerAddress(project.minecraft_java_server)
|
||||
if (!address) return
|
||||
installStore.showAddServerToInstanceModal(project.name, address)
|
||||
}
|
||||
|
||||
const unlistenProcesses = await process_listener((e: any) => {
|
||||
if (e.event === 'finished') {
|
||||
const projectId = Object.entries(runningServerProjects.value).find(
|
||||
([, path]) => path === e.profile_path_id,
|
||||
)?.[0]
|
||||
if (projectId) {
|
||||
const { [projectId]: _, ...rest } = runningServerProjects.value
|
||||
runningServerProjects.value = rest
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcesses()
|
||||
})
|
||||
|
||||
const {
|
||||
serverCurrentSortType,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
serverSortTypes,
|
||||
serverFilterTypes,
|
||||
serverRequestParams,
|
||||
createServerPageParams,
|
||||
} = useServerSearch({ tags, query, maxResults, currentPage })
|
||||
|
||||
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
for (const hit of hits) {
|
||||
const address = hit.minecraft_java_server?.address
|
||||
if (!address) continue
|
||||
get_server_status(address)
|
||||
.then((status) => {
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to ping server ${address}:`, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const previousFilterState = ref('')
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
@@ -179,20 +289,14 @@ breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: rout
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
const projectType = ref(route.params.projectType)
|
||||
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
|
||||
|
||||
watch(projectType, () => {
|
||||
loading.value = true
|
||||
})
|
||||
|
||||
type SearchResult = {
|
||||
project_id: string
|
||||
}
|
||||
|
||||
type SearchResults = {
|
||||
total_hits: number
|
||||
limit: number
|
||||
hits: SearchResult[]
|
||||
interface SearchResults extends Labrinth.Search.v2.SearchResults {
|
||||
hits: (Labrinth.Search.v2.ResultSearchProject & { installed?: boolean })[]
|
||||
}
|
||||
|
||||
const results: Ref<SearchResults | null> = shallowRef(null)
|
||||
@@ -200,73 +304,125 @@ const pageCount = computed(() =>
|
||||
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
|
||||
)
|
||||
|
||||
watch(requestParams, () => {
|
||||
const effectiveRequestParams = computed(() => {
|
||||
return projectType.value === 'server' ? serverRequestParams.value : requestParams.value
|
||||
})
|
||||
|
||||
watch(effectiveRequestParams, async () => {
|
||||
if (!route.params.projectType) return
|
||||
await nextTick()
|
||||
refreshSearch()
|
||||
})
|
||||
|
||||
async function refreshSearch() {
|
||||
let rawResults = await get_search_results(requestParams.value)
|
||||
if (!rawResults) {
|
||||
rawResults = {
|
||||
result: {
|
||||
if (isRefreshing.value) return
|
||||
isRefreshing.value = true
|
||||
|
||||
try {
|
||||
const isServer = projectType.value === 'server'
|
||||
|
||||
if (isServer) {
|
||||
const rawResults = (await get_search_results_v3(serverRequestParams.value)) as {
|
||||
result: Labrinth.Search.v3.SearchResults
|
||||
} | null
|
||||
|
||||
const searchResults = rawResults?.result ?? { hits: [], total_hits: 0 }
|
||||
const hits = searchResults.hits ?? []
|
||||
serverHits.value = hits
|
||||
serverPings.value = {}
|
||||
pingServerHits(hits)
|
||||
checkServerRunningStates(hits)
|
||||
results.value = {
|
||||
hits: [],
|
||||
total_hits: 0,
|
||||
limit: 1,
|
||||
},
|
||||
total_hits: searchResults.total_hits ?? 0,
|
||||
limit: maxResults.value,
|
||||
offset: 0,
|
||||
}
|
||||
} else {
|
||||
let rawResults = (await get_search_results(requestParams.value)) as {
|
||||
result: SearchResults
|
||||
} | null
|
||||
|
||||
if (!rawResults) {
|
||||
rawResults = {
|
||||
result: {
|
||||
hits: [],
|
||||
total_hits: 0,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (instance.value) {
|
||||
const installedProjectIds = new Set([
|
||||
...newlyInstalled.value,
|
||||
...Object.values(instanceProjects.value ?? {})
|
||||
.filter((x) => x.metadata)
|
||||
.map((x) => x.metadata.project_id),
|
||||
])
|
||||
|
||||
rawResults.result.hits = rawResults.result.hits.map((val) => ({
|
||||
...val,
|
||||
installed: installedProjectIds.has(val.project_id),
|
||||
}))
|
||||
}
|
||||
results.value = rawResults.result
|
||||
}
|
||||
}
|
||||
if (instance.value) {
|
||||
for (const val of rawResults.result.hits) {
|
||||
val.installed =
|
||||
newlyInstalled.value.includes(val.project_id) ||
|
||||
Object.values(instanceProjects.value).some(
|
||||
(x) => x.metadata && x.metadata.project_id === val.project_id,
|
||||
)
|
||||
|
||||
const currentFilterState = JSON.stringify({
|
||||
query: query.value,
|
||||
filters: toRaw(currentFilters.value),
|
||||
sort: toRaw(currentSortType.value),
|
||||
maxResults: maxResults.value,
|
||||
projectTypes: toRaw(projectTypes.value),
|
||||
})
|
||||
|
||||
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
}
|
||||
results.value = rawResults.result
|
||||
|
||||
const currentFilterState = JSON.stringify({
|
||||
query: query.value,
|
||||
filters: currentFilters.value,
|
||||
sort: currentSortType.value,
|
||||
maxResults: maxResults.value,
|
||||
projectTypes: projectTypes.value,
|
||||
})
|
||||
previousFilterState.value = currentFilterState
|
||||
|
||||
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
|
||||
currentPage.value = 1
|
||||
}
|
||||
const persistentParams: LocationQuery = {}
|
||||
|
||||
previousFilterState.value = currentFilterState
|
||||
|
||||
const persistentParams: LocationQuery = {}
|
||||
|
||||
for (const [key, value] of Object.entries(route.query)) {
|
||||
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
|
||||
persistentParams[key] = value
|
||||
for (const [key, value] of Object.entries(route.query)) {
|
||||
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
|
||||
persistentParams[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceHideInstalled.value) {
|
||||
persistentParams.ai = 'true'
|
||||
} else {
|
||||
delete persistentParams.ai
|
||||
}
|
||||
if (instanceHideInstalled.value) {
|
||||
persistentParams.ai = 'true'
|
||||
} else {
|
||||
delete persistentParams.ai
|
||||
}
|
||||
|
||||
const params = {
|
||||
...persistentParams,
|
||||
...createPageParams(),
|
||||
}
|
||||
const params = {
|
||||
...persistentParams,
|
||||
...(isServer ? createServerPageParams() : createPageParams()),
|
||||
}
|
||||
|
||||
breadcrumbs.setContext({
|
||||
name: 'Discover content',
|
||||
link: `/browse/${projectType.value}`,
|
||||
query: params,
|
||||
})
|
||||
await router.replace({ path: route.path, query: params })
|
||||
loading.value = false
|
||||
breadcrumbs.setContext({
|
||||
name: 'Discover content',
|
||||
link: `/browse/${projectType.value}`,
|
||||
query: params,
|
||||
})
|
||||
const queryString = Object.entries(params)
|
||||
.flatMap(([key, value]) => {
|
||||
const values = Array.isArray(value) ? value : [value]
|
||||
return values
|
||||
.filter((v): v is string => v != null)
|
||||
.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`)
|
||||
})
|
||||
.join('&')
|
||||
const newUrl = `${route.path}${queryString ? '?' + queryString : ''}`
|
||||
window.history.replaceState(window.history.state, '', newUrl)
|
||||
} catch (err) {
|
||||
console.error('Error refreshing search:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setPage(newPageNumber: number) {
|
||||
@@ -289,7 +445,7 @@ function clearSearch() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.projectType,
|
||||
() => route.params.projectType as ProjectType,
|
||||
async (newType) => {
|
||||
// Check if the newType is not the same as the current value
|
||||
if (!newType || newType === projectType.value) return
|
||||
@@ -308,8 +464,9 @@ const selectableProjectTypes = computed(() => {
|
||||
|
||||
if (instance.value) {
|
||||
if (
|
||||
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
|
||||
availableGameVersions.value.findIndex((x) => x.version === '1.13')
|
||||
availableGameVersions.value &&
|
||||
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
|
||||
availableGameVersions.value.findIndex((x) => x.version === '1.13')
|
||||
) {
|
||||
dataPacks = true
|
||||
}
|
||||
@@ -338,6 +495,7 @@ const selectableProjectTypes = computed(() => {
|
||||
{ label: 'Resource Packs', href: `/browse/resourcepack` },
|
||||
{ label: 'Data Packs', href: `/browse/datapack`, shown: dataPacks },
|
||||
{ label: 'Shaders', href: `/browse/shader` },
|
||||
{ label: 'Servers', href: `/browse/server`, shown: !instance.value },
|
||||
]
|
||||
|
||||
if (params) {
|
||||
@@ -374,9 +532,27 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const getServerModpackContent = (project: Labrinth.Search.v3.ResultSearchProject) => {
|
||||
const content = project.minecraft_java_server?.content
|
||||
if (content?.kind === 'modpack') {
|
||||
const { project_name, project_icon, project_id } = content
|
||||
if (!project_name) return undefined
|
||||
return {
|
||||
name: project_name,
|
||||
icon: project_icon,
|
||||
onclick: () => {
|
||||
router.push(`/project/${project_id}`)
|
||||
},
|
||||
showCustomModpackTooltip: project_id === project.project_id,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const options = ref(null)
|
||||
const handleRightClick = (event, result) => {
|
||||
options.value.showMenu(event, result, [
|
||||
const handleRightClick = (event: any, result: any) => {
|
||||
// @ts-ignore
|
||||
options.value?.showMenu(event, result, [
|
||||
{
|
||||
name: 'open_link',
|
||||
},
|
||||
@@ -385,7 +561,7 @@ const handleRightClick = (event, result) => {
|
||||
},
|
||||
])
|
||||
}
|
||||
const handleOptionsClick = (args) => {
|
||||
const handleOptionsClick = (args: any) => {
|
||||
switch (args.option) {
|
||||
case 'open_link':
|
||||
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
|
||||
@@ -424,33 +600,54 @@ previousFilterState.value = JSON.stringify({
|
||||
@click.prevent.stop
|
||||
/>
|
||||
</div>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in filters.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="currentFilters"
|
||||
v-model:toggled-groups="toggledGroups"
|
||||
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
|
||||
:provided-filters="instanceFilters"
|
||||
:filter-type="filter"
|
||||
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
|
||||
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
|
||||
content-class="mb-3"
|
||||
inner-panel-class="ml-2 mr-3"
|
||||
:open-by-default="
|
||||
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
|
||||
</template>
|
||||
<template #locked-game_version>
|
||||
{{ formatMessage(messages.gameVersionProvidedByInstance) }}
|
||||
</template>
|
||||
<template #locked-mod_loader>
|
||||
{{ formatMessage(messages.modLoaderProvidedByInstance) }}
|
||||
</template>
|
||||
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
|
||||
</SearchSidebarFilter>
|
||||
<template v-if="projectType === 'server'">
|
||||
<SearchSidebarFilter
|
||||
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
|
||||
:key="`server-filter-${filterType.id}`"
|
||||
v-model:selected-filters="serverCurrentFilters"
|
||||
v-model:toggled-groups="serverToggledGroups"
|
||||
:provided-filters="[]"
|
||||
:filter-type="filterType"
|
||||
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
|
||||
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
|
||||
content-class="mb-3"
|
||||
inner-panel-class="ml-2 mr-3"
|
||||
:open-by-default="true"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-base m-0">{{ filterType.formatted_name }}</h3>
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in filters.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="currentFilters"
|
||||
v-model:toggled-groups="toggledGroups"
|
||||
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
|
||||
:provided-filters="instanceFilters"
|
||||
:filter-type="filter"
|
||||
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
|
||||
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
|
||||
content-class="mb-3"
|
||||
inner-panel-class="ml-2 mr-3"
|
||||
:open-by-default="
|
||||
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
|
||||
</template>
|
||||
<template #locked-game_version>
|
||||
{{ formatMessage(messages.gameVersionProvidedByInstance) }}
|
||||
</template>
|
||||
<template #locked-mod_loader>
|
||||
{{ formatMessage(messages.modLoaderProvidedByInstance) }}
|
||||
</template>
|
||||
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
</Teleport>
|
||||
<div ref="searchWrapper" class="flex flex-col gap-3 p-6">
|
||||
<template v-if="instance">
|
||||
@@ -472,11 +669,17 @@ previousFilterState.value = JSON.stringify({
|
||||
<div class="flex gap-2">
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="currentSortType"
|
||||
:model-value="projectType === 'server' ? serverCurrentSortType : currentSortType"
|
||||
class="max-w-[16rem]"
|
||||
name="Sort by"
|
||||
:options="sortTypes as any"
|
||||
:options="(projectType === 'server' ? serverSortTypes : sortTypes) as any"
|
||||
:display-name="(option: SortType | undefined) => option?.display"
|
||||
@update:model-value="
|
||||
(v: SortType) => {
|
||||
if (projectType === 'server') serverCurrentSortType = v
|
||||
else currentSortType = v
|
||||
}
|
||||
"
|
||||
>
|
||||
<span class="font-semibold text-primary">Sort by: </span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
@@ -494,6 +697,14 @@ previousFilterState.value = JSON.stringify({
|
||||
<Pagination :page="currentPage" :count="pageCount" class="ml-auto" @switch-page="setPage" />
|
||||
</div>
|
||||
<SearchFilterControl
|
||||
v-if="projectType === 'server'"
|
||||
v-model:selected-filters="serverCurrentFilters"
|
||||
:filters="serverFilterTypes"
|
||||
:provided-filters="[]"
|
||||
:overridden-provided-filter-types="[]"
|
||||
/>
|
||||
<SearchFilterControl
|
||||
v-else
|
||||
v-model:selected-filters="currentFilters"
|
||||
:filters="filters.filter((f) => f.display !== 'none')"
|
||||
:provided-filters="instanceFilters"
|
||||
@@ -504,36 +715,107 @@ previousFilterState.value = JSON.stringify({
|
||||
<section v-if="loading" class="offline">
|
||||
<LoadingIndicator />
|
||||
</section>
|
||||
<section v-else-if="offline && results.total_hits === 0" class="offline">
|
||||
<section v-else-if="offline && results?.total_hits === 0" class="offline">
|
||||
You are currently offline. Connect to the internet to browse Modrinth!
|
||||
</section>
|
||||
<section
|
||||
v-else-if="
|
||||
projectType === 'server'
|
||||
? serverHits.length === 0
|
||||
: results && results.hits && results.hits.length === 0
|
||||
"
|
||||
class="offline"
|
||||
>
|
||||
No results found for your query!
|
||||
</section>
|
||||
|
||||
<ProjectCardList v-else :layout="'list'">
|
||||
<SearchCard
|
||||
v-for="result in results.hits"
|
||||
:key="result?.project_id"
|
||||
:project-type="projectType"
|
||||
:project="result"
|
||||
:instance="instance"
|
||||
:categories="[
|
||||
...categories.filter(
|
||||
(cat) =>
|
||||
result?.display_categories.includes(cat.name) && cat.project_type === projectType,
|
||||
),
|
||||
...loaders.filter(
|
||||
(loader) =>
|
||||
result?.display_categories.includes(loader.name) &&
|
||||
loader.supported_project_types?.includes(projectType),
|
||||
),
|
||||
]"
|
||||
:installed="result.installed || newlyInstalled.includes(result.project_id)"
|
||||
@install="
|
||||
(id) => {
|
||||
newlyInstalled.push(id)
|
||||
}
|
||||
"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
|
||||
/>
|
||||
<template v-if="projectType === 'server'">
|
||||
<ProjectCard
|
||||
v-for="project in serverHits"
|
||||
:key="`server-card-${project.project_id}`"
|
||||
:title="project.name"
|
||||
:icon-url="project.icon_url || undefined"
|
||||
:summary="project.summary"
|
||||
:tags="project.categories"
|
||||
:link="`/project/${project.slug ?? project.project_id}`"
|
||||
:server-online-players="project.minecraft_java_server?.ping?.data?.players_online ?? 0"
|
||||
:server-region-code="project.minecraft_server?.country"
|
||||
:server-recent-plays="project.minecraft_java_server?.verified_plays_4w ?? 0"
|
||||
:server-modpack-content="getServerModpackContent(project)"
|
||||
:server-ping="serverPings[project.project_id]"
|
||||
:server-status-online="!!project.minecraft_java_server?.ping?.data"
|
||||
:hide-online-players-label="true"
|
||||
:hide-recent-plays-label="true"
|
||||
layout="list"
|
||||
:max-tags="2"
|
||||
is-server-project
|
||||
exclude-loaders
|
||||
@contextmenu.prevent.stop="
|
||||
(event: any) =>
|
||||
handleRightClick(event, { project_type: 'server', slug: project.slug })
|
||||
"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="'Add server to instance'"
|
||||
@click.stop="() => handleAddServerToInstance(project)"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="runningServerProjects[project.project_id]"
|
||||
color="red"
|
||||
type="outlined"
|
||||
>
|
||||
<button @click="() => handleStopServerProject(project.project_id)">
|
||||
<StopCircleIcon />
|
||||
Stop
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" type="outlined">
|
||||
<button
|
||||
:disabled="
|
||||
(installStore.installingServerProjects as string[]).includes(
|
||||
project.project_id,
|
||||
)
|
||||
"
|
||||
@click="() => handlePlayServerProject(project.project_id)"
|
||||
>
|
||||
<PlayIcon />
|
||||
{{
|
||||
(installStore.installingServerProjects as string[]).includes(
|
||||
project.project_id,
|
||||
)
|
||||
? 'Installing...'
|
||||
: 'Play'
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SearchCard
|
||||
v-for="result in results?.hits ?? []"
|
||||
:key="result?.project_id"
|
||||
:project-type="projectType"
|
||||
:project="result"
|
||||
:instance="instance ?? undefined"
|
||||
:installed="result.installed || newlyInstalled.includes(result.project_id || '')"
|
||||
@install="
|
||||
(id) => {
|
||||
newlyInstalled.push(id)
|
||||
}
|
||||
"
|
||||
@contextmenu.prevent.stop="(event: any) => handleRightClick(event, result)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
||||
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
|
||||
@@ -1,36 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="p-6 pr-2 pb-4"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
||||
>
|
||||
<div v-if="instance">
|
||||
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
|
||||
<ExportModal ref="exportModal" :instance="instance" />
|
||||
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
|
||||
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
|
||||
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
|
||||
<button @click="updateToPlayModal.show()">Update to play modal</button>
|
||||
</ButtonStyled>
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
|
||||
<Avatar
|
||||
:src="icon ? icon : undefined"
|
||||
:alt="instance.name"
|
||||
size="64px"
|
||||
:tint-by="instance.path"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
{{ instance.name }}
|
||||
</template>
|
||||
<template #summary> </template>
|
||||
<template #stats>
|
||||
<div
|
||||
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
|
||||
>
|
||||
<GameIcon class="h-6 w-6 text-secondary" />
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
<TimerIcon class="h-6 w-6 text-secondary" />
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<template v-if="!isServerInstance">
|
||||
<div class="flex items-center gap-2 capitalize font-medium">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
|
||||
<div v-if="linkedProjectV3" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div
|
||||
v-if="linkedProjectV3"
|
||||
class="flex gap-1.5 items-center font-medium text-primary"
|
||||
>
|
||||
Linked to
|
||||
<Avatar
|
||||
:src="linkedProjectV3.icon_url"
|
||||
:alt="linkedProjectV3.name"
|
||||
:tint-by="instance.path"
|
||||
size="24px"
|
||||
/>
|
||||
<router-link
|
||||
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
||||
class="hover:underline text-primary"
|
||||
>
|
||||
{{ linkedProjectV3.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<ServerOnlinePlayers :online="playersOnline ?? 0" :status-online="statusOnline" />
|
||||
|
||||
<div
|
||||
v-if="playersOnline !== undefined && (minecraftServer?.country || ping)"
|
||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
|
||||
<ServerRegion v-if="minecraftServer?.country" :region="minecraftServer?.country" />
|
||||
|
||||
<ServerPing v-if="ping" :ping="ping" />
|
||||
|
||||
<div
|
||||
v-if="modpackContentProjectV3 && (minecraftServer?.country || ping)"
|
||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="linkedProjectV3"
|
||||
class="flex gap-1.5 items-center font-medium text-primary"
|
||||
>
|
||||
Linked to
|
||||
<Avatar
|
||||
:src="linkedProjectV3.icon_url"
|
||||
:alt="linkedProjectV3.name"
|
||||
:tint-by="instance.path"
|
||||
size="24px"
|
||||
/>
|
||||
<router-link
|
||||
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
||||
class="hover:underline text-primary"
|
||||
>
|
||||
{{ linkedProjectV3.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
@@ -63,7 +122,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="playing === false && loading === false"
|
||||
v-else-if="playing === false && loading === false && !isServerInstance"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
@@ -72,6 +131,44 @@
|
||||
Play
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
v-else-if="playing === false && loading === false && isServerInstance"
|
||||
class="joined-buttons"
|
||||
>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<button @click="handlePlayServer()">
|
||||
<PlayIcon />
|
||||
Play
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'join_server',
|
||||
action: () => handlePlayServer(),
|
||||
},
|
||||
{
|
||||
id: 'launch_instance',
|
||||
action: () => startInstance('InstancePage'),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div class="w-0 text-xl relative top-0.5 right-2.5">
|
||||
<DropdownIcon />
|
||||
</div>
|
||||
|
||||
<template #join_server>
|
||||
<PlayIcon />
|
||||
Join server
|
||||
</template>
|
||||
<template #launch_instance>
|
||||
<PlayIcon />
|
||||
Launch instance
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else-if="loading === true && playing === false"
|
||||
color="brand"
|
||||
@@ -79,21 +176,23 @@
|
||||
>
|
||||
<button disabled>Loading...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular>
|
||||
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
|
||||
<ButtonStyled circular size="large">
|
||||
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" type="transparent" circular>
|
||||
<ButtonStyled type="transparent" circular size="large">
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'open-folder',
|
||||
action: () => showProfileInFolder(instance.path),
|
||||
action: () => {
|
||||
if (instance) showProfileInFolder(instance.path)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'export-mrpack',
|
||||
action: () => $refs.exportModal.show(),
|
||||
action: () => exportModal?.show(),
|
||||
},
|
||||
]"
|
||||
>
|
||||
@@ -112,7 +211,11 @@
|
||||
<NavTabs :links="tabs" />
|
||||
</div>
|
||||
<div v-if="!!instance" class="p-6 pt-4">
|
||||
<RouterView v-slot="{ Component }" :key="instance.path">
|
||||
<RouterView
|
||||
v-if="route.path.startsWith('/instance')"
|
||||
v-slot="{ Component }"
|
||||
:key="instance.path"
|
||||
>
|
||||
<template v-if="Component">
|
||||
<Suspense
|
||||
:key="instance.path"
|
||||
@@ -127,6 +230,7 @@
|
||||
:playing="playing"
|
||||
:versions="modrinthVersions"
|
||||
:installed="instance.install_stage !== 'installed'"
|
||||
:is-server-instance="isServerInstance"
|
||||
@play="updatePlayState"
|
||||
@stop="() => stopInstance('InstanceSubpage')"
|
||||
></component>
|
||||
@@ -160,16 +264,17 @@
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
GameIcon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
MoreVerticalIcon,
|
||||
@@ -179,7 +284,6 @@ import {
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
UpdatedIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
@@ -191,6 +295,9 @@ import {
|
||||
injectNotificationManager,
|
||||
LoadingIndicator,
|
||||
OverflowMenu,
|
||||
ServerOnlinePlayers,
|
||||
ServerPing,
|
||||
ServerRegion,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -205,20 +312,21 @@ import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.v
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project, get_version_many } from '@/helpers/cache.js'
|
||||
import { get_project_v3, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { process_listener, profile_listener } from '@/helpers/events'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
|
||||
import { finish_install, get, get_full_path, get_projects, kill, run } from '@/helpers/profile'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import { get_server_status } from '@/helpers/worlds'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { playServerProject } from '@/store/install.js'
|
||||
import { useBreadcrumbs, useLoading } from '@/store/state'
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
dayjs.extend(duration)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const themeStore = useTheming()
|
||||
const route = useRoute()
|
||||
|
||||
const router = useRouter()
|
||||
@@ -232,38 +340,93 @@ window.addEventListener('online', () => {
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
const instance = ref()
|
||||
const modrinthVersions = ref([])
|
||||
const instance = ref<GameInstance>()
|
||||
const modrinthVersions = ref<any[]>([])
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const updateToPlayModal = ref()
|
||||
const exportModal = ref<InstanceType<typeof ExportModal>>()
|
||||
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
|
||||
|
||||
const isServerInstance = ref(false)
|
||||
const hasContent = ref(true)
|
||||
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
|
||||
const modpackContentProjectV3 = ref<Labrinth.Projects.v3.Project | null>(null)
|
||||
const selected = ref<unknown[]>([])
|
||||
|
||||
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
|
||||
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
|
||||
const statusOnline = computed(() => !!javaServerPingData.value)
|
||||
const playersOnline = ref<number | undefined>(undefined)
|
||||
const ping = ref<number | undefined>(undefined)
|
||||
|
||||
async function fetchInstance() {
|
||||
instance.value = await get(route.params.id).catch(handleError)
|
||||
isServerInstance.value = false
|
||||
linkedProjectV3.value = undefined
|
||||
modpackContentProjectV3.value = null
|
||||
modrinthVersions.value = []
|
||||
hasContent.value = true
|
||||
ping.value = undefined
|
||||
|
||||
if (!offline.value && instance.value.linked_data && instance.value.linked_data.project_id) {
|
||||
get_project(instance.value.linked_data.project_id, 'must_revalidate')
|
||||
.catch(handleError)
|
||||
.then((project) => {
|
||||
if (project && project.versions) {
|
||||
get_version_many(project.versions, 'must_revalidate')
|
||||
.catch(handleError)
|
||||
.then((versions) => {
|
||||
modrinthVersions.value = versions.sort(
|
||||
(a, b) => dayjs(b.date_published) - dayjs(a.date_published),
|
||||
)
|
||||
})
|
||||
instance.value = await get(route.params.id as string).catch(handleError)
|
||||
|
||||
if (!offline.value && instance.value?.linked_data && instance.value.linked_data.project_id) {
|
||||
try {
|
||||
linkedProjectV3.value = await get_project_v3(
|
||||
instance.value.linked_data.project_id,
|
||||
'must_revalidate',
|
||||
)
|
||||
|
||||
if (linkedProjectV3.value && linkedProjectV3.value.versions) {
|
||||
const versions = await get_version_many(linkedProjectV3.value.versions, 'must_revalidate')
|
||||
modrinthVersions.value = versions.sort(
|
||||
(a: any, b: any) => dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
|
||||
)
|
||||
if (linkedProjectV3.value?.minecraft_server != null) {
|
||||
isServerInstance.value = true
|
||||
|
||||
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
||||
if (serverAddress) {
|
||||
get_server_status(serverAddress)
|
||||
.then((status) => {
|
||||
if (status.ping != null) {
|
||||
ping.value = status.ping
|
||||
playersOnline.value = status.players?.online
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to ping server ${serverAddress}:`, err)
|
||||
})
|
||||
}
|
||||
|
||||
await fetchModpackContent()
|
||||
const projects = await get_projects(instance.value!.path).catch(() => ({}))
|
||||
hasContent.value = Object.keys(projects).length > 0
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
await updatePlayState()
|
||||
}
|
||||
|
||||
async function updatePlayState() {
|
||||
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
|
||||
async function fetchModpackContent() {
|
||||
modpackContentProjectV3.value = null
|
||||
const versionId = instance.value?.linked_data?.version_id
|
||||
if (!versionId) return
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
const contentVersion = await get_version(versionId, 'bypass')
|
||||
const projectId = contentVersion?.project_id
|
||||
if (projectId) {
|
||||
modpackContentProjectV3.value = await get_project_v3(projectId, 'bypass')
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlayState() {
|
||||
const runningProcesses = await get_by_profile_path(route.params.id as string).catch(handleError)
|
||||
|
||||
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
|
||||
}
|
||||
|
||||
await fetchInstance()
|
||||
@@ -276,61 +439,74 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
|
||||
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
label: 'Content',
|
||||
href: `${basePath.value}`,
|
||||
},
|
||||
{
|
||||
label: 'Worlds',
|
||||
href: `${basePath.value}/worlds`,
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
href: `${basePath.value}/logs`,
|
||||
},
|
||||
])
|
||||
const tabs = computed(() => {
|
||||
const allTabs = []
|
||||
if (!isServerInstance.value || hasContent.value) {
|
||||
allTabs.push({ label: 'Content', href: `${basePath.value}` })
|
||||
}
|
||||
allTabs.push({ label: 'Worlds', href: `${basePath.value}/worlds` })
|
||||
allTabs.push({ label: 'Logs', href: `${basePath.value}/logs` })
|
||||
return allTabs
|
||||
})
|
||||
|
||||
breadcrumbs.setName(
|
||||
'Instance',
|
||||
instance.value.name.length > 40
|
||||
? instance.value.name.substring(0, 40) + '...'
|
||||
: instance.value.name,
|
||||
watch(
|
||||
[isServerInstance, hasContent],
|
||||
() => {
|
||||
if (isServerInstance.value && !hasContent.value && route.path === basePath.value) {
|
||||
router.replace(`${basePath.value}/worlds`)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
breadcrumbs.setContext({
|
||||
name: instance.value.name,
|
||||
link: route.path,
|
||||
query: route.query,
|
||||
})
|
||||
if (instance.value) {
|
||||
breadcrumbs.setName(
|
||||
'Instance',
|
||||
instance.value.name.length > 40
|
||||
? instance.value.name.substring(0, 40) + '...'
|
||||
: instance.value.name,
|
||||
)
|
||||
breadcrumbs.setContext({
|
||||
name: instance.value.name,
|
||||
link: route.path,
|
||||
query: route.query,
|
||||
})
|
||||
}
|
||||
|
||||
const loadingBar = useLoading()
|
||||
|
||||
const options = ref(null)
|
||||
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
|
||||
const startInstance = async (context: string) => {
|
||||
if (!instance.value) return
|
||||
if (updateToPlayModal.value?.hasUpdate) {
|
||||
updateToPlayModal.value.show(instance.value)
|
||||
return
|
||||
}
|
||||
|
||||
const startInstance = async (context) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await run(route.params.id)
|
||||
await run(route.params.id as string)
|
||||
playing.value = true
|
||||
} catch (err) {
|
||||
handleSevereError(err, { profilePath: route.params.id })
|
||||
handleSevereError(err, { profilePath: route.params.id as string })
|
||||
}
|
||||
loading.value = false
|
||||
|
||||
trackEvent('InstanceStart', {
|
||||
trackEvent('InstancePlay', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
|
||||
const stopInstance = async (context) => {
|
||||
const stopInstance = async (context: string) => {
|
||||
playing.value = false
|
||||
await kill(route.params.id).catch(handleError)
|
||||
await kill(route.params.id as string).catch(handleError)
|
||||
|
||||
if (!instance.value) return
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
@@ -338,11 +514,22 @@ const stopInstance = async (context) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handlePlayServer = async () => {
|
||||
if (!instance.value?.linked_data?.project_id) return
|
||||
loading.value = true
|
||||
try {
|
||||
await playServerProject(instance.value.linked_data.project_id)
|
||||
} finally {
|
||||
await updatePlayState()
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const repairInstance = async () => {
|
||||
await finish_install(instance.value).catch(handleError)
|
||||
}
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const handleRightClick = (event: MouseEvent) => {
|
||||
const baseOptions = [
|
||||
{ name: 'add_content' },
|
||||
{ type: 'divider' },
|
||||
@@ -351,7 +538,7 @@ const handleRightClick = (event) => {
|
||||
{ name: 'copy_path' },
|
||||
]
|
||||
|
||||
options.value.showMenu(
|
||||
options.value?.showMenu(
|
||||
event,
|
||||
instance.value,
|
||||
playing.value
|
||||
@@ -372,7 +559,7 @@ const handleRightClick = (event) => {
|
||||
)
|
||||
}
|
||||
|
||||
const handleOptionsClick = async (args) => {
|
||||
const handleOptionsClick = async (args: { option: string; item: unknown }) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
await startInstance('InstancePageContextMenu')
|
||||
@@ -382,27 +569,29 @@ const handleOptionsClick = async (args) => {
|
||||
break
|
||||
case 'add_content':
|
||||
await router.push({
|
||||
path: `/browse/${instance.value.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
path: `/browse/${instance.value?.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: route.params.id },
|
||||
})
|
||||
break
|
||||
case 'edit':
|
||||
await router.push({
|
||||
path: `/instance/${encodeURIComponent(route.params.id)}/options`,
|
||||
path: `/instance/${encodeURIComponent(route.params.id as string)}/options`,
|
||||
})
|
||||
break
|
||||
case 'open_folder':
|
||||
await showProfileInFolder(instance.value.path)
|
||||
if (instance.value) await showProfileInFolder(instance.value.path)
|
||||
break
|
||||
case 'copy_path': {
|
||||
const fullPath = await get_full_path(instance.value.path)
|
||||
await navigator.clipboard.writeText(fullPath)
|
||||
if (instance.value) {
|
||||
const fullPath = await get_full_path(instance.value?.path)
|
||||
await navigator.clipboard.writeText(fullPath)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unlistenProfiles = await profile_listener(async (event) => {
|
||||
const unlistenProfiles = await profile_listener(async (event: any) => {
|
||||
if (event.profile_path_id === route.params.id) {
|
||||
if (event.event === 'removed') {
|
||||
await router.push({
|
||||
@@ -410,24 +599,26 @@ const unlistenProfiles = await profile_listener(async (event) => {
|
||||
})
|
||||
return
|
||||
}
|
||||
instance.value = await get(route.params.id).catch(handleError)
|
||||
instance.value = await get(route.params.id as string).catch(handleError)
|
||||
}
|
||||
})
|
||||
|
||||
const unlistenProcesses = await process_listener((e) => {
|
||||
const unlistenProcesses = await process_listener((e: any) => {
|
||||
if (e.event === 'finished' && e.profile_path_id === route.params.id) {
|
||||
playing.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const icon = computed(() =>
|
||||
instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
|
||||
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
|
||||
)
|
||||
|
||||
const settingsModal = ref()
|
||||
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
|
||||
|
||||
const timePlayed = computed(() => {
|
||||
return instance.value.recent_time_played + instance.value.submitted_time_played
|
||||
return instance.value
|
||||
? instance.value.recent_time_played + instance.value.submitted_time_played
|
||||
: 0
|
||||
})
|
||||
|
||||
const timePlayedHumanized = computed(() => {
|
||||
|
||||
@@ -278,7 +278,12 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
|
||||
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
|
||||
import type {
|
||||
Organization,
|
||||
Project,
|
||||
TeamMember,
|
||||
Version
|
||||
} from '@modrinth/utils'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
@@ -323,6 +328,7 @@ const props = defineProps<{
|
||||
playing: boolean
|
||||
versions: Version[]
|
||||
installed: boolean
|
||||
isServerInstance?: boolean
|
||||
}>()
|
||||
|
||||
type ProjectListEntryAuthor = {
|
||||
@@ -352,6 +358,7 @@ type ProjectListEntry = {
|
||||
const isPackLocked = computed(() => {
|
||||
return props.instance.linked_data && props.instance.linked_data.locked
|
||||
})
|
||||
|
||||
const canUpdatePack = computed(() => {
|
||||
if (!props.instance.linked_data || !props.versions || !props.versions[0]) return false
|
||||
return props.instance.linked_data.version_id !== props.versions[0].id
|
||||
|
||||
@@ -80,9 +80,13 @@
|
||||
@refresh="() => refreshServer((world as ServerWorld).address)"
|
||||
@edit="
|
||||
() =>
|
||||
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
|
||||
isLinkedWorld(world)
|
||||
? undefined
|
||||
: world.type === 'server'
|
||||
? editServerModal?.show(world)
|
||||
: editWorldModal?.show(world)
|
||||
"
|
||||
@delete="() => promptToRemoveWorld(world)"
|
||||
@delete="() => !isLinkedWorld(world) && promptToRemoveWorld(world)"
|
||||
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
|
||||
/>
|
||||
</div>
|
||||
@@ -150,6 +154,7 @@ import {
|
||||
handleDefaultProfileUpdateEvent,
|
||||
hasServerQuickPlaySupport,
|
||||
hasWorldQuickPlaySupport,
|
||||
isLinkedWorld,
|
||||
type ProfileEvent,
|
||||
type ProtocolVersion,
|
||||
refreshServerData,
|
||||
@@ -166,6 +171,7 @@ import {
|
||||
start_join_singleplayer_world,
|
||||
type World,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { playServerProject } from '@/store/install'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
@@ -328,6 +334,9 @@ async function joinWorld(world: World) {
|
||||
startingInstance.value = true
|
||||
worldPlaying.value = world
|
||||
if (world.type === 'server') {
|
||||
if (isLinkedWorld(world)) {
|
||||
playServerProject(world.linked_project_id)
|
||||
}
|
||||
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
|
||||
} else if (world.type === 'singleplayer') {
|
||||
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
|
||||
|
||||
@@ -10,7 +10,7 @@ defineProps({
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay
|
||||
v-if="instances.length > 0"
|
||||
v-if="instances && instances.length > 0"
|
||||
label="Instances"
|
||||
:instances="instances.filter((i) => !i.linked_data)"
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ defineProps({
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay
|
||||
v-if="instances.length > 0"
|
||||
v-if="instances && instances.length > 0"
|
||||
label="Instances"
|
||||
:instances="instances.filter((i) => i.linked_data)"
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,7 @@ onUnmounted(() => {
|
||||
{ label: 'Saved', href: `/library/saved`, shown: false },
|
||||
]"
|
||||
/>
|
||||
<template v-if="instances.length > 0">
|
||||
<template v-if="instances && instances.length > 0">
|
||||
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
|
||||
</template>
|
||||
<div v-else class="no-instance">
|
||||
|
||||
@@ -9,5 +9,5 @@ defineProps({
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
|
||||
<GridDisplay v-if="instances && instances.length > 0" label="Instances" :instances="instances" />
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="gallery">
|
||||
<Card v-for="(image, index) in project.gallery" :key="image.url" class="gallery-item">
|
||||
<Card v-for="(image, index) in filteredGallery" :key="image.url" class="gallery-item">
|
||||
<a @click="expandImage(image, index)">
|
||||
<img :src="image.url" :alt="image.title" class="gallery-image" />
|
||||
</a>
|
||||
@@ -64,14 +64,14 @@
|
||||
<ContractIcon v-else aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="project.gallery.length > 1"
|
||||
v-if="filteredGallery.length > 1"
|
||||
class="previous"
|
||||
icon-only
|
||||
@click="previousImage()"
|
||||
>
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<Button v-if="project.gallery.length > 1" class="next" icon-only @click="nextImage()">
|
||||
<Button v-if="filteredGallery.length > 1" class="next" icon-only @click="nextImage()">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -92,11 +92,13 @@ import {
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, Card } from '@modrinth/ui'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
@@ -104,6 +106,10 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const filteredGallery = computed(() =>
|
||||
props.project.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
|
||||
)
|
||||
|
||||
const expandedGalleryItem = ref(null)
|
||||
const expandedGalleryIndex = ref(0)
|
||||
const zoomedIn = ref(false)
|
||||
@@ -115,10 +121,10 @@ const hideImage = () => {
|
||||
|
||||
const nextImage = () => {
|
||||
expandedGalleryIndex.value++
|
||||
if (expandedGalleryIndex.value >= props.project.gallery.length) {
|
||||
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
|
||||
expandedGalleryIndex.value = 0
|
||||
}
|
||||
expandedGalleryItem.value = props.project.gallery[expandedGalleryIndex.value]
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
|
||||
trackEvent('GalleryImageNext', {
|
||||
project_id: props.project.id,
|
||||
url: expandedGalleryItem.value.url,
|
||||
@@ -128,9 +134,9 @@ const nextImage = () => {
|
||||
const previousImage = () => {
|
||||
expandedGalleryIndex.value--
|
||||
if (expandedGalleryIndex.value < 0) {
|
||||
expandedGalleryIndex.value = props.project.gallery.length - 1
|
||||
expandedGalleryIndex.value = filteredGallery.value.length - 1
|
||||
}
|
||||
expandedGalleryItem.value = props.project.gallery[expandedGalleryIndex.value]
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
|
||||
trackEvent('GalleryImagePrevious', {
|
||||
project_id: props.project.id,
|
||||
url: expandedGalleryItem.value,
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<InstallToPlayModal ref="installToPlayModal" :project="data" />
|
||||
<div v-if="data">
|
||||
<Teleport to="#sidebar-teleport-target">
|
||||
<ProjectSidebarCompatibility
|
||||
v-if="!isServerProject"
|
||||
:project="data"
|
||||
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
|
||||
:v3-metadata="projectV3"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<ProjectSidebarServerInfo
|
||||
v-if="isServerProject"
|
||||
:project-v3="projectV3"
|
||||
:tags="{ loaders: allLoaders, gameVersions: allGameVersions }"
|
||||
:required-content="serverRequiredContent"
|
||||
:recommended-version="serverRecommendedVersion"
|
||||
:supported-versions="serverSupportedVersions"
|
||||
:loaders="serverModpackLoaders"
|
||||
:ping="serverPing"
|
||||
:status-online="serverStatusOnline"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<ProjectSidebarLinks
|
||||
link-target="_blank"
|
||||
:project="data"
|
||||
:project-v3="projectV3"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<ProjectSidebarLinks link-target="_blank" :project="data" class="project-sidebar-section" />
|
||||
<ProjectSidebarCreators
|
||||
:organization="null"
|
||||
:members="members"
|
||||
@@ -16,17 +34,16 @@
|
||||
link-target="_blank"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
<ProjectSidebarTags :project="data" class="project-sidebar-section" />
|
||||
<ProjectSidebarDetails
|
||||
:project="data"
|
||||
:has-versions="versions.length > 0"
|
||||
:link-target="`_blank`"
|
||||
:hide-license="isServerProject"
|
||||
class="project-sidebar-section"
|
||||
/>
|
||||
</Teleport>
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<ButtonStyled v-if="themeStore.featureFlags.server_project_qa">
|
||||
<button @click="installToPlayModal.show()">Install to play modal</button>
|
||||
</ButtonStyled>
|
||||
<InstanceIndicator v-if="instance" :instance="instance" />
|
||||
<template v-if="data">
|
||||
<Teleport
|
||||
@@ -35,7 +52,67 @@
|
||||
>
|
||||
<ProjectBackgroundGradient :project="data" />
|
||||
</Teleport>
|
||||
<ProjectHeader :project="data" @contextmenu.prevent.stop="handleRightClick">
|
||||
<ServerProjectHeader
|
||||
v-if="isServerProject"
|
||||
:project="data"
|
||||
:project-v3="projectV3"
|
||||
:ping="serverPing"
|
||||
@contextmenu.prevent.stop="handleRightClick"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="serverPlaying" size="large" color="red">
|
||||
<button @click="handleStopServer">
|
||||
<StopCircleIcon />
|
||||
Stop
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else size="large" color="brand">
|
||||
<button
|
||||
:disabled="data && installStore.installingServerProjects.includes(data.id)"
|
||||
@click="handleClickPlay"
|
||||
>
|
||||
<PlayIcon />
|
||||
{{
|
||||
data && installStore.installingServerProjects.includes(data.id)
|
||||
? 'Installing...'
|
||||
: 'Play'
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular type="transparent">
|
||||
<button v-tooltip="'Add server to instance'" @click="handleAddServerToInstance">
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular type="transparent">
|
||||
<OverflowMenu
|
||||
:tooltip="`More options`"
|
||||
:options="[
|
||||
{
|
||||
id: 'open-in-browser',
|
||||
link: `https://modrinth.com/project/${data.slug}`,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
|
||||
<template #report> <ReportIcon /> Report </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ServerProjectHeader>
|
||||
<ProjectHeader v-else :project="data" @contextmenu.prevent.stop="handleRightClick">
|
||||
<template #actions>
|
||||
<ButtonStyled size="large" color="brand">
|
||||
<button
|
||||
@@ -103,6 +180,7 @@
|
||||
query: instanceFilters,
|
||||
},
|
||||
subpages: ['version'],
|
||||
shown: projectV3?.minecraft_server == null,
|
||||
},
|
||||
{
|
||||
label: 'Gallery',
|
||||
@@ -112,6 +190,7 @@
|
||||
]"
|
||||
/>
|
||||
<RouterView
|
||||
v-if="route.path.startsWith('/project')"
|
||||
:project="data"
|
||||
:versions="versions"
|
||||
:members="members"
|
||||
@@ -142,7 +221,10 @@ import {
|
||||
GlobeIcon,
|
||||
HeartIcon,
|
||||
MoreVerticalIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
StopCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
@@ -154,22 +236,43 @@ import {
|
||||
ProjectSidebarCreators,
|
||||
ProjectSidebarDetails,
|
||||
ProjectSidebarLinks,
|
||||
ProjectSidebarServerInfo,
|
||||
ProjectSidebarTags,
|
||||
ServerProjectHeader,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||
import NavTabs from '@/components/ui/NavTabs.vue'
|
||||
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
|
||||
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
|
||||
import {
|
||||
get_project,
|
||||
get_project_v3,
|
||||
get_team,
|
||||
get_version,
|
||||
get_version_many,
|
||||
} from '@/helpers/cache.js'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import {
|
||||
get as getInstance,
|
||||
get_projects as getInstanceProjects,
|
||||
kill,
|
||||
list as listInstances,
|
||||
} from '@/helpers/profile'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { get_server_status } from '@/helpers/worlds'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { install as installVersion } from '@/store/install.js'
|
||||
import {
|
||||
getServerAddress,
|
||||
install as installVersion,
|
||||
playServerProject,
|
||||
useInstall,
|
||||
} from '@/store/install.js'
|
||||
import { useTheming } from '@/store/state.js'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
@@ -180,6 +283,7 @@ const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
|
||||
const installStore = useInstall()
|
||||
const installing = ref(false)
|
||||
const data = shallowRef(null)
|
||||
const versions = shallowRef([])
|
||||
@@ -190,8 +294,16 @@ const instanceProjects = ref(null)
|
||||
|
||||
const installed = ref(false)
|
||||
const installedVersion = ref(null)
|
||||
|
||||
const installToPlayModal = ref()
|
||||
const isServerProject = ref(false)
|
||||
const projectV3 = shallowRef(null)
|
||||
const serverRequiredContent = shallowRef(null)
|
||||
const serverRecommendedVersion = shallowRef(null)
|
||||
const serverSupportedVersions = shallowRef([])
|
||||
const serverModpackLoaders = shallowRef([])
|
||||
const serverPing = ref(undefined)
|
||||
const serverStatusOnline = ref(false)
|
||||
const serverInstancePath = ref(null)
|
||||
const serverPlaying = ref(false)
|
||||
|
||||
const instanceFilters = computed(() => {
|
||||
if (!instance.value) {
|
||||
@@ -216,8 +328,41 @@ const [allLoaders, allGameVersions] = await Promise.all([
|
||||
get_game_versions().catch(handleError).then(ref),
|
||||
])
|
||||
|
||||
async function handleClickPlay() {
|
||||
if (!isServerProject.value) return
|
||||
await playServerProject(data.value.id).catch(handleError)
|
||||
await updateServerPlayState()
|
||||
}
|
||||
|
||||
async function updateServerPlayState() {
|
||||
if (!isServerProject.value || !data.value) return
|
||||
const packs = await listInstances()
|
||||
const inst = packs.find((p) => p.linked_data?.project_id === data.value.id)
|
||||
if (inst) {
|
||||
serverInstancePath.value = inst.path
|
||||
const processes = await get_by_profile_path(inst.path).catch(() => [])
|
||||
serverPlaying.value = Array.isArray(processes) && processes.length > 0
|
||||
} else {
|
||||
serverInstancePath.value = null
|
||||
serverPlaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopServer() {
|
||||
if (!serverInstancePath.value) return
|
||||
await kill(serverInstancePath.value).catch(() => {})
|
||||
serverPlaying.value = false
|
||||
}
|
||||
|
||||
function handleAddServerToInstance() {
|
||||
const address = getServerAddress(projectV3.value?.minecraft_java_server)
|
||||
if (!address || !data.value) return
|
||||
installStore.showAddServerToInstanceModal(data.value.title, address)
|
||||
}
|
||||
|
||||
async function fetchProjectData() {
|
||||
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
|
||||
projectV3.value = await get_project_v3(route.params.id, 'must_revalidate').catch(handleError)
|
||||
|
||||
if (!project) {
|
||||
handleError('Error loading project')
|
||||
@@ -245,11 +390,81 @@ async function fetchProjectData() {
|
||||
installedVersion.value = installedFile.metadata.version_id
|
||||
}
|
||||
}
|
||||
|
||||
isServerProject.value = projectV3.value?.minecraft_server != null
|
||||
|
||||
// Ping server for latency
|
||||
const serverAddress = projectV3.value?.minecraft_java_server?.address
|
||||
serverStatusOnline.value = !!projectV3.value?.minecraft_java_server?.ping?.data
|
||||
if (serverAddress) {
|
||||
serverPing.value = undefined
|
||||
get_server_status(serverAddress)
|
||||
.then((status) => {
|
||||
if (status.ping != null) {
|
||||
serverPing.value = status.ping
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to ping server ${serverAddress}:`, err)
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch server sidebar data (modpack version + project)
|
||||
const content = projectV3.value?.minecraft_java_server?.content
|
||||
if (content?.kind === 'modpack' && content.version_id) {
|
||||
const modpackVersion = await get_version(content.version_id, 'bypass').catch(handleError)
|
||||
if (modpackVersion) {
|
||||
serverRecommendedVersion.value = modpackVersion.game_versions?.[0] ?? null
|
||||
serverModpackLoaders.value = modpackVersion.mrpack_loaders ?? []
|
||||
if (modpackVersion.project_id) {
|
||||
const modpackProject = await get_project_v3(
|
||||
modpackVersion.project_id,
|
||||
'must_revalidate',
|
||||
).catch(handleError)
|
||||
if (modpackProject) {
|
||||
serverRequiredContent.value = {
|
||||
name: modpackProject.name,
|
||||
versionNumber: modpackVersion.version_number ?? '',
|
||||
icon: modpackProject.icon_url,
|
||||
onclickName:
|
||||
modpackProject.id !== project.id
|
||||
? () => router.push(`/project/${modpackProject.id}`)
|
||||
: undefined,
|
||||
onclickVersion:
|
||||
modpackProject.id !== project.id
|
||||
? () => router.push(`/project/${modpackProject.id}/version/${modpackVersion.id}`)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (content?.kind === 'vanilla') {
|
||||
serverRecommendedVersion.value = content.recommended_game_version ?? null
|
||||
const supported = content.supported_game_versions ?? []
|
||||
serverSupportedVersions.value = supported.filter((v) => !!v)
|
||||
}
|
||||
|
||||
breadcrumbs.setName('Project', data.value.title)
|
||||
|
||||
await updateServerPlayState()
|
||||
}
|
||||
|
||||
await fetchProjectData()
|
||||
|
||||
const unlistenProcesses = await process_listener((e) => {
|
||||
if (
|
||||
e.event === 'finished' &&
|
||||
serverInstancePath.value &&
|
||||
e.profile_path_id === serverInstancePath.value
|
||||
) {
|
||||
serverPlaying.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcesses()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async () => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AbstractPopupNotificationManager, type PopupNotification } from '@modrinth/ui'
|
||||
import { type Ref, ref } from 'vue'
|
||||
|
||||
export class AppPopupNotificationManager extends AbstractPopupNotificationManager {
|
||||
private readonly state: Ref<PopupNotification[]>
|
||||
|
||||
public constructor() {
|
||||
super()
|
||||
this.state = ref<PopupNotification[]>([])
|
||||
}
|
||||
|
||||
public getNotifications(): PopupNotification[] {
|
||||
return this.state.value
|
||||
}
|
||||
|
||||
protected addNotificationToStorage(notification: PopupNotification): void {
|
||||
this.state.value.push(notification)
|
||||
}
|
||||
|
||||
protected removeNotificationFromStorage(id: string | number): void {
|
||||
const index = this.state.value.findIndex((n) => n.id === id)
|
||||
if (index > -1) {
|
||||
this.state.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
protected clearAllNotificationsFromStorage(): void {
|
||||
this.state.value.splice(0)
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,42 @@ import dayjs from 'dayjs'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project, get_version_many } from '@/helpers/cache.js'
|
||||
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
|
||||
import { get_project, get_project_v3, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import {
|
||||
install_to_existing_profile,
|
||||
create_profile_and_install as packInstall,
|
||||
} from '@/helpers/pack.js'
|
||||
import {
|
||||
add_project_from_version,
|
||||
check_installed,
|
||||
create,
|
||||
edit,
|
||||
edit_icon,
|
||||
get,
|
||||
get_projects,
|
||||
install as installProfile,
|
||||
list,
|
||||
remove_project,
|
||||
} from '@/helpers/profile.js'
|
||||
import {
|
||||
add_server_to_profile,
|
||||
edit_server_in_profile,
|
||||
get_profile_worlds,
|
||||
start_join_server,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import router from '@/routes.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
export const useInstall = defineStore('installStore', {
|
||||
state: () => ({
|
||||
installConfirmModal: null,
|
||||
modInstallModal: null,
|
||||
incompatibilityWarningModal: null,
|
||||
installToPlayModal: null,
|
||||
updateToPlayModal: null,
|
||||
popupNotificationManager: null,
|
||||
installingServerProjects: [],
|
||||
addServerToInstanceModal: null,
|
||||
}),
|
||||
actions: {
|
||||
setInstallConfirmModal(ref) {
|
||||
@@ -38,6 +58,38 @@ export const useInstall = defineStore('installStore', {
|
||||
showModInstallModal(project, versions, onInstall) {
|
||||
this.modInstallModal.show(project, versions, onInstall)
|
||||
},
|
||||
setInstallToPlayModal(ref) {
|
||||
this.installToPlayModal = ref
|
||||
},
|
||||
showInstallToPlayModal(projectV3, modpackVersionId, onInstallComplete) {
|
||||
this.installToPlayModal.show(projectV3, modpackVersionId, onInstallComplete)
|
||||
},
|
||||
setUpdateToPlayModal(ref) {
|
||||
this.updateToPlayModal = ref
|
||||
},
|
||||
showUpdateToPlayModal(instance, activeVersionId, onUpdateComplete) {
|
||||
this.updateToPlayModal.show(instance, activeVersionId, onUpdateComplete)
|
||||
},
|
||||
setPopupNotificationManager(manager) {
|
||||
this.popupNotificationManager = manager
|
||||
},
|
||||
setAddServerToInstanceModal(ref) {
|
||||
this.addServerToInstanceModal = ref
|
||||
},
|
||||
showAddServerToInstanceModal(serverName, serverAddress) {
|
||||
this.addServerToInstanceModal.show(serverName, serverAddress)
|
||||
},
|
||||
startInstallingServer(projectId) {
|
||||
if (!this.installingServerProjects.includes(projectId)) {
|
||||
this.installingServerProjects.push(projectId)
|
||||
}
|
||||
},
|
||||
stopInstallingServer(projectId) {
|
||||
this.installingServerProjects = this.installingServerProjects.filter((id) => id !== projectId)
|
||||
},
|
||||
isServerInstalling(projectId) {
|
||||
return this.installingServerProjects.includes(projectId)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -88,8 +140,9 @@ export const install = async (
|
||||
createInstanceCallback = () => {},
|
||||
) => {
|
||||
const project = await get_project(projectId, 'must_revalidate')
|
||||
const projectV3 = await get_project_v3(projectId, 'must_revalidate')
|
||||
|
||||
if (project.project_type === 'modpack') {
|
||||
if (project.project_type === 'modpack' || projectV3?.minecraft_server != null) {
|
||||
const version = versionId ?? project.versions[project.versions.length - 1]
|
||||
const packs = await list()
|
||||
|
||||
@@ -220,3 +273,297 @@ export const installVersionDependencies = async (profile, version) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server projects that use modpack content use have linked_data.project_id as
|
||||
* the server project id and linked_data.version_id as the modpack content version id
|
||||
*
|
||||
* The modpack content version can be of the same server project, or from a different project
|
||||
*/
|
||||
export const installServerProject = async (serverProjectId) => {
|
||||
const [project, projectV3] = await Promise.all([
|
||||
get_project(serverProjectId, 'must_revalidate'),
|
||||
get_project_v3(serverProjectId, 'must_revalidate'),
|
||||
])
|
||||
|
||||
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
|
||||
|
||||
const content = projectV3?.minecraft_java_server?.content
|
||||
if (!content || content.kind !== 'modpack') return
|
||||
|
||||
const contentVersionId = content.version_id
|
||||
const contentVersion = await get_version(contentVersionId, 'bypass')
|
||||
const contentProjectId = contentVersion.project_id
|
||||
const gameVersion = contentVersion.game_versions?.[0] ?? ''
|
||||
|
||||
const profilePath = await create(
|
||||
project.title,
|
||||
gameVersion,
|
||||
'vanilla',
|
||||
null,
|
||||
project.icon_url,
|
||||
true,
|
||||
{
|
||||
project_id: serverProjectId,
|
||||
version_id: contentVersionId,
|
||||
locked: true,
|
||||
},
|
||||
)
|
||||
|
||||
// Save the icon path before pack install overwrites it
|
||||
const profileBeforeInstall = await get(profilePath)
|
||||
const originalIconPath = profileBeforeInstall?.icon_path ?? null
|
||||
|
||||
await install_to_existing_profile(contentProjectId, contentVersionId, project.title, profilePath)
|
||||
|
||||
// Pack install overwrites name, icon, and linked_data with the content project's values.
|
||||
// Restore them to point to the server project.
|
||||
await edit(profilePath, {
|
||||
name: project.title,
|
||||
linked_data: {
|
||||
project_id: serverProjectId,
|
||||
version_id: contentVersionId,
|
||||
locked: true,
|
||||
},
|
||||
})
|
||||
await edit_icon(profilePath, originalIconPath)
|
||||
|
||||
await syncServerAsWorld(profilePath, project.title, serverAddress, serverProjectId)
|
||||
}
|
||||
|
||||
export const getServerAddress = (javaServer) => {
|
||||
if (!javaServer) return null
|
||||
const { address, port } = javaServer
|
||||
return port !== 25565 ? `${address}:${port}` : address
|
||||
}
|
||||
|
||||
const syncServerAsWorld = async (
|
||||
profilePath,
|
||||
serverName,
|
||||
serverAddress,
|
||||
serverProjectId = null,
|
||||
) => {
|
||||
if (!profilePath || !serverAddress) return
|
||||
try {
|
||||
const worlds = await get_profile_worlds(profilePath)
|
||||
|
||||
if (serverProjectId) {
|
||||
// Check if a linked world for this project already exists
|
||||
const linkedWorld = worlds.find(
|
||||
(w) => w.type === 'server' && w.linked_project_id === serverProjectId,
|
||||
)
|
||||
if (linkedWorld) {
|
||||
// Sync linked world data with project details
|
||||
if (linkedWorld.address !== serverAddress || linkedWorld.name !== serverName) {
|
||||
await edit_server_in_profile(
|
||||
profilePath,
|
||||
linkedWorld.index,
|
||||
serverName,
|
||||
serverAddress,
|
||||
linkedWorld.pack_status,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const alreadyExists = worlds.some((w) => w.type === 'server' && w.address === serverAddress)
|
||||
if (!alreadyExists) {
|
||||
await add_server_to_profile(
|
||||
profilePath,
|
||||
serverName,
|
||||
serverAddress,
|
||||
'prompt',
|
||||
serverProjectId ?? undefined,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add server to instance worlds:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const joinServer = async (profilePath, serverAddress, serverProjectId) => {
|
||||
if (!serverAddress) return
|
||||
await start_join_server(profilePath, serverAddress)
|
||||
}
|
||||
|
||||
const findInstalledInstance = async (projectId) => {
|
||||
const packs = await list()
|
||||
return packs.find((pack) => pack.linked_data?.project_id === projectId) ?? null
|
||||
}
|
||||
|
||||
const createVanillaInstance = async (project, gameVersion, serverAddress) => {
|
||||
const profilePath = await create(
|
||||
project.title,
|
||||
gameVersion,
|
||||
'fabric',
|
||||
'latest',
|
||||
project.icon_url,
|
||||
false,
|
||||
{
|
||||
project_id: project.id,
|
||||
version_id: '',
|
||||
locked: true,
|
||||
},
|
||||
)
|
||||
|
||||
await syncServerAsWorld(profilePath, project.title, serverAddress, project.id)
|
||||
|
||||
return profilePath
|
||||
}
|
||||
|
||||
const updateVanillaGameVersion = async (instance, targetGameVersion) => {
|
||||
if (instance.game_version === targetGameVersion) return
|
||||
|
||||
await edit(instance.path, { game_version: targetGameVersion })
|
||||
await installProfile(instance.path, false)
|
||||
}
|
||||
|
||||
const showModpackInstallSuccess = (installStore, project, serverAddress) => {
|
||||
installStore.popupNotificationManager?.addPopupNotification({
|
||||
title: 'Install complete',
|
||||
text: `${project.name} is installed and ready to play.`,
|
||||
type: 'success',
|
||||
buttons: [
|
||||
...(serverAddress
|
||||
? [
|
||||
{
|
||||
label: 'Launch game',
|
||||
action: async () => {
|
||||
try {
|
||||
await joinServer(
|
||||
project.path,
|
||||
serverAddress,
|
||||
project.linked_data?.project_id ?? null,
|
||||
)
|
||||
} catch (err) {
|
||||
handleSevereError(err, { profilePath: project.path })
|
||||
}
|
||||
},
|
||||
color: 'brand',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Instance',
|
||||
action: () => router.push(`/instance/${encodeURIComponent(project.path)}`),
|
||||
},
|
||||
],
|
||||
autoCloseMs: null,
|
||||
})
|
||||
}
|
||||
|
||||
const showUpdateSuccess = (installStore, instance, serverAddress) => {
|
||||
installStore.popupNotificationManager?.addPopupNotification({
|
||||
title: 'Update complete',
|
||||
text: `${instance.name} has been updated and is ready to play.`,
|
||||
type: 'success',
|
||||
buttons: [
|
||||
...(serverAddress
|
||||
? [
|
||||
{
|
||||
label: 'Launch game',
|
||||
action: async () => {
|
||||
try {
|
||||
if (serverAddress) await start_join_server(instance.path, serverAddress)
|
||||
} catch (err) {
|
||||
handleSevereError(err, { profilePath: instance.path })
|
||||
}
|
||||
},
|
||||
color: 'brand',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Instance',
|
||||
action: () => router.push(`/instance/${encodeURIComponent(instance.path)}`),
|
||||
},
|
||||
],
|
||||
autoCloseMs: null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles logic when clicking "Play" on a server project. This includes:
|
||||
* - Checking if need to install modpack content. If so, opens install to play modal
|
||||
* - Checking if need to update modpack content. If so, open update to play modal
|
||||
* - Checking if need to create instance for vanilla server. If so, creates instance.
|
||||
* - Adding server to worlds list if not already there
|
||||
* - Joining server
|
||||
*/
|
||||
export const playServerProject = async (projectId) => {
|
||||
const installStore = useInstall()
|
||||
|
||||
const [project, projectV3] = await Promise.all([
|
||||
get_project(projectId, 'must_revalidate'),
|
||||
get_project_v3(projectId, 'must_revalidate'),
|
||||
])
|
||||
|
||||
if (projectV3?.minecraft_server == null) {
|
||||
console.warn('playServerProject failed: project is not a server project')
|
||||
}
|
||||
|
||||
const content = projectV3?.minecraft_java_server?.content
|
||||
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
|
||||
const isVanilla = content?.kind === 'vanilla'
|
||||
const isModpack = content?.kind === 'modpack'
|
||||
const modpackVersionId = content?.version_id ?? null
|
||||
const recommendedGameVersion = content?.recommended_game_version
|
||||
|
||||
let instance = await findInstalledInstance(project.id)
|
||||
|
||||
if (isVanilla && !instance) {
|
||||
if (installStore.installingServerProjects.includes(projectId)) return
|
||||
installStore.startInstallingServer(projectId)
|
||||
try {
|
||||
const path = await createVanillaInstance(project, recommendedGameVersion, serverAddress)
|
||||
if (path) {
|
||||
instance = await get(path)
|
||||
showModpackInstallSuccess(installStore, instance, serverAddress)
|
||||
}
|
||||
} finally {
|
||||
installStore.stopInstallingServer(projectId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isModpack && !instance) {
|
||||
installStore.showInstallToPlayModal(projectV3, modpackVersionId, async () => {
|
||||
const newInstance = await findInstalledInstance(project.id)
|
||||
if (!newInstance) return
|
||||
// Ensure the server is in the worlds list after modpack install
|
||||
await syncServerAsWorld(newInstance.path, project.title, serverAddress, project.id)
|
||||
showModpackInstallSuccess(installStore, newInstance, serverAddress)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!instance) return
|
||||
|
||||
await syncServerAsWorld(instance.path, project.title, serverAddress, project.id)
|
||||
|
||||
// Update existing instance if needed
|
||||
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {
|
||||
installStore.showUpdateToPlayModal(instance, modpackVersionId, () => {
|
||||
showUpdateSuccess(installStore, instance, serverAddress)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (isVanilla && instance.game_version !== recommendedGameVersion) {
|
||||
if (installStore.installingServerProjects.includes(projectId)) return
|
||||
installStore.startInstallingServer(projectId)
|
||||
try {
|
||||
await updateVanillaGameVersion(instance, recommendedGameVersion)
|
||||
showUpdateSuccess(installStore, instance, serverAddress)
|
||||
} finally {
|
||||
installStore.stopInstallingServer(projectId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// join server
|
||||
try {
|
||||
await joinServer(instance.path, serverAddress, project.id)
|
||||
} catch (err) {
|
||||
handleSevereError(err, { profilePath: instance.path })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ fn main() {
|
||||
.commands(&[
|
||||
"get_project",
|
||||
"get_project_many",
|
||||
"get_project_v3",
|
||||
"get_project_v3_many",
|
||||
"get_version",
|
||||
"get_version_many",
|
||||
"get_user",
|
||||
@@ -39,6 +41,8 @@ fn main() {
|
||||
"get_organization_many",
|
||||
"get_search_results",
|
||||
"get_search_results_many",
|
||||
"get_search_results_v3",
|
||||
"get_search_results_v3_many",
|
||||
"purge_cache_types",
|
||||
])
|
||||
.default_permission(
|
||||
|
||||
@@ -30,11 +30,13 @@ macro_rules! impl_cache_methods {
|
||||
|
||||
impl_cache_methods!(
|
||||
(Project, Project),
|
||||
(ProjectV3, ProjectV3),
|
||||
(Version, Version),
|
||||
(User, User),
|
||||
(Team, Vec<TeamMember>),
|
||||
(Organization, Organization),
|
||||
(SearchResults, SearchResults)
|
||||
(SearchResults, SearchResults),
|
||||
(SearchResultsV3, SearchResultsV3)
|
||||
);
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
@@ -42,6 +44,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_project,
|
||||
get_project_many,
|
||||
get_project_v3,
|
||||
get_project_v3_many,
|
||||
get_version,
|
||||
get_version_many,
|
||||
get_user,
|
||||
@@ -52,6 +56,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
get_organization_many,
|
||||
get_search_results,
|
||||
get_search_results_many,
|
||||
get_search_results_v3,
|
||||
get_search_results_v3_many,
|
||||
purge_cache_types,
|
||||
])
|
||||
.build()
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theseus::prelude::*;
|
||||
use theseus::profile::QuickPlayType;
|
||||
use theseus::server_address::ServerAddress;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("profile")
|
||||
@@ -250,8 +251,15 @@ pub async fn profile_get_pack_export_candidates(
|
||||
// for the actual Child in the state.
|
||||
// invoke('plugin:profile|profile_run', path)
|
||||
#[tauri::command]
|
||||
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
||||
let process = profile::run(path, QuickPlayType::None).await?;
|
||||
pub async fn profile_run(
|
||||
path: &str,
|
||||
server_address: Option<String>,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let quick_play = match server_address {
|
||||
Some(addr) => QuickPlayType::Server(ServerAddress::Unresolved(addr)),
|
||||
None => QuickPlayType::None,
|
||||
};
|
||||
let process = profile::run(path, quick_play).await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub async fn profile_create(
|
||||
loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
|
||||
icon: Option<String>, // the icon for the profile
|
||||
skip_install: Option<bool>,
|
||||
linked_data: Option<LinkedData>,
|
||||
) -> Result<String> {
|
||||
let res = profile::create::profile_create(
|
||||
name,
|
||||
@@ -27,7 +28,7 @@ pub async fn profile_create(
|
||||
modloader,
|
||||
loader_version,
|
||||
icon,
|
||||
None,
|
||||
linked_data,
|
||||
skip_install,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -151,11 +151,18 @@ pub async fn add_server_to_profile(
|
||||
name: String,
|
||||
address: String,
|
||||
pack_status: ServerPackStatus,
|
||||
linked_project_id: Option<String>,
|
||||
) -> Result<usize> {
|
||||
let path = get_full_path(path).await?;
|
||||
Ok(
|
||||
worlds::add_server_to_profile(&path, name, address, pack_status)
|
||||
.await?,
|
||||
worlds::add_server_to_profile(
|
||||
&path,
|
||||
name,
|
||||
address,
|
||||
pack_status,
|
||||
linked_project_id,
|
||||
)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: bold;
|
||||
font-size: 2rem;
|
||||
}
|
||||
@@ -99,7 +99,7 @@
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
|
||||
// Same styling as h3
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.17rem;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -381,7 +381,7 @@
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,7 +1113,7 @@ svg.inline-svg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-12);
|
||||
padding: var(--gap-16) var(--gap-24);
|
||||
padding: var(--gap-16);
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-18);
|
||||
|
||||
@@ -380,18 +380,18 @@ a {
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
input {
|
||||
|
||||
@@ -114,28 +114,19 @@ iframe[id^='google_ads_iframe'] {
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui::before {
|
||||
background: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui::before,
|
||||
#qc-cmp2-ui::after {
|
||||
background: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui button[mode='primary'] {
|
||||
#qc-cmp2-ui button[mode='primary'],
|
||||
#qc-cmp2-ui button[mode='secondary'] {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
border-radius: var(--radius-lg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
#qc-cmp2-ui button[mode='secondary'] {
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-base);
|
||||
border-radius: var(--radius-lg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
#qc-cmp2-ui button[mode='link'] {
|
||||
color: var(--color-link);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="filteredLinks.length > 1"
|
||||
ref="scrollContainer"
|
||||
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
:class="{ 'card-shadow': mode === 'navigation' }"
|
||||
@@ -299,7 +300,14 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(() => props.links, updateActiveTab, { deep: true })
|
||||
watch(
|
||||
() => props.links,
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateActiveTab()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="!noHeader" class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast">
|
||||
Minecraft versions <span class="text-red">*</span>
|
||||
</span>
|
||||
@@ -45,6 +45,7 @@
|
||||
versionType === 'all' && !group.isReleaseGroup ? 'w-max' : 'w-16',
|
||||
modelValue.includes(version) ? '!text-contrast' : '',
|
||||
]"
|
||||
:disabled="disabled"
|
||||
@click="() => handleToggleVersion(version)"
|
||||
@blur="
|
||||
() => {
|
||||
@@ -76,6 +77,8 @@ type GameVersion = Labrinth.Tags.v2.GameVersion
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
gameVersions: Labrinth.Tags.v2.GameVersion[]
|
||||
noHeader?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -158,7 +161,7 @@ function groupVersions(gameVersions: GameVersion[]) {
|
||||
|
||||
const groups: Record<string, string[]> = {}
|
||||
|
||||
let currentGroupKey
|
||||
let currentGroupKey: string
|
||||
|
||||
gameVersions.forEach((gameVersion) => {
|
||||
if (gameVersion.version_type === 'release') {
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="min-w-md flex max-w-md flex-col gap-3">
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
projectType === 'server'
|
||||
? formatMessage(messages.serverProjectTitle)
|
||||
: formatMessage(messages.title)
|
||||
"
|
||||
:max-width="'550px'"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<CreateLimitAlert v-model="hasHitLimit" type="project" />
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div v-if="showTypeSelector" class="flex flex-col gap-2.5">
|
||||
<label for="project-type">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.typeLabel) }}
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
id="project-type"
|
||||
v-model="projectType"
|
||||
name="project-type"
|
||||
:options="projectTypeOptions"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="name">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
@@ -19,32 +42,46 @@
|
||||
@update:model-value="updatedName()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="slug">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.urlLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="text-input-wrapper">
|
||||
<label for="slug" class="flex flex-col gap-2.5">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.urlLabel) }}
|
||||
</span>
|
||||
<div class="text-input-wrapper !w-full">
|
||||
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
|
||||
<StyledInput
|
||||
id="slug"
|
||||
v-model="slug"
|
||||
:maxlength="64"
|
||||
class="w-full"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:disabled="hasHitLimit"
|
||||
@update:model-value="manualSlug = true"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="owner">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.ownerLabel) }}
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
id="owner"
|
||||
v-model="owner"
|
||||
name="owner"
|
||||
:options="[userOption, ...ownerOptions]"
|
||||
searchable
|
||||
:disabled="hasHitLimit"
|
||||
show-icon-in-selected
|
||||
/>
|
||||
<span>{{ formatMessage(messages.ownerDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="visibility" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(commonMessages.visibilityLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
|
||||
</label>
|
||||
<Chips
|
||||
id="visibility"
|
||||
@@ -54,14 +91,13 @@
|
||||
:capitalize="false"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="additional-information" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.summaryLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>{{ formatMessage(messages.summaryDescription) }}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="additional-information"
|
||||
@@ -71,8 +107,9 @@
|
||||
:placeholder="formatMessage(messages.summaryPlaceholder)"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
<span>{{ formatMessage(messages.summaryDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<ButtonStyled class="w-24">
|
||||
<button @click="cancel">
|
||||
<XIcon aria-hidden="true" />
|
||||
@@ -80,7 +117,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand" class="w-32">
|
||||
<button :disabled="hasHitLimit" @click="createProject">
|
||||
<button v-tooltip="missingFieldsTooltip" :disabled="disableCreate" @click="createProject">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createProject) }}
|
||||
</button>
|
||||
@@ -90,30 +127,77 @@
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, defineAsyncComponent, h } from 'vue'
|
||||
|
||||
import CreateLimitAlert from './CreateLimitAlert.vue'
|
||||
|
||||
type ProjectTypes = 'server' | 'project'
|
||||
interface VisibilityOption {
|
||||
actual: Labrinth.Projects.v2.ProjectStatus
|
||||
display: string
|
||||
}
|
||||
interface ShowOptions {
|
||||
type?: 'server' | 'project'
|
||||
showTypeSelector?: boolean
|
||||
}
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
|
||||
const auth = (await useAuth()) as Ref<{
|
||||
user: { id: string; username: string; avatar_url: string } | null
|
||||
}>
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'create.project.title',
|
||||
defaultMessage: 'Creating a project',
|
||||
},
|
||||
serverProjectTitle: {
|
||||
id: 'create.project.server-project-title',
|
||||
defaultMessage: 'Creating a server project',
|
||||
},
|
||||
typeLabel: {
|
||||
id: 'create.project.type-label',
|
||||
defaultMessage: 'Type',
|
||||
},
|
||||
typeProject: {
|
||||
id: 'create.project.type-project',
|
||||
defaultMessage: 'Project',
|
||||
},
|
||||
typeServer: {
|
||||
id: 'create.project.type-server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
ownerLabel: {
|
||||
id: 'create.project.owner-label',
|
||||
defaultMessage: 'Owner',
|
||||
},
|
||||
ownerDescription: {
|
||||
id: 'create.project.owner-description',
|
||||
defaultMessage: `Set the project owner as yourself or an organization you're a member of.`,
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'create.project.name-label',
|
||||
defaultMessage: 'Name',
|
||||
@@ -146,6 +230,10 @@ const messages = defineMessages({
|
||||
id: 'create.project.create-project',
|
||||
defaultMessage: 'Create project',
|
||||
},
|
||||
createServerProject: {
|
||||
id: 'create.project.create-server-project',
|
||||
defaultMessage: 'Create server',
|
||||
},
|
||||
visibilityPublic: {
|
||||
id: 'create.project.visibility-public',
|
||||
defaultMessage: 'Public',
|
||||
@@ -158,24 +246,39 @@ const messages = defineMessages({
|
||||
id: 'create.project.visibility-private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
organizationId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
missingFieldsTooltip: {
|
||||
id: 'create.project.missing-fields-tooltip',
|
||||
defaultMessage: 'Missing fields: {fields}',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
const props = defineProps<{
|
||||
organizationId?: string | null
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const hasHitLimit = ref(false)
|
||||
|
||||
const name = ref('')
|
||||
const slug = ref('')
|
||||
const description = ref('')
|
||||
const manualSlug = ref(false)
|
||||
const visibilities = ref([
|
||||
const projectType = ref<ProjectTypes>('project')
|
||||
const showTypeSelector = ref(false)
|
||||
const projectTypeOptions = computed<ComboboxOption<ProjectTypes>[]>(() => [
|
||||
{
|
||||
value: 'project',
|
||||
label: formatMessage(messages.typeProject),
|
||||
},
|
||||
{
|
||||
value: 'server',
|
||||
label: formatMessage(messages.typeServer),
|
||||
},
|
||||
])
|
||||
const ownerOptions = ref<ComboboxOption<string>[]>([])
|
||||
const owner = ref<string | undefined>('self')
|
||||
const organizations = ref<Labrinth.Projects.v3.Organization[]>([])
|
||||
const visibilities = ref<VisibilityOption[]>([
|
||||
{
|
||||
actual: 'approved',
|
||||
display: formatMessage(messages.visibilityPublic),
|
||||
@@ -189,10 +292,84 @@ const visibilities = ref([
|
||||
display: formatMessage(messages.visibilityPrivate),
|
||||
},
|
||||
])
|
||||
const visibility = ref(visibilities.value[0])
|
||||
const visibility = ref<VisibilityOption>(visibilities.value[0])
|
||||
|
||||
const disableCreate = computed(() => {
|
||||
if (hasHitLimit.value) return true
|
||||
if (!name.value.trim() || !slug.value.trim()) return true
|
||||
if (description.value.trim().length < 3) return true
|
||||
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
|
||||
return true
|
||||
return false
|
||||
})
|
||||
|
||||
const missingFieldsTooltip = computed(() => {
|
||||
const missingFields = []
|
||||
if (!name.value.trim()) missingFields.push(formatMessage(messages.nameLabel))
|
||||
if (!slug.value.trim()) missingFields.push(formatMessage(messages.urlLabel))
|
||||
if (description.value.trim().length < 3) missingFields.push(formatMessage(messages.summaryLabel))
|
||||
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
|
||||
missingFields.push(formatMessage(messages.ownerLabel))
|
||||
|
||||
if (missingFields.length === 0) return ''
|
||||
return formatMessage(messages.missingFieldsTooltip, {
|
||||
fields: missingFields.join(', '),
|
||||
})
|
||||
})
|
||||
|
||||
const cancel = () => {
|
||||
modal.value.hide()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const userOption = computed(() => ({
|
||||
value: 'self',
|
||||
label: auth.value.user?.username || 'Unknown user',
|
||||
icon: auth.value.user?.avatar_url
|
||||
? defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () =>
|
||||
h('img', {
|
||||
src: auth.value.user?.avatar_url,
|
||||
alt: 'User Avatar',
|
||||
class: 'h-5 w-5 rounded',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
async function fetchOrganizations() {
|
||||
if (!auth.value.user?.id) return
|
||||
|
||||
try {
|
||||
const orgs = (await useBaseFetch(`user/${auth.value.user.id}/organizations`, {
|
||||
apiVersion: 3,
|
||||
})) as Labrinth.Projects.v3.Organization[]
|
||||
|
||||
organizations.value = orgs || []
|
||||
|
||||
ownerOptions.value = organizations.value.map((org) => ({
|
||||
value: org.id,
|
||||
label: org.name,
|
||||
icon: org.icon_url
|
||||
? defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () =>
|
||||
h('img', {
|
||||
src: org.icon_url,
|
||||
alt: `${org.name} Icon`,
|
||||
class: 'h-5 w-5 rounded',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
}))
|
||||
if (props.organizationId) owner.value = props.organizationId
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch organizations:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
@@ -200,9 +377,7 @@ async function createProject() {
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const projectData = {
|
||||
const projectData: Labrinth.Projects.v2.CreateProjectBase = {
|
||||
title: name.value.trim(),
|
||||
project_type: 'mod',
|
||||
slug: slug.value,
|
||||
@@ -212,8 +387,8 @@ async function createProject() {
|
||||
initial_versions: [],
|
||||
team_members: [
|
||||
{
|
||||
user_id: auth.value.user.id,
|
||||
name: auth.value.user.username,
|
||||
user_id: auth.value.user?.id,
|
||||
name: auth.value.user?.username,
|
||||
role: 'Owner',
|
||||
},
|
||||
],
|
||||
@@ -224,45 +399,71 @@ async function createProject() {
|
||||
is_draft: true,
|
||||
}
|
||||
|
||||
if (props.organizationId) {
|
||||
projectData.organization_id = props.organizationId
|
||||
}
|
||||
|
||||
formData.append('data', JSON.stringify(projectData))
|
||||
|
||||
try {
|
||||
await useBaseFetch('project', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Disposition': formData,
|
||||
},
|
||||
})
|
||||
let createdProjectId: string | undefined
|
||||
|
||||
modal.value.hide()
|
||||
if (projectType.value === 'server') {
|
||||
const result = await labrinth.projects_v3.createServerProject({
|
||||
base: {
|
||||
name: projectData.title,
|
||||
slug: projectData.slug,
|
||||
summary: projectData.description,
|
||||
description: '',
|
||||
requested_status: projectData.requested_status,
|
||||
organization_id: owner.value !== 'self' ? owner.value : undefined,
|
||||
},
|
||||
minecraft_server: {
|
||||
// empty component
|
||||
},
|
||||
minecraft_java_server: {
|
||||
address: '',
|
||||
port: 25565,
|
||||
},
|
||||
minecraft_bedrock_server: {
|
||||
address: '',
|
||||
port: 19132,
|
||||
},
|
||||
})
|
||||
createdProjectId = result.id
|
||||
} else {
|
||||
const result = (await useBaseFetch('project', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Disposition': formData as unknown as string,
|
||||
},
|
||||
})) as Labrinth.Projects.v3.Project
|
||||
createdProjectId = result.id
|
||||
console.log(createdProjectId)
|
||||
}
|
||||
|
||||
modal.value?.hide()
|
||||
await router.push(`/project/${slug.value}/settings`)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
const error = err as { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
text: error.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
function show(event) {
|
||||
async function show(event?: MouseEvent, options?: ShowOptions) {
|
||||
name.value = ''
|
||||
slug.value = ''
|
||||
description.value = ''
|
||||
manualSlug.value = false
|
||||
modal.value.show(event)
|
||||
owner.value = 'self'
|
||||
projectType.value = options?.type ?? 'project'
|
||||
showTypeSelector.value = options?.showTypeSelector ?? false
|
||||
await fetchOrganizations()
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
|
||||
function updatedName() {
|
||||
if (!manualSlug.value) {
|
||||
slug.value = name.value
|
||||
|
||||
@@ -100,6 +100,7 @@ interface Tags {
|
||||
|
||||
interface Props {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
projectV3: Labrinth.Projects.v3.Project
|
||||
versions?: Labrinth.Versions.v2.Version[]
|
||||
currentMember?: Labrinth.Projects.v3.TeamMember | null
|
||||
collapsed?: boolean
|
||||
@@ -172,6 +173,7 @@ const emit = defineEmits<{
|
||||
|
||||
const nagContext = computed<NagContext>(() => ({
|
||||
project: props.project,
|
||||
projectV3: props.projectV3,
|
||||
versions: props.versions,
|
||||
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
|
||||
currentRoute: props.routeName,
|
||||
|
||||
@@ -1654,7 +1654,7 @@ function shouldShowStage(stage: Stage): boolean {
|
||||
|
||||
function shouldShowAction(action: Action): boolean {
|
||||
if (typeof action.shouldShow === 'function') {
|
||||
return action.shouldShow(projectV2.value)
|
||||
return action.shouldShow(projectV2.value, projectV3.value)
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -1663,7 +1663,7 @@ function shouldShowAction(action: Action): boolean {
|
||||
function getVisibleDropdownOptions(action: DropdownAction) {
|
||||
return action.options.filter((option) => {
|
||||
if (typeof option.shouldShow === 'function') {
|
||||
return option.shouldShow(projectV2.value)
|
||||
return option.shouldShow(projectV2.value, projectV3.value)
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -1672,7 +1672,7 @@ function getVisibleDropdownOptions(action: DropdownAction) {
|
||||
function getVisibleMultiSelectOptions(action: MultiSelectChipsAction) {
|
||||
return action.options.filter((option) => {
|
||||
if (typeof option.shouldShow === 'function') {
|
||||
return option.shouldShow(projectV2.value)
|
||||
return option.shouldShow(projectV2.value, projectV3.value)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xl font-semibold text-contrast">Server compatibility</div>
|
||||
<div v-if="!content" class="text-sm text-secondary">
|
||||
Select whether your server is vanilla or modded and which versions it supports. The
|
||||
Modrinth App uses this when a player joins.
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="content.kind === 'vanilla'" class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
|
||||
>
|
||||
<BoxIcon class="h-4 w-4 shrink-0 text-secondary" />
|
||||
</div>
|
||||
|
||||
Vanilla server
|
||||
</div>
|
||||
<div
|
||||
v-else-if="content.kind === 'modpack' && !usingCustomMrpack"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
|
||||
>
|
||||
<PackageIcon class="h-4 w-4 shrink-0 text-secondary" />
|
||||
</div>
|
||||
Published modpack
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
|
||||
>
|
||||
<PackagePlusIcon class="h-4 w-4 shrink-0 text-secondary" />
|
||||
</div>
|
||||
Custom modpack
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled v-if="content" type="outlined">
|
||||
<button class="!border-[1px]" @click="handleSwitchCompatibility">
|
||||
<ArrowLeftRightIcon />
|
||||
Switch type
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button @click="handleSetCompatibility">
|
||||
<ComponentIcon />
|
||||
Set compatibility
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="content"
|
||||
class="flex justify-between rounded-2xl border border-solid border-surface-5 p-4"
|
||||
>
|
||||
<!-- kind = vanilla -->
|
||||
<div
|
||||
v-if="content?.kind === 'vanilla'"
|
||||
class="flex flex-col items-start justify-between gap-2.5"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-medium text-secondary">Recommended version</div>
|
||||
<div class="text-2xl font-semibold text-contrast">
|
||||
{{ content.recommended_game_version ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-medium text-secondary">Supported versions</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<TagItem
|
||||
v-for="v in formatVersionsForDisplay(
|
||||
content.supported_game_versions,
|
||||
tags.gameVersions,
|
||||
)"
|
||||
:key="v"
|
||||
>
|
||||
{{ v }}
|
||||
</TagItem>
|
||||
<div v-if="!content.supported_game_versions.length">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- kind = modpack -->
|
||||
<div
|
||||
v-if="content?.kind === 'modpack' && modpackProject"
|
||||
class="flex w-full max-w-[500px] flex-col items-start justify-between gap-4"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="font-medium text-secondary">Required modpack</div>
|
||||
<div class="w-fullitems-center flex gap-3 rounded-2xl bg-surface-1 p-3">
|
||||
<Avatar
|
||||
v-if="!usingCustomMrpack"
|
||||
:src="modpackProject.icon_url"
|
||||
size="56px"
|
||||
:tint-by="modpackProject.name"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-solid border-surface-5 bg-surface-3"
|
||||
>
|
||||
<PackagePlusIcon class="h-10 w-10 shrink-0 text-secondary" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center gap-1">
|
||||
<div class="font-semibold text-contrast">
|
||||
<NuxtLink
|
||||
v-if="!usingCustomMrpack"
|
||||
:to="`/modpack/${modpackProject.slug}`"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ modpackProject.name }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ modpackFileName }}</span>
|
||||
</div>
|
||||
<div class="flex h-6 items-center gap-1.5 text-secondary">
|
||||
<NuxtLink
|
||||
v-if="modpackOrg?.name"
|
||||
:to="`/organization/${modpackOrg.slug}`"
|
||||
class="flex items-center gap-1 hover:underline"
|
||||
>
|
||||
<Avatar
|
||||
v-if="modpackOrg?.icon_url"
|
||||
:src="modpackOrg.icon_url"
|
||||
size="24px"
|
||||
class="rounded-2xl"
|
||||
/>
|
||||
{{ modpackOrg.name }}
|
||||
</NuxtLink>
|
||||
<div
|
||||
v-if="modpackOrg?.name && modpackVersion"
|
||||
class="h-1.5 w-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
<NuxtLink
|
||||
v-if="modpackVersion && !usingCustomMrpack"
|
||||
:to="`/modpack/${modpackProject?.slug}/version/${modpackVersion.id}`"
|
||||
class="hover:underline"
|
||||
>
|
||||
v{{ modpackVersion.version_number }}
|
||||
</NuxtLink>
|
||||
<div v-else-if="modpackVersion" class="flex items-center gap-1.5">
|
||||
<div>v{{ modpackVersion.version_number }}</div>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-surface-5"></div>
|
||||
<a
|
||||
v-if="modpackVersion.files?.length"
|
||||
:href="
|
||||
modpackVersion.files.find((f) => f.primary)?.url ??
|
||||
modpackVersion.files[0]?.url
|
||||
"
|
||||
class="flex items-center gap-0.5 hover:underline"
|
||||
>
|
||||
<DownloadIcon />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="modpackVersion" class="flex flex-col gap-2">
|
||||
<div class="font-medium text-secondary">Required version</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<TagItem v-for="gv in modpackVersion.game_versions" :key="gv">
|
||||
{{ gv }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="loader in modpackVersion.mrpack_loaders"
|
||||
:key="loader"
|
||||
:style="`--_color: var(--color-platform-${loader})`"
|
||||
>
|
||||
<component
|
||||
:is="getLoaderIcon(loader)"
|
||||
v-if="getLoaderIcon(loader)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<FormattedTag :tag="loader" enforce-type="loader" />
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="content">
|
||||
<button class="!w-full !max-w-[160px]" @click="handleUpdateContent">
|
||||
<RefreshCwIcon />
|
||||
Update
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<ServerCompatibilityModal ref="serverCompatibilityModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
BoxIcon,
|
||||
ComponentIcon,
|
||||
DownloadIcon,
|
||||
getLoaderIcon,
|
||||
PackageIcon,
|
||||
PackagePlusIcon,
|
||||
RefreshCwIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
FormattedTag,
|
||||
injectModrinthClient,
|
||||
injectProjectPageContext,
|
||||
TagItem,
|
||||
} from '@modrinth/ui'
|
||||
import { formatVersionsForDisplay } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
|
||||
import ServerCompatibilityModal from './ServerCompatibilityModal/ServerCompatibilityModal.vue'
|
||||
|
||||
const serverCompatibilityModal = useTemplateRef<InstanceType<typeof ServerCompatibilityModal>>(
|
||||
'serverCompatibilityModal',
|
||||
)
|
||||
|
||||
const { projectV3 } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const content = computed(() => {
|
||||
if (!projectV3.value) return null
|
||||
|
||||
const content = projectV3.value.minecraft_java_server?.content
|
||||
if (!content) return null
|
||||
|
||||
if (content?.kind === 'vanilla' && !content.recommended_game_version) {
|
||||
return null
|
||||
}
|
||||
|
||||
return content
|
||||
})
|
||||
|
||||
const modpackVersionId = computed(() => {
|
||||
if (content.value?.kind === 'modpack') return content.value.version_id
|
||||
return null
|
||||
})
|
||||
|
||||
const { data: modpackVersion } = useQuery({
|
||||
queryKey: computed(() => ['versions', 'detail', modpackVersionId.value]),
|
||||
queryFn: () => labrinth.versions_v3.getVersion(modpackVersionId.value!),
|
||||
enabled: computed(() => !!modpackVersionId.value),
|
||||
})
|
||||
|
||||
const modpackProjectId = computed(() => modpackVersion.value?.project_id ?? null)
|
||||
|
||||
const { data: modpackProject } = useQuery({
|
||||
queryKey: computed(() => ['project', 'v3', modpackProjectId.value]),
|
||||
queryFn: () => labrinth.projects_v3.get(modpackProjectId.value!),
|
||||
enabled: computed(() => !!modpackProjectId.value),
|
||||
})
|
||||
|
||||
const { data: modpackOrg } = useQuery({
|
||||
queryKey: computed(() => ['project', 'org', modpackProjectId.value]),
|
||||
queryFn: () => labrinth.projects_v3.getOrganization(modpackProjectId.value!),
|
||||
enabled: computed(() => !!modpackProjectId.value && !!modpackProject.value?.organization),
|
||||
})
|
||||
|
||||
const usingCustomMrpack = computed(() => modpackVersion.value?.project_id === projectV3.value?.id)
|
||||
|
||||
const modpackFileName = computed(() => {
|
||||
if (!modpackVersion.value?.files?.length) return null
|
||||
const primary = modpackVersion.value.files.find((f) => f.primary)
|
||||
return (primary ?? modpackVersion.value.files[0]).filename
|
||||
})
|
||||
|
||||
function handleSetCompatibility() {
|
||||
serverCompatibilityModal.value?.show()
|
||||
}
|
||||
|
||||
function handleSwitchCompatibility() {
|
||||
serverCompatibilityModal.value?.show({ isSwitchingCompatibilityType: true })
|
||||
}
|
||||
|
||||
function handleUpdateContent() {
|
||||
if (!content.value?.kind) return
|
||||
|
||||
switch (content.value.kind) {
|
||||
case 'vanilla':
|
||||
serverCompatibilityModal.value?.show({ updateContentKind: 'vanilla' })
|
||||
break
|
||||
case 'modpack':
|
||||
if (usingCustomMrpack.value) {
|
||||
serverCompatibilityModal.value?.show({ updateContentKind: 'custom-modpack' })
|
||||
} else {
|
||||
serverCompatibilityModal.value?.show({ updateContentKind: 'published-modpack' })
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<Admonition v-if="isSwitchingCompatibilityType" type="critical" header="Data loss warning">
|
||||
Changing the compatibility type will reset your previous compatibility settings and redistribute
|
||||
the new settings to users in the Modrinth App.
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Admonition } from '@modrinth/ui'
|
||||
|
||||
import { injectServerCompatibilityContext } from '../../../../providers/manage-server-compatibility-modal'
|
||||
|
||||
const { isSwitchingCompatibilityType } = injectServerCompatibilityContext()
|
||||
</script>
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<MultiStageModal
|
||||
ref="modal"
|
||||
:stages="ctx.stageConfigs"
|
||||
:context="ctx"
|
||||
:fade="ctx.isSwitchingCompatibilityType.value ? 'danger' : 'standard'"
|
||||
@hide="handleHide"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { injectProjectPageContext, MultiStageModal } from '@modrinth/ui'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import {
|
||||
type CompatibilityType,
|
||||
createServerCompatibilityContext,
|
||||
provideServerCompatibilityContext,
|
||||
} from '../../../../providers/manage-server-compatibility-modal'
|
||||
|
||||
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
|
||||
|
||||
const { projectV3 } = injectProjectPageContext()
|
||||
const ctx = createServerCompatibilityContext(modal)
|
||||
provideServerCompatibilityContext(ctx)
|
||||
|
||||
interface ShowModalOptions {
|
||||
stageId?: string | null
|
||||
updateContentKind?: CompatibilityType
|
||||
isSwitchingCompatibilityType?: boolean
|
||||
}
|
||||
|
||||
async function show(options?: ShowModalOptions) {
|
||||
if (options?.updateContentKind) {
|
||||
ctx.compatibilityType.value = options.updateContentKind
|
||||
ctx.isEditingExistingCompatibility.value = true
|
||||
|
||||
// Prefill existing values for vanilla
|
||||
const content = projectV3.value?.minecraft_java_server?.content
|
||||
if (options.updateContentKind === 'vanilla' && content && content.kind === 'vanilla') {
|
||||
ctx.supportedGameVersions.value = content.supported_game_versions ?? []
|
||||
ctx.recommendedGameVersion.value = content.recommended_game_version ?? null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
modal.value?.setStage(1)
|
||||
} else {
|
||||
modal.value?.setStage(options?.stageId ?? 0)
|
||||
}
|
||||
|
||||
if (options?.isSwitchingCompatibilityType) {
|
||||
ctx.isSwitchingCompatibilityType.value = true
|
||||
}
|
||||
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
ctx.resetContext()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
handleHide()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<DataLossWarningBanner />
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="font-semibold text-contrast">Select compatibility type</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="flex !w-full items-center gap-4 rounded-3xl bg-surface-4 p-3 text-left transition-all hover:brightness-125"
|
||||
@click="selectType(option.value)"
|
||||
>
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-solid border-surface-5"
|
||||
>
|
||||
<component :is="option.icon" class="h-9 w-9 text-secondary" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-bold text-contrast">{{ option.label }}</span>
|
||||
<span class="text-sm text-secondary">{{ option.description }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-secondary">
|
||||
Servers with custom modpacks should not be uploaded as separate modpack projects.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BoxIcon, PackageIcon, PackagePlusIcon } from '@modrinth/assets'
|
||||
|
||||
import {
|
||||
type CompatibilityType,
|
||||
injectServerCompatibilityContext,
|
||||
} from '~/providers/manage-server-compatibility-modal'
|
||||
|
||||
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
|
||||
|
||||
const ctx = injectServerCompatibilityContext()
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'vanilla' as CompatibilityType,
|
||||
label: 'Vanilla server',
|
||||
description: 'A vanilla server with no mods.',
|
||||
icon: BoxIcon,
|
||||
},
|
||||
{
|
||||
value: 'published-modpack' as CompatibilityType,
|
||||
label: 'Published modpack',
|
||||
description: 'A modded server using a published modpack.',
|
||||
icon: PackageIcon,
|
||||
},
|
||||
{
|
||||
value: 'custom-modpack' as CompatibilityType,
|
||||
label: 'Custom modpack',
|
||||
description: 'A modded server using a custom modpack.',
|
||||
icon: PackagePlusIcon,
|
||||
},
|
||||
]
|
||||
|
||||
function selectType(type: CompatibilityType) {
|
||||
ctx.compatibilityType.value = type
|
||||
ctx.modal.value?.nextStage()
|
||||
}
|
||||
</script>
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<DataLossWarningBanner />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Select modpack</div>
|
||||
|
||||
<div class="flex flex-col gap-6 rounded-2xl border border-solid border-surface-5 p-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast">Project</label>
|
||||
<ProjectCombobox
|
||||
v-model="selectedProjectId"
|
||||
:project-types="['modpack']"
|
||||
:exclude-project-ids="[currentProjectId]"
|
||||
placeholder="Select modpack"
|
||||
search-placeholder="Search by name or paste ID..."
|
||||
loading-message="Loading..."
|
||||
no-results-message="No results found"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedProjectId" class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast">Version</label>
|
||||
<Combobox
|
||||
v-model="selectedVersionId"
|
||||
placeholder="Select version"
|
||||
:options="versionOptions"
|
||||
:searchable="true"
|
||||
search-placeholder="Search versions..."
|
||||
:no-options-message="versionsLoading ? 'Loading...' : 'No results found'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedVersion" class="flex flex-col gap-4 rounded-2xl bg-surface-2 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-secondary">Game version</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem v-for="gv in selectedVersion.game_versions" :key="gv">
|
||||
{{ gv }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedVersion.mrpack_loaders?.length"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="text-secondary">Platform</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem
|
||||
v-for="loader in selectedVersion.mrpack_loaders"
|
||||
:key="loader"
|
||||
:style="`--_color: var(--color-platform-${loader})`"
|
||||
>
|
||||
<component :is="getLoaderIcon(loader)" v-if="getLoaderIcon(loader)" class="h-4 w-4" />
|
||||
<FormattedTag :tag="loader" enforce-type="loader" />
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getLoaderIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Combobox,
|
||||
FormattedTag,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
ProjectCombobox,
|
||||
TagItem,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
|
||||
|
||||
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
|
||||
|
||||
const { projectV3 } = injectProjectPageContext()
|
||||
|
||||
const currentProjectId = computed(() => projectV3.value?.id)
|
||||
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
interface VersionInfo {
|
||||
id: string
|
||||
version_number: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const versionsLoading = ref(false)
|
||||
const projectVersions = ref<VersionInfo[]>([])
|
||||
|
||||
const versionOptions = computed(() =>
|
||||
projectVersions.value.map((v) => ({
|
||||
label: v.version_number,
|
||||
value: v.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const { data: selectedVersion } = useQuery({
|
||||
queryKey: computed(() => ['versions', 'detail', selectedVersionId.value]),
|
||||
queryFn: () => labrinth.versions_v3.getVersion(selectedVersionId.value),
|
||||
enabled: computed(() => !!selectedVersionId.value),
|
||||
})
|
||||
|
||||
watch(
|
||||
() => selectedProjectId.value,
|
||||
async (newProjectId) => {
|
||||
selectedVersionId.value = ''
|
||||
projectVersions.value = []
|
||||
|
||||
if (!newProjectId) return
|
||||
|
||||
versionsLoading.value = true
|
||||
try {
|
||||
const versions = await labrinth.versions_v3.getProjectVersions(newProjectId)
|
||||
projectVersions.value = versions.map((v) => ({
|
||||
id: v.id,
|
||||
version_number: v.version_number,
|
||||
name: v.name,
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
const err = error as { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: 'Failed to load versions',
|
||||
text: err.data?.description || String(error),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
versionsLoading.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<DataLossWarningBanner />
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<label>
|
||||
<span class="label__title">Supported MC versions</span>
|
||||
<McVersionPicker v-model="supportedGameVersions" no-header :game-versions="gameVersions" />
|
||||
</label>
|
||||
<div>
|
||||
<label>
|
||||
<span class="label__title">Recommended MC version</span>
|
||||
<Combobox
|
||||
v-model="recommendedGameVersion"
|
||||
v-tooltip="
|
||||
!recommendedOptions.length
|
||||
? 'Set supported versions before selecting the recommended version'
|
||||
: undefined
|
||||
"
|
||||
:options="recommendedOptions"
|
||||
searchable
|
||||
:display-name="(val: string) => val"
|
||||
placeholder="Select version"
|
||||
:disabled="!recommendedOptions.length"
|
||||
/>
|
||||
<div class="mt-2 text-secondary">
|
||||
Players joining the server from the Modrinth App will connect using this version.
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Combobox } from '@modrinth/ui'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import McVersionPicker from '~/components/ui/create-project-version/components/McVersionPicker.vue'
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
|
||||
|
||||
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
|
||||
|
||||
const { supportedGameVersions, recommendedGameVersion } = injectServerCompatibilityContext()
|
||||
|
||||
const generatedState = useGeneratedState()
|
||||
const gameVersions = generatedState.value.gameVersions
|
||||
|
||||
const recommendedOptions = computed(() =>
|
||||
gameVersions
|
||||
.filter((v) => v.version_type === 'release')
|
||||
.filter((v) => supportedGameVersions.value.includes(v.version))
|
||||
.map((v) => ({ label: v.version, value: v.version })),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => supportedGameVersions.value,
|
||||
(supported) => {
|
||||
if (recommendedGameVersion.value && !supported.includes(recommendedGameVersion.value)) {
|
||||
recommendedGameVersion.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<DataLossWarningBanner />
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<div class="font-semibold text-contrast">Upload custom modpack</div>
|
||||
|
||||
<DropzoneFileInput
|
||||
v-if="!ctx.customModpackFile.value"
|
||||
primary-prompt="Drag and drop your .mrpack file"
|
||||
secondary-prompt="Or click to browse"
|
||||
accept=".mrpack"
|
||||
size="medium"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="ctx.customModpackFile.value"
|
||||
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-2 text-button-text"
|
||||
>
|
||||
<div class="flex items-center gap-2 overflow-hidden">
|
||||
<FileIcon />
|
||||
<span
|
||||
v-tooltip="ctx.customModpackFile.value.name"
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap font-medium"
|
||||
>
|
||||
{{ ctx.customModpackFile.value.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ButtonStyled size="standard" :circular="true">
|
||||
<button
|
||||
v-tooltip="'Replace file'"
|
||||
aria-label="Replace file"
|
||||
class="!shadow-none"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<ArrowLeftRightIcon aria-hidden="true" />
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".mrpack"
|
||||
@change="handleFileInputChange"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<Checkbox v-model="ctx.hasLicensePermission.value">
|
||||
<span class="max-w-[90%] text-left text-primary">
|
||||
Do you have the appropriate licenses to redistribute all content in this Modpack?
|
||||
<NuxtLink
|
||||
to="https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions"
|
||||
external
|
||||
target="_blank"
|
||||
class="font-medium text-blue underline"
|
||||
>
|
||||
Learn more
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeftRightIcon, FileIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Checkbox, DropzoneFileInput } from '@modrinth/ui'
|
||||
|
||||
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
|
||||
|
||||
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
|
||||
|
||||
const ctx = injectServerCompatibilityContext()
|
||||
|
||||
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
|
||||
|
||||
function handleFileUpload(files: File[]) {
|
||||
if (files.length > 0) {
|
||||
ctx.customModpackFile.value = files[0]
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileInputChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
ctx.customModpackFile.value = file
|
||||
}
|
||||
target.value = ''
|
||||
}
|
||||
</script>
|
||||
@@ -38,7 +38,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
newProjectGeneralSettings: false,
|
||||
newProjectEnvironmentSettings: true,
|
||||
hideRussiaCensorshipBanner: false,
|
||||
serverDiscovery: false,
|
||||
serverDiscovery: true,
|
||||
disablePrettyProjectUrlRedirects: false,
|
||||
hidePreviewBanner: false,
|
||||
i18nDebug: false,
|
||||
|
||||
@@ -92,6 +92,11 @@ export const useGeneratedState = () =>
|
||||
id: 'modpack',
|
||||
display: 'modpack',
|
||||
},
|
||||
{
|
||||
actual: 'server',
|
||||
id: 'server',
|
||||
display: 'server',
|
||||
},
|
||||
],
|
||||
loaderData: {
|
||||
pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'folia'],
|
||||
|
||||
@@ -3,6 +3,8 @@ export const getProjectTypeForUrl = (type, categories) => {
|
||||
}
|
||||
|
||||
export const getProjectTypeForUrlShorthand = (type, categories, overrideTags) => {
|
||||
if (type === 'minecraft_java_server') return 'server'
|
||||
|
||||
const tags = overrideTags ?? useGeneratedState().value
|
||||
|
||||
if (type === 'mod') {
|
||||
|
||||
@@ -398,6 +398,10 @@
|
||||
id: 'new-project',
|
||||
action: (event) => $refs.modal_creation.show(event),
|
||||
},
|
||||
{
|
||||
id: 'new-server-project',
|
||||
action: (event) => $refs.modal_creation.show(event, { type: 'server' }),
|
||||
},
|
||||
{
|
||||
id: 'new-collection',
|
||||
action: (event) => $refs.modal_collection_creation.show(event),
|
||||
@@ -410,10 +414,13 @@
|
||||
]"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
{{ formatMessage(messages.publish) }}
|
||||
<template #new-project>
|
||||
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newProject) }}
|
||||
</template>
|
||||
<template #new-server-project>
|
||||
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newServerProject) }}
|
||||
</template>
|
||||
<!-- <template #import-project> <BoxImportIcon /> Import project </template>-->
|
||||
<template #new-collection>
|
||||
<CollectionIcon aria-hidden="true" /> {{ formatMessage(messages.newCollection) }}
|
||||
@@ -835,6 +842,10 @@ const messages = defineMessages({
|
||||
id: 'layout.action.create-new',
|
||||
defaultMessage: 'Create new...',
|
||||
},
|
||||
publish: {
|
||||
id: 'layout.action.publish',
|
||||
defaultMessage: 'Publish',
|
||||
},
|
||||
reviewProjects: {
|
||||
id: 'layout.action.review-projects',
|
||||
defaultMessage: 'Project review',
|
||||
@@ -867,6 +878,10 @@ const messages = defineMessages({
|
||||
id: 'layout.action.new-project',
|
||||
defaultMessage: 'New project',
|
||||
},
|
||||
newServerProject: {
|
||||
id: 'layout.action.new-server-project',
|
||||
defaultMessage: 'New server',
|
||||
},
|
||||
newCollection: {
|
||||
id: 'layout.action.new-collection',
|
||||
defaultMessage: 'New collection',
|
||||
|
||||
@@ -497,12 +497,27 @@
|
||||
"create.project.create-project": {
|
||||
"message": "Create project"
|
||||
},
|
||||
"create.project.create-server-project": {
|
||||
"message": "Create server"
|
||||
},
|
||||
"create.project.missing-fields-tooltip": {
|
||||
"message": "Missing fields: {fields}"
|
||||
},
|
||||
"create.project.name-label": {
|
||||
"message": "Name"
|
||||
},
|
||||
"create.project.name-placeholder": {
|
||||
"message": "Enter project name..."
|
||||
},
|
||||
"create.project.owner-description": {
|
||||
"message": "Set the project owner as yourself or an organization you're a member of."
|
||||
},
|
||||
"create.project.owner-label": {
|
||||
"message": "Owner"
|
||||
},
|
||||
"create.project.server-project-title": {
|
||||
"message": "Creating a server project"
|
||||
},
|
||||
"create.project.summary-description": {
|
||||
"message": "A sentence or two that describes your project."
|
||||
},
|
||||
@@ -515,6 +530,15 @@
|
||||
"create.project.title": {
|
||||
"message": "Creating a project"
|
||||
},
|
||||
"create.project.type-label": {
|
||||
"message": "Type"
|
||||
},
|
||||
"create.project.type-project": {
|
||||
"message": "Project"
|
||||
},
|
||||
"create.project.type-server": {
|
||||
"message": "Server"
|
||||
},
|
||||
"create.project.url-label": {
|
||||
"message": "URL"
|
||||
},
|
||||
@@ -1433,6 +1457,12 @@
|
||||
"layout.action.new-project": {
|
||||
"message": "New project"
|
||||
},
|
||||
"layout.action.new-server-project": {
|
||||
"message": "New server"
|
||||
},
|
||||
"layout.action.publish": {
|
||||
"message": "Publish"
|
||||
},
|
||||
"layout.action.reports": {
|
||||
"message": "Review reports"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,16 @@ import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import { useServerModrinthClient } from '~/server/utils/api-client'
|
||||
|
||||
// All valid project type URL segments
|
||||
const PROJECT_TYPES = ['project', 'mod', 'plugin', 'datapack', 'shader', 'resourcepack', 'modpack']
|
||||
const PROJECT_TYPES = [
|
||||
'project',
|
||||
'mod',
|
||||
'plugin',
|
||||
'datapack',
|
||||
'shader',
|
||||
'resourcepack',
|
||||
'modpack',
|
||||
'server',
|
||||
]
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Only handle project routes
|
||||
@@ -23,6 +32,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Fetch v2 project for redirect check AND cache it for the page
|
||||
// Using fetchQuery ensures the page's useQuery gets this cached result
|
||||
const project = await queryClient.fetchQuery(projectQueryOptions.v2(projectId, client))
|
||||
const projectV3 = await queryClient.fetchQuery(projectQueryOptions.v3(projectId, client))
|
||||
|
||||
// Let page handle 404
|
||||
if (!project) return
|
||||
@@ -35,12 +45,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
queryClient.setQueryData(['project', 'v2', project.id], project)
|
||||
}
|
||||
|
||||
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
|
||||
// Determine the correct URL type
|
||||
const correctType = getProjectTypeForUrlShorthand(
|
||||
project.project_type,
|
||||
project.loaders,
|
||||
tags.value,
|
||||
)
|
||||
const correctType = getProjectTypeForUrlShorthand(projectType, project.loaders, tags.value)
|
||||
|
||||
// Preserve the rest of the path (subpages like /versions, /settings, etc.)
|
||||
const pathParts = to.path.split('/')
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"
|
||||
/>
|
||||
</NewModal>
|
||||
<OpenInAppModal ref="openInAppModal" />
|
||||
<div
|
||||
class="over-the-top-download-animation"
|
||||
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
|
||||
@@ -432,7 +433,14 @@
|
||||
}"
|
||||
>
|
||||
<div class="normal-page__header relative my-4">
|
||||
<ProjectHeader :project="project" :member="!!currentMember">
|
||||
<component
|
||||
:is="isServerProject ? ServerProjectHeader : ProjectHeader"
|
||||
v-bind="
|
||||
isServerProject
|
||||
? { project, projectV3, member: !!currentMember }
|
||||
: { project, member: !!currentMember }
|
||||
"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
|
||||
<nuxt-link
|
||||
@@ -446,6 +454,7 @@
|
||||
|
||||
<div class="hidden sm:contents">
|
||||
<ButtonStyled
|
||||
v-if="!isServerProject"
|
||||
size="large"
|
||||
:color="
|
||||
(auth.user && currentMember) || route.name === 'type-id-version-version'
|
||||
@@ -458,8 +467,6 @@
|
||||
v-tooltip="
|
||||
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
|
||||
"
|
||||
@mouseenter="loadVersions"
|
||||
@focus="loadVersions"
|
||||
@click="(event) => downloadModal.show(event)"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
@@ -468,10 +475,29 @@
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
size="large"
|
||||
:color="
|
||||
(auth.user && currentMember) || route.name === 'type-id-version-version'
|
||||
? `standard`
|
||||
: `brand`
|
||||
"
|
||||
:circular="!!auth.user && !!currentMember"
|
||||
>
|
||||
<button
|
||||
v-tooltip="auth.user && currentMember && !openInAppModal?.open ? 'Play' : ''"
|
||||
@click="handlePlayServerProject"
|
||||
>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ auth.user && currentMember ? '' : 'Play' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="contents sm:hidden">
|
||||
<ButtonStyled
|
||||
v-if="!isServerProject"
|
||||
size="large"
|
||||
circular
|
||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
||||
@@ -479,13 +505,21 @@
|
||||
<button
|
||||
:aria-label="formatMessage(commonMessages.downloadButton)"
|
||||
class="flex sm:hidden"
|
||||
@mouseenter="loadVersions"
|
||||
@focus="loadVersions"
|
||||
@click="(event) => downloadModal.show(event)"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
size="large"
|
||||
circular
|
||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
||||
>
|
||||
<button aria-label="Play" class="flex sm:hidden" @click="handlePlayServerProject">
|
||||
<PlayIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
|
||||
@@ -737,7 +771,7 @@
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ProjectHeader>
|
||||
</component>
|
||||
<ProjectMemberHeader
|
||||
v-if="currentMember"
|
||||
:project="project"
|
||||
@@ -796,7 +830,19 @@
|
||||
</div>
|
||||
|
||||
<div class="normal-page__sidebar">
|
||||
<ProjectSidebarServerInfo
|
||||
v-if="isServerProject"
|
||||
:project-v3="projectV3"
|
||||
:tags="tags"
|
||||
:required-content="serverRequiredContent"
|
||||
:recommended-version="serverRecommendedVersion"
|
||||
:supported-versions="serverSupportedVersions"
|
||||
:loaders="serverModpackLoaders"
|
||||
:status-online="projectV3?.minecraft_java_server?.ping?.data != null"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<ProjectSidebarCompatibility
|
||||
v-if="!isServerProject"
|
||||
:project="project"
|
||||
:tags="tags"
|
||||
:v3-metadata="projectV3"
|
||||
@@ -805,16 +851,22 @@
|
||||
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
|
||||
<ProjectSidebarLinks
|
||||
:project="project"
|
||||
:project-v3="projectV3"
|
||||
:link-target="$external()"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<ProjectSidebarCreators
|
||||
v-if="!isServerProject"
|
||||
:organization="organization"
|
||||
:members="members"
|
||||
:org-link="(slug) => `/organization/${slug}`"
|
||||
:user-link="(username) => `/user/${username}`"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<ProjectSidebarTags
|
||||
:project="project"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<!-- TODO: Finish license modal and enable -->
|
||||
<ProjectSidebarDetails
|
||||
v-if="false"
|
||||
@@ -827,7 +879,7 @@
|
||||
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
||||
|
||||
<div class="details-list">
|
||||
<div class="details-list__item">
|
||||
<div v-if="!isServerProject" class="details-list__item">
|
||||
<BookTextIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.licensedLabel) }}
|
||||
@@ -953,6 +1005,7 @@ import {
|
||||
ListIcon,
|
||||
ModrinthIcon,
|
||||
MoreVerticalIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
@@ -976,6 +1029,7 @@ import {
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
OpenInAppModal,
|
||||
OverflowMenu,
|
||||
PopoutMenu,
|
||||
ProjectBackgroundGradient,
|
||||
@@ -985,8 +1039,11 @@ import {
|
||||
ProjectSidebarCreators,
|
||||
ProjectSidebarDetails,
|
||||
ProjectSidebarLinks,
|
||||
ProjectSidebarServerInfo,
|
||||
ProjectSidebarTags,
|
||||
provideProjectPageContext,
|
||||
ScrollablePanel,
|
||||
ServerProjectHeader,
|
||||
ServersPromo,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
@@ -1034,6 +1091,7 @@ const user = await useUser()
|
||||
const tags = useGeneratedState()
|
||||
const flags = useFeatureFlags()
|
||||
const cosmetics = useCosmetics()
|
||||
const router = useNativeRouter()
|
||||
|
||||
const { locale, formatMessage } = useVIntl()
|
||||
|
||||
@@ -1041,6 +1099,7 @@ const debug = useDebugLogger('DownloadModal')
|
||||
|
||||
const settingsModal = ref()
|
||||
const downloadModal = ref()
|
||||
const openInAppModal = ref()
|
||||
const overTheTopDownloadAnimation = ref()
|
||||
|
||||
const userSelectedGameVersion = ref(null)
|
||||
@@ -1051,6 +1110,8 @@ const gameVersionFilterInput = ref()
|
||||
|
||||
const versionFilter = ref('')
|
||||
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
|
||||
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
|
||||
|
||||
const baseId = useId()
|
||||
@@ -1121,6 +1182,21 @@ const showVersionsCheckbox = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const serverProject = computed(() => ({
|
||||
name: project.value.title,
|
||||
slug: project.value.slug || project.value.id,
|
||||
numPlayers: projectV3.value?.minecraft_java_server?.ping?.data?.players_online,
|
||||
icon: project.value.icon_url,
|
||||
statusOnline: !!projectV3.value?.minecraft_java_server?.ping?.data,
|
||||
region: projectV3.value?.minecraft_server?.country,
|
||||
}))
|
||||
|
||||
function handlePlayServerProject() {
|
||||
openInAppModal.value?.show({
|
||||
serverProject: serverProject.value,
|
||||
})
|
||||
}
|
||||
|
||||
function installWithApp() {
|
||||
setTimeout(() => {
|
||||
getModrinthAppAccordion.value.open()
|
||||
@@ -1543,6 +1619,79 @@ const { data: projectV3, error: _projectV3Error } = useQuery({
|
||||
enabled: computed(() => !!projectId.value),
|
||||
})
|
||||
|
||||
// Server sidebar: modpack version + project for required content
|
||||
const serverModpackVersionId = computed(() => {
|
||||
const content = projectV3.value?.minecraft_java_server?.content
|
||||
return content?.kind === 'modpack' ? content.version_id : null
|
||||
})
|
||||
|
||||
const { data: serverModpackVersion } = useQuery({
|
||||
queryKey: computed(() => ['sidebar-modpack-version', serverModpackVersionId.value]),
|
||||
queryFn: () => client.labrinth.versions_v3.getVersion(serverModpackVersionId.value),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: computed(() => !!serverModpackVersionId.value),
|
||||
})
|
||||
|
||||
const serverModpackProjectId = computed(() => serverModpackVersion.value?.project_id ?? null)
|
||||
|
||||
const { data: serverModpackProject } = useQuery({
|
||||
queryKey: computed(() => ['sidebar-modpack-project', serverModpackProjectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v3.get(serverModpackProjectId.value),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: computed(() => !!serverModpackProjectId.value),
|
||||
})
|
||||
|
||||
const serverRequiredContent = computed(() => {
|
||||
if (!serverModpackProject.value) return null
|
||||
const primaryFile =
|
||||
serverModpackVersion.value?.files?.find((f) => f.primary) ??
|
||||
serverModpackVersion.value?.files?.[0]
|
||||
return {
|
||||
name: serverModpackProject.value.name,
|
||||
versionNumber: serverModpackVersion.value?.version_number ?? '',
|
||||
icon: serverModpackProject.value.icon_url,
|
||||
onclickName: () => navigateTo(`/modpack/${serverModpackProject.value.slug}`),
|
||||
onclickVersion: () =>
|
||||
navigateTo(
|
||||
`/modpack/${serverModpackProject.value.slug}/version/${serverModpackVersion.value?.id}`,
|
||||
),
|
||||
onclickDownload: primaryFile?.url
|
||||
? () => navigateTo(primaryFile.url, { external: true })
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const serverRecommendedVersion = computed(() => {
|
||||
const content = projectV3.value?.minecraft_java_server?.content
|
||||
if (!content) return null
|
||||
|
||||
if (content.kind === 'modpack') {
|
||||
return serverModpackVersion.value?.game_versions?.[0] ?? null
|
||||
}
|
||||
|
||||
if (content.kind === 'vanilla') {
|
||||
return content.recommended_game_version ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const serverSupportedVersions = computed(() => {
|
||||
const content = projectV3.value?.minecraft_java_server?.content
|
||||
if (!content) return []
|
||||
|
||||
if (content.kind === 'vanilla') {
|
||||
return content.supported_game_versions?.filter((v) => !!v) ?? []
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const serverModpackLoaders = computed(() => {
|
||||
if (!serverModpackVersion.value) return []
|
||||
return serverModpackVersion.value.mrpack_loaders ?? []
|
||||
})
|
||||
|
||||
// Members
|
||||
const { data: allMembersRaw, error: _membersError } = useQuery({
|
||||
queryKey: computed(() => ['project', projectId.value, 'members']),
|
||||
@@ -1711,7 +1860,6 @@ const patchProjectMutation = useMutation({
|
||||
text: err.data ? err.data.description : err.message,
|
||||
type: 'error',
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
},
|
||||
|
||||
onSettled: async () => {
|
||||
@@ -1764,6 +1912,57 @@ const patchStatusMutation = useMutation({
|
||||
},
|
||||
})
|
||||
|
||||
// Mutation for patching V3 project data
|
||||
const patchProjectV3Mutation = useMutation({
|
||||
mutationFn: async ({ projectId, data }) => {
|
||||
await client.labrinth.projects_v3.edit(projectId, data)
|
||||
return data
|
||||
},
|
||||
|
||||
onMutate: async ({ projectId, data }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
|
||||
|
||||
const previousProject = queryClient.getQueryData(['project', 'v3', projectId])
|
||||
|
||||
queryClient.setQueryData(['project', 'v3', projectId], (old) => {
|
||||
if (!old) return old
|
||||
const merged = { ...old }
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
merged[key] &&
|
||||
typeof merged[key] === 'object' &&
|
||||
!Array.isArray(merged[key])
|
||||
) {
|
||||
merged[key] = { ...merged[key], ...value }
|
||||
} else {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
|
||||
return { previousProject, projectId }
|
||||
},
|
||||
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousProject) {
|
||||
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousProject)
|
||||
}
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err.message,
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
|
||||
onSettled: async () => {
|
||||
await invalidateProject()
|
||||
},
|
||||
})
|
||||
|
||||
// Mutation for patching project icon
|
||||
const patchIconMutation = useMutation({
|
||||
mutationFn: async ({ projectId, icon }) => {
|
||||
@@ -1790,7 +1989,6 @@ const patchIconMutation = useMutation({
|
||||
text: err.data ? err.data.description : err.message,
|
||||
type: 'error',
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
},
|
||||
|
||||
onSettled: async () => {
|
||||
@@ -2147,7 +2345,30 @@ async function patchProject(resData, quiet = false) {
|
||||
text: formatMessage(messages.projectUpdatedMessage),
|
||||
type: 'success',
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
resolve(true)
|
||||
},
|
||||
onError: () => resolve(false),
|
||||
onSettled: () => stopLoading(),
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function patchProjectV3(resData, quiet = false) {
|
||||
startLoading()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
patchProjectV3Mutation.mutate(
|
||||
{ projectId: project.value.id, data: resData },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
if (!quiet) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.projectUpdated),
|
||||
text: formatMessage(messages.projectUpdatedMessage),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
resolve(true)
|
||||
},
|
||||
@@ -2279,7 +2500,8 @@ async function deleteVersion(id) {
|
||||
}
|
||||
|
||||
const navLinks = computed(() => {
|
||||
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
|
||||
const routeType = route.params.type || project.value.project_type
|
||||
const projectUrl = `/${routeType}/${project.value.slug ? project.value.slug : project.value.id}`
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -2294,13 +2516,15 @@ const navLinks = computed(() => {
|
||||
{
|
||||
label: formatMessage(messages.changelogTab),
|
||||
href: `${projectUrl}/changelog`,
|
||||
shown: hasVersions.value,
|
||||
shown: hasVersions.value && projectV3.value?.minecraft_server === undefined,
|
||||
onHover: loadVersions,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.versionsTab),
|
||||
href: `${projectUrl}/versions`,
|
||||
shown: hasVersions.value || !!currentMember.value,
|
||||
shown:
|
||||
(hasVersions.value || !!currentMember.value) &&
|
||||
projectV3.value?.minecraft_server === undefined,
|
||||
subpages: [`${projectUrl}/version/`],
|
||||
onHover: loadVersions,
|
||||
},
|
||||
@@ -2335,6 +2559,7 @@ provideProjectPageContext({
|
||||
|
||||
// Mutation functions
|
||||
patchProject,
|
||||
patchProjectV3,
|
||||
patchIcon,
|
||||
setProcessing,
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
:src="
|
||||
previewImage
|
||||
? previewImage
|
||||
: project.gallery?.[editIndex]?.url
|
||||
? project.gallery[editIndex].url
|
||||
: filteredGallery[editIndex]?.url
|
||||
? filteredGallery[editIndex].url
|
||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
"
|
||||
alt="gallery-preview"
|
||||
@@ -175,14 +175,14 @@
|
||||
<ContractIcon v-else aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="(project?.gallery?.length ?? 0) > 1"
|
||||
v-if="filteredGallery.length > 1"
|
||||
class="previous circle-button"
|
||||
@click="previousImage()"
|
||||
>
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="(project?.gallery?.length ?? 0) > 1"
|
||||
v-if="filteredGallery.length > 1"
|
||||
class="next circle-button"
|
||||
@click="nextImage()"
|
||||
>
|
||||
@@ -222,7 +222,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
<div v-if="currentMember && project?.gallery?.length" class="card header-buttons">
|
||||
<div v-if="currentMember && filteredGallery.length" class="card header-buttons">
|
||||
<FileInput
|
||||
:max-size="5242880"
|
||||
:accept="acceptFileTypes"
|
||||
@@ -243,8 +243,8 @@
|
||||
@change="handleFiles"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="project?.gallery?.length" class="items">
|
||||
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
|
||||
<div v-if="filteredGallery.length" class="items">
|
||||
<div v-for="(item, index) in filteredGallery" :key="index" class="card gallery-item">
|
||||
<a class="gallery-thumbnail" @click="expandImage(item as GalleryItem, index)">
|
||||
<img
|
||||
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
|
||||
@@ -414,8 +414,13 @@ const previewImage = ref<string | null>(null)
|
||||
const shouldPreventActions = ref(false)
|
||||
|
||||
// Constant for accepted file types
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
||||
|
||||
const filteredGallery = computed(() =>
|
||||
project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
|
||||
)
|
||||
|
||||
// Keyboard navigation for expanded image modal
|
||||
useEventListener(document, 'keydown', (e) => {
|
||||
if (expandedGalleryItem.value) {
|
||||
@@ -435,18 +440,18 @@ useEventListener(document, 'keydown', (e) => {
|
||||
// Navigation functions
|
||||
function nextImage() {
|
||||
expandedGalleryIndex.value++
|
||||
if (expandedGalleryIndex.value >= project.value.gallery!.length) {
|
||||
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
|
||||
expandedGalleryIndex.value = 0
|
||||
}
|
||||
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
|
||||
}
|
||||
|
||||
function previousImage() {
|
||||
expandedGalleryIndex.value--
|
||||
if (expandedGalleryIndex.value < 0) {
|
||||
expandedGalleryIndex.value = project.value.gallery!.length - 1
|
||||
expandedGalleryIndex.value = filteredGallery.value.length - 1
|
||||
}
|
||||
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
|
||||
}
|
||||
|
||||
function expandImage(item: GalleryItem, index: number) {
|
||||
@@ -509,7 +514,7 @@ async function editGalleryItem() {
|
||||
shouldPreventActions.value = true
|
||||
startLoading()
|
||||
|
||||
const imageUrl = project.value!.gallery![editIndex.value].url
|
||||
const imageUrl = filteredGallery.value[editIndex.value].url
|
||||
const success = await contextEditGalleryItem(
|
||||
imageUrl,
|
||||
editTitle.value || undefined,
|
||||
@@ -529,7 +534,7 @@ async function editGalleryItem() {
|
||||
async function deleteGalleryImage() {
|
||||
startLoading()
|
||||
|
||||
const imageUrl = project.value!.gallery![deleteIndex.value].url!
|
||||
const imageUrl = filteredGallery.value[deleteIndex.value].url!
|
||||
await contextDeleteGalleryItem(imageUrl)
|
||||
|
||||
stopLoading()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ImageIcon,
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
ServerIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
@@ -36,6 +37,8 @@ const {
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
|
||||
const navItems = computed(() => {
|
||||
const base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
|
||||
|
||||
@@ -57,26 +60,36 @@ const navItems = computed(() => {
|
||||
icon: InfoIcon,
|
||||
}
|
||||
: null,
|
||||
isServerProject.value && {
|
||||
link: `/${base}/settings/server`,
|
||||
label: formatMessage(commonProjectSettingsMessages.server),
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
link: `/${base}/settings/tags`,
|
||||
label: formatMessage(commonProjectSettingsMessages.tags),
|
||||
icon: TagsIcon,
|
||||
},
|
||||
{
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/description`,
|
||||
label: formatMessage(commonProjectSettingsMessages.description),
|
||||
icon: AlignLeftIcon,
|
||||
},
|
||||
{
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/versions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||
icon: VersionIcon,
|
||||
},
|
||||
{
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/license`,
|
||||
label: formatMessage(commonProjectSettingsMessages.license),
|
||||
icon: BookTextIcon,
|
||||
},
|
||||
isServerProject.value && {
|
||||
link: `/${base}/settings/description`,
|
||||
label: formatMessage(commonProjectSettingsMessages.description),
|
||||
icon: AlignLeftIcon,
|
||||
},
|
||||
{
|
||||
link: `/${base}/settings/gallery`,
|
||||
label: formatMessage(commonProjectSettingsMessages.gallery),
|
||||
@@ -92,13 +105,13 @@ const navItems = computed(() => {
|
||||
label: formatMessage(commonProjectSettingsMessages.members),
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/analytics`,
|
||||
label: formatMessage(commonProjectSettingsMessages.analytics),
|
||||
icon: ChartIcon,
|
||||
},
|
||||
{ type: 'heading', label: 'moderation', shown: showEnvironment },
|
||||
{
|
||||
!isServerProject.value && { type: 'heading', label: 'moderation', shown: showEnvironment },
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/environment`,
|
||||
label: formatMessage(commonProjectSettingsMessages.environment),
|
||||
icon: GlobeIcon,
|
||||
@@ -125,14 +138,16 @@ watch(route, () => {
|
||||
<div class="mb-8 flex w-full flex-col gap-4">
|
||||
<ModerationProjectNags
|
||||
v-if="
|
||||
(currentMember && project.status === 'draft') ||
|
||||
tags.rejectedStatuses.includes(project.status)
|
||||
projectV3 &&
|
||||
((currentMember && project.status === 'draft') ||
|
||||
tags.rejectedStatuses.includes(project.status))
|
||||
"
|
||||
:project="project"
|
||||
:versions="versions"
|
||||
:project-v3="projectV3"
|
||||
:versions="versions ?? undefined"
|
||||
:current-member="currentMember"
|
||||
:collapsed="collapsedChecklist"
|
||||
:route-name="route.name"
|
||||
:route-name="route.name as string"
|
||||
:tags="tags"
|
||||
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
||||
@set-processing="setProcessing"
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
:src="
|
||||
previewImage
|
||||
? previewImage
|
||||
: project.gallery[editIndex] && project.gallery[editIndex].url
|
||||
? project.gallery[editIndex].url
|
||||
: filteredGallery[editIndex] && filteredGallery[editIndex].url
|
||||
? filteredGallery[editIndex].url
|
||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
"
|
||||
alt="gallery-preview"
|
||||
@@ -175,14 +175,14 @@
|
||||
<ContractIcon v-else aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="project.gallery.length > 1"
|
||||
v-if="filteredGallery.length > 1"
|
||||
class="previous circle-button"
|
||||
@click="previousImage()"
|
||||
>
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="project.gallery.length > 1"
|
||||
v-if="filteredGallery.length > 1"
|
||||
class="next circle-button"
|
||||
@click="nextImage()"
|
||||
>
|
||||
@@ -215,7 +215,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="items">
|
||||
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
|
||||
<div v-for="(item, index) in filteredGallery" :key="index" class="card gallery-item">
|
||||
<a class="gallery-thumbnail" @click="expandImage(item, index)">
|
||||
<img
|
||||
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
|
||||
@@ -340,22 +340,27 @@ const editFile = ref(null)
|
||||
const previewImage = ref(null)
|
||||
const shouldPreventActions = ref(false)
|
||||
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
||||
|
||||
const filteredGallery = computed(() =>
|
||||
project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
|
||||
)
|
||||
|
||||
const nextImage = () => {
|
||||
expandedGalleryIndex.value++
|
||||
if (expandedGalleryIndex.value >= project.value.gallery.length) {
|
||||
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
|
||||
expandedGalleryIndex.value = 0
|
||||
}
|
||||
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
|
||||
}
|
||||
|
||||
const previousImage = () => {
|
||||
expandedGalleryIndex.value--
|
||||
if (expandedGalleryIndex.value < 0) {
|
||||
expandedGalleryIndex.value = project.value.gallery.length - 1
|
||||
expandedGalleryIndex.value = filteredGallery.value.length - 1
|
||||
}
|
||||
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
|
||||
}
|
||||
|
||||
const expandImage = (item, index) => {
|
||||
@@ -414,7 +419,7 @@ const editGalleryItem = async () => {
|
||||
shouldPreventActions.value = true
|
||||
|
||||
const success = await editGalleryItemMutation(
|
||||
project.value.gallery[editIndex.value].url,
|
||||
filteredGallery.value[editIndex.value].url,
|
||||
editTitle.value || undefined,
|
||||
editDescription.value || undefined,
|
||||
editFeatured.value,
|
||||
@@ -429,7 +434,7 @@ const editGalleryItem = async () => {
|
||||
}
|
||||
|
||||
const deleteGalleryImage = async () => {
|
||||
await deleteGalleryItemMutation(project.value.gallery[deleteIndex.value].url)
|
||||
await deleteGalleryItemMutation(filteredGallery.value[deleteIndex.value].url)
|
||||
}
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
|
||||
@@ -10,154 +10,236 @@
|
||||
@proceed="deleteProject"
|
||||
/>
|
||||
<section class="universal-card">
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Project information</span>
|
||||
</h3>
|
||||
</div>
|
||||
<label for="project-icon">
|
||||
<span class="label__title">Icon</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<Avatar
|
||||
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
|
||||
:alt="project.title"
|
||||
size="md"
|
||||
class="project__icon"
|
||||
/>
|
||||
<div class="input-stack">
|
||||
<FileInput
|
||||
id="project-icon"
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="choose-image iconified-button"
|
||||
prompt="Upload icon"
|
||||
aria-label="Upload icon"
|
||||
:disabled="!hasPermission"
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
</FileInput>
|
||||
<button
|
||||
v-if="!deletedIcon && (previewImage || project.icon_url)"
|
||||
class="iconified-button"
|
||||
:disabled="!hasPermission"
|
||||
@click="markIconForDeletion"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Remove icon
|
||||
</button>
|
||||
<div class="flex max-w-[600px] flex-col gap-6">
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Project information</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="project-name">
|
||||
<span class="label__title">Name</span>
|
||||
</label>
|
||||
<StyledInput id="project-name" v-model="name" :maxlength="2048" :disabled="!hasPermission" />
|
||||
|
||||
<label for="project-slug">
|
||||
<span class="label__title">URL</span>
|
||||
</label>
|
||||
<div class="text-input-wrapper">
|
||||
<div class="text-input-wrapper__before">
|
||||
<span class="hidden sm:inline">https://modrinth.com</span>/{{
|
||||
$getProjectTypeForUrl(project.project_type, project.loaders)
|
||||
}}/
|
||||
</div>
|
||||
<StyledInput
|
||||
id="project-slug"
|
||||
v-model="slug"
|
||||
:maxlength="64"
|
||||
autocomplete="off"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label for="project-summary">
|
||||
<span class="label__title">Summary</span>
|
||||
</label>
|
||||
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
|
||||
<TriangleAlertIcon class="my-auto" />
|
||||
{{ summaryWarning }}
|
||||
</div>
|
||||
<StyledInput
|
||||
id="project-summary"
|
||||
v-model="summary"
|
||||
multiline
|
||||
:maxlength="256"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="summary-input"
|
||||
/>
|
||||
<template
|
||||
v-if="
|
||||
!flags.newProjectEnvironmentSettings &&
|
||||
project.versions?.length !== 0 &&
|
||||
project.project_type !== 'resourcepack' &&
|
||||
project.project_type !== 'plugin' &&
|
||||
project.project_type !== 'shader' &&
|
||||
project.project_type !== 'datapack'
|
||||
"
|
||||
>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-client">
|
||||
<span class="label__title">Client-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
client side. Just because a mod works in Singleplayer doesn't mean it has actual
|
||||
client-side functionality.
|
||||
</span>
|
||||
<div>
|
||||
<label for="project-name">
|
||||
<span class="label__title">Name</span>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="project-env-client"
|
||||
v-model="clientSide"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="sideTypes"
|
||||
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
<StyledInput
|
||||
id="project-name"
|
||||
v-model="name"
|
||||
:maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-server">
|
||||
<span class="label__title">Server-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
|
||||
server.
|
||||
</span>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="project-env-server"
|
||||
v-model="serverSide"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="sideTypes"
|
||||
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-visibility">
|
||||
<span class="label__title">Visibility</span>
|
||||
<div class="label__description">
|
||||
Public and archived projects are visible in search. Unlisted projects are published, but
|
||||
not visible in search or on user profiles. Private projects are only accessible by
|
||||
members of the project.
|
||||
|
||||
<p>If approved by the moderators:</p>
|
||||
<ul class="visibility-info">
|
||||
<div>
|
||||
<label for="project-slug">
|
||||
<span class="label__title">URL</span>
|
||||
</label>
|
||||
<div class="text-input-wrapper !w-full">
|
||||
<div class="text-input-wrapper__before">
|
||||
<span class="hidden sm:inline">https://modrinth.com</span>/{{
|
||||
$getProjectTypeForUrl(project.project_type, project.loaders)
|
||||
}}/
|
||||
</div>
|
||||
<StyledInput
|
||||
id="project-slug"
|
||||
v-model="slug"
|
||||
:maxlength="64"
|
||||
autocomplete="off"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="project-summary">
|
||||
<span class="label__title">Summary</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="project-summary"
|
||||
v-model="summary"
|
||||
multiline
|
||||
:maxlength="256"
|
||||
:disabled="!hasPermission"
|
||||
resize="vertical"
|
||||
/>
|
||||
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
|
||||
<TriangleAlertIcon class="my-auto" />
|
||||
{{ summaryWarning }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="project-icon">
|
||||
<span class="label__title"
|
||||
>Icon <span class="font-normal text-secondary">(optional)</span></span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="input-group">
|
||||
<Avatar
|
||||
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
|
||||
:alt="project.title"
|
||||
size="md"
|
||||
class="project__icon"
|
||||
/>
|
||||
<div class="input-stack">
|
||||
<FileInput
|
||||
id="project-icon"
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="choose-image iconified-button"
|
||||
prompt="Upload icon"
|
||||
aria-label="Upload icon"
|
||||
:disabled="!hasPermission"
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
</FileInput>
|
||||
<button
|
||||
v-if="!deletedIcon && (previewImage || project.icon_url)"
|
||||
class="iconified-button"
|
||||
:disabled="!hasPermission"
|
||||
@click="markIconForDeletion"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Remove icon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Project Settings -->
|
||||
<template v-if="isServerProject">
|
||||
<!-- Banner -->
|
||||
<div>
|
||||
<label>
|
||||
<span class="label__title"
|
||||
>Banner <span class="font-normal text-secondary">(optional)</span></span
|
||||
>
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<label
|
||||
class="flex cursor-pointer flex-col items-center justify-center rounded-2xl border-dashed border-surface-5 transition-colors"
|
||||
:class="
|
||||
!deletedBanner && (bannerPreview || bannerGalleryImage?.url)
|
||||
? 'border-none'
|
||||
: 'aspect-[468/60] border-2 bg-surface-2'
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
|
||||
class="relative h-full w-full overflow-hidden rounded-2xl"
|
||||
>
|
||||
<img
|
||||
:src="bannerPreview || bannerGalleryImage?.url"
|
||||
alt="Banner preview"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<ImageIcon v-else aria-hidden="true" class="h-8 w-8 text-secondary" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="hidden"
|
||||
:disabled="!hasPermission"
|
||||
@change="
|
||||
(e) => {
|
||||
const input = e.target
|
||||
if (input.files?.length) {
|
||||
if (fileIsValid(input.files[0], { maxSize: 524288, alertOnInvalid: true }))
|
||||
showBannerPreview(Array.from(input.files))
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<FileInput
|
||||
:max-size="524288"
|
||||
:show-icon="true"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="iconified-button"
|
||||
prompt="Upload banner"
|
||||
:disabled="!hasPermission"
|
||||
@change="showBannerPreview"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
</FileInput>
|
||||
<button
|
||||
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
|
||||
class="iconified-button"
|
||||
:disabled="!hasPermission"
|
||||
@click="markBannerForDeletion"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Remove banner
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-secondary">Gif, 468×60px recommended.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="
|
||||
!isServerProject &&
|
||||
!flags.newProjectEnvironmentSettings &&
|
||||
project.versions?.length !== 0 &&
|
||||
project.project_type !== 'resourcepack' &&
|
||||
project.project_type !== 'plugin' &&
|
||||
project.project_type !== 'shader' &&
|
||||
project.project_type !== 'datapack'
|
||||
"
|
||||
>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-client">
|
||||
<span class="label__title">Client-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
client side. Just because a mod works in Singleplayer doesn't mean it has actual
|
||||
client-side functionality.
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="clientSide"
|
||||
:options="sideTypeOptions"
|
||||
placeholder="Select one"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-server">
|
||||
<span class="label__title">Server-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
|
||||
server.
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="serverSide"
|
||||
:options="sideTypeOptions"
|
||||
placeholder="Select one"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<label>
|
||||
<span class="label__title">Visibility</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<Combobox
|
||||
v-model="visibility"
|
||||
:options="visibilityOptions"
|
||||
placeholder="Select one"
|
||||
:disabled="!hasPermission"
|
||||
:max-height="500"
|
||||
/>
|
||||
<div>If approved by the moderators:</div>
|
||||
<ul class="visibility-info m-0">
|
||||
<li>
|
||||
<CheckIcon
|
||||
v-if="visibility === 'approved' || visibility === 'archived'"
|
||||
@@ -187,31 +269,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="project-visibility"
|
||||
v-model="visibility"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="tags.approvedStatuses"
|
||||
:custom-label="(value) => formatProjectStatus(value)"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -235,14 +293,21 @@
|
||||
Delete project
|
||||
</button>
|
||||
</section>
|
||||
<UnsavedChangesPopup
|
||||
:original="original"
|
||||
:modified="modified"
|
||||
:saving="saving"
|
||||
@reset="resetChanges"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
ImageIcon,
|
||||
IssuesIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
TriangleAlertIcon,
|
||||
UploadIcon,
|
||||
@@ -251,13 +316,15 @@ import {
|
||||
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
ConfirmModal,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
StyledInput,
|
||||
UnsavedChangesPopup,
|
||||
} from '@modrinth/ui'
|
||||
import { formatProjectStatus, formatProjectType } from '@modrinth/utils'
|
||||
import { Multiselect } from 'vue-multiselect'
|
||||
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
|
||||
|
||||
import FileInput from '~/components/ui/FileInput.vue'
|
||||
import { useFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
@@ -265,11 +332,13 @@ import { useFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
projectV2: project,
|
||||
projectV3,
|
||||
currentMember,
|
||||
patchProject,
|
||||
patchIcon,
|
||||
invalidate,
|
||||
} = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
@@ -290,6 +359,15 @@ const visibility = ref(
|
||||
: project.value.requested_status,
|
||||
)
|
||||
|
||||
// Server project specific refs
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
const bannerPreview = ref(null)
|
||||
const deletedBanner = ref(false)
|
||||
const bannerFile = ref(null)
|
||||
const bannerGalleryImage = computed(() =>
|
||||
project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME),
|
||||
)
|
||||
const hasPermission = computed(() => {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
|
||||
@@ -311,9 +389,37 @@ const summaryWarning = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
const sideTypes = ['required', 'optional', 'unsupported']
|
||||
const sideTypeOptions = [
|
||||
{ value: 'required', label: 'Required' },
|
||||
{ value: 'optional', label: 'Optional' },
|
||||
{ value: 'unsupported', label: 'Unsupported' },
|
||||
]
|
||||
|
||||
const patchData = computed(() => {
|
||||
const visibilityOptions = computed(() =>
|
||||
tags.value.approvedStatuses.map((status) => {
|
||||
const subLabel = () => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'Visible via URL, on your profile, and in search.'
|
||||
case 'archived':
|
||||
return 'Visible via URL, on your profile, and in search, but marked as archived.'
|
||||
case 'unlisted':
|
||||
return 'Visible via URL only. Not shown on your profile or in search.'
|
||||
case 'private':
|
||||
return 'Not publicly visible. Only accessible to project members.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: status,
|
||||
label: formatProjectStatus(status),
|
||||
subLabel: subLabel(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const basePatchData = computed(() => {
|
||||
const data = {}
|
||||
|
||||
if (name.value !== project.value.title) {
|
||||
@@ -342,9 +448,52 @@ const patchData = computed(() => {
|
||||
return data
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
|
||||
})
|
||||
const saving = ref(false)
|
||||
|
||||
const original = computed(() => ({
|
||||
name: project.value.title,
|
||||
slug: project.value.slug,
|
||||
summary: project.value.description,
|
||||
clientSide: project.value.client_side,
|
||||
serverSide: project.value.server_side,
|
||||
visibility: tags.value.approvedStatuses.includes(project.value.status)
|
||||
? project.value.status
|
||||
: project.value.requested_status,
|
||||
icon: null,
|
||||
deletedIcon: false,
|
||||
bannerFile: null,
|
||||
deletedBanner: false,
|
||||
}))
|
||||
|
||||
const modified = computed(() => ({
|
||||
name: name.value,
|
||||
slug: slug.value,
|
||||
summary: summary.value,
|
||||
clientSide: clientSide.value,
|
||||
serverSide: serverSide.value,
|
||||
visibility: visibility.value,
|
||||
icon: icon.value,
|
||||
deletedIcon: deletedIcon.value,
|
||||
bannerFile: bannerFile.value,
|
||||
deletedBanner: deletedBanner.value,
|
||||
}))
|
||||
|
||||
function resetChanges() {
|
||||
name.value = project.value.title
|
||||
slug.value = project.value.slug
|
||||
summary.value = project.value.description
|
||||
clientSide.value = project.value.client_side
|
||||
serverSide.value = project.value.server_side
|
||||
visibility.value = tags.value.approvedStatuses.includes(project.value.status)
|
||||
? project.value.status
|
||||
: project.value.requested_status
|
||||
icon.value = null
|
||||
previewImage.value = null
|
||||
deletedIcon.value = false
|
||||
bannerFile.value = null
|
||||
bannerPreview.value = null
|
||||
deletedBanner.value = false
|
||||
}
|
||||
|
||||
const hasModifiedVisibility = () => {
|
||||
const originalVisibility = tags.value.approvedStatuses.includes(project.value.status)
|
||||
@@ -354,17 +503,33 @@ const hasModifiedVisibility = () => {
|
||||
return originalVisibility !== visibility.value
|
||||
}
|
||||
|
||||
const saveChanges = async () => {
|
||||
if (hasChanges.value) {
|
||||
await patchProject(patchData.value)
|
||||
}
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const hasV2Changes = Object.keys(basePatchData.value).length > 0
|
||||
|
||||
if (deletedIcon.value) {
|
||||
await deleteIcon()
|
||||
deletedIcon.value = false
|
||||
} else if (icon.value) {
|
||||
await patchIcon(icon.value)
|
||||
icon.value = null
|
||||
if (hasV2Changes) {
|
||||
await patchProject(basePatchData.value)
|
||||
}
|
||||
|
||||
if (deletedIcon.value) {
|
||||
await deleteIcon()
|
||||
deletedIcon.value = false
|
||||
} else if (icon.value) {
|
||||
await patchIcon(icon.value)
|
||||
icon.value = null
|
||||
}
|
||||
|
||||
if (deletedBanner.value) {
|
||||
await deleteBanner()
|
||||
deletedBanner.value = false
|
||||
} else if (bannerFile.value) {
|
||||
await uploadBanner()
|
||||
bannerFile.value = null
|
||||
bannerPreview.value = null
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +543,83 @@ const showPreviewImage = (files) => {
|
||||
}
|
||||
}
|
||||
|
||||
const showBannerPreview = (files) => {
|
||||
const file = files[0]
|
||||
if (file) {
|
||||
bannerFile.value = file
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
bannerPreview.value = e.target.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
deletedBanner.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const markBannerForDeletion = () => {
|
||||
bannerPreview.value = null
|
||||
bannerFile.value = null
|
||||
deletedBanner.value = true
|
||||
}
|
||||
|
||||
const uploadBanner = async () => {
|
||||
if (!bannerFile.value) return
|
||||
|
||||
try {
|
||||
// First, delete existing banner image if there is one
|
||||
const existingBanner = project.value.gallery?.find(
|
||||
(img) => img.title === MC_SERVER_BANNER_NAME,
|
||||
)
|
||||
if (existingBanner) {
|
||||
await labrinth.projects_v2.deleteGalleryImage(project.value.id, existingBanner.url)
|
||||
}
|
||||
|
||||
// Upload new banner as gallery image with special title
|
||||
const ext = bannerFile.value.type.split('/').pop() ?? 'png'
|
||||
await labrinth.projects_v2.createGalleryImage(project.value.id, bannerFile.value, {
|
||||
ext,
|
||||
featured: false,
|
||||
title: MC_SERVER_BANNER_NAME,
|
||||
})
|
||||
|
||||
await invalidate()
|
||||
addNotification({
|
||||
title: 'Banner updated',
|
||||
text: 'Your project banner has been updated.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'Failed to update banner',
|
||||
text: err.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBanner = async () => {
|
||||
try {
|
||||
const bannerImage = project.value.gallery?.find(
|
||||
(img) => img.title === MC_SERVER_BANNER_NAME,
|
||||
)
|
||||
if (bannerImage) {
|
||||
await labrinth.projects_v2.deleteGalleryImage(project.value.id, bannerImage.url)
|
||||
await invalidate()
|
||||
addNotification({
|
||||
title: 'Banner removed',
|
||||
text: 'Your project banner has been removed.',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'Failed to remove banner',
|
||||
text: err.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProject = async () => {
|
||||
await useBaseFetch(`project/${project.value.id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -409,10 +651,14 @@ const deleteIcon = async () => {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.visibility-info {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-xs);
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
@@ -435,15 +681,6 @@ svg {
|
||||
}
|
||||
}
|
||||
|
||||
.summary-input {
|
||||
min-height: 8rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.small-multiselect {
|
||||
max-width: 15rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<!-- Server Project Links -->
|
||||
<section v-if="isServerProject" class="universal-card">
|
||||
<h2>External links</h2>
|
||||
<div class="adjacent-input">
|
||||
<label id="server-website" title="Your server's website.">
|
||||
<span class="label__title">Website</span>
|
||||
<span class="label__description">Your server's official website.</span>
|
||||
</label>
|
||||
<input
|
||||
id="server-website"
|
||||
v-model="siteUrl"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label id="server-store" title="Your server's store page.">
|
||||
<span class="label__title">Store</span>
|
||||
<span class="label__description">A link to your server's store or shop.</span>
|
||||
</label>
|
||||
<input
|
||||
id="server-store"
|
||||
v-model="storeUrl"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
id="server-wiki"
|
||||
title="A page containing information, documentation, and help for the server."
|
||||
>
|
||||
<span class="label__title">Wiki page</span>
|
||||
<span class="label__description"
|
||||
>A page containing information, documentation, and help for the server.</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="server-wiki"
|
||||
v-model="serverWikiUrl"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label id="server-discord" title="An invitation link to your Discord server.">
|
||||
<span class="label__title">Discord</span>
|
||||
<span class="label__description">An invitation link to your Discord server.</span>
|
||||
</label>
|
||||
<input
|
||||
id="server-discord"
|
||||
v-model="serverDiscordUrl"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasServerChanges"
|
||||
@click="saveServerChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Standard Project Links -->
|
||||
<section v-if="!isServerProject" class="universal-card">
|
||||
<h2>External links</h2>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
@@ -174,17 +252,51 @@
|
||||
<script setup>
|
||||
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
|
||||
import { DropdownSelect, injectProjectPageContext, StyledInput } from '@modrinth/ui'
|
||||
import {
|
||||
DropdownSelect,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
||||
const {
|
||||
projectV2: project,
|
||||
projectV3,
|
||||
currentMember,
|
||||
patchProject,
|
||||
invalidate,
|
||||
} = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const issuesUrl = ref(project.value.issues_url)
|
||||
const sourceUrl = ref(project.value.source_url)
|
||||
const wikiUrl = ref(project.value.wiki_url)
|
||||
const discordUrl = ref(project.value.discord_url)
|
||||
|
||||
// Server project links
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
const siteUrl = ref(projectV3.value?.link_urls?.site?.url ?? '')
|
||||
const storeUrl = ref(projectV3.value?.link_urls?.store?.url ?? '')
|
||||
const serverWikiUrl = ref(projectV3.value?.link_urls?.wiki?.url ?? '')
|
||||
const serverDiscordUrl = ref(projectV3.value?.link_urls?.discord?.url ?? '')
|
||||
|
||||
watch(
|
||||
projectV3,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
siteUrl.value = newVal.link_urls?.site?.url ?? ''
|
||||
storeUrl.value = newVal.link_urls?.store?.url ?? ''
|
||||
serverWikiUrl.value = newVal.link_urls?.wiki?.url ?? ''
|
||||
serverDiscordUrl.value = newVal.link_urls?.discord?.url ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const isIssuesUrlCommon = computed(() => {
|
||||
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true
|
||||
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues)
|
||||
@@ -281,6 +393,56 @@ const hasChanges = computed(() => {
|
||||
return Object.keys(patchData.value).length > 0
|
||||
})
|
||||
|
||||
// Server project links
|
||||
const serverPatchData = computed(() => {
|
||||
const data = {}
|
||||
const originalSite = projectV3.value?.link_urls?.site?.url ?? ''
|
||||
const originalStore = projectV3.value?.link_urls?.store?.url ?? ''
|
||||
const originalWiki = projectV3.value?.link_urls?.wiki?.url ?? ''
|
||||
const originalDiscord = projectV3.value?.link_urls?.discord?.url ?? ''
|
||||
|
||||
if (checkDifference(siteUrl.value, originalSite)) {
|
||||
data.site = siteUrl.value === '' ? null : siteUrl.value?.trim()
|
||||
}
|
||||
if (checkDifference(storeUrl.value, originalStore)) {
|
||||
data.store = storeUrl.value === '' ? null : storeUrl.value?.trim()
|
||||
}
|
||||
if (checkDifference(serverWikiUrl.value, originalWiki)) {
|
||||
data.wiki = serverWikiUrl.value === '' ? null : serverWikiUrl.value?.trim()
|
||||
}
|
||||
if (checkDifference(serverDiscordUrl.value, originalDiscord)) {
|
||||
data.discord = serverDiscordUrl.value === '' ? null : serverDiscordUrl.value?.trim()
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
const hasServerChanges = computed(() => {
|
||||
return Object.keys(serverPatchData.value).length > 0
|
||||
})
|
||||
|
||||
async function saveServerChanges() {
|
||||
const linkUpdates = serverPatchData.value
|
||||
if (Object.keys(linkUpdates).length === 0) return
|
||||
|
||||
try {
|
||||
await labrinth.projects_v3.edit(project.value.id, {
|
||||
link_urls: linkUpdates,
|
||||
})
|
||||
await invalidate()
|
||||
addNotification({
|
||||
title: 'Links updated',
|
||||
text: 'Your server links have been updated.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'Failed to update links',
|
||||
text: err.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (patchData.value && (await patchProject(patchData.value))) {
|
||||
donationLinks.value = JSON.parse(JSON.stringify(project.value.donation_urls))
|
||||
@@ -321,13 +483,7 @@ function updateDonationLinks() {
|
||||
}
|
||||
|
||||
function checkDifference(newLink, existingLink) {
|
||||
if (newLink === '' && existingLink !== null) {
|
||||
return true
|
||||
}
|
||||
if (!newLink && !existingLink) {
|
||||
return false
|
||||
}
|
||||
return newLink !== existingLink
|
||||
return newLink != existingLink
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-2xl font-semibold text-contrast">Server details</div>
|
||||
|
||||
<!-- Country -->
|
||||
<div class="max-w-[600px]">
|
||||
<label for="server-country">
|
||||
<span class="label__title">Country</span>
|
||||
</label>
|
||||
<Combobox
|
||||
id="server-country"
|
||||
v-model="country"
|
||||
:options="countryOptions"
|
||||
searchable
|
||||
placeholder="Select country"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="max-w-[600px]">
|
||||
<label for="server-language">
|
||||
<span class="label__title">Language</span>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="server-language"
|
||||
v-model="languages"
|
||||
:options="languageOptions.map((l) => l.value)"
|
||||
:custom-label="(code) => languageOptions.find((l) => l.value === code)?.label ?? code"
|
||||
:multiple="true"
|
||||
:searchable="true"
|
||||
:show-labels="false"
|
||||
:close-on-select="false"
|
||||
placeholder="Select languages"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Java Address -->
|
||||
<div class="max-w-[600px]">
|
||||
<label for="java-address">
|
||||
<span class="label__title !text-contrast">Java address</span>
|
||||
</label>
|
||||
<div class="mt-2 flex items-center gap-2" @focusout="pingJavaServer">
|
||||
<StyledInput
|
||||
id="java-address"
|
||||
v-model="javaAddress"
|
||||
placeholder="Enter address"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="flex-grow"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<StyledInput
|
||||
v-model="javaPort"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="w-24"
|
||||
input-class="text-center"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="javaPingResult !== null"
|
||||
class="mt-2 flex items-center gap-1.5"
|
||||
:class="javaPingResult.online ? 'text-green' : 'text-orange'"
|
||||
>
|
||||
<CheckIcon v-if="javaPingResult.online" class="h-4 w-4" />
|
||||
<TriangleAlertIcon v-else class="h-4 w-4" />
|
||||
{{
|
||||
javaPingResult.online
|
||||
? `Server is online! ${javaPingResult.latency ? `Latency: ${javaPingResult.latency}ms` : ``}`
|
||||
: 'Cannot ping server'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bedrock Address -->
|
||||
<div class="max-w-[600px]">
|
||||
<label for="bedrock-address">
|
||||
<span class="label__title !text-contrast">Bedrock address</span>
|
||||
</label>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<StyledInput
|
||||
id="bedrock-address"
|
||||
v-model="bedrockAddress"
|
||||
placeholder="Enter address"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="flex-grow"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<StyledInput
|
||||
v-model="bedrockPort"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="w-24"
|
||||
input-class="text-center"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CompatibilityCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UnsavedChangesPopup
|
||||
:original="original"
|
||||
:modified="modified"
|
||||
:saving="saving"
|
||||
@reset="resetChanges"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Combobox,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
StyledInput,
|
||||
UnsavedChangesPopup,
|
||||
} from '@modrinth/ui'
|
||||
import { Multiselect } from 'vue-multiselect'
|
||||
|
||||
import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { projectV2: project, projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
|
||||
|
||||
const javaAddress = ref('')
|
||||
const javaPort = ref(25565)
|
||||
const bedrockAddress = ref('')
|
||||
const bedrockPort = ref(19132)
|
||||
const country = ref('')
|
||||
const languages = ref([])
|
||||
|
||||
function initFromProjectV3(v3) {
|
||||
if (!v3) return
|
||||
javaAddress.value = v3.minecraft_java_server?.address ?? ''
|
||||
javaPort.value = v3.minecraft_java_server?.port ?? 25565
|
||||
bedrockAddress.value = v3.minecraft_bedrock_server?.address ?? ''
|
||||
bedrockPort.value = v3.minecraft_bedrock_server?.port ?? 19132
|
||||
country.value = v3.minecraft_server?.country ?? ''
|
||||
languages.value = v3.minecraft_server?.languages ?? []
|
||||
}
|
||||
|
||||
// initialize projectV3 values once
|
||||
if (projectV3.value) {
|
||||
initFromProjectV3(projectV3.value)
|
||||
} else {
|
||||
const stop = watch(
|
||||
() => projectV3.value,
|
||||
(v3) => {
|
||||
if (!v3) return
|
||||
initFromProjectV3(v3)
|
||||
stop()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const javaPingLoading = ref(false)
|
||||
const javaPingResult = ref(null)
|
||||
|
||||
const pingJavaServer = async () => {
|
||||
const address = javaAddress.value?.trim()
|
||||
if (!address) {
|
||||
javaPingResult.value = null
|
||||
return
|
||||
}
|
||||
|
||||
javaPingLoading.value = true
|
||||
javaPingResult.value = null
|
||||
|
||||
const port = javaPort.value || 25565
|
||||
const query = port !== 25565 ? `${address}:${port}` : address
|
||||
|
||||
try {
|
||||
// TODO replace with api-client labrinth server ping route
|
||||
} catch {
|
||||
javaPingResult.value = { online: false, latency: null }
|
||||
} finally {
|
||||
javaPingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasPermission = computed(() => {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
|
||||
})
|
||||
|
||||
const countryOptions = [
|
||||
{ value: 'US', label: 'United States' },
|
||||
{ value: 'CA', label: 'Canada' },
|
||||
{ value: 'GB', label: 'United Kingdom' },
|
||||
{ value: 'DE', label: 'Germany' },
|
||||
{ value: 'FR', label: 'France' },
|
||||
{ value: 'NL', label: 'Netherlands' },
|
||||
{ value: 'FI', label: 'Finland' },
|
||||
{ value: 'SE', label: 'Sweden' },
|
||||
{ value: 'NO', label: 'Norway' },
|
||||
{ value: 'DK', label: 'Denmark' },
|
||||
{ value: 'PL', label: 'Poland' },
|
||||
{ value: 'CZ', label: 'Czech Republic' },
|
||||
{ value: 'RO', label: 'Romania' },
|
||||
{ value: 'CH', label: 'Switzerland' },
|
||||
{ value: 'AT', label: 'Austria' },
|
||||
{ value: 'BE', label: 'Belgium' },
|
||||
{ value: 'IE', label: 'Ireland' },
|
||||
{ value: 'ES', label: 'Spain' },
|
||||
{ value: 'IT', label: 'Italy' },
|
||||
{ value: 'PT', label: 'Portugal' },
|
||||
{ value: 'RU', label: 'Russia' },
|
||||
{ value: 'UA', label: 'Ukraine' },
|
||||
{ value: 'LT', label: 'Lithuania' },
|
||||
{ value: 'LV', label: 'Latvia' },
|
||||
{ value: 'EE', label: 'Estonia' },
|
||||
{ value: 'BG', label: 'Bulgaria' },
|
||||
{ value: 'HR', label: 'Croatia' },
|
||||
{ value: 'HU', label: 'Hungary' },
|
||||
{ value: 'SK', label: 'Slovakia' },
|
||||
{ value: 'RS', label: 'Serbia' },
|
||||
{ value: 'GR', label: 'Greece' },
|
||||
{ value: 'TR', label: 'Turkey' },
|
||||
{ value: 'IL', label: 'Israel' },
|
||||
{ value: 'AE', label: 'United Arab Emirates' },
|
||||
{ value: 'SA', label: 'Saudi Arabia' },
|
||||
{ value: 'IN', label: 'India' },
|
||||
{ value: 'SG', label: 'Singapore' },
|
||||
{ value: 'JP', label: 'Japan' },
|
||||
{ value: 'KR', label: 'South Korea' },
|
||||
{ value: 'CN', label: 'China' },
|
||||
{ value: 'HK', label: 'Hong Kong' },
|
||||
{ value: 'TW', label: 'Taiwan' },
|
||||
{ value: 'AU', label: 'Australia' },
|
||||
{ value: 'NZ', label: 'New Zealand' },
|
||||
{ value: 'BR', label: 'Brazil' },
|
||||
{ value: 'AR', label: 'Argentina' },
|
||||
{ value: 'CL', label: 'Chile' },
|
||||
{ value: 'CO', label: 'Colombia' },
|
||||
{ value: 'MX', label: 'Mexico' },
|
||||
{ value: 'ZA', label: 'South Africa' },
|
||||
{ value: 'NG', label: 'Nigeria' },
|
||||
{ value: 'KE', label: 'Kenya' },
|
||||
{ value: 'EG', label: 'Egypt' },
|
||||
{ value: 'MY', label: 'Malaysia' },
|
||||
{ value: 'TH', label: 'Thailand' },
|
||||
{ value: 'VN', label: 'Vietnam' },
|
||||
{ value: 'PH', label: 'Philippines' },
|
||||
{ value: 'ID', label: 'Indonesia' },
|
||||
{ value: 'PK', label: 'Pakistan' },
|
||||
{ value: 'BD', label: 'Bangladesh' },
|
||||
]
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Spanish' },
|
||||
{ value: 'pt', label: 'Portuguese' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'de', label: 'German' },
|
||||
{ value: 'it', label: 'Italian' },
|
||||
{ value: 'nl', label: 'Dutch' },
|
||||
{ value: 'ru', label: 'Russian' },
|
||||
{ value: 'uk', label: 'Ukrainian' },
|
||||
{ value: 'pl', label: 'Polish' },
|
||||
{ value: 'cs', label: 'Czech' },
|
||||
{ value: 'sk', label: 'Slovak' },
|
||||
{ value: 'hu', label: 'Hungarian' },
|
||||
{ value: 'ro', label: 'Romanian' },
|
||||
{ value: 'bg', label: 'Bulgarian' },
|
||||
{ value: 'hr', label: 'Croatian' },
|
||||
{ value: 'sr', label: 'Serbian' },
|
||||
{ value: 'el', label: 'Greek' },
|
||||
{ value: 'tr', label: 'Turkish' },
|
||||
{ value: 'ar', label: 'Arabic' },
|
||||
{ value: 'he', label: 'Hebrew' },
|
||||
{ value: 'hi', label: 'Hindi' },
|
||||
{ value: 'bn', label: 'Bengali' },
|
||||
{ value: 'ur', label: 'Urdu' },
|
||||
{ value: 'zh', label: 'Chinese' },
|
||||
{ value: 'ja', label: 'Japanese' },
|
||||
{ value: 'ko', label: 'Korean' },
|
||||
{ value: 'th', label: 'Thai' },
|
||||
{ value: 'vi', label: 'Vietnamese' },
|
||||
{ value: 'id', label: 'Indonesian' },
|
||||
{ value: 'ms', label: 'Malay' },
|
||||
{ value: 'tl', label: 'Filipino' },
|
||||
{ value: 'sv', label: 'Swedish' },
|
||||
{ value: 'no', label: 'Norwegian' },
|
||||
{ value: 'da', label: 'Danish' },
|
||||
{ value: 'fi', label: 'Finnish' },
|
||||
{ value: 'lt', label: 'Lithuanian' },
|
||||
{ value: 'lv', label: 'Latvian' },
|
||||
{ value: 'et', label: 'Estonian' },
|
||||
]
|
||||
|
||||
const javaServerPatchData = computed(() => {
|
||||
const origJava = projectV3.value?.minecraft_java_server
|
||||
|
||||
const addressChanged =
|
||||
javaAddress.value !== (origJava?.address ?? '') ||
|
||||
javaPort.value !== (origJava?.port ?? 25565)
|
||||
|
||||
if (addressChanged) {
|
||||
return {
|
||||
address: javaAddress.value.trim(),
|
||||
port: javaPort.value,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
const bedrockServerPatchData = computed(() => {
|
||||
const origBedrock = projectV3.value?.minecraft_bedrock_server
|
||||
if (
|
||||
bedrockAddress.value !== (origBedrock?.address ?? '') ||
|
||||
bedrockPort.value !== (origBedrock?.port ?? 19132)
|
||||
) {
|
||||
return {
|
||||
address: bedrockAddress.value.trim(),
|
||||
port: bedrockPort.value,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
const serverPatchData = computed(() => {
|
||||
const origServer = projectV3.value?.minecraft_server
|
||||
const countryChanged = country.value && country.value !== origServer?.country
|
||||
const languagesChanged =
|
||||
JSON.stringify([...languages.value].sort()) !==
|
||||
JSON.stringify([...(origServer?.languages ?? [])].sort())
|
||||
|
||||
if (countryChanged || languagesChanged) {
|
||||
return {
|
||||
...origServer,
|
||||
...(countryChanged ? { country: country.value } : {}),
|
||||
...(languagesChanged ? { languages: languages.value } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
const v3PatchData = computed(() => {
|
||||
const data = {}
|
||||
if (Object.keys(serverPatchData.value).length > 0) {
|
||||
data.minecraft_server = serverPatchData.value
|
||||
}
|
||||
if (Object.keys(javaServerPatchData.value).length > 0) {
|
||||
data.minecraft_java_server = javaServerPatchData.value
|
||||
}
|
||||
if (Object.keys(bedrockServerPatchData.value).length > 0) {
|
||||
data.minecraft_bedrock_server = bedrockServerPatchData.value
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const original = computed(() => ({
|
||||
javaAddress: projectV3.value?.minecraft_java_server?.address ?? '',
|
||||
javaPort: projectV3.value?.minecraft_java_server?.port ?? 25565,
|
||||
bedrockAddress: projectV3.value?.minecraft_bedrock_server?.address ?? '',
|
||||
bedrockPort: projectV3.value?.minecraft_bedrock_server?.port ?? 19132,
|
||||
country: projectV3.value?.minecraft_server?.country ?? '',
|
||||
languages: projectV3.value?.minecraft_server?.languages ?? [],
|
||||
}))
|
||||
|
||||
const modified = computed(() => ({
|
||||
javaAddress: javaAddress.value,
|
||||
javaPort: javaPort.value,
|
||||
bedrockAddress: bedrockAddress.value,
|
||||
bedrockPort: bedrockPort.value,
|
||||
country: country.value,
|
||||
languages: languages.value,
|
||||
}))
|
||||
|
||||
function resetChanges() {
|
||||
javaAddress.value = projectV3.value?.minecraft_java_server?.address ?? ''
|
||||
javaPort.value = projectV3.value?.minecraft_java_server?.port ?? 25565
|
||||
bedrockAddress.value = projectV3.value?.minecraft_bedrock_server?.address ?? ''
|
||||
bedrockPort.value = projectV3.value?.minecraft_bedrock_server?.port ?? 19132
|
||||
country.value = projectV3.value?.minecraft_server?.country ?? ''
|
||||
languages.value = projectV3.value?.minecraft_server?.languages ?? []
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const hasV3Changes = Object.keys(v3PatchData.value).length > 0
|
||||
if (hasV3Changes) {
|
||||
await patchProjectV3(v3PatchData.value)
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -31,7 +31,10 @@
|
||||
that apply.
|
||||
</p>
|
||||
|
||||
<p v-if="project.versions.length === 0" class="known-errors">
|
||||
<p
|
||||
v-if="project.versions.length === 0 && projectV3?.minecraft_server == null"
|
||||
class="known-errors"
|
||||
>
|
||||
Please upload a version first in order to select tags!
|
||||
</p>
|
||||
<template v-else>
|
||||
@@ -156,24 +159,28 @@ interface Category {
|
||||
const tags = useGeneratedState()
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const { projectV2: project, patchProject } = injectProjectPageContext()
|
||||
const { projectV2: project, projectV3, patchProject } = injectProjectPageContext()
|
||||
|
||||
const formatCategoryName = (categoryName: string) => {
|
||||
return formatCategory(formatMessage, categoryName)
|
||||
}
|
||||
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
|
||||
const matchesProjectType = (x: Category) =>
|
||||
x.project_type === project.value.actualProjectType ||
|
||||
(x.project_type === 'minecraft_java_server' && isServerProject.value)
|
||||
|
||||
const { saved, current, saving, reset, save } = useSavable(
|
||||
() => ({
|
||||
selectedTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
|
||||
(x: Category) =>
|
||||
x.project_type === project.value.actualProjectType &&
|
||||
matchesProjectType(x) &&
|
||||
(project.value.categories.includes(x.name) ||
|
||||
project.value.additional_categories.includes(x.name)),
|
||||
) as Category[],
|
||||
featuredTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
|
||||
(x: Category) =>
|
||||
x.project_type === project.value.actualProjectType &&
|
||||
project.value.categories.includes(x.name),
|
||||
(x: Category) => matchesProjectType(x) && project.value.categories.includes(x.name),
|
||||
) as Category[],
|
||||
}),
|
||||
async () => {
|
||||
@@ -217,7 +224,7 @@ const { saved, current, saving, reset, save } = useSavable(
|
||||
const categoryLists = computed(() => {
|
||||
const lists: Record<string, Category[]> = {}
|
||||
sortedCategories(tags.value, formatCategoryName, locale.value).forEach((x: Category) => {
|
||||
if (x.project_type === project.value.actualProjectType) {
|
||||
if (matchesProjectType(x)) {
|
||||
const header = x.header
|
||||
if (!lists[header]) {
|
||||
lists[header] = []
|
||||
@@ -230,7 +237,13 @@ const categoryLists = computed(() => {
|
||||
|
||||
const tooManyTagsWarning = computed(() => {
|
||||
const tagCount = current.value.selectedTags.length
|
||||
if (tagCount > 8) {
|
||||
if (projectV3?.value?.minecraft_server != null) {
|
||||
if (tagCount > 18) {
|
||||
return `You've selected ${tagCount} tags. Please reduce to 18 or fewer to keep your server focused and easier to discover.`
|
||||
} else if (tagCount > 12) {
|
||||
return `You've selected ${tagCount} tags. Consider reducing to 12 or fewer to keep your server focused and easier to discover.`
|
||||
}
|
||||
} else if (tagCount > 8) {
|
||||
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
</ProjectPageVersions>
|
||||
|
||||
<template v-if="!versions?.length">
|
||||
<div class="grid place-content-center py-10">
|
||||
<div class="grid place-items-center py-10">
|
||||
<svg
|
||||
width="250"
|
||||
height="200"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
link: '/dashboard/affiliate-links',
|
||||
label: formatMessage(commonMessages.affiliateLinksButton),
|
||||
icon: AffiliateIcon,
|
||||
shown: isAffiliate,
|
||||
shown: !!isAffiliate,
|
||||
},
|
||||
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon, matchNested: true },
|
||||
]"
|
||||
|
||||
@@ -159,7 +159,10 @@
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Projects</h2>
|
||||
<div class="input-group">
|
||||
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
|
||||
<button
|
||||
class="iconified-button brand-button"
|
||||
@click="$refs.modal_creation.show($event, { showTypeSelector: true })"
|
||||
>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(commonMessages.createAProjectButton) }}
|
||||
</button>
|
||||
@@ -454,6 +457,14 @@ async function bulkEditLinks() {
|
||||
await initUserProjects()
|
||||
if (user.value?.projects) {
|
||||
projects.value = updateSort(user.value.projects, 'Name', false)
|
||||
|
||||
// minecraft_java_server type determined from component on projectV3
|
||||
projects.value = projects.value.map((project) => {
|
||||
const projectV3 = user.value?.projectsV3?.find((p) => p.id === project.id)
|
||||
if (projectV3?.minecraft_server != null)
|
||||
return { ...project, project_type: 'minecraft_java_server' }
|
||||
return project
|
||||
})
|
||||
user.value?.projectsV3?.forEach((project) => {
|
||||
if (
|
||||
project.side_types_migration_review_status === 'pending' &&
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DropdownSelect,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
OpenInAppModal,
|
||||
Pagination,
|
||||
ProjectCard,
|
||||
ProjectCardList,
|
||||
@@ -33,11 +34,12 @@ import {
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useSearch,
|
||||
useServerSearch,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { useThrottleFn, useTimeoutFn } from '@vueuse/core'
|
||||
import { templateRef, useThrottleFn, useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, type Reactive, watch } from 'vue'
|
||||
|
||||
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
||||
@@ -84,6 +86,10 @@ const currentType = computed(() =>
|
||||
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
|
||||
)
|
||||
|
||||
watch(currentType, (newType) => {
|
||||
console.log('currentType changed:', newType)
|
||||
})
|
||||
|
||||
const projectType = computed(() => tags.value.projectTypes.find((x) => x.id === currentType.value))
|
||||
const projectTypes = computed(() => (projectType.value ? [projectType.value.id] : []))
|
||||
|
||||
@@ -99,6 +105,7 @@ const resultsDisplayMode = computed<DisplayMode>(() =>
|
||||
const server = ref<Reactive<ModrinthServer>>()
|
||||
const serverHideInstalled = ref(false)
|
||||
const eraseDataOnInstall = ref(false)
|
||||
const serverInAppModal = templateRef('openInAppModal')
|
||||
|
||||
const PERSISTENT_QUERY_PARAMS = ['sid', 'shi']
|
||||
|
||||
@@ -336,6 +343,28 @@ async function serverInstall(project: InstallableSearchResult) {
|
||||
}
|
||||
|
||||
const noLoad = ref(false)
|
||||
|
||||
const {
|
||||
serverCurrentSortType,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
serverSortTypes,
|
||||
serverFilterTypes,
|
||||
serverRequestParams,
|
||||
createServerPageParams,
|
||||
} = useServerSearch({ tags, query, maxResults, currentPage })
|
||||
|
||||
const effectiveSortType = computed({
|
||||
get: () => (currentType.value === 'server' ? serverCurrentSortType.value : currentSortType.value),
|
||||
set: (v: SortType) => {
|
||||
if (currentType.value === 'server') serverCurrentSortType.value = v
|
||||
else currentSortType.value = v
|
||||
},
|
||||
})
|
||||
const effectiveSortTypes = computed(() =>
|
||||
currentType.value === 'server' ? serverSortTypes : [...sortTypes],
|
||||
)
|
||||
|
||||
const {
|
||||
data: rawResults,
|
||||
refresh: refreshSearch,
|
||||
@@ -343,7 +372,12 @@ const {
|
||||
} = useLazyFetch(
|
||||
() => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
|
||||
if (currentType.value === 'server') {
|
||||
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
|
||||
return `${base}search${serverRequestParams.value}`
|
||||
}
|
||||
|
||||
return `${base}search${requestParams.value}`
|
||||
},
|
||||
@@ -395,7 +429,7 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
|
||||
|
||||
const params = {
|
||||
...persistentParams,
|
||||
...createPageParams(),
|
||||
...(currentType.value === 'server' ? createServerPageParams() : createPageParams()),
|
||||
}
|
||||
|
||||
router.replace({ path: route.path, query: params })
|
||||
@@ -406,6 +440,12 @@ watch([currentFilters], () => {
|
||||
updateSearchResults(1, false)
|
||||
})
|
||||
|
||||
watch([serverCurrentFilters, serverCurrentSortType], () => {
|
||||
if (currentType.value === 'server') {
|
||||
updateSearchResults(1, false)
|
||||
}
|
||||
})
|
||||
|
||||
const throttledSearch = useThrottleFn(() => updateSearchResults(), 500, true)
|
||||
|
||||
function cycleSearchDisplayMode() {
|
||||
@@ -444,6 +484,45 @@ useSeoMeta({
|
||||
ogTitle,
|
||||
ogDescription: description,
|
||||
})
|
||||
|
||||
const serverHits = computed(
|
||||
() =>
|
||||
((rawResults.value as unknown as Labrinth.Search.v3.SearchResults)
|
||||
?.hits as Labrinth.Search.v3.ResultSearchProject[]) ?? [],
|
||||
)
|
||||
|
||||
const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) => {
|
||||
const content = hit.minecraft_java_server?.content
|
||||
if (content?.kind === 'modpack') {
|
||||
const { project_name, project_icon, project_id } = content
|
||||
if (!project_name) return undefined
|
||||
return {
|
||||
name: project_name,
|
||||
icon: project_icon,
|
||||
onclick:
|
||||
project_id !== hit.project_id
|
||||
? () => {
|
||||
navigateTo(`/project/${project_id}`)
|
||||
}
|
||||
: undefined,
|
||||
showCustomModpackTooltip: project_id === hit.project_id,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function handleServerProjectPlay(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||
serverInAppModal.value?.show({
|
||||
serverProject: {
|
||||
name: project.name,
|
||||
slug: project.slug ?? '',
|
||||
numPlayers: project.minecraft_java_server?.ping?.data?.players_online,
|
||||
icon: project.icon_url ?? '',
|
||||
statusOnline: !!project.minecraft_java_server?.ping?.data,
|
||||
region: project.minecraft_server?.country,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
|
||||
@@ -547,41 +626,66 @@ useSeoMeta({
|
||||
@update:model-value="updateSearchResults()"
|
||||
/>
|
||||
</div>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in filters.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="currentFilters"
|
||||
v-model:toggled-groups="toggledGroups"
|
||||
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
|
||||
:provided-filters="serverFilters"
|
||||
:filter-type="filter"
|
||||
:class="
|
||||
filtersMenuOpen
|
||||
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised'
|
||||
"
|
||||
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
|
||||
content-class="mb-4 mx-3"
|
||||
inner-panel-class="p-1"
|
||||
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
|
||||
</template>
|
||||
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #locked-game_version>
|
||||
{{ formatMessage(messages.gameVersionProvidedByServer) }}
|
||||
</template>
|
||||
<template #locked-mod_loader>
|
||||
{{ formatMessage(messages.modLoaderProvidedByServer) }}
|
||||
</template>
|
||||
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
|
||||
</SearchSidebarFilter>
|
||||
<template v-if="currentType === 'server'">
|
||||
<SearchSidebarFilter
|
||||
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
|
||||
:key="`server-filter-${filterType.id}`"
|
||||
v-model:selected-filters="serverCurrentFilters"
|
||||
v-model:toggled-groups="serverToggledGroups"
|
||||
:provided-filters="serverFilters"
|
||||
:filter-type="filterType"
|
||||
:class="
|
||||
filtersMenuOpen
|
||||
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised'
|
||||
"
|
||||
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
|
||||
content-class="mb-4 mx-3"
|
||||
inner-panel-class="p-1"
|
||||
:open-by-default="!['server_category_minecraft_server_meta', 'server_category_minecraft_server_community', 'server_game_version'].includes(filterType.id)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="m-0 text-lg">{{ filterType.formatted_name }}</h3>
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in filters.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="currentFilters"
|
||||
v-model:toggled-groups="toggledGroups"
|
||||
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
|
||||
:provided-filters="serverFilters"
|
||||
:filter-type="filter"
|
||||
:class="
|
||||
filtersMenuOpen
|
||||
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised'
|
||||
"
|
||||
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
|
||||
content-class="mb-4 mx-3"
|
||||
inner-panel-class="p-1"
|
||||
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
|
||||
</template>
|
||||
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #locked-game_version>
|
||||
{{ formatMessage(messages.gameVersionProvidedByServer) }}
|
||||
</template>
|
||||
<template #locked-mod_loader>
|
||||
{{ formatMessage(messages.modLoaderProvidedByServer) }}
|
||||
</template>
|
||||
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="normal-page__content">
|
||||
@@ -601,10 +705,10 @@ useSeoMeta({
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="currentSortType"
|
||||
v-model="effectiveSortType"
|
||||
class="!w-auto flex-grow md:flex-grow-0"
|
||||
name="Sort by"
|
||||
:options="[...sortTypes]"
|
||||
:options="effectiveSortTypes"
|
||||
:display-name="(option?: SortType) => option?.display"
|
||||
@change="updateSearchResults()"
|
||||
>
|
||||
@@ -650,6 +754,14 @@ useSeoMeta({
|
||||
/>
|
||||
</div>
|
||||
<SearchFilterControl
|
||||
v-if="currentType === 'server'"
|
||||
v-model:selected-filters="serverCurrentFilters"
|
||||
:filters="serverFilterTypes"
|
||||
:provided-filters="[]"
|
||||
:overridden-provided-filter-types="[]"
|
||||
/>
|
||||
<SearchFilterControl
|
||||
v-else
|
||||
v-model:selected-filters="currentFilters"
|
||||
:filters="filters.filter((f) => f.display !== 'none')"
|
||||
:provided-filters="serverFilters"
|
||||
@@ -657,7 +769,14 @@ useSeoMeta({
|
||||
:provided-message="messages.providedByServer"
|
||||
/>
|
||||
<LogoAnimated v-if="searchLoading && !noLoad" />
|
||||
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
|
||||
<div
|
||||
v-else-if="
|
||||
currentType === 'server'
|
||||
? serverHits.length === 0
|
||||
: results && results.hits && results.hits.length === 0
|
||||
"
|
||||
class="no-results"
|
||||
>
|
||||
<p>No results found for your query!</p>
|
||||
</div>
|
||||
<div v-else class="search-results-container">
|
||||
@@ -667,90 +786,117 @@ useSeoMeta({
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
>
|
||||
<ProjectCard
|
||||
v-for="result in results?.hits"
|
||||
:key="result.project_id"
|
||||
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
|
||||
:title="result.title"
|
||||
:icon-url="result.icon_url"
|
||||
:author="{ name: result.author, link: `/user/${result.author}` }"
|
||||
:date-updated="result.date_modified"
|
||||
:date-published="result.date_created"
|
||||
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
|
||||
:downloads="result.downloads"
|
||||
:summary="result.description"
|
||||
:tags="result.display_categories"
|
||||
:all-tags="result.categories"
|
||||
:deprioritized-tags="deprioritizedTags"
|
||||
:exclude-loaders="excludeLoaders"
|
||||
:followers="result.follows"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
:color="result.color ?? undefined"
|
||||
:environment="
|
||||
['mod', 'modpack'].includes(currentType)
|
||||
? {
|
||||
clientSide: result.client_side,
|
||||
serverSide: result.server_side,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:layout="
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
@mouseenter="handleProjectMouseEnter(result)"
|
||||
@mouseleave="handleProjectHoverEnd"
|
||||
>
|
||||
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
|
||||
<template v-if="flags.showDiscoverProjectButtons">
|
||||
<ButtonStyled color="brand">
|
||||
<button>
|
||||
<DownloadIcon />
|
||||
Download
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<HeartIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<BookmarkIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button>
|
||||
<MoreVerticalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template v-if="currentType === 'server'">
|
||||
<ProjectCard
|
||||
v-for="project in serverHits"
|
||||
:key="`server-card-${project.project_id}`"
|
||||
:title="project.name"
|
||||
:icon-url="project.icon_url || undefined"
|
||||
:summary="project.summary"
|
||||
:tags="project.categories"
|
||||
:link="`/server/${project.slug}`"
|
||||
:server-online-players="
|
||||
project.minecraft_java_server?.ping?.data?.players_online ?? 0
|
||||
"
|
||||
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
|
||||
:server-region-code="project.minecraft_server?.country"
|
||||
:server-status-online="!!project.minecraft_java_server?.ping?.data"
|
||||
:server-modpack-content="getServerModpackContent(project)"
|
||||
:layout="
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
:max-tags="2"
|
||||
is-server-project
|
||||
exclude-loaders
|
||||
>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ProjectCard
|
||||
v-for="result in results?.hits"
|
||||
:key="result.project_id"
|
||||
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
|
||||
:title="result.title"
|
||||
:icon-url="result.icon_url"
|
||||
:author="{ name: result.author, link: `/user/${result.author}` }"
|
||||
:date-updated="result.date_modified"
|
||||
:date-published="result.date_created"
|
||||
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
|
||||
:downloads="result.downloads"
|
||||
:summary="result.description"
|
||||
:tags="result.display_categories"
|
||||
:all-tags="result.categories"
|
||||
:deprioritized-tags="deprioritizedTags"
|
||||
:exclude-loaders="excludeLoaders"
|
||||
:followers="result.follows"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
:color="result.color ?? undefined"
|
||||
:environment="
|
||||
['mod', 'modpack'].includes(currentType)
|
||||
? {
|
||||
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
|
||||
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:layout="
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
@mouseenter="handleProjectMouseEnter(result)"
|
||||
@mouseleave="handleProjectHoverEnd"
|
||||
>
|
||||
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
|
||||
<template v-if="flags.showDiscoverProjectButtons">
|
||||
<ButtonStyled color="brand">
|
||||
<button>
|
||||
<DownloadIcon />
|
||||
Download
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<HeartIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<BookmarkIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button>
|
||||
<MoreVerticalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="server">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
v-if="
|
||||
(result as InstallableSearchResult).installed ||
|
||||
(server?.content?.data &&
|
||||
server.content.data.find(
|
||||
(x: InstallableMod) => x.project_id === result.project_id,
|
||||
)) ||
|
||||
server.general?.project?.id === result.project_id
|
||||
"
|
||||
disabled
|
||||
>
|
||||
<CheckIcon />
|
||||
Installed
|
||||
</button>
|
||||
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
|
||||
Installing...
|
||||
</button>
|
||||
<button v-else @click="serverInstall(result as InstallableSearchResult)">
|
||||
<DownloadIcon />
|
||||
Install
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="server">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
v-if="
|
||||
(result as InstallableSearchResult).installed ||
|
||||
(server?.content?.data &&
|
||||
server.content.data.find(
|
||||
(x: InstallableMod) => x.project_id === result.project_id,
|
||||
)) ||
|
||||
server.general?.project?.id === result.project_id
|
||||
"
|
||||
disabled
|
||||
>
|
||||
<CheckIcon />
|
||||
Installed
|
||||
</button>
|
||||
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
|
||||
Installing...
|
||||
</button>
|
||||
<button v-else @click="serverInstall(result as InstallableSearchResult)">
|
||||
<DownloadIcon />
|
||||
Install
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
</ProjectCardList>
|
||||
</div>
|
||||
<div class="pagination-after">
|
||||
@@ -763,6 +909,8 @@ useSeoMeta({
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OpenInAppModal ref="openInAppModal" />
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.normal-page__content {
|
||||
|
||||
@@ -221,6 +221,7 @@ const filterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Data Packs', label: 'Data Packs' },
|
||||
{ value: 'Plugins', label: 'Plugins' },
|
||||
{ value: 'Shaders', label: 'Shaders' },
|
||||
{ value: 'Servers', label: 'Servers' },
|
||||
]
|
||||
|
||||
const currentSortType = ref('Oldest')
|
||||
@@ -282,15 +283,17 @@ const typeFiltered = computed(() => {
|
||||
'Data Packs': 'datapack',
|
||||
Plugins: 'plugin',
|
||||
Shaders: 'shader',
|
||||
Servers: 'minecraft_java_server',
|
||||
}
|
||||
|
||||
const projectType = filterMap[currentFilterType.value]
|
||||
if (!projectType) return baseFiltered.value
|
||||
|
||||
return baseFiltered.value.filter(
|
||||
(queueItem) =>
|
||||
queueItem.project.project_types.length > 0 &&
|
||||
queueItem.project.project_types[0] === projectType,
|
||||
(queueItem.project.project_types.length > 0 &&
|
||||
queueItem.project.project_types[0] === projectType) ||
|
||||
(projectType === 'minecraft_java_server' &&
|
||||
queueItem.project.project_types.includes('minecraft_java_server')),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ const onDeleteOrganization = useClientTry(async () => {
|
||||
multiline
|
||||
:maxlength="256"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="summary-input"
|
||||
resize="vertical"
|
||||
/>
|
||||
</div>
|
||||
<div class="universal-card">
|
||||
@@ -245,10 +245,3 @@ const onDeleteOrganization = useClientTry(async () => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.summary-input {
|
||||
min-height: 8rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -159,7 +159,10 @@
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Projects</h2>
|
||||
<div class="input-group">
|
||||
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
|
||||
<button
|
||||
class="iconified-button brand-button"
|
||||
@click="$refs.modal_creation.show($event, { showTypeSelector: true })"
|
||||
>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(commonMessages.createAProjectButton) }}
|
||||
</button>
|
||||
|
||||
@@ -48,6 +48,7 @@ export default defineNuxtPlugin({
|
||||
modpack: 'list',
|
||||
shader: 'gallery',
|
||||
datapack: 'list',
|
||||
server: 'list',
|
||||
user: 'list',
|
||||
collection: 'list',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import type { Labrinth, UploadProgress } from '@modrinth/api-client'
|
||||
import { ArrowLeftRightIcon, LeftArrowIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
createContext,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
type MultiStageModal,
|
||||
type StageConfigInput,
|
||||
} from '@modrinth/ui'
|
||||
import JSZip from 'jszip'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { markRaw, toRaw } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import SelectCompatibilityType from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectCompatibilityType.vue'
|
||||
import SelectPublishedModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue'
|
||||
import SelectVanillaVersions from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectVanillaVersions.vue'
|
||||
import UploadCustomModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/UploadCustomModpack.vue'
|
||||
|
||||
export type CompatibilityType = 'vanilla' | 'published-modpack' | 'custom-modpack'
|
||||
|
||||
export interface ServerCompatibilityContextValue {
|
||||
// Stage management
|
||||
stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[]
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
|
||||
isSubmitting: Ref<boolean>
|
||||
isUploading: Ref<boolean>
|
||||
uploadProgress: Ref<UploadProgress>
|
||||
|
||||
// State
|
||||
compatibilityType: Ref<CompatibilityType | null>
|
||||
selectedProjectId: Ref<string>
|
||||
selectedVersionId: Ref<string>
|
||||
supportedGameVersions: Ref<string[]>
|
||||
recommendedGameVersion: Ref<string | null>
|
||||
customModpackFile: Ref<File | null>
|
||||
hasLicensePermission: Ref<boolean>
|
||||
isEditingExistingCompatibility: Ref<boolean>
|
||||
isSwitchingCompatibilityType: Ref<boolean>
|
||||
|
||||
// Actions
|
||||
resetContext: () => void
|
||||
handleSave: () => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectServerCompatibilityContext, provideServerCompatibilityContext] =
|
||||
createContext<ServerCompatibilityContextValue>('ServerCompatibilityModal')
|
||||
|
||||
export function createServerCompatibilityContext(
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
|
||||
): ServerCompatibilityContextValue {
|
||||
const { projectV3, patchProjectV3 } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadProgress = ref<UploadProgress>({ loaded: 0, total: 0, progress: 0 })
|
||||
const compatibilityType = ref<CompatibilityType | null>(null)
|
||||
const selectedProjectId = ref('')
|
||||
const selectedVersionId = ref('')
|
||||
const supportedGameVersions = ref<string[]>([])
|
||||
const recommendedGameVersion = ref<string | null>(null)
|
||||
const customModpackFile = ref<File | null>(null)
|
||||
const hasLicensePermission = ref(false)
|
||||
const isEditingExistingCompatibility = ref(false)
|
||||
const isSwitchingCompatibilityType = ref(false)
|
||||
|
||||
async function uploadCustomModpackFile(file: File): Promise<Labrinth.Versions.v3.Version> {
|
||||
const rawFile = toRaw(file)
|
||||
|
||||
// Default to filename if we can't parse the mrpack
|
||||
let versionName = rawFile.name.replace(/\.(zip|mrpack)$/i, '')
|
||||
let versionNumber = versionName
|
||||
const loaders: string[] = []
|
||||
let gameVersions: string[] = []
|
||||
|
||||
try {
|
||||
const zip = await JSZip.loadAsync(rawFile)
|
||||
const indexFile = zip.file('modrinth.index.json')
|
||||
|
||||
if (indexFile) {
|
||||
const indexContent = await indexFile.async('text')
|
||||
const metadata = JSON.parse(indexContent) as {
|
||||
name?: string
|
||||
versionId?: string
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
if (metadata.name) {
|
||||
versionName = metadata.name
|
||||
}
|
||||
if (metadata.versionId) {
|
||||
versionNumber = metadata.versionId
|
||||
}
|
||||
if (metadata.dependencies) {
|
||||
if ('forge' in metadata.dependencies) loaders.push('forge')
|
||||
if ('neoforge' in metadata.dependencies) loaders.push('neoforge')
|
||||
if ('fabric-loader' in metadata.dependencies) loaders.push('fabric')
|
||||
if ('quilt-loader' in metadata.dependencies) loaders.push('quilt')
|
||||
if (metadata.dependencies.minecraft) {
|
||||
gameVersions = [metadata.dependencies.minecraft]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn('Could not parse modrinth.index.json from mrpack')
|
||||
}
|
||||
|
||||
const draftVersion: Labrinth.Versions.v3.DraftVersion = {
|
||||
project_id: projectV3.value.id,
|
||||
name: versionName,
|
||||
version_number: versionNumber,
|
||||
version_type: 'release',
|
||||
loaders,
|
||||
game_versions: gameVersions,
|
||||
featured: false,
|
||||
status: 'listed',
|
||||
changelog: '',
|
||||
dependencies: [],
|
||||
environment: 'client_and_server',
|
||||
}
|
||||
|
||||
const files: Labrinth.Versions.v3.DraftVersionFile[] = [{ file: rawFile, fileType: undefined }]
|
||||
|
||||
const uploadHandle = labrinth.versions_v3.createVersion(draftVersion, files, 'modpack')
|
||||
|
||||
uploadHandle.onProgress((progress) => {
|
||||
uploadProgress.value = progress
|
||||
})
|
||||
|
||||
return await uploadHandle.promise
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSubmitting.value = true
|
||||
setTimeout(() => {
|
||||
if (compatibilityType.value === 'custom-modpack' && isSubmitting.value === true) {
|
||||
isUploading.value = true
|
||||
uploadProgress.value = { loaded: 0, total: 0, progress: 0 }
|
||||
}
|
||||
}, 1500)
|
||||
try {
|
||||
switch (compatibilityType.value) {
|
||||
case 'vanilla':
|
||||
await patchProjectV3({
|
||||
minecraft_java_server: {
|
||||
content: {
|
||||
kind: 'vanilla',
|
||||
supported_game_versions: supportedGameVersions.value,
|
||||
recommended_game_version: recommendedGameVersion.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
case 'published-modpack':
|
||||
await patchProjectV3({
|
||||
minecraft_java_server: {
|
||||
content: {
|
||||
kind: 'modpack',
|
||||
version_id: selectedVersionId.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
case 'custom-modpack': {
|
||||
if (!customModpackFile.value) break
|
||||
|
||||
// Upload the modpack file as a new version
|
||||
let uploadedVersion: Labrinth.Versions.v3.Version
|
||||
uploadedVersion = await uploadCustomModpackFile(customModpackFile.value)
|
||||
|
||||
// Patch the project to point to the newly uploaded version
|
||||
try {
|
||||
await patchProjectV3({
|
||||
minecraft_java_server: {
|
||||
content: {
|
||||
kind: 'modpack',
|
||||
version_id: uploadedVersion.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
// If patch fails, clean up the uploaded version
|
||||
try {
|
||||
await labrinth.versions_v3.deleteVersion(uploadedVersion.id)
|
||||
} catch {
|
||||
console.error('Failed to clean up uploaded version after patch failure')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isUploading.value = false
|
||||
isSubmitting.value = false
|
||||
await nextTick()
|
||||
modal.value?.hide()
|
||||
} catch (err) {
|
||||
isUploading.value = false
|
||||
isSubmitting.value = false
|
||||
|
||||
const error = err as { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: 'Failed to save server compatibility',
|
||||
text: error.data?.description || String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resetContext() {
|
||||
compatibilityType.value = null
|
||||
selectedProjectId.value = ''
|
||||
selectedVersionId.value = ''
|
||||
supportedGameVersions.value = []
|
||||
recommendedGameVersion.value = null
|
||||
customModpackFile.value = null
|
||||
hasLicensePermission.value = false
|
||||
isEditingExistingCompatibility.value = false
|
||||
isSwitchingCompatibilityType.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
stageConfigs,
|
||||
modal,
|
||||
isSubmitting,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
compatibilityType,
|
||||
selectedProjectId,
|
||||
selectedVersionId,
|
||||
supportedGameVersions,
|
||||
recommendedGameVersion,
|
||||
customModpackFile,
|
||||
hasLicensePermission,
|
||||
isEditingExistingCompatibility,
|
||||
isSwitchingCompatibilityType,
|
||||
resetContext,
|
||||
handleSave,
|
||||
}
|
||||
}
|
||||
|
||||
const selectCompatibilityTypeStage: StageConfigInput<ServerCompatibilityContextValue> = {
|
||||
id: 'select-compatibility-type',
|
||||
stageContent: markRaw(SelectCompatibilityType),
|
||||
title: 'Compatibility type',
|
||||
cannotNavigateForward: (ctx) => !ctx.compatibilityType.value,
|
||||
leftButtonConfig: null,
|
||||
rightButtonConfig: null,
|
||||
}
|
||||
|
||||
const selectVanillaVersionsStage: StageConfigInput<ServerCompatibilityContextValue> = {
|
||||
id: 'select-vanilla-versions',
|
||||
stageContent: markRaw(SelectVanillaVersions),
|
||||
title: 'Vanilla versions',
|
||||
skip: (ctx) => ctx.compatibilityType.value !== 'vanilla' && !!ctx.compatibilityType.value,
|
||||
leftButtonConfig: (ctx) =>
|
||||
ctx.isEditingExistingCompatibility.value
|
||||
? {
|
||||
label: 'Cancel',
|
||||
icon: XIcon,
|
||||
onClick: () => ctx.modal.value?.hide(),
|
||||
}
|
||||
: {
|
||||
label: 'Back',
|
||||
icon: LeftArrowIcon,
|
||||
onClick: () => ctx.modal.value?.prevStage(),
|
||||
},
|
||||
rightButtonConfig: (ctx) =>
|
||||
ctx.isSwitchingCompatibilityType.value
|
||||
? {
|
||||
label: 'Change type',
|
||||
icon: ArrowLeftRightIcon,
|
||||
iconPosition: 'before' as const,
|
||||
color: 'red' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value ||
|
||||
ctx.supportedGameVersions.value.length === 0 ||
|
||||
!ctx.recommendedGameVersion.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
}
|
||||
: {
|
||||
label: ctx.isSubmitting.value
|
||||
? ctx.isEditingExistingCompatibility.value
|
||||
? 'Updating…'
|
||||
: 'Saving…'
|
||||
: ctx.isEditingExistingCompatibility.value
|
||||
? 'Save changes'
|
||||
: 'Save',
|
||||
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
|
||||
iconPosition: 'before' as const,
|
||||
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
|
||||
color: 'green' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value ||
|
||||
ctx.supportedGameVersions.value.length === 0 ||
|
||||
!ctx.recommendedGameVersion.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
},
|
||||
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
|
||||
}
|
||||
|
||||
const selectPublishedModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
|
||||
id: 'select-published-modpack',
|
||||
stageContent: markRaw(SelectPublishedModpack),
|
||||
title: 'Select modpack',
|
||||
skip: (ctx) => ctx.compatibilityType.value !== 'published-modpack',
|
||||
cannotNavigateForward: (ctx) => !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
|
||||
leftButtonConfig: (ctx) =>
|
||||
ctx.isEditingExistingCompatibility.value
|
||||
? {
|
||||
label: 'Cancel',
|
||||
icon: XIcon,
|
||||
onClick: () => ctx.modal.value?.hide(),
|
||||
}
|
||||
: {
|
||||
label: 'Back',
|
||||
icon: LeftArrowIcon,
|
||||
onClick: () => ctx.modal.value?.prevStage(),
|
||||
},
|
||||
rightButtonConfig: (ctx) =>
|
||||
ctx.isSwitchingCompatibilityType.value
|
||||
? {
|
||||
label: 'Change type',
|
||||
icon: ArrowLeftRightIcon,
|
||||
iconPosition: 'before' as const,
|
||||
color: 'red' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
}
|
||||
: {
|
||||
label: ctx.isSubmitting.value
|
||||
? ctx.isEditingExistingCompatibility.value
|
||||
? 'Updating…'
|
||||
: 'Saving…'
|
||||
: ctx.isEditingExistingCompatibility.value
|
||||
? 'Save changes'
|
||||
: 'Save',
|
||||
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
|
||||
iconPosition: 'before' as const,
|
||||
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
|
||||
color: 'green' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
},
|
||||
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
|
||||
}
|
||||
|
||||
function getUploadLabel(ctx: ServerCompatibilityContextValue): string {
|
||||
if (ctx.isUploading.value) {
|
||||
if (ctx.uploadProgress.value.progress >= 1) {
|
||||
return 'Saving…'
|
||||
}
|
||||
return `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
|
||||
}
|
||||
if (ctx.isSubmitting.value) {
|
||||
return ctx.isEditingExistingCompatibility.value ? 'Updating…' : 'Saving…'
|
||||
}
|
||||
return ctx.isEditingExistingCompatibility.value ? 'Save changes' : 'Save'
|
||||
}
|
||||
|
||||
const uploadCustomModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
|
||||
id: 'upload-custom-modpack',
|
||||
stageContent: markRaw(UploadCustomModpack),
|
||||
title: 'Custom modpack',
|
||||
skip: (ctx) => ctx.compatibilityType.value !== 'custom-modpack',
|
||||
disableClose: (ctx) => ctx.isUploading.value,
|
||||
leftButtonConfig: (ctx) =>
|
||||
ctx.isEditingExistingCompatibility.value
|
||||
? {
|
||||
label: 'Cancel',
|
||||
icon: XIcon,
|
||||
disabled: ctx.isUploading.value,
|
||||
onClick: () => ctx.modal.value?.hide(),
|
||||
}
|
||||
: {
|
||||
label: 'Back',
|
||||
icon: LeftArrowIcon,
|
||||
disabled: ctx.isUploading.value,
|
||||
onClick: () => ctx.modal.value?.prevStage(),
|
||||
},
|
||||
rightButtonConfig: (ctx) =>
|
||||
ctx.isSwitchingCompatibilityType.value
|
||||
? {
|
||||
label: ctx.isUploading.value
|
||||
? `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
|
||||
: 'Change type',
|
||||
icon: ctx.isSubmitting.value ? SpinnerIcon : ArrowLeftRightIcon,
|
||||
iconPosition: 'before' as const,
|
||||
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
|
||||
color: 'red' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value ||
|
||||
!ctx.customModpackFile.value ||
|
||||
!ctx.hasLicensePermission.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
buttonClass: 'tabular-nums',
|
||||
}
|
||||
: {
|
||||
label: getUploadLabel(ctx),
|
||||
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
|
||||
iconPosition: 'before' as const,
|
||||
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
|
||||
color: 'green' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value ||
|
||||
!ctx.customModpackFile.value ||
|
||||
!ctx.hasLicensePermission.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
buttonClass: 'tabular-nums',
|
||||
},
|
||||
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
|
||||
}
|
||||
|
||||
const stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[] = [
|
||||
selectCompatibilityTypeStage,
|
||||
selectVanillaVersionsStage,
|
||||
selectPublishedModpackStage,
|
||||
uploadCustomModpackStage,
|
||||
]
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -41,6 +41,14 @@ const projectTypeMessages = defineMessages({
|
||||
id: 'project-type.resourcepack.plural',
|
||||
defaultMessage: 'Resource Packs',
|
||||
},
|
||||
server: {
|
||||
id: 'project-type.server.singular',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
servers: {
|
||||
id: 'project-type.server.plural',
|
||||
defaultMessage: 'Servers',
|
||||
},
|
||||
shader: {
|
||||
id: 'project-type.shader.singular',
|
||||
defaultMessage: 'Shader',
|
||||
|
||||
@@ -157,3 +157,5 @@ MURALPAY_TRANSFER_API_KEY=none
|
||||
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
|
||||
|
||||
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
|
||||
SERVER_PING_TIMEOUT=10000
|
||||
SERVER_PING_RETRIES=3
|
||||
|
||||
@@ -167,3 +167,6 @@ MURALPAY_TRANSFER_API_KEY=none
|
||||
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
|
||||
|
||||
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
|
||||
SERVER_PING_TIMEOUT=10000
|
||||
SERVER_PING_RETRIES=3
|
||||
SERVER_PING_MIN_INTERVAL_SEC=1800
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ",
|
||||
"query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -16,10 +16,11 @@
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Int4"
|
||||
"Int4",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce"
|
||||
"hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE mods\n SET components = $1\n WHERE id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "46f309eb085e487bf868d4fee5170eef9d1af5ff41b097b3552de86d3940e01e"
|
||||
}
|
||||
Generated
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, components AS \"components: Json<exp::ProjectSerial>\"\n FROM mods\n WHERE components ? 'minecraft_java_server'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "components: Json<exp::ProjectSerial>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "591bf29b883b66b111496f8c07ebd2619cd3bcf92f637ca99876c14bd44c85f6"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ",
|
||||
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,\n m.components AS \"components: sqlx::types::Json<exp::ProjectSerial>\"\n\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -137,6 +137,11 @@
|
||||
"ordinal": 26,
|
||||
"name": "additional_categories",
|
||||
"type_info": "VarcharArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "components: sqlx::types::Json<exp::ProjectSerial>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -172,8 +177,9 @@
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62"
|
||||
"hash": "59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering\n FROM versions v\n WHERE v.id = ANY($1);\n ",
|
||||
"query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n v.components AS \"components: sqlx::types::Json<exp::VersionSerial>\"\n FROM versions v\n WHERE v.id = ANY($1);\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"ordinal": 12,
|
||||
"name": "ordering",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "components: sqlx::types::Json<exp::VersionSerial>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -87,8 +92,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6"
|
||||
"hash": "760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(\n SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1\n )",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.updated updated, m.approved approved, m.published, m.license license, m.slug slug, m.color\n FROM mods m\n WHERE m.status = ANY($1) AND m.id > $3\n GROUP BY m.id\n ORDER BY m.id ASC\n LIMIT $2;\n ",
|
||||
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.updated updated, m.approved approved, m.published, m.license license, m.slug slug, m.color,\n m.components AS \"components: sqlx::types::Json<exp::ProjectSerial>\"\n FROM mods m\n WHERE m.status = ANY($1) AND m.id > $3\n GROUP BY m.id\n ORDER BY m.id ASC\n LIMIT $2;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -62,6 +62,11 @@
|
||||
"ordinal": 11,
|
||||
"name": "color",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "components: sqlx::types::Json<exp::ProjectSerial>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -83,8 +88,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "702a2826d5857dc51b1a7a79c9043ae8987441bb5e89c9ea62d347e47899e3c2"
|
||||
"hash": "b760951120c1885530f3fb8208a5874fc472ec160a673abeeee10c778b9cf23f"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18\n )\n ",
|
||||
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status,\n components\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18,\n $19\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -22,10 +22,11 @@
|
||||
"Int4",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Varchar"
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702"
|
||||
"hash": "cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192"
|
||||
}
|
||||
Generated
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, slug FROM mods\n WHERE id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "slug",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f3da0156702de4b91b6cf3cb4c34c38807b2594efab47312066c71b85897fec5"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n v.id AS \"version_id: DBVersionId\",\n m.id AS \"project_id: DBProjectId\",\n m.name AS \"project_name!\",\n COALESCE(m.icon_url, '') AS \"project_icon!\"\n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n WHERE v.id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "version_id: DBVersionId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "project_name!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "project_icon!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fc99699ff89c3245c7288718ff15d16058b00bf5df286923c0c6b1db2dbbde4f"
|
||||
}
|
||||
@@ -21,6 +21,7 @@ actix-ws = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
ariadne = { workspace = true }
|
||||
async-minecraft-ping = { workspace = true }
|
||||
async-stripe = { workspace = true, features = [
|
||||
"billing",
|
||||
"checkout",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- mistake from previous migration `20260218183440_server_project_links_categories`
|
||||
DELETE FROM categories
|
||||
WHERE
|
||||
header = 'minecraft_server_community'
|
||||
AND category = 'adventure-mode';
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::database::PgPool;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::queue::analytics::cache::cache_analytics;
|
||||
use crate::queue::billing::{index_billing, index_subscriptions};
|
||||
use crate::queue::email::EmailQueue;
|
||||
use crate::queue::payouts::{
|
||||
@@ -11,7 +12,8 @@ use crate::search::indexing::index_projects;
|
||||
use crate::util::anrok;
|
||||
use crate::{database, search};
|
||||
use clap::ValueEnum;
|
||||
use tracing::{error, info, warn};
|
||||
use eyre::WrapErr;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
|
||||
#[clap(rename_all = "kebab_case")]
|
||||
@@ -25,6 +27,12 @@ pub enum BackgroundTask {
|
||||
IndexSubscriptions,
|
||||
Migrations,
|
||||
Mail,
|
||||
/// Queries server project analytics (e.g. number of verified plays in last
|
||||
/// 2 weeks for server projects) and caches them in Redis.
|
||||
CacheAnalytics,
|
||||
/// Attempts to ping Minecraft Java servers as if we were a client, to
|
||||
/// collect info on if they're online, game version, description, etc.
|
||||
PingMinecraftJavaServers,
|
||||
}
|
||||
|
||||
impl BackgroundTask {
|
||||
@@ -40,7 +48,7 @@ impl BackgroundTask {
|
||||
anrok_client: anrok::Client,
|
||||
email_queue: EmailQueue,
|
||||
mural_client: muralpay::Client,
|
||||
) {
|
||||
) -> eyre::Result<()> {
|
||||
use BackgroundTask::*;
|
||||
match self {
|
||||
Migrations => run_migrations().await,
|
||||
@@ -62,7 +70,7 @@ impl BackgroundTask {
|
||||
)
|
||||
.await;
|
||||
|
||||
update_bank_balances(pool).await;
|
||||
update_bank_balances(pool).await
|
||||
}
|
||||
IndexSubscriptions => {
|
||||
index_subscriptions(
|
||||
@@ -71,71 +79,69 @@ impl BackgroundTask {
|
||||
stripe_client,
|
||||
anrok_client,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
Mail => {
|
||||
run_email(email_queue).await;
|
||||
Mail => run_email(email_queue).await,
|
||||
CacheAnalytics => {
|
||||
cache_analytics(&pool, &redis_pool, &clickhouse).await
|
||||
}
|
||||
PingMinecraftJavaServers => {
|
||||
ping_minecraft_java_servers(pool, redis_pool, clickhouse).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_email(email_queue: EmailQueue) {
|
||||
pub async fn run_email(email_queue: EmailQueue) -> eyre::Result<()> {
|
||||
// Only index for 5 emails at a time, to reduce transaction length,
|
||||
// for a total of 100 emails.
|
||||
for _ in 0..20 {
|
||||
let then = std::time::Instant::now();
|
||||
|
||||
match email_queue.index(5).await {
|
||||
Ok(true) => {
|
||||
info!(
|
||||
"Indexed email queue in {}ms",
|
||||
then.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
Ok(false) => {
|
||||
info!("No more emails to index");
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, "Failed to index email queue");
|
||||
}
|
||||
let indexed = email_queue
|
||||
.index(5)
|
||||
.await
|
||||
.wrap_err("failed to index email queue")?;
|
||||
if indexed {
|
||||
info!("Indexed email queue in {}ms", then.elapsed().as_millis());
|
||||
} else {
|
||||
info!("No more emails to index");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_bank_balances(pool: PgPool) {
|
||||
pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> {
|
||||
let payouts_queue = PayoutsQueue::new();
|
||||
|
||||
match insert_bank_balances_and_webhook(&payouts_queue, &pool).await {
|
||||
Ok(_) => info!("Bank balances updated successfully"),
|
||||
Err(error) => error!(%error, "Bank balances update failed"),
|
||||
}
|
||||
insert_bank_balances_and_webhook(&payouts_queue, &pool)
|
||||
.await
|
||||
.wrap_err("failed to update bank balances")?;
|
||||
info!("Bank balances updated successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_migrations() {
|
||||
database::check_for_migrations()
|
||||
.await
|
||||
.expect("An error occurred while running migrations.");
|
||||
pub async fn run_migrations() -> eyre::Result<()> {
|
||||
database::check_for_migrations().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn index_search(
|
||||
ro_pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
search_config: search::SearchConfig,
|
||||
) {
|
||||
) -> eyre::Result<()> {
|
||||
info!("Indexing local database");
|
||||
let result = index_projects(ro_pool, redis_pool, &search_config).await;
|
||||
if let Err(e) = result {
|
||||
warn!("Local project indexing failed: {:?}", e);
|
||||
}
|
||||
info!("Done indexing local database");
|
||||
index_projects(ro_pool, redis_pool, &search_config).await
|
||||
}
|
||||
|
||||
pub async fn release_scheduled(pool: PgPool) {
|
||||
pub async fn release_scheduled(pool: PgPool) -> eyre::Result<()> {
|
||||
info!("Releasing scheduled versions/projects!");
|
||||
|
||||
let projects_results = sqlx::query!(
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE mods
|
||||
SET status = requested_status
|
||||
@@ -143,14 +149,11 @@ pub async fn release_scheduled(pool: PgPool) {
|
||||
",
|
||||
crate::models::projects::ProjectStatus::Scheduled.as_str(),
|
||||
)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
.execute(&pool)
|
||||
.await
|
||||
.wrap_err("failed syncing scheduled releases for projects")?;
|
||||
|
||||
if let Err(e) = projects_results {
|
||||
warn!("Syncing scheduled releases for projects failed: {:?}", e);
|
||||
}
|
||||
|
||||
let versions_results = sqlx::query!(
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE versions
|
||||
SET status = requested_status
|
||||
@@ -158,78 +161,96 @@ pub async fn release_scheduled(pool: PgPool) {
|
||||
",
|
||||
crate::models::projects::VersionStatus::Scheduled.as_str(),
|
||||
)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
if let Err(e) = versions_results {
|
||||
warn!("Syncing scheduled releases for versions failed: {:?}", e);
|
||||
}
|
||||
.execute(&pool)
|
||||
.await
|
||||
.wrap_err("failed syncing scheduled releases for versions")?;
|
||||
|
||||
info!("Finished releasing scheduled versions/projects");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_versions(pool: PgPool, redis_pool: RedisPool) {
|
||||
pub async fn update_versions(
|
||||
pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
) -> eyre::Result<()> {
|
||||
info!("Indexing game versions list from Mojang");
|
||||
let result = version_updater::update_versions(&pool, &redis_pool).await;
|
||||
if let Err(e) = result {
|
||||
warn!("Version update failed: {}", e);
|
||||
}
|
||||
version_updater::update_versions(&pool, &redis_pool)
|
||||
.await
|
||||
.wrap_err("failed to update game versions")?;
|
||||
info!("Done indexing game versions");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn payouts(
|
||||
pool: PgPool,
|
||||
clickhouse: clickhouse::Client,
|
||||
redis_pool: RedisPool,
|
||||
) {
|
||||
) -> eyre::Result<()> {
|
||||
info!("Started running payouts");
|
||||
let result = process_payout(&pool, &clickhouse).await;
|
||||
if let Err(e) = result {
|
||||
warn!("Payouts run failed: {e:#?}");
|
||||
}
|
||||
process_payout(&pool, &clickhouse)
|
||||
.await
|
||||
.wrap_err("payout processing failed")?;
|
||||
|
||||
let result = index_payouts_notifications(&pool, &redis_pool).await;
|
||||
if let Err(e) = result {
|
||||
warn!("Payouts notifications indexing failed: {e:#?}");
|
||||
}
|
||||
index_payouts_notifications(&pool, &redis_pool)
|
||||
.await
|
||||
.wrap_err("payout notifications indexing failed")?;
|
||||
|
||||
let result = process_affiliate_payouts(&pool).await;
|
||||
if let Err(e) = result {
|
||||
warn!("Affiliate payouts run failed: {e:#?}");
|
||||
}
|
||||
process_affiliate_payouts(&pool)
|
||||
.await
|
||||
.wrap_err("affiliate payouts processing failed")?;
|
||||
|
||||
let result = remove_payouts_for_refunded_charges(&pool).await;
|
||||
if let Err(e) = result {
|
||||
warn!("Removing affiliate payouts for refunded charges failed: {e:#?}");
|
||||
}
|
||||
remove_payouts_for_refunded_charges(&pool)
|
||||
.await
|
||||
.wrap_err("removing payouts for refunded charges failed")?;
|
||||
|
||||
info!("Done running payouts");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn sync_payout_statuses(pool: PgPool, mural: muralpay::Client) {
|
||||
pub async fn sync_payout_statuses(
|
||||
pool: PgPool,
|
||||
mural: muralpay::Client,
|
||||
) -> eyre::Result<()> {
|
||||
// Mural sets a max limit of 100 for search payouts endpoint
|
||||
const LIMIT: u32 = 100;
|
||||
|
||||
info!("Started syncing payout statuses");
|
||||
|
||||
let result = crate::queue::payouts::mural::sync_pending_payouts_from_mural(
|
||||
crate::queue::payouts::mural::sync_pending_payouts_from_mural(
|
||||
&pool, &mural, LIMIT,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
warn!("Failed to sync pending payouts from Mural: {e:?}");
|
||||
}
|
||||
.await
|
||||
.wrap_err("failed to sync pending payouts from Mural")?;
|
||||
|
||||
let result =
|
||||
crate::queue::payouts::mural::sync_failed_mural_payouts_to_labrinth(
|
||||
&pool, &mural, LIMIT,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
warn!("Failed to sync failed Mural payouts to Labrinth: {e:?}");
|
||||
}
|
||||
crate::queue::payouts::mural::sync_failed_mural_payouts_to_labrinth(
|
||||
&pool, &mural, LIMIT,
|
||||
)
|
||||
.await
|
||||
.wrap_err("failed to sync failed Mural payouts to Labrinth")?;
|
||||
|
||||
info!("Done syncing payout statuses");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ping_minecraft_java_servers(
|
||||
pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
clickhouse: clickhouse::Client,
|
||||
) -> eyre::Result<()> {
|
||||
info!("Started pinging Minecraft Java servers");
|
||||
|
||||
let server_ping_queue = crate::queue::server_ping::ServerPingQueue::new(
|
||||
pool, redis_pool, clickhouse,
|
||||
);
|
||||
|
||||
server_ping_queue
|
||||
.ping_minecraft_java_servers()
|
||||
.await
|
||||
.wrap_err("failed to ping Minecraft Java servers")?;
|
||||
info!("Successfully pinged Minecraft Java servers");
|
||||
|
||||
info!("Done pinging Minecraft Java servers");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod version_updater {
|
||||
|
||||
@@ -6,6 +6,8 @@ mod fetch;
|
||||
pub use fetch::*;
|
||||
|
||||
use crate::env::ENV;
|
||||
use crate::queue::server_ping;
|
||||
use crate::routes::analytics::MINECRAFT_SERVER_PLAYS;
|
||||
|
||||
pub async fn init_client() -> clickhouse::error::Result<clickhouse::Client> {
|
||||
init_client_with_database(&ENV.CLICKHOUSE_DATABASE).await
|
||||
@@ -14,6 +16,8 @@ pub async fn init_client() -> clickhouse::error::Result<clickhouse::Client> {
|
||||
pub async fn init_client_with_database(
|
||||
database: &str,
|
||||
) -> clickhouse::error::Result<clickhouse::Client> {
|
||||
const MINECRAFT_JAVA_SERVER_PINGS: &str = server_ping::CLICKHOUSE_TABLE;
|
||||
|
||||
let client = {
|
||||
let https_connector = HttpsConnectorBuilder::new()
|
||||
.with_native_roots()?
|
||||
@@ -160,5 +164,60 @@ pub async fn init_client_with_database(
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
client
|
||||
.query(&format!(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS {database}.{MINECRAFT_JAVA_SERVER_PINGS} {cluster_line}
|
||||
(
|
||||
recorded DateTime64(4),
|
||||
project_id UInt64,
|
||||
address String,
|
||||
port UInt16,
|
||||
online Bool,
|
||||
latency_ms Nullable(UInt32),
|
||||
description Nullable(String),
|
||||
version_name Nullable(String),
|
||||
version_protocol Nullable(UInt32),
|
||||
players_online Nullable(UInt32),
|
||||
players_max Nullable(UInt32)
|
||||
)
|
||||
ENGINE = {engine}
|
||||
{ttl}
|
||||
PRIMARY KEY (project_id, recorded)
|
||||
SETTINGS index_granularity = 8192
|
||||
"
|
||||
))
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
client
|
||||
.query(&format!(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS {database}.{MINECRAFT_SERVER_PLAYS} {cluster_line}
|
||||
(
|
||||
recorded DateTime64(4),
|
||||
user_id UInt64,
|
||||
project_id UInt64,
|
||||
minecraft_uuid UUID
|
||||
)
|
||||
ENGINE = {engine}
|
||||
{ttl}
|
||||
PRIMARY KEY (project_id, recorded)
|
||||
SETTINGS index_granularity = 8192
|
||||
"
|
||||
))
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
client
|
||||
.query(&format!(
|
||||
"
|
||||
ALTER TABLE {database}.{MINECRAFT_SERVER_PLAYS} {cluster_line}
|
||||
ADD COLUMN IF NOT EXISTS minecraft_uuid UUID
|
||||
"
|
||||
))
|
||||
.execute()
|
||||
.await?;
|
||||
|
||||
Ok(client.with_database(database))
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ pub use moderation_lock_item::{DBModerationLock, ModerationLockWithUser};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DatabaseError {
|
||||
#[error(transparent)]
|
||||
Internal(#[from] eyre::Report),
|
||||
#[error("Error while interacting with the database: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("Error while trying to generate random ID")]
|
||||
|
||||
@@ -6,9 +6,13 @@ use super::{DBUser, ids::*};
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::database::{PgTransaction, models};
|
||||
use crate::models::exp;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::projects::{
|
||||
MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus,
|
||||
};
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::error::Context;
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::{DashMap, DashSet};
|
||||
@@ -176,6 +180,7 @@ pub struct ProjectBuilder {
|
||||
pub gallery_items: Vec<DBGalleryItem>,
|
||||
pub color: Option<u32>,
|
||||
pub monetization_status: MonetizationStatus,
|
||||
pub components: exp::ProjectSerial,
|
||||
}
|
||||
|
||||
impl ProjectBuilder {
|
||||
@@ -215,6 +220,7 @@ impl ProjectBuilder {
|
||||
side_types_migration_review_status:
|
||||
SideTypesMigrationReviewStatus::Reviewed,
|
||||
loaders: vec![],
|
||||
components: self.components,
|
||||
};
|
||||
project_struct.insert(&mut *transaction).await?;
|
||||
|
||||
@@ -294,6 +300,7 @@ pub struct DBProject {
|
||||
pub monetization_status: MonetizationStatus,
|
||||
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
|
||||
pub loaders: Vec<String>,
|
||||
pub components: exp::ProjectSerial,
|
||||
}
|
||||
|
||||
impl DBProject {
|
||||
@@ -308,14 +315,16 @@ impl DBProject {
|
||||
published, downloads, icon_url, raw_icon_url, status, requested_status,
|
||||
license_url, license,
|
||||
slug, color, monetization_status, organization_id,
|
||||
side_types_migration_review_status
|
||||
side_types_migration_review_status,
|
||||
components
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13,
|
||||
LOWER($14), $15, $16, $17,
|
||||
$18
|
||||
$18,
|
||||
$19
|
||||
)
|
||||
",
|
||||
self.id as DBProjectId,
|
||||
@@ -335,7 +344,8 @@ impl DBProject {
|
||||
self.color.map(|x| x as i32),
|
||||
self.monetization_status.as_str(),
|
||||
self.organization_id.map(|x| x.0 as i64),
|
||||
self.side_types_migration_review_status.as_str()
|
||||
self.side_types_migration_review_status.as_str(),
|
||||
serde_json::to_value(&self.components).expect("serialization shouldn't fail"),
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
@@ -347,12 +357,15 @@ impl DBProject {
|
||||
id: DBProjectId,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Option<()>, DatabaseError> {
|
||||
let project = Self::get_id(id, &mut *transaction, redis).await?;
|
||||
) -> Result<Option<()>, ApiError> {
|
||||
let project = Self::get_id(id, &mut *transaction, redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch project by ID")?;
|
||||
|
||||
if let Some(project) = project {
|
||||
DBProject::clear_cache(id, project.inner.slug, Some(true), redis)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to clear project cache")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -362,7 +375,8 @@ impl DBProject {
|
||||
id as DBProjectId
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to delete project followers")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -372,7 +386,8 @@ impl DBProject {
|
||||
id as DBProjectId
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to delete project gallery items")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -382,7 +397,10 @@ impl DBProject {
|
||||
id as DBProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err(
|
||||
"failed to delete duplicate project followers",
|
||||
)?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -393,7 +411,8 @@ impl DBProject {
|
||||
id as DBProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to clear report project references")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -403,7 +422,8 @@ impl DBProject {
|
||||
id as DBProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to delete project categories")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -413,11 +433,13 @@ impl DBProject {
|
||||
id as DBProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to delete project links")?;
|
||||
|
||||
for version in project.versions {
|
||||
super::DBVersion::remove_full(version, redis, transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to remove project version")?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
@@ -427,7 +449,8 @@ impl DBProject {
|
||||
id as DBProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to delete dependency references")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -438,7 +461,8 @@ impl DBProject {
|
||||
id as DBProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to clear payout project references")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -448,10 +472,12 @@ impl DBProject {
|
||||
id as DBProjectId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to delete project row")?;
|
||||
|
||||
models::DBTeamMember::clear_cache(project.inner.team_id, redis)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to clear team member cache")?;
|
||||
|
||||
let affected_user_ids = sqlx::query!(
|
||||
"
|
||||
@@ -464,9 +490,12 @@ impl DBProject {
|
||||
.fetch(&mut *transaction)
|
||||
.map_ok(|x| DBUserId(x.user_id))
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to delete team members")?;
|
||||
|
||||
DBUser::clear_project_cache(&affected_user_ids, redis).await?;
|
||||
DBUser::clear_project_cache(&affected_user_ids, redis)
|
||||
.await
|
||||
.wrap_internal_err("failed to clear user project cache")?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
@@ -476,7 +505,8 @@ impl DBProject {
|
||||
project.inner.team_id as DBTeamId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to delete team")?;
|
||||
|
||||
Ok(Some(()))
|
||||
} else {
|
||||
@@ -488,7 +518,7 @@ impl DBProject {
|
||||
string: &str,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Option<ProjectQueryResult>, DatabaseError>
|
||||
) -> Result<Option<ProjectQueryResult>, ApiError>
|
||||
where
|
||||
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
@@ -501,7 +531,7 @@ impl DBProject {
|
||||
id: DBProjectId,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Option<ProjectQueryResult>, DatabaseError>
|
||||
) -> Result<Option<ProjectQueryResult>, ApiError>
|
||||
where
|
||||
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
@@ -518,7 +548,7 @@ impl DBProject {
|
||||
project_ids: &[DBProjectId],
|
||||
exec: E,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<ProjectQueryResult>, DatabaseError>
|
||||
) -> Result<Vec<ProjectQueryResult>, ApiError>
|
||||
where
|
||||
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
@@ -537,7 +567,7 @@ impl DBProject {
|
||||
project_strings: &[T],
|
||||
exec: E,
|
||||
redis: &RedisPool,
|
||||
) -> Result<Vec<ProjectQueryResult>, DatabaseError>
|
||||
) -> Result<Vec<ProjectQueryResult>, ApiError>
|
||||
where
|
||||
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
@@ -547,7 +577,9 @@ impl DBProject {
|
||||
false,
|
||||
project_strings,
|
||||
|ids| async move {
|
||||
let mut exec = exec.acquire().await?;
|
||||
let mut exec = exec
|
||||
.acquire()
|
||||
.await?;
|
||||
let project_ids_parsed: Vec<i64> = ids
|
||||
.iter()
|
||||
.filter_map(|x| parse_base62(&x.to_string()).ok())
|
||||
@@ -668,7 +700,8 @@ impl DBProject {
|
||||
});
|
||||
async move { Ok(acc) }
|
||||
}
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
let links: DashMap<DBProjectId, Vec<LinkUrl>> = sqlx::query!(
|
||||
"
|
||||
@@ -692,7 +725,8 @@ impl DBProject {
|
||||
});
|
||||
async move { Ok(acc) }
|
||||
}
|
||||
).await?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[derive(Default)]
|
||||
struct VersionLoaderData {
|
||||
@@ -743,7 +777,9 @@ impl DBProject {
|
||||
(project_id, version_loader_data)
|
||||
|
||||
}
|
||||
).try_collect().await?;
|
||||
)
|
||||
.try_collect()
|
||||
.await?;
|
||||
|
||||
let loader_fields: Vec<QueryLoaderField> = sqlx::query!(
|
||||
"
|
||||
@@ -766,8 +802,8 @@ impl DBProject {
|
||||
.try_collect()
|
||||
.await?;
|
||||
|
||||
let projects = sqlx::query!(
|
||||
"
|
||||
let project_rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,
|
||||
m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,
|
||||
m.approved approved, m.queued, m.status status, m.requested_status requested_status,
|
||||
@@ -777,24 +813,48 @@ impl DBProject {
|
||||
t.id thread_id, m.monetization_status monetization_status,
|
||||
m.side_types_migration_review_status side_types_migration_review_status,
|
||||
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,
|
||||
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories
|
||||
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories,
|
||||
m.components AS "components: sqlx::types::Json<exp::ProjectSerial>"
|
||||
|
||||
FROM mods m
|
||||
INNER JOIN threads t ON t.mod_id = m.id
|
||||
LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id
|
||||
LEFT JOIN categories c ON mc.joining_category_id = c.id
|
||||
|
||||
WHERE m.id = ANY($1) OR m.slug = ANY($2)
|
||||
GROUP BY t.id, m.id;
|
||||
",
|
||||
GROUP BY t.id, m.id
|
||||
"#,
|
||||
&project_ids_parsed,
|
||||
&slugs,
|
||||
)
|
||||
.fetch(&mut exec)
|
||||
.try_fold(DashMap::new(), |acc, m| {
|
||||
.fetch_all(&mut exec)
|
||||
.await?;
|
||||
|
||||
let project_components = project_rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(
|
||||
ProjectId::from(DBProjectId(row.id)),
|
||||
&row.components.0,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let project_query_context = exp::project::fetch_query_context(
|
||||
&project_components,
|
||||
&mut exec,
|
||||
redis,
|
||||
)
|
||||
.await
|
||||
.wrap_err("failed to fetch project query context")?;
|
||||
|
||||
let projects = project_rows
|
||||
.into_iter()
|
||||
.try_fold(DashMap::new(), |acc, m| -> Result<_, DatabaseError> {
|
||||
let id = m.id;
|
||||
let project_id = DBProjectId(id);
|
||||
let VersionLoaderData {
|
||||
loaders,
|
||||
project_types,
|
||||
mut project_types,
|
||||
games,
|
||||
loader_loader_field_ids,
|
||||
} = loaders_ptypes_games.remove(&project_id).map(|x|x.1).unwrap_or_default();
|
||||
@@ -809,6 +869,17 @@ impl DBProject {
|
||||
.filter(|x| loader_loader_field_ids.contains(&x.id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let components_serial = m.components.0;
|
||||
exp::compat::correct_project_types(
|
||||
&components_serial,
|
||||
&mut project_types,
|
||||
);
|
||||
let components = components_serial.clone().into_query(
|
||||
ProjectId::from(project_id),
|
||||
&project_query_context,
|
||||
)
|
||||
.wrap_err("failed to populate query components")?;
|
||||
|
||||
let project = ProjectQueryResult {
|
||||
inner: DBProject {
|
||||
id: DBProjectId(id),
|
||||
@@ -845,6 +916,7 @@ impl DBProject {
|
||||
&m.side_types_migration_review_status,
|
||||
),
|
||||
loaders,
|
||||
components: components_serial,
|
||||
},
|
||||
categories: m.categories.unwrap_or_default(),
|
||||
additional_categories: m.additional_categories.unwrap_or_default(),
|
||||
@@ -858,16 +930,19 @@ impl DBProject {
|
||||
urls,
|
||||
aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true),
|
||||
thread_id: DBThreadId(m.thread_id),
|
||||
components,
|
||||
};
|
||||
|
||||
acc.insert(m.id, (m.slug, project));
|
||||
async move { Ok(acc) }
|
||||
Ok(acc)
|
||||
})
|
||||
.await?;
|
||||
?;
|
||||
|
||||
Ok(projects)
|
||||
},
|
||||
).await?;
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch cached projects")?;
|
||||
|
||||
Ok(val)
|
||||
}
|
||||
@@ -983,4 +1058,6 @@ pub struct ProjectQueryResult {
|
||||
pub gallery_items: Vec<DBGalleryItem>,
|
||||
pub thread_id: DBThreadId,
|
||||
pub aggregate_version_fields: Vec<VersionField>,
|
||||
#[serde(flatten)]
|
||||
pub components: exp::ProjectQuery,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::database::models::loader_fields::{
|
||||
QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::exp;
|
||||
use crate::models::projects::{FileType, VersionStatus};
|
||||
use crate::routes::internal::delphi::DelphiRunParameters;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -37,6 +38,7 @@ pub struct VersionBuilder {
|
||||
pub status: VersionStatus,
|
||||
pub requested_status: Option<VersionStatus>,
|
||||
pub ordering: Option<i32>,
|
||||
pub components: exp::VersionSerial,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -206,6 +208,7 @@ impl VersionBuilder {
|
||||
status: self.status,
|
||||
requested_status: self.requested_status,
|
||||
ordering: self.ordering,
|
||||
components: self.components,
|
||||
};
|
||||
|
||||
version.insert(transaction).await?;
|
||||
@@ -285,7 +288,7 @@ impl DBLoaderVersion {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct DBVersion {
|
||||
pub id: DBVersionId,
|
||||
pub project_id: DBProjectId,
|
||||
@@ -300,8 +303,17 @@ pub struct DBVersion {
|
||||
pub status: VersionStatus,
|
||||
pub requested_status: Option<VersionStatus>,
|
||||
pub ordering: Option<i32>,
|
||||
pub components: exp::VersionSerial,
|
||||
}
|
||||
|
||||
impl PartialEq for DBVersion {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for DBVersion {}
|
||||
|
||||
impl DBVersion {
|
||||
pub async fn insert(
|
||||
&self,
|
||||
@@ -312,12 +324,14 @@ impl DBVersion {
|
||||
INSERT INTO versions (
|
||||
id, mod_id, author_id, name, version_number,
|
||||
changelog, date_published, downloads,
|
||||
version_type, featured, status, ordering
|
||||
version_type, featured, status, ordering,
|
||||
components
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8,
|
||||
$9, $10, $11, $12
|
||||
$9, $10, $11, $12,
|
||||
$13
|
||||
)
|
||||
",
|
||||
self.id as DBVersionId,
|
||||
@@ -331,7 +345,9 @@ impl DBVersion {
|
||||
&self.version_type,
|
||||
self.featured,
|
||||
self.status.as_str(),
|
||||
self.ordering
|
||||
self.ordering,
|
||||
serde_json::to_value(&self.components)
|
||||
.expect("serialization shouldn't fail"),
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
@@ -719,13 +735,14 @@ impl DBVersion {
|
||||
).await?;
|
||||
|
||||
let res = sqlx::query!(
|
||||
"
|
||||
r#"
|
||||
SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,
|
||||
v.changelog changelog, v.date_published date_published, v.downloads downloads,
|
||||
v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering
|
||||
v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,
|
||||
v.components AS "components: sqlx::types::Json<exp::VersionSerial>"
|
||||
FROM versions v
|
||||
WHERE v.id = ANY($1);
|
||||
",
|
||||
"#,
|
||||
&version_ids
|
||||
)
|
||||
.fetch(&mut exec)
|
||||
@@ -746,6 +763,7 @@ impl DBVersion {
|
||||
.filter(|x| loader_loader_field_ids.contains(&x.id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let components_serial = v.components.0;
|
||||
let query_version = VersionQueryResult {
|
||||
inner: DBVersion {
|
||||
id: DBVersionId(v.id),
|
||||
@@ -762,6 +780,7 @@ impl DBVersion {
|
||||
requested_status: v.requested_status
|
||||
.map(|x| VersionStatus::from_string(&x)),
|
||||
ordering: v.ordering,
|
||||
components: components_serial,
|
||||
},
|
||||
files: {
|
||||
let mut files = files.into_iter().map(|x| {
|
||||
@@ -804,6 +823,8 @@ impl DBVersion {
|
||||
project_types,
|
||||
games,
|
||||
dependencies,
|
||||
// TODO populate
|
||||
components: exp::VersionQuery::default(),
|
||||
};
|
||||
|
||||
acc.insert(v.id, query_version);
|
||||
@@ -937,7 +958,7 @@ impl DBVersion {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct VersionQueryResult {
|
||||
pub inner: DBVersion,
|
||||
|
||||
@@ -947,6 +968,8 @@ pub struct VersionQueryResult {
|
||||
pub project_types: Vec<String>,
|
||||
pub games: Vec<String>,
|
||||
pub dependencies: Vec<DependencyQueryResult>,
|
||||
#[serde(flatten)]
|
||||
pub components: exp::VersionQuery,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
@@ -993,6 +1016,14 @@ impl std::cmp::PartialOrd for VersionQueryResult {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::cmp::PartialEq for VersionQueryResult {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.inner == other.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl std::cmp::Eq for VersionQueryResult {}
|
||||
|
||||
impl std::cmp::Ord for DBVersion {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
let ordering_order = match (self.ordering, other.ordering) {
|
||||
@@ -1061,6 +1092,7 @@ mod tests {
|
||||
featured: false,
|
||||
status: VersionStatus::Listed,
|
||||
requested_status: None,
|
||||
components: exp::VersionSerial::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,4 +277,10 @@ vars! {
|
||||
DELPHI_SLACK_WEBHOOK: String = "";
|
||||
|
||||
TREMENDOUS_CAMPAIGN_ID: String = "";
|
||||
|
||||
// server pinging
|
||||
SERVER_PING_MAX_CONCURRENT: usize = 16usize;
|
||||
SERVER_PING_RETRIES: usize = 3usize;
|
||||
SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60;
|
||||
SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000;
|
||||
}
|
||||
|
||||
@@ -120,12 +120,15 @@ pub fn app_setup(
|
||||
let redis_pool_ref = redis_pool_ref.clone();
|
||||
let search_config_ref = search_config_ref.clone();
|
||||
async move {
|
||||
background_task::index_search(
|
||||
if let Err(e) = background_task::index_search(
|
||||
pool_ref,
|
||||
redis_pool_ref,
|
||||
search_config_ref,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
warn!("Local project indexing failed: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -135,7 +138,11 @@ pub fn app_setup(
|
||||
scheduler.run(Duration::from_secs(60 * 5), move || {
|
||||
let pool_ref = pool_ref.clone();
|
||||
async move {
|
||||
background_task::release_scheduled(pool_ref).await;
|
||||
if let Err(e) =
|
||||
background_task::release_scheduled(pool_ref).await
|
||||
{
|
||||
warn!("Syncing scheduled releases failed: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -147,7 +154,9 @@ pub fn app_setup(
|
||||
let pool_ref = pool_ref.clone();
|
||||
let redis = redis_pool_ref.clone();
|
||||
async move {
|
||||
update_versions(pool_ref, redis).await;
|
||||
if let Err(e) = update_versions(pool_ref, redis).await {
|
||||
warn!("Version update failed: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -159,7 +168,12 @@ pub fn app_setup(
|
||||
let client_ref = client_ref.clone();
|
||||
let redis_ref = redis_pool_ref.clone();
|
||||
async move {
|
||||
background_task::payouts(pool_ref, client_ref, redis_ref).await;
|
||||
if let Err(e) =
|
||||
background_task::payouts(pool_ref, client_ref, redis_ref)
|
||||
.await
|
||||
{
|
||||
warn!("Payout task failed: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -178,7 +178,8 @@ async fn app() -> std::io::Result<()> {
|
||||
email_queue,
|
||||
muralpay,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user