mirror of
https://github.com/modrinth/code.git
synced 2026-08-01 05:36:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92a8be6c4 | ||
|
|
7f1ae9672b | ||
|
|
9006dce2b0 | ||
|
|
efdfb51149 | ||
|
|
dab8b49449 | ||
|
|
79cc5fef72 | ||
|
|
ddefc28ca6 | ||
|
|
654757f9fb | ||
|
|
407641ee7c | ||
|
|
96ab120cd0 | ||
|
|
3ed75a146a | ||
|
|
5a1c2a46bc | ||
|
|
2ab3b73a7c | ||
|
|
2ac7c5fd81 | ||
|
|
df8ebbd3e0 | ||
|
|
d705fd2367 | ||
|
|
517c3d2d72 | ||
|
|
7f15772f59 | ||
|
|
0905ea72f6 | ||
|
|
8d20fd82db | ||
|
|
c831e38e32 | ||
|
|
40b8fb3a4a | ||
|
|
977bb2ff58 | ||
|
|
732bdfc79b | ||
|
|
1cce1eb248 | ||
|
|
76320f227f | ||
|
|
34228a5b6c | ||
|
|
6d66aee4ec | ||
|
|
fc7be043c7 | ||
|
|
7fa88e5d4d | ||
|
|
21508e0637 | ||
|
|
ad4912d64d | ||
|
|
0002ae2479 | ||
|
|
e4568330a7 | ||
|
|
b0108e0624 | ||
|
|
ac63abadb7 | ||
|
|
fa749b5aff | ||
|
|
a2adbe18c4 | ||
|
|
0fcbe51816 | ||
|
|
23cfeca91d | ||
|
|
db830ef65c | ||
|
|
08f44b3582 | ||
|
|
8ffc4e2599 | ||
|
|
58a5619073 | ||
|
|
4a6fa9fc3d | ||
|
|
b26d048a63 | ||
|
|
d5be2bd739 | ||
|
|
0dac5684af | ||
|
|
ca0b8d5ca4 | ||
|
|
b6651efe52 | ||
|
|
eb28121c0a | ||
|
|
a5ebc85356 | ||
|
|
c530671be1 | ||
|
|
bb6d8a6fc4 | ||
|
|
256e8f946a | ||
|
|
7e5590b8b9 | ||
|
|
d20f05fb04 | ||
|
|
a00a7971d1 | ||
|
|
26e98b7155 | ||
|
|
77b06f679e | ||
|
|
50e9472dd9 |
@@ -79,6 +79,7 @@ jobs:
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
RUST_MIN_STACK: '16777216'
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -93,10 +94,11 @@ jobs:
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Install build dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: cmake libcurl4-openssl-dev
|
||||
version: v2 # cache key
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
|
||||
#uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
#with:
|
||||
# packages: cmake libcurl4-openssl-dev
|
||||
# version: v2 # cache key
|
||||
|
||||
- name: Cache Cargo registry and index
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
|
||||
Generated
+147
-64
@@ -59,9 +59,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "actix-http"
|
||||
version = "3.11.2"
|
||||
version = "3.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49"
|
||||
checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2"
|
||||
dependencies = [
|
||||
"actix-codec",
|
||||
"actix-rt",
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"foldhash 0.1.5",
|
||||
"foldhash 0.2.0",
|
||||
"futures-core",
|
||||
"h2 0.3.27",
|
||||
"http 0.2.12",
|
||||
@@ -87,8 +87,8 @@ dependencies = [
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"rand 0.10.1",
|
||||
"sha1 0.11.0",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -146,9 +146,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "actix-router"
|
||||
version = "0.5.3"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
|
||||
checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7"
|
||||
dependencies = [
|
||||
"bytestring",
|
||||
"cfg-if",
|
||||
@@ -209,9 +209,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "actix-web"
|
||||
version = "4.11.0"
|
||||
version = "4.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea"
|
||||
checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4"
|
||||
dependencies = [
|
||||
"actix-codec",
|
||||
"actix-http",
|
||||
@@ -228,7 +228,7 @@ dependencies = [
|
||||
"cookie 0.16.2",
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"foldhash 0.1.5",
|
||||
"foldhash 0.2.0",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"impl-more",
|
||||
@@ -244,7 +244,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"smallvec",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.1",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1127,7 +1127,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"md-5",
|
||||
"pin-project-lite",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1499,7 +1499,7 @@ version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1511,6 +1511,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -2224,6 +2233,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -2424,7 +2439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d"
|
||||
dependencies = [
|
||||
"crc",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"rustversion",
|
||||
"spin 0.10.0",
|
||||
]
|
||||
@@ -2515,6 +2530,15 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.29.6"
|
||||
@@ -2791,7 +2815,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2904,12 +2928,23 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "6.0.0"
|
||||
@@ -3127,7 +3162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
@@ -3151,7 +3186,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
@@ -4402,7 +4437,7 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4532,6 +4567,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.32"
|
||||
@@ -4893,9 +4937,9 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||
|
||||
[[package]]
|
||||
name = "impl-more"
|
||||
version = "0.1.9"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
|
||||
checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
|
||||
|
||||
[[package]]
|
||||
name = "indenter"
|
||||
@@ -5390,11 +5434,12 @@ dependencies = [
|
||||
"rust_iso3166",
|
||||
"rustls 0.23.32",
|
||||
"rusty-money",
|
||||
"scalar_api_reference",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
"spdx",
|
||||
"sqlx",
|
||||
@@ -5411,8 +5456,6 @@ dependencies = [
|
||||
"url",
|
||||
"urlencoding",
|
||||
"utoipa",
|
||||
"utoipa-actix-web",
|
||||
"utoipa-scalar",
|
||||
"uuid 1.23.3",
|
||||
"validator",
|
||||
"webauthn-rs",
|
||||
@@ -5822,7 +5865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8494,8 +8537,8 @@ version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
@@ -8508,6 +8551,40 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn 2.0.106",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
@@ -8827,6 +8904,18 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scalar_api_reference"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d67c85edd56c41f364603eff688a330f01dee99bdfc84c005d3bc3d0721d6b2"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scc"
|
||||
version = "2.4.0"
|
||||
@@ -9252,15 +9341,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.145"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9455,7 +9544,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9472,7 +9572,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9505,7 +9605,7 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
@@ -9782,7 +9882,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"futures-channel",
|
||||
@@ -9803,7 +9903,7 @@ dependencies = [
|
||||
"rsa",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
@@ -11290,7 +11390,7 @@ dependencies = [
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
@@ -11505,7 +11605,7 @@ dependencies = [
|
||||
"rand 0.9.2",
|
||||
"rustls 0.23.32",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"thiserror 2.0.17",
|
||||
"utf-8",
|
||||
]
|
||||
@@ -11524,9 +11624,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
@@ -11757,17 +11857,6 @@ dependencies = [
|
||||
"utoipa-gen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-actix-web"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f"
|
||||
dependencies = [
|
||||
"actix-service",
|
||||
"actix-web",
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-gen"
|
||||
version = "5.4.0"
|
||||
@@ -11782,18 +11871,6 @@ dependencies = [
|
||||
"uuid 1.23.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-scalar"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
@@ -13450,6 +13527,12 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.2"
|
||||
|
||||
+1
-2
@@ -158,6 +158,7 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [
|
||||
] }
|
||||
rustls = "0.23.32"
|
||||
rusty-money = "0.4.1"
|
||||
scalar_api_reference = { version = "0.2.2", default-features = false }
|
||||
secrecy = "0.10.3"
|
||||
sentry = { version = "0.45.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
@@ -217,8 +218,6 @@ typed-path = "0.12.0"
|
||||
url = "2.5.7"
|
||||
urlencoding = "2.1.3"
|
||||
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
|
||||
utoipa-actix-web = { version = "0.1.2" }
|
||||
utoipa-scalar = { version = "0.3.0", default-features = false }
|
||||
uuid = "1.18.1"
|
||||
validator = "0.20.0"
|
||||
webauthn-rs = "0.5.5"
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Modrinth Monorepo
|
||||
|
||||
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors!
|
||||
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors!
|
||||
|
||||
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
|
||||
|
||||
|
||||
@@ -14,31 +14,7 @@
|
||||
<UnplugIcon class="text-secondary" />
|
||||
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
|
||||
</div>
|
||||
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
|
||||
<button
|
||||
v-if="showUpdatePill"
|
||||
type="button"
|
||||
class="!h-[34px] overflow-hidden text-sm !transition-[width,opacity,transform,background-color,color,filter] !duration-200 ease-out"
|
||||
:class="[
|
||||
updatePillWidthClass,
|
||||
{
|
||||
'update-pill-ready-hidden': finishedDownloading && !animateReadyPill,
|
||||
'update-pill-ready-visible': finishedDownloading && animateReadyPill,
|
||||
},
|
||||
]"
|
||||
:disabled="isUpdateDownloading"
|
||||
:aria-busy="isUpdateDownloading"
|
||||
@click="handleUpdateClick"
|
||||
>
|
||||
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
|
||||
<DownloadIcon v-else />
|
||||
<span v-if="isUpdateDownloading">
|
||||
{{ formatMessage(messages.downloadingUpdate) }}
|
||||
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
|
||||
</span>
|
||||
<span v-else>{{ updateLabel }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<AppUpdateButton />
|
||||
<div
|
||||
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
|
||||
>
|
||||
@@ -144,7 +120,6 @@ import {
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
RefreshCwIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
@@ -161,9 +136,10 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
@@ -172,11 +148,6 @@ import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/p
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
installAvailableAppUpdate,
|
||||
} from '@/providers/app-update'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
@@ -241,96 +212,8 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.view-active-downloads',
|
||||
defaultMessage: 'View active downloads',
|
||||
},
|
||||
update: {
|
||||
id: 'app.action-bar.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
downloadingUpdate: {
|
||||
id: 'app.action-bar.downloading-update',
|
||||
defaultMessage: 'Downloading update',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.action-bar.reload-to-update',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
downloading,
|
||||
downloadPercent,
|
||||
downloadProgress,
|
||||
finishedDownloading,
|
||||
isVisible: isUpdateVisible,
|
||||
metered,
|
||||
restarting,
|
||||
} = appUpdateState
|
||||
|
||||
const isUpdateDownloading = computed(
|
||||
() =>
|
||||
downloading.value ||
|
||||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
|
||||
)
|
||||
const showUpdatePill = computed(
|
||||
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
|
||||
)
|
||||
const animateReadyPill = ref(false)
|
||||
const updateLabel = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return formatMessage(messages.downloadingUpdate)
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return formatMessage(messages.reloadToUpdate)
|
||||
}
|
||||
|
||||
return formatMessage(messages.update)
|
||||
})
|
||||
const updatePillWidthClass = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return 'w-[219px]'
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return 'w-[166px]'
|
||||
}
|
||||
|
||||
return '!w-[96px]'
|
||||
})
|
||||
let readyPillAnimationFrame: number | null = null
|
||||
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
readyPillAnimationFrame = null
|
||||
}
|
||||
|
||||
if (!show || !ready) {
|
||||
animateReadyPill.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (wasShown && wasReady) {
|
||||
return
|
||||
}
|
||||
|
||||
animateReadyPill.value = false
|
||||
await nextTick()
|
||||
readyPillAnimationFrame = requestAnimationFrame(() => {
|
||||
animateReadyPill.value = true
|
||||
readyPillAnimationFrame = null
|
||||
})
|
||||
})
|
||||
async function handleUpdateClick() {
|
||||
if (isUpdateDownloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
await installAvailableAppUpdate()
|
||||
} else {
|
||||
await downloadAvailableAppUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
const currentProcesses = ref<RunningProcess[]>([])
|
||||
const selectedProcess = ref<RunningProcess | undefined>()
|
||||
|
||||
@@ -619,20 +502,5 @@ onBeforeUnmount(() => {
|
||||
unlistenProcess()
|
||||
unlistenLoading()
|
||||
installJobNotifications.dispose()
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-pill-ready-hidden {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.update-pill-ready-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
|
||||
<button
|
||||
v-if="showUpdatePill"
|
||||
type="button"
|
||||
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out"
|
||||
:class="{
|
||||
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
|
||||
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
|
||||
}"
|
||||
:disabled="isUpdateDownloading"
|
||||
:aria-busy="isUpdateDownloading"
|
||||
@click="handleUpdateClick"
|
||||
>
|
||||
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
|
||||
<DownloadIcon v-else />
|
||||
<span v-if="isUpdateDownloading">
|
||||
{{ formatMessage(messages.downloadingUpdate) }}
|
||||
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
|
||||
</span>
|
||||
<span v-else>{{ updateLabel }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
installAvailableAppUpdate,
|
||||
} from '@/providers/app-update'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
update: {
|
||||
id: 'app.action-bar.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
downloadingUpdate: {
|
||||
id: 'app.action-bar.downloading-update',
|
||||
defaultMessage: 'Downloading update',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.action-bar.reload-to-update',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
downloading,
|
||||
downloadPercent,
|
||||
downloadProgress,
|
||||
finishedDownloading,
|
||||
isVisible: isUpdateVisible,
|
||||
metered,
|
||||
restarting,
|
||||
} = appUpdateState
|
||||
|
||||
const isUpdateDownloading = computed(
|
||||
() =>
|
||||
downloading.value ||
|
||||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
|
||||
)
|
||||
const showUpdatePill = computed(
|
||||
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
|
||||
)
|
||||
const animateReadyPill = ref(false)
|
||||
const updateLabel = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return formatMessage(messages.downloadingUpdate)
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return formatMessage(messages.reloadToUpdate)
|
||||
}
|
||||
|
||||
return formatMessage(messages.update)
|
||||
})
|
||||
let readyPillAnimationFrame: number | null = null
|
||||
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
readyPillAnimationFrame = null
|
||||
}
|
||||
|
||||
if (!show || !ready) {
|
||||
animateReadyPill.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (wasShown && wasReady) {
|
||||
return
|
||||
}
|
||||
|
||||
animateReadyPill.value = false
|
||||
await nextTick()
|
||||
readyPillAnimationFrame = requestAnimationFrame(() => {
|
||||
animateReadyPill.value = true
|
||||
readyPillAnimationFrame = null
|
||||
})
|
||||
})
|
||||
async function handleUpdateClick() {
|
||||
if (isUpdateDownloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
await installAvailableAppUpdate()
|
||||
} else {
|
||||
await downloadAvailableAppUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
import { onUnmounted, ref, shallowRef } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { kill, list as listInstances } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
|
||||
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
|
||||
|
||||
interface BrowseServerInstance {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
@@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
const debugLog = useDebugLogger('BrowseServer')
|
||||
const serverPings = shallowRef<Record<string, number | undefined>>({})
|
||||
const serverPingCache = new Map<string, number | undefined>()
|
||||
const pendingServerPings = new Map<string, Promise<number | undefined>>()
|
||||
const runningServerProjects = ref<Record<string, string>>({})
|
||||
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
|
||||
const contextMenuRef = ref<ContextMenuHandle | null>(null)
|
||||
let serverPingCacheActive = true
|
||||
let serverPingsActive = true
|
||||
let unlistenProcesses: (() => void) | null = null
|
||||
|
||||
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
@@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
})
|
||||
const nextPings = { ...serverPings.value }
|
||||
for (const { hit, address } of pingsToFetch) {
|
||||
if (serverPingCache.has(address)) {
|
||||
nextPings[hit.project_id] = serverPingCache.get(address)
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
|
||||
if (cachedStatus) {
|
||||
nextPings[hit.project_id] = cachedStatus.ping
|
||||
}
|
||||
}
|
||||
serverPings.value = nextPings
|
||||
|
||||
await Promise.all(
|
||||
pingsToFetch.map(async ({ hit, address }) => {
|
||||
if (serverPingCache.has(address)) return
|
||||
if (getFreshCachedServerStatus(queryClient, address)) return
|
||||
|
||||
let pending = pendingServerPings.get(address)
|
||||
if (!pending) {
|
||||
pending = getServerLatency(address)
|
||||
.then((latency) => {
|
||||
if (serverPingCacheActive) serverPingCache.set(address, latency)
|
||||
return latency
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to ping server ${address}:`, error)
|
||||
if (serverPingCacheActive) serverPingCache.set(address, undefined)
|
||||
return undefined
|
||||
})
|
||||
.finally(() => {
|
||||
pendingServerPings.delete(address)
|
||||
})
|
||||
pendingServerPings.set(address, pending)
|
||||
try {
|
||||
const status = await fetchCachedServerStatus(queryClient, address)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping server ${address}:`, error)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
|
||||
}
|
||||
|
||||
const latency = await pending
|
||||
if (!serverPingCacheActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -308,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
.catch(options.handleError)
|
||||
|
||||
onUnmounted(() => {
|
||||
serverPingCacheActive = false
|
||||
serverPingsActive = false
|
||||
unlistenProcesses?.()
|
||||
serverPingCache.clear()
|
||||
pendingServerPings.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { QueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import {
|
||||
get_server_status,
|
||||
normalizeServerAddress,
|
||||
type ProtocolVersion,
|
||||
type ServerStatus,
|
||||
} from '@/helpers/worlds'
|
||||
|
||||
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
|
||||
|
||||
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
|
||||
if (!protocolVersion) return 'default'
|
||||
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
|
||||
}
|
||||
|
||||
export function getServerStatusQueryKey(
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
return [
|
||||
'minecraft-server-status',
|
||||
normalizeServerAddress(address) || address.trim().toLowerCase(),
|
||||
getProtocolVersionKey(protocolVersion),
|
||||
] as const
|
||||
}
|
||||
|
||||
export function getFreshCachedServerStatus(
|
||||
queryClient: QueryClient,
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
const queryKey = getServerStatusQueryKey(address, protocolVersion)
|
||||
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
|
||||
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
|
||||
return queryClient.getQueryData<ServerStatus>(queryKey)
|
||||
}
|
||||
|
||||
export async function fetchCachedServerStatus(
|
||||
queryClient: QueryClient,
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: getServerStatusQueryKey(address, protocolVersion),
|
||||
queryFn: () => get_server_status(address, protocolVersion),
|
||||
staleTime: SERVER_STATUS_CACHE_MS,
|
||||
gcTime: SERVER_STATUS_CACHE_MS,
|
||||
})
|
||||
}
|
||||
@@ -180,7 +180,7 @@
|
||||
"message": "Discover servers"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Hide already added servers"
|
||||
"message": "Hide servers already added"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
@@ -410,12 +410,27 @@
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "This project is already installed"
|
||||
},
|
||||
"app.project.install-button.switch-version": {
|
||||
"message": "Switch version"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Back to discover"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Install content to instance"
|
||||
},
|
||||
"app.project.version.all-versions": {
|
||||
"message": "All versions"
|
||||
},
|
||||
"app.project.version.download-in-browser": {
|
||||
"message": "Download in browser"
|
||||
},
|
||||
"app.project.version.installing": {
|
||||
"message": "Installing"
|
||||
},
|
||||
"app.project.versions.already-installed": {
|
||||
"message": "Already installed"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Developer mode enabled."
|
||||
},
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
preferencesDiffer,
|
||||
provideBrowseManager,
|
||||
requestInstall,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useBrowseSearch,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
get_installed_project_ids as getInstalledProjectIds,
|
||||
} from '@/helpers/instance'
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { get_instance_worlds } from '@/helpers/worlds'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
@@ -403,7 +406,7 @@ const messages = defineMessages({
|
||||
},
|
||||
hideAddedServers: {
|
||||
id: 'app.browse.hide-added-servers',
|
||||
defaultMessage: 'Hide already added servers',
|
||||
defaultMessage: 'Hide servers already added',
|
||||
},
|
||||
installingToServer: {
|
||||
id: 'app.browse.server.installing',
|
||||
@@ -680,7 +683,6 @@ async function chooseInstanceInstallVersion(
|
||||
const selectedVersion = getLatestMatchingInstallVersion(
|
||||
await getInstallProjectVersions(project.project_id),
|
||||
selectedPreferences,
|
||||
projectTypeValue,
|
||||
)
|
||||
|
||||
if (!selectedVersion) {
|
||||
@@ -763,11 +765,15 @@ function getCardActions(
|
||||
project: projectResult,
|
||||
contentType,
|
||||
mode: isModpack ? 'immediate' : 'queue',
|
||||
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
|
||||
selectedFilters: isModpack
|
||||
? []
|
||||
: stripServerRuntimeInstallFilters(searchState.currentFilters.value),
|
||||
providedFilters: isModpack ? [] : combinedProvidedFilters.value,
|
||||
overriddenProvidedFilterTypes: isModpack
|
||||
? []
|
||||
: searchState.overriddenProvidedFilterTypes.value,
|
||||
: stripServerRuntimeInstallOverrides(
|
||||
searchState.overriddenProvidedFilterTypes.value,
|
||||
),
|
||||
targetPreferences: getServerInstallTargetPreferences(contentType),
|
||||
getProjectVersions: getInstallProjectVersions,
|
||||
queue: serverInstallQueue,
|
||||
@@ -1036,10 +1042,24 @@ function getProjectBrowseQuery() {
|
||||
}
|
||||
}
|
||||
|
||||
const advancedFiltersCollapsed = computed({
|
||||
get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
|
||||
set: (value) => {
|
||||
themeStore.featureFlags['advanced_filters_collapsed'] = value
|
||||
getSettings()
|
||||
.then((settings) => {
|
||||
settings.feature_flags['advanced_filters_collapsed'] = value
|
||||
return setSettings(settings)
|
||||
})
|
||||
.catch(handleError)
|
||||
},
|
||||
})
|
||||
|
||||
provideBrowseManager({
|
||||
tags,
|
||||
projectType,
|
||||
...searchState,
|
||||
advancedFiltersCollapsed,
|
||||
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
|
||||
path: `/project/${result.project_id ?? result.slug}`,
|
||||
query: getProjectBrowseQuery(),
|
||||
|
||||
@@ -317,6 +317,10 @@ import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
@@ -327,7 +331,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
|
||||
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { useBreadcrumbs, useTheming } from '@/store/state'
|
||||
@@ -372,13 +376,15 @@ 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 liveServerStatusOnline = ref(false)
|
||||
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
|
||||
const recentPlays = computed(
|
||||
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
|
||||
)
|
||||
const playersOnline = ref<number | undefined>(undefined)
|
||||
const ping = ref<number | undefined>(undefined)
|
||||
const loadingServerPing = ref(false)
|
||||
const activeInstanceId = ref<string>()
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
@@ -390,6 +396,20 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function applyServerStatus(status: ServerStatus) {
|
||||
playersOnline.value = status.players?.online
|
||||
ping.value = status.ping
|
||||
liveServerStatusOnline.value = true
|
||||
loadingServerPing.value = true
|
||||
}
|
||||
|
||||
function resetServerStatus() {
|
||||
ping.value = undefined
|
||||
playersOnline.value = undefined
|
||||
liveServerStatusOnline.value = false
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
|
||||
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
|
||||
}
|
||||
@@ -398,9 +418,7 @@ async function fetchInstance() {
|
||||
isServerInstance.value = false
|
||||
linkedProjectV3.value = undefined
|
||||
preloadedContent.value = null
|
||||
ping.value = undefined
|
||||
playersOnline.value = undefined
|
||||
loadingServerPing.value = false
|
||||
resetServerStatus()
|
||||
|
||||
const nextInstance = await get(route.params.id as string).catch(handleError)
|
||||
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
|
||||
@@ -429,8 +447,9 @@ async function fetchInstance() {
|
||||
linkedProjectV3.value = nextLinkedProjectV3
|
||||
isServerInstance.value = nextIsServerInstance
|
||||
preloadedContent.value = nextPreloadedContent
|
||||
activeInstanceId.value = nextInstance?.id
|
||||
|
||||
fetchDeferredData()
|
||||
fetchDeferredData(nextInstance?.id)
|
||||
|
||||
if (nextInstance) {
|
||||
queryClient.prefetchQuery({
|
||||
@@ -441,18 +460,32 @@ async function fetchInstance() {
|
||||
}
|
||||
}
|
||||
|
||||
function fetchDeferredData() {
|
||||
function fetchDeferredData(instanceId?: string) {
|
||||
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
||||
if (isServerInstance.value && serverAddress) {
|
||||
get_server_status(serverAddress)
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||
if (cachedStatus) {
|
||||
applyServerStatus(cachedStatus)
|
||||
} else {
|
||||
playersOnline.value = undefined
|
||||
ping.value = undefined
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
fetchCachedServerStatus(queryClient, serverAddress)
|
||||
.then((status) => {
|
||||
playersOnline.value = status.players?.online
|
||||
ping.value = status.ping
|
||||
if (
|
||||
activeInstanceId.value !== instanceId ||
|
||||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
|
||||
)
|
||||
return
|
||||
applyServerStatus(status)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeInstanceId.value !== instanceId) return
|
||||
loadingServerPing.value = true
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -1351,7 +1351,9 @@ provideContentManager({
|
||||
title: item.file_name.replace('.disabled', ''),
|
||||
icon_url: null,
|
||||
},
|
||||
projectLink: item.project?.id ? { path: `/project/${item.project.id}` } : undefined,
|
||||
projectLink: item.project?.id
|
||||
? { path: `/project/${item.project.id}`, query: { i: props.instance.id } }
|
||||
: undefined,
|
||||
version: item.version ?? {
|
||||
id: item.file_name,
|
||||
version_number: formatMessage(commonMessages.unknownLabel),
|
||||
@@ -1361,6 +1363,7 @@ provideContentManager({
|
||||
item.project?.id && item.version?.id
|
||||
? {
|
||||
path: `/project/${item.project.id}/version/${item.version.id}`,
|
||||
query: { i: props.instance.id },
|
||||
}
|
||||
: undefined,
|
||||
owner: item.owner
|
||||
|
||||
@@ -116,13 +116,27 @@
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
<template #report> <ReportIcon /> Report </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else #actions>
|
||||
<ButtonStyled size="large" color="brand">
|
||||
<ButtonStyled v-if="showSwitchVersion && onVersionsPage" size="large">
|
||||
<button v-tooltip="installButtonTooltip" disabled>
|
||||
<CheckIcon />
|
||||
{{ formatMessage(commonMessages.installedLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="showSwitchVersion" size="large">
|
||||
<button @click="goToVersions">
|
||||
<SwapIcon />
|
||||
{{ formatMessage(messages.switchVersion) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else size="large" color="brand">
|
||||
<button
|
||||
v-tooltip="installButtonTooltip"
|
||||
:disabled="installButtonDisabled"
|
||||
@@ -171,7 +185,9 @@
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
<template #follow> <HeartIcon /> Follow </template>
|
||||
<template #save> <BookmarkIcon /> Save </template>
|
||||
<template #report> <ReportIcon /> Report </template>
|
||||
@@ -286,6 +302,7 @@ import {
|
||||
SelectedProjectsFloatingBar,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -293,8 +310,13 @@ import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import {
|
||||
get_organization,
|
||||
get_project,
|
||||
@@ -313,7 +335,7 @@ import {
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { getServerAddress, getServerLatency } from '@/helpers/worlds'
|
||||
import { getServerAddress } from '@/helpers/worlds'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { createServerInstallContent } from '@/providers/setup/server-install-content'
|
||||
@@ -326,6 +348,7 @@ const { handleError } = injectNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -343,6 +366,10 @@ const messages = defineMessages({
|
||||
id: 'app.project.install-button.already-installed',
|
||||
defaultMessage: 'This project is already installed',
|
||||
},
|
||||
switchVersion: {
|
||||
id: 'app.project.install-button.switch-version',
|
||||
defaultMessage: 'Switch version',
|
||||
},
|
||||
})
|
||||
|
||||
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
|
||||
@@ -514,6 +541,13 @@ const installButtonTooltip = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
const showSwitchVersion = computed(() => !!instance.value && installed.value)
|
||||
const onVersionsPage = computed(() => route.name === 'Versions')
|
||||
|
||||
function goToVersions() {
|
||||
router.push(versionsHref.value)
|
||||
}
|
||||
|
||||
const [allLoaders, allGameVersions] = await Promise.all([
|
||||
get_loaders().catch(handleError).then(ref),
|
||||
get_game_versions().catch(handleError).then(ref),
|
||||
@@ -600,10 +634,19 @@ async function fetchProjectData() {
|
||||
function fetchDeferredServerData(project) {
|
||||
const serverAddress = projectV3.value?.minecraft_java_server?.address
|
||||
if (serverAddress) {
|
||||
serverPing.value = undefined
|
||||
getServerLatency(serverAddress)
|
||||
.then((latency) => {
|
||||
serverPing.value = latency
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||
if (cachedStatus) {
|
||||
serverPing.value = cachedStatus.ping
|
||||
serverStatusOnline.value = true
|
||||
} else {
|
||||
serverPing.value = undefined
|
||||
}
|
||||
|
||||
fetchCachedServerStatus(queryClient, serverAddress)
|
||||
.then((status) => {
|
||||
if (projectV3.value?.minecraft_java_server?.address !== serverAddress) return
|
||||
serverPing.value = status.ping
|
||||
serverStatusOnline.value = true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to ping server ${serverAddress}:`, error)
|
||||
|
||||
@@ -1,259 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<Card>
|
||||
<Breadcrumbs
|
||||
:current-title="version.name"
|
||||
:link-stack="[
|
||||
{
|
||||
href: buildProjectHref(`/project/${route.params.id}/versions`),
|
||||
label: 'Versions',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div class="version-title">
|
||||
<h2>{{ version.name }}</h2>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<div class="flex flex-col">
|
||||
<router-link
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
|
||||
>
|
||||
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
|
||||
</router-link>
|
||||
<VersionPage
|
||||
v-if="version"
|
||||
:version="version"
|
||||
:enrichment="enrichment"
|
||||
:enrichment-loading="enrichmentLoading"
|
||||
:members="members"
|
||||
:dependency-link-creator="createDependencyLink"
|
||||
>
|
||||
<template #headerActions>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="installing || (installed && installedVersion === version.id)"
|
||||
@click="() => install(version.id)"
|
||||
@click="() => version && install(version.id)"
|
||||
>
|
||||
<DownloadIcon v-if="!installed" />
|
||||
<SwapIcon v-else-if="installedVersion !== version.id" />
|
||||
<CheckIcon v-else />
|
||||
{{
|
||||
installing
|
||||
? 'Installing...'
|
||||
? formatMessage(messages.installing)
|
||||
: installed && installedVersion === version.id
|
||||
? 'Installed'
|
||||
: 'Install'
|
||||
? formatMessage(commonMessages.installedLabel)
|
||||
: installed
|
||||
? formatMessage(commonMessages.switchToVersionButton)
|
||||
: formatMessage(commonMessages.installButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button>
|
||||
<ReportIcon />
|
||||
Report
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
:href="`https://modrinth.com/mod/${route.params.id}/version/${route.params.version}`"
|
||||
rel="external"
|
||||
<ButtonStyled type="outlined" circular>
|
||||
<OverflowMenu
|
||||
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
|
||||
:options="[
|
||||
{
|
||||
id: 'open-in-browser',
|
||||
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
link: `https://modrinth.com/report?item=version&itemID=${version.id}`,
|
||||
external: true,
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
Modrinth website
|
||||
<ExternalIcon />
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" /> {{ formatMessage(commonMessages.reportButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #supplementaryResourceActions="{ file }">
|
||||
<ButtonStyled>
|
||||
<a :href="file.url" :download="file.filename" target="_blank">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.downloadInBrowser) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="version-container">
|
||||
<div class="description-cards">
|
||||
<Card>
|
||||
<h3 class="card-title">Changelog</h3>
|
||||
<div class="markdown-body" v-html="renderString(version.changelog ?? '')" />
|
||||
</Card>
|
||||
<Card>
|
||||
<h3 class="card-title">Files</h3>
|
||||
<Card
|
||||
v-for="file in version.files"
|
||||
:key="file.id"
|
||||
:class="{ primary: file.primary }"
|
||||
class="file"
|
||||
>
|
||||
<span class="label">
|
||||
<FileIcon />
|
||||
<span>
|
||||
<span class="title">
|
||||
{{ file.filename }}
|
||||
</span>
|
||||
({{ formatBytes(file.size) }})
|
||||
<span v-if="file.primary" class="primary-label"> Primary </span>
|
||||
</span>
|
||||
</span>
|
||||
<ButtonStyled v-if="project.project_type !== 'modpack' || file.primary" color="brand">
|
||||
<button class="download" :disabled="installed" @click="() => install(version.id)">
|
||||
<DownloadIcon v-if="!installed" />
|
||||
<CheckIcon v-else />
|
||||
{{ installed ? 'Installed' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</Card>
|
||||
</Card>
|
||||
<Card v-if="displayDependencies.length > 0">
|
||||
<h2>Dependencies</h2>
|
||||
<div v-for="dependency in displayDependencies" :key="dependency.title">
|
||||
<router-link v-if="dependency.link" class="btn dependency" :to="dependency.link">
|
||||
<Avatar size="sm" :src="dependency.icon" />
|
||||
<div>
|
||||
<span class="title"> {{ dependency.title }} </span> <br />
|
||||
<span> {{ dependency.subtitle }} </span>
|
||||
</div>
|
||||
</router-link>
|
||||
<div v-else class="dependency disabled" disabled="">
|
||||
<Avatar size="sm" :src="dependency.icon" />
|
||||
<div class="text">
|
||||
<div class="title">{{ dependency.title }}</div>
|
||||
<div>{{ dependency.subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card class="metadata-card">
|
||||
<h3 class="card-title">Metadata</h3>
|
||||
<div class="metadata">
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Release Channel</span>
|
||||
<span class="metadata-value"
|
||||
><Badge
|
||||
:color="releaseColor(version.version_type)"
|
||||
:type="
|
||||
version.version_type.charAt(0).toUpperCase() + version.version_type.slice(1)
|
||||
"
|
||||
/></span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Version Number</span>
|
||||
<span class="metadata-value">{{ version.version_number }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Loaders</span>
|
||||
<span class="metadata-value">{{
|
||||
version.loaders
|
||||
.map((loader) => loader.charAt(0).toUpperCase() + loader.slice(1))
|
||||
.join(', ')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Game Versions</span>
|
||||
<span class="metadata-value"> {{ version.game_versions.join(', ') }} </span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Downloads</span>
|
||||
<span class="metadata-value">{{ version.downloads }}</span>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Publication Date</span>
|
||||
<span class="metadata-value">
|
||||
{{ formatDateTime(version.date_published) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="author" class="metadata-item">
|
||||
<span class="metadata-label">Author</span>
|
||||
<a
|
||||
:href="`https://modrinth.com/user/${author.user.username}`"
|
||||
rel="external"
|
||||
class="metadata-value btn author"
|
||||
>
|
||||
<Avatar size="sm" :src="author.user.avatar_url" circle />
|
||||
<span>
|
||||
<strong>
|
||||
{{ author.user.username }}
|
||||
</strong>
|
||||
<br />
|
||||
{{ author.role }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Version ID</span>
|
||||
<span class="metadata-value"><CopyCode class="copycode" :text="version.id" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</VersionPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckIcon, DownloadIcon, ExternalIcon, FileIcon, ReportIcon } from '@modrinth/assets'
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
MoreVerticalIcon,
|
||||
ReportIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Breadcrumbs,
|
||||
ButtonStyled,
|
||||
Card,
|
||||
CopyCode,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
type DependencyContext,
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
VersionPage,
|
||||
} from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons'
|
||||
import { get_project_many, get_version_many } from '@/helpers/cache.js'
|
||||
import { releaseColor } from '@/helpers/utils'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
const formatBytes = useFormatBytes()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
members: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
install: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
const messages = defineMessages({
|
||||
allVersions: {
|
||||
id: 'app.project.version.all-versions',
|
||||
defaultMessage: 'All versions',
|
||||
},
|
||||
installing: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
id: 'app.project.version.installing',
|
||||
defaultMessage: 'Installing',
|
||||
},
|
||||
installedVersion: {
|
||||
type: String,
|
||||
required: true,
|
||||
downloadInBrowser: {
|
||||
id: 'app.project.version.download-in-browser',
|
||||
defaultMessage: 'Download in browser',
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps<{
|
||||
project: Labrinth.Projects.v2.Project
|
||||
versions: Labrinth.Versions.v3.Version[]
|
||||
members: Labrinth.Projects.v3.TeamMember[]
|
||||
install: (version: string | null) => void
|
||||
installed: boolean
|
||||
installing: boolean
|
||||
installedVersion: string
|
||||
}>()
|
||||
|
||||
const version = ref(props.versions.find((version) => version.id === route.params.version))
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
if (version.value) {
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.versions,
|
||||
async () => {
|
||||
if (route.params.version) {
|
||||
version.value = props.versions.find((version) => version.id === route.params.version)
|
||||
await refreshDisplayDependencies()
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
},
|
||||
)
|
||||
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
|
||||
const enrichmentLoading = ref(false)
|
||||
|
||||
const author = computed(() =>
|
||||
props.members ? props.members.find((member) => member.user.id === version.value.author_id) : null,
|
||||
)
|
||||
|
||||
const displayDependencies = ref({})
|
||||
|
||||
function buildProjectHref(path) {
|
||||
function buildProjectHref(path: string): string {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, val] of Object.entries(route.query)) {
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) params.append(key, v)
|
||||
for (const v of val) {
|
||||
if (v != null) params.append(key, v)
|
||||
}
|
||||
} else if (val) {
|
||||
params.append(key, String(val))
|
||||
}
|
||||
@@ -262,222 +155,65 @@ function buildProjectHref(path) {
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
async function refreshDisplayDependencies() {
|
||||
const projectIds = new Set()
|
||||
const versionIds = new Set()
|
||||
if (version.value.dependencies) {
|
||||
for (const dependency of version.value.dependencies) {
|
||||
if (dependency.project_id) {
|
||||
projectIds.add(dependency.project_id)
|
||||
}
|
||||
if (dependency.version_id) {
|
||||
versionIds.add(dependency.version_id)
|
||||
}
|
||||
}
|
||||
function createDependencyLink(context: DependencyContext): string | undefined {
|
||||
if (context.version) {
|
||||
return buildProjectHref(`/project/${context.version.project_id}/version/${context.version.id}`)
|
||||
}
|
||||
const [projectDeps, versionDeps] = await Promise.all([
|
||||
get_project_many([...projectIds]),
|
||||
get_version_many([...versionIds]),
|
||||
])
|
||||
|
||||
const dependencies = {
|
||||
projects: projectDeps,
|
||||
versions: versionDeps,
|
||||
if (context.project) {
|
||||
return buildProjectHref(`/project/${context.project.id}`)
|
||||
}
|
||||
|
||||
displayDependencies.value = version.value.dependencies.map((dependency) => {
|
||||
const version = dependencies.versions.find((obj) => obj.id === dependency.version_id)
|
||||
if (version) {
|
||||
const project = dependencies.projects.find(
|
||||
(obj) => obj.id === version.project_id || obj.id === dependency.project_id,
|
||||
)
|
||||
return {
|
||||
icon: project?.icon_url,
|
||||
title: project?.title || project?.name,
|
||||
subtitle: `Version ${version.version_number} is ${dependency.dependency_type}`,
|
||||
link: buildProjectHref(`/project/${project.slug}/version/${version.id}`),
|
||||
}
|
||||
} else {
|
||||
const project = dependencies.projects.find((obj) => obj.id === dependency.project_id)
|
||||
|
||||
if (project) {
|
||||
return {
|
||||
icon: project?.icon_url,
|
||||
title: project?.title || project?.name,
|
||||
subtitle: `${dependency.dependency_type}`,
|
||||
link: buildProjectHref(`/project/${project.slug}`),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
icon: null,
|
||||
title: dependency.file_name,
|
||||
subtitle: `Added via overrides`,
|
||||
link: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
await refreshDisplayDependencies()
|
||||
|
||||
async function refreshEnrichment() {
|
||||
if (!version.value) return
|
||||
|
||||
const projectIds = new Set<string>()
|
||||
const versionIds = new Set<string>()
|
||||
for (const dependency of version.value.dependencies ?? []) {
|
||||
if (dependency.project_id) {
|
||||
projectIds.add(dependency.project_id)
|
||||
}
|
||||
if (dependency.version_id) {
|
||||
versionIds.add(dependency.version_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (projectIds.size === 0 && versionIds.size === 0) {
|
||||
enrichment.value = { projects: [], versions: [] }
|
||||
return
|
||||
}
|
||||
|
||||
enrichmentLoading.value = true
|
||||
try {
|
||||
const versionResults = versionIds.size > 0 ? await get_version_many([...versionIds]) : []
|
||||
for (const dependencyVersion of versionResults ?? []) {
|
||||
if (dependencyVersion.project_id) {
|
||||
projectIds.add(dependencyVersion.project_id)
|
||||
}
|
||||
}
|
||||
const projectResults = projectIds.size > 0 ? await get_project_many([...projectIds]) : []
|
||||
enrichment.value = {
|
||||
projects: projectResults ?? [],
|
||||
versions: versionResults ?? [],
|
||||
}
|
||||
} finally {
|
||||
enrichmentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.versions,
|
||||
async () => {
|
||||
if (route.params.version) {
|
||||
version.value = props.versions.find((v) => v.id === route.params.version)
|
||||
if (version.value) {
|
||||
breadcrumbs.setName('Version', version.value.name)
|
||||
}
|
||||
await refreshEnrichment()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await refreshEnrichment()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.version-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.version-title {
|
||||
margin-bottom: 1rem;
|
||||
h2 {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-contrast);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dependency {
|
||||
display: flex;
|
||||
padding: 0.5rem 1rem 0.5rem 0.5rem;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-raised-bg);
|
||||
color: var(--color-base);
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
:deep(svg) {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.file {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-base);
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
.download {
|
||||
margin-left: auto;
|
||||
background-color: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
margin: auto 0 auto;
|
||||
gap: 0.5rem;
|
||||
|
||||
.title {
|
||||
font-weight: bolder;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
svg {
|
||||
min-width: 1.1rem;
|
||||
min-height: 1.1rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.primary-label {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-brand-highlight);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-contrast);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.description-cards {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metadata-card {
|
||||
width: 20rem;
|
||||
height: min-content;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.metadata-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: var(--color-base);
|
||||
background: var(--color-raised-bg);
|
||||
padding: 0.5rem;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
:deep(hr),
|
||||
:deep(h1),
|
||||
:deep(h2),
|
||||
img {
|
||||
max-width: max(60rem, 90%) !important;
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.copycode {
|
||||
border: 0;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
vertical-align: center;
|
||||
align-items: center;
|
||||
cursor: not-allowed;
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
.text {
|
||||
filter: brightness(0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,10 +9,18 @@
|
||||
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
|
||||
>
|
||||
<template #actions="{ version }">
|
||||
<ButtonStyled circular type="transparent" color="green">
|
||||
<ButtonStyled
|
||||
circular
|
||||
type="transparent"
|
||||
:color="installed && version.id === installedVersion ? 'standard' : 'green'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!installed ? 'Install' : version.id !== installedVersion ? 'Swap version' : ''
|
||||
!installed
|
||||
? formatMessage(commonMessages.installButton)
|
||||
: version.id !== installedVersion
|
||||
? formatMessage(commonMessages.switchToVersionButton)
|
||||
: formatMessage(messages.alreadyInstalled)
|
||||
"
|
||||
:disabled="installing || (installed && version.id === installedVersion)"
|
||||
@click.stop="() => install(version.id)"
|
||||
@@ -45,11 +53,13 @@
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Add to another instance
|
||||
</template>
|
||||
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
|
||||
<template #open-in-browser>
|
||||
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
<a
|
||||
v-else
|
||||
v-tooltip="`Open in browser`"
|
||||
v-tooltip="formatMessage(commonMessages.openInBrowserButton)"
|
||||
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
|
||||
target="_blank"
|
||||
>
|
||||
@@ -65,9 +75,12 @@
|
||||
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
ProjectPageVersions,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -76,8 +89,16 @@ import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
|
||||
const messages = defineMessages({
|
||||
alreadyInstalled: {
|
||||
id: 'app.project.versions.already-installed',
|
||||
defaultMessage: 'Already installed',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||
pride_fundraiser: true,
|
||||
i18n_debug: false,
|
||||
show_instance_play_time: true,
|
||||
advanced_filters_collapsed: true,
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
|
||||
|
||||
@@ -27,6 +27,8 @@ pub enum ErrorKind {
|
||||
inner: Box<s3::error::S3Error>,
|
||||
file: String,
|
||||
},
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Error acquiring semaphore: {0}")]
|
||||
Acquire(#[from] tokio::sync::AcquireError),
|
||||
#[error("Tracing error: {0}")]
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
//! Fetches Fabric-compatible loader metadata.
|
||||
//!
|
||||
//! Fabric and Quilt both expose loader profiles for a concrete Minecraft
|
||||
//! version, but Daedalus publishes templated profiles using
|
||||
//! `${modrinth.gameVersion}`. A group is a set of Minecraft versions whose
|
||||
//! upstream loader profiles have the same structure after the concrete
|
||||
//! Minecraft version is replaced with `${modrinth.gameVersion}`. Fabric uses
|
||||
//! one universal group, so its public profile paths stay as
|
||||
//! `versions/{loader}.json`. Quilt has more than one group: versions before
|
||||
//! 26.x include hashed/intermediary libraries, while 26.x versions do not. For
|
||||
//! Quilt, Daedalus writes one templated profile per group at
|
||||
//! `version-group/{group}/loader-version/{loader}`.
|
||||
|
||||
use crate::metadata_groups::{
|
||||
UNIVERSAL_METADATA_GROUP, metadata_group_for_game_version, metadata_groups,
|
||||
};
|
||||
use crate::util::{download_file, fetch_json, format_url};
|
||||
use crate::{
|
||||
Error, FetchResult, MirrorArtifact, UploadFile, insert_mirrored_artifact,
|
||||
@@ -64,7 +80,112 @@ async fn fetch(
|
||||
&semaphore,
|
||||
)
|
||||
.await?;
|
||||
let all_loader_versions = fabric_manifest.loader.clone();
|
||||
let all_game_versions = fabric_manifest.game.clone();
|
||||
let metadata_groups = metadata_groups(
|
||||
mod_loader,
|
||||
all_game_versions.iter().map(|x| x.version.as_str()),
|
||||
);
|
||||
|
||||
if metadata_groups
|
||||
.iter()
|
||||
.any(|group| group.id != UNIVERSAL_METADATA_GROUP)
|
||||
{
|
||||
let loaders = all_loader_versions
|
||||
.iter()
|
||||
.filter(|x| !skip_versions.contains(&&*x.version))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let profile_requests = metadata_groups
|
||||
.iter()
|
||||
.flat_map(|group| {
|
||||
loaders.iter().map(move |loader| ProfileRequest {
|
||||
group: group.id.to_string(),
|
||||
loader_profile_template_game_version: group
|
||||
.loader_profile_template_game_version
|
||||
.clone(),
|
||||
game_versions: group.game_versions.clone(),
|
||||
loader_version: loader.version.clone(),
|
||||
url: format!(
|
||||
"{}/versions/loader/{}/{}/profile/json",
|
||||
meta_url,
|
||||
group.loader_profile_template_game_version,
|
||||
loader.version
|
||||
),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
fetch_metadata_profiles(
|
||||
mod_loader,
|
||||
format_version,
|
||||
maven_url,
|
||||
profile_requests,
|
||||
&upload_files,
|
||||
&mirror_artifacts,
|
||||
&semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let version_groups = metadata_groups
|
||||
.iter()
|
||||
.map(|group| daedalus::modded::VersionGroup {
|
||||
id: group.id.to_string(),
|
||||
loaders: loaders
|
||||
.iter()
|
||||
.map(|loader| {
|
||||
let version_path = metadata_version_path(
|
||||
mod_loader,
|
||||
format_version,
|
||||
&loader.version,
|
||||
group.id,
|
||||
);
|
||||
|
||||
daedalus::modded::LoaderVersion {
|
||||
id: loader.version.clone(),
|
||||
url: format_url(&version_path),
|
||||
stable: loader.stable,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let manifest = daedalus::modded::Manifest {
|
||||
game_versions: all_game_versions
|
||||
.into_iter()
|
||||
.map(|game_version| {
|
||||
let group = metadata_group_for_game_version(
|
||||
&metadata_groups,
|
||||
mod_loader,
|
||||
&game_version.version,
|
||||
)
|
||||
.expect("game version should have a metadata group");
|
||||
|
||||
daedalus::modded::Version {
|
||||
id: game_version.version.clone(),
|
||||
stable: game_version.stable,
|
||||
version_group: Some(group.id.to_string()),
|
||||
loaders: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
version_groups,
|
||||
};
|
||||
|
||||
upload_files.insert(
|
||||
format!("{mod_loader}/v{format_version}/manifest.json"),
|
||||
UploadFile {
|
||||
file: bytes::Bytes::from(serde_json::to_vec(&manifest)?),
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
return Ok(FetchResult {
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
});
|
||||
}
|
||||
// We check Modrinth's manifest to find newly added loader versions,
|
||||
// intermediary/mapping artifacts, and game versions.
|
||||
let (
|
||||
@@ -125,8 +246,6 @@ async fn fetch(
|
||||
)
|
||||
};
|
||||
|
||||
const DUMMY_GAME_VERSION: &str = "1.21";
|
||||
|
||||
if !fetch_intermediary_versions.is_empty() {
|
||||
for x in &fetch_intermediary_versions {
|
||||
insert_mirrored_artifact(
|
||||
@@ -140,94 +259,38 @@ async fn fetch(
|
||||
}
|
||||
|
||||
if !fetch_fabric_versions.is_empty() {
|
||||
let fabric_version_manifest_urls = fetch_fabric_versions
|
||||
let universal_group = metadata_groups
|
||||
.iter()
|
||||
.map(|x| {
|
||||
format!(
|
||||
.find(|group| group.id == UNIVERSAL_METADATA_GROUP)
|
||||
.expect("fabric metadata should have a universal group");
|
||||
let profile_requests = fetch_fabric_versions
|
||||
.iter()
|
||||
.map(|loader| ProfileRequest {
|
||||
group: universal_group.id.to_string(),
|
||||
loader_profile_template_game_version: universal_group
|
||||
.loader_profile_template_game_version
|
||||
.clone(),
|
||||
game_versions: universal_group.game_versions.clone(),
|
||||
loader_version: loader.version.clone(),
|
||||
url: format!(
|
||||
"{}/versions/loader/{}/{}/profile/json",
|
||||
meta_url, DUMMY_GAME_VERSION, x.version
|
||||
)
|
||||
meta_url,
|
||||
universal_group.loader_profile_template_game_version,
|
||||
loader.version
|
||||
),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let fabric_version_manifests = futures::future::try_join_all(
|
||||
fabric_version_manifest_urls
|
||||
.iter()
|
||||
.map(|x| download_file(x, None, &semaphore)),
|
||||
|
||||
fetch_metadata_profiles(
|
||||
mod_loader,
|
||||
format_version,
|
||||
maven_url,
|
||||
profile_requests,
|
||||
&upload_files,
|
||||
&mirror_artifacts,
|
||||
&semaphore,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|x| serde_json::from_slice(&x))
|
||||
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
|
||||
|
||||
let patched_version_manifests = fabric_version_manifests
|
||||
.into_iter()
|
||||
.map(|mut version_info| {
|
||||
for lib in &mut version_info.libraries {
|
||||
let new_name = lib
|
||||
.name
|
||||
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
|
||||
|
||||
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
|
||||
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
|
||||
lib.url = Some(
|
||||
"https://libraries.minecraft.net/".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// If a library is not intermediary, we add it to mirror artifacts to be mirrored
|
||||
if lib.name == new_name {
|
||||
insert_mirrored_artifact(
|
||||
&new_name,
|
||||
None,
|
||||
vec![
|
||||
lib.url
|
||||
.clone()
|
||||
.unwrap_or_else(|| maven_url.to_string()),
|
||||
],
|
||||
false,
|
||||
&mirror_artifacts,
|
||||
)?;
|
||||
} else {
|
||||
lib.name = new_name;
|
||||
}
|
||||
|
||||
lib.url = Some(format_url("maven/"));
|
||||
}
|
||||
|
||||
version_info.id = version_info
|
||||
.id
|
||||
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
|
||||
version_info.inherits_from = version_info
|
||||
.inherits_from
|
||||
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
|
||||
|
||||
Ok(version_info)
|
||||
})
|
||||
.collect::<Result<Vec<_>, Error>>()?;
|
||||
let serialized_version_manifests = patched_version_manifests
|
||||
.iter()
|
||||
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?;
|
||||
|
||||
serialized_version_manifests
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.for_each(|(index, bytes)| {
|
||||
let loader = fetch_fabric_versions[index];
|
||||
|
||||
let version_path = format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
loader.version
|
||||
);
|
||||
|
||||
upload_files.insert(
|
||||
version_path,
|
||||
UploadFile {
|
||||
file: bytes,
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
});
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !fetch_fabric_versions.is_empty()
|
||||
@@ -240,17 +303,20 @@ async fn fetch(
|
||||
let loader_versions = daedalus::modded::Version {
|
||||
id: DUMMY_REPLACE_STRING.to_string(),
|
||||
stable: true,
|
||||
loaders: fabric_manifest
|
||||
.loader
|
||||
.into_iter()
|
||||
version_group: None,
|
||||
loaders: all_loader_versions
|
||||
.iter()
|
||||
.filter(|x| !skip_versions.contains(&&*x.version))
|
||||
.map(|x| {
|
||||
let version_path = format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
x.version,
|
||||
let version_path = metadata_version_path(
|
||||
mod_loader,
|
||||
format_version,
|
||||
&x.version,
|
||||
UNIVERSAL_METADATA_GROUP,
|
||||
);
|
||||
|
||||
daedalus::modded::LoaderVersion {
|
||||
id: x.version,
|
||||
id: x.version.clone(),
|
||||
url: format_url(&version_path),
|
||||
stable: x.stable,
|
||||
}
|
||||
@@ -260,14 +326,16 @@ async fn fetch(
|
||||
|
||||
let manifest = daedalus::modded::Manifest {
|
||||
game_versions: std::iter::once(loader_versions)
|
||||
.chain(fabric_manifest.game.into_iter().map(|x| {
|
||||
.chain(all_game_versions.into_iter().map(|x| {
|
||||
daedalus::modded::Version {
|
||||
id: x.version,
|
||||
stable: x.stable,
|
||||
version_group: None,
|
||||
loaders: vec![],
|
||||
}
|
||||
}))
|
||||
.collect(),
|
||||
version_groups: Vec::new(),
|
||||
};
|
||||
|
||||
upload_files.insert(
|
||||
@@ -285,6 +353,145 @@ async fn fetch(
|
||||
})
|
||||
}
|
||||
|
||||
struct ProfileRequest {
|
||||
group: String,
|
||||
loader_profile_template_game_version: String,
|
||||
game_versions: Vec<String>,
|
||||
loader_version: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
fn metadata_version_path(
|
||||
mod_loader: &str,
|
||||
format_version: usize,
|
||||
loader_version: &str,
|
||||
group: &str,
|
||||
) -> String {
|
||||
if group == UNIVERSAL_METADATA_GROUP {
|
||||
format!("{mod_loader}/v{format_version}/versions/{loader_version}.json")
|
||||
} else {
|
||||
format!(
|
||||
"{mod_loader}/v{format_version}/version-group/{group}/loader-version/{loader_version}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_metadata_profiles(
|
||||
mod_loader: &str,
|
||||
format_version: usize,
|
||||
maven_url: &str,
|
||||
profile_requests: Vec<ProfileRequest>,
|
||||
upload_files: &DashMap<String, UploadFile>,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<(), Error> {
|
||||
let version_manifests = futures::future::try_join_all(
|
||||
profile_requests
|
||||
.iter()
|
||||
.map(|x| download_file(&x.url, None, semaphore)),
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|x| serde_json::from_slice(&x))
|
||||
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
|
||||
|
||||
let patched_version_manifests = version_manifests
|
||||
.into_iter()
|
||||
.zip(profile_requests.iter())
|
||||
.map(|(mut version_info, request)| {
|
||||
patch_version_info(
|
||||
&mut version_info,
|
||||
&request.loader_profile_template_game_version,
|
||||
&request.game_versions,
|
||||
maven_url,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
|
||||
Ok(version_info)
|
||||
})
|
||||
.collect::<Result<Vec<_>, Error>>()?;
|
||||
let serialized_version_manifests = patched_version_manifests
|
||||
.iter()
|
||||
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
|
||||
.collect::<Result<Vec<_>, serde_json::Error>>()?;
|
||||
|
||||
serialized_version_manifests
|
||||
.into_iter()
|
||||
.zip(profile_requests)
|
||||
.for_each(|(bytes, request)| {
|
||||
let version_path = metadata_version_path(
|
||||
mod_loader,
|
||||
format_version,
|
||||
&request.loader_version,
|
||||
&request.group,
|
||||
);
|
||||
|
||||
upload_files.insert(
|
||||
version_path,
|
||||
UploadFile {
|
||||
file: bytes,
|
||||
content_type: Some("application/json".to_string()),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch_version_info(
|
||||
version_info: &mut PartialVersionInfo,
|
||||
game_version: &str,
|
||||
game_versions: &[String],
|
||||
maven_url: &str,
|
||||
mirror_artifacts: &DashMap<String, MirrorArtifact>,
|
||||
) -> Result<(), Error> {
|
||||
for lib in &mut version_info.libraries {
|
||||
let new_name = lib.name.replace(game_version, DUMMY_REPLACE_STRING);
|
||||
|
||||
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
|
||||
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
|
||||
lib.url = Some("https://libraries.minecraft.net/".to_string());
|
||||
}
|
||||
let source_url =
|
||||
lib.url.clone().unwrap_or_else(|| maven_url.to_string());
|
||||
|
||||
if lib.name == new_name {
|
||||
insert_mirrored_artifact(
|
||||
&new_name,
|
||||
None,
|
||||
vec![source_url],
|
||||
false,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
} else {
|
||||
for concrete_game_version in game_versions {
|
||||
let concrete_name =
|
||||
lib.name.replace(game_version, concrete_game_version);
|
||||
|
||||
insert_mirrored_artifact(
|
||||
&concrete_name,
|
||||
None,
|
||||
vec![source_url.clone()],
|
||||
false,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
}
|
||||
|
||||
lib.name = new_name;
|
||||
}
|
||||
|
||||
lib.url = Some(format_url("maven/"));
|
||||
}
|
||||
|
||||
version_info.id =
|
||||
version_info.id.replace(game_version, DUMMY_REPLACE_STRING);
|
||||
version_info.inherits_from = version_info
|
||||
.inherits_from
|
||||
.replace(game_version, DUMMY_REPLACE_STRING);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct FabricVersions {
|
||||
pub loader: Vec<FabricLoaderVersion>,
|
||||
|
||||
@@ -12,7 +12,10 @@ use itertools::Itertools;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[tracing::instrument(skip(semaphore))]
|
||||
@@ -243,12 +246,45 @@ async fn fetch(
|
||||
};
|
||||
|
||||
if !fetch_versions.is_empty() {
|
||||
let forge_installers = futures::future::try_join_all(
|
||||
fetch_versions
|
||||
.iter()
|
||||
.map(|x| download_file(&x.installer_url, None, &semaphore)),
|
||||
)
|
||||
.await?;
|
||||
let total_installers = fetch_versions.len();
|
||||
let downloaded_installers = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
tracing::info!(
|
||||
mod_loader,
|
||||
total_files = total_installers,
|
||||
"Downloading loader installers"
|
||||
);
|
||||
|
||||
let forge_installers =
|
||||
futures::future::try_join_all(fetch_versions.iter().map(|x| {
|
||||
let downloaded_installers = downloaded_installers.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
|
||||
async move {
|
||||
let installer =
|
||||
download_file(&x.installer_url, None, &semaphore)
|
||||
.await?;
|
||||
let downloaded = downloaded_installers
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
+ 1;
|
||||
|
||||
if downloaded.is_multiple_of(100)
|
||||
|| downloaded == total_installers
|
||||
{
|
||||
tracing::info!(
|
||||
mod_loader,
|
||||
downloaded_files = downloaded,
|
||||
remaining_files =
|
||||
total_installers.saturating_sub(downloaded),
|
||||
total_files = total_installers,
|
||||
"Downloaded loader installers"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(installer)
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
#[tracing::instrument(skip(raw, upload_files, mirror_artifacts))]
|
||||
async fn read_forge_installer(
|
||||
@@ -761,21 +797,23 @@ async fn fetch(
|
||||
.into_iter()
|
||||
.map(|(game_version, loaders)| {
|
||||
daedalus::modded::Version {
|
||||
id: game_version,
|
||||
stable: true,
|
||||
loaders: loaders
|
||||
.map(|x| daedalus::modded::LoaderVersion {
|
||||
url: format_url(&format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
x.loader_version
|
||||
)),
|
||||
id: x.loader_version,
|
||||
stable: false,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
id: game_version,
|
||||
stable: true,
|
||||
version_group: None,
|
||||
loaders: loaders
|
||||
.map(|x| daedalus::modded::LoaderVersion {
|
||||
url: format_url(&format!(
|
||||
"{mod_loader}/v{format_version}/versions/{}.json",
|
||||
x.loader_version
|
||||
)),
|
||||
id: x.loader_version,
|
||||
stable: false,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
version_groups: Vec::new(),
|
||||
};
|
||||
|
||||
upload_files.insert(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use crate::util::{
|
||||
REQWEST_CLIENT, format_url, upload_file_to_bucket,
|
||||
upload_url_to_bucket_mirrors,
|
||||
upload_url_to_bucket_mirrors, write_file_to_local_output,
|
||||
write_url_to_local_output_mirrors,
|
||||
};
|
||||
use daedalus::get_path_from_artifact;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use std::sync::Arc;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
@@ -12,6 +16,7 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
mod error;
|
||||
mod fabric;
|
||||
mod forge;
|
||||
mod metadata_groups;
|
||||
mod minecraft;
|
||||
pub mod util;
|
||||
|
||||
@@ -44,51 +49,92 @@ async fn main() -> Result<()> {
|
||||
));
|
||||
|
||||
let mut fetch_result = FetchResult::default();
|
||||
let only_loader = dotenvy::var("DAEDALUS_ONLY").ok();
|
||||
|
||||
match minecraft::fetch(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "minecraft") {
|
||||
match minecraft::fetch(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
match fabric::fetch_fabric(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "fabric") {
|
||||
match fabric::fetch_fabric(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
match fabric::fetch_quilt(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "quilt") {
|
||||
match fabric::fetch_quilt(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
match forge::fetch_neo(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "neo") {
|
||||
match forge::fetch_neo(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
match forge::fetch_forge(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
|
||||
if should_fetch(only_loader.as_deref(), "forge") {
|
||||
match forge::fetch_forge(semaphore.clone()).await {
|
||||
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
|
||||
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let FetchResult {
|
||||
upload_files,
|
||||
mirror_artifacts,
|
||||
} = fetch_result;
|
||||
let upload_file_total = upload_files.len();
|
||||
let mirror_file_total = mirror_artifacts.len();
|
||||
|
||||
futures::future::try_join_all(upload_files.iter().map(|entry| {
|
||||
upload_file_to_bucket(
|
||||
entry.key().clone(),
|
||||
entry.value().file.clone(),
|
||||
entry.value().content_type.clone(),
|
||||
&semaphore,
|
||||
)
|
||||
}))
|
||||
.await?;
|
||||
if dotenvy::var("LOCAL_OUTPUT_DIR").is_ok() {
|
||||
let written_files = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
|
||||
upload_url_to_bucket_mirrors(
|
||||
format!("maven/{}", entry.key()),
|
||||
entry
|
||||
tracing::info!(
|
||||
total_files = upload_file_total,
|
||||
"Writing local metadata files"
|
||||
);
|
||||
|
||||
futures::future::try_join_all(upload_files.iter().map(|entry| {
|
||||
let path = entry.key().clone();
|
||||
let file = entry.value().file.clone();
|
||||
let written_files = written_files.clone();
|
||||
|
||||
async move {
|
||||
write_file_to_local_output(&path, file).await?;
|
||||
let written = written_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if written.is_multiple_of(100) || written == upload_file_total {
|
||||
tracing::info!(
|
||||
written_files = written,
|
||||
remaining_files =
|
||||
upload_file_total.saturating_sub(written),
|
||||
total_files = upload_file_total,
|
||||
"Wrote local metadata files"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let written_mirror_files = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
tracing::info!(
|
||||
total_files = mirror_file_total,
|
||||
"Writing local mirror files"
|
||||
);
|
||||
|
||||
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
|
||||
let path = format!("maven/{}", entry.key());
|
||||
let mirrors = entry
|
||||
.value()
|
||||
.mirrors
|
||||
.iter()
|
||||
@@ -99,12 +145,117 @@ async fn main() -> Result<()> {
|
||||
format!("{}{}", mirror.path, entry.key())
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
entry.value().sha1.clone(),
|
||||
&semaphore,
|
||||
)
|
||||
}))
|
||||
.await?;
|
||||
.collect();
|
||||
let sha1 = entry.value().sha1.clone();
|
||||
let written_mirror_files = written_mirror_files.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
|
||||
async move {
|
||||
write_url_to_local_output_mirrors(
|
||||
path, mirrors, sha1, &semaphore,
|
||||
)
|
||||
.await?;
|
||||
let written =
|
||||
written_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if written.is_multiple_of(100) || written == mirror_file_total {
|
||||
tracing::info!(
|
||||
written_files = written,
|
||||
remaining_files =
|
||||
mirror_file_total.saturating_sub(written),
|
||||
total_files = mirror_file_total,
|
||||
"Wrote local mirror files"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
} else {
|
||||
let uploaded_files = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
tracing::info!(
|
||||
total_files = upload_file_total,
|
||||
"Uploading metadata files"
|
||||
);
|
||||
|
||||
futures::future::try_join_all(upload_files.iter().map(|entry| {
|
||||
let path = entry.key().clone();
|
||||
let file = entry.value().file.clone();
|
||||
let content_type = entry.value().content_type.clone();
|
||||
let uploaded_files = uploaded_files.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
|
||||
async move {
|
||||
upload_file_to_bucket(path, file, content_type, &semaphore)
|
||||
.await?;
|
||||
let uploaded =
|
||||
uploaded_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if uploaded.is_multiple_of(100) || uploaded == upload_file_total
|
||||
{
|
||||
tracing::info!(
|
||||
uploaded_files = uploaded,
|
||||
remaining_files =
|
||||
upload_file_total.saturating_sub(uploaded),
|
||||
total_files = upload_file_total,
|
||||
"Uploaded metadata files"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let uploaded_mirror_files = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
tracing::info!(
|
||||
total_files = mirror_file_total,
|
||||
"Uploading mirror files"
|
||||
);
|
||||
|
||||
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
|
||||
let path = format!("maven/{}", entry.key());
|
||||
let mirrors = entry
|
||||
.value()
|
||||
.mirrors
|
||||
.iter()
|
||||
.map(|mirror| {
|
||||
if mirror.entire_url {
|
||||
mirror.path.clone()
|
||||
} else {
|
||||
format!("{}{}", mirror.path, entry.key())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sha1 = entry.value().sha1.clone();
|
||||
let uploaded_mirror_files = uploaded_mirror_files.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
|
||||
async move {
|
||||
upload_url_to_bucket_mirrors(path, mirrors, sha1, &semaphore)
|
||||
.await?;
|
||||
let uploaded =
|
||||
uploaded_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
|
||||
if uploaded.is_multiple_of(100) || uploaded == mirror_file_total
|
||||
{
|
||||
tracing::info!(
|
||||
uploaded_files = uploaded,
|
||||
remaining_files =
|
||||
mirror_file_total.saturating_sub(uploaded),
|
||||
total_files = mirror_file_total,
|
||||
"Uploaded mirror files"
|
||||
);
|
||||
}
|
||||
|
||||
Ok::<_, Error>(())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
}
|
||||
|
||||
if dotenvy::var("CLOUDFLARE_INTEGRATION")
|
||||
.ok()
|
||||
@@ -151,6 +302,20 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_fetch(only_loader: Option<&str>, loader: &str) -> bool {
|
||||
let Some(only_loader) = only_loader else {
|
||||
return true;
|
||||
};
|
||||
|
||||
only_loader.split(',').any(|entry| {
|
||||
let entry = entry.trim();
|
||||
|
||||
entry.eq_ignore_ascii_case("all")
|
||||
|| entry.eq_ignore_ascii_case(loader)
|
||||
|| (loader == "neo" && entry.eq_ignore_ascii_case("neoforge"))
|
||||
})
|
||||
}
|
||||
|
||||
pub struct UploadFile {
|
||||
file: bytes::Bytes,
|
||||
content_type: Option<String>,
|
||||
@@ -248,11 +413,13 @@ fn check_env_vars() -> bool {
|
||||
|
||||
failed |= check_var::<String>("BASE_URL");
|
||||
|
||||
failed |= check_var::<String>("S3_ACCESS_TOKEN");
|
||||
failed |= check_var::<String>("S3_SECRET");
|
||||
failed |= check_var::<String>("S3_URL");
|
||||
failed |= check_var::<String>("S3_REGION");
|
||||
failed |= check_var::<String>("S3_BUCKET_NAME");
|
||||
if dotenvy::var("LOCAL_OUTPUT_DIR").is_err() {
|
||||
failed |= check_var::<String>("S3_ACCESS_TOKEN");
|
||||
failed |= check_var::<String>("S3_SECRET");
|
||||
failed |= check_var::<String>("S3_URL");
|
||||
failed |= check_var::<String>("S3_REGION");
|
||||
failed |= check_var::<String>("S3_BUCKET_NAME");
|
||||
}
|
||||
|
||||
if dotenvy::var("CLOUDFLARE_INTEGRATION")
|
||||
.ok()
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Determines how version info is generated for pairs of game and loader
|
||||
//! versions.
|
||||
//!
|
||||
//! When a user installs a version of the game, they install two things: the
|
||||
//! game (of some specific version), and a loader (of some other specific
|
||||
//! version). Each combination of game and loader version requires a specific
|
||||
//! configuration, like a specific set of libraries that must be downloaded and
|
||||
//! run along with the game. However, some versions of the game or loader may
|
||||
//! change configuration requirements without the other version being affected.
|
||||
//! For example, pre-26.x game versions with Quilt require the Quilt `hashed`
|
||||
//! libraries to also be downloaded. However, 26.x and later don't require the
|
||||
//! `hashed` libraries, and don't even have a download for them. The problem is
|
||||
//! that Quilt loader 0.30.0 can be used for both pre-26.x and 26.x - but our v0
|
||||
//! manifest files can't differentiate the two. The result is that you either
|
||||
//! break compatibility for 0.30.0 game versions pre-26.x, or break 0.30.0
|
||||
//! on 26.x and later.
|
||||
//!
|
||||
//! To fix this, v1 introduces the concept of *version groups*: game versions
|
||||
//! before 26.x are version group v1, and 26.x and later are v2. Then, we
|
||||
//! parameterize our version info on both version group and loader version,
|
||||
//! letting us specify the right configuration based on both game version and
|
||||
//! loader version.
|
||||
//!
|
||||
//! Why not parameterize on game version and loader version directly? Most game
|
||||
//! versions have the same configuration as their surrounding game versions, so
|
||||
//! we'd end up with many duplicate configurations: the number of game versions
|
||||
//! multiplied by the number of loader versions.
|
||||
//!
|
||||
//! This file lets you configure what game versions are grouped together.
|
||||
//!
|
||||
//! Each version group is templated from a specific game version - e.g. game
|
||||
//! version 1.21 is used as the template file for 1.20, 1.19, etc.
|
||||
|
||||
pub const UNIVERSAL_METADATA_GROUP: &str = "universal";
|
||||
pub const QUILT_LEGACY_METADATA_GROUP: &str = "v1";
|
||||
pub const QUILT_MODERN_METADATA_GROUP: &str = "v2";
|
||||
|
||||
pub struct MetadataGroup {
|
||||
pub id: &'static str,
|
||||
/// Minecraft version used to fetch and template this group's loader profiles.
|
||||
pub loader_profile_template_game_version: String,
|
||||
pub game_versions: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn metadata_groups<'a>(
|
||||
mod_loader: &str,
|
||||
game_versions: impl IntoIterator<Item = &'a str>,
|
||||
) -> Vec<MetadataGroup> {
|
||||
// Non-Quilt loaders don't need the concept of version groups, so we just
|
||||
// make one "universal" group, and template it on 1.21.
|
||||
if mod_loader != "quilt" {
|
||||
return vec![MetadataGroup {
|
||||
id: UNIVERSAL_METADATA_GROUP,
|
||||
loader_profile_template_game_version: "1.21".to_string(),
|
||||
game_versions: game_versions
|
||||
.into_iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
}];
|
||||
}
|
||||
|
||||
let game_versions = game_versions.into_iter().collect::<Vec<_>>();
|
||||
let legacy_game_versions = game_versions
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|game_version| {
|
||||
metadata_group_id_for_game_version(mod_loader, game_version)
|
||||
== QUILT_LEGACY_METADATA_GROUP
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let modern_game_versions = game_versions
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|game_version| {
|
||||
metadata_group_id_for_game_version(mod_loader, game_version)
|
||||
== QUILT_MODERN_METADATA_GROUP
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut groups = Vec::new();
|
||||
|
||||
if !legacy_game_versions.is_empty() {
|
||||
groups.push(MetadataGroup {
|
||||
id: QUILT_LEGACY_METADATA_GROUP,
|
||||
loader_profile_template_game_version: legacy_game_versions
|
||||
.iter()
|
||||
.find(|x| **x == "1.21")
|
||||
.copied()
|
||||
.unwrap_or(legacy_game_versions[0])
|
||||
.to_string(),
|
||||
game_versions: legacy_game_versions
|
||||
.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
if !modern_game_versions.is_empty() {
|
||||
groups.push(MetadataGroup {
|
||||
id: QUILT_MODERN_METADATA_GROUP,
|
||||
loader_profile_template_game_version: modern_game_versions[0]
|
||||
.to_string(),
|
||||
game_versions: modern_game_versions
|
||||
.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
pub fn metadata_group_for_game_version<'a>(
|
||||
groups: &'a [MetadataGroup],
|
||||
mod_loader: &str,
|
||||
game_version: &str,
|
||||
) -> Option<&'a MetadataGroup> {
|
||||
let group_id = metadata_group_id_for_game_version(mod_loader, game_version);
|
||||
|
||||
groups.iter().find(|group| group.id == group_id)
|
||||
}
|
||||
|
||||
fn metadata_group_id_for_game_version(
|
||||
mod_loader: &str,
|
||||
game_version: &str,
|
||||
) -> &'static str {
|
||||
if mod_loader == "quilt" && is_modern_quilt_game_version(game_version) {
|
||||
QUILT_MODERN_METADATA_GROUP
|
||||
} else if mod_loader == "quilt" {
|
||||
QUILT_LEGACY_METADATA_GROUP
|
||||
} else {
|
||||
UNIVERSAL_METADATA_GROUP
|
||||
}
|
||||
}
|
||||
|
||||
// Update these Quilt group boundaries if upstream loader profiles gain another
|
||||
// structural incompatibility between Minecraft versions.
|
||||
fn is_modern_quilt_game_version(game_version: &str) -> bool {
|
||||
let major = game_version
|
||||
.split(['.', 'w'])
|
||||
.next()
|
||||
.and_then(|x| x.parse::<usize>().ok());
|
||||
|
||||
major.is_some_and(|x| x >= 26)
|
||||
}
|
||||
@@ -3,7 +3,11 @@ use bytes::Bytes;
|
||||
use s3::creds::Credentials;
|
||||
use s3::{Bucket, Region};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{
|
||||
Arc, LazyLock,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
static BUCKET: LazyLock<Bucket> = LazyLock::new(|| {
|
||||
@@ -55,6 +59,8 @@ pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
static DOWNLOADED_FILE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
#[tracing::instrument(skip(bytes, semaphore))]
|
||||
pub async fn upload_file_to_bucket(
|
||||
path: String,
|
||||
@@ -135,6 +141,74 @@ pub async fn upload_url_to_bucket(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn write_file_to_local_output(
|
||||
path: &str,
|
||||
bytes: Bytes,
|
||||
) -> Result<(), Error> {
|
||||
let output_path = local_output_path(path)?;
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
tokio::fs::write(output_path, bytes).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn write_url_to_local_output_mirrors(
|
||||
output_path: String,
|
||||
mirrors: Vec<String>,
|
||||
sha1: Option<String>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<(), Error> {
|
||||
if mirrors.is_empty() {
|
||||
return Err(ErrorKind::InvalidInput(
|
||||
"No mirrors provided!".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
for (index, mirror) in mirrors.iter().enumerate() {
|
||||
let result = write_url_to_local_output(
|
||||
output_path.clone(),
|
||||
mirror.clone(),
|
||||
sha1.clone(),
|
||||
semaphore,
|
||||
)
|
||||
.await;
|
||||
|
||||
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn write_url_to_local_output(
|
||||
path: String,
|
||||
url: String,
|
||||
sha1: Option<String>,
|
||||
semaphore: &Arc<Semaphore>,
|
||||
) -> Result<(), Error> {
|
||||
let data = download_file(&url, sha1.as_deref(), semaphore).await?;
|
||||
|
||||
write_file_to_local_output(&path, data).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn local_output_path(path: &str) -> Result<PathBuf, Error> {
|
||||
let output_dir = dotenvy::var("LOCAL_OUTPUT_DIR").map_err(|_| {
|
||||
ErrorKind::InvalidInput(
|
||||
"LOCAL_OUTPUT_DIR is required for local output".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(PathBuf::from(output_dir).join(path))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(bytes))]
|
||||
pub async fn sha1_async(bytes: Bytes) -> Result<String, Error> {
|
||||
let hash = tokio::task::spawn_blocking(move || {
|
||||
@@ -182,6 +256,16 @@ pub async fn download_file(
|
||||
}
|
||||
}
|
||||
|
||||
let downloaded = DOWNLOADED_FILE_COUNT
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
+ 1;
|
||||
if downloaded.is_multiple_of(100) {
|
||||
tracing::info!(
|
||||
downloaded_files = downloaded,
|
||||
"Downloaded metadata files"
|
||||
);
|
||||
}
|
||||
|
||||
return Ok(bytes);
|
||||
} else if attempt <= RETRIES {
|
||||
continue;
|
||||
|
||||
@@ -692,6 +692,12 @@ components:
|
||||
type: string
|
||||
description: The ID of the project
|
||||
example: AABBCCDD
|
||||
all_project_types:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: All project types across every version of the project, unlike `project_type` which only reflects a version-specific type
|
||||
example: [mod, plugin, datapack]
|
||||
author:
|
||||
type: string
|
||||
description: The username of the project's author
|
||||
@@ -748,6 +754,7 @@ components:
|
||||
- client_side
|
||||
- server_side
|
||||
- project_id
|
||||
- all_project_types
|
||||
- author
|
||||
- versions
|
||||
- follows
|
||||
@@ -1948,6 +1955,7 @@ paths:
|
||||
|
||||
These are the most commonly used facet types:
|
||||
- `project_type`
|
||||
- `all_project_types` (matches against every project type across all of the project's versions, not just the primary/version-specific type)
|
||||
- `categories` (loaders are lumped in with categories in search)
|
||||
- `versions`
|
||||
- `client_side`
|
||||
|
||||
+1
-1
@@ -566,7 +566,7 @@ export function buildChartDatasets(
|
||||
? isNoDependentAnalyticsBreakdownValue(dependentProjectId)
|
||||
? formatMessage(analyticsMessages.noDependentTooltip)
|
||||
: isUnknownAnalyticsBreakdownValue(dependentProjectId)
|
||||
? formatMessage(analyticsMessages.unknown)
|
||||
? formatMessage(analyticsMessages.unknownDependentTooltip)
|
||||
: formatDependentProjectDatasetTooltip(
|
||||
versionName,
|
||||
dependentProjectName,
|
||||
|
||||
@@ -92,6 +92,11 @@ export const analyticsMessages = defineMessages({
|
||||
id: 'analytics.value.no-dependent-tooltip',
|
||||
defaultMessage: 'Downloaded for reasons other than being a dependency',
|
||||
},
|
||||
unknownDependentTooltip: {
|
||||
id: 'analytics.value.unknown-dependent-tooltip',
|
||||
defaultMessage:
|
||||
"There's no metadata to determine which dependent project this download attributes to.",
|
||||
},
|
||||
other: {
|
||||
id: 'analytics.value.other',
|
||||
defaultMessage: 'Other',
|
||||
|
||||
@@ -515,7 +515,7 @@ function getDependentProjectTooltip(row: AnalyticsTableRow) {
|
||||
return formatMessage(analyticsMessages.noDependentTooltip)
|
||||
}
|
||||
if (isUnknownAnalyticsBreakdownValue(row.breakdownValues.dependent_project_download)) {
|
||||
return formatMessage(analyticsMessages.unknown)
|
||||
return formatMessage(analyticsMessages.unknownDependentTooltip)
|
||||
}
|
||||
|
||||
const dependencyProjectIds = new Set(row.dependentOnProjectIds)
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
<template>
|
||||
<div v-if="downloadRows.length > 0" class="flex flex-col gap-1">
|
||||
<div v-if="showTitle" class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="m-0 flex items-center gap-1.5 text-base font-semibold text-contrast">
|
||||
{{ sectionTitle }}
|
||||
<InfoIcon
|
||||
v-if="duplicateDependencyRowsHidden"
|
||||
v-tooltip="formatMessage(messages.duplicateDependenciesHidden)"
|
||||
aria-hidden="true"
|
||||
class="size-4 text-secondary"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<DownloadDependency
|
||||
v-for="dependency in downloadRows"
|
||||
:key="dependency.key"
|
||||
:dependency="dependency"
|
||||
@download="emit('download')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { FileIcon, InfoIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type CdnDownloadReason,
|
||||
defineMessages,
|
||||
fileTypeMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { type Component, computed, watch } from 'vue'
|
||||
|
||||
import DownloadDependency from './DownloadDependency.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DownloadDependencies',
|
||||
})
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ResolvedContent = Labrinth.Content.v3.ResolvedContent | Labrinth.Content.v3.SkippedContent
|
||||
|
||||
interface DownloadDependencyRow {
|
||||
key: string
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
}
|
||||
|
||||
interface DownloadableDependencyFile {
|
||||
href: string
|
||||
filename: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
dependencies?: DownloadDependencyRow[] | null
|
||||
project?: DownloadModalProject | null
|
||||
selectedVersion?: Labrinth.Versions.v3.Version | null
|
||||
currentGameVersion?: string | null
|
||||
currentPlatform?: string | null
|
||||
downloadReason?: CdnDownloadReason
|
||||
additionalFiles?: Labrinth.Versions.v3.VersionFile[]
|
||||
showTitle?: boolean
|
||||
}>(),
|
||||
{
|
||||
dependencies: null,
|
||||
project: null,
|
||||
selectedVersion: null,
|
||||
currentGameVersion: null,
|
||||
currentPlatform: null,
|
||||
downloadReason: 'standalone',
|
||||
additionalFiles: () => [],
|
||||
showTitle: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
'update:downloadable-files': [files: DownloadableDependencyFile[]]
|
||||
}>()
|
||||
const client = injectModrinthClient()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const shouldResolveDependencies = computed(
|
||||
() => !props.dependencies && !!props.project && !!props.selectedVersion,
|
||||
)
|
||||
|
||||
const dependencyResolutionPreferences = computed<Labrinth.Content.v3.ResolutionPreferences>(() => ({
|
||||
game_versions: props.selectedVersion?.game_versions || [],
|
||||
loaders: props.currentPlatform ? [props.currentPlatform] : props.selectedVersion?.loaders || [],
|
||||
}))
|
||||
|
||||
const { data: dependencyResolution } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'content-resolve',
|
||||
props.project?.id,
|
||||
props.selectedVersion?.id,
|
||||
props.project?.project_type,
|
||||
dependencyResolutionPreferences.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
client.labrinth.content_v3.resolve({
|
||||
project_id: props.project!.id,
|
||||
version_id: props.selectedVersion!.id,
|
||||
content_type: resolveContentType(props.project!.project_type),
|
||||
selected: dependencyResolutionPreferences.value,
|
||||
target: dependencyResolutionPreferences.value,
|
||||
}),
|
||||
enabled: shouldResolveDependencies,
|
||||
})
|
||||
|
||||
const visibleResolvedDependencies = computed<ResolvedContent[]>(() => {
|
||||
return [
|
||||
...(dependencyResolution.value?.dependencies || []),
|
||||
...(dependencyResolution.value?.skipped || []),
|
||||
].filter(shouldShowDependency)
|
||||
})
|
||||
|
||||
const dependencyVersionIds = computed<string[]>(() => {
|
||||
return [
|
||||
...new Set(
|
||||
visibleResolvedDependencies.value
|
||||
.filter((dependency) => !('reason' in dependency))
|
||||
.map((dependency) => dependency.version_id)
|
||||
.filter((versionId): versionId is string => !!versionId),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const { data: dependencyVersions } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'resolved-versions',
|
||||
dependencyVersionIds.value,
|
||||
]),
|
||||
queryFn: () => client.labrinth.versions_v3.getVersions(dependencyVersionIds.value),
|
||||
enabled: computed(() => shouldResolveDependencies.value && dependencyVersionIds.value.length > 0),
|
||||
})
|
||||
|
||||
const dependencyVersionById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Versions.v3.Version>()
|
||||
for (const version of dependencyVersions.value || []) {
|
||||
if (!version) continue
|
||||
map.set(version.id, version)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependencyProjectIds = computed<string[]>(() => {
|
||||
return [
|
||||
...new Set(
|
||||
visibleResolvedDependencies.value
|
||||
.map((dependency) => dependency.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
const { data: dependencyProjects } = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'project-download-modal',
|
||||
'resolved-projects',
|
||||
dependencyProjectIds.value,
|
||||
]),
|
||||
queryFn: () => client.labrinth.projects_v2.getMultiple(dependencyProjectIds.value),
|
||||
enabled: computed(() => shouldResolveDependencies.value && dependencyProjectIds.value.length > 0),
|
||||
})
|
||||
|
||||
const dependencyProjectById = computed(() => {
|
||||
const map = new Map<string, Labrinth.Projects.v2.Project>()
|
||||
for (const project of dependencyProjects.value || []) {
|
||||
map.set(project.id, project)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesByParentVersionId = computed(() => {
|
||||
const map = new Map<string, ResolvedContent[]>()
|
||||
|
||||
for (const dependency of visibleResolvedDependencies.value) {
|
||||
if (!dependency.dependent_on_version_id) continue
|
||||
|
||||
const dependencies = map.get(dependency.dependent_on_version_id) || []
|
||||
dependencies.push(dependency)
|
||||
map.set(dependency.dependent_on_version_id, dependencies)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const dependenciesLoaded = computed(() => {
|
||||
if (!shouldResolveDependencies.value) return false
|
||||
if (!dependencyResolution.value) return false
|
||||
if (
|
||||
dependencyResolution.value.primary.version_id &&
|
||||
dependencyResolution.value.primary.version_id !== props.selectedVersion?.id
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const resolvedDependencyRows = computed<DownloadDependencyRow[]>(() => {
|
||||
if (!dependenciesLoaded.value) return []
|
||||
|
||||
const primaryVersionId =
|
||||
dependencyResolution.value?.primary.version_id || props.selectedVersion?.id
|
||||
if (!primaryVersionId) return []
|
||||
|
||||
const dependencies = dependenciesByParentVersionId.value.get(primaryVersionId) || []
|
||||
|
||||
return dependencies.flatMap((dependency) => {
|
||||
const row = createDependencyRow(dependency)
|
||||
return row ? [row] : []
|
||||
})
|
||||
})
|
||||
|
||||
const dependencyRows = computed<DownloadDependencyRow[]>(
|
||||
() => props.dependencies || resolvedDependencyRows.value,
|
||||
)
|
||||
|
||||
const visibleDependencyRows = computed<DownloadDependencyRow[]>(() =>
|
||||
dedupeDependencyRows(dependencyRows.value),
|
||||
)
|
||||
|
||||
const duplicateDependencyRowsHidden = computed(() =>
|
||||
hasDuplicateDependencyRows(dependencyRows.value),
|
||||
)
|
||||
|
||||
const additionalFileRows = computed<DownloadDependencyRow[]>(() =>
|
||||
props.additionalFiles.map((file) => ({
|
||||
key: `additional-file-${additionalFileKey(file)}`,
|
||||
name: file.filename,
|
||||
fallbackIcon: FileIcon,
|
||||
downloadHref: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
fileSize: file.size,
|
||||
typeLabel: fileTypeLabel(file.file_type),
|
||||
unavailableTooltip: formatMessage(messages.unavailableFile),
|
||||
dependencies: [],
|
||||
})),
|
||||
)
|
||||
|
||||
const downloadRows = computed<DownloadDependencyRow[]>(() => [
|
||||
...visibleDependencyRows.value,
|
||||
...additionalFileRows.value,
|
||||
])
|
||||
|
||||
const sectionTitle = computed(() =>
|
||||
formatMessage(
|
||||
visibleDependencyRows.value.length > 0
|
||||
? messages.dependenciesTitle
|
||||
: messages.additionalFilesTitle,
|
||||
),
|
||||
)
|
||||
|
||||
const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
|
||||
collectDownloadableDependencyFiles(visibleDependencyRows.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
downloadableDependencyFiles,
|
||||
(files) => {
|
||||
emit('update:downloadable-files', files)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
|
||||
return version?.files?.find((file) => file.primary) || version?.files?.[0]
|
||||
}
|
||||
|
||||
function shouldShowDependency(dependency: ResolvedContent) {
|
||||
return !(
|
||||
'reason' in dependency && ['duplicate_project', 'quilt_fabric_api'].includes(dependency.reason)
|
||||
)
|
||||
}
|
||||
|
||||
function createDependencyRow(dependency: ResolvedContent): DownloadDependencyRow | null {
|
||||
const versionId = dependency.version_id ?? undefined
|
||||
const version = versionId ? dependencyVersionById.value.get(versionId) : undefined
|
||||
const project = dependencyProjectById.value.get(dependency.project_id)
|
||||
if (!project) return null
|
||||
|
||||
const primaryFile = primaryFileForVersion(version)
|
||||
const unavailableTooltip =
|
||||
'reason' in dependency && dependency.reason
|
||||
? skippedReasonLabel(dependency.reason)
|
||||
: formatMessage(messages.unavailableDependency)
|
||||
const name = project.title
|
||||
|
||||
return {
|
||||
key: `${dependency.project_id}-${versionId ?? 'unresolved'}-${
|
||||
'reason' in dependency ? dependency.reason : 'resolved'
|
||||
}`,
|
||||
name,
|
||||
icon: project.icon_url ?? undefined,
|
||||
projectHref: `/${project.project_type}/${project.slug || project.id}`,
|
||||
downloadHref:
|
||||
'reason' in dependency || !primaryFile ? undefined : getDownloadUrl(primaryFile.url),
|
||||
filename: primaryFile?.filename,
|
||||
fileSize: primaryFile?.size,
|
||||
typeLabel: 'Required',
|
||||
unavailableTooltip,
|
||||
dependencies: (versionId && dependenciesByParentVersionId.value.get(versionId)
|
||||
? dependenciesByParentVersionId.value.get(versionId)!
|
||||
: []
|
||||
).flatMap((subDependency) => {
|
||||
const row = createDependencyRow(subDependency)
|
||||
return row ? [row] : []
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function skippedReasonLabel(reason: Labrinth.Content.v3.SkippedContent['reason']) {
|
||||
return (
|
||||
{
|
||||
already_installed: formatMessage(messages.alreadyInstalledDependency),
|
||||
duplicate_project: formatMessage(messages.duplicateDependency),
|
||||
conflicting_dependency: formatMessage(messages.conflictingDependency),
|
||||
no_compatible_version: formatMessage(messages.noCompatibleDependency),
|
||||
missing_version: formatMessage(messages.missingDependencyVersion),
|
||||
quilt_fabric_api: formatMessage(messages.quiltFabricApiDependency),
|
||||
}[reason] || formatMessage(messages.unavailableDependency)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveContentType(projectType: DisplayProjectType): Labrinth.Content.v3.ContentType {
|
||||
return ['mod', 'plugin', 'datapack', 'resourcepack', 'shader', 'modpack'].includes(projectType)
|
||||
? (projectType as Labrinth.Content.v3.ContentType)
|
||||
: 'mod'
|
||||
}
|
||||
|
||||
function getDownloadUrl(url: string) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: props.currentGameVersion ?? undefined,
|
||||
loader: props.currentPlatform ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function fileTypeLabel(type?: Labrinth.Versions.v3.FileType | null) {
|
||||
return formatMessage(fileTypeMessages[type ?? 'unknown'] ?? fileTypeMessages.unknown)
|
||||
}
|
||||
|
||||
function additionalFileKey(file: Labrinth.Versions.v3.VersionFile) {
|
||||
return file.hashes?.sha1 ?? file.filename
|
||||
}
|
||||
|
||||
function dedupeDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): DownloadDependencyRow[] {
|
||||
return rows.flatMap((row) => {
|
||||
const identity = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(identity)) return []
|
||||
|
||||
seenDependencies.add(identity)
|
||||
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
dependencies: dedupeDependencyRows(row.dependencies, seenDependencies),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function dependencyRowIdentity(row: DownloadDependencyRow) {
|
||||
return row.projectHref ?? row.downloadHref ?? row.key
|
||||
}
|
||||
|
||||
function hasDuplicateDependencyRows(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenDependencies = new Set<string>(),
|
||||
): boolean {
|
||||
for (const row of rows) {
|
||||
const rowId = dependencyRowIdentity(row)
|
||||
if (seenDependencies.has(rowId)) return true
|
||||
seenDependencies.add(rowId)
|
||||
if (hasDuplicateDependencyRows(row.dependencies, seenDependencies)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function collectDownloadableDependencyFiles(
|
||||
rows: DownloadDependencyRow[],
|
||||
seenHrefs = new Set<string>(),
|
||||
): DownloadableDependencyFile[] {
|
||||
const files: DownloadableDependencyFile[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.downloadHref && !seenHrefs.has(row.downloadHref)) {
|
||||
seenHrefs.add(row.downloadHref)
|
||||
files.push({
|
||||
href: row.downloadHref,
|
||||
filename: row.filename || filenameFromUrl(row.downloadHref),
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
|
||||
files.push(...collectDownloadableDependencyFiles(row.dependencies, seenHrefs))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string) {
|
||||
try {
|
||||
const filename = new URL(url).pathname.split('/').pop()
|
||||
return filename ? decodeURIComponent(filename) : 'dependency.jar'
|
||||
} catch {
|
||||
return 'dependency.jar'
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
dependenciesTitle: {
|
||||
id: 'project.download.dependencies-title',
|
||||
defaultMessage: 'Dependencies',
|
||||
},
|
||||
duplicateDependenciesHidden: {
|
||||
id: 'project.download.duplicate-dependencies-hidden',
|
||||
defaultMessage: 'Duplicate dependencies are hidden',
|
||||
},
|
||||
additionalFilesTitle: {
|
||||
id: 'project.download.additional-files-title',
|
||||
defaultMessage: 'Additional files',
|
||||
},
|
||||
alreadyInstalledDependency: {
|
||||
id: 'project.download.dependency-already-installed',
|
||||
defaultMessage: 'This dependency is already installed',
|
||||
},
|
||||
conflictingDependency: {
|
||||
id: 'project.download.dependency-conflicting',
|
||||
defaultMessage: 'This dependency conflicts with another dependency',
|
||||
},
|
||||
duplicateDependency: {
|
||||
id: 'project.download.dependency-duplicate',
|
||||
defaultMessage: 'This dependency is already included',
|
||||
},
|
||||
missingDependencyVersion: {
|
||||
id: 'project.download.dependency-missing-version',
|
||||
defaultMessage: 'This dependency version is unavailable',
|
||||
},
|
||||
noCompatibleDependency: {
|
||||
id: 'project.download.dependency-no-compatible-version',
|
||||
defaultMessage: 'No compatible version is available for this dependency',
|
||||
},
|
||||
quiltFabricApiDependency: {
|
||||
id: 'project.download.dependency-quilt-fabric-api',
|
||||
defaultMessage: 'Fabric API is skipped for Quilt',
|
||||
},
|
||||
unavailableDependency: {
|
||||
id: 'project.download.dependency-unavailable',
|
||||
defaultMessage: 'This dependency cannot be downloaded',
|
||||
},
|
||||
unavailableFile: {
|
||||
id: 'project.download.file-unavailable',
|
||||
defaultMessage: 'This file cannot be downloaded',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div
|
||||
class="grid min-h-10 grid-cols-[minmax(0,1fr)_min-content] items-center gap-3 rounded-xl bg-button-bg py-0 pl-3.5 pr-2 text-primary"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
v-if="dependency.icon"
|
||||
:src="dependency.icon"
|
||||
:alt="dependency.name"
|
||||
size="24px"
|
||||
class="!rounded-lg !shadow-none"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-4 flex-shrink-0 items-center justify-center rounded-lg border border-solid border-surface-5 text-secondary"
|
||||
>
|
||||
<component
|
||||
:is="dependency.fallbackIcon ?? PackageIcon"
|
||||
aria-hidden="true"
|
||||
class="size-5"
|
||||
/>
|
||||
</span>
|
||||
<a
|
||||
v-if="dependency.projectHref"
|
||||
ref="dependencyNameRef"
|
||||
v-tooltip="truncatedTooltip(dependencyNameRef, dependency.name)"
|
||||
:href="dependency.projectHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="min-w-0 truncate text-base font-semibold text-contrast no-underline hover:underline"
|
||||
>
|
||||
{{ dependency.name }}
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
ref="dependencyNameRef"
|
||||
v-tooltip="truncatedTooltip(dependencyNameRef, dependency.name)"
|
||||
class="min-w-0 truncate text-base font-semibold text-contrast"
|
||||
>
|
||||
{{ dependency.name }}
|
||||
</span>
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5 !px-3 !py-1 text-base">
|
||||
{{ dependency.typeLabel }}
|
||||
</TagItem>
|
||||
</span>
|
||||
<ButtonStyled v-if="dependency.downloadHref" circular type="transparent">
|
||||
<a
|
||||
v-tooltip="downloadTooltip"
|
||||
:href="dependency.downloadHref"
|
||||
:download="dependency.filename"
|
||||
:aria-label="downloadTooltip"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" class="size-6 text-secondary" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else circular type="transparent">
|
||||
<button
|
||||
v-tooltip="dependency.unavailableTooltip"
|
||||
disabled
|
||||
:aria-label="dependency.unavailableTooltip"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" class="size-6 text-secondary" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
v-for="childDependency in dependency.dependencies"
|
||||
:key="childDependency.key"
|
||||
class="group/dependency relative pl-10"
|
||||
>
|
||||
<DownloadDependency :dependency="childDependency" class="z-1" @download="emit('download')" />
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute -top-2 left-6 z-0 h-[calc(100%+1rem)] w-0.5 bg-surface-5 group-first/dependency:-top-2 group-first/dependency:h-20 group-last/dependency:h-7"
|
||||
/>
|
||||
<div aria-hidden="true" class="absolute left-6 top-5 z-0 h-0.5 w-4 bg-surface-5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, PackageIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
TagItem,
|
||||
truncatedTooltip,
|
||||
useFormatBytes,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { type Component, computed, ref } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DownloadDependency',
|
||||
})
|
||||
|
||||
interface DownloadDependencyRow {
|
||||
key: string
|
||||
name: string
|
||||
icon?: string
|
||||
fallbackIcon?: Component
|
||||
projectHref?: string
|
||||
downloadHref?: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
typeLabel: string
|
||||
unavailableTooltip: string
|
||||
dependencies: DownloadDependencyRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
dependency: DownloadDependencyRow
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const dependencyNameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const downloadTooltip = computed(() => {
|
||||
const filename = props.dependency.filename || props.dependency.name
|
||||
|
||||
if (typeof props.dependency.fileSize === 'number') {
|
||||
return formatMessage(messages.downloadFileWithSize, {
|
||||
filename,
|
||||
size: formatBytes(props.dependency.fileSize, 1),
|
||||
})
|
||||
}
|
||||
|
||||
return formatMessage(messages.downloadFile, { filename })
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
downloadFile: {
|
||||
id: 'project.download.dependency-download-file',
|
||||
defaultMessage: 'Download {filename}',
|
||||
},
|
||||
downloadFileWithSize: {
|
||||
id: 'project.download.dependency-download-file-with-size',
|
||||
defaultMessage: 'Download {filename} ({size})',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,766 @@
|
||||
<template>
|
||||
<div class="flex w-full gap-2 max-sm:flex-wrap">
|
||||
<Combobox
|
||||
:model-value="currentGameVersion || undefined"
|
||||
class="w-full"
|
||||
:options="gameVersionOptions"
|
||||
:placeholder="formatMessage(messages.selectGameVersion)"
|
||||
:searchable="project.game_versions.length > 4"
|
||||
search-autocomplete="off"
|
||||
:search-placeholder="formatMessage(messages.searchGameVersions)"
|
||||
:no-options-message="formatMessage(messages.noGameVersionsFound)"
|
||||
trigger-class="!rounded-xl !bg-button-bg !px-3 !py-2"
|
||||
dropdown-class="!rounded-xl"
|
||||
select-search-text-on-focus
|
||||
@update:model-value="selectGameVersion"
|
||||
@search-input="versionFilter = $event"
|
||||
@close="versionFilter = ''"
|
||||
>
|
||||
<template #option="{ item, isSelected }">
|
||||
<div
|
||||
v-tooltip="gameVersionOptionTooltip(item.value)"
|
||||
class="flex w-full items-center justify-between gap-2 px-4 py-2"
|
||||
:class="{
|
||||
'text-brand-red opacity-40': isGameVersionUnavailable(item.value),
|
||||
'text-green': isSelected,
|
||||
'!opacity-100': isSelected,
|
||||
'text-primary': !isGameVersionUnavailable(item.value) && !isSelected,
|
||||
}"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold leading-tight">{{ item.label }}</span>
|
||||
<TriangleAlertIcon
|
||||
v-if="isSelected && isGameVersionUnavailable(item.value)"
|
||||
aria-hidden="true"
|
||||
class="size-5 shrink-0 text-orange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #dropdown-footer>
|
||||
<div
|
||||
v-if="showVersionsCheckbox"
|
||||
class="border-0 border-t border-solid border-surface-5 p-3"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="showAllVersionsModel"
|
||||
:label="formatMessage(messages.showAllVersions)"
|
||||
:disabled="!!versionFilter"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
<Combobox
|
||||
v-if="project.project_type !== 'resourcepack'"
|
||||
:model-value="currentPlatform || undefined"
|
||||
class="w-full"
|
||||
:options="platformOptions"
|
||||
:placeholder="formatMessage(messages.selectPlatform)"
|
||||
trigger-class="!rounded-xl !bg-button-bg !px-3 !py-2"
|
||||
dropdown-class="!rounded-xl"
|
||||
@update:model-value="selectPlatform"
|
||||
>
|
||||
<template #option="{ item, isSelected }">
|
||||
<div
|
||||
v-tooltip="platformOptionTooltip(item.value, item.label)"
|
||||
class="flex w-full items-center justify-between gap-2 px-4 py-2"
|
||||
:class="{
|
||||
'text-brand-red opacity-40': isPlatformUnavailable(item.value),
|
||||
'text-green': isSelected,
|
||||
'!opacity-100': isSelected,
|
||||
'text-primary': !isPlatformUnavailable(item.value) && !isSelected,
|
||||
}"
|
||||
>
|
||||
<span class="min-w-0 truncate font-semibold leading-tight">{{ item.label }}</span>
|
||||
<TriangleAlertIcon
|
||||
v-if="isSelected && isPlatformUnavailable(item.value)"
|
||||
aria-hidden="true"
|
||||
class="size-5 shrink-0 text-orange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedVersion" class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="relative top-0.5 m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.compatibleVersionTitle) }}
|
||||
</h3>
|
||||
<ButtonStyled v-if="downloadAllFiles.length > 1" type="transparent">
|
||||
<button :disabled="downloadingSelectedVersion" @click="downloadSelectedVersionFiles">
|
||||
<SpinnerIcon v-if="downloadingSelectedVersion" aria-hidden="true" class="animate-spin" />
|
||||
<DownloadIcon v-else aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(
|
||||
downloadingSelectedVersion
|
||||
? messages.downloadingSelectedVersion
|
||||
: messages.downloadAllSelectedVersion,
|
||||
{
|
||||
current: selectedVersionDownloadProgress.current,
|
||||
total: selectedVersionDownloadProgress.total,
|
||||
},
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_min-content] items-center gap-3 rounded-2xl bg-surface-2 px-3 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<nuxt-link
|
||||
v-tooltip="truncatedTooltip(versionNumberRef, selectedVersion.version_number)"
|
||||
:to="`/${project.project_type}/${project.slug || project.id}/version/${selectedVersion.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block min-w-0 text-contrast no-underline hover:underline"
|
||||
>
|
||||
<span ref="versionNumberRef" class="block truncate font-semibold">
|
||||
{{ selectedVersion.version_number }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
<VersionChannelTag
|
||||
:channel="selectedVersion.version_type"
|
||||
class="relative -top-px !py-0.5"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
ref="versionNameRef"
|
||||
v-tooltip="truncatedTooltip(versionNameRef, selectedVersion.name)"
|
||||
class="m-0 w-fit max-w-full truncate text-sm text-secondary"
|
||||
>
|
||||
{{ selectedVersion.name }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled v-if="selectedPrimaryFile" color="brand" circular>
|
||||
<a
|
||||
v-tooltip="'Download'"
|
||||
:href="selectedPrimaryFileDownloadUrl"
|
||||
:download="selectedPrimaryFile.filename"
|
||||
:aria-label="
|
||||
formatMessage(messages.downloadVersion, {
|
||||
version: selectedVersion.version_number,
|
||||
})
|
||||
"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="currentPlatform && currentGameVersion && versions.length > 0">
|
||||
{{
|
||||
formatMessage(messages.noVersionsAvailable, {
|
||||
gameVersion: currentGameVersion,
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, SpinnerIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
type CdnDownloadReason,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
defineMessages,
|
||||
getTagMessage,
|
||||
injectNotificationManager,
|
||||
truncatedTooltip,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import VersionChannelTag from '@modrinth/ui/src/components/version/VersionChannelTag.vue'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import JSZip from 'jszip'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'DownloadProject',
|
||||
})
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ProjectDownloadSelection = {
|
||||
currentGameVersion: string | null
|
||||
currentPlatform: string | null
|
||||
selectedVersion: Labrinth.Versions.v3.Version | null
|
||||
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
|
||||
}
|
||||
|
||||
type DownloadableFile = {
|
||||
href: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: DownloadModalProject
|
||||
versions?: Labrinth.Versions.v3.Version[]
|
||||
dependencyDownloadFiles?: DownloadableFile[]
|
||||
downloadReason?: CdnDownloadReason
|
||||
initialGameVersion?: string | null
|
||||
initialPlatform?: string | null
|
||||
incompatibleGameVersions?: string[]
|
||||
incompatibleLoaders?: string[]
|
||||
resetKey?: number
|
||||
}>(),
|
||||
{
|
||||
versions: () => [],
|
||||
dependencyDownloadFiles: () => [],
|
||||
downloadReason: 'standalone',
|
||||
initialGameVersion: null,
|
||||
initialPlatform: null,
|
||||
incompatibleGameVersions: () => [],
|
||||
incompatibleLoaders: () => [],
|
||||
resetKey: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
selectGameVersion: [gameVersion: string]
|
||||
selectPlatform: [platform: string]
|
||||
'update:selection': [selection: ProjectDownloadSelection]
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const debug = useDebugLogger('DownloadProject')
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const userSelectedGameVersion = ref<string | null>(props.initialGameVersion)
|
||||
const userSelectedPlatform = ref<string | null>(props.initialPlatform)
|
||||
const showAllVersions = ref(defaultShowAllVersions())
|
||||
const versionFilter = ref('')
|
||||
const versionNumberRef = ref<HTMLElement | null>(null)
|
||||
const versionNameRef = ref<HTMLElement | null>(null)
|
||||
const downloadingSelectedVersion = ref(false)
|
||||
const selectedVersionDownloadProgress = ref({
|
||||
current: 0,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const incompatibleGameVersionsSet = computed(() => new Set(props.incompatibleGameVersions))
|
||||
const incompatibleLoadersSet = computed(() => new Set(props.incompatibleLoaders))
|
||||
|
||||
const showAllVersionsModel = computed({
|
||||
get() {
|
||||
return showAllVersions.value
|
||||
},
|
||||
set(value) {
|
||||
showAllVersions.value = value
|
||||
},
|
||||
})
|
||||
|
||||
const selectedPlatform = computed<string | null>(() => {
|
||||
if (userSelectedPlatform.value) return userSelectedPlatform.value
|
||||
return props.project.loaders.length === 1 ? props.project.loaders[0] : null
|
||||
})
|
||||
|
||||
const selectedGameVersion = computed<string | null>(() => {
|
||||
if (userSelectedGameVersion.value) return userSelectedGameVersion.value
|
||||
return props.project.game_versions.length === 1 ? props.project.game_versions[0] : null
|
||||
})
|
||||
|
||||
const compatiblePlatforms = computed<string[]>(() => {
|
||||
return props.project.loaders.filter(
|
||||
(platform) =>
|
||||
props.versions.some(
|
||||
(version) =>
|
||||
version.loaders.includes(platform) &&
|
||||
(!selectedGameVersion.value || version.game_versions.includes(selectedGameVersion.value)),
|
||||
) && !incompatibleLoadersSet.value.has(platform),
|
||||
)
|
||||
})
|
||||
|
||||
const currentPlatform = computed<string | null>(() => {
|
||||
if (selectedPlatform.value) return selectedPlatform.value
|
||||
return compatiblePlatforms.value.length === 1 ? compatiblePlatforms.value[0] : null
|
||||
})
|
||||
|
||||
const possibleGameVersions = computed<string[]>(() => {
|
||||
return props.versions
|
||||
.filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value))
|
||||
.flatMap((x) => x.game_versions)
|
||||
})
|
||||
|
||||
const compatibleGameVersions = computed<string[]>(() => {
|
||||
return props.project.game_versions.filter(
|
||||
(gameVersion) =>
|
||||
possibleGameVersions.value.includes(gameVersion) &&
|
||||
!incompatibleGameVersionsSet.value.has(gameVersion),
|
||||
)
|
||||
})
|
||||
|
||||
const currentGameVersion = computed<string | null>(() => {
|
||||
if (selectedGameVersion.value) return selectedGameVersion.value
|
||||
return compatibleGameVersions.value.length === 1 ? compatibleGameVersions.value[0] : null
|
||||
})
|
||||
|
||||
const possiblePlatforms = computed<string[]>(() => {
|
||||
return props.versions
|
||||
.filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value))
|
||||
.flatMap((x) => x.loaders)
|
||||
})
|
||||
|
||||
const currentPlatformText = computed(() => {
|
||||
if (!currentPlatform.value) return ''
|
||||
return loaderLabel(currentPlatform.value)
|
||||
})
|
||||
|
||||
const releaseVersions = computed<Set<string>>(() => {
|
||||
const set = new Set<string>()
|
||||
for (const gameVersion of tags.value.gameVersions || []) {
|
||||
if (gameVersion?.version && gameVersion.version_type === 'release') {
|
||||
set.add(gameVersion.version)
|
||||
}
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
const nonReleaseVersions = computed<Set<string>>(() => {
|
||||
const set = new Set<string>()
|
||||
for (const gameVersion of tags.value.gameVersions || []) {
|
||||
if (gameVersion?.version && gameVersion.version_type !== 'release') {
|
||||
set.add(gameVersion.version)
|
||||
}
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
const showVersionsCheckbox = computed(() => {
|
||||
let hasRelease = false
|
||||
let hasNonRelease = false
|
||||
|
||||
for (const version of props.project.game_versions) {
|
||||
if (isReleaseGameVersion(version)) {
|
||||
hasRelease = true
|
||||
} else {
|
||||
hasNonRelease = true
|
||||
}
|
||||
|
||||
if (hasRelease && hasNonRelease) return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const filteredGameVersions = computed(() => {
|
||||
return props.project.game_versions
|
||||
.filter(
|
||||
(x) =>
|
||||
(versionFilter.value && x.includes(versionFilter.value)) ||
|
||||
(!versionFilter.value && (showAllVersions.value || isReleaseGameVersion(x))),
|
||||
)
|
||||
.slice()
|
||||
.reverse()
|
||||
})
|
||||
|
||||
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
return filteredGameVersions.value.map((gameVersion) => ({
|
||||
value: gameVersion,
|
||||
label: gameVersion,
|
||||
class: '!px-0 !py-1',
|
||||
}))
|
||||
})
|
||||
|
||||
const platformOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
return props.project.loaders
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((platform) => ({
|
||||
value: platform,
|
||||
label: loaderLabel(platform),
|
||||
class: '!px-0 !py-1',
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredVersions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
const gameVersion = currentGameVersion.value
|
||||
if (!gameVersion) return []
|
||||
|
||||
const platform = currentPlatform.value
|
||||
const result = props.versions.filter((x) => {
|
||||
const matchesPlatform =
|
||||
props.project.project_type === 'resourcepack' || (!!platform && x.loaders.includes(platform))
|
||||
|
||||
return x.game_versions.includes(gameVersion) && matchesPlatform
|
||||
})
|
||||
debug('filteredVersions', {
|
||||
total: props.versions.length,
|
||||
filtered: result.length,
|
||||
currentGameVersion: currentGameVersion.value,
|
||||
currentPlatform: currentPlatform.value,
|
||||
sampleLoaders: props.versions.slice(0, 3).map((v) => v.loaders),
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const filteredRelease = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||
return filteredVersions.value.find((x) => x.version_type === 'release')
|
||||
})
|
||||
|
||||
const filteredBeta = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'beta' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))),
|
||||
)
|
||||
})
|
||||
|
||||
const filteredAlpha = computed<Labrinth.Versions.v3.Version | undefined>(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'alpha' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) &&
|
||||
(!filteredBeta.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))),
|
||||
)
|
||||
})
|
||||
|
||||
const selectedVersion = computed<Labrinth.Versions.v3.Version | null>(() => {
|
||||
return filteredRelease.value || filteredBeta.value || filteredAlpha.value || null
|
||||
})
|
||||
|
||||
const selectedPrimaryFile = computed<Labrinth.Versions.v3.VersionFile | null>(() => {
|
||||
return (
|
||||
selectedVersion.value?.files?.find((file) => file.primary) ||
|
||||
selectedVersion.value?.files?.[0] ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const selectedPrimaryFileDownloadUrl = computed(() => {
|
||||
if (!selectedPrimaryFile.value) return '#'
|
||||
return getDownloadUrl(selectedPrimaryFile.value.url)
|
||||
})
|
||||
|
||||
const selectedVersionDownloadFiles = computed(() => {
|
||||
if (!selectedVersion.value) return []
|
||||
|
||||
return selectedVersion.value.files.map((file) => ({
|
||||
href: getDownloadUrl(file.url),
|
||||
filename: file.filename,
|
||||
}))
|
||||
})
|
||||
|
||||
const downloadAllFiles = computed(() => {
|
||||
const files: DownloadableFile[] = []
|
||||
const hrefs = new Set<string>()
|
||||
|
||||
for (const file of [...selectedVersionDownloadFiles.value, ...props.dependencyDownloadFiles]) {
|
||||
if (hrefs.has(file.href)) continue
|
||||
hrefs.add(file.href)
|
||||
files.push(file)
|
||||
}
|
||||
|
||||
return files
|
||||
})
|
||||
|
||||
const selectedVersionZipFilename = computed(() => {
|
||||
if (!selectedVersion.value) return `${sanitizeFilename(props.project.title)}.zip`
|
||||
|
||||
return `${sanitizeFilename(props.project.title)} ${sanitizeFilename(
|
||||
selectedVersion.value.version_number,
|
||||
)}.zip`
|
||||
})
|
||||
|
||||
watch(
|
||||
[currentGameVersion, currentPlatform, selectedVersion, selectedPrimaryFile],
|
||||
() => {
|
||||
emit('update:selection', {
|
||||
currentGameVersion: currentGameVersion.value,
|
||||
currentPlatform: currentPlatform.value,
|
||||
selectedVersion: selectedVersion.value,
|
||||
selectedPrimaryFile: selectedPrimaryFile.value,
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.resetKey,
|
||||
() => {
|
||||
userSelectedGameVersion.value = props.initialGameVersion
|
||||
userSelectedPlatform.value = props.initialPlatform
|
||||
showAllVersions.value = defaultShowAllVersions()
|
||||
versionFilter.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
function selectGameVersion(gameVersion?: string) {
|
||||
if (!gameVersion) return
|
||||
userSelectedGameVersion.value = gameVersion
|
||||
emit('selectGameVersion', gameVersion)
|
||||
selectOnlyCompatiblePlatform()
|
||||
}
|
||||
|
||||
function selectPlatform(platform?: string) {
|
||||
if (!platform) return
|
||||
userSelectedPlatform.value = platform
|
||||
emit('selectPlatform', platform)
|
||||
}
|
||||
|
||||
function getDownloadUrl(url: string) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: props.downloadReason,
|
||||
gameVersion: currentGameVersion.value ?? undefined,
|
||||
loader: currentPlatform.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function downloadSelectedVersionFiles() {
|
||||
if (downloadingSelectedVersion.value || downloadAllFiles.value.length <= 1) return
|
||||
|
||||
downloadingSelectedVersion.value = true
|
||||
const files = [...downloadAllFiles.value]
|
||||
selectedVersionDownloadProgress.value = {
|
||||
current: 0,
|
||||
total: files.length,
|
||||
}
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
const usedFilenames = new Set<string>()
|
||||
|
||||
for (const [index, file] of files.entries()) {
|
||||
selectedVersionDownloadProgress.value = {
|
||||
current: index + 1,
|
||||
total: files.length,
|
||||
}
|
||||
const response = await fetch(file.href)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${file.filename}`)
|
||||
}
|
||||
|
||||
zip.file(uniqueFilename(file.filename, usedFilenames), await response.blob())
|
||||
}
|
||||
|
||||
downloadBlob(
|
||||
await zip.generateAsync({
|
||||
type: 'blob',
|
||||
mimeType: 'application/zip',
|
||||
}),
|
||||
selectedVersionZipFilename.value,
|
||||
)
|
||||
emit('download')
|
||||
} catch (error) {
|
||||
console.error('Failed to download selected version files:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.downloadSelectedVersionFailedTitle),
|
||||
text: formatMessage(messages.downloadSelectedVersionFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
downloadingSelectedVersion.value = false
|
||||
selectedVersionDownloadProgress.value = {
|
||||
current: 0,
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
function sanitizeFilename(value: string) {
|
||||
const sanitized = value
|
||||
.replace(/[<>:"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
return sanitized || 'download'
|
||||
}
|
||||
|
||||
function uniqueFilename(filename: string, usedFilenames: Set<string>) {
|
||||
const sanitizedFilename = sanitizeFilename(filename)
|
||||
|
||||
if (!usedFilenames.has(sanitizedFilename)) {
|
||||
usedFilenames.add(sanitizedFilename)
|
||||
return sanitizedFilename
|
||||
}
|
||||
|
||||
const extensionIndex = sanitizedFilename.lastIndexOf('.')
|
||||
const basename =
|
||||
extensionIndex > 0 ? sanitizedFilename.slice(0, extensionIndex) : sanitizedFilename
|
||||
const extension = extensionIndex > 0 ? sanitizedFilename.slice(extensionIndex) : ''
|
||||
let index = 2
|
||||
let candidate = `${basename} (${index})${extension}`
|
||||
|
||||
while (usedFilenames.has(candidate)) {
|
||||
index += 1
|
||||
candidate = `${basename} (${index})${extension}`
|
||||
}
|
||||
|
||||
usedFilenames.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
|
||||
function loaderLabel(loader: string) {
|
||||
return formatMessage(getTagMessage(loader, 'loader') ?? messages.unknownLoader)
|
||||
}
|
||||
|
||||
function isReleaseGameVersion(version: string) {
|
||||
if (releaseVersions.value.has(version)) return true
|
||||
if (nonReleaseVersions.value.has(version)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function defaultShowAllVersions() {
|
||||
return (
|
||||
props.project.game_versions.length > 0 &&
|
||||
props.project.game_versions.every((projectVersion) => {
|
||||
const gameVersion = tags.value.gameVersions?.find((x) => x.version === projectVersion)
|
||||
return !!gameVersion?.version_type && gameVersion.version_type !== 'release'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function isGameVersionUnavailable(gameVersion: string) {
|
||||
return (
|
||||
incompatibleGameVersionsSet.value.has(gameVersion) ||
|
||||
!possibleGameVersions.value.includes(gameVersion)
|
||||
)
|
||||
}
|
||||
|
||||
function isPlatformUnavailable(platform: string) {
|
||||
return incompatibleLoadersSet.value.has(platform) || !possiblePlatforms.value.includes(platform)
|
||||
}
|
||||
|
||||
function selectOnlyCompatiblePlatform() {
|
||||
const compatiblePlatforms = props.project.loaders.filter(
|
||||
(platform) =>
|
||||
possiblePlatforms.value.includes(platform) && !incompatibleLoadersSet.value.has(platform),
|
||||
)
|
||||
|
||||
if (compatiblePlatforms.length !== 1) return
|
||||
|
||||
userSelectedPlatform.value = compatiblePlatforms[0]
|
||||
emit('selectPlatform', compatiblePlatforms[0])
|
||||
}
|
||||
|
||||
function gameVersionOptionTooltip(gameVersion: string) {
|
||||
if (incompatibleGameVersionsSet.value.has(gameVersion)) {
|
||||
return formatMessage(messages.baseGameVersionIncompatibleTooltip)
|
||||
}
|
||||
|
||||
if (!possibleGameVersions.value.includes(gameVersion)) {
|
||||
return formatMessage(messages.gameVersionUnsupportedTooltip, {
|
||||
title: props.project.title,
|
||||
gameVersion,
|
||||
platform: currentPlatformText.value,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function platformOptionTooltip(platform: string, platformLabel: string) {
|
||||
if (incompatibleLoadersSet.value.has(platform)) {
|
||||
return formatMessage(messages.baseLoaderIncompatibleTooltip)
|
||||
}
|
||||
|
||||
if (!possiblePlatforms.value.includes(platform)) {
|
||||
return formatMessage(messages.platformUnsupportedTooltip, {
|
||||
title: props.project.title,
|
||||
platform: platformLabel,
|
||||
gameVersion: currentGameVersion.value,
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
baseGameVersionIncompatibleTooltip: {
|
||||
id: 'project.download.base-game-version-incompatible-tooltip',
|
||||
defaultMessage: 'This game version is incompatible with the base project.',
|
||||
},
|
||||
baseLoaderIncompatibleTooltip: {
|
||||
id: 'project.download.base-loader-incompatible-tooltip',
|
||||
defaultMessage: 'This loader is incompatible with the base project.',
|
||||
},
|
||||
gameVersionUnsupportedTooltip: {
|
||||
id: 'project.download.game-version-unsupported-tooltip',
|
||||
defaultMessage: '{title} does not support {gameVersion} for {platform}',
|
||||
},
|
||||
downloadVersion: {
|
||||
id: 'project.download.download-version',
|
||||
defaultMessage: 'Download {version}',
|
||||
},
|
||||
compatibleVersionTitle: {
|
||||
id: 'project.download.compatible-version-title',
|
||||
defaultMessage: 'Compatible version',
|
||||
},
|
||||
downloadAllSelectedVersion: {
|
||||
id: 'project.download.selected-version-download-all',
|
||||
defaultMessage: 'Download all (.zip)',
|
||||
},
|
||||
downloadingSelectedVersion: {
|
||||
id: 'project.download.selected-version-downloading',
|
||||
defaultMessage: 'Downloading... ({current}/{total})',
|
||||
},
|
||||
downloadSelectedVersionFailedTitle: {
|
||||
id: 'project.download.selected-version-failed-title',
|
||||
defaultMessage: 'Could not download version',
|
||||
},
|
||||
downloadSelectedVersionFailedText: {
|
||||
id: 'project.download.selected-version-failed-text',
|
||||
defaultMessage: 'One or more version files could not be downloaded. Please try again.',
|
||||
},
|
||||
noGameVersionsFound: {
|
||||
id: 'project.download.no-game-versions-found',
|
||||
defaultMessage: 'No game versions found',
|
||||
},
|
||||
noVersionsAvailable: {
|
||||
id: 'project.download.no-versions-available',
|
||||
defaultMessage: 'No versions available for {gameVersion} and {platform}.',
|
||||
},
|
||||
platformUnsupportedTooltip: {
|
||||
id: 'project.download.platform-unsupported-tooltip',
|
||||
defaultMessage: '{title} does not support {platform} for {gameVersion}',
|
||||
},
|
||||
searchGameVersions: {
|
||||
id: 'project.download.search-game-versions',
|
||||
defaultMessage: 'Select game version',
|
||||
},
|
||||
selectGameVersion: {
|
||||
id: 'project.download.select-game-version',
|
||||
defaultMessage: 'Select game version',
|
||||
},
|
||||
selectPlatform: {
|
||||
id: 'project.download.select-platform',
|
||||
defaultMessage: 'Select platform',
|
||||
},
|
||||
showAllVersions: {
|
||||
id: 'project.download.show-all-versions',
|
||||
defaultMessage: 'Show all versions',
|
||||
},
|
||||
unknownLoader: {
|
||||
id: 'project.download.unknown-loader',
|
||||
defaultMessage: 'Unknown loader',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="
|
||||
project.project_type !== 'plugin' ||
|
||||
project.loaders.some((x) => !tags.loaderData.allPluginLoaders.includes(x))
|
||||
"
|
||||
class="modrinth-app-section contents"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<a
|
||||
class="modrinth-app-install-card flex items-center justify-between gap-3 rounded-2xl border border-solid border-brand-highlight bg-surface-1 px-4 py-3 text-primary no-underline transition-[filter] hover:brightness-110"
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="installWithApp"
|
||||
>
|
||||
<span class="flex w-full min-w-0 flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex min-w-0 items-center gap-1.5 font-medium text-contrast">
|
||||
Install with
|
||||
<span class="text-brand">Modrinth App</span>
|
||||
<ModrinthIcon aria-hidden="true" class="size-4 flex-shrink-0 text-brand" />
|
||||
</span>
|
||||
<ExternalIcon
|
||||
aria-hidden="true"
|
||||
class="size-4 flex-shrink-0 text-contrast transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate text-base text-contrast opacity-80">
|
||||
{{ formatMessage(messages.installWithModrinthAppDescription) }}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<Accordion ref="getModrinthAppAccordion">
|
||||
<nuxt-link class="mt-2 flex justify-center text-brand-blue hover:underline" to="/app">
|
||||
{{ formatMessage(messages.dontHaveModrinthApp) }}
|
||||
</nuxt-link>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
<span class="flex-shrink-0 text-sm font-medium text-secondary">
|
||||
{{ formatMessage(messages.downloadManually) }}
|
||||
</span>
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ExternalIcon, ModrinthIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Accordion from '~/components/ui/Accordion.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'InstallWithModrinthApp',
|
||||
})
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
project: DownloadModalProject
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const tags = useGeneratedState()
|
||||
const getModrinthAppAccordion = ref<InstanceType<typeof Accordion> | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
dontHaveModrinthApp: {
|
||||
id: 'project.download.no-app',
|
||||
defaultMessage: "Don't have Modrinth App?",
|
||||
},
|
||||
downloadManually: {
|
||||
id: 'project.download.manually',
|
||||
defaultMessage: 'Download manually',
|
||||
},
|
||||
installWithModrinthAppDescription: {
|
||||
id: 'project.download.install-with-app-description',
|
||||
defaultMessage: 'Automatically install the correct version and dependencies.',
|
||||
},
|
||||
})
|
||||
|
||||
function installWithApp() {
|
||||
setTimeout(() => {
|
||||
getModrinthAppAccordion.value?.open()
|
||||
}, 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modrinth-app-install-card {
|
||||
background: radial-gradient(
|
||||
ellipse 90% 250% at 50% 200%,
|
||||
color-mix(in srgb, var(--color-brand-shadow) 50%, var(--surface-1)) -30%,
|
||||
var(--surface-1) 72%
|
||||
);
|
||||
}
|
||||
|
||||
@media (hover: none) and (max-width: 767px) {
|
||||
.modrinth-app-section {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,447 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :on-show="onShow" :on-hide="onHide" width="544px">
|
||||
<template #title>
|
||||
<template v-if="project">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||
<div
|
||||
ref="downloadTitleRef"
|
||||
v-tooltip="truncatedTooltip(downloadTitleRef, downloadTitle)"
|
||||
class="truncate text-lg font-extrabold text-contrast"
|
||||
>
|
||||
{{ downloadTitle }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #default>
|
||||
<div v-if="project" class="mx-auto flex w-full flex-col gap-4">
|
||||
<InstallWithModrinthApp :project="project" />
|
||||
<DownloadProject
|
||||
:project="project"
|
||||
:versions="versions"
|
||||
:dependency-download-files="dependencyDownloadFiles"
|
||||
:download-reason="downloadReason"
|
||||
:initial-game-version="initialGameVersion"
|
||||
:initial-platform="initialPlatform"
|
||||
:incompatible-game-versions="showOptions.incompatibleGameVersions"
|
||||
:incompatible-loaders="showOptions.incompatibleLoaders"
|
||||
:reset-key="downloadProjectResetKey"
|
||||
@select-game-version="selectGameVersion"
|
||||
@select-platform="selectPlatform"
|
||||
@update:selection="projectDownloadSelection = $event"
|
||||
@download="onDownload"
|
||||
/>
|
||||
<div class="flex flex-col gap-4">
|
||||
<DownloadDependencies
|
||||
:project="project"
|
||||
:selected-version="selectedVersion"
|
||||
:current-game-version="currentGameVersion"
|
||||
:current-platform="currentPlatform"
|
||||
:download-reason="downloadReason"
|
||||
:additional-files="additionalFiles"
|
||||
@update:downloadable-files="dependencyDownloadFiles = $event"
|
||||
@download="onDownload"
|
||||
/>
|
||||
</div>
|
||||
<ServersPromo
|
||||
v-if="flags.showProjectPageDownloadModalServersPromo"
|
||||
:link="`/hosting#plan`"
|
||||
@close="
|
||||
() => {
|
||||
flags.showProjectPageDownloadModalServersPromo = false
|
||||
saveFeatureFlags()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
Avatar,
|
||||
type CdnDownloadReason,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
NewModal,
|
||||
ServersPromo,
|
||||
truncatedTooltip,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { DisplayProjectType } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { navigateTo } from '#app'
|
||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||
|
||||
import DownloadDependencies from './DownloadDependencies.vue'
|
||||
import DownloadProject from './DownloadProject.vue'
|
||||
import InstallWithModrinthApp from './InstallWithModrinthApp.vue'
|
||||
|
||||
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
||||
project_type: DisplayProjectType
|
||||
actualProjectType: Labrinth.Projects.v2.ProjectType
|
||||
}
|
||||
|
||||
type ProjectDownloadSelection = {
|
||||
currentGameVersion: string | null
|
||||
currentPlatform: string | null
|
||||
selectedVersion: Labrinth.Versions.v3.Version | null
|
||||
selectedPrimaryFile: Labrinth.Versions.v3.VersionFile | null
|
||||
}
|
||||
|
||||
type DownloadableFile = {
|
||||
href: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
type NewModalRef = {
|
||||
show: (event?: MouseEvent) => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
type ProjectDownloadModalShowOptions = {
|
||||
projectId?: string
|
||||
incompatibleGameVersions?: string[]
|
||||
incompatibleLoaders?: string[]
|
||||
}
|
||||
|
||||
type ResolvedProjectDownloadModalShowOptions = {
|
||||
projectId?: string
|
||||
incompatibleGameVersions: string[]
|
||||
incompatibleLoaders: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
projectId?: string
|
||||
downloadReason?: CdnDownloadReason
|
||||
useRouteHash?: boolean
|
||||
updateRouteSelection?: boolean
|
||||
}>(),
|
||||
{
|
||||
downloadReason: 'standalone',
|
||||
useRouteHash: true,
|
||||
updateRouteSelection: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: []
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const flags = useFeatureFlags()
|
||||
const tags = useGeneratedState()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('DownloadModal')
|
||||
|
||||
const modal = ref<NewModalRef | null>(null)
|
||||
const downloadTitleRef = ref<HTMLElement | null>(null)
|
||||
const modalOpen = ref(false)
|
||||
const showProjectId = ref<string | null>(null)
|
||||
const showOptions = ref<ResolvedProjectDownloadModalShowOptions>(getDefaultShowOptions())
|
||||
const downloadProjectResetKey = ref(0)
|
||||
const projectDownloadSelection = ref<ProjectDownloadSelection>(getDefaultProjectDownloadSelection())
|
||||
const dependencyDownloadFiles = ref<DownloadableFile[]>([])
|
||||
const MODAL_CLOSE_STATE_RESET_MS = 350
|
||||
let closeStateResetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const routeProjectId = computed(() => showProjectId.value ?? props.projectId ?? null)
|
||||
|
||||
const {
|
||||
data: projectRaw,
|
||||
error: projectV2Error,
|
||||
refetch: refetchProject,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['project', 'v2', routeProjectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v2.get(routeProjectId.value!),
|
||||
enabled: computed(() => !!routeProjectId.value),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
|
||||
const resolvedProjectId = computed(() => projectRaw.value?.id)
|
||||
|
||||
const project = computed<DownloadModalProject | null>(() => {
|
||||
if (!projectRaw.value) return null
|
||||
|
||||
return {
|
||||
...projectRaw.value,
|
||||
actualProjectType: projectRaw.value.project_type,
|
||||
project_type: getProjectTypeForUrl(projectRaw.value.project_type, projectRaw.value.loaders),
|
||||
}
|
||||
})
|
||||
|
||||
const downloadTitle = computed(() => {
|
||||
if (!project.value) return ''
|
||||
return formatMessage(messages.downloadTitle, { title: project.value.title })
|
||||
})
|
||||
|
||||
const versionsEnabled = ref(false)
|
||||
const {
|
||||
data: versionsV3,
|
||||
error: _versionsV3Error,
|
||||
isFetching: versionsV3Loading,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['project', resolvedProjectId.value, 'versions', 'v3']),
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getProjectVersions(resolvedProjectId.value!, {
|
||||
include_changelog: false,
|
||||
apiVersion: 3,
|
||||
}),
|
||||
staleTime: STALE_TIME_LONG,
|
||||
enabled: computed(() => !!resolvedProjectId.value && versionsEnabled.value),
|
||||
})
|
||||
|
||||
const versions = computed<Labrinth.Versions.v3.Version[]>(() => {
|
||||
const isModpack =
|
||||
project.value?.actualProjectType === 'modpack' || project.value?.project_type === 'modpack'
|
||||
|
||||
return (versionsV3.value ?? []).map((version) => {
|
||||
const files = Array.isArray(version.files) ? version.files : []
|
||||
const gameVersions = Array.isArray(version.game_versions) ? version.game_versions : []
|
||||
const loaders = Array.isArray(version.loaders) ? version.loaders : []
|
||||
const mrpackLoaders = Array.isArray(version.mrpack_loaders) ? version.mrpack_loaders : []
|
||||
|
||||
return {
|
||||
...version,
|
||||
files,
|
||||
game_versions: gameVersions,
|
||||
loaders: isModpack && mrpackLoaders.length ? mrpackLoaders : loaders,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const initialGameVersion = computed(() => {
|
||||
const version = route.query.version
|
||||
if (typeof version !== 'string' || !project.value?.game_versions.includes(version)) return null
|
||||
return version
|
||||
})
|
||||
const initialPlatform = computed(() => {
|
||||
const loader = route.query.loader
|
||||
if (typeof loader !== 'string' || !project.value?.loaders.includes(loader)) return null
|
||||
return loader
|
||||
})
|
||||
|
||||
const currentGameVersion = computed(() => projectDownloadSelection.value.currentGameVersion)
|
||||
const currentPlatform = computed(() => projectDownloadSelection.value.currentPlatform)
|
||||
const selectedVersion = computed(() => projectDownloadSelection.value.selectedVersion)
|
||||
const selectedPrimaryFile = computed(() => projectDownloadSelection.value.selectedPrimaryFile)
|
||||
|
||||
const additionalFiles = computed(() => {
|
||||
if (!selectedVersion.value || !selectedPrimaryFile.value) return []
|
||||
return selectedVersion.value.files.filter((file) => file !== selectedPrimaryFile.value)
|
||||
})
|
||||
|
||||
watch(projectV2Error, (error) => {
|
||||
if (error) {
|
||||
debug('project query failed', error)
|
||||
}
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
downloadTitle: {
|
||||
id: 'project.download.title',
|
||||
defaultMessage: 'Download {title}',
|
||||
},
|
||||
})
|
||||
|
||||
function getProjectTypeForUrl(
|
||||
type: Labrinth.Projects.v2.ProjectType,
|
||||
loaders: string[],
|
||||
): DisplayProjectType {
|
||||
if (type !== 'mod') return type as DisplayProjectType
|
||||
|
||||
const isMod = loaders.some((loader) => tags.value.loaderData.modLoaders.includes(loader))
|
||||
const isPlugin = loaders.some((loader) => tags.value.loaderData.allPluginLoaders.includes(loader))
|
||||
const isDataPack = loaders.some((loader) =>
|
||||
tags.value.loaderData.dataPackLoaders.includes(loader),
|
||||
)
|
||||
|
||||
if (isDataPack) return 'datapack'
|
||||
if (isPlugin) return 'plugin'
|
||||
if (isMod) return 'mod'
|
||||
|
||||
return 'mod'
|
||||
}
|
||||
|
||||
function updateDownloadQuery({
|
||||
gameVersion,
|
||||
platform,
|
||||
}: {
|
||||
gameVersion: string | null
|
||||
platform: string | null
|
||||
}) {
|
||||
if (!props.updateRouteSelection) return
|
||||
navigateTo(
|
||||
{
|
||||
query: {
|
||||
...route.query,
|
||||
...(gameVersion && {
|
||||
version: gameVersion,
|
||||
}),
|
||||
...(platform && {
|
||||
loader: platform,
|
||||
}),
|
||||
},
|
||||
hash: route.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
|
||||
function selectGameVersion(gameVersion: string) {
|
||||
updateDownloadQuery({
|
||||
gameVersion,
|
||||
platform: currentPlatform.value,
|
||||
})
|
||||
}
|
||||
|
||||
function selectPlatform(platform: string) {
|
||||
updateDownloadQuery({
|
||||
gameVersion: currentGameVersion.value,
|
||||
platform,
|
||||
})
|
||||
}
|
||||
|
||||
function onShow() {
|
||||
clearCloseStateResetTimeout()
|
||||
modalOpen.value = true
|
||||
debug('on-show fired')
|
||||
versionsEnabled.value = true
|
||||
if (props.useRouteHash && !showProjectId.value) {
|
||||
navigateTo({ query: route.query, hash: '#download' }, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
const hadShowProjectId = !!showProjectId.value
|
||||
modalOpen.value = false
|
||||
clearCloseStateResetTimeout()
|
||||
closeStateResetTimeout = setTimeout(() => {
|
||||
showProjectId.value = null
|
||||
showOptions.value = getDefaultShowOptions()
|
||||
closeStateResetTimeout = null
|
||||
}, MODAL_CLOSE_STATE_RESET_MS)
|
||||
if (props.useRouteHash && !hadShowProjectId) {
|
||||
navigateTo({ query: route.query, hash: '' }, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function show(
|
||||
event?: MouseEvent,
|
||||
options: ProjectDownloadModalShowOptions = {},
|
||||
): Promise<void> {
|
||||
if (!modal.value || modalOpen.value) return
|
||||
await waitForCloseStateReset()
|
||||
if (!modal.value || modalOpen.value) return
|
||||
showOptions.value = {
|
||||
...getDefaultShowOptions(),
|
||||
...options,
|
||||
}
|
||||
showProjectId.value = showOptions.value.projectId ?? null
|
||||
await nextTick()
|
||||
if (!(await loadProjectForModal(!!showOptions.value.projectId))) return
|
||||
resetDownloadState()
|
||||
modalOpen.value = true
|
||||
modal.value.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
if (!modal.value || !modalOpen.value) return
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function onDownload() {
|
||||
emit('download')
|
||||
}
|
||||
|
||||
function getDefaultProjectDownloadSelection(): ProjectDownloadSelection {
|
||||
return {
|
||||
currentGameVersion: null,
|
||||
currentPlatform: null,
|
||||
selectedVersion: null,
|
||||
selectedPrimaryFile: null,
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultShowOptions(): ResolvedProjectDownloadModalShowOptions {
|
||||
return {
|
||||
projectId: undefined,
|
||||
incompatibleGameVersions: [],
|
||||
incompatibleLoaders: [],
|
||||
}
|
||||
}
|
||||
|
||||
function clearCloseStateResetTimeout() {
|
||||
if (!closeStateResetTimeout) return
|
||||
clearTimeout(closeStateResetTimeout)
|
||||
closeStateResetTimeout = null
|
||||
}
|
||||
|
||||
async function waitForCloseStateReset() {
|
||||
if (!closeStateResetTimeout) return
|
||||
await new Promise((resolve) => setTimeout(resolve, MODAL_CLOSE_STATE_RESET_MS))
|
||||
}
|
||||
|
||||
async function loadProjectForModal(forceRefetch: boolean) {
|
||||
if (!routeProjectId.value) return false
|
||||
if (!forceRefetch && projectRaw.value) return true
|
||||
|
||||
const { data } = await refetchProject()
|
||||
return !!data
|
||||
}
|
||||
|
||||
function resetDownloadState() {
|
||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||
dependencyDownloadFiles.value = []
|
||||
downloadProjectResetKey.value += 1
|
||||
}
|
||||
|
||||
function openFromHash() {
|
||||
if (
|
||||
!props.useRouteHash ||
|
||||
!modal.value ||
|
||||
modalOpen.value ||
|
||||
showProjectId.value ||
|
||||
route.hash !== '#download'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
debug('hash #download watch fired, opening modal')
|
||||
show()
|
||||
}
|
||||
|
||||
if (
|
||||
props.useRouteHash &&
|
||||
(route.hash === '#download' ||
|
||||
route.query.version !== undefined ||
|
||||
route.query.loader !== undefined)
|
||||
) {
|
||||
debug('eager loadVersions from setup', {
|
||||
hash: route.hash,
|
||||
version: route.query.version,
|
||||
loader: route.query.loader,
|
||||
loading: versionsV3Loading.value,
|
||||
})
|
||||
versionsEnabled.value = true
|
||||
}
|
||||
|
||||
watch(modal, openFromHash)
|
||||
watch(() => route.hash, openFromHash)
|
||||
watch(routeProjectId, () => {
|
||||
projectDownloadSelection.value = getDefaultProjectDownloadSelection()
|
||||
dependencyDownloadFiles.value = []
|
||||
downloadProjectResetKey.value += 1
|
||||
})
|
||||
|
||||
onUnmounted(clearCloseStateResetTimeout)
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -257,7 +257,7 @@ const messages = defineMessages({
|
||||
},
|
||||
managePasskeyModalLoading: {
|
||||
id: 'settings.account.security.passkey.modal.loading',
|
||||
defaultMessage: 'Loading passkeys…',
|
||||
defaultMessage: 'Loading passkeys...',
|
||||
},
|
||||
managePasskeyModalNoPasskeys: {
|
||||
id: 'settings.account.security.passkey.modal.no-passkeys',
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
:options="[userOption, ...ownerOptions]"
|
||||
searchable
|
||||
:disabled="hasHitLimit"
|
||||
select-search-text-on-focus
|
||||
show-icon-in-selected
|
||||
/>
|
||||
<span>{{ formatMessage(messages.ownerDescription) }}</span>
|
||||
@@ -129,7 +130,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { OrganizationIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
@@ -362,7 +363,13 @@ async function fetchOrganizations() {
|
||||
}),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
: markRaw(
|
||||
defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () => h(OrganizationIcon, { class: 'size-5' }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}))
|
||||
if (props.organizationId) owner.value = props.organizationId
|
||||
} catch (err) {
|
||||
@@ -395,6 +402,7 @@ async function createProject() {
|
||||
server_side: 'required',
|
||||
license_id: 'LicenseRef-Unknown',
|
||||
is_draft: true,
|
||||
organization_id: owner.value !== 'self' ? owner.value : undefined,
|
||||
}
|
||||
|
||||
formData.append('data', JSON.stringify(projectData))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-divider bg-bg-raised p-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 break-words font-semibold text-contrast">
|
||||
{{ trace.project_name }}
|
||||
</p>
|
||||
<p class="m-0 mt-1 break-all text-sm text-secondary">
|
||||
Project {{ trace.project_slug ?? trace.project_id }} / Version
|
||||
{{ trace.version_number }} / File {{ trace.file_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm text-secondary">Local</span>
|
||||
<Badge :type="trace.local_status" />
|
||||
<span class="text-sm text-secondary">Effective</span>
|
||||
<Badge :type="trace.effective_status" />
|
||||
<ButtonStyled>
|
||||
<NuxtLink :to="localTraceLink">
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
View
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-2 text-sm text-secondary md:grid-cols-2">
|
||||
<p class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">Issue</span>
|
||||
{{ trace.issue_type }}
|
||||
</p>
|
||||
<p class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">Severity</span>
|
||||
{{ trace.severity }}
|
||||
</p>
|
||||
<p class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">Path</span>
|
||||
{{ trace.file_path }}
|
||||
</p>
|
||||
<p v-if="trace.jar" class="m-0 break-all">
|
||||
<span class="font-semibold text-contrast">JAR</span>
|
||||
{{ trace.jar }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import { Badge, ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
const props = defineProps<{
|
||||
trace: Labrinth.TechReview.Internal.GlobalIssueDetailTrace
|
||||
}>()
|
||||
|
||||
const localTraceLink = computed(
|
||||
() =>
|
||||
`/moderation/technical-review/${props.trace.project_id}?detail=${encodeURIComponent(
|
||||
props.trace.detail_id,
|
||||
)}`,
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div>
|
||||
<form class="flex flex-col gap-2 sm:flex-row" @submit.prevent="executeSearch">
|
||||
<StyledInput
|
||||
v-model="query"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Search global trace keys..."
|
||||
clearable
|
||||
wrapper-class="flex-1 w-full"
|
||||
/>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit" :disabled="isLoading">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
Search
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="!isLoading && !loadError && total > 0"
|
||||
class="mt-4 flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<p class="m-0 text-sm text-secondary">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="isLoading"
|
||||
type="no-search-result"
|
||||
heading="Loading global detail traces..."
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="loadError"
|
||||
type="no-search-result"
|
||||
heading="Failed to load global detail traces"
|
||||
/>
|
||||
<div v-else-if="traces.length > 0" class="mt-4 flex flex-col gap-3">
|
||||
<article
|
||||
v-for="trace in traces"
|
||||
:key="trace.detail_key"
|
||||
class="universal-card flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
|
||||
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
|
||||
Trace
|
||||
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<p class="m-0 mt-1 text-sm text-secondary">
|
||||
{{ formatTraceCount(trace.local_trace_count) }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge :type="trace.verdict" />
|
||||
</div>
|
||||
|
||||
<div v-if="getPreviewLocalTraces(trace).length > 0" class="flex flex-col gap-2">
|
||||
<div
|
||||
v-if="getVisibleLocalTraceTotal(trace) > getPreviewLocalTraces(trace).length"
|
||||
class="flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
Showing first {{ getPreviewLocalTraces(trace).length }} of
|
||||
{{ getVisibleLocalTraceTotal(trace) }} local traces
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<NuxtLink :to="getGlobalTraceLink(trace)">
|
||||
<ListIcon aria-hidden="true" />
|
||||
View all
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<GlobalDetailLocalTraceCard
|
||||
v-for="localTrace in getPreviewLocalTraces(trace)"
|
||||
:key="localTrace.detail_id"
|
||||
:trace="localTrace"
|
||||
/>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else
|
||||
type="no-search-result"
|
||||
heading="No local traces currently match this key"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
<EmptyState v-else type="no-search-result" heading="No global detail traces found" />
|
||||
|
||||
<div v-if="!isLoading && !loadError && total > 0" class="mt-4 flex justify-end">
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { HashIcon, ListIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
EmptyState,
|
||||
injectModrinthClient,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const query = ref('')
|
||||
const activeQuery = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = 20
|
||||
const localTracePreviewLimit = 10
|
||||
const total = ref(0)
|
||||
const traces = ref<Labrinth.TechReview.Internal.GlobalIssueDetail[]>([])
|
||||
|
||||
const pageCount = computed(() => Math.max(Math.ceil(total.value / itemsPerPage), 1))
|
||||
const pageStart = computed(() =>
|
||||
total.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage + 1,
|
||||
)
|
||||
const pageEnd = computed(() => Math.min(currentPage.value * itemsPerPage, total.value))
|
||||
|
||||
function formatTraceCount(count: number) {
|
||||
return `${count} local ${count === 1 ? 'trace' : 'traces'}`
|
||||
}
|
||||
|
||||
function getPreviewLocalTraces(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
return trace.local_traces.slice(0, localTracePreviewLimit)
|
||||
}
|
||||
|
||||
function getVisibleLocalTraceTotal(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
return Math.max(trace.local_trace_count, trace.local_traces.length)
|
||||
}
|
||||
|
||||
function getGlobalTraceLink(trace: Labrinth.TechReview.Internal.GlobalIssueDetail) {
|
||||
return `/moderation/global-traces/${encodeURIComponent(trace.detail_key)}`
|
||||
}
|
||||
|
||||
async function loadTraces() {
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.tech_review_internal.searchGlobalIssueDetails({
|
||||
query: activeQuery.value,
|
||||
limit: itemsPerPage,
|
||||
page: currentPage.value - 1,
|
||||
})
|
||||
|
||||
traces.value = response.traces
|
||||
total.value = response.total
|
||||
} catch (error) {
|
||||
console.error('Failed to load global detail traces', error)
|
||||
traces.value = []
|
||||
total.value = 0
|
||||
loadError.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSearch() {
|
||||
activeQuery.value = query.value.trim() || null
|
||||
currentPage.value = 1
|
||||
await loadTraces()
|
||||
}
|
||||
|
||||
async function switchPage(page: number) {
|
||||
currentPage.value = page
|
||||
await loadTraces()
|
||||
}
|
||||
|
||||
onMounted(loadTraces)
|
||||
</script>
|
||||
@@ -2,12 +2,7 @@
|
||||
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink
|
||||
:to="`/project/${queueEntry.project.slug}`"
|
||||
target="_blank"
|
||||
tabindex="-1"
|
||||
class="flex"
|
||||
>
|
||||
<NuxtLink :to="`/project/${projectRouteParam}`" target="_blank" tabindex="-1" class="flex">
|
||||
<Avatar
|
||||
:src="queueEntry.project.icon_url"
|
||||
size="4rem"
|
||||
@@ -17,7 +12,7 @@
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/project/${queueEntry.project.slug}`"
|
||||
:to="`/project/${projectRouteParam}`"
|
||||
target="_blank"
|
||||
class="text-lg font-semibold text-contrast hover:underline"
|
||||
>
|
||||
@@ -177,7 +172,7 @@ function getDaysQueued(date: Date): number {
|
||||
const queuedDate = computed(() => {
|
||||
return dayjs(
|
||||
props.queueEntry.project.queued ||
|
||||
props.queueEntry.project.created ||
|
||||
props.queueEntry.project.published ||
|
||||
props.queueEntry.project.updated,
|
||||
)
|
||||
})
|
||||
@@ -186,10 +181,14 @@ const daysInQueue = computed(() => {
|
||||
return getDaysQueued(queuedDate.value.toDate())
|
||||
})
|
||||
|
||||
const projectRouteParam = computed(
|
||||
() => props.queueEntry.project.slug || props.queueEntry.project.id,
|
||||
)
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const date =
|
||||
props.queueEntry.project.queued ||
|
||||
props.queueEntry.project.created ||
|
||||
props.queueEntry.project.published ||
|
||||
props.queueEntry.project.updated
|
||||
if (!date) return 'Unknown'
|
||||
|
||||
@@ -202,7 +201,7 @@ const formattedDate = computed(() => {
|
||||
|
||||
function copyLink() {
|
||||
const base = window.location.origin
|
||||
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
|
||||
const projectUrl = `${base}/project/${projectRouteParam.value}`
|
||||
navigator.clipboard.writeText(projectUrl).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
|
||||
@@ -10,9 +10,8 @@ import {
|
||||
CodeIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EllipsisVerticalIcon,
|
||||
ExternalIcon,
|
||||
EyeOffIcon,
|
||||
LinkIcon,
|
||||
LoaderCircleIcon,
|
||||
ScaleIcon,
|
||||
ShieldCheckIcon,
|
||||
@@ -46,7 +45,7 @@ import {
|
||||
type User,
|
||||
} from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
|
||||
import ThreadView from '~/components/ui/thread/ThreadView.vue'
|
||||
@@ -90,6 +89,7 @@ const props = defineProps<{
|
||||
thread: Labrinth.TechReview.Internal.Thread
|
||||
reports: FlattenedFileReport[]
|
||||
}
|
||||
focusedDetailId?: string | null
|
||||
loadingIssues: Set<string>
|
||||
decompiledSources: Map<string, string>
|
||||
}>()
|
||||
@@ -113,51 +113,6 @@ const isProjectApproved = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const quickActions = computed<OverflowMenuOption[]>(() => {
|
||||
const actions: OverflowMenuOption[] = []
|
||||
|
||||
const sourceUrl = props.item.project.link_urls?.['source']?.url
|
||||
if (sourceUrl) {
|
||||
actions.push({
|
||||
id: 'view-source',
|
||||
action: () => {
|
||||
window.open(sourceUrl, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
actions.push(
|
||||
{
|
||||
id: 'copy-link',
|
||||
action: () => {
|
||||
const base = window.location.origin
|
||||
const reportUrl = `${base}/moderation/technical-review/${props.item.project.id}`
|
||||
navigator.clipboard.writeText(reportUrl).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Technical Review link copied',
|
||||
text: 'The link to this review has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'copy-id',
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.item.project.id).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Project ID copied',
|
||||
text: 'The ID of this project has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return actions
|
||||
})
|
||||
|
||||
const isLoadingStatusAction = ref(false)
|
||||
const projectStatusActions = computed<OverflowMenuOption[]>(() => [
|
||||
{
|
||||
@@ -238,7 +193,12 @@ watch(selectedFile, (newFile) => {
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | 'unsafe' }[]) {
|
||||
async function updateIssueDetails(
|
||||
data: {
|
||||
detail_id: string
|
||||
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
|
||||
}[],
|
||||
) {
|
||||
await client.request('/moderation/tech-review/issue-detail', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
@@ -427,6 +387,49 @@ function viewFileFlags(file: FlattenedFileReport) {
|
||||
currentTab.value = 'File'
|
||||
}
|
||||
|
||||
function getDetailElementId(detailId: string) {
|
||||
return `tech-review-detail-${detailId}`
|
||||
}
|
||||
|
||||
function findFileForDetail(detailId: string): FlattenedFileReport | null {
|
||||
for (const report of props.item.reports) {
|
||||
for (const issue of report.issues) {
|
||||
if (issue.details.some((detail) => detail.id === detailId)) {
|
||||
return report
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function focusDetail(detailId: string) {
|
||||
const file = findFileForDetail(detailId)
|
||||
if (!file) return
|
||||
|
||||
viewFileFlags(file)
|
||||
await nextTick()
|
||||
|
||||
const classItem = groupedByClass.value.find((group) =>
|
||||
group.flags.some((flag) => flag.detail.id === detailId),
|
||||
)
|
||||
|
||||
if (classItem) {
|
||||
expandClass(classItem)
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
if (!import.meta.client) return
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById(getDetailElementId(detailId))?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function backToFileList() {
|
||||
selectedFileId.value = null
|
||||
if (currentTab.value === 'File') {
|
||||
@@ -737,6 +740,16 @@ const groupedByJar = computed<JarGroup[]>(() => {
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.focusedDetailId,
|
||||
(detailId) => {
|
||||
if (detailId) {
|
||||
focusDetail(detailId)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Auto-expand/load source for small files; keep larger files lazy.
|
||||
watch(
|
||||
[selectedFileId, groupedByClass],
|
||||
@@ -1020,6 +1033,16 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyId() {
|
||||
navigator.clipboard.writeText(props.item.project.id).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Project ID copied',
|
||||
text: 'The ID of this project has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1105,25 +1128,31 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-base text-secondary">{{ formattedDate }}</span>
|
||||
<ButtonStyled circular>
|
||||
<OverflowMenu :options="quickActions" class="!shadow-none">
|
||||
<template #default>
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon />
|
||||
<span class="hidden sm:inline">Copy ID</span>
|
||||
</template>
|
||||
<template #copy-link>
|
||||
<LinkIcon />
|
||||
<span class="hidden sm:inline">Copy link</span>
|
||||
</template>
|
||||
<template #view-source>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="props.item.project.link_urls?.['source']?.url" circular>
|
||||
<a
|
||||
v-tooltip="'Open sources in new tab'"
|
||||
:href="props.item.project.link_urls?.['source']?.url"
|
||||
target="_blank"
|
||||
>
|
||||
<CodeIcon />
|
||||
<span class="hidden sm:inline">View source</span>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button v-tooltip="'Copy ID'" @click="copyId">
|
||||
<ClipboardCopyIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Open in new tab'"
|
||||
:href="`/moderation/technical-review/${props.item.project.id}`"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1414,8 +1443,12 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
>
|
||||
<div
|
||||
v-for="flag in classItem.flags"
|
||||
:id="getDetailElementId(flag.detail.id)"
|
||||
:key="`${flag.issueId}-${flag.detail.id}`"
|
||||
class="flex flex-col gap-2 rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
|
||||
:class="{
|
||||
'!border-brand bg-brand-highlight': props.focusedDetailId === flag.detail.id,
|
||||
}"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_auto] items-center">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { FolderSearchIcon, StarIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
NewModal,
|
||||
Table,
|
||||
type TableColumn,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'modpack-scan-modal.title',
|
||||
defaultMessage: 'Modpack Scan ({scanned}/{total} Files)',
|
||||
},
|
||||
scanAllFiles: {
|
||||
id: 'modpack-scan-modal.scan-all-files',
|
||||
defaultMessage: 'Scan All Files',
|
||||
},
|
||||
packFileName: {
|
||||
id: 'modpack-scan-modal.pack-file-name',
|
||||
defaultMessage: 'Pack File Name',
|
||||
},
|
||||
newFiles: {
|
||||
id: 'modpack-scan-modal.new-files',
|
||||
defaultMessage: 'New Files',
|
||||
},
|
||||
newGroups: {
|
||||
id: 'modpack-scan-modal.new-groups',
|
||||
defaultMessage: 'New Groups',
|
||||
},
|
||||
loadingVersions: {
|
||||
id: 'modpack-scan-modal.loading-versions',
|
||||
defaultMessage: 'Loading versions...',
|
||||
},
|
||||
noFiles: {
|
||||
id: 'modpack-scan-modal.no-files',
|
||||
defaultMessage: 'No files found.',
|
||||
},
|
||||
notScanned: {
|
||||
id: 'modpack-scan-modal.not-scanned',
|
||||
defaultMessage: 'Not scanned',
|
||||
},
|
||||
scanning: {
|
||||
id: 'modpack-scan-modal.scanning',
|
||||
defaultMessage: 'Scanning...',
|
||||
},
|
||||
failed: {
|
||||
id: 'modpack-scan-modal.failed',
|
||||
defaultMessage: 'Failed',
|
||||
},
|
||||
overrideFiles: {
|
||||
id: 'modpack-scan-modal.override-files',
|
||||
defaultMessage: 'Override Files ({count})',
|
||||
},
|
||||
loadVersionsError: {
|
||||
id: 'modpack-scan-modal.load-versions-error',
|
||||
defaultMessage: 'Failed to load versions: {error}',
|
||||
},
|
||||
scanError: {
|
||||
id: 'modpack-scan-modal.scan-error',
|
||||
defaultMessage: 'Some files failed to scan: {error}',
|
||||
},
|
||||
})
|
||||
|
||||
type ScanTableColumn = 'filename' | 'newFiles' | 'newGroups'
|
||||
|
||||
type ScanRow = {
|
||||
id: string
|
||||
filename: string
|
||||
primary: boolean
|
||||
scan?: Labrinth.Attribution.Internal.FileScanResponse
|
||||
isScanning: boolean
|
||||
error?: string
|
||||
newFiles?: number
|
||||
newGroups?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
project_id: string
|
||||
}>()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const rows = ref<ScanRow[]>([])
|
||||
const isLoadingVersions = ref(false)
|
||||
const isScanning = ref(false)
|
||||
const versionLoadError = ref<string | null>(null)
|
||||
const scanError = ref<string | null>(null)
|
||||
const requestId = ref(0)
|
||||
const scanRequestId = ref(0)
|
||||
|
||||
const columns = computed<TableColumn<ScanTableColumn>[]>(() => [
|
||||
{ key: 'filename', label: formatMessage(messages.packFileName), width: '60%' },
|
||||
{ key: 'newFiles', label: formatMessage(messages.newFiles), align: 'center', width: '20%' },
|
||||
{ key: 'newGroups', label: formatMessage(messages.newGroups), align: 'center', width: '20%' },
|
||||
])
|
||||
|
||||
const scannedCount = computed(() => rows.value.filter((row) => row.scan || row.error).length)
|
||||
const isBusy = computed(() => isLoadingVersions.value || isScanning.value)
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null && 'data' in error) {
|
||||
const data = (error as { data?: { description?: string } }).data
|
||||
if (data?.description) {
|
||||
return data.description
|
||||
}
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
|
||||
async function fetchAllVersions() {
|
||||
const currentRequestId = ++requestId.value
|
||||
isLoadingVersions.value = true
|
||||
versionLoadError.value = null
|
||||
scanError.value = null
|
||||
rows.value = []
|
||||
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(props.project_id)
|
||||
if (currentRequestId !== requestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
rows.value = versions
|
||||
.flatMap((version) => version.files)
|
||||
.filter((file): file is Labrinth.Versions.v2.VersionFile & { id: string } => Boolean(file.id))
|
||||
.map((file) => ({
|
||||
id: file.id,
|
||||
filename: file.filename,
|
||||
primary: file.primary,
|
||||
isScanning: false,
|
||||
newFiles: undefined,
|
||||
newGroups: undefined,
|
||||
}))
|
||||
} catch (error) {
|
||||
if (currentRequestId === requestId.value) {
|
||||
versionLoadError.value = formatMessage(messages.loadVersionsError, {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestId === requestId.value) {
|
||||
isLoadingVersions.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllScans() {
|
||||
if (isBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentScanRequestId = ++scanRequestId.value
|
||||
isScanning.value = true
|
||||
scanError.value = null
|
||||
rows.value = rows.value.map((row) => ({
|
||||
...row,
|
||||
scan: undefined,
|
||||
isScanning: false,
|
||||
error: undefined,
|
||||
newFiles: undefined,
|
||||
newGroups: undefined,
|
||||
}))
|
||||
|
||||
try {
|
||||
for (const row of rows.value) {
|
||||
if (currentScanRequestId !== scanRequestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
row.isScanning = true
|
||||
try {
|
||||
const scan = await client.labrinth.attribution_internal.scanFile(row.id)
|
||||
if (currentScanRequestId !== scanRequestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
row.scan = scan
|
||||
row.newFiles = scan.new_attribution_files
|
||||
row.newGroups = scan.new_attribution_groups
|
||||
} catch (error) {
|
||||
if (currentScanRequestId !== scanRequestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
row.error = getErrorMessage(error)
|
||||
scanError.value = formatMessage(messages.scanError, { error: row.error })
|
||||
} finally {
|
||||
row.isScanning = false
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (currentScanRequestId === scanRequestId.value) {
|
||||
isScanning.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
scanRequestId.value++
|
||||
isScanning.value = false
|
||||
rows.value = []
|
||||
void fetchAllVersions()
|
||||
modalRef.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modalRef.value?.hide()
|
||||
}
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modalRef"
|
||||
width="60vw"
|
||||
:close-on-click-outside="false"
|
||||
:close-on-esc="false"
|
||||
:disable-close="isBusy"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{
|
||||
formatMessage(messages.title, {
|
||||
scanned: scannedCount,
|
||||
total: rows.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.scanAllFiles)"
|
||||
:disabled="isBusy || rows.length === 0"
|
||||
@click="fetchAllScans"
|
||||
>
|
||||
<FolderSearchIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full">
|
||||
<div
|
||||
v-if="versionLoadError || scanError"
|
||||
class="mb-3 rounded-xl bg-highlight-red p-3 text-red"
|
||||
>
|
||||
{{ versionLoadError || scanError }}
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
row-key="id"
|
||||
:row-below-visible="
|
||||
(row) => Boolean(row.scan?.scanned_file_names && row.scan.scanned_file_names.length > 0)
|
||||
"
|
||||
table-min-width="42rem"
|
||||
>
|
||||
<template #cell-filename="{ row }">
|
||||
<div class="flex min-w-0 items-center gap-1 text-contrast">
|
||||
<StarIcon v-if="row.primary" class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">{{ row.filename }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-newFiles="{ row }">
|
||||
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
|
||||
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</span>
|
||||
<span v-else-if="row.scan">{{ row.scan.new_attribution_files }}</span>
|
||||
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
|
||||
</template>
|
||||
<template #cell-newGroups="{ row }">
|
||||
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
|
||||
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</span>
|
||||
<span v-else-if="row.scan">{{ row.scan.new_attribution_groups }}</span>
|
||||
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
|
||||
</template>
|
||||
<template #row-below="{ row }">
|
||||
<div class="border-0 border-t border-solid border-surface-4 px-4 py-3">
|
||||
<details>
|
||||
<summary>
|
||||
{{
|
||||
formatMessage(messages.overrideFiles, {
|
||||
count: row.scan?.scanned_file_names.length ?? 0,
|
||||
})
|
||||
}}
|
||||
</summary>
|
||||
<div class="flex flex-wrap gap-1 pt-2">
|
||||
<span
|
||||
v-for="name of row.scan?.scanned_file_names ?? []"
|
||||
:key="name"
|
||||
v-tooltip="name"
|
||||
class="flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium text-contrast"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="flex h-64 items-center justify-center text-secondary">
|
||||
{{ formatMessage(isLoadingVersions ? messages.loadingVersions : messages.noFiles) }}
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -1949,7 +1949,10 @@ function generateModpackMessage(allFiles: {
|
||||
|
||||
const hasNextProject = ref(false)
|
||||
async function refreshModerationCaches(threadId?: string) {
|
||||
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
|
||||
const refreshes: Promise<unknown>[] = [
|
||||
invalidate(),
|
||||
queryClient.invalidateQueries({ queryKey: ['moderation-projects'] }),
|
||||
]
|
||||
|
||||
if (threadId) {
|
||||
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
|
||||
|
||||
@@ -58,6 +58,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showHostingAccessInstanceAuditLog: false,
|
||||
versionDevInfoCollapsed: true,
|
||||
alwaysShowVersionDevInfo: false,
|
||||
advancedFiltersCollapsed: true,
|
||||
} as const)
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
BrowseInstallPlan,
|
||||
BrowseSearchState,
|
||||
CreationFlowContextValue,
|
||||
EnvironmentSearchOverride,
|
||||
FilterValue,
|
||||
PendingServerContentInstall,
|
||||
PendingServerContentInstallType,
|
||||
@@ -21,6 +22,8 @@ import {
|
||||
readStoredServerInstallQueue,
|
||||
removePendingServerContentInstall,
|
||||
requestInstall,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useVIntl,
|
||||
writePendingServerContentInstallBaseline,
|
||||
writeStoredServerInstallQueue,
|
||||
@@ -160,6 +163,7 @@ export function useServerInstallContent({
|
||||
})
|
||||
|
||||
const serverHideInstalled = ref(false)
|
||||
const serverContentServerOnly = ref(false)
|
||||
const hideSelectedServerInstalls = ref(false)
|
||||
const installingProjectIds = ref<Set<string>>(new Set())
|
||||
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
|
||||
@@ -348,10 +352,6 @@ export function useServerInstallContent({
|
||||
filters.push({ type: 'plugin_loader', option: platform })
|
||||
}
|
||||
|
||||
if (projectType.value?.id === 'mod') {
|
||||
filters.push({ type: 'environment', option: 'server' })
|
||||
}
|
||||
|
||||
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
|
||||
for (const x of hiddenInstalledProjectIds.value) {
|
||||
filters.push({
|
||||
@@ -383,6 +383,28 @@ export function useServerInstallContent({
|
||||
return filters
|
||||
})
|
||||
|
||||
const showServerOnlyToggle = computed(() => !!serverData.value && projectType.value?.id === 'mod')
|
||||
|
||||
const serverEnvironmentOverride = computed<EnvironmentSearchOverride | undefined>(() => {
|
||||
if (!showServerOnlyToggle.value) return undefined
|
||||
if (serverContentServerOnly.value) {
|
||||
return {
|
||||
mode: 'include',
|
||||
values: [
|
||||
'server_only',
|
||||
'dedicated_server_only',
|
||||
'server_only_client_optional',
|
||||
'client_or_server_prefers_both',
|
||||
'client_or_server',
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'exclude',
|
||||
values: ['client_only', 'singleplayer_only'],
|
||||
}
|
||||
})
|
||||
|
||||
function getCurrentServerInstallType(): BrowseInstallContentType {
|
||||
const type = projectType.value?.id
|
||||
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
|
||||
@@ -584,11 +606,15 @@ export function useServerInstallContent({
|
||||
project,
|
||||
contentType,
|
||||
mode: isModpack ? 'immediate' : 'queue',
|
||||
selectedFilters: isModpack ? [] : browseSearchState.currentFilters.value,
|
||||
selectedFilters: isModpack
|
||||
? []
|
||||
: stripServerRuntimeInstallFilters(browseSearchState.currentFilters.value),
|
||||
providedFilters: isModpack ? [] : serverFilters.value,
|
||||
overriddenProvidedFilterTypes: isModpack
|
||||
? []
|
||||
: browseSearchState.overriddenProvidedFilterTypes.value,
|
||||
: stripServerRuntimeInstallOverrides(
|
||||
browseSearchState.overriddenProvidedFilterTypes.value,
|
||||
),
|
||||
targetPreferences: getServerInstallTargetPreferences(contentType),
|
||||
getProjectVersions: getInstallProjectVersions,
|
||||
queue: serverInstallQueue,
|
||||
@@ -737,6 +763,10 @@ export function useServerInstallContent({
|
||||
serverHideInstalled.value = route.query.shi === 'true'
|
||||
}
|
||||
|
||||
if (route.query.so && projectType.value?.id === 'mod') {
|
||||
serverContentServerOnly.value = route.query.so === 'true'
|
||||
}
|
||||
|
||||
watch(serverHideInstalled, (hideInstalled) => {
|
||||
if (hideInstalled) {
|
||||
syncHiddenInstalledProjectIds()
|
||||
@@ -763,6 +793,9 @@ export function useServerInstallContent({
|
||||
serverContentData,
|
||||
serverFilters,
|
||||
serverHideInstalled,
|
||||
serverContentServerOnly,
|
||||
showServerOnlyToggle,
|
||||
serverEnvironmentOverride,
|
||||
hideSelectedServerInstalls,
|
||||
installingProjectIds,
|
||||
optimisticallyInstalledProjectIds,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
|
||||
import type {
|
||||
Organization,
|
||||
@@ -197,14 +198,10 @@ export interface ModerationOwnershipOrganization {
|
||||
|
||||
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
|
||||
|
||||
export interface ProjectWithOwnership {
|
||||
ownership: ModerationOwnership
|
||||
external_dependencies_count: number
|
||||
[key: string]: any
|
||||
}
|
||||
export type ProjectWithOwnership = Labrinth.Moderation.Internal.QueueProject
|
||||
|
||||
export interface ModerationProject {
|
||||
project: any
|
||||
project: Omit<ProjectWithOwnership, 'ownership' | 'external_dependencies_count'>
|
||||
ownership: ModerationOwnership | null
|
||||
external_dependencies_count: number
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
<div class="pointer-events-none absolute inset-0 z-[-1]">
|
||||
<div id="absolute-background-teleport" class="relative"></div>
|
||||
</div>
|
||||
<div
|
||||
class="pride-backdrop pointer-events-none absolute inset-0 z-[-1]"
|
||||
:class="{ shown: showPrideBackdrop }"
|
||||
></div>
|
||||
<div class="pointer-events-none absolute inset-0 z-50">
|
||||
<div
|
||||
class="over-the-top-random-animation"
|
||||
@@ -801,6 +805,7 @@ import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { errors as generatedStateErrors } from '~/generated/state.json'
|
||||
import { provideCurrentProjectId } from '~/providers/current-project.ts'
|
||||
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
|
||||
import { hasActiveMidas } from '~/utils/user-membership.ts'
|
||||
|
||||
@@ -867,6 +872,32 @@ const showTinMismatchBanner = computed(() => {
|
||||
return !!auth.value.user && status === 'tin-mismatch'
|
||||
})
|
||||
|
||||
const PRIDE_COLLECTION_ID = 'M4c3ITvd'
|
||||
const PRIDE_ARTICLE_SLUGS = ['pride-campaign-2025', 'pride-campaign-2026', 'proud-of-you-2026']
|
||||
const PRIDE_CACHE_TIME = 1000 * 60 * 60 * 24
|
||||
|
||||
const { data: prideCollection } = useQuery({
|
||||
queryKey: computed(() => ['collection', PRIDE_COLLECTION_ID]),
|
||||
queryFn: () => client.labrinth.collections.get(PRIDE_COLLECTION_ID),
|
||||
staleTime: PRIDE_CACHE_TIME,
|
||||
gcTime: PRIDE_CACHE_TIME,
|
||||
})
|
||||
|
||||
const prideProjectIds = computed(() => new Set(prideCollection.value?.projects ?? []))
|
||||
|
||||
const currentProjectId = ref()
|
||||
provideCurrentProjectId(currentProjectId)
|
||||
|
||||
const showPrideBackdrop = computed(() => {
|
||||
if (PRIDE_ARTICLE_SLUGS.includes(route.params.slug)) {
|
||||
return true
|
||||
}
|
||||
if (route.params.collection === PRIDE_COLLECTION_ID) {
|
||||
return true
|
||||
}
|
||||
return !!currentProjectId.value && prideProjectIds.value.has(currentProjectId.value)
|
||||
})
|
||||
|
||||
const basePopoutId = useId()
|
||||
|
||||
async function fetchIntercomToken() {
|
||||
@@ -1695,4 +1726,21 @@ const { cycle: changeTheme } = useTheme()
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.pride-backdrop {
|
||||
background-image: linear-gradient(to right, #c20732, #f57203, #ffd632, #21ca8b, #2f9ff2, #e420fc);
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0) 80%);
|
||||
height: 30rem;
|
||||
opacity: 0;
|
||||
transition: opacity 1s ease;
|
||||
}
|
||||
|
||||
.pride-backdrop.shown {
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.light-mode .pride-backdrop.shown,
|
||||
.light .pride-backdrop.shown {
|
||||
opacity: 0.15;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2357,42 +2357,18 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licencováno"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Verze hry: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Chyba: žádné verze hry nebyly nalezeny"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} je k dispozici pouze pro verzi {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} nepodporuje {gameVersion} pro {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalovat přes Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Nemáte Modrinth App?"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Chyba: žádné platformy nenalezeny"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} je k dispozici pouze pro {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} nepodporuje {platform} pro {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Hledat verze hry..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Hledat verze hry..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Vybrat verzi hry"
|
||||
},
|
||||
|
||||
@@ -1760,45 +1760,21 @@
|
||||
"project.description.title": {
|
||||
"message": "Beskrivelse"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spille version: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Fejl: ingen spil versioner fundet"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} er kun tilgængelig til {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} understøtter ikke {gameVersion} til {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installer med Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Har ikke Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Ingen versioner tilgængelig til {gameVersion} og {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Fejl: ingen platforme fundet"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} er kun tilgængelig til {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} understøtter ikke {platform} til {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Søg spil versioner..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Søg spil versioner..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Vælg spil version"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lizenziert unter"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spielversion: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Fehler: Keine Spielversion gefunden"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} ist nur für {version} verfügbar"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {gameVersion} für {platform} nicht"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Mit Modrinth App installieren"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Du hast die Modrinth App nicht?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Keinen versionen verfügbar für {gameVersion} und {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Fehler: Keine Plattform gefunden"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} ist nur für {platform} verfügbar"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {platform} für {gameVersion} nicht"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Spielversionen durchsuchen..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Spielversionen durchuschen..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Spielversion wählen"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lizenziert unter"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spielversion: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Fehler: Keine Spielversion gefunden"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} ist nur für {version} verfügbar"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {gameVersion} für {platform} nicht"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Mit Modrinth App installieren"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Du hast die Modrinth App nicht?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Keine Versionen für {gameVersion} und {platform} verfügbar."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plattform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Fehler: Keine Platform gefunden"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} ist nur für {platform} verfügbar"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} unterstützt {platform} für die {gameVersion} nicht"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Spielversionen durchsuchen..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Spielversionen durchuschen..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Spielversion auswählen"
|
||||
},
|
||||
|
||||
@@ -518,6 +518,9 @@
|
||||
"analytics.value.unknown": {
|
||||
"message": "Unknown"
|
||||
},
|
||||
"analytics.value.unknown-dependent-tooltip": {
|
||||
"message": "There's no metadata to determine which dependent project this download attributes to."
|
||||
},
|
||||
"analytics.value.unmonetized": {
|
||||
"message": "Unmonetized"
|
||||
},
|
||||
@@ -2669,12 +2672,18 @@
|
||||
"layout.nav.upgrade-to-modrinth-plus": {
|
||||
"message": "Upgrade to Modrinth+"
|
||||
},
|
||||
"moderation.exclude-technical-review": {
|
||||
"message": "Exclude TR"
|
||||
},
|
||||
"moderation.moderate": {
|
||||
"message": "Moderate"
|
||||
},
|
||||
"moderation.page.external-projects": {
|
||||
"message": "External projects"
|
||||
},
|
||||
"moderation.page.global-detail-traces": {
|
||||
"message": "Global traces"
|
||||
},
|
||||
"moderation.page.projects": {
|
||||
"message": "Projects"
|
||||
},
|
||||
@@ -2684,6 +2693,45 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Tech review"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Failed"
|
||||
},
|
||||
"modpack-scan-modal.load-versions-error": {
|
||||
"message": "Failed to load versions: {error}"
|
||||
},
|
||||
"modpack-scan-modal.loading-versions": {
|
||||
"message": "Loading versions..."
|
||||
},
|
||||
"modpack-scan-modal.new-files": {
|
||||
"message": "New Files"
|
||||
},
|
||||
"modpack-scan-modal.new-groups": {
|
||||
"message": "New Groups"
|
||||
},
|
||||
"modpack-scan-modal.no-files": {
|
||||
"message": "No files found."
|
||||
},
|
||||
"modpack-scan-modal.not-scanned": {
|
||||
"message": "Not scanned"
|
||||
},
|
||||
"modpack-scan-modal.override-files": {
|
||||
"message": "Override Files ({count})"
|
||||
},
|
||||
"modpack-scan-modal.pack-file-name": {
|
||||
"message": "Pack File Name"
|
||||
},
|
||||
"modpack-scan-modal.scan-all-files": {
|
||||
"message": "Scan All Files"
|
||||
},
|
||||
"modpack-scan-modal.scan-error": {
|
||||
"message": "Some files failed to scan: {error}"
|
||||
},
|
||||
"modpack-scan-modal.scanning": {
|
||||
"message": "Scanning..."
|
||||
},
|
||||
"modpack-scan-modal.title": {
|
||||
"message": "Modpack Scan ({scanned}/{total} Files)"
|
||||
},
|
||||
"muralpay.account-type.checking": {
|
||||
"message": "Checking"
|
||||
},
|
||||
@@ -3230,44 +3278,80 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licensed"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Game version: {version}"
|
||||
"project.download.additional-files-title": {
|
||||
"message": "Additional files"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: no game versions found"
|
||||
"project.download.base-game-version-incompatible-tooltip": {
|
||||
"message": "This game version is incompatible with the base project."
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} is only available for {version}"
|
||||
"project.download.base-loader-incompatible-tooltip": {
|
||||
"message": "This loader is incompatible with the base project."
|
||||
},
|
||||
"project.download.compatible-version-title": {
|
||||
"message": "Compatible version"
|
||||
},
|
||||
"project.download.dependencies-title": {
|
||||
"message": "Dependencies"
|
||||
},
|
||||
"project.download.dependency-already-installed": {
|
||||
"message": "This dependency is already installed"
|
||||
},
|
||||
"project.download.dependency-conflicting": {
|
||||
"message": "This dependency conflicts with another dependency"
|
||||
},
|
||||
"project.download.dependency-download-file": {
|
||||
"message": "Download {filename}"
|
||||
},
|
||||
"project.download.dependency-download-file-with-size": {
|
||||
"message": "Download {filename} ({size})"
|
||||
},
|
||||
"project.download.dependency-duplicate": {
|
||||
"message": "This dependency is already included"
|
||||
},
|
||||
"project.download.dependency-missing-version": {
|
||||
"message": "This dependency version is unavailable"
|
||||
},
|
||||
"project.download.dependency-no-compatible-version": {
|
||||
"message": "No compatible version is available for this dependency"
|
||||
},
|
||||
"project.download.dependency-quilt-fabric-api": {
|
||||
"message": "Fabric API is skipped for Quilt"
|
||||
},
|
||||
"project.download.dependency-unavailable": {
|
||||
"message": "This dependency cannot be downloaded"
|
||||
},
|
||||
"project.download.download-version": {
|
||||
"message": "Download {version}"
|
||||
},
|
||||
"project.download.duplicate-dependencies-hidden": {
|
||||
"message": "Duplicate dependencies are hidden"
|
||||
},
|
||||
"project.download.file-unavailable": {
|
||||
"message": "This file cannot be downloaded"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} does not support {gameVersion} for {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Install with Modrinth App"
|
||||
"project.download.install-with-app-description": {
|
||||
"message": "Automatically install the correct version and dependencies."
|
||||
},
|
||||
"project.download.manually": {
|
||||
"message": "Download manually"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Don't have Modrinth App?"
|
||||
},
|
||||
"project.download.no-game-versions-found": {
|
||||
"message": "No game versions found"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "No versions available for {gameVersion} and {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: no platforms found"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} is only available for {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} does not support {platform} for {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Search game versions..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Search game versions..."
|
||||
"message": "Select game version"
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Select game version"
|
||||
@@ -3275,12 +3359,27 @@
|
||||
"project.download.select-platform": {
|
||||
"message": "Select platform"
|
||||
},
|
||||
"project.download.selected-version-download-all": {
|
||||
"message": "Download all (.zip)"
|
||||
},
|
||||
"project.download.selected-version-downloading": {
|
||||
"message": "Downloading... ({current}/{total})"
|
||||
},
|
||||
"project.download.selected-version-failed-text": {
|
||||
"message": "One or more version files could not be downloaded. Please try again."
|
||||
},
|
||||
"project.download.selected-version-failed-title": {
|
||||
"message": "Could not download version"
|
||||
},
|
||||
"project.download.show-all-versions": {
|
||||
"message": "Show all versions"
|
||||
},
|
||||
"project.download.title": {
|
||||
"message": "Download {title}"
|
||||
},
|
||||
"project.download.unknown-loader": {
|
||||
"message": "Unknown loader"
|
||||
},
|
||||
"project.environment.migration-no-permission.message": {
|
||||
"message": "We've just overhauled the Environments system on Modrinth and new options are now available. You don't have permission to modify these settings, but please let another member of the project know that the environment metadata needs to be verified."
|
||||
},
|
||||
@@ -4110,7 +4209,7 @@
|
||||
"message": "Last used {ago}"
|
||||
},
|
||||
"settings.account.security.passkey.modal.loading": {
|
||||
"message": "Loading passkeys…"
|
||||
"message": "Loading passkeys..."
|
||||
},
|
||||
"settings.account.security.passkey.modal.never-used": {
|
||||
"message": "Never used"
|
||||
@@ -4761,7 +4860,7 @@
|
||||
"message": "View version"
|
||||
},
|
||||
"version.download.download-dependency": {
|
||||
"message": "Download dependency"
|
||||
"message": "Select a version to download"
|
||||
},
|
||||
"version.download.no-primary-file": {
|
||||
"message": "Error: No primary file found"
|
||||
@@ -4772,6 +4871,9 @@
|
||||
"version.download.required-resource-pack": {
|
||||
"message": "Required resource pack"
|
||||
},
|
||||
"version.download.version": {
|
||||
"message": "Download {version} ({size})"
|
||||
},
|
||||
"version.edit.button": {
|
||||
"message": "Edit"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Con licencia"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versión del juego: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: no se encontraron versiones del juego"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} solo está disponible para {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} no soporta {gameVersion} para {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalar con la Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "¿No tienes la Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "No hay versiones disponibles para {gameVersion} y {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: no se encontraron plataformas"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} solo está disponible para {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} no soporta {platform} para {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Buscar versiones del juego..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Buscar versiones del juego..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecciona la versión del juego"
|
||||
},
|
||||
|
||||
@@ -3101,45 +3101,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Con licencia"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versión del juego: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: no se han encontrado versiones del juego"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} solo está disponible para {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} no está soportado para la versión {gameVersion} de {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalar con la aplicación Modrinth"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "¿No tienes la aplicación Modrinth?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "No hay versiones disponibles para {gameVersion} y {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: plataforma no encontrada"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} olo está disponible para {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} no está soportado para {platform} en la {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Buscar versiones del juego..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Buscar versiones del juego..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecciona la versión del juego"
|
||||
},
|
||||
|
||||
@@ -2408,45 +2408,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lisensiyang"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Bersiyon ng laro: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: walang mahanap na mga bersiyon ng laro"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "Ang {title} ay magagamit lamang sa {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "Hindi masuporta ng {title} ang {gameVersion} para sa {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "I-install gamit ang Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Wala kang Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Walang bersiyong magagamit para sa {gameVersion} at {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataporma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: walang mahanap na platform"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "Ang {title} ay magagamit lamang sa {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "Hindi masuport ng {title} ang {platform} para sa {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Maghanap ng mga bersiyon ng laro..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Maghanap ng mga bersiyon ng laro..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Piliin ang lahat ng bersiyon ng laro"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "License"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Version du jeu : {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Erreur : pas de versions du jeu trouvées"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} n'est disponible que pour {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} ne supporte pas {gameVersion} pour {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installer avec Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Vous n'avez pas Modrinth App ?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Aucune version disponible pour {gameVersion} et {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plateforme : {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Erreur : pas de plateformes trouvées"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} est que disponible pour {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} ne supporte pas {platform} pour {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Recherchez une version du jeu..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Recherchez une version du jeu..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Sélectionnez la version du jeu"
|
||||
},
|
||||
|
||||
@@ -2027,45 +2027,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "ברישיון"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "גרסת המשחק: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "שגיאה: לא נמצאו גרסאות משחק"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} זמין עבור {version} בלבד"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} אינו תומך ב-{gameVersion} עבור {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "התקן באמצעות Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "אין לכם את אפליקציית Modrinth?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "אין גרסאות זמינות עבור {gameVersion} ו-{platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "פלטפורמה: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "שגיאה: לא נמצאו פלטפורמות"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} זמין עבור {platform} בלבד"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} אינו תומך ב-{platform} עבור {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "חיפוש גרסאות משחק..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "חיפוש גרסאות משחק..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "בחירת גרסת משחק"
|
||||
},
|
||||
|
||||
@@ -2918,45 +2918,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licensz:"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Játékverzió: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Hiba: nem található játékverzió"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} csak erre a verzióra érthető el: {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "A(z) {title} nem támogatja ezt a verziót: {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Letöltés a Modrinth Appal"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Nincs meg a Modrinth App? "
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nem érhető el verzió ehhez: {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Hiba: nem található platform"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "A(z) {title} csak erre érthető el: {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "A(z) {title} nem támogatja ezt a verziót: {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Játékverziók keresése..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Játékverziók keresése..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Játékverzió kiválasztása"
|
||||
},
|
||||
|
||||
@@ -2423,45 +2423,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Berlisensi"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versi permainan: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Kesalahan: tidak ada versi permainan yang ditemukan"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} hanya tersedia untuk {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} tidak mendukung {gameVersion} untuk {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Pasang dengan Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Tidak memiliki Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Tidak ada versi tersedia untuk {gameVersion} dan {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Kesalahan: tidak ada platform yang ditemukan"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} hanya tersedia untuk {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} tidak mendukung {platform} untuk {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Cari versi permainan..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Cari versi permainan..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Pilih versi permainan"
|
||||
},
|
||||
|
||||
@@ -3212,45 +3212,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licenza"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versione del gioco: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Errore: nessuna versione del gioco trovata"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} è disponibile solo per {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} non supporta {platform} per {gameVersion}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installa con Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Non hai Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nessuna versione disponibile per {gameVersion} e {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Piattaforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Errore: nessuna piattaforma trovata"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} è disponibile solo per {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} non supporta {platform} per {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Cerca versioni del gioco..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Cerca tra le versioni del gioco..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Seleziona la versione del gioco"
|
||||
},
|
||||
|
||||
@@ -2729,45 +2729,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "ライセンス"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "ゲームバージョン: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "エラー: ゲームバージョンが見つかりません"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title}は{version}でのみ利用可能です"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title}は{platform}の{gameVersion}に対応していません"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Modrinth Appでインストール"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth Appをお持ちでないですか?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion}および{platform}向けのバージョンは利用できません。"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "プラットフォーム: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "エラー: プラットフォームが見つかりません"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title}は{platform}でのみ利用可能です"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title}は{gameVersion}の{platform}に対応していません"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "ゲームバージョンを検索…"
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "ゲームバージョンを検索…"
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "ゲームバージョンを選択"
|
||||
},
|
||||
|
||||
@@ -2543,45 +2543,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "라이선스"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "게임 버전: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "오류: 게임 버전을 찾을 수 없습니다"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title}은(는) {version}에서만 사용할 수 있습니다"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title}은(는) {platform}용 {gameVersion}을(를) 지원하지 않습니다"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Modrinth App으로 설치"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth 앱을 설치하지 않으셨나요?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion} 및 {platform}용 버전이 없습니다."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "플랫폼: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "오류: 플랫폼을 찾을 수 없음"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} 은(는) {platform} 에서만 사용할 수 있습니다"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} 은(는) {gameVersion} 용 {platform} 을(를) 지원하지 않습니다."
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "게임 버전 검색..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "게임 버전 검색..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "게임 버전 선택"
|
||||
},
|
||||
|
||||
@@ -2918,45 +2918,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Berlesen"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versi permainan: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Ralat: tiada versi permainan dijumpai"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} hanya tersedia untuk {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} tidak menyokong {gameVersion} untuk {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Pasang dengan Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Tidak ada Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Tiada versi tersedia untuk {gameVersion} dan {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Ralat: tiada platform dijumpai"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} hanya tersedia untuk {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} tidak menyokong {platform} untuk {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Cari versi permainan..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Cari versi permainan..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Pilih versi permainan"
|
||||
},
|
||||
|
||||
@@ -2792,45 +2792,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Gelicentieerd"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spel versie: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: geen spel-versies gevonden"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} is alleen beschikbaar voor {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} steunt niet {gameVersion} voor {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installeer met de Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Heb je de Modrinth App niet?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Geen versies beschikbaar voor {gameVersion} en {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: geen platformen gevonden"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} is alleen beschikbaar voor {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} steunt niet {platform} voor {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Game-versies zoeken..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Zoek game-versies..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecteer game-versie"
|
||||
},
|
||||
|
||||
@@ -2249,45 +2249,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lisensert"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spillversjon: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Error: ingen spillversjon funnet"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} er bare tilgjengelig for {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} støtter ikke {gameVersion} for {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installer med Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Har du ikke en Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Ingen versjoner tilgjengelige for {gameVersion} og {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plattform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Error: Ingen plattformer funnet"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} er bare tilgjengelig for {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} støtter ikke {platform} for {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Søk opp spillversjoner..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Søk opp spillversjoner..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Velg spillversjon"
|
||||
},
|
||||
|
||||
@@ -3215,45 +3215,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licencja"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Wersja gry: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Błąd: nie znaleziono wersji gry"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} jest dostępny tylko dla {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} nie obsługuje {gameVersion} dla {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Zainstaluj za pomocą Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Nie masz Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Brak dostępnych wersji dla {gameVersion} i {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Błąd: nie znaleziono żadnych platform"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} jest dostępny tylko dla {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} nie obsługuje {platform} dla {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Szukaj wersji gry..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Szukaj wersji gry..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Wybierz wersję gry"
|
||||
},
|
||||
|
||||
@@ -3212,45 +3212,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licença"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versão do jogo: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Erro: nenhuma versão do jogo encontrada"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} só está disponível para {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} não suporta a {gameVersion} para {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalar com o Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Não tem o Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nenhuma versão disponível para {gameVersion} e {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Erro: nenhuma plataforma encontrada"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} só está disponível para {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} não suporta {platform} para {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Buscar versões do jogo..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Buscar versões do jogo..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecionar versão do jogo"
|
||||
},
|
||||
|
||||
@@ -2222,45 +2222,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licenciado"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versão do jogo: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Erro: nenhuma versão do jogo encontrada"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} apenas está disponível para {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} não suporta {gameVersion} para {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instala com a Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Não tens a Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nenhuma versão disponível para {gameVersion} e {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plataforma: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Erro: nenhuma plataforma encontrada"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} apenas está disponível para {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} não suporta {platform} para {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Procurar versões do jogo..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Procurar versões do jogo..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selecionar versão do jogo"
|
||||
},
|
||||
|
||||
@@ -1415,45 +1415,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licență"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Versiunea jocului: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Eroare: nu s-au găsit versiuni ale jocului"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} este disponibil doar pentru {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} nu este compatibil cu {gameVersion} pentru {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Instalează cu Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Nu ai Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Nu există versiuni disponibile pentru {gameVersion} și {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platformă: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Eroare: nu s-au găsit platforme"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} este disponibil doar pentru {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} nu este compatibil cu {platform} pentru {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Caută versiuni ale jocului..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Caută versiuni ale jocului..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Selectează versiunea jocului"
|
||||
},
|
||||
|
||||
@@ -3215,45 +3215,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Лицензия"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Версия игры: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Ошибка: версии игры не найдены"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} доступен только для {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} не поддерживает {gameVersion} для {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Скачать в Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "У вас нет Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Нет версий, доступных для {gameVersion} и {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Платформа: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Ошибка: платформы не найдены"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} доступен только для {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} не поддерживает {platform} для {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Поиск версий игры..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Поиск версий игры..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Выбрать версию игры"
|
||||
},
|
||||
|
||||
@@ -2804,45 +2804,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Licensierad"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Spelversion: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Fel: inga spelversioner hittades"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} är bara tillgängligt för {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} stöttar inte {gameVersion} för {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Installera med Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Har du inte Modrinth appen?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Inga versioner finns tillgängliga för {gameVersion} och {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Plattform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Fel: ingen plattform hittades"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} är bara tillgängligt för {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} stöttar inte {platform} för {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Sök spelversioner..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Sök spelversioner..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Välj spelversion"
|
||||
},
|
||||
|
||||
@@ -3161,45 +3161,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Lisans:"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Oyun sürümü: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Hata: oyun sürümü bulunamadı"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} sadece {version} sürümünde mevcut"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title}, {platform} için {gameVersion} sürümünü desteklemiyor"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Modrinth App ile yükle"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Modrinth App Yok Mu?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{gameVersion} ve {platform} için sürüm mevcut değil."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Platform: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Hata: platform bulunamadı"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} sadece {platform} için mevcut"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title}, {gameVersion} sürümünde {platform} desteklemiyor"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Oyun sürümü ara..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Oyun sürümü ara..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Oyun sürümü seç"
|
||||
},
|
||||
|
||||
@@ -2849,45 +2849,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Ліцензія:"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Версія гри: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Помилка: версію гри не знайдено"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "«{title}» доступно лише для {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "«{title}» не підтримує {gameVersion} для {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Установити через Modrinth App"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Не маєте Modrinth App?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Немає доступних версій для {gameVersion} та {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Платформа: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Помилка: платформи не знайдено"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} доступне лише для {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} не підтримує {platform} для {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Пошук версій гри…"
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Пошук версій гри…"
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Виберіть версію гри"
|
||||
},
|
||||
|
||||
@@ -3065,45 +3065,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "Giấy phép"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "Phiên bản game: {version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "Lỗi: không tìm thấy phiên bản game"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} chỉ khả dụng cho {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} không hỗ trợ {gameVersion} cho {platform}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "Cài đặt bằng Ứng dụng Modrinth"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "Chưa có Ứng dụng Modrinth?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "Không có phiên bản khả dụng cho {gameVersion} và {platform}."
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "Nền tảng: {platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "Lỗi: không tìm thấy nền tảng"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} chỉ khả dụng cho {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} không hỗ trợ {platform} cho {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "Tìm kiếm phiên bản game..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "Tìm kiếm phiên bản game..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "Chọn phiên bản game"
|
||||
},
|
||||
|
||||
@@ -3215,45 +3215,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "许可证"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "游戏版本:{version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "错误:没有合适的游戏版本"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title}仅适用于{version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} 不支持 {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "使用 Modrinth App 安装"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "还没有 Modrinth App 吗?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "没有支持 {platform} {gameVersion} 的版本。"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "平台:{platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "错误:没有合适的平台"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} 仅适用于 {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} 不支持 {gameVersion} 的 {platform}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "搜索游戏版本…"
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "搜索游戏版本…"
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "选择游戏版本"
|
||||
},
|
||||
|
||||
@@ -3221,45 +3221,21 @@
|
||||
"project.details.licensed": {
|
||||
"message": "授權條款"
|
||||
},
|
||||
"project.download.game-version": {
|
||||
"message": "遊戲版本:{version}"
|
||||
},
|
||||
"project.download.game-version-error": {
|
||||
"message": "錯誤:找不到適合的遊戲版本"
|
||||
},
|
||||
"project.download.game-version-tooltip": {
|
||||
"message": "{title} 僅適用於 {version}"
|
||||
},
|
||||
"project.download.game-version-unsupported-tooltip": {
|
||||
"message": "{title} 不支援 {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.install-with-app": {
|
||||
"message": "使用 Modrinth App 安裝"
|
||||
},
|
||||
"project.download.no-app": {
|
||||
"message": "還沒有 Modrinth App 嗎?"
|
||||
},
|
||||
"project.download.no-versions-available": {
|
||||
"message": "{platform} {gameVersion} 沒有可用的版本。"
|
||||
},
|
||||
"project.download.platform": {
|
||||
"message": "平臺:{platform}"
|
||||
},
|
||||
"project.download.platform-error": {
|
||||
"message": "錯誤:找不到平臺"
|
||||
},
|
||||
"project.download.platform-tooltip": {
|
||||
"message": "{title} 僅適用於 {platform}"
|
||||
},
|
||||
"project.download.platform-unsupported-tooltip": {
|
||||
"message": "{title} 不支援 {platform} {gameVersion}"
|
||||
},
|
||||
"project.download.search-game-versions": {
|
||||
"message": "搜尋遊戲版本..."
|
||||
},
|
||||
"project.download.search-game-versions-label": {
|
||||
"message": "搜尋遊戲版本..."
|
||||
},
|
||||
"project.download.select-game-version": {
|
||||
"message": "搜尋遊戲版本"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { projectQueryOptions } from '~/composables/queries/project'
|
||||
import { useAppQueryClient } from '~/composables/query-client'
|
||||
import { createModrinthClient } from '~/helpers/api.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import { useServerModrinthClient } from '~/server/utils/api-client'
|
||||
|
||||
@@ -18,9 +19,6 @@ const PROJECT_TYPES = [
|
||||
]
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Only run this middleware on the server - it relies on server-only runtime config
|
||||
if (import.meta.client) return
|
||||
|
||||
const routeProjectParam = to.params.project
|
||||
const projectId = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
|
||||
const routeType = Array.isArray(to.params.type) ? to.params.type[0] : to.params.type
|
||||
@@ -31,10 +29,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
|
||||
const queryClient = useAppQueryClient()
|
||||
const authToken = useCookie('auth-token')
|
||||
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
|
||||
const client = await getProjectMiddlewareClient()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
if (import.meta.client) startLoading()
|
||||
|
||||
try {
|
||||
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
|
||||
const [project, projectV3] = await Promise.all([
|
||||
@@ -48,9 +47,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Cache by slug if we looked up by ID (or vice versa)
|
||||
if (projectId !== project.slug) {
|
||||
queryClient.setQueryData(['project', 'v2', project.slug], project)
|
||||
queryClient.setQueryData(['project', 'v3', project.slug], projectV3)
|
||||
}
|
||||
if (projectId !== project.id) {
|
||||
queryClient.setQueryData(['project', 'v2', project.id], project)
|
||||
queryClient.setQueryData(['project', 'v3', project.id], projectV3)
|
||||
}
|
||||
|
||||
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
|
||||
@@ -81,5 +82,23 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
} catch {
|
||||
// Let the page handle 404s and other errors
|
||||
} finally {
|
||||
if (import.meta.client) stopLoading()
|
||||
}
|
||||
})
|
||||
|
||||
async function getProjectMiddlewareClient() {
|
||||
if (import.meta.server) {
|
||||
const authToken = useCookie('auth-token')
|
||||
return useServerModrinthClient({ authToken: authToken.value || undefined })
|
||||
}
|
||||
|
||||
const auth = await useAuth()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
return createModrinthClient(auth, {
|
||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,339 +88,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewModal
|
||||
<ProjectDownloadModal
|
||||
ref="downloadModal"
|
||||
:on-show="
|
||||
() => {
|
||||
debug('on-show fired')
|
||||
loadVersions()
|
||||
navigateTo({ query: route.query, hash: '#download' }, { replace: true })
|
||||
}
|
||||
"
|
||||
:on-hide="
|
||||
() => {
|
||||
navigateTo({ query: route.query, hash: '' }, { replace: true })
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||
<div class="truncate text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.downloadTitle, { title: project.title }) }}
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="mx-auto flex max-w-[44rem] flex-col gap-4 md:w-[30rem]">
|
||||
<div
|
||||
v-if="
|
||||
project.project_type !== 'plugin' ||
|
||||
project.loaders.some((x) => !tags.loaderData.allPluginLoaders.includes(x))
|
||||
"
|
||||
class="modrinth-app-section contents"
|
||||
>
|
||||
<div class="mx-auto flex w-fit flex-col">
|
||||
<ButtonStyled color="brand">
|
||||
<a
|
||||
class="w-fit"
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="() => installWithApp()"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.installWithModrinthApp) }}
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<Accordion ref="getModrinthAppAccordion">
|
||||
<nuxt-link
|
||||
class="mt-2 flex justify-center text-brand-blue hover:underline"
|
||||
to="/app"
|
||||
>
|
||||
{{ formatMessage(messages.dontHaveModrinthApp) }}
|
||||
</nuxt-link>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 px-4">
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
<span class="flex-shrink-0 text-sm font-semibold text-secondary">
|
||||
{{ formatMessage(commonMessages.orLabel) }}
|
||||
</span>
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto flex w-fit flex-col gap-2">
|
||||
<ButtonStyled v-if="project.game_versions.length === 1">
|
||||
<div class="disabled button-like">
|
||||
<GameIcon aria-hidden="true" />
|
||||
{{
|
||||
currentGameVersion
|
||||
? formatMessage(messages.gameVersionLabel, { version: currentGameVersion })
|
||||
: formatMessage(messages.gameVersionError)
|
||||
}}
|
||||
<InfoIcon
|
||||
v-tooltip="
|
||||
formatMessage(messages.gameVersionTooltip, {
|
||||
title: project.title,
|
||||
version: currentGameVersion,
|
||||
})
|
||||
"
|
||||
class="ml-auto size-5"
|
||||
/>
|
||||
</div>
|
||||
</ButtonStyled>
|
||||
<Accordion
|
||||
v-else
|
||||
ref="gameVersionAccordion"
|
||||
class="accordion-with-bg"
|
||||
@on-open="
|
||||
() => {
|
||||
if (platformAccordion) {
|
||||
platformAccordion.close()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<GameIcon aria-hidden="true" />
|
||||
{{
|
||||
currentGameVersion
|
||||
? formatMessage(messages.gameVersionLabel, { version: currentGameVersion })
|
||||
: formatMessage(messages.selectGameVersion)
|
||||
}}
|
||||
</template>
|
||||
<label for="game-versions-filtering" hidden>{{
|
||||
formatMessage(messages.searchGameVersionsLabel)
|
||||
}}</label>
|
||||
<StyledInput
|
||||
id="game-versions-filtering"
|
||||
ref="gameVersionFilterInput"
|
||||
v-model="versionFilter"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchGameVersions)"
|
||||
wrapper-class="mb-2 w-full"
|
||||
/>
|
||||
<ScrollablePanel :class="project.game_versions.length > 4 ? 'h-[15rem]' : ''">
|
||||
<ButtonStyled
|
||||
v-for="gameVersion in project.game_versions
|
||||
.filter(
|
||||
(x) =>
|
||||
(versionFilter && x.includes(versionFilter)) ||
|
||||
(!versionFilter && (showAllVersions || isReleaseGameVersion(x))),
|
||||
)
|
||||
.slice()
|
||||
.reverse()"
|
||||
:key="gameVersion"
|
||||
:color="currentGameVersion === gameVersion ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!possibleGameVersions.includes(gameVersion)
|
||||
? formatMessage(messages.gameVersionUnsupportedTooltip, {
|
||||
title: project.title,
|
||||
gameVersion: gameVersion,
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
: null
|
||||
"
|
||||
:class="{
|
||||
'looks-disabled !text-brand-red':
|
||||
!possibleGameVersions.includes(gameVersion),
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
userSelectedGameVersion = gameVersion
|
||||
gameVersionAccordion.close()
|
||||
if (!currentPlatform && platformAccordion) {
|
||||
platformAccordion.open()
|
||||
}
|
||||
|
||||
navigateTo(
|
||||
{
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && {
|
||||
version: userSelectedGameVersion,
|
||||
}),
|
||||
...(userSelectedPlatform && {
|
||||
loader: userSelectedPlatform,
|
||||
}),
|
||||
},
|
||||
hash: route.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ gameVersion }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ScrollablePanel>
|
||||
<Checkbox
|
||||
v-if="showVersionsCheckbox"
|
||||
v-model="showAllVersions"
|
||||
class="mx-1"
|
||||
:label="formatMessage(messages.showAllVersions)"
|
||||
:disabled="!!versionFilter"
|
||||
/>
|
||||
</Accordion>
|
||||
<ButtonStyled
|
||||
v-if="project.loaders.length === 1 && project.project_type !== 'resourcepack'"
|
||||
>
|
||||
<div class="disabled button-like">
|
||||
<WrenchIcon aria-hidden="true" />
|
||||
{{
|
||||
currentPlatform
|
||||
? formatMessage(messages.platformLabel, {
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
: formatMessage(messages.platformError)
|
||||
}}
|
||||
<InfoIcon
|
||||
v-tooltip="
|
||||
formatMessage(messages.platformTooltip, {
|
||||
title: project.title,
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
"
|
||||
class="ml-auto size-5"
|
||||
/>
|
||||
</div>
|
||||
</ButtonStyled>
|
||||
<Accordion
|
||||
v-else-if="project.project_type !== 'resourcepack'"
|
||||
ref="platformAccordion"
|
||||
class="accordion-with-bg"
|
||||
@on-open="
|
||||
() => {
|
||||
if (gameVersionAccordion) {
|
||||
gameVersionAccordion.close()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<WrenchIcon aria-hidden="true" />
|
||||
{{
|
||||
currentPlatform
|
||||
? formatMessage(messages.platformLabel, {
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
: formatMessage(messages.selectPlatform)
|
||||
}}
|
||||
</template>
|
||||
<ScrollablePanel :class="project.loaders.length > 4 ? 'h-[15rem]' : ''">
|
||||
<ButtonStyled
|
||||
v-for="platform in project.loaders.slice().reverse()"
|
||||
:key="platform"
|
||||
:color="currentPlatform === platform ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!possiblePlatforms.includes(platform)
|
||||
? formatMessage(messages.platformUnsupportedTooltip, {
|
||||
title: project.title,
|
||||
platform: currentPlatformText,
|
||||
gameVersion: currentGameVersion,
|
||||
})
|
||||
: null
|
||||
"
|
||||
:class="{
|
||||
'looks-disabled !text-brand-red': !possiblePlatforms.includes(platform),
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
userSelectedPlatform = platform
|
||||
|
||||
platformAccordion.close()
|
||||
if (!currentGameVersion && gameVersionAccordion) {
|
||||
gameVersionAccordion.open()
|
||||
}
|
||||
|
||||
navigateTo(
|
||||
{
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && {
|
||||
version: userSelectedGameVersion,
|
||||
}),
|
||||
...(userSelectedPlatform && {
|
||||
loader: userSelectedPlatform,
|
||||
}),
|
||||
},
|
||||
hash: route.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ formatMessage(getTagMessage(platform, 'loader')) }}
|
||||
<CheckIcon v-if="userSelectedPlatform === platform" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ScrollablePanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
<AutomaticAccordion div class="flex flex-col gap-2">
|
||||
<VersionSummary
|
||||
v-if="filteredRelease"
|
||||
:version="filteredRelease"
|
||||
:decorate-download-url="decorateModalDownloadUrl"
|
||||
@on-download="onDownload"
|
||||
@on-navigate="onVersionNavigate"
|
||||
/>
|
||||
<VersionSummary
|
||||
v-if="filteredBeta"
|
||||
:version="filteredBeta"
|
||||
:decorate-download-url="decorateModalDownloadUrl"
|
||||
@on-download="onDownload"
|
||||
@on-navigate="onVersionNavigate"
|
||||
/>
|
||||
<VersionSummary
|
||||
v-if="filteredAlpha"
|
||||
:version="filteredAlpha"
|
||||
:decorate-download-url="decorateModalDownloadUrl"
|
||||
@on-download="onDownload"
|
||||
@on-navigate="onVersionNavigate"
|
||||
/>
|
||||
<p
|
||||
v-if="
|
||||
currentPlatform &&
|
||||
currentGameVersion &&
|
||||
!filteredRelease &&
|
||||
!filteredBeta &&
|
||||
!filteredAlpha &&
|
||||
!versionsLoading &&
|
||||
versions.length > 0
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.noVersionsAvailable, {
|
||||
gameVersion: currentGameVersion,
|
||||
platform: currentPlatformText,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</AutomaticAccordion>
|
||||
<ServersPromo
|
||||
v-if="flags.showProjectPageDownloadModalServersPromo"
|
||||
:link="`/hosting#plan`"
|
||||
@close="
|
||||
() => {
|
||||
flags.showProjectPageDownloadModalServersPromo = false
|
||||
saveFeatureFlags()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
:project-id="routeProjectId"
|
||||
:download-reason="downloadReason"
|
||||
@download="triggerDownloadAnimation"
|
||||
/>
|
||||
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
|
||||
<ModpackScanModal ref="scanModal" :project_id="project.id" />
|
||||
|
||||
<div
|
||||
class="new-page sidebar"
|
||||
:class="{
|
||||
@@ -750,13 +426,6 @@
|
||||
tags.staffRoles.includes(auth.user.role) &&
|
||||
!showModerationChecklist,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown:
|
||||
auth.user &&
|
||||
tags.staffRoles.includes(auth.user.role) &&
|
||||
!showModerationChecklist,
|
||||
},
|
||||
{
|
||||
id: 'tech-review',
|
||||
link: `/moderation/technical-review/${project.id}`,
|
||||
@@ -764,6 +433,16 @@
|
||||
hoverOnly: true,
|
||||
shown: auth.user && tags.staffRoles.includes(auth.user.role),
|
||||
},
|
||||
{
|
||||
id: 'moderation-modpack-rescan',
|
||||
action: () => scanModal.show(),
|
||||
color: 'orange',
|
||||
hoverOnly: true,
|
||||
shown:
|
||||
auth.user &&
|
||||
tags.staffRoles.includes(auth.user.role) &&
|
||||
project.actualProjectType === 'modpack',
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: auth.user && tags.staffRoles.includes(auth.user.role),
|
||||
@@ -795,6 +474,9 @@
|
||||
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }}
|
||||
</template>
|
||||
<template #tech-review> <ScanEyeIcon aria-hidden="true" /> Tech review </template>
|
||||
<template #moderation-modpack-rescan>
|
||||
<FolderSearchIcon aria-hidden="true" /> Rescan modpack
|
||||
</template>
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.reportButton) }}
|
||||
@@ -1048,27 +730,22 @@ import {
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
ChartIcon,
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
GameIcon,
|
||||
FolderSearchIcon,
|
||||
HeartIcon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ModrinthIcon,
|
||||
MoreVerticalIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
ScanEyeIcon,
|
||||
SearchIcon,
|
||||
ServerPlusIcon,
|
||||
SettingsIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -1078,7 +755,6 @@ import {
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
getTagMessage,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
@@ -1098,8 +774,6 @@ import {
|
||||
ProjectSidebarServerInfo,
|
||||
ProjectSidebarTags,
|
||||
provideProjectPageContext,
|
||||
ScrollablePanel,
|
||||
ServersPromo,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
useDebugLogger,
|
||||
@@ -1108,28 +782,27 @@ import {
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import VersionSummary from '@modrinth/ui/src/components/version/VersionSummary.vue'
|
||||
import { capitalizeString, formatProjectType, renderString } from '@modrinth/utils'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
|
||||
import { onScopeDispose, readonly, ref, useTemplateRef, watch, watchEffect } from 'vue'
|
||||
|
||||
import { navigateTo } from '#app'
|
||||
import Accordion from '~/components/ui/Accordion.vue'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import AutomaticAccordion from '~/components/ui/AutomaticAccordion.vue'
|
||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
|
||||
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
||||
import ModpackScanModal from '~/components/ui/moderation/ModpackScanModal.vue'
|
||||
import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue'
|
||||
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||
import { versionQueryOptions } from '~/composables/queries/version'
|
||||
import { userCollectProject, userFollowProject } from '~/composables/user.js'
|
||||
import { injectCurrentProjectId } from '~/providers/current-project.ts'
|
||||
import {
|
||||
loadChecklistOpenState,
|
||||
saveChecklistOpenState,
|
||||
@@ -1189,14 +862,7 @@ const debug = useDebugLogger('DownloadModal')
|
||||
const downloadModal = ref()
|
||||
const openInAppModal = ref()
|
||||
const overTheTopDownloadAnimation = ref()
|
||||
|
||||
const userSelectedGameVersion = ref(null)
|
||||
const userSelectedPlatform = ref(null)
|
||||
const showAllVersions = ref(false)
|
||||
|
||||
const gameVersionFilterInput = ref()
|
||||
|
||||
const versionFilter = ref('')
|
||||
const scanModal = ref()
|
||||
|
||||
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
@@ -1205,80 +871,6 @@ const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
|
||||
|
||||
const baseId = useId()
|
||||
|
||||
const currentGameVersion = computed(() => {
|
||||
if (!project.value) return null
|
||||
return (
|
||||
userSelectedGameVersion.value ||
|
||||
(project.value.game_versions.length === 1 && project.value.game_versions[0])
|
||||
)
|
||||
})
|
||||
|
||||
const possibleGameVersions = computed(() => {
|
||||
return versions.value
|
||||
.filter((x) => !currentPlatform.value || x.loaders.includes(currentPlatform.value))
|
||||
.flatMap((x) => x.game_versions)
|
||||
})
|
||||
|
||||
const possiblePlatforms = computed(() => {
|
||||
return versions.value
|
||||
.filter((x) => !currentGameVersion.value || x.game_versions.includes(currentGameVersion.value))
|
||||
.flatMap((x) => x.loaders)
|
||||
})
|
||||
|
||||
const currentPlatform = computed(() => {
|
||||
if (!project.value) return null
|
||||
return (
|
||||
userSelectedPlatform.value || (project.value.loaders.length === 1 && project.value.loaders[0])
|
||||
)
|
||||
})
|
||||
|
||||
const currentPlatformText = computed(() => {
|
||||
if (!currentPlatform.value) return null
|
||||
return formatMessage(getTagMessage(currentPlatform.value, 'loader'))
|
||||
})
|
||||
|
||||
function decorateModalDownloadUrl(url) {
|
||||
return createProjectDownloadUrl(url, {
|
||||
reason: downloadReason.value,
|
||||
gameVersion: currentGameVersion.value ?? undefined,
|
||||
loader: currentPlatform.value ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const releaseVersions = computed(() => {
|
||||
const set = new Set()
|
||||
for (const gv of tags.value.gameVersions || []) {
|
||||
if (gv?.version && gv.version_type === 'release') set.add(gv.version)
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
const nonReleaseVersions = computed(() => {
|
||||
const set = new Set()
|
||||
for (const gv of tags.value.gameVersions || []) {
|
||||
if (gv?.version && gv.version_type !== 'release') set.add(gv.version)
|
||||
}
|
||||
return set
|
||||
})
|
||||
|
||||
function isReleaseGameVersion(ver) {
|
||||
if (releaseVersions.value.has(ver)) return true
|
||||
if (nonReleaseVersions.value.has(ver)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const showVersionsCheckbox = computed(() => {
|
||||
const list = project.value?.game_versions || []
|
||||
let hasRelease = false
|
||||
let hasNonRelease = false
|
||||
for (const v of list) {
|
||||
if (isReleaseGameVersion(v)) hasRelease = true
|
||||
else hasNonRelease = true
|
||||
if (hasRelease && hasNonRelease) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const serverProject = computed(() => ({
|
||||
name: project.value.title,
|
||||
slug: project.value.slug || project.value.id,
|
||||
@@ -1294,16 +886,6 @@ function handlePlayServerProject() {
|
||||
})
|
||||
}
|
||||
|
||||
function installWithApp() {
|
||||
setTimeout(() => {
|
||||
getModrinthAppAccordion.value.open()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const gameVersionAccordion = ref()
|
||||
const platformAccordion = ref()
|
||||
const getModrinthAppAccordion = ref()
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const detailsMessages = defineMessages({
|
||||
@@ -1359,18 +941,10 @@ const messages = defineMessages({
|
||||
id: 'project.description.title',
|
||||
defaultMessage: 'Description',
|
||||
},
|
||||
dontHaveModrinthApp: {
|
||||
id: 'project.download.no-app',
|
||||
defaultMessage: "Don't have Modrinth App?",
|
||||
},
|
||||
dontShowAgain: {
|
||||
id: 'project.actions.dont-show-again',
|
||||
defaultMessage: "Don't show again",
|
||||
},
|
||||
downloadTitle: {
|
||||
id: 'project.download.title',
|
||||
defaultMessage: 'Download {title}',
|
||||
},
|
||||
downloadsStat: {
|
||||
id: 'project.stats.downloads-label',
|
||||
defaultMessage: '{count, plural, one {download} other {downloads}}',
|
||||
@@ -1409,26 +983,6 @@ const messages = defineMessages({
|
||||
id: 'project.gallery.title',
|
||||
defaultMessage: 'Gallery',
|
||||
},
|
||||
gameVersionError: {
|
||||
id: 'project.download.game-version-error',
|
||||
defaultMessage: 'Error: no game versions found',
|
||||
},
|
||||
gameVersionLabel: {
|
||||
id: 'project.download.game-version',
|
||||
defaultMessage: 'Game version: {version}',
|
||||
},
|
||||
gameVersionTooltip: {
|
||||
id: 'project.download.game-version-tooltip',
|
||||
defaultMessage: '{title} is only available for {version}',
|
||||
},
|
||||
gameVersionUnsupportedTooltip: {
|
||||
id: 'project.download.game-version-unsupported-tooltip',
|
||||
defaultMessage: '{title} does not support {gameVersion} for {platform}',
|
||||
},
|
||||
installWithModrinthApp: {
|
||||
id: 'project.download.install-with-app',
|
||||
defaultMessage: 'Install with Modrinth App',
|
||||
},
|
||||
licenseErrorMessage: {
|
||||
id: 'project.license.error',
|
||||
defaultMessage: 'License text could not be retrieved.',
|
||||
@@ -1453,30 +1007,10 @@ const messages = defineMessages({
|
||||
id: 'project.collections.none-found',
|
||||
defaultMessage: 'No collections found.',
|
||||
},
|
||||
noVersionsAvailable: {
|
||||
id: 'project.download.no-versions-available',
|
||||
defaultMessage: 'No versions available for {gameVersion} and {platform}.',
|
||||
},
|
||||
pageNotFound: {
|
||||
id: 'project.error.page-not-found',
|
||||
defaultMessage: 'The page could not be found',
|
||||
},
|
||||
platformError: {
|
||||
id: 'project.download.platform-error',
|
||||
defaultMessage: 'Error: no platforms found',
|
||||
},
|
||||
platformLabel: {
|
||||
id: 'project.download.platform',
|
||||
defaultMessage: 'Platform: {platform}',
|
||||
},
|
||||
platformTooltip: {
|
||||
id: 'project.download.platform-tooltip',
|
||||
defaultMessage: '{title} is only available for {platform}',
|
||||
},
|
||||
platformUnsupportedTooltip: {
|
||||
id: 'project.download.platform-unsupported-tooltip',
|
||||
defaultMessage: '{title} does not support {platform} for {gameVersion}',
|
||||
},
|
||||
projectIconUpdated: {
|
||||
id: 'project.notification.icon-updated.title',
|
||||
defaultMessage: 'Project icon updated',
|
||||
@@ -1505,22 +1039,6 @@ const messages = defineMessages({
|
||||
id: 'project.actions.review-project',
|
||||
defaultMessage: 'Review project',
|
||||
},
|
||||
searchGameVersions: {
|
||||
id: 'project.download.search-game-versions',
|
||||
defaultMessage: 'Search game versions...',
|
||||
},
|
||||
searchGameVersionsLabel: {
|
||||
id: 'project.download.search-game-versions-label',
|
||||
defaultMessage: 'Search game versions...',
|
||||
},
|
||||
selectGameVersion: {
|
||||
id: 'project.download.select-game-version',
|
||||
defaultMessage: 'Select game version',
|
||||
},
|
||||
selectPlatform: {
|
||||
id: 'project.download.select-platform',
|
||||
defaultMessage: 'Select platform',
|
||||
},
|
||||
serversPromoDescription: {
|
||||
id: 'project.actions.servers-promo.description',
|
||||
defaultMessage: 'Modrinth Hosting is the easiest way to play with your friends without hassle!',
|
||||
@@ -1537,10 +1055,6 @@ const messages = defineMessages({
|
||||
id: 'project.settings.title',
|
||||
defaultMessage: 'Settings',
|
||||
},
|
||||
showAllVersions: {
|
||||
id: 'project.download.show-all-versions',
|
||||
defaultMessage: 'Show all versions',
|
||||
},
|
||||
versionsTab: {
|
||||
id: 'project.versions.title',
|
||||
defaultMessage: 'Versions',
|
||||
@@ -1590,48 +1104,6 @@ async function getLicenseData(event) {
|
||||
}
|
||||
}
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
const result = versions.value.filter(
|
||||
(x) =>
|
||||
x.game_versions?.includes(currentGameVersion.value) &&
|
||||
(x.loaders?.includes(currentPlatform.value) || project.value.project_type === 'resourcepack'),
|
||||
)
|
||||
debug('filteredVersions', {
|
||||
total: versions.value.length,
|
||||
filtered: result.length,
|
||||
currentGameVersion: currentGameVersion.value,
|
||||
currentPlatform: currentPlatform.value,
|
||||
versionsEnabled: versionsEnabled.value,
|
||||
versionsLoading: versionsV3Loading.value,
|
||||
sampleLoaders: versions.value.slice(0, 3).map((v) => v.loaders),
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const filteredRelease = computed(() => {
|
||||
return filteredVersions.value.find((x) => x.version_type === 'release')
|
||||
})
|
||||
|
||||
const filteredBeta = computed(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'beta' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))),
|
||||
)
|
||||
})
|
||||
|
||||
const filteredAlpha = computed(() => {
|
||||
return filteredVersions.value.find(
|
||||
(x) =>
|
||||
x.version_type === 'alpha' &&
|
||||
(!filteredRelease.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredRelease.value.date_published))) &&
|
||||
(!filteredBeta.value ||
|
||||
dayjs(x.date_published).isAfter(dayjs(filteredBeta.value.date_published))),
|
||||
)
|
||||
})
|
||||
|
||||
const displayCollectionsSearch = ref('')
|
||||
const collections = computed(() =>
|
||||
user.value && user.value.collections
|
||||
@@ -1705,6 +1177,16 @@ const project = computed(() => {
|
||||
// Use actual project ID for dependent queries (ensures cache consistency)
|
||||
const projectId = computed(() => projectRaw.value?.id)
|
||||
|
||||
const sharedProjectId = injectCurrentProjectId(null)
|
||||
if (sharedProjectId) {
|
||||
watchEffect(() => {
|
||||
sharedProjectId.value = projectId.value ?? undefined
|
||||
})
|
||||
onScopeDispose(() => {
|
||||
sharedProjectId.value = undefined
|
||||
})
|
||||
}
|
||||
|
||||
// V3 Project
|
||||
const {
|
||||
data: projectV3,
|
||||
@@ -2396,40 +1878,6 @@ if (!route.name.startsWith('type-project-settings')) {
|
||||
|
||||
const onUserCollectProject = useClientTry(userCollectProject)
|
||||
|
||||
const { version, loader } = route.query
|
||||
|
||||
if (
|
||||
project.value &&
|
||||
project.value.game_versions.length > 0 &&
|
||||
project.value.game_versions.every((v) => !isReleaseGameVersion(v))
|
||||
) {
|
||||
showAllVersions.value = true
|
||||
}
|
||||
|
||||
if (project.value && version !== undefined && project.value.game_versions.includes(version)) {
|
||||
userSelectedGameVersion.value = version
|
||||
}
|
||||
|
||||
if (project.value && loader !== undefined && project.value.loaders.includes(loader)) {
|
||||
userSelectedPlatform.value = loader
|
||||
}
|
||||
|
||||
if (route.hash === '#download' || version !== undefined || loader !== undefined) {
|
||||
debug('eager loadVersions from setup', { hash: route.hash, version, loader })
|
||||
loadVersions()
|
||||
}
|
||||
|
||||
watch(downloadModal, (modal) => {
|
||||
if (!modal) return
|
||||
|
||||
// route.hash returns everything in the hash string, including the # itself
|
||||
if (route.hash === '#download') {
|
||||
debug('hash #download watch fired, opening modal')
|
||||
loadVersions()
|
||||
modal.show()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[versionsV3, _versionsV3Error],
|
||||
([data, error]) => {
|
||||
@@ -2650,32 +2098,11 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function closeDownloadModal(event) {
|
||||
downloadModal.value.hide(event)
|
||||
userSelectedPlatform.value = null
|
||||
userSelectedGameVersion.value = null
|
||||
showAllVersions.value = false
|
||||
}
|
||||
|
||||
function triggerDownloadAnimation() {
|
||||
overTheTopDownloadAnimation.value = true
|
||||
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
|
||||
}
|
||||
|
||||
function onDownload(event) {
|
||||
triggerDownloadAnimation()
|
||||
setTimeout(() => {
|
||||
closeDownloadModal(event)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
function onVersionNavigate(url) {
|
||||
closeDownloadModal()
|
||||
nextTick(() => {
|
||||
navigateTo(url)
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteVersion(id) {
|
||||
if (!id) return
|
||||
|
||||
@@ -2831,11 +2258,6 @@ provideProjectPageContext({
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.accordion-with-bg) {
|
||||
@apply rounded-2xl bg-bg p-2;
|
||||
--scrollable-pane-bg: var(--color-bg);
|
||||
}
|
||||
|
||||
.over-the-top-download-animation {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
@@ -2886,12 +2308,6 @@ provideProjectPageContext({
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (max-width: 767px) {
|
||||
.modrinth-app-section {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.servers-popup {
|
||||
box-shadow:
|
||||
0 0 12px 1px rgba(0, 175, 92, 0.6),
|
||||
|
||||
@@ -80,6 +80,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<ProjectDownloadModal
|
||||
ref="dependencyDownloadModal"
|
||||
download-reason="dependency"
|
||||
:use-route-hash="false"
|
||||
:update-route-selection="false"
|
||||
@download="emit('onDownload')"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<nuxt-link
|
||||
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
|
||||
@@ -311,11 +318,25 @@
|
||||
<template #dependencyActions="{ dependency }">
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link
|
||||
v-if="createDependencyLink(dependency)"
|
||||
v-if="
|
||||
createDependencyLink({
|
||||
project: dependency.project,
|
||||
version: dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
})
|
||||
"
|
||||
v-tooltip="
|
||||
formatMessage(dependency.version ? messages.viewVersion : messages.viewProject)
|
||||
formatMessage(
|
||||
(dependency.version ?? getDependencyVersion(dependency.dependency))
|
||||
? messages.viewVersion
|
||||
: messages.viewProject,
|
||||
)
|
||||
"
|
||||
:to="
|
||||
createDependencyLink({
|
||||
project: dependency.project,
|
||||
version: dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
})
|
||||
"
|
||||
:to="createDependencyLink(dependency)"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon />
|
||||
@@ -324,37 +345,40 @@
|
||||
<ButtonStyled circular color="brand" color-fill="text">
|
||||
<a
|
||||
v-if="
|
||||
dependency.version && dependency.dependency.dependency_type !== 'incompatible'
|
||||
(dependency.version ?? getDependencyVersion(dependency.dependency)) &&
|
||||
dependency.dependency.dependency_type !== 'incompatible'
|
||||
"
|
||||
v-tooltip="
|
||||
dependencyVersionPrimaryFiles[dependency.version.id]
|
||||
? dependencyVersionPrimaryFiles[dependency.version.id].filename +
|
||||
' (' +
|
||||
formatBytes(dependencyVersionPrimaryFiles[dependency.version.id].size) +
|
||||
')'
|
||||
: formatMessage(messages.noPrimaryFile)
|
||||
"
|
||||
:href="
|
||||
createProjectDownloadUrl(
|
||||
dependencyVersionPrimaryFiles[dependency.version.id].url,
|
||||
{
|
||||
reason: 'dependency',
|
||||
},
|
||||
getDependencyPrimaryFileTooltip(
|
||||
dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
)
|
||||
"
|
||||
:download="dependencyVersionPrimaryFiles[dependency.version.id].filename"
|
||||
:disabled="dependencyVersionPrimaryFiles[dependency.version.id].url === undefined"
|
||||
:href="
|
||||
getDependencyDownloadUrl(
|
||||
dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
)
|
||||
"
|
||||
:download="
|
||||
getDependencyPrimaryFile(
|
||||
dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
)?.filename
|
||||
"
|
||||
:disabled="
|
||||
!getDependencyPrimaryFile(
|
||||
dependency.version ?? getDependencyVersion(dependency.dependency),
|
||||
)?.url
|
||||
"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
<a
|
||||
<button
|
||||
v-else-if="dependency.project"
|
||||
v-tooltip="formatMessage(messages.downloadProject)"
|
||||
:href="`/project/${dependency.project.id}#download`"
|
||||
target="_blank"
|
||||
:aria-label="formatMessage(messages.downloadProject)"
|
||||
@click="openDependencyDownloadModal(dependency.project, $event)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</VersionPage>
|
||||
@@ -502,6 +526,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { onServerPrefetch } from 'vue'
|
||||
|
||||
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
|
||||
import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { projectQueryOptions, STALE_TIME } from '~/composables/queries/project'
|
||||
import { versionQueryOptions } from '~/composables/queries/version'
|
||||
@@ -658,6 +683,80 @@ watch(
|
||||
|
||||
const enrichment = computed(() => contextDependencies.value ?? undefined)
|
||||
|
||||
const projectOnlyDependencyProjectIds = computed(() => {
|
||||
const projectIds = new Set<string>()
|
||||
for (const dependency of version.value?.dependencies ?? []) {
|
||||
if (
|
||||
dependency.project_id &&
|
||||
!dependency.version_id &&
|
||||
dependency.dependency_type !== 'incompatible'
|
||||
) {
|
||||
projectIds.add(dependency.project_id)
|
||||
}
|
||||
}
|
||||
return [...projectIds]
|
||||
})
|
||||
|
||||
const dependencyResolutionLoaders = computed(() => {
|
||||
if (!version.value) return []
|
||||
if (version.value.loaders.includes('mrpack')) {
|
||||
return (version.value.mrpack_loaders ?? []).filter((loader) => loader !== 'minecraft')
|
||||
}
|
||||
return version.value.loaders
|
||||
})
|
||||
|
||||
const { data: projectOnlyDependencyVersions } = useQuery({
|
||||
queryKey: computed(
|
||||
() =>
|
||||
[
|
||||
'version-page',
|
||||
'project-only-dependency-versions',
|
||||
version.value?.id,
|
||||
version.value?.game_versions ?? [],
|
||||
dependencyResolutionLoaders.value,
|
||||
projectOnlyDependencyProjectIds.value,
|
||||
] as const,
|
||||
),
|
||||
queryFn: async () => {
|
||||
const currentVersion = version.value!
|
||||
const entries = await Promise.all(
|
||||
projectOnlyDependencyProjectIds.value.map(async (projectId) => {
|
||||
let versions: Labrinth.Versions.v2.Version[] = []
|
||||
try {
|
||||
versions = await client.labrinth.versions_v2.getProjectVersions(projectId, {
|
||||
game_versions: currentVersion.game_versions,
|
||||
loaders: dependencyResolutionLoaders.value,
|
||||
include_changelog: false,
|
||||
limit: 100,
|
||||
})
|
||||
} catch {
|
||||
return [projectId, undefined] as const
|
||||
}
|
||||
|
||||
return [
|
||||
projectId,
|
||||
getOnlyCompatibleDependencyVersion(
|
||||
versions,
|
||||
currentVersion.game_versions,
|
||||
dependencyResolutionLoaders.value,
|
||||
),
|
||||
] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const versionsByProjectId: Record<string, Labrinth.Versions.v2.Version> = {}
|
||||
for (const [projectId, dependencyVersion] of entries) {
|
||||
if (dependencyVersion) {
|
||||
versionsByProjectId[projectId] = dependencyVersion
|
||||
}
|
||||
}
|
||||
|
||||
return versionsByProjectId
|
||||
},
|
||||
enabled: computed(() => !!version.value && projectOnlyDependencyProjectIds.value.length > 0),
|
||||
staleTime: STALE_TIME,
|
||||
})
|
||||
|
||||
const primaryFile = computed(
|
||||
() => version.value?.files?.find((file) => file.primary) ?? version.value?.files?.[0],
|
||||
)
|
||||
@@ -690,6 +789,7 @@ useSeoMeta({
|
||||
const editModal = useTemplateRef('editModal')
|
||||
const confirmModal = useTemplateRef('confirmModal')
|
||||
const packageModal = useTemplateRef('packageModal')
|
||||
const dependencyDownloadModal = useTemplateRef('dependencyDownloadModal')
|
||||
|
||||
const packageLoaders = ref(['forge', 'fabric', 'quilt', 'neoforge'])
|
||||
const packageLoaderOptions = [
|
||||
@@ -712,6 +812,22 @@ function handleOpenEditVersionModal(versionId: string, projectId: string, stageI
|
||||
editModal.value?.openEditVersionModal(versionId, projectId, stageId)
|
||||
}
|
||||
|
||||
function openDependencyDownloadModal(
|
||||
dependencyProject: Labrinth.Projects.v2.Project,
|
||||
event: MouseEvent,
|
||||
) {
|
||||
const baseGameVersions = new Set(version.value?.game_versions ?? [])
|
||||
const baseLoaders = new Set(dependencyResolutionLoaders.value)
|
||||
|
||||
dependencyDownloadModal.value?.show(event, {
|
||||
projectId: dependencyProject.id,
|
||||
incompatibleGameVersions: dependencyProject.game_versions.filter(
|
||||
(gameVersion) => !baseGameVersions.has(gameVersion),
|
||||
),
|
||||
incompatibleLoaders: dependencyProject.loaders.filter((loader) => !baseLoaders.has(loader)),
|
||||
})
|
||||
}
|
||||
|
||||
const deleteVersionMutation = useMutation({
|
||||
mutationFn: () => client.labrinth.versions_v3.deleteVersion(version.value!.id),
|
||||
onSuccess: async () => {
|
||||
@@ -823,6 +939,10 @@ const messages = defineMessages({
|
||||
id: 'version.download.no-primary-file',
|
||||
defaultMessage: 'Error: No primary file found',
|
||||
},
|
||||
downloadVersion: {
|
||||
id: 'version.download.version',
|
||||
defaultMessage: 'Download {version} ({size})',
|
||||
},
|
||||
edit: {
|
||||
id: 'version.edit.button',
|
||||
defaultMessage: 'Edit',
|
||||
@@ -873,7 +993,7 @@ const messages = defineMessages({
|
||||
},
|
||||
downloadProject: {
|
||||
id: 'version.download.download-dependency',
|
||||
defaultMessage: 'Download dependency',
|
||||
defaultMessage: 'Select a version to download',
|
||||
},
|
||||
requiredResourcePack: {
|
||||
id: 'version.download.required-resource-pack',
|
||||
@@ -951,13 +1071,20 @@ const copyFileHash = async (
|
||||
file: Labrinth.Versions.v3.VersionFile,
|
||||
method: Labrinth.Versions.v3.FileHashType,
|
||||
) => {
|
||||
await navigator.clipboard.writeText(file.hashes[method])
|
||||
await copyToClipboard(file.hashes[method])
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const dependencyVersionPrimaryFiles = computed(() => {
|
||||
const versions = enrichment.value?.versions
|
||||
const versions = [
|
||||
...(enrichment.value?.versions ?? []),
|
||||
...Object.values(projectOnlyDependencyVersions.value ?? {}),
|
||||
]
|
||||
const primaryFileMap: Record<string, Labrinth.Versions.v2.VersionFile> = {}
|
||||
versions?.forEach((depVersion) => {
|
||||
versions.forEach((depVersion) => {
|
||||
const depPrimaryFile = depVersion.files.find((file) => file.primary) ?? depVersion.files[0]
|
||||
|
||||
primaryFileMap[depVersion.id] = depPrimaryFile
|
||||
@@ -965,6 +1092,65 @@ const dependencyVersionPrimaryFiles = computed(() => {
|
||||
return primaryFileMap
|
||||
})
|
||||
|
||||
function getDependencyVersion(dependency: Labrinth.Versions.v3.Dependency) {
|
||||
if (dependency.version_id || !dependency.project_id) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return projectOnlyDependencyVersions.value?.[dependency.project_id]
|
||||
}
|
||||
|
||||
function getOnlyCompatibleDependencyVersion(
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
gameVersions: string[],
|
||||
loaders: string[],
|
||||
) {
|
||||
const compatibleVersions = versions
|
||||
.filter(
|
||||
(version) =>
|
||||
version.game_versions.some((gameVersion) => gameVersions.includes(gameVersion)) &&
|
||||
version.loaders.some((loader) => loaders.includes(loader)),
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime())
|
||||
const targetKeys = new Set(
|
||||
compatibleVersions.flatMap((version) =>
|
||||
version.game_versions.flatMap((gameVersion) =>
|
||||
gameVersions.includes(gameVersion)
|
||||
? version.loaders
|
||||
.filter((loader) => loaders.includes(loader))
|
||||
.map((loader) => `${gameVersion}:${loader}`)
|
||||
: [],
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return targetKeys.size === 1 ? compatibleVersions[0] : undefined
|
||||
}
|
||||
|
||||
function getDependencyPrimaryFile(version?: Labrinth.Versions.v2.Version) {
|
||||
return version ? dependencyVersionPrimaryFiles.value[version.id] : undefined
|
||||
}
|
||||
|
||||
function getDependencyPrimaryFileTooltip(version?: Labrinth.Versions.v2.Version) {
|
||||
const dependencyPrimaryFile = getDependencyPrimaryFile(version)
|
||||
return dependencyPrimaryFile
|
||||
? formatMessage(messages.downloadVersion, {
|
||||
version: version?.name || version?.version_number || dependencyPrimaryFile.filename,
|
||||
size: formatBytes(dependencyPrimaryFile.size),
|
||||
})
|
||||
: formatMessage(messages.noPrimaryFile)
|
||||
}
|
||||
|
||||
function getDependencyDownloadUrl(version?: Labrinth.Versions.v2.Version) {
|
||||
const dependencyPrimaryFile = getDependencyPrimaryFile(version)
|
||||
return dependencyPrimaryFile?.url
|
||||
? createProjectDownloadUrl(dependencyPrimaryFile.url, {
|
||||
reason: 'dependency',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
|
||||
function createDependencyLink(context: {
|
||||
project?: Labrinth.Projects.v2.Project
|
||||
version?: Labrinth.Versions.v2.Version
|
||||
|
||||
@@ -143,7 +143,7 @@ onMounted(() => {
|
||||
{{ previewError }}
|
||||
</div>
|
||||
|
||||
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview…</div>
|
||||
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview...</div>
|
||||
<div v-else>
|
||||
<div v-if="previewVariables.length" class="mt-2 grid gap-3 md:grid-cols-2">
|
||||
<label
|
||||
|
||||
@@ -954,5 +954,9 @@ function openEditModal(event) {
|
||||
color: var(--color-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -158,6 +158,9 @@ const {
|
||||
serverContentData,
|
||||
serverFilters,
|
||||
serverHideInstalled,
|
||||
serverContentServerOnly,
|
||||
showServerOnlyToggle,
|
||||
serverEnvironmentOverride,
|
||||
hideSelectedServerInstalls,
|
||||
installingProjectIds,
|
||||
optimisticallyInstalledProjectIds,
|
||||
@@ -369,6 +372,14 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const advancedFiltersCollapsed = computed({
|
||||
get: () => flags.value.advancedFiltersCollapsed,
|
||||
set: (value) => {
|
||||
flags.value.advancedFiltersCollapsed = value
|
||||
saveFeatureFlags()
|
||||
},
|
||||
})
|
||||
|
||||
const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
|
||||
|
||||
debug('projectTypeId:', projectTypeId.value)
|
||||
@@ -378,10 +389,12 @@ const searchState = useBrowseSearch({
|
||||
projectType: projectTypeId,
|
||||
tags,
|
||||
providedFilters: serverFilters,
|
||||
environmentOverride: serverEnvironmentOverride,
|
||||
search,
|
||||
persistentQueryParams: ['sid', 'wid', 'shi', 'from'],
|
||||
persistentQueryParams: ['sid', 'wid', 'shi', 'so', 'from'],
|
||||
getExtraQueryParams: () => ({
|
||||
shi: serverHideInstalled.value ? 'true' : undefined,
|
||||
so: showServerOnlyToggle.value && serverContentServerOnly.value ? 'true' : undefined,
|
||||
}),
|
||||
maxResultsOptions: currentMaxResultsOptions,
|
||||
displayMode: resultsDisplayMode,
|
||||
@@ -469,6 +482,11 @@ provideBrowseManager({
|
||||
queuedServerInstallCount.value > 0,
|
||||
),
|
||||
hideSelectedLabel: computed(() => formatMessage(commonMessages.hideSelectedContentLabel)),
|
||||
serverOnly: serverContentServerOnly,
|
||||
showServerOnly: showServerOnlyToggle,
|
||||
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
|
||||
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
|
||||
advancedFiltersCollapsed,
|
||||
displayMode: resultsDisplayMode,
|
||||
cycleDisplayMode: cycleSearchDisplayMode,
|
||||
maxResultsOptions: currentMaxResultsOptions,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderIcon, GlobeIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
||||
import { FolderIcon, GlobeIcon, HashIcon, ReportIcon, ShieldCheckIcon } from '@modrinth/assets'
|
||||
import { Chips, defineMessages, NavTabs, useVIntl } from '@modrinth/ui'
|
||||
|
||||
definePageMeta({
|
||||
@@ -47,6 +47,10 @@ const messages = defineMessages({
|
||||
id: 'moderation.page.external-projects',
|
||||
defaultMessage: 'External projects',
|
||||
},
|
||||
globalDetailTracesTitle: {
|
||||
id: 'moderation.page.global-detail-traces',
|
||||
defaultMessage: 'Global traces',
|
||||
},
|
||||
})
|
||||
|
||||
const moderationLinks = [
|
||||
@@ -62,6 +66,11 @@ const moderationLinks = [
|
||||
href: '/moderation/external-projects',
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.globalDetailTracesTitle),
|
||||
href: '/moderation/global-traces',
|
||||
icon: HashIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const mobileNavOptions = [
|
||||
@@ -69,15 +78,20 @@ const mobileNavOptions = [
|
||||
formatMessage(messages.technicalReviewTitle),
|
||||
formatMessage(messages.reportsTitle),
|
||||
formatMessage(messages.externalFilesTitle),
|
||||
formatMessage(messages.globalDetailTracesTitle),
|
||||
]
|
||||
|
||||
const selectedChip = computed({
|
||||
get() {
|
||||
const path = route.path
|
||||
if (path === '/moderation/technical-review') {
|
||||
if (path.startsWith('/moderation/technical-review')) {
|
||||
return formatMessage(messages.technicalReviewTitle)
|
||||
} else if (path.startsWith('/moderation/reports/')) {
|
||||
} else if (path.startsWith('/moderation/reports')) {
|
||||
return formatMessage(messages.reportsTitle)
|
||||
} else if (path.startsWith('/moderation/external-projects')) {
|
||||
return formatMessage(messages.externalFilesTitle)
|
||||
} else if (path.startsWith('/moderation/global-traces')) {
|
||||
return formatMessage(messages.globalDetailTracesTitle)
|
||||
} else {
|
||||
return formatMessage(messages.projectsTitle)
|
||||
}
|
||||
@@ -92,6 +106,10 @@ function navigateToPage(selectedOption: string) {
|
||||
router.push('/moderation/technical-review')
|
||||
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
|
||||
router.push('/moderation/reports')
|
||||
} else if (selectedOption === formatMessage(messages.externalFilesTitle)) {
|
||||
router.push('/moderation/external-projects')
|
||||
} else if (selectedOption === formatMessage(messages.globalDetailTracesTitle)) {
|
||||
router.push('/moderation/global-traces')
|
||||
} else {
|
||||
router.push('/moderation')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<ButtonStyled>
|
||||
<NuxtLink to="/moderation/global-traces">
|
||||
<ArrowLeftIcon aria-hidden="true" />
|
||||
Back to global traces
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="isLoading && !trace"
|
||||
type="no-search-result"
|
||||
heading="Loading global detail trace..."
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="loadError"
|
||||
type="no-search-result"
|
||||
heading="Failed to load global detail trace"
|
||||
/>
|
||||
<article v-else-if="trace" class="universal-card flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<HashIcon class="shrink-0 text-secondary" aria-hidden="true" />
|
||||
<h2 class="m-0 min-w-0 text-lg font-semibold text-contrast">
|
||||
Trace
|
||||
<span class="break-all font-mono text-base">{{ trace.detail_key }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<p class="m-0 mt-1 text-sm text-secondary">
|
||||
{{ pageStart }}-{{ pageEnd }} of {{ trace.local_trace_count }} local traces
|
||||
</p>
|
||||
</div>
|
||||
<Badge :type="trace.verdict" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="trace.local_trace_count > localTracePageSize"
|
||||
class="flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
<p v-if="isLoading" class="m-0 text-sm text-secondary">Loading page...</p>
|
||||
</div>
|
||||
|
||||
<div v-if="trace.local_traces.length > 0" class="flex flex-col gap-2">
|
||||
<GlobalDetailLocalTraceCard
|
||||
v-for="localTrace in trace.local_traces"
|
||||
:key="localTrace.detail_id"
|
||||
:trace="localTrace"
|
||||
/>
|
||||
</div>
|
||||
<EmptyState v-else type="no-search-result" heading="No local traces match this key" />
|
||||
|
||||
<div v-if="trace.local_trace_count > localTracePageSize" class="mt-1 flex justify-end">
|
||||
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
|
||||
</div>
|
||||
</article>
|
||||
<EmptyState v-else type="no-search-result" heading="Global detail trace not found" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ArrowLeftIcon, HashIcon } from '@modrinth/assets'
|
||||
import { Badge, ButtonStyled, EmptyState, injectModrinthClient, Pagination } from '@modrinth/ui'
|
||||
|
||||
import GlobalDetailLocalTraceCard from '~/components/ui/moderation/GlobalDetailLocalTraceCard.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const route = useRoute()
|
||||
|
||||
const detailKey = computed(() => {
|
||||
const key = route.params.key
|
||||
return Array.isArray(key) ? key.join('/') : String(key)
|
||||
})
|
||||
|
||||
useHead({ title: () => `Global trace - ${detailKey.value} - Modrinth` })
|
||||
|
||||
const localTracePageSize = 20
|
||||
const isLoading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageStartCursors = ref<(string | null)[]>([null])
|
||||
const trace = ref<Labrinth.TechReview.Internal.GlobalIssueDetail | null>(null)
|
||||
|
||||
const pageCount = computed(() =>
|
||||
Math.max(Math.ceil((trace.value?.local_trace_count ?? 0) / localTracePageSize), 1),
|
||||
)
|
||||
const pageStart = computed(() =>
|
||||
trace.value && trace.value.local_trace_count > 0
|
||||
? (currentPage.value - 1) * localTracePageSize + 1
|
||||
: 0,
|
||||
)
|
||||
const pageEnd = computed(() =>
|
||||
Math.min(currentPage.value * localTracePageSize, trace.value?.local_trace_count ?? 0),
|
||||
)
|
||||
|
||||
async function fetchTracePage(afterDetailId: string | null) {
|
||||
return await client.labrinth.tech_review_internal.getGlobalIssueDetail({
|
||||
detail_key: detailKey.value,
|
||||
limit: localTracePageSize,
|
||||
after_detail_id: afterDetailId,
|
||||
})
|
||||
}
|
||||
|
||||
async function loadPage(page: number) {
|
||||
if (page < 1 || isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
loadError.value = false
|
||||
|
||||
try {
|
||||
while (pageStartCursors.value.length < page) {
|
||||
const cursor = pageStartCursors.value[pageStartCursors.value.length - 1]
|
||||
const response = await fetchTracePage(cursor)
|
||||
|
||||
if (!response.next_after_detail_id) {
|
||||
trace.value = response.trace
|
||||
currentPage.value = pageStartCursors.value.length
|
||||
return
|
||||
}
|
||||
|
||||
pageStartCursors.value.push(response.next_after_detail_id)
|
||||
}
|
||||
|
||||
const response = await fetchTracePage(pageStartCursors.value[page - 1])
|
||||
trace.value = response.trace
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
console.error('Failed to load global detail trace', error)
|
||||
loadError.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function switchPage(page: number) {
|
||||
await loadPage(page)
|
||||
}
|
||||
|
||||
watch(
|
||||
detailKey,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
pageStartCursors.value = [null]
|
||||
trace.value = null
|
||||
loadPage(1)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<GlobalDetailTracesList />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GlobalDetailTracesList from '~/components/ui/moderation/GlobalDetailTracesList.vue'
|
||||
|
||||
useHead({ title: 'Global detail traces - Modrinth' })
|
||||
</script>
|
||||
@@ -18,6 +18,7 @@
|
||||
<Combobox
|
||||
v-model="currentFilterType"
|
||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
trigger-class="!h-10"
|
||||
:options="filterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@select="goToPage(1)"
|
||||
@@ -26,7 +27,7 @@
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast"
|
||||
>{{ currentFilterType }} ({{ filteredProjects.length }})</span
|
||||
>{{ currentFilterType }} ({{ totalProjects }})</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
@@ -35,6 +36,7 @@
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
|
||||
trigger-class="!h-10"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
@select="goToPage(1)"
|
||||
@@ -54,6 +56,7 @@
|
||||
<Combobox
|
||||
v-model="itemsPerPage"
|
||||
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
|
||||
trigger-class="!h-10"
|
||||
:options="itemsPerPageOptions"
|
||||
placeholder="Items per page"
|
||||
@select="goToPage(1)"
|
||||
@@ -69,7 +72,7 @@
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
|
||||
:disabled="paginatedProjects?.length === 0"
|
||||
:disabled="pending || paginatedProjects?.length === 0"
|
||||
@click="moderateAllInFilter()"
|
||||
>
|
||||
<ScaleIcon class="flex-shrink-0" />
|
||||
@@ -80,17 +83,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-between">
|
||||
<div>
|
||||
Showing {{ itemsPerPage * (currentPage - 1) + 1 }}–{{
|
||||
itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length)
|
||||
}}
|
||||
of {{ filteredProjects.length }}
|
||||
{{
|
||||
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
|
||||
}}
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div v-if="totalProjects > 0">
|
||||
Showing {{ pageStart }}–{{ pageEnd }} of {{ totalProjects }}
|
||||
{{
|
||||
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
|
||||
}}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-secondary">
|
||||
<Toggle id="moderation-exclude-technical-review" v-model="excludeTechnicalReview" small />
|
||||
<label class="cursor-pointer" for="moderation-exclude-technical-review">
|
||||
{{ formatMessage(messages.excludeTechnicalReview) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
<Pagination
|
||||
v-if="totalPages > 1"
|
||||
:page="currentPage"
|
||||
:count="totalPages"
|
||||
@switch-page="goToPage"
|
||||
/>
|
||||
<ConfettiExplosion v-if="visible" />
|
||||
</div>
|
||||
|
||||
@@ -124,6 +137,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
@@ -132,20 +146,18 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||
|
||||
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
||||
import {
|
||||
type ModerationProject,
|
||||
type ProjectWithOwnership,
|
||||
toModerationProjects,
|
||||
} from '~/helpers/moderation.ts'
|
||||
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
|
||||
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
||||
|
||||
useHead({ title: 'Projects queue - Modrinth' })
|
||||
@@ -155,6 +167,7 @@ const { addNotification } = injectNotificationManager()
|
||||
const moderationQueue = useModerationQueue()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const visible = ref(false)
|
||||
if (import.meta.client && history && history.state && history.state.confetti) {
|
||||
@@ -173,37 +186,14 @@ const messages = defineMessages({
|
||||
id: 'moderation.moderate',
|
||||
defaultMessage: 'Moderate',
|
||||
},
|
||||
})
|
||||
|
||||
const { data: allProjects, pending } = await useLazyAsyncData('moderation-projects', async () => {
|
||||
const startTime = performance.now()
|
||||
let currentOffset = 0
|
||||
const PROJECT_ENDPOINT_COUNT = 350
|
||||
const allProjects: ModerationProject[] = []
|
||||
|
||||
let projects: ProjectWithOwnership[] = []
|
||||
do {
|
||||
projects = (await useBaseFetch(
|
||||
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
||||
{ internal: true },
|
||||
)) as ProjectWithOwnership[]
|
||||
|
||||
if (projects.length === 0) break
|
||||
|
||||
allProjects.push(...toModerationProjects(projects))
|
||||
currentOffset += projects.length
|
||||
} while (projects.length === PROJECT_ENDPOINT_COUNT)
|
||||
|
||||
const duration = performance.now() - startTime
|
||||
|
||||
console.debug(
|
||||
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||
)
|
||||
|
||||
return allProjects
|
||||
excludeTechnicalReview: {
|
||||
id: 'moderation.exclude-technical-review',
|
||||
defaultMessage: 'Exclude TR',
|
||||
},
|
||||
})
|
||||
|
||||
const query = ref(route.query.q?.toString() || '')
|
||||
const excludeTechnicalReview = ref(false)
|
||||
|
||||
watch(
|
||||
query,
|
||||
@@ -379,116 +369,106 @@ const itemsPerPage = computed({
|
||||
})
|
||||
|
||||
const currentPage = ref(1)
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage.value),
|
||||
|
||||
function toApiProjectType(label: string): string | undefined {
|
||||
switch (label) {
|
||||
case 'Modpacks':
|
||||
return 'modpack'
|
||||
case 'Mods':
|
||||
return 'mod'
|
||||
case 'Resource Packs':
|
||||
return 'resourcepack'
|
||||
case 'Data Packs':
|
||||
return 'datapack'
|
||||
case 'Plugins':
|
||||
return 'plugin'
|
||||
case 'Shaders':
|
||||
return 'shader'
|
||||
case 'Servers':
|
||||
return 'minecraft_java_server'
|
||||
case 'Fucked up':
|
||||
return 'none'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function toApiSort(label: string): Labrinth.Moderation.Internal.ProjectsSort {
|
||||
switch (label) {
|
||||
case 'Newest':
|
||||
return 'newest'
|
||||
case 'Most external deps':
|
||||
return 'most_external_deps'
|
||||
case 'Least external deps':
|
||||
return 'least_external_deps'
|
||||
default:
|
||||
return 'oldest'
|
||||
}
|
||||
}
|
||||
|
||||
const moderationProjectsRequest = computed<Labrinth.Moderation.Internal.ProjectsRequest>(() => ({
|
||||
count: itemsPerPage.value,
|
||||
offset: (currentPage.value - 1) * itemsPerPage.value,
|
||||
exclude_technical_review: excludeTechnicalReview.value,
|
||||
query: query.value || undefined,
|
||||
project_type: toApiProjectType(currentFilterType.value),
|
||||
sort: toApiSort(currentSortType.value),
|
||||
}))
|
||||
|
||||
const moderationProjectsQueryKey = computed(
|
||||
() => ['moderation-projects', moderationProjectsRequest.value] as const,
|
||||
)
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (!allProjects.value || allProjects.value.length === 0) return null
|
||||
return new Fuse(allProjects.value, {
|
||||
keys: [
|
||||
{
|
||||
name: 'project.title',
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: 'project.slug',
|
||||
weight: 2,
|
||||
},
|
||||
{
|
||||
name: 'project.description',
|
||||
weight: 2,
|
||||
},
|
||||
{
|
||||
name: 'project.project_type',
|
||||
weight: 1,
|
||||
},
|
||||
'ownership.name',
|
||||
],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
})
|
||||
const {
|
||||
data: moderationProjectsResponse,
|
||||
isPending: moderationProjectsPending,
|
||||
isPlaceholderData: moderationProjectsPlaceholder,
|
||||
} = useQuery({
|
||||
queryKey: moderationProjectsQueryKey,
|
||||
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
|
||||
placeholderData: (previousData) => previousData,
|
||||
})
|
||||
|
||||
const searchResults = computed(() => {
|
||||
if (!query.value || !fuse.value) return null
|
||||
return fuse.value.search(query.value).map((result) => result.item)
|
||||
})
|
||||
|
||||
const baseFiltered = computed(() => {
|
||||
if (!allProjects.value) return []
|
||||
return query.value && searchResults.value ? searchResults.value : [...allProjects.value]
|
||||
})
|
||||
|
||||
const typeFiltered = computed(() => {
|
||||
if (currentFilterType.value === 'All projects') {
|
||||
return baseFiltered.value
|
||||
} else if (currentFilterType.value === 'Fucked up') {
|
||||
return baseFiltered.value.filter((queueItem) => queueItem.project.project_types.length === 0)
|
||||
const pending = computed(
|
||||
() => moderationProjectsPending.value || moderationProjectsPlaceholder.value,
|
||||
)
|
||||
const totalProjects = computed(() => moderationProjectsResponse.value?.total ?? 0)
|
||||
const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.value))
|
||||
const filteredProjects = computed(() =>
|
||||
toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
|
||||
)
|
||||
const paginatedProjects = computed(() => filteredProjects.value)
|
||||
const pageStart = computed(() =>
|
||||
totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
|
||||
)
|
||||
const pageEnd = computed(() =>
|
||||
Math.min(
|
||||
(currentPage.value - 1) * itemsPerPage.value + paginatedProjects.value.length,
|
||||
totalProjects.value,
|
||||
),
|
||||
)
|
||||
const projectsById = computed(() => {
|
||||
const projects = new Map<string, ModerationProject>()
|
||||
for (const project of filteredProjects.value) {
|
||||
projects.set(project.project.id, project)
|
||||
}
|
||||
|
||||
const filterMap: Record<string, string> = {
|
||||
Modpacks: 'modpack',
|
||||
Mods: 'mod',
|
||||
'Resource Packs': 'resourcepack',
|
||||
'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) ||
|
||||
(projectType === 'minecraft_java_server' &&
|
||||
queueItem.project.project_types.includes('minecraft_java_server')),
|
||||
)
|
||||
return projects
|
||||
})
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
const filtered = [...typeFiltered.value]
|
||||
|
||||
if (currentSortType.value === 'Most external deps') {
|
||||
filtered.sort((a, b) => {
|
||||
const depsDiff = b.external_dependencies_count - a.external_dependencies_count
|
||||
if (depsDiff !== 0) return depsDiff
|
||||
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
|
||||
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
|
||||
return dateA - dateB
|
||||
})
|
||||
} else if (currentSortType.value === 'Least external deps') {
|
||||
filtered.sort((a, b) => {
|
||||
const depsDiff = a.external_dependencies_count - b.external_dependencies_count
|
||||
if (depsDiff !== 0) return depsDiff
|
||||
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
|
||||
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
|
||||
return dateA - dateB
|
||||
})
|
||||
} else if (currentSortType.value === 'Oldest') {
|
||||
filtered.sort((a, b) => {
|
||||
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
|
||||
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
|
||||
return dateA - dateB
|
||||
})
|
||||
} else {
|
||||
filtered.sort((a, b) => {
|
||||
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
|
||||
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
|
||||
return dateB - dateA
|
||||
})
|
||||
watch(totalPages, (pages) => {
|
||||
if (pages === 0 && currentPage.value !== 1) {
|
||||
currentPage.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
return filtered
|
||||
if (pages > 0 && currentPage.value > pages) {
|
||||
currentPage.value = pages
|
||||
}
|
||||
})
|
||||
|
||||
const paginatedProjects = computed(() => {
|
||||
if (!filteredProjects.value) return []
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||
const end = start + itemsPerPage.value
|
||||
return filteredProjects.value.slice(start, end)
|
||||
watch(excludeTechnicalReview, () => {
|
||||
goToPage(1)
|
||||
})
|
||||
|
||||
const emptyStateHeading = computed(() => {
|
||||
@@ -525,21 +505,16 @@ function notifySkippedProjects(skippedCount: number) {
|
||||
})
|
||||
}
|
||||
|
||||
async function findFirstEligibleProject(): Promise<ModerationProject | null> {
|
||||
async function findFirstEligibleProject(): Promise<string | null> {
|
||||
let skippedCount = 0
|
||||
|
||||
while (moderationQueue.hasItems) {
|
||||
const currentId = moderationQueue.getCurrentProjectId()
|
||||
if (!currentId) return null
|
||||
|
||||
const project = filteredProjects.value.find((p) => p.project.id === currentId)
|
||||
if (!project) {
|
||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||
skippedCount++
|
||||
continue
|
||||
}
|
||||
const project = projectsById.value.get(currentId)
|
||||
|
||||
if (project.project.status !== 'processing') {
|
||||
if (project && project.project.status !== 'processing') {
|
||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||
skippedCount++
|
||||
continue
|
||||
@@ -550,13 +525,13 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
|
||||
|
||||
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
|
||||
notifySkippedProjects(skippedCount)
|
||||
return project
|
||||
return currentId
|
||||
}
|
||||
|
||||
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||
skippedCount++
|
||||
} catch {
|
||||
return project
|
||||
return currentId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,17 +540,42 @@ async function findFirstEligibleProject(): Promise<ModerationProject | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
function getProjectRouteParam(projectId: string): string {
|
||||
return projectsById.value.get(projectId)?.project.slug || projectId
|
||||
}
|
||||
|
||||
async function navigateToModerationProject(projectId: string) {
|
||||
await navigateTo({
|
||||
name: 'type-project',
|
||||
params: {
|
||||
type: 'project',
|
||||
project: getProjectRouteParam(projectId),
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function getFilteredProjectIds(): Promise<string[]> {
|
||||
const response = await client.labrinth.moderation_internal.getProjectIds({
|
||||
exclude_technical_review: excludeTechnicalReview.value,
|
||||
query: query.value || undefined,
|
||||
project_type: toApiProjectType(currentFilterType.value),
|
||||
sort: toApiSort(currentSortType.value),
|
||||
})
|
||||
|
||||
return response.ids
|
||||
}
|
||||
|
||||
async function moderateAllInFilter() {
|
||||
// Start from the current page - get projects from current page onwards
|
||||
const startIndex = (currentPage.value - 1) * itemsPerPage.value
|
||||
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
|
||||
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
|
||||
const projectIds = (await getFilteredProjectIds()).slice(startIndex)
|
||||
await moderationQueue.setQueue(projectIds)
|
||||
|
||||
// Find first unlocked project
|
||||
const targetProject = await findFirstEligibleProject()
|
||||
const targetProjectId = await findFirstEligibleProject()
|
||||
|
||||
if (!targetProject) {
|
||||
if (!targetProjectId) {
|
||||
addNotification({
|
||||
title: 'No projects available',
|
||||
text: 'All projects in queue are already moderated or locked by others.',
|
||||
@@ -584,34 +584,22 @@ async function moderateAllInFilter() {
|
||||
return
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
name: 'type-project',
|
||||
params: {
|
||||
type: 'project',
|
||||
project: targetProject.project.slug,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
})
|
||||
await navigateToModerationProject(targetProjectId)
|
||||
}
|
||||
|
||||
async function startFromProject(projectId: string) {
|
||||
// Find the index of the clicked project in the filtered list
|
||||
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
|
||||
const allFilteredProjectIds = await getFilteredProjectIds()
|
||||
const projectIndex = allFilteredProjectIds.indexOf(projectId)
|
||||
if (projectIndex === -1) {
|
||||
// Project not found in filtered list, just moderate it alone
|
||||
await moderationQueue.setSingleProject(projectId)
|
||||
} else {
|
||||
// Start queue from this project onwards
|
||||
const projectsFromHere = filteredProjects.value.slice(projectIndex)
|
||||
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
|
||||
const projectIds = allFilteredProjectIds.slice(projectIndex)
|
||||
await moderationQueue.setQueue(projectIds)
|
||||
}
|
||||
|
||||
const targetProject = await findFirstEligibleProject()
|
||||
const targetProjectId = await findFirstEligibleProject()
|
||||
|
||||
if (!targetProject) {
|
||||
if (!targetProjectId) {
|
||||
addNotification({
|
||||
title: 'No projects available',
|
||||
text: 'All projects in queue are already moderated or locked by others.',
|
||||
@@ -620,15 +608,6 @@ async function startFromProject(projectId: string) {
|
||||
return
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
name: 'type-project',
|
||||
params: {
|
||||
type: 'project',
|
||||
project: targetProject.project.slug,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
})
|
||||
await navigateToModerationProject(targetProjectId)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,7 @@ import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevC
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const route = useRoute()
|
||||
|
||||
const projectId = String(useRouteId('project'))
|
||||
|
||||
@@ -245,6 +246,8 @@ const reviewItem = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const focusedDetailId = computed(() => route.query.detail?.toString() ?? null)
|
||||
|
||||
async function handleMarkComplete(projectId: string) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] }),
|
||||
@@ -299,6 +302,7 @@ function refetch() {
|
||||
<ModerationTechRevCard
|
||||
v-else
|
||||
:item="reviewItem"
|
||||
:focused-detail-id="focusedDetailId"
|
||||
:loading-issues="loadingIssues"
|
||||
:decompiled-sources="decompiledSources"
|
||||
@refetch="refetch"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from '@modrinth/ui'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Informs the default layout of the current project ID, if any, because it can't be gleaned from the route which may be a slug
|
||||
*/
|
||||
export const [injectCurrentProjectId, provideCurrentProjectId] = createContext<
|
||||
Ref<string | undefined>
|
||||
>('root', 'currentProjectId')
|
||||
@@ -313,6 +313,12 @@ export function createManageVersionContext(
|
||||
// detect primary file if no primary file is set
|
||||
const primaryFileIndex = primaryFile.value ? null : detectPrimaryFileIndex(newFiles)
|
||||
|
||||
if (hasSupplementaryMrpackFile(newFiles, primaryFileIndex)) {
|
||||
notifyInvalidSupplementaryMrpack()
|
||||
handlingNewFiles.value = false
|
||||
return
|
||||
}
|
||||
|
||||
newFiles.forEach((file) => filesToAdd.value.push({ file }))
|
||||
|
||||
if (primaryFileIndex !== null) {
|
||||
@@ -567,6 +573,29 @@ export function createManageVersionContext(
|
||||
})
|
||||
|
||||
// File handling helpers
|
||||
function isMrpackFile(file: File): boolean {
|
||||
const name = file.name.toLowerCase()
|
||||
return name.endsWith('.mrpack') || name.endsWith('.mrpack-primary')
|
||||
}
|
||||
|
||||
function hasSupplementaryMrpackFile(
|
||||
files: File[] | Labrinth.Versions.v3.DraftVersionFile[],
|
||||
primaryFileIndex: number | null = 0,
|
||||
): boolean {
|
||||
return files.some((file, index) => {
|
||||
const rawFile = 'file' in file ? file.file : file
|
||||
return index !== primaryFileIndex && isMrpackFile(rawFile)
|
||||
})
|
||||
}
|
||||
|
||||
function notifyInvalidSupplementaryMrpack() {
|
||||
addNotification({
|
||||
title: 'Invalid supplementary file',
|
||||
text: 'mrpacks cannot be uploaded as supplementary files, only as the primary file',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
function detectPrimaryFileIndex(files: File[]): number {
|
||||
const extensionPriority = ['.jar', '.zip', '.litemod', '.mrpack', '.mrpack-primary']
|
||||
|
||||
@@ -738,6 +767,12 @@ export function createManageVersionContext(
|
||||
async function handleCreateVersion() {
|
||||
const version = toRaw(draftVersion.value)
|
||||
const files = toRaw(filesToAdd.value)
|
||||
|
||||
if (hasSupplementaryMrpackFile(files)) {
|
||||
notifyInvalidSupplementaryMrpack()
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
isUploading.value = true
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 777 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"title": "Proud of you all",
|
||||
"summary": "Over 2,400 of you came together to raise more than $40,000 for charity this Pride month!",
|
||||
"thumbnail": "https://modrinth.com/news/article/proud-of-you-2026/thumbnail.webp",
|
||||
"date": "2026-07-02T01:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/proud-of-you-2026"
|
||||
},
|
||||
{
|
||||
"title": "Improving Modpack review delays",
|
||||
"summary": "Reducing the back-and-forth needed to get your modpack approved.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Generated
-34
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (\n cardinality($4::text[]) = 0\n OR (\n 'minecraft_java_server' = ANY($4::text[])\n AND (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n OR EXISTS (\n SELECT 1\n FROM versions type_v\n INNER JOIN loaders_versions type_lv\n ON type_lv.version_id = type_v.id\n INNER JOIN loaders_project_types type_lpt\n ON type_lpt.joining_loader_id = type_lv.loader_id\n INNER JOIN project_types type_pt\n ON type_pt.id = type_lpt.joining_project_type_id\n WHERE\n type_v.mod_id = m.id\n AND type_pt.name = ANY($4::text[])\n AND (\n type_pt.name != 'modpack'\n OR NOT (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n )\n )\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "thread_id!: DBThreadId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0545adc0340800b9fb4c23eb5ec2b30d5bba824f80cd25dbf08ca9f86c32ea1f"
|
||||
}
|
||||
Generated
+148
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n didws.key AS \"detail_key!\",\n didws.id AS \"detail_id!: DelphiReportIssueDetailsId\",\n didws.issue_id AS \"issue_id!: DelphiReportIssueId\",\n dri.issue_type,\n m.id AS \"project_id!: DBProjectId\",\n m.slug AS \"project_slug?\",\n m.name AS \"project_name!\",\n v.id AS \"version_id!: DBVersionId\",\n v.version_number,\n f.id AS \"file_id!: DBFileId\",\n f.filename AS \"file_name!\",\n didws.jar AS \"jar?\",\n didws.file_path AS \"file_path!\",\n didws.severity AS \"severity!: DelphiSeverity\",\n COALESCE(didv.verdict, 'pending'::delphi_report_issue_status)\n AS \"local_status!: DelphiStatus\",\n didws.status AS \"effective_status!: DelphiStatus\"\n FROM delphi_issue_details_with_statuses didws\n INNER JOIN delphi_report_issues dri ON dri.id = didws.issue_id\n INNER JOIN delphi_reports dr ON dr.id = dri.report_id\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n INNER JOIN mods m ON m.id = v.mod_id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON didv.project_id = didws.project_id\n AND didv.detail_key = didws.key\n WHERE\n didws.key = $1\n AND ($2::bigint IS NULL OR didws.id > $2)\n AND dri.issue_type != '__dummy'\n ORDER BY didws.id\n LIMIT $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "detail_key!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "detail_id!: DelphiReportIssueDetailsId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "issue_id!: DelphiReportIssueId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "issue_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "project_id!: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "project_slug?",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "project_name!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "version_id!: DBVersionId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "version_number",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "file_id!: DBFileId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "file_name!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "jar?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "file_path!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "severity!: DelphiSeverity",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "delphi_severity",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"severe"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "local_status!: DelphiStatus",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "delphi_report_issue_status",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"pending",
|
||||
"safe",
|
||||
"unsafe"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "effective_status!: DelphiStatus",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "delphi_report_issue_status",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"pending",
|
||||
"safe",
|
||||
"unsafe"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "055f71ec6d193c5f5259cafc989a04f3f791205e6c2df15c5bf9d5afcf71a76a"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select id\n from mods\n where id = any($1)\n and status not in ('rejected', 'draft', 'withheld', 'withdrawn')\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "079846a1e6a6b080e0e7f2e3efbb53b6526332433faf66de8716bc5cd2b12afd"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE AND user_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c"
|
||||
}
|
||||
Generated
-31
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id,\n external_dependencies_count as \"external_dependencies_count!\"\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued,\n (\n SELECT COUNT(*)\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE v.mod_id = m.id\n AND d.dependency_file_name IS NOT NULL\n ) external_dependencies_count\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n WHERE\n ($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "external_dependencies_count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082"
|
||||
}
|
||||
Generated
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect\n\t\t\tf.url,\n\t\t\tf.version_id as \"version_id: DBVersionId\",\n\t\t\tv.mod_id as \"project_id: DBProjectId\"\n\t\tfrom files f\n\t\tinner join versions v on v.id = f.version_id\n\t\twhere f.id = $1\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version_id: DBVersionId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "17f415f1140df5b3dd42a161c0f77b2475edb8041bc2b9701d51cf9cbfd69ba1"
|
||||
}
|
||||
Generated
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_issue_details_with_statuses didws\n ON didws.issue_id = dri.id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (\n cardinality($4::text[]) = 0\n OR (\n 'minecraft_java_server' = ANY($4::text[])\n AND (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n OR EXISTS (\n SELECT 1\n FROM versions type_v\n INNER JOIN loaders_versions type_lv\n ON type_lv.version_id = type_v.id\n INNER JOIN loaders_project_types type_lpt\n ON type_lpt.joining_loader_id = type_lv.loader_id\n INNER JOIN project_types type_pt\n ON type_pt.id = type_lpt.joining_project_type_id\n WHERE\n type_v.mod_id = m.id\n AND type_pt.name = ANY($4::text[])\n AND (\n type_pt.name != 'modpack'\n OR NOT (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n )\n )\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND didws.status = 'pending'\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "thread_id!: DBThreadId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1a6d4ac11af078439cff5c772f4dc2392729f99ba1f8c7892831235f341fb276"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user