You've already forked AstralRinth
Compare commits
16 Commits
AR-0.10.30
...
8d36c14554
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d36c14554 | |||
|
|
6387fb21c6 | ||
|
|
c7d0839bfb | ||
| 2b43e26a85 | |||
|
|
175b90be5a | ||
|
|
13103b4950 | ||
|
|
8804478221 | ||
|
|
b8982a6d17 | ||
|
|
ff88724d01 | ||
|
|
7dffb352d5 | ||
|
|
1df6e29aa1 | ||
|
|
5deb4179ad | ||
|
|
358cf31c87 | ||
| 7cea4b21a8 | |||
|
|
6db1d66591 | ||
|
|
8052fda840 |
13
.github/workflows/astralrinth-build.yml
vendored
13
.github/workflows/astralrinth-build.yml
vendored
@@ -96,6 +96,11 @@ jobs:
|
||||
librsvg2-dev \
|
||||
xdg-utils \
|
||||
openjdk-11-jdk
|
||||
|
||||
- name: ⚙️ Set application environment
|
||||
shell: bash
|
||||
run: |
|
||||
cp packages/app-lib/.env.prod packages/app-lib/.env
|
||||
|
||||
- name: 💨 Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@v1.8
|
||||
@@ -114,14 +119,6 @@ jobs:
|
||||
run: |
|
||||
rm -rf target/release/bundle
|
||||
rm -rf target/*/release/bundle || true
|
||||
|
||||
- name: 🌍 Load environment variables for build.rs
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Loading .env.prod..."
|
||||
set -a
|
||||
source packages/app-lib/.env.prod
|
||||
set +a
|
||||
|
||||
# - name: 🔨 Build macOS app
|
||||
# if: matrix.platform == 'macos-latest'
|
||||
|
||||
289
Cargo.lock
generated
289
Cargo.lock
generated
@@ -87,7 +87,7 @@ dependencies = [
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
@@ -182,7 +182,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"mio",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -244,7 +244,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"smallvec",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -508,7 +508,7 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
@@ -590,9 +590,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-fs"
|
||||
version = "2.1.2"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a"
|
||||
checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"blocking",
|
||||
@@ -601,9 +601,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.4.1"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1237c0ae75a0f3765f58910ff9cdd0a12eeb39ab2f4c7de23262f337f0aacbb3"
|
||||
checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"cfg-if",
|
||||
@@ -614,8 +614,7 @@ dependencies = [
|
||||
"polling",
|
||||
"rustix 1.0.8",
|
||||
"slab",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -631,9 +630,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.3.1"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cde3f4e40e6021d7acffc90095cbd6dc54cb593903d1de5832f435eb274b85dc"
|
||||
checksum = "65daa13722ad51e6ab1a1b9c01299142bc75135b337923cfa10e79bbbd669f00"
|
||||
dependencies = [
|
||||
"async-channel 2.5.0",
|
||||
"async-io",
|
||||
@@ -645,7 +644,6 @@ dependencies = [
|
||||
"event-listener 5.4.0",
|
||||
"futures-lite 2.6.0",
|
||||
"rustix 1.0.8",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -661,9 +659,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.11"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7605a4e50d4b06df3898d5a70bf5fde51ed9059b0434b73105193bc27acce0d"
|
||||
checksum = "f567af260ef69e1d52c2b560ce0ea230763e6fbb9214a85d768760a920e3e3c1"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
@@ -674,7 +672,7 @@ dependencies = [
|
||||
"rustix 1.0.8",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1287,19 +1285,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cargo_toml"
|
||||
version = "0.22.1"
|
||||
version = "0.22.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257"
|
||||
checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"toml 0.8.23",
|
||||
"toml 0.9.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.29"
|
||||
version = "1.2.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362"
|
||||
checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"libc",
|
||||
@@ -2313,9 +2311,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.19"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
@@ -3417,7 +3415,7 @@ dependencies = [
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"thiserror 2.0.12",
|
||||
"tinyvec",
|
||||
@@ -3439,7 +3437,7 @@ dependencies = [
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"resolv-conf",
|
||||
"smallvec",
|
||||
"thiserror 2.0.12",
|
||||
@@ -3615,7 +3613,7 @@ dependencies = [
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3674,7 +3672,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.2",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.1",
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3692,9 +3690,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.15"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df"
|
||||
checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -3708,7 +3706,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.0",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -4022,9 +4020,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.8"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
|
||||
checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
@@ -4037,7 +4035,7 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f"
|
||||
dependencies = [
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"widestring",
|
||||
"windows-sys 0.48.0",
|
||||
"winreg 0.50.0",
|
||||
@@ -4176,7 +4174,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mappings",
|
||||
"once_cell",
|
||||
"pprof_util",
|
||||
"pprof_util 0.7.0",
|
||||
"tempfile",
|
||||
"tikv-jemalloc-ctl",
|
||||
"tokio",
|
||||
@@ -4440,9 +4438,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.17"
|
||||
version = "0.11.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb2a0354e9ece2fcdcf9fa53417f6de587230c0c248068eb058fa26c4a753179"
|
||||
checksum = "5cb54db6ff7a89efac87dba5baeac57bb9ccd726b49a9b6f21fb92b3966aaf56"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chumsky",
|
||||
@@ -4459,7 +4457,7 @@ dependencies = [
|
||||
"quoted_printable",
|
||||
"rustls 0.23.29",
|
||||
"rustls-native-certs 0.8.1",
|
||||
"socket2",
|
||||
"socket2 0.6.0",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
@@ -4527,7 +4525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.53.2",
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4538,9 +4536,9 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.4"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638"
|
||||
checksum = "360e552c93fa0e8152ab463bc4c4837fce76a225df11dfaeea66c313de5e61f7"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"libc",
|
||||
@@ -4597,9 +4595,9 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "0.4.1"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
|
||||
checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed"
|
||||
|
||||
[[package]]
|
||||
name = "local-channel"
|
||||
@@ -4676,14 +4674,14 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mappings"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e434981a332777c2b3062652d16a55f8e74fa78e6b1882633f0d77399c84fc2a"
|
||||
checksum = "db4d277bb50d4508057e7bddd7fcd19ef4a4cc38051b6a5a36868d75ae2cbeb9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pprof_util",
|
||||
"pprof_util 0.8.0",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -5757,6 +5755,17 @@ dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||
dependencies = [
|
||||
"phf_macros 0.12.1",
|
||||
"phf_shared 0.12.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.8.0"
|
||||
@@ -5807,6 +5816,16 @@ dependencies = [
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b"
|
||||
dependencies = [
|
||||
"fastrand 2.3.0",
|
||||
"phf_shared 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.10.0"
|
||||
@@ -5834,6 +5853,19 @@ dependencies = [
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368"
|
||||
dependencies = [
|
||||
"phf_generator 0.12.1",
|
||||
"phf_shared 0.12.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.8.0"
|
||||
@@ -5861,6 +5893,15 @@ dependencies = [
|
||||
"siphasher 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
@@ -5959,17 +6000,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.8.0"
|
||||
version = "3.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50"
|
||||
checksum = "8ee9b2fa7a4517d2c91ff5bc6c297a427a96749d15f98fcdbb22c05571a4d4b7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix 1.0.8",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6008,6 +6048,19 @@ dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pprof_util"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9aba4251d95ac86f14c33e688d57a9344bfcff29e9b0c5a063fc66b5facc8a1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flate2",
|
||||
"num",
|
||||
"paste",
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -6372,7 +6425,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.29",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -6388,7 +6441,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.3",
|
||||
"lru-slab",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.29",
|
||||
@@ -6409,7 +6462,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -6479,9 +6532,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
@@ -6656,7 +6709,7 @@ dependencies = [
|
||||
"r2d2",
|
||||
"ryu",
|
||||
"sha1_smol",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
@@ -6664,9 +6717,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.13"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
|
||||
checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
]
|
||||
@@ -6825,7 +6878,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.1",
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7035,9 +7088,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.25"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
|
||||
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
@@ -7476,7 +7529,7 @@ version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00e9bd2cadaeda3af41e9fa5d14645127d6f6a4aec73da3ae38e477ecafd3682"
|
||||
dependencies = [
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"sentry-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -7523,7 +7576,7 @@ checksum = "a08e7154abe2cd557f26fd70038452810748aefdf39bc973f674421224b147c1"
|
||||
dependencies = [
|
||||
"debugid",
|
||||
"hex",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.12",
|
||||
@@ -7618,9 +7671,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.140"
|
||||
version = "1.0.141"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
|
||||
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -7937,6 +7990,16 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "softbuffer"
|
||||
version = "0.4.6"
|
||||
@@ -8247,9 +8310,9 @@ checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb"
|
||||
|
||||
[[package]]
|
||||
name = "strfmt"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a8348af2d9fc3258c8733b8d9d8db2e56f54b2363a4b5b81585c7875ed65e65"
|
||||
checksum = "29fdc163db75f7b5ffa3daf0c5a7136fb0d4b2f35523cd1769da05e034159feb"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
@@ -8513,9 +8576,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.6.2"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "124e129c9c0faa6bec792c5948c89e86c90094133b0b9044df0ce5f0a8efaa0d"
|
||||
checksum = "352a4bc7bf6c25f5624227e3641adf475a6535707451b09bb83271df8b7a6ac7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -8564,9 +8627,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-build"
|
||||
version = "2.3.0"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83"
|
||||
checksum = "182d688496c06bf08ea896459bf483eb29cdff35c1c4c115fb14053514303064"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cargo_toml",
|
||||
@@ -8588,9 +8651,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-codegen"
|
||||
version = "2.3.0"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5df493a1075a241065bc865ed5ef8d0fbc1e76c7afdc0bf0eccfaa7d4f0e406"
|
||||
checksum = "b54a99a6cd8e01abcfa61508177e6096a4fe2681efecee9214e962f2f073ae4a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"brotli",
|
||||
@@ -8615,9 +8678,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-macros"
|
||||
version = "2.3.1"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f237fbea5866fa5f2a60a21bea807a2d6e0379db070d89c3a10ac0f2d4649bbc"
|
||||
checksum = "7945b14dc45e23532f2ded6e120170bbdd4af5ceaa45784a6b33d250fbce3f9e"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
@@ -8629,9 +8692,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.3.0"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d9a0bd00bf1930ad1a604d08b0eb6b2a9c1822686d65d7f4731a7723b8901d3"
|
||||
checksum = "5bd5c1e56990c70a906ef67a9851bbdba9136d26075ee9a2b19c8b46986b3e02"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
@@ -8646,9 +8709,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.0"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab261eb006db10ab478e3fbb5a4e2692df3f7eb3e28300ee2b64428979167ed0"
|
||||
checksum = "1fec67f32d7a06d80bd3dc009fdb678c35a66116d9cb8cd2bb32e406c2b5bbd2"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"rust-ini",
|
||||
@@ -8666,9 +8729,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.3.0"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aefb14219b492afb30b12647b5b1247cadd2c0603467310c36e0f7ae1698c28"
|
||||
checksum = "37e5858cc7b455a73ab4ea2ebc08b5be33682c00ff1bf4cad5537d4fb62499d9"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
@@ -8684,9 +8747,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.0"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c341290d31991dbca38b31d412c73dfbdb070bb11536784f19dd2211d13b778f"
|
||||
checksum = "8c6ef84ee2f2094ce093e55106d90d763ba343fad57566992962e8f76d113f99"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -8706,9 +8769,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.5.0"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c1a38da944b357ffa23bafd563b1579f18e6fbd118fcd84769406d35dcc5c7"
|
||||
checksum = "fcde333d97e565a7765aad82f32d8672458f7bd77b6ee653830d5dded9d7b5c2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
@@ -8770,9 +8833,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.3.0"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b441b6d5d1a194e9fee0b358fe0d602ded845d0f580e1f8c8ef78ebc3c8b225d"
|
||||
checksum = "50a0e5a4ce43cb3a733c3aef85e8478bc769dac743c615e26639cbf5d953faf7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -8817,9 +8880,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.3.0"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a3d22b21b9cec73601b512a868f7c74f93c044d44fd6ca1c84e9d6afb6b1559"
|
||||
checksum = "2d5f6fe3291bfa609c7e0b0ee3bedac294d94c7018934086ce782c1d0f2a468e"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"log",
|
||||
@@ -8832,9 +8895,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.7.0"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e7bb73d1bceac06c20b3f755b2c8a2cb13b20b50083084a8cf3700daf397ba4"
|
||||
checksum = "2b1cc885be806ea15ff7b0eb47098a7b16323d9228876afda329e34e2d6c4676"
|
||||
dependencies = [
|
||||
"cookie 0.18.1",
|
||||
"dpi",
|
||||
@@ -8854,9 +8917,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime-wry"
|
||||
version = "2.7.1"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "902b5aa9035e16f342eb64f8bf06ccdc2808e411a2525ed1d07672fa4e780bad"
|
||||
checksum = "fe653a2fbbef19fe898efc774bc52c8742576342a33d3d028c189b57eb1d2439"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http 1.3.1",
|
||||
@@ -8881,9 +8944,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-utils"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41743bbbeb96c3a100d234e5a0b60a46d5aa068f266160862c7afdbf828ca02e"
|
||||
checksum = "9330c15cabfe1d9f213478c9e8ec2b0c76dab26bb6f314b8ad1c8a568c1d186e"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brotli",
|
||||
@@ -8999,6 +9062,7 @@ dependencies = [
|
||||
"notify-debouncer-mini",
|
||||
"p256",
|
||||
"paste",
|
||||
"phf 0.12.1",
|
||||
"png",
|
||||
"quartz_nbt",
|
||||
"quick-xml 0.37.5",
|
||||
@@ -9232,9 +9296,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.46.1"
|
||||
version = "1.47.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
|
||||
checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
@@ -9245,10 +9309,10 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"socket2",
|
||||
"socket2 0.6.0",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9430,7 +9494,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"prost",
|
||||
"socket2",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower 0.4.13",
|
||||
@@ -9448,7 +9512,7 @@ dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"sha2",
|
||||
]
|
||||
@@ -9643,7 +9707,7 @@ dependencies = [
|
||||
"http 1.3.1",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"rustls 0.23.29",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
@@ -9881,7 +9945,7 @@ checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"js-sys",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -10268,14 +10332,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.1",
|
||||
"webpki-roots 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.1"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502"
|
||||
checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -10562,7 +10626,7 @@ version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.2",
|
||||
"windows-targets 0.53.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10613,10 +10677,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.2"
|
||||
version = "0.53.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
|
||||
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.0",
|
||||
"windows_aarch64_msvc 0.53.0",
|
||||
"windows_i686_gnu 0.53.0",
|
||||
@@ -11047,9 +11112,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.8.0"
|
||||
version = "5.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597f45e98bc7e6f0988276012797855613cd8269e23b5be62cc4e5d28b7e515d"
|
||||
checksum = "4bb4f9a464286d42851d18a605f7193b8febaf5b0919d71c6399b7b26e5b0aad"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
@@ -11081,9 +11146,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.8.0"
|
||||
version = "5.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5c8e4e14dcdd9d97a98b189cd1220f30e8394ad271e8c987da84f73693862c2"
|
||||
checksum = "ef9859f68ee0c4ee2e8cde84737c78e3f4c54f946f2a38645d0d4c7a95327659"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.3.0",
|
||||
"proc-macro2",
|
||||
|
||||
@@ -99,6 +99,7 @@ notify = { version = "8.0.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||
p256 = "0.13.2"
|
||||
paste = "1.0.15"
|
||||
phf = { version = "0.12.1", features = ["macros"] }
|
||||
png = "0.17.16"
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ProtocolVersion,
|
||||
type ServerWorld,
|
||||
type ServerData,
|
||||
type WorldWithProfile,
|
||||
@@ -33,7 +34,7 @@ const theme = useTheming()
|
||||
|
||||
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
const protocolVersions = ref<Record<string, number | null>>({})
|
||||
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
|
||||
|
||||
const MIN_JUMP_BACK_IN = 3
|
||||
const MAX_JUMP_BACK_IN = 6
|
||||
@@ -121,11 +122,8 @@ async function populateJumpBackIn() {
|
||||
}
|
||||
})
|
||||
|
||||
// fetch each server's data
|
||||
Promise.all(
|
||||
servers.map(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
),
|
||||
servers.forEach(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,8 +148,8 @@ async function populateJumpBackIn() {
|
||||
.slice(0, MAX_JUMP_BACK_IN)
|
||||
}
|
||||
|
||||
async function refreshServer(address: string, instancePath: string) {
|
||||
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||
function refreshServer(address: string, instancePath: string) {
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||
}
|
||||
|
||||
async function joinWorld(world: WorldWithProfile) {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
|
||||
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
||||
import type {
|
||||
ProtocolVersion,
|
||||
ServerStatus,
|
||||
ServerWorld,
|
||||
SingleplayerWorld,
|
||||
World,
|
||||
set_world_display_status,
|
||||
getWorldIdentifier,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||
import {
|
||||
useRelativeTime,
|
||||
@@ -55,7 +62,7 @@ const props = withDefaults(
|
||||
playingWorld?: boolean
|
||||
startingInstance?: boolean
|
||||
supportsQuickPlay?: boolean
|
||||
currentProtocol?: number | null
|
||||
currentProtocol?: ProtocolVersion | null
|
||||
highlighted?: boolean
|
||||
|
||||
// Server only
|
||||
@@ -102,7 +109,8 @@ const serverIncompatible = computed(
|
||||
!!props.serverStatus &&
|
||||
!!props.serverStatus.version?.protocol &&
|
||||
!!props.currentProtocol &&
|
||||
props.serverStatus.version.protocol !== props.currentProtocol,
|
||||
(props.serverStatus.version.protocol !== props.currentProtocol.version ||
|
||||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
|
||||
)
|
||||
|
||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||
|
||||
@@ -51,6 +51,7 @@ export type ServerStatus = {
|
||||
version?: {
|
||||
name: string
|
||||
protocol: number
|
||||
legacy: boolean
|
||||
}
|
||||
favicon?: string
|
||||
enforces_secure_chat: boolean
|
||||
@@ -70,11 +71,17 @@ export interface Chat {
|
||||
|
||||
export type ServerData = {
|
||||
refreshing: boolean
|
||||
lastSuccessfulRefresh?: number
|
||||
status?: ServerStatus
|
||||
rawMotd?: string | Chat
|
||||
renderedMotd?: string
|
||||
}
|
||||
|
||||
export type ProtocolVersion = {
|
||||
version: number
|
||||
legacy: boolean
|
||||
}
|
||||
|
||||
export async function get_recent_worlds(
|
||||
limit: number,
|
||||
displayStatuses?: DisplayStatus[],
|
||||
@@ -156,13 +163,13 @@ export async function remove_server_from_profile(path: string, index: number): P
|
||||
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
||||
}
|
||||
|
||||
export async function get_profile_protocol_version(path: string): Promise<number | null> {
|
||||
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
|
||||
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
||||
}
|
||||
|
||||
export async function get_server_status(
|
||||
address: string,
|
||||
protocolVersion: number | null = null,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
): Promise<ServerStatus> {
|
||||
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
||||
}
|
||||
@@ -206,30 +213,39 @@ export function isServerWorld(world: World): world is ServerWorld {
|
||||
|
||||
export async function refreshServerData(
|
||||
serverData: ServerData,
|
||||
protocolVersion: number | null,
|
||||
protocolVersion: ProtocolVersion | null,
|
||||
address: string,
|
||||
): Promise<void> {
|
||||
const refreshTime = Date.now()
|
||||
serverData.refreshing = true
|
||||
await get_server_status(address, protocolVersion)
|
||||
.then((status) => {
|
||||
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
|
||||
// Don't update if there was a more recent successful refresh
|
||||
return
|
||||
}
|
||||
serverData.lastSuccessfulRefresh = Date.now()
|
||||
serverData.status = status
|
||||
if (status.description) {
|
||||
serverData.rawMotd = status.description
|
||||
serverData.renderedMotd = autoToHTML(status.description)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Refreshing addr: ${address}`, err)
|
||||
})
|
||||
.finally(() => {
|
||||
serverData.refreshing = false
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Refreshing addr ${address}`, protocolVersion, err)
|
||||
if (!protocolVersion?.legacy) {
|
||||
refreshServerData(serverData, { version: 74, legacy: true }, address)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function refreshServers(
|
||||
export function refreshServers(
|
||||
worlds: World[],
|
||||
serverData: Record<string, ServerData>,
|
||||
protocolVersion: number | null,
|
||||
protocolVersion: ProtocolVersion | null,
|
||||
) {
|
||||
const servers = worlds.filter(isServerWorld)
|
||||
servers.forEach((server) => {
|
||||
@@ -243,10 +259,8 @@ export async function refreshServers(
|
||||
})
|
||||
|
||||
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
|
||||
Promise.all(
|
||||
Object.keys(serverData).map((address) =>
|
||||
refreshServerData(serverData[address], protocolVersion, address),
|
||||
),
|
||||
Object.keys(serverData).forEach((address) =>
|
||||
refreshServerData(serverData[address], protocolVersion, address),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type ProtocolVersion,
|
||||
type SingleplayerWorld,
|
||||
type World,
|
||||
type ServerWorld,
|
||||
@@ -210,7 +211,9 @@ const worldPlaying = ref<World>()
|
||||
const worlds = ref<World[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
|
||||
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
|
||||
const protocolVersion = ref<ProtocolVersion | null>(
|
||||
await get_profile_protocol_version(instance.value.path),
|
||||
)
|
||||
|
||||
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||
if (e.profile_path_id !== instance.value.path) return
|
||||
@@ -246,7 +249,7 @@ async function refreshAllWorlds() {
|
||||
worlds.value = await refreshWorlds(instance.value.path).finally(
|
||||
() => (refreshingAll.value = false),
|
||||
)
|
||||
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
|
||||
const hasNoWorlds = worlds.value.length === 0
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ use tauri::{AppHandle, Manager, Runtime};
|
||||
use theseus::prelude::ProcessMetadata;
|
||||
use theseus::profile::{QuickPlayType, get_full_path};
|
||||
use theseus::worlds::{
|
||||
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
|
||||
WorldWithProfile,
|
||||
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
|
||||
WorldType, WorldWithProfile,
|
||||
};
|
||||
use theseus::{profile, worlds};
|
||||
|
||||
@@ -183,14 +183,16 @@ pub async fn remove_server_from_profile(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
|
||||
pub async fn get_profile_protocol_version(
|
||||
path: &str,
|
||||
) -> Result<Option<ProtocolVersion>> {
|
||||
Ok(worlds::get_profile_protocol_version(path).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_server_status(
|
||||
address: &str,
|
||||
protocol_version: Option<i32>,
|
||||
protocol_version: Option<ProtocolVersion>,
|
||||
) -> Result<ServerStatus> {
|
||||
Ok(worlds::get_server_status(address, protocol_version).await?)
|
||||
}
|
||||
|
||||
@@ -59,10 +59,12 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"prettier": "^3.6.2",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"three": "^0.172.0",
|
||||
"vue-confetti-explosion": "^1.0.2",
|
||||
"vue-multiselect": "3.0.0-alpha.2",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { MailIcon, CheckIcon } from "@modrinth/assets";
|
||||
import { ref, watchEffect } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { useBaseFetch } from "~/composables/fetch.js";
|
||||
|
||||
const auth = await useAuth();
|
||||
const showSubscriptionConfirmation = ref(false);
|
||||
const subscribed = ref(false);
|
||||
|
||||
async function checkSubscribed() {
|
||||
if (auth.value?.user) {
|
||||
try {
|
||||
const { data } = await useBaseFetch("auth/email/subscribe", {
|
||||
method: "GET",
|
||||
});
|
||||
subscribed.value = data?.subscribed || false;
|
||||
} catch {
|
||||
subscribed.value = false;
|
||||
const showSubscribeButton = useAsyncData(
|
||||
async () => {
|
||||
if (auth.value?.user) {
|
||||
try {
|
||||
const { subscribed } = await useBaseFetch("auth/email/subscribe", {
|
||||
method: "GET",
|
||||
});
|
||||
return !subscribed;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
checkSubscribed();
|
||||
});
|
||||
},
|
||||
{ watch: [auth], server: false },
|
||||
);
|
||||
|
||||
async function subscribe() {
|
||||
try {
|
||||
@@ -35,14 +34,19 @@ async function subscribe() {
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
showSubscriptionConfirmation.value = false;
|
||||
subscribed.value = true;
|
||||
showSubscribeButton.status.value = "success";
|
||||
showSubscribeButton.data.value = false;
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonStyled v-if="auth?.user && !subscribed" color="brand" type="outlined">
|
||||
<ButtonStyled
|
||||
v-if="showSubscribeButton.status.value === 'success' && showSubscribeButton.data.value"
|
||||
color="brand"
|
||||
type="outlined"
|
||||
>
|
||||
<button v-tooltip="`Subscribe to the Modrinth newsletter`" @click="subscribe">
|
||||
<template v-if="!showSubscriptionConfirmation"> <MailIcon /> Subscribe </template>
|
||||
<template v-else> <CheckIcon /> Subscribed! </template>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
|
||||
|
||||
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
|
||||
<nuxt-link
|
||||
v-if="report.target"
|
||||
:to="`/${report.target.type}/${report.target.slug}`"
|
||||
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
|
||||
>
|
||||
<Avatar
|
||||
:src="report.target.avatar_url"
|
||||
:circle="report.target.type === 'user'"
|
||||
size="1rem"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">
|
||||
<OrganizationIcon
|
||||
v-if="report.target.type === 'organization'"
|
||||
class="align-middle"
|
||||
/>
|
||||
{{ report.target.name }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
|
||||
>
|
||||
Score: {{ report.priority_score }}
|
||||
</span>
|
||||
<span
|
||||
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
|
||||
:class="{
|
||||
'text-brand': report.status === 'approved',
|
||||
'text-red': report.status === 'rejected',
|
||||
'text-secondary': report.status === 'pending',
|
||||
}"
|
||||
>
|
||||
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
|
||||
</span>
|
||||
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
|
||||
{{
|
||||
report.version.files.find((file) => file.primary)?.filename ||
|
||||
"Unknown primary file"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
|
||||
>
|
||||
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
|
||||
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled class="flex-1 sm:flex-none">
|
||||
<button
|
||||
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
|
||||
:disabled="!isPending"
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled class="flex-1 sm:flex-none">
|
||||
<button
|
||||
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
|
||||
:disabled="!isPending"
|
||||
class="w-full sm:w-auto"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center gap-2 sm:justify-start">
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link :to="versionUrl">
|
||||
<EyeIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<OverflowMenu :options="quickActions">
|
||||
<template #default>
|
||||
<EllipsisVerticalIcon />
|
||||
</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>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-secondary sm:hidden">
|
||||
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
Avatar,
|
||||
useRelativeTime,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
ButtonStyled,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
OrganizationIcon,
|
||||
EyeIcon,
|
||||
ClipboardCopyIcon,
|
||||
LinkIcon,
|
||||
} from "@modrinth/assets";
|
||||
import type { ExtendedDelphiReport } from "@modrinth/moderation";
|
||||
|
||||
const props = defineProps<{
|
||||
report: ExtendedDelphiReport;
|
||||
}>();
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
const isPending = computed(() => props.report.status === "pending");
|
||||
|
||||
const quickActions: OverflowMenuOption[] = [
|
||||
{
|
||||
id: "copy-link",
|
||||
action: () => {
|
||||
const base = window.location.origin;
|
||||
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`;
|
||||
navigator.clipboard.writeText(reviewUrl).then(() => {
|
||||
addNotification({
|
||||
type: "success",
|
||||
title: "Tech review link copied",
|
||||
text: "The link to this tech review has been copied to your clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "copy-id",
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.report.version.id).then(() => {
|
||||
addNotification({
|
||||
type: "success",
|
||||
title: "Version ID copied",
|
||||
text: "The ID of this version has been copied to your clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const versionUrl = computed(() => {
|
||||
return `/${props.report.project.project_type}/${props.report.project.slug}/versions/${props.report.version.id}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div
|
||||
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div class="flex-shrink-0 rounded-lg">
|
||||
<Avatar size="48px" :src="queueEntry.project.icon_url" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<h3 class="truncate text-lg font-semibold">
|
||||
{{ queueEntry.project.name }}
|
||||
</h3>
|
||||
<nuxt-link
|
||||
v-if="queueEntry.owner"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
||||
:to="`/user/${queueEntry.owner.user.username}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="queueEntry.owner.user.avatar_url"
|
||||
circle
|
||||
size="16px"
|
||||
class="inline-block flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-else-if="queueEntry.org"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
||||
:to="`/organization/${queueEntry.org.slug}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="queueEntry.org.icon_url"
|
||||
circle
|
||||
size="16px"
|
||||
class="inline-block flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ queueEntry.org.name }}</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
|
||||
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
|
||||
<BoxIcon
|
||||
v-if="queueEntry.project.project_type === 'mod'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PaintbrushIcon
|
||||
v-else-if="queueEntry.project.project_type === 'resourcepack'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<BracesIcon
|
||||
v-else-if="queueEntry.project.project_type === 'datapack'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PackageOpenIcon
|
||||
v-else-if="queueEntry.project.project_type === 'modpack'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<GlassesIcon
|
||||
v-else-if="queueEntry.project.project_type === 'shader'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PlugIcon
|
||||
v-else-if="queueEntry.project.project_type === 'plugin'"
|
||||
class="size-4 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="hidden sm:inline">{{
|
||||
props.queueEntry.project.project_types.map(formatProjectType).join(", ")
|
||||
}}</span>
|
||||
<span class="sm:hidden">{{
|
||||
formatProjectType(props.queueEntry.project.project_type ?? "project").substring(0, 3)
|
||||
}}</span>
|
||||
</span>
|
||||
|
||||
<span class="hidden text-sm sm:inline">•</span>
|
||||
|
||||
<div class="flex flex-row gap-2 text-sm">
|
||||
Requesting
|
||||
<Badge
|
||||
v-if="props.queueEntry.project.requested_status"
|
||||
:type="props.queueEntry.project.requested_status"
|
||||
class="status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="hidden text-sm sm:inline">•</span>
|
||||
|
||||
<span
|
||||
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
|
||||
class="truncate text-sm"
|
||||
:class="{
|
||||
'text-red': daysInQueue > 4,
|
||||
'text-orange': daysInQueue > 2,
|
||||
}"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
|
||||
<span class="sm:hidden">{{
|
||||
getSubmittedTime(queueEntry).replace("Submitted ", "")
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 sm:justify-start">
|
||||
<ButtonStyled circular>
|
||||
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
|
||||
<EyeIcon class="size-4" />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular color="orange" @click="openProjectForReview">
|
||||
<button>
|
||||
<ScaleIcon class="size-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
EyeIcon,
|
||||
PaintbrushIcon,
|
||||
ScaleIcon,
|
||||
BoxIcon,
|
||||
GlassesIcon,
|
||||
PlugIcon,
|
||||
PackageOpenIcon,
|
||||
BracesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { useRelativeTime, Avatar, ButtonStyled, Badge } from "@modrinth/ui";
|
||||
import {
|
||||
formatProjectType,
|
||||
type Organization,
|
||||
type Project,
|
||||
type TeamMember,
|
||||
} from "@modrinth/utils";
|
||||
import { computed } from "vue";
|
||||
import { useModerationStore } from "~/store/moderation.ts";
|
||||
import type { ModerationProject } from "~/helpers/moderation";
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
const moderationStore = useModerationStore();
|
||||
|
||||
const props = defineProps<{
|
||||
queueEntry: ModerationProject;
|
||||
}>();
|
||||
|
||||
function getDaysQueued(date: Date): number {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
const queuedDate = computed(() => {
|
||||
return dayjs(
|
||||
props.queueEntry.project.queued ||
|
||||
props.queueEntry.project.created ||
|
||||
props.queueEntry.project.updated,
|
||||
);
|
||||
});
|
||||
|
||||
const daysInQueue = computed(() => {
|
||||
return getDaysQueued(queuedDate.value.toDate());
|
||||
});
|
||||
|
||||
function openProjectForReview() {
|
||||
moderationStore.setSingleProject(props.queueEntry.project.id);
|
||||
navigateTo({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: "project",
|
||||
id: props.queueEntry.project.id,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getSubmittedTime(project: any): string {
|
||||
const date =
|
||||
props.queueEntry.project.queued ||
|
||||
props.queueEntry.project.created ||
|
||||
props.queueEntry.project.updated;
|
||||
if (!date) return "Unknown";
|
||||
|
||||
try {
|
||||
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`;
|
||||
} catch {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<div
|
||||
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
|
||||
>
|
||||
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<span class="flex items-center gap-2">
|
||||
Reported for
|
||||
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
|
||||
{{ formattedReportType }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="hidden sm:inline">By</span>
|
||||
<span class="sm:hidden">Reporter:</span>
|
||||
<nuxt-link
|
||||
:to="`/user/${report.reporter_user.username}`"
|
||||
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
|
||||
>
|
||||
<Avatar
|
||||
:src="report.reporter_user.avatar_url"
|
||||
circle
|
||||
size="1.75rem"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ report.reporter_user.username }}</span>
|
||||
</nuxt-link>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
|
||||
<span class="text-md whitespace-nowrap text-secondary">{{
|
||||
formatRelativeTime(report.created)
|
||||
}}</span>
|
||||
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
|
||||
<OverflowMenu :options="visibleQuickReplies">
|
||||
<span class="hidden sm:inline">Quick Reply</span>
|
||||
<span class="sr-only sm:hidden">Quick Reply</span>
|
||||
<ChevronDownIcon />
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<OverflowMenu :options="quickActions">
|
||||
<template #default>
|
||||
<EllipsisVerticalIcon />
|
||||
</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>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4 rounded-xl border-solid text-divider" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Avatar
|
||||
:src="reportItemAvatarUrl"
|
||||
:circle="report.item_type === 'user'"
|
||||
size="3rem"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
|
||||
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
|
||||
<nuxt-link
|
||||
v-if="report.target && report.item_type != 'user'"
|
||||
:to="`/${report.target.type}/${report.target.slug}`"
|
||||
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
|
||||
>
|
||||
<Avatar
|
||||
:src="report.target?.avatar_url"
|
||||
:circle="report.target.type === 'user'"
|
||||
size="1rem"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span class="truncate">
|
||||
<OrganizationIcon
|
||||
v-if="report.target.type === 'organization'"
|
||||
class="align-middle"
|
||||
/>
|
||||
{{ report.target.name || "Unknown User" }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
|
||||
>
|
||||
{{ formattedItemType }}
|
||||
</span>
|
||||
<span
|
||||
v-if="report.item_type === 'version' && report.version"
|
||||
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
|
||||
>
|
||||
{{
|
||||
report.version.files.find((file) => file.primary)?.filename || "Unknown Version"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end sm:justify-start">
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link :to="reportItemUrl">
|
||||
<EyeIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleRegion class="my-4" ref="collapsibleRegion">
|
||||
<ReportThread
|
||||
v-if="report.thread"
|
||||
ref="reportThread"
|
||||
class="mb-16 sm:mb-0"
|
||||
:thread="report.thread"
|
||||
:report="report"
|
||||
:reporter="report.reporter_user"
|
||||
@update-thread="updateThread"
|
||||
/>
|
||||
</CollapsibleRegion>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
useRelativeTime,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
CollapsibleRegion,
|
||||
ButtonStyled,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
EllipsisVerticalIcon,
|
||||
OrganizationIcon,
|
||||
EyeIcon,
|
||||
ClipboardCopyIcon,
|
||||
LinkIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
type ExtendedReport,
|
||||
reportQuickReplies,
|
||||
type ReportQuickReply,
|
||||
} from "@modrinth/moderation";
|
||||
import ChevronDownIcon from "../servers/icons/ChevronDownIcon.vue";
|
||||
import ReportThread from "../thread/ReportThread.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
report: ExtendedReport;
|
||||
}>();
|
||||
|
||||
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null);
|
||||
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null);
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
function updateThread(newThread: any) {
|
||||
if (props.report.thread) {
|
||||
Object.assign(props.report.thread, newThread);
|
||||
}
|
||||
}
|
||||
|
||||
const quickActions: OverflowMenuOption[] = [
|
||||
{
|
||||
id: "copy-link",
|
||||
action: () => {
|
||||
const base = window.location.origin;
|
||||
const reportUrl = `${base}/moderation/reports/${props.report.id}`;
|
||||
navigator.clipboard.writeText(reportUrl).then(() => {
|
||||
addNotification({
|
||||
type: "success",
|
||||
title: "Report link copied",
|
||||
text: "The link to this report has been copied to your clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "copy-id",
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.report.id).then(() => {
|
||||
addNotification({
|
||||
type: "success",
|
||||
title: "Report ID copied",
|
||||
text: "The ID of this report has been copied to your clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||
return reportQuickReplies
|
||||
.filter((reply) => {
|
||||
if (reply.shouldShow === undefined) return true;
|
||||
if (typeof reply.shouldShow === "function") {
|
||||
return reply.shouldShow(props.report);
|
||||
}
|
||||
|
||||
return reply.shouldShow;
|
||||
})
|
||||
.map(
|
||||
(reply) =>
|
||||
({
|
||||
id: reply.label,
|
||||
action: () => handleQuickReply(reply),
|
||||
}) as OverflowMenuOption,
|
||||
);
|
||||
});
|
||||
|
||||
async function handleQuickReply(reply: ReportQuickReply) {
|
||||
const message =
|
||||
typeof reply.message === "function" ? await reply.message(props.report) : reply.message;
|
||||
|
||||
collapsibleRegion.value?.setCollapsed(false);
|
||||
await nextTick();
|
||||
reportThread.value?.setReplyContent(message);
|
||||
}
|
||||
|
||||
const reportItemAvatarUrl = computed(() => {
|
||||
switch (props.report.item_type) {
|
||||
case "project":
|
||||
case "version":
|
||||
return props.report.project?.icon_url || "";
|
||||
case "user":
|
||||
return props.report.user?.avatar_url || "";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const reportItemTitle = computed(() => {
|
||||
if (props.report.item_type === "user") return props.report.user?.username || "Unknown User";
|
||||
|
||||
return props.report.project?.title || "Unknown Project";
|
||||
});
|
||||
|
||||
const reportItemUrl = computed(() => {
|
||||
switch (props.report.item_type) {
|
||||
case "user":
|
||||
return `/user/${props.report.user?.username}`;
|
||||
case "project":
|
||||
return `/${props.report.project?.project_type}/${props.report.project?.slug}`;
|
||||
case "version":
|
||||
return `/${props.report.project?.project_type}/${props.report.project?.slug}/versions/${props.report.version?.id}`;
|
||||
}
|
||||
});
|
||||
|
||||
const formattedItemType = computed(() => {
|
||||
const itemType = props.report.item_type;
|
||||
return itemType.charAt(0).toUpperCase() + itemType.slice(1);
|
||||
});
|
||||
|
||||
const formattedReportType = computed(() => {
|
||||
const reportType = props.report.report_type;
|
||||
|
||||
// some are split by -, some are split by " "
|
||||
const words = reportType.includes("-") ? reportType.split("-") : reportType.split(" ");
|
||||
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { ref } from "vue";
|
||||
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
||||
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
||||
|
||||
@@ -64,7 +64,7 @@ function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
return navigator.platform.toUpperCase().includes("MAC");
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
@@ -42,9 +42,9 @@
|
||||
<div v-if="done">
|
||||
<p>
|
||||
You are done moderating this project!
|
||||
<template v-if="futureProjectCount > 0">
|
||||
<template v-if="moderationStore.hasItems">
|
||||
There are
|
||||
{{ futureProjectCount }} left.
|
||||
{{ moderationStore.queueLength }} left.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
<div v-if="toggleActions.length > 0" class="toggle-actions-group space-y-3">
|
||||
<template v-for="action in toggleActions" :key="getActionKey(action)">
|
||||
<Checkbox
|
||||
:model-value="actionStates[getActionId(action)]?.selected ?? false"
|
||||
:model-value="isActionSelected(action)"
|
||||
:label="action.label"
|
||||
:description="action.description"
|
||||
:disabled="false"
|
||||
@@ -215,26 +215,26 @@
|
||||
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="!done && !generatedMessage && futureProjectCount > 0">
|
||||
<button @click="goToNextProject">
|
||||
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
|
||||
<button @click="skipCurrentProject">
|
||||
<XIcon aria-hidden="true" />
|
||||
Skip
|
||||
Skip ({{ moderationStore.queueLength }} left)
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="done">
|
||||
<ButtonStyled v-if="futureProjectCount > 0" color="brand">
|
||||
<button @click="goToNextProject">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next Project
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<button @click="exitModeration">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Done
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="endChecklist(undefined)">
|
||||
<template v-if="hasNextProject">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next Project ({{ moderationStore.queueLength }} left)
|
||||
</template>
|
||||
<template v-else>
|
||||
<CheckIcon aria-hidden="true" />
|
||||
All Done!
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -370,29 +370,21 @@ import {
|
||||
import * as prettier from "prettier";
|
||||
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
|
||||
import KeybindsModal from "./ChecklistKeybindsModal.vue";
|
||||
import { useModerationStore } from "~/store/moderation.ts";
|
||||
|
||||
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: Project;
|
||||
futureProjectIds?: string[];
|
||||
collapsed: boolean;
|
||||
}>(),
|
||||
{
|
||||
futureProjectIds: () => [] as string[],
|
||||
},
|
||||
);
|
||||
const props = defineProps<{
|
||||
project: Project;
|
||||
collapsed: boolean;
|
||||
}>();
|
||||
|
||||
const moderationStore = useModerationStore();
|
||||
|
||||
const variables = computed(() => {
|
||||
return flattenProjectVariables(props.project);
|
||||
});
|
||||
|
||||
const futureProjectCount = computed(() => {
|
||||
const ids = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
||||
return ids.length;
|
||||
});
|
||||
|
||||
const modpackPermissionsComplete = ref(false);
|
||||
const modpackJudgements = ref<ModerationJudgements>({});
|
||||
const isModpackPermissionsStage = computed(() => {
|
||||
@@ -516,7 +508,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
||||
isLoadingMessage: loadingMessage.value,
|
||||
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
||||
|
||||
futureProjectCount: futureProjectCount.value,
|
||||
futureProjectCount: moderationStore.queueLength,
|
||||
visibleActionsCount: visibleActions.value.length,
|
||||
|
||||
focusedActionIndex: focusedActionIndex.value,
|
||||
@@ -529,7 +521,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
||||
tryGoNext: nextStage,
|
||||
tryGoBack: previousStage,
|
||||
tryGenerateMessage: generateMessage,
|
||||
trySkipProject: goToNextProject,
|
||||
trySkipProject: skipCurrentProject,
|
||||
|
||||
tryToggleCollapse: () => emit("toggleCollapsed"),
|
||||
tryResetProgress: resetProgress,
|
||||
@@ -652,12 +644,17 @@ function initializeStageActions(stage: Stage, stageIndex: number) {
|
||||
}
|
||||
|
||||
function getActionId(action: Action, index?: number): string {
|
||||
// If index is not provided, find it in the current stage's actions
|
||||
if (index === undefined) {
|
||||
index = currentStageObj.value.actions.indexOf(action);
|
||||
}
|
||||
return getActionIdForStage(action, currentStage.value, index);
|
||||
}
|
||||
|
||||
function getActionKey(action: Action): string {
|
||||
const index = visibleActions.value.indexOf(action);
|
||||
return `${currentStage.value}-${index}-${getActionId(action)}`;
|
||||
// Find the actual index of this action in the current stage's actions array
|
||||
const index = currentStageObj.value.actions.indexOf(action);
|
||||
return `${currentStage.value}-${index}-${getActionId(action, index)}`;
|
||||
}
|
||||
|
||||
const visibleActions = computed(() => {
|
||||
@@ -727,7 +724,8 @@ const multiSelectActions = computed(() =>
|
||||
);
|
||||
|
||||
function getDropdownValue(action: DropdownAction) {
|
||||
const actionId = getActionId(action);
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const visibleOptions = getVisibleDropdownOptions(action);
|
||||
const currentValue = actionStates.value[actionId]?.value ?? action.defaultOption ?? 0;
|
||||
|
||||
@@ -742,12 +740,14 @@ function getDropdownValue(action: DropdownAction) {
|
||||
}
|
||||
|
||||
function isActionSelected(action: Action): boolean {
|
||||
const actionId = getActionId(action);
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
return actionStates.value[actionId]?.selected || false;
|
||||
}
|
||||
|
||||
function toggleAction(action: Action) {
|
||||
const actionId = getActionId(action);
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const state = actionStates.value[actionId];
|
||||
if (state) {
|
||||
state.selected = !state.selected;
|
||||
@@ -756,7 +756,8 @@ function toggleAction(action: Action) {
|
||||
}
|
||||
|
||||
function selectDropdownOption(action: DropdownAction, selected: any) {
|
||||
const actionId = getActionId(action);
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const state = actionStates.value[actionId];
|
||||
if (state && selected !== undefined && selected !== null) {
|
||||
const optionIndex = action.options.findIndex(
|
||||
@@ -772,7 +773,8 @@ function selectDropdownOption(action: DropdownAction, selected: any) {
|
||||
}
|
||||
|
||||
function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): boolean {
|
||||
const actionId = getActionId(action);
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const selectedSet = actionStates.value[actionId]?.value as Set<number> | undefined;
|
||||
|
||||
const visibleOptions = getVisibleMultiSelectOptions(action);
|
||||
@@ -783,7 +785,8 @@ function isChipSelected(action: MultiSelectChipsAction, optionIndex: number): bo
|
||||
}
|
||||
|
||||
function toggleChip(action: MultiSelectChipsAction, optionIndex: number) {
|
||||
const actionId = getActionId(action);
|
||||
const actionIndex = currentStageObj.value.actions.indexOf(action);
|
||||
const actionId = getActionId(action, actionIndex);
|
||||
const state = actionStates.value[actionId];
|
||||
if (state && state.value instanceof Set) {
|
||||
const visibleOptions = getVisibleMultiSelectOptions(action);
|
||||
@@ -1056,7 +1059,7 @@ function nextStage() {
|
||||
if (isModpackPermissionsStage.value && !modpackPermissionsComplete.value) {
|
||||
addNotification({
|
||||
title: "Modpack permissions stage unfinished",
|
||||
message: "Please complete the modpack permissions stage before proceeding.",
|
||||
text: "Please complete the modpack permissions stage before proceeding.",
|
||||
type: "error",
|
||||
});
|
||||
|
||||
@@ -1133,7 +1136,7 @@ async function generateMessage() {
|
||||
console.error("Error generating message:", error);
|
||||
addNotification({
|
||||
title: "Error generating message",
|
||||
message: "Failed to generate moderation message. Please try again.",
|
||||
text: "Failed to generate moderation message. Please try again.",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
@@ -1161,6 +1164,8 @@ function generateModpackMessage(allFiles: {
|
||||
attributeMods.push(file.file_name);
|
||||
} else if (file.status === "no" && file.approved === "no") {
|
||||
noMods.push(file.file_name);
|
||||
} else if (file.status === "permanent-no") {
|
||||
permanentNoMods.push(file.file_name);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1202,6 +1207,7 @@ function generateModpackMessage(allFiles: {
|
||||
return issues.join("\n\n");
|
||||
}
|
||||
|
||||
const hasNextProject = ref(false);
|
||||
async function sendMessage(status: "approved" | "rejected" | "withheld") {
|
||||
try {
|
||||
await useBaseFetch(`project/${props.project.id}`, {
|
||||
@@ -1236,55 +1242,73 @@ async function sendMessage(status: "approved" | "rejected" | "withheld") {
|
||||
|
||||
done.value = true;
|
||||
|
||||
// Clear local storage for future reviews
|
||||
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
|
||||
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
|
||||
localStorage.removeItem(`moderation-actions-${props.project.slug}`);
|
||||
localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
|
||||
actionStates.value = {};
|
||||
|
||||
addNotification({
|
||||
title: "Moderation submitted",
|
||||
message: `Project ${status} successfully.`,
|
||||
type: "success",
|
||||
});
|
||||
hasNextProject.value = await moderationStore.completeCurrentProject(
|
||||
props.project.id,
|
||||
"completed",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error submitting moderation:", error);
|
||||
addNotification({
|
||||
title: "Error submitting moderation",
|
||||
message: "Failed to submit moderation decision. Please try again.",
|
||||
text: "Failed to submit moderation decision. Please try again.",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function goToNextProject() {
|
||||
const currentIds = JSON.parse(localStorage.getItem("moderation-future-projects") || "[]");
|
||||
async function endChecklist(status?: string) {
|
||||
clearProjectLocalStorage();
|
||||
|
||||
if (currentIds.length === 0) {
|
||||
await navigateTo("/moderation/review");
|
||||
return;
|
||||
if (!hasNextProject.value) {
|
||||
await navigateTo({
|
||||
name: "moderation",
|
||||
state: {
|
||||
confetti: true,
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (moderationStore.currentQueue.total > 1) {
|
||||
addNotification({
|
||||
title: "Moderation completed",
|
||||
text: `You have completed the moderation queue.`,
|
||||
type: "success",
|
||||
});
|
||||
} else {
|
||||
addNotification({
|
||||
title: "Moderation submitted",
|
||||
text: `Project ${status ?? "completed successfully"}.`,
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
navigateTo({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: "project",
|
||||
id: moderationStore.getCurrentProjectId(),
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const nextProjectId = currentIds[0];
|
||||
const remainingIds = currentIds.slice(1);
|
||||
|
||||
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingIds));
|
||||
|
||||
await router.push({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: "project",
|
||||
id: nextProjectId,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function exitModeration() {
|
||||
await navigateTo("/moderation/review");
|
||||
async function skipCurrentProject() {
|
||||
hasNextProject.value = await moderationStore.completeCurrentProject(props.project.id, "skipped");
|
||||
|
||||
await endChecklist("skipped");
|
||||
}
|
||||
|
||||
function clearProjectLocalStorage() {
|
||||
localStorage.removeItem(`modpack-permissions-${props.project.id}`);
|
||||
localStorage.removeItem(`modpack-permissions-index-${props.project.id}`);
|
||||
localStorage.removeItem(`moderation-actions-${props.project.slug}`);
|
||||
localStorage.removeItem(`moderation-inputs-${props.project.slug}`);
|
||||
localStorage.removeItem(`moderation-stage-${props.project.slug}`);
|
||||
actionStates.value = {};
|
||||
}
|
||||
|
||||
const isLastVisibleStage = computed(() => {
|
||||
@@ -1,13 +1,21 @@
|
||||
<template>
|
||||
<template v-if="moderation">
|
||||
<Chips v-model="reasonFilter" :items="reasons" />
|
||||
<p v-if="reports.length === MAX_REPORTS" class="text-red">
|
||||
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
|
||||
not show any more recent ones.
|
||||
</p>
|
||||
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
|
||||
<p v-else>
|
||||
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
|
||||
</p>
|
||||
</template>
|
||||
<ReportInfo
|
||||
v-for="report in reports.filter(
|
||||
(x) =>
|
||||
(moderation || x.reporterUser.id === auth.user.id) &&
|
||||
(viewMode === 'open' ? x.open : !x.open),
|
||||
)"
|
||||
v-for="report in filteredReports"
|
||||
:key="report.id"
|
||||
:report="report"
|
||||
:thread="report.thread"
|
||||
:show-message="false"
|
||||
:moderation="moderation"
|
||||
raised
|
||||
:auth="auth"
|
||||
@@ -16,11 +24,12 @@
|
||||
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Chips } from "@modrinth/ui";
|
||||
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
||||
import { addReportMessage } from "~/helpers/threads.js";
|
||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
moderation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -32,9 +41,14 @@ defineProps({
|
||||
});
|
||||
|
||||
const viewMode = ref("open");
|
||||
const reasonFilter = ref("All");
|
||||
const reports = ref([]);
|
||||
|
||||
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
|
||||
const MAX_REPORTS = 1500;
|
||||
|
||||
let { data: rawReports } = await useAsyncData("report", () =>
|
||||
useBaseFetch(`report?count=${MAX_REPORTS}`),
|
||||
);
|
||||
|
||||
rawReports = rawReports.value.map((report) => {
|
||||
report.item_id = report.item_id.replace(/"/g, "");
|
||||
@@ -51,6 +65,7 @@ const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
|
||||
const threadIds = [
|
||||
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
|
||||
];
|
||||
const reasons = ["All", ...new Set(rawReports.map((report) => report.report_type))];
|
||||
|
||||
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
|
||||
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
|
||||
@@ -93,4 +108,13 @@ reports.value = rawReports.map((report) => {
|
||||
report.open = true;
|
||||
return report;
|
||||
});
|
||||
|
||||
const filteredReports = computed(() =>
|
||||
reports.value?.filter(
|
||||
(x) =>
|
||||
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
|
||||
(viewMode.value === "open" ? x.open : !x.open) &&
|
||||
(reasonFilter.value === "All" || reasonFilter.value === x.report_type),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -66,6 +66,27 @@
|
||||
<UiServersPanelSpinner />
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
|
||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been cancelled. Please
|
||||
update your billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason"
|
||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended:
|
||||
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
|
||||
for more information.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended'"
|
||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
@@ -87,7 +108,8 @@ import { Avatar, CopyCode } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
if (props.server_id) {
|
||||
if (props.server_id && props.status === "available") {
|
||||
// Necessary only to get server icon
|
||||
await useModrinthServers(props.server_id, ["general"]);
|
||||
}
|
||||
|
||||
@@ -109,11 +131,6 @@ if (props.upstream) {
|
||||
}
|
||||
|
||||
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
|
||||
|
||||
if (import.meta.server && projectData.value?.icon_url) {
|
||||
await useModrinthServers(props.server_id!, ["general"]);
|
||||
}
|
||||
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
||||
const isConfiguring = computed(() => props.flows?.intro);
|
||||
</script>
|
||||
|
||||
@@ -34,6 +34,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal ref="modalReply" header="Reply to thread">
|
||||
<div class="modal-submit universal-body">
|
||||
<span>
|
||||
Your project is already approved. As such, the moderation team does not actively monitor
|
||||
this thread. However, they may still see your message if there is a problem with your
|
||||
project.
|
||||
</span>
|
||||
<span>
|
||||
If you need to get in contact with the moderation team, please use the
|
||||
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
||||
Modrinth Help Center
|
||||
</a>
|
||||
and click the green bubble to contact support.
|
||||
</span>
|
||||
<Checkbox
|
||||
v-model="replyConfirmation"
|
||||
description="Confirm moderators do not actively monitor this"
|
||||
>
|
||||
I acknowledge that the moderators do not actively monitor the thread.
|
||||
</Checkbox>
|
||||
<div class="input-group push-right">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!replyConfirmation"
|
||||
@click="sendReplyFromModal()"
|
||||
>
|
||||
<ReplyIcon aria-hidden="true" />
|
||||
Reply to thread
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<div v-if="flags.developerMode" class="thread-id">
|
||||
Thread ID:
|
||||
<CopyCode :text="thread.id" />
|
||||
@@ -71,12 +103,17 @@
|
||||
v-if="sortedMessages.length > 0"
|
||||
class="btn btn-primary"
|
||||
:disabled="!replyBody"
|
||||
@click="sendReply()"
|
||||
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
||||
>
|
||||
<ReplyIcon aria-hidden="true" />
|
||||
Reply
|
||||
</button>
|
||||
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()">
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-primary"
|
||||
:disabled="!replyBody"
|
||||
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
|
||||
>
|
||||
<SendIcon aria-hidden="true" />
|
||||
Send
|
||||
</button>
|
||||
@@ -289,6 +326,7 @@ const sortedMessages = computed(() => {
|
||||
});
|
||||
|
||||
const modalSubmit = ref(null);
|
||||
const modalReply = ref(null);
|
||||
|
||||
async function updateThreadLocal() {
|
||||
let threadId = null;
|
||||
@@ -316,6 +354,11 @@ async function onUploadImage(file) {
|
||||
return response.url;
|
||||
}
|
||||
|
||||
async function sendReplyFromModal(status = null, privateMessage = false) {
|
||||
modalReply.value.hide();
|
||||
await sendReply(status, privateMessage);
|
||||
}
|
||||
|
||||
async function sendReply(status = null, privateMessage = false) {
|
||||
try {
|
||||
const body = {
|
||||
@@ -398,6 +441,7 @@ async function reopenReport() {
|
||||
|
||||
const replyWithSubmission = ref(false);
|
||||
const submissionConfirmation = ref(false);
|
||||
const replyConfirmation = ref(false);
|
||||
|
||||
function openResubmitModal(reply) {
|
||||
submissionConfirmation.value = false;
|
||||
@@ -405,6 +449,11 @@ function openResubmitModal(reply) {
|
||||
modalSubmit.value.show();
|
||||
}
|
||||
|
||||
function openReplyModal(reply) {
|
||||
replyConfirmation.value = false;
|
||||
modalReply.value.show();
|
||||
}
|
||||
|
||||
async function resubmit() {
|
||||
if (replyWithSubmission.value) {
|
||||
await sendReply("processing");
|
||||
|
||||
282
apps/frontend/src/components/ui/thread/ReportThread.vue
Normal file
282
apps/frontend/src/components/ui/thread/ReportThread.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="flags.developerMode" class="mb-4 font-bold text-heading">
|
||||
Thread ID:
|
||||
<CopyCode :text="thread.id" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sortedMessages.length > 0"
|
||||
class="bg-raised flex flex-col space-y-4 rounded-xl p-3 sm:p-4"
|
||||
>
|
||||
<ThreadMessage
|
||||
v-for="message in sortedMessages"
|
||||
:key="'message-' + message.id"
|
||||
:thread="thread"
|
||||
:message="message"
|
||||
:members="members"
|
||||
:report="report"
|
||||
:auth="auth"
|
||||
raised
|
||||
@update-thread="() => updateThreadLocal()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="reportClosed">
|
||||
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
|
||||
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2 w-full sm:w-auto">
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="reopenReport()"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
Reopen Thread
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="mt-4">
|
||||
<MarkdownEditor
|
||||
v-model="replyBody"
|
||||
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
|
||||
:on-image-upload="onUploadImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<ButtonStyled v-if="sortedMessages.length > 0" color="brand" class="w-full sm:w-auto">
|
||||
<button
|
||||
:disabled="!replyBody"
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<ReplyIcon class="size-4" />
|
||||
Reply
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" class="w-full sm:w-auto">
|
||||
<button
|
||||
:disabled="!replyBody"
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<SendIcon class="size-4" />
|
||||
Send
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="isStaff(auth.user)" class="w-full sm:w-auto">
|
||||
<button
|
||||
:disabled="!replyBody"
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="sendReply(true)"
|
||||
>
|
||||
<ScaleIcon class="size-4" />
|
||||
<span class="hidden sm:inline">Add private note</span>
|
||||
<span class="sm:hidden">Private note</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<template v-if="isStaff(auth.user)">
|
||||
<ButtonStyled v-if="replyBody" color="red" class="w-full sm:w-auto">
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="closeReport(true)"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
<span class="hidden sm:inline">Close with reply</span>
|
||||
<span class="sm:hidden">Close & reply</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="red" class="w-full sm:w-auto">
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="closeReport()"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
Close report
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CopyCode, MarkdownEditor, ButtonStyled } from "@modrinth/ui";
|
||||
import { ReplyIcon, SendIcon, CheckCircleIcon, ScaleIcon } from "@modrinth/assets";
|
||||
import type { Thread, Report, User, ThreadMessage as TypeThreadMessage } from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import ThreadMessage from "./ThreadMessage.vue";
|
||||
import { useImageUpload } from "~/composables/image-upload.ts";
|
||||
import { isStaff } from "~/helpers/users.js";
|
||||
|
||||
const props = defineProps<{
|
||||
thread: Thread;
|
||||
reporter: User;
|
||||
report: Report;
|
||||
}>();
|
||||
|
||||
const auth = await useAuth();
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateThread: [thread: Thread];
|
||||
}>();
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
const members = computed(() => {
|
||||
const membersMap: Record<string, User> = {
|
||||
[props.reporter.id]: props.reporter,
|
||||
};
|
||||
for (const member of props.thread.members) {
|
||||
membersMap[member.id] = member;
|
||||
}
|
||||
return membersMap;
|
||||
});
|
||||
|
||||
const replyBody = ref("");
|
||||
function setReplyContent(content: string) {
|
||||
replyBody.value = content;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
setReplyContent,
|
||||
});
|
||||
|
||||
const sortedMessages = computed(() => {
|
||||
const messages: TypeThreadMessage[] = [
|
||||
{
|
||||
id: null,
|
||||
author_id: props.reporter.id,
|
||||
body: {
|
||||
type: "text",
|
||||
body: props.report.body || "Report opened.",
|
||||
private: false,
|
||||
replying_to: null,
|
||||
associated_images: [],
|
||||
},
|
||||
created: props.report.created,
|
||||
hide_identity: false,
|
||||
},
|
||||
];
|
||||
if (props.thread) {
|
||||
messages.push(
|
||||
...[...props.thread.messages].sort(
|
||||
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
});
|
||||
|
||||
async function updateThreadLocal() {
|
||||
const threadId = props.report.thread_id;
|
||||
if (threadId) {
|
||||
try {
|
||||
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread;
|
||||
emit("updateThread", thread);
|
||||
} catch (error) {
|
||||
console.error("Failed to update thread:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imageIDs = ref<string[]>([]);
|
||||
|
||||
async function onUploadImage(file: File) {
|
||||
const response = await useImageUpload(file, { context: "thread_message" });
|
||||
|
||||
imageIDs.value.push(response.id);
|
||||
imageIDs.value = imageIDs.value.slice(-10);
|
||||
|
||||
return response.url;
|
||||
}
|
||||
|
||||
async function sendReply(privateMessage = false) {
|
||||
try {
|
||||
const body: any = {
|
||||
body: {
|
||||
type: "text",
|
||||
body: replyBody.value,
|
||||
private: privateMessage,
|
||||
},
|
||||
};
|
||||
|
||||
if (imageIDs.value.length > 0) {
|
||||
body.body = {
|
||||
...body.body,
|
||||
uploaded_images: imageIDs.value,
|
||||
};
|
||||
}
|
||||
|
||||
await useBaseFetch(`thread/${props.thread.id}`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
replyBody.value = "";
|
||||
await updateThreadLocal();
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: "Error sending message",
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const didCloseReport = ref(false);
|
||||
const reportClosed = computed(() => {
|
||||
return didCloseReport.value || (props.report && props.report.closed);
|
||||
});
|
||||
|
||||
async function closeReport(reply = false) {
|
||||
if (reply) {
|
||||
await sendReply();
|
||||
}
|
||||
|
||||
try {
|
||||
await useBaseFetch(`report/${props.report.id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
closed: true,
|
||||
},
|
||||
});
|
||||
await updateThreadLocal();
|
||||
didCloseReport.value = true;
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: "Error closing report",
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function reopenReport() {
|
||||
try {
|
||||
await useBaseFetch(`report/${props.report.id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
closed: false,
|
||||
},
|
||||
});
|
||||
await updateThreadLocal();
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: "Error reopening report",
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -36,7 +36,7 @@
|
||||
v-tooltip="'Modrinth Team'"
|
||||
/>
|
||||
<MicrophoneIcon
|
||||
v-if="report && message.author_id === report.reporterUser.id"
|
||||
v-if="report && message.author_id === report.reporter_user?.id"
|
||||
v-tooltip="'Reporter'"
|
||||
class="reporter-icon"
|
||||
/>
|
||||
|
||||
236
apps/frontend/src/helpers/moderation.ts
Normal file
236
apps/frontend/src/helpers/moderation.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import type { ExtendedReport, OwnershipTarget } from "@modrinth/moderation";
|
||||
import type {
|
||||
Thread,
|
||||
Version,
|
||||
User,
|
||||
Project,
|
||||
TeamMember,
|
||||
Organization,
|
||||
Report,
|
||||
} from "@modrinth/utils";
|
||||
|
||||
export const useModerationCache = () => ({
|
||||
threads: useState<Map<string, Thread>>("moderation-report-cache-threads", () => new Map()),
|
||||
users: useState<Map<string, User>>("moderation-report-cache-users", () => new Map()),
|
||||
projects: useState<Map<string, Project>>("moderation-report-cache-projects", () => new Map()),
|
||||
versions: useState<Map<string, Version>>("moderation-report-cache-versions", () => new Map()),
|
||||
teams: useState<Map<string, TeamMember[]>>("moderation-report-cache-teams", () => new Map()),
|
||||
orgs: useState<Map<string, Organization>>("moderation-report-cache-orgs", () => new Map()),
|
||||
});
|
||||
|
||||
// TODO: @AlexTMjugador - backend should do all of these functions.
|
||||
export async function enrichReportBatch(reports: Report[]): Promise<ExtendedReport[]> {
|
||||
if (reports.length === 0) return [];
|
||||
|
||||
const cache = useModerationCache();
|
||||
|
||||
const threadIDs = reports
|
||||
.map((r) => r.thread_id)
|
||||
.filter(Boolean)
|
||||
.filter((id) => !cache.threads.value.has(id));
|
||||
const userIDs = [
|
||||
...reports.filter((r) => r.item_type === "user").map((r) => r.item_id),
|
||||
...reports.map((r) => r.reporter),
|
||||
].filter((id) => !cache.users.value.has(id));
|
||||
const versionIDs = reports
|
||||
.filter((r) => r.item_type === "version")
|
||||
.map((r) => r.item_id)
|
||||
.filter((id) => !cache.versions.value.has(id));
|
||||
const projectIDs = reports
|
||||
.filter((r) => r.item_type === "project")
|
||||
.map((r) => r.item_id)
|
||||
.filter((id) => !cache.projects.value.has(id));
|
||||
|
||||
const [newThreads, newVersions, newUsers] = await Promise.all([
|
||||
threadIDs.length > 0
|
||||
? (fetchSegmented(threadIDs, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||
Thread[]
|
||||
>)
|
||||
: Promise.resolve([]),
|
||||
versionIDs.length > 0
|
||||
? (fetchSegmented(versionIDs, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||
Version[]
|
||||
>)
|
||||
: Promise.resolve([]),
|
||||
[...new Set(userIDs)].length > 0
|
||||
? (fetchSegmented(
|
||||
[...new Set(userIDs)],
|
||||
(ids) => `users?ids=${asEncodedJsonArray(ids)}`,
|
||||
) as Promise<User[]>)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
newThreads.forEach((t) => cache.threads.value.set(t.id, t));
|
||||
newVersions.forEach((v) => cache.versions.value.set(v.id, v));
|
||||
newUsers.forEach((u) => cache.users.value.set(u.id, u));
|
||||
|
||||
const allVersions = [...newVersions, ...Array.from(cache.versions.value.values())];
|
||||
const fullProjectIds = new Set([
|
||||
...projectIDs,
|
||||
...allVersions
|
||||
.filter((v) => versionIDs.includes(v.id))
|
||||
.map((v) => v.project_id)
|
||||
.filter(Boolean),
|
||||
]);
|
||||
|
||||
const uncachedProjectIds = Array.from(fullProjectIds).filter(
|
||||
(id) => !cache.projects.value.has(id),
|
||||
);
|
||||
const newProjects =
|
||||
uncachedProjectIds.length > 0
|
||||
? ((await fetchSegmented(
|
||||
uncachedProjectIds,
|
||||
(ids) => `projects?ids=${asEncodedJsonArray(ids)}`,
|
||||
)) as Project[])
|
||||
: [];
|
||||
|
||||
newProjects.forEach((p) => cache.projects.value.set(p.id, p));
|
||||
|
||||
const allProjects = [...newProjects, ...Array.from(cache.projects.value.values())];
|
||||
const teamIds = [...new Set(allProjects.map((p) => p.team).filter(Boolean))].filter(
|
||||
(id) => !cache.teams.value.has(id || "invalid team id"),
|
||||
);
|
||||
const orgIds = [...new Set(allProjects.map((p) => p.organization).filter(Boolean))].filter(
|
||||
(id) => !cache.orgs.value.has(id),
|
||||
);
|
||||
|
||||
const [newTeams, newOrgs] = await Promise.all([
|
||||
teamIds.length > 0
|
||||
? (fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||
TeamMember[][]
|
||||
>)
|
||||
: Promise.resolve([]),
|
||||
orgIds.length > 0
|
||||
? (fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||
apiVersion: 3,
|
||||
}) as Promise<Organization[]>)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
newTeams.forEach((team) => {
|
||||
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
|
||||
});
|
||||
newOrgs.forEach((org) => cache.orgs.value.set(org.id, org));
|
||||
|
||||
return reports.map((report) => {
|
||||
const thread = cache.threads.value.get(report.thread_id) || ({} as Thread);
|
||||
const version =
|
||||
report.item_type === "version" ? cache.versions.value.get(report.item_id) : undefined;
|
||||
|
||||
const project =
|
||||
report.item_type === "project"
|
||||
? cache.projects.value.get(report.item_id)
|
||||
: report.item_type === "version" && version
|
||||
? cache.projects.value.get(version.project_id)
|
||||
: undefined;
|
||||
|
||||
let target: OwnershipTarget | undefined;
|
||||
|
||||
if (report.item_type === "user") {
|
||||
const targetUser = cache.users.value.get(report.item_id);
|
||||
if (targetUser) {
|
||||
target = {
|
||||
name: targetUser.username,
|
||||
slug: targetUser.username,
|
||||
avatar_url: targetUser.avatar_url,
|
||||
type: "user",
|
||||
};
|
||||
}
|
||||
} else if (project) {
|
||||
let owner: TeamMember | null = null;
|
||||
let org: Organization | null = null;
|
||||
|
||||
if (project.team) {
|
||||
const teamMembers = cache.teams.value.get(project.team);
|
||||
if (teamMembers) {
|
||||
owner = teamMembers.find((member) => member.role === "Owner") || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (project.organization) {
|
||||
org = cache.orgs.value.get(project.organization) || null;
|
||||
}
|
||||
|
||||
if (org) {
|
||||
target = {
|
||||
name: org.name,
|
||||
avatar_url: org.icon_url,
|
||||
type: "organization",
|
||||
slug: org.slug,
|
||||
};
|
||||
} else if (owner) {
|
||||
target = {
|
||||
name: owner.user.username,
|
||||
avatar_url: owner.user.avatar_url,
|
||||
type: "user",
|
||||
slug: owner.user.username,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...report,
|
||||
thread,
|
||||
reporter_user: cache.users.value.get(report.reporter) || ({} as User),
|
||||
project,
|
||||
user: report.item_type === "user" ? cache.users.value.get(report.item_id) : undefined,
|
||||
version,
|
||||
target,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Doesn't need to be in @modrinth/moderation because it is specific to the frontend.
|
||||
export interface ModerationProject {
|
||||
project: any;
|
||||
owner: TeamMember | null;
|
||||
org: Organization | null;
|
||||
}
|
||||
|
||||
export async function enrichProjectBatch(projects: any[]): Promise<ModerationProject[]> {
|
||||
const teamIds = [...new Set(projects.map((p) => p.team_id).filter(Boolean))];
|
||||
const orgIds = [...new Set(projects.map((p) => p.organization).filter(Boolean))];
|
||||
|
||||
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
|
||||
teamIds.length > 0
|
||||
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
|
||||
: Promise.resolve([]),
|
||||
orgIds.length > 0
|
||||
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||
apiVersion: 3,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const cache = useModerationCache();
|
||||
|
||||
teamsData.forEach((team) => {
|
||||
if (team.length > 0) cache.teams.value.set(team[0].team_id, team);
|
||||
});
|
||||
|
||||
orgsData.forEach((org: Organization) => {
|
||||
cache.orgs.value.set(org.id, org);
|
||||
});
|
||||
|
||||
return projects.map((project) => {
|
||||
let owner: TeamMember | null = null;
|
||||
let org: Organization | null = null;
|
||||
|
||||
if (project.team_id) {
|
||||
const teamMembers = cache.teams.value.get(project.team_id);
|
||||
if (teamMembers) {
|
||||
owner = teamMembers.find((member) => member.role === "Owner") || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (project.organization) {
|
||||
org = cache.orgs.value.get(project.organization) || null;
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
owner,
|
||||
org,
|
||||
} as ModerationProject;
|
||||
});
|
||||
}
|
||||
@@ -295,7 +295,7 @@
|
||||
{
|
||||
id: 'review-projects',
|
||||
color: 'orange',
|
||||
link: '/moderation/review',
|
||||
link: '/moderation/',
|
||||
},
|
||||
{
|
||||
id: 'review-reports',
|
||||
@@ -981,23 +981,6 @@ const userMenuOptions = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
if (
|
||||
(auth.value && auth.value.user && auth.value.user.role === "moderator") ||
|
||||
auth.value.user.role === "admin"
|
||||
) {
|
||||
options = [
|
||||
...options,
|
||||
{
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
id: "moderation",
|
||||
color: "orange",
|
||||
link: "/moderation/review",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
options = [
|
||||
...options,
|
||||
{
|
||||
|
||||
@@ -182,9 +182,6 @@
|
||||
"collection.button.unfollow-project": {
|
||||
"message": "Unfollow project"
|
||||
},
|
||||
"collection.button.upload-icon": {
|
||||
"message": "Upload icon"
|
||||
},
|
||||
"collection.delete-modal.description": {
|
||||
"message": "This will remove this collection forever. This action cannot be undone."
|
||||
},
|
||||
@@ -479,6 +476,30 @@
|
||||
"layout.nav.search": {
|
||||
"message": "Search"
|
||||
},
|
||||
"moderation.filter.by": {
|
||||
"message": "Filter by"
|
||||
},
|
||||
"moderation.moderate": {
|
||||
"message": "Moderate"
|
||||
},
|
||||
"moderation.page.projects": {
|
||||
"message": "Projects"
|
||||
},
|
||||
"moderation.page.reports": {
|
||||
"message": "Reports"
|
||||
},
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Technical Review"
|
||||
},
|
||||
"moderation.search.placeholder": {
|
||||
"message": "Search..."
|
||||
},
|
||||
"moderation.sort.by": {
|
||||
"message": "Sort by"
|
||||
},
|
||||
"moderation.technical.search.placeholder": {
|
||||
"message": "Search tech reviews..."
|
||||
},
|
||||
"profile.button.billing": {
|
||||
"message": "Manage user billing"
|
||||
},
|
||||
|
||||
@@ -689,7 +689,10 @@
|
||||
},
|
||||
{
|
||||
id: 'moderation-checklist',
|
||||
action: () => (showModerationChecklist = true),
|
||||
action: () => {
|
||||
moderationStore.setSingleProject(project.id);
|
||||
showModerationChecklist = true;
|
||||
},
|
||||
color: 'orange',
|
||||
hoverOnly: true,
|
||||
shown:
|
||||
@@ -870,19 +873,6 @@
|
||||
@delete-version="deleteVersion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__ultimate-sidebar">
|
||||
<!-- Uncomment this to enable the old moderation checklist. -->
|
||||
<!-- <ModerationChecklist
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
:project="project"
|
||||
:future-projects="futureProjects"
|
||||
:reset-project="resetProject"
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
/> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -890,9 +880,8 @@
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
class="moderation-checklist"
|
||||
>
|
||||
<NewModerationChecklist
|
||||
<ModerationChecklist
|
||||
:project="project"
|
||||
:future-project-ids="futureProjectIds"
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
@@ -951,14 +940,7 @@ import {
|
||||
useRelativeTime,
|
||||
} from "@modrinth/ui";
|
||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||
import {
|
||||
formatCategory,
|
||||
formatProjectType,
|
||||
isRejected,
|
||||
isStaff,
|
||||
isUnderReview,
|
||||
renderString,
|
||||
} from "@modrinth/utils";
|
||||
import { formatCategory, formatProjectType, renderString } from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { Tooltip } from "floating-vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
@@ -976,11 +958,13 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||
import { userCollectProject } from "~/composables/user.js";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
|
||||
import ModerationChecklist from "~/components/ui/moderation/checklist/ModerationChecklist.vue";
|
||||
import { useModerationStore } from "~/store/moderation.ts";
|
||||
|
||||
const data = useNuxtApp();
|
||||
const route = useNativeRoute();
|
||||
const config = useRuntimeConfig();
|
||||
const moderationStore = useModerationStore();
|
||||
|
||||
const auth = await useAuth();
|
||||
const user = await useUser();
|
||||
@@ -1568,12 +1552,6 @@ const showModerationChecklist = useLocalStorage(
|
||||
);
|
||||
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
||||
|
||||
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
|
||||
|
||||
watch(futureProjectIds, (newValue) => {
|
||||
console.log("Future project IDs updated:", newValue);
|
||||
});
|
||||
|
||||
watch(
|
||||
showModerationChecklist,
|
||||
(newValue) => {
|
||||
@@ -1646,9 +1624,7 @@ const navLinks = computed(() => {
|
||||
{
|
||||
label: formatMessage(messages.moderationTab),
|
||||
href: `${projectUrl}/moderation`,
|
||||
shown:
|
||||
!!currentMember.value &&
|
||||
(isRejected(project.value) || isUnderReview(project.value) || isStaff(auth.value.user)),
|
||||
shown: !!currentMember.value,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -365,8 +365,10 @@ export default defineNuxtComponent({
|
||||
if (e.key === "Escape") {
|
||||
this.expandedGalleryItem = null;
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.stopPropagation();
|
||||
this.previousImage();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.stopPropagation();
|
||||
this.nextImage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +76,15 @@
|
||||
<p>
|
||||
This is a private conversation thread with the Modrinth moderators. They may message you
|
||||
with issues concerning this project. This thread is only checked when you submit your
|
||||
project for review. For additional inquiries, contact
|
||||
<a href="https://support.modrinth.com">Modrinth Support</a>.
|
||||
project for review. For additional inquiries, please go to the
|
||||
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
||||
Modrinth Help Center
|
||||
</a>
|
||||
and click the green bubble to contact support.
|
||||
</p>
|
||||
<p v-if="isApproved(project)" class="warning">
|
||||
<IssuesIcon /> The moderators do not actively monitor this chat. However, they may still see
|
||||
messages here if there is a problem with your project.
|
||||
</p>
|
||||
<ConversationThread
|
||||
v-if="thread"
|
||||
|
||||
@@ -58,6 +58,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<NewModal ref="modifyModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">Modify charge</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="cancel" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Cancel server
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>
|
||||
Whether or not the subscription should be cancelled. Submitting this as "true" will
|
||||
cancel the subscription, while submitting it as "false" will force another charge
|
||||
attempt to be made.
|
||||
</span>
|
||||
</label>
|
||||
<Toggle id="cancel" v-model="cancel" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="modifying" @click="modifyCharge">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Modify charge
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modifyModal.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="page experimental-styles-within">
|
||||
<div
|
||||
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
|
||||
@@ -201,6 +236,12 @@
|
||||
Refund options
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="charge.status === 'failed'" color="red" color-fill="text">
|
||||
<button @click="showModifyModal(subscription)">
|
||||
<CurrencyIcon />
|
||||
Modify charge
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +275,6 @@ import { products } from "~/generated/state.json";
|
||||
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const data = useNuxtApp();
|
||||
const vintl = useVIntl();
|
||||
|
||||
const { formatMessage } = vintl;
|
||||
@@ -304,6 +344,10 @@ const refundTypes = ref(["full", "partial", "none"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(true);
|
||||
|
||||
const modifying = ref(false);
|
||||
const modifyModal = ref();
|
||||
const cancel = ref(false);
|
||||
|
||||
function showRefundModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
refundType.value = "full";
|
||||
@@ -312,6 +356,12 @@ function showRefundModal(charge) {
|
||||
refundModal.value.show();
|
||||
}
|
||||
|
||||
function showModifyModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
cancel.value = false;
|
||||
modifyModal.value.show();
|
||||
}
|
||||
|
||||
async function refundCharge() {
|
||||
refunding.value = true;
|
||||
try {
|
||||
@@ -327,8 +377,7 @@ async function refundCharge() {
|
||||
await refreshCharges();
|
||||
refundModal.value.hide();
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
addNotification({
|
||||
title: "Error refunding",
|
||||
text: err.data?.description ?? err,
|
||||
type: "error",
|
||||
@@ -337,6 +386,32 @@ async function refundCharge() {
|
||||
refunding.value = false;
|
||||
}
|
||||
|
||||
async function modifyCharge() {
|
||||
modifying.value = true;
|
||||
try {
|
||||
await useBaseFetch(`billing/subscription/${selectedCharge.value.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
cancelled: cancel.value,
|
||||
}),
|
||||
internal: true,
|
||||
});
|
||||
addNotification({
|
||||
title: "Resubscription request submitted",
|
||||
text: "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.",
|
||||
type: "success",
|
||||
});
|
||||
await refreshCharges();
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: "Error reattempting charge",
|
||||
text: err.data?.description ?? err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
modifying.value = false;
|
||||
}
|
||||
|
||||
const chargeStatuses = {
|
||||
open: {
|
||||
color: "bg-blue",
|
||||
|
||||
@@ -218,7 +218,7 @@ const username = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const token = ref("");
|
||||
const subscribe = ref(true);
|
||||
const subscribe = ref(false);
|
||||
|
||||
async function createAccount() {
|
||||
startLoading();
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.uploadIconButton) }}
|
||||
</FileInput>
|
||||
<Button
|
||||
v-if="!deletedIcon && (previewImage || collection.icon_url)"
|
||||
@@ -479,10 +478,6 @@ const messages = defineMessages({
|
||||
id: "collection.label.updated-at",
|
||||
defaultMessage: "Updated {ago}",
|
||||
},
|
||||
uploadIconButton: {
|
||||
id: "collection.button.upload-icon",
|
||||
defaultMessage: "Upload icon",
|
||||
},
|
||||
});
|
||||
|
||||
const data = useNuxtApp();
|
||||
|
||||
@@ -1,33 +1,84 @@
|
||||
<template>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<h1>Moderation</h1>
|
||||
<NavStack>
|
||||
<NavStackItem link="/moderation" label="Overview">
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/moderation/review" label="Review projects">
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/moderation/reports" label="Reports">
|
||||
<ReportIcon aria-hidden="true" />
|
||||
</NavStackItem>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<NuxtPage />
|
||||
<div
|
||||
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||
>
|
||||
<h1>Moderation</h1>
|
||||
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
|
||||
<div class="mb-4 sm:hidden">
|
||||
<Chips
|
||||
v-model="selectedChip"
|
||||
:items="mobileNavOptions"
|
||||
:never-empty="true"
|
||||
@change="navigateToPage"
|
||||
/>
|
||||
</div>
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ModrinthIcon, ScaleIcon, ReportIcon } from "@modrinth/assets";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||
import { Chips } from "@modrinth/ui";
|
||||
import NavTabs from "@/components/ui/NavTabs.vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const messages = defineMessages({
|
||||
projectsTitle: {
|
||||
id: "moderation.page.projects",
|
||||
defaultMessage: "Projects",
|
||||
},
|
||||
technicalReviewTitle: {
|
||||
id: "moderation.page.technicalReview",
|
||||
defaultMessage: "Technical Review",
|
||||
},
|
||||
reportsTitle: {
|
||||
id: "moderation.page.reports",
|
||||
defaultMessage: "Reports",
|
||||
},
|
||||
});
|
||||
|
||||
const moderationLinks = [
|
||||
{ label: formatMessage(messages.projectsTitle), href: "/moderation" },
|
||||
{ label: formatMessage(messages.technicalReviewTitle), href: "/moderation/technical-review" },
|
||||
{ label: formatMessage(messages.reportsTitle), href: "/moderation/reports" },
|
||||
];
|
||||
|
||||
const mobileNavOptions = [
|
||||
formatMessage(messages.projectsTitle),
|
||||
formatMessage(messages.technicalReviewTitle),
|
||||
formatMessage(messages.reportsTitle),
|
||||
];
|
||||
|
||||
const selectedChip = computed({
|
||||
get() {
|
||||
const path = route.path;
|
||||
if (path === "/moderation/technical-review") {
|
||||
return formatMessage(messages.technicalReviewTitle);
|
||||
} else if (path.startsWith("/moderation/reports/")) {
|
||||
return formatMessage(messages.reportsTitle);
|
||||
} else {
|
||||
return formatMessage(messages.projectsTitle);
|
||||
}
|
||||
},
|
||||
set(value: string) {
|
||||
navigateToPage(value);
|
||||
},
|
||||
});
|
||||
|
||||
function navigateToPage(selectedOption: string) {
|
||||
if (selectedOption === formatMessage(messages.technicalReviewTitle)) {
|
||||
router.push("/moderation/technical-review");
|
||||
} else if (selectedOption === formatMessage(messages.reportsTitle)) {
|
||||
router.push("/moderation/reports");
|
||||
} else {
|
||||
router.push("/moderation");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,42 +1,339 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Statistics</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Projects</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.projects, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Versions</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.versions, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Files</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.files, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Authors</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.authors, false) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||
<div class="iconified-input flex-1 lg:max-w-md">
|
||||
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||
<input
|
||||
v-model="query"
|
||||
class="h-[40px]"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
@input="goToPage(1)"
|
||||
/>
|
||||
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
<ConfettiExplosion v-if="visible" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="currentFilterType"
|
||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
:name="formatMessage(messages.filterBy)"
|
||||
:options="filterTypes as unknown[]"
|
||||
@change="goToPage(1)"
|
||||
>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||
<FilterIcon class="size-4 flex-shrink-0" />
|
||||
<span class="truncate">{{ selected }} ({{ filteredProjects.length }})</span>
|
||||
</span>
|
||||
</DropdownSelect>
|
||||
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||
:name="formatMessage(messages.sortBy)"
|
||||
:options="sortTypes as unknown[]"
|
||||
@change="goToPage(1)"
|
||||
>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
|
||||
<SortDescIcon v-else class="size-4 flex-shrink-0" />
|
||||
<span class="truncate">{{ selected }}</span>
|
||||
</span>
|
||||
</DropdownSelect>
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="orange" class="w-full sm:w-auto">
|
||||
<button
|
||||
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="moderateAllInFilter()"
|
||||
>
|
||||
<ScaleIcon class="size-4 flex-shrink-0" />
|
||||
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
|
||||
<span class="sm:hidden">Moderate</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
<ConfettiExplosion v-if="visible" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<div v-if="paginatedProjects.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||
<ModerationQueueCard
|
||||
v-for="item in paginatedProjects"
|
||||
v-else
|
||||
:key="item.project.id"
|
||||
:queue-entry="item"
|
||||
:owner="item.owner"
|
||||
:org="item.org"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { formatNumber } from "@modrinth/utils";
|
||||
<script setup lang="ts">
|
||||
import { DropdownSelect, Button, ButtonStyled, Pagination } from "@modrinth/ui";
|
||||
import {
|
||||
XIcon,
|
||||
SearchIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
FilterIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import ConfettiExplosion from "vue-confetti-explosion";
|
||||
import Fuse from "fuse.js";
|
||||
import ModerationQueueCard from "~/components/ui/moderation/ModerationQueueCard.vue";
|
||||
import { useModerationStore } from "~/store/moderation.ts";
|
||||
import { enrichProjectBatch, type ModerationProject } from "~/helpers/moderation.ts";
|
||||
|
||||
useHead({
|
||||
title: "Staff overview - Modrinth",
|
||||
const { formatMessage } = useVIntl();
|
||||
const moderationStore = useModerationStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const visible = ref(false);
|
||||
if (import.meta.client && history && history.state && history.state.confetti) {
|
||||
setTimeout(async () => {
|
||||
history.state.confetti = false;
|
||||
visible.value = true;
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
visible.value = false;
|
||||
}, 5000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: "moderation.search.placeholder",
|
||||
defaultMessage: "Search...",
|
||||
},
|
||||
filterBy: {
|
||||
id: "moderation.filter.by",
|
||||
defaultMessage: "Filter by",
|
||||
},
|
||||
sortBy: {
|
||||
id: "moderation.sort.by",
|
||||
defaultMessage: "Sort by",
|
||||
},
|
||||
moderate: {
|
||||
id: "moderation.moderate",
|
||||
defaultMessage: "Moderate",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: stats } = await useAsyncData("statistics", () => useBaseFetch("statistics"));
|
||||
const { data: allProjects } = await useLazyAsyncData("moderation-projects", async () => {
|
||||
const startTime = performance.now();
|
||||
let currentOffset = 0;
|
||||
const PROJECT_ENDPOINT_COUNT = 350;
|
||||
const allProjects: ModerationProject[] = [];
|
||||
|
||||
const enrichmentPromises: Promise<ModerationProject[]>[] = [];
|
||||
|
||||
while (true) {
|
||||
const projects = (await useBaseFetch(
|
||||
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
||||
{ internal: true },
|
||||
)) as any[];
|
||||
|
||||
if (projects.length === 0) break;
|
||||
|
||||
const enrichmentPromise = enrichProjectBatch(projects);
|
||||
enrichmentPromises.push(enrichmentPromise);
|
||||
|
||||
currentOffset += projects.length;
|
||||
|
||||
if (enrichmentPromises.length >= 3) {
|
||||
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
|
||||
allProjects.push(...completed.flat());
|
||||
}
|
||||
|
||||
if (projects.length < PROJECT_ENDPOINT_COUNT) break;
|
||||
}
|
||||
|
||||
const remainingBatches = await Promise.all(enrichmentPromises);
|
||||
allProjects.push(...remainingBatches.flat());
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
console.debug(
|
||||
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||
);
|
||||
|
||||
return allProjects;
|
||||
});
|
||||
|
||||
const query = ref(route.query.q?.toString() || "");
|
||||
|
||||
watch(
|
||||
query,
|
||||
(newQuery) => {
|
||||
const currentQuery = { ...route.query };
|
||||
if (newQuery) {
|
||||
currentQuery.q = newQuery;
|
||||
} else {
|
||||
delete currentQuery.q;
|
||||
}
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: currentQuery,
|
||||
});
|
||||
},
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(newQueryParam) => {
|
||||
const newValue = newQueryParam?.toString() || "";
|
||||
if (query.value !== newValue) {
|
||||
query.value = newValue;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const currentFilterType = useLocalStorage("moderation-current-filter-type", () => "All projects");
|
||||
const filterTypes: readonly string[] = readonly([
|
||||
"All projects",
|
||||
"Modpacks",
|
||||
"Mods",
|
||||
"Resource Packs",
|
||||
"Data Packs",
|
||||
"Plugins",
|
||||
"Shaders",
|
||||
]);
|
||||
|
||||
const currentSortType = useLocalStorage("moderation-current-sort-type", () => "Oldest");
|
||||
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
|
||||
|
||||
const currentPage = ref(1);
|
||||
const itemsPerPage = 15;
|
||||
const totalPages = computed(() => Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage));
|
||||
|
||||
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,
|
||||
},
|
||||
"owner.user.username",
|
||||
"org.name",
|
||||
"org.slug",
|
||||
],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const filterMap: Record<string, string> = {
|
||||
Modpacks: "modpack",
|
||||
Mods: "mod",
|
||||
"Resource Packs": "resourcepack",
|
||||
"Data Packs": "datapack",
|
||||
Plugins: "plugin",
|
||||
Shaders: "shader",
|
||||
};
|
||||
|
||||
const projectType = filterMap[currentFilterType.value];
|
||||
if (!projectType) return baseFiltered.value;
|
||||
|
||||
return baseFiltered.value.filter((queueItem) =>
|
||||
queueItem.project.project_types.includes(projectType),
|
||||
);
|
||||
});
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
const filtered = [...typeFiltered.value];
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const paginatedProjects = computed(() => {
|
||||
if (!filteredProjects.value) return [];
|
||||
const start = (currentPage.value - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
return filteredProjects.value.slice(start, end);
|
||||
});
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page;
|
||||
}
|
||||
|
||||
function moderateAllInFilter() {
|
||||
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id));
|
||||
navigateTo({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: "project",
|
||||
id: moderationStore.getCurrentProjectId(),
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<ReportView
|
||||
:auth="auth"
|
||||
:report-id="route.params.id"
|
||||
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportView from "~/components/ui/report/ReportView.vue";
|
||||
|
||||
const auth = await useAuth();
|
||||
const route = useNativeRoute();
|
||||
|
||||
useHead({
|
||||
title: `Report ${route.params.id} - Modrinth`,
|
||||
});
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Reports</h2>
|
||||
<ReportsList :auth="auth" moderation />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportsList from "~/components/ui/report/ReportsList.vue";
|
||||
|
||||
const auth = await useAuth();
|
||||
useHead({
|
||||
title: "Reports - Modrinth",
|
||||
});
|
||||
</script>
|
||||
28
apps/frontend/src/pages/moderation/reports/[id].vue
Normal file
28
apps/frontend/src/pages/moderation/reports/[id].vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { Report } from "@modrinth/utils";
|
||||
import { enrichReportBatch } from "~/helpers/moderation.ts";
|
||||
import ModerationReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
|
||||
|
||||
const { params } = useRoute();
|
||||
const reportId = params.id as string;
|
||||
|
||||
const { data: report } = await useAsyncData(`moderation-report-${reportId}`, async () => {
|
||||
try {
|
||||
const report = (await useBaseFetch(`report/${reportId}`, { apiVersion: 3 })) as Report;
|
||||
const enrichedReport = (await enrichReportBatch([report]))[0];
|
||||
return enrichedReport;
|
||||
} catch (error) {
|
||||
console.error("Error fetching report:", error);
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Report not found",
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<ModerationReportCard v-if="report" :report="report" />
|
||||
</div>
|
||||
</template>
|
||||
290
apps/frontend/src/pages/moderation/reports/index.vue
Normal file
290
apps/frontend/src/pages/moderation/reports/index.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||
<div class="iconified-input flex-1 lg:max-w-md">
|
||||
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||
<input
|
||||
v-model="query"
|
||||
class="h-[40px]"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
@input="goToPage(1)"
|
||||
/>
|
||||
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="currentFilterType"
|
||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
:name="formatMessage(messages.filterBy)"
|
||||
:options="filterTypes as unknown[]"
|
||||
@change="goToPage(1)"
|
||||
>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||
<FilterIcon class="size-4 flex-shrink-0" />
|
||||
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
|
||||
</span>
|
||||
</DropdownSelect>
|
||||
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||
:name="formatMessage(messages.sortBy)"
|
||||
:options="sortTypes as unknown[]"
|
||||
@change="goToPage(1)"
|
||||
>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
|
||||
<SortDescIcon v-else class="size-4 flex-shrink-0" />
|
||||
<span class="truncate">{{ selected }}</span>
|
||||
</span>
|
||||
</DropdownSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
|
||||
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
|
||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import type { Report } from "@modrinth/utils";
|
||||
import Fuse from "fuse.js";
|
||||
import type { ExtendedReport } from "@modrinth/moderation";
|
||||
import ReportCard from "~/components/ui/moderation/ModerationReportCard.vue";
|
||||
import { enrichReportBatch } from "~/helpers/moderation.ts";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: "moderation.search.placeholder",
|
||||
defaultMessage: "Search...",
|
||||
},
|
||||
filterBy: {
|
||||
id: "moderation.filter.by",
|
||||
defaultMessage: "Filter by",
|
||||
},
|
||||
sortBy: {
|
||||
id: "moderation.sort.by",
|
||||
defaultMessage: "Sort by",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: allReports } = await useLazyAsyncData("new-moderation-reports", async () => {
|
||||
const startTime = performance.now();
|
||||
let currentOffset = 0;
|
||||
const REPORT_ENDPOINT_COUNT = 350;
|
||||
const allReports: ExtendedReport[] = [];
|
||||
|
||||
const enrichmentPromises: Promise<ExtendedReport[]>[] = [];
|
||||
|
||||
while (true) {
|
||||
const reports = (await useBaseFetch(
|
||||
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`,
|
||||
{ apiVersion: 3 },
|
||||
)) as Report[];
|
||||
|
||||
if (reports.length === 0) break;
|
||||
|
||||
const enrichmentPromise = enrichReportBatch(reports);
|
||||
enrichmentPromises.push(enrichmentPromise);
|
||||
|
||||
currentOffset += reports.length;
|
||||
|
||||
if (enrichmentPromises.length >= 3) {
|
||||
const completed = await Promise.all(enrichmentPromises.splice(0, 2));
|
||||
allReports.push(...completed.flat());
|
||||
}
|
||||
|
||||
if (reports.length < REPORT_ENDPOINT_COUNT) break;
|
||||
}
|
||||
|
||||
const remainingBatches = await Promise.all(enrichmentPromises);
|
||||
allReports.push(...remainingBatches.flat());
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
console.debug(
|
||||
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
|
||||
);
|
||||
|
||||
return allReports;
|
||||
});
|
||||
|
||||
const query = ref(route.query.q?.toString() || "");
|
||||
|
||||
watch(
|
||||
query,
|
||||
(newQuery) => {
|
||||
const currentQuery = { ...route.query };
|
||||
if (newQuery) {
|
||||
currentQuery.q = newQuery;
|
||||
} else {
|
||||
delete currentQuery.q;
|
||||
}
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: currentQuery,
|
||||
});
|
||||
},
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(newQueryParam) => {
|
||||
const newValue = newQueryParam?.toString() || "";
|
||||
if (query.value !== newValue) {
|
||||
query.value = newValue;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const currentFilterType = useLocalStorage("moderation-reports-filter-type", () => "All");
|
||||
const filterTypes: readonly string[] = readonly(["All", "Unread", "Read"]);
|
||||
|
||||
const currentSortType = useLocalStorage("moderation-reports-sort-type", () => "Oldest");
|
||||
const sortTypes: readonly string[] = readonly(["Oldest", "Newest"]);
|
||||
|
||||
const currentPage = ref(1);
|
||||
const itemsPerPage = 15;
|
||||
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (!allReports.value || allReports.value.length === 0) return null;
|
||||
return new Fuse(allReports.value, {
|
||||
keys: [
|
||||
{
|
||||
name: "id",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "report_type",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "item_id",
|
||||
weight: 2,
|
||||
},
|
||||
{
|
||||
name: "reporter_user.username",
|
||||
weight: 2,
|
||||
},
|
||||
"project.name",
|
||||
"project.slug",
|
||||
"user.username",
|
||||
"version.name",
|
||||
"target.name",
|
||||
"target.slug",
|
||||
],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
});
|
||||
});
|
||||
|
||||
const memberRoleMap = computed(() => {
|
||||
if (!allReports.value?.length) return new Map();
|
||||
|
||||
const map = new Map();
|
||||
for (const report of allReports.value) {
|
||||
if (report.thread?.members?.length) {
|
||||
const roleMap = new Map();
|
||||
for (const member of report.thread.members) {
|
||||
roleMap.set(member.id, member.role);
|
||||
}
|
||||
map.set(report.id, roleMap);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const searchResults = computed(() => {
|
||||
if (!query.value || !fuse.value) return null;
|
||||
return fuse.value.search(query.value).map((result) => result.item);
|
||||
});
|
||||
|
||||
const baseFiltered = computed(() => {
|
||||
if (!allReports.value) return [];
|
||||
return query.value && searchResults.value ? searchResults.value : [...allReports.value];
|
||||
});
|
||||
|
||||
const typeFiltered = computed(() => {
|
||||
if (currentFilterType.value === "All") return baseFiltered.value;
|
||||
|
||||
return baseFiltered.value.filter((report) => {
|
||||
const messages = report.thread?.messages || [];
|
||||
|
||||
if (messages.length === 0) {
|
||||
return currentFilterType.value === "Unread";
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage.author_id) return false;
|
||||
|
||||
const roleMap = memberRoleMap.value.get(report.id);
|
||||
if (!roleMap) return false;
|
||||
|
||||
const authorRole = roleMap.get(lastMessage.author_id);
|
||||
const isModeratorMessage = authorRole === "moderator" || authorRole === "admin";
|
||||
|
||||
return currentFilterType.value === "Read" ? isModeratorMessage : !isModeratorMessage;
|
||||
});
|
||||
});
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
const filtered = [...typeFiltered.value];
|
||||
|
||||
if (currentSortType.value === "Oldest") {
|
||||
filtered.sort((a, b) => new Date(a.created).getTime() - new Date(b.created).getTime());
|
||||
} else {
|
||||
filtered.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const paginatedReports = computed(() => {
|
||||
if (!filteredReports.value) return [];
|
||||
const start = (currentPage.value - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
return filteredReports.value.slice(start, end);
|
||||
});
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page;
|
||||
}
|
||||
</script>
|
||||
@@ -1,304 +0,0 @@
|
||||
<template>
|
||||
<section class="universal-card">
|
||||
<h2>Review projects</h2>
|
||||
<div class="input-group">
|
||||
<Chips
|
||||
v-model="projectType"
|
||||
:items="projectTypes"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : formatProjectType(x) + 's')"
|
||||
/>
|
||||
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
|
||||
<SortDescIcon />
|
||||
Sorting by oldest
|
||||
</button>
|
||||
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
|
||||
<SortAscIcon />
|
||||
Sorting by newest
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-highlight"
|
||||
:disabled="projectsFiltered.length === 0"
|
||||
@click="goToProjects()"
|
||||
>
|
||||
<ScaleIcon />
|
||||
Start moderating
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="projectType !== 'all'" class="project-count">
|
||||
Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
|
||||
projects in the queue.
|
||||
</p>
|
||||
<p v-else class="project-count">There are {{ projects.length }} projects in the queue.</p>
|
||||
<p v-if="projectsOver24Hours.length > 0" class="warning project-count">
|
||||
<IssuesIcon />
|
||||
{{ projectsOver24Hours.length }} {{ projectTypePlural }}
|
||||
have been in the queue for over 24 hours.
|
||||
</p>
|
||||
<p v-if="projectsOver48Hours.length > 0" class="danger project-count">
|
||||
<IssuesIcon />
|
||||
{{ projectsOver48Hours.length }} {{ projectTypePlural }}
|
||||
have been in the queue for over 48 hours.
|
||||
</p>
|
||||
<div
|
||||
v-for="project in projectsFiltered.sort((a, b) => {
|
||||
if (oldestFirst) {
|
||||
return b.age - a.age;
|
||||
} else {
|
||||
return a.age - b.age;
|
||||
}
|
||||
})"
|
||||
:key="`project-${project.id}`"
|
||||
class="universal-card recessed project"
|
||||
>
|
||||
<div class="project-title">
|
||||
<div class="mobile-row">
|
||||
<nuxt-link :to="`/project/${project.id}`" class="iconified-stacked-link">
|
||||
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
|
||||
<span class="stacked">
|
||||
<span class="title">{{ project.name }}</span>
|
||||
<span>{{ formatProjectType(project.inferred_project_type) }}</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="mobile-row">
|
||||
by
|
||||
<nuxt-link
|
||||
v-if="project.owner"
|
||||
:to="`/user/${project.owner.user.id}`"
|
||||
class="iconified-link"
|
||||
>
|
||||
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
|
||||
<span>{{ project.owner.user.username }}</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-else-if="project.org"
|
||||
:to="`/organization/${project.org.id}`"
|
||||
class="iconified-link"
|
||||
>
|
||||
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
|
||||
<span>{{ project.org.name }}</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="mobile-row">
|
||||
is requesting to be
|
||||
<ProjectStatusBadge
|
||||
:status="project.requested_status ? project.requested_status : 'approved'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<nuxt-link :to="`/project/${project.id}`" class="iconified-button raised-button">
|
||||
<EyeIcon />
|
||||
View project
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<span v-if="project.queued" :class="`submitter-info ${project.age_warning}`">
|
||||
<IssuesIcon v-if="project.age_warning" />
|
||||
Submitted
|
||||
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
|
||||
formatRelativeTime(project.queued)
|
||||
}}</span>
|
||||
</span>
|
||||
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui";
|
||||
import {
|
||||
UnknownIcon,
|
||||
EyeIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
IssuesIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatProjectType } from "@modrinth/utils";
|
||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
||||
|
||||
useHead({
|
||||
title: "Review projects - Modrinth",
|
||||
});
|
||||
|
||||
const app = useNuxtApp();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const now = app.$dayjs();
|
||||
const TIME_24H = 86400000;
|
||||
const TIME_48H = TIME_24H * 2;
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
const { data: projects } = await useAsyncData("moderation/projects?count=1000", () =>
|
||||
useBaseFetch("moderation/projects?count=1000", { internal: true }),
|
||||
);
|
||||
const members = ref([]);
|
||||
const projectType = ref("all");
|
||||
const oldestFirst = ref(true);
|
||||
|
||||
const projectsFiltered = computed(() =>
|
||||
projects.value.filter(
|
||||
(x) =>
|
||||
projectType.value === "all" ||
|
||||
app.$getProjectTypeForUrl(x.project_types[0], x.loaders) === projectType.value,
|
||||
),
|
||||
);
|
||||
|
||||
const projectsOver24Hours = computed(() =>
|
||||
projectsFiltered.value.filter((project) => project.age >= TIME_24H && project.age < TIME_48H),
|
||||
);
|
||||
const projectsOver48Hours = computed(() =>
|
||||
projectsFiltered.value.filter((project) => project.age >= TIME_48H),
|
||||
);
|
||||
const projectTypePlural = computed(() =>
|
||||
projectType.value === "all"
|
||||
? "projects"
|
||||
: (formatProjectType(projectType.value) + "s").toLowerCase(),
|
||||
);
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
const set = new Set();
|
||||
set.add("all");
|
||||
|
||||
if (projects.value) {
|
||||
for (const project of projects.value) {
|
||||
set.add(project.inferred_project_type);
|
||||
}
|
||||
}
|
||||
|
||||
return [...set];
|
||||
});
|
||||
|
||||
if (projects.value) {
|
||||
const teamIds = projects.value.map((x) => x.team_id);
|
||||
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
|
||||
|
||||
const [{ data: teams }, { data: orgs }] = await Promise.all([
|
||||
useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
|
||||
fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
|
||||
),
|
||||
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
|
||||
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
if (teams.value) {
|
||||
members.value = teams.value;
|
||||
|
||||
projects.value = projects.value.map((project) => {
|
||||
project.owner = members.value
|
||||
? members.value.flat().find((x) => x.team_id === project.team_id && x.role === "Owner")
|
||||
: null;
|
||||
project.org = orgs.value ? orgs.value.find((x) => x.id === project.organization) : null;
|
||||
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE;
|
||||
project.age_warning = "";
|
||||
if (project.age > TIME_24H * 2) {
|
||||
project.age_warning = "danger";
|
||||
} else if (project.age > TIME_24H) {
|
||||
project.age_warning = "warning";
|
||||
}
|
||||
project.inferred_project_type = app.$getProjectTypeForUrl(
|
||||
project.project_types[0],
|
||||
project.loaders,
|
||||
);
|
||||
return project;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function goToProjects() {
|
||||
const project = projectsFiltered.value[0];
|
||||
const remainingProjectIds = projectsFiltered.value.slice(1).map((p) => p.id);
|
||||
|
||||
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingProjectIds));
|
||||
|
||||
await router.push({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: project.project_types[0],
|
||||
id: project.slug ? project.slug : project.id,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.project {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
@media screen and (min-width: 650px) {
|
||||
display: grid;
|
||||
grid-template: "title action" "date action";
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
.submitter-info {
|
||||
margin: 0;
|
||||
grid-area: date;
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--color-red);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.project-count {
|
||||
margin-block: var(--spacing-card-md);
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
grid-area: action;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
display: flex;
|
||||
gap: var(--spacing-card-xs);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.mobile-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.mobile-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-card-xs);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.avatar) {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.size-xs {
|
||||
margin-right: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
386
apps/frontend/src/pages/moderation/technical-review-mockup.vue
Normal file
386
apps/frontend/src/pages/moderation/technical-review-mockup.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col justify-between gap-3 lg:flex-row">
|
||||
<div class="iconified-input flex-1 lg:max-w-md">
|
||||
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||
<input
|
||||
v-model="query"
|
||||
class="h-[40px]"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
@input="updateSearchResults()"
|
||||
/>
|
||||
<Button v-if="query" class="r-btn" @click="() => (query = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="currentFilterType"
|
||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
:name="formatMessage(messages.filterBy)"
|
||||
:options="filterTypes as unknown[]"
|
||||
@change="updateSearchResults()"
|
||||
>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||
<FilterIcon class="size-4 flex-shrink-0" />
|
||||
<span class="truncate">{{ selected }} ({{ filteredReports.length }})</span>
|
||||
</span>
|
||||
</DropdownSelect>
|
||||
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||
:name="formatMessage(messages.sortBy)"
|
||||
:options="sortTypes as unknown[]"
|
||||
@change="updateSearchResults()"
|
||||
>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold text-secondary">
|
||||
<SortAscIcon v-if="selected === 'Oldest'" class="size-4 flex-shrink-0" />
|
||||
<SortDescIcon v-else class="size-4 flex-shrink-0" />
|
||||
<span class="truncate">{{ selected }}</span>
|
||||
</span>
|
||||
</DropdownSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<DelphiReportCard
|
||||
v-for="report in paginatedReports"
|
||||
:key="report.version.id"
|
||||
:report="report"
|
||||
/>
|
||||
<div
|
||||
v-if="!paginatedReports || paginatedReports.length === 0"
|
||||
class="universal-card h-24 animate-pulse"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownSelect, Button, Pagination } from "@modrinth/ui";
|
||||
import { XIcon, SearchIcon, SortAscIcon, SortDescIcon, FilterIcon } from "@modrinth/assets";
|
||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import type { TeamMember, Organization, DelphiReport, Project, Version } from "@modrinth/utils";
|
||||
import Fuse from "fuse.js";
|
||||
import type { OwnershipTarget, ExtendedDelphiReport } from "@modrinth/moderation";
|
||||
import DelphiReportCard from "~/components/ui/moderation/ModerationDelphiReportCard.vue";
|
||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: "moderation.technical.search.placeholder",
|
||||
defaultMessage: "Search tech reviews...",
|
||||
},
|
||||
filterBy: {
|
||||
id: "moderation.filter.by",
|
||||
defaultMessage: "Filter by",
|
||||
},
|
||||
sortBy: {
|
||||
id: "moderation.sort.by",
|
||||
defaultMessage: "Sort by",
|
||||
},
|
||||
});
|
||||
|
||||
async function getProjectQuicklyForMock(projectId: string): Promise<Project> {
|
||||
return (await useBaseFetch(`project/${projectId}`)) as Project;
|
||||
}
|
||||
|
||||
async function getVersionQuicklyForMock(versionId: string): Promise<Version> {
|
||||
return (await useBaseFetch(`version/${versionId}`)) as Version;
|
||||
}
|
||||
|
||||
const mockDelphiReports: DelphiReport[] = [
|
||||
{
|
||||
project: await getProjectQuicklyForMock("7MoE34WK"),
|
||||
version: await getVersionQuicklyForMock("cTkKLWgA"),
|
||||
trace_type: "url_usage",
|
||||
file_path: "me/decce/gnetum/ASMEventHandlerHelper.java",
|
||||
priority_score: 29,
|
||||
status: "pending",
|
||||
detected_at: "2025-04-01T12:00:00Z",
|
||||
} as DelphiReport,
|
||||
{
|
||||
project: await getProjectQuicklyForMock("7MoE34WK"),
|
||||
version: await getVersionQuicklyForMock("cTkKLWgA"),
|
||||
trace_type: "url_usage",
|
||||
file_path: "me/decce/gnetum/SomeOtherFile.java",
|
||||
priority_score: 48,
|
||||
status: "rejected",
|
||||
detected_at: "2025-03-02T12:00:00Z",
|
||||
} as DelphiReport,
|
||||
{
|
||||
project: await getProjectQuicklyForMock("7MoE34WK"),
|
||||
version: await getVersionQuicklyForMock("cTkKLWgA"),
|
||||
trace_type: "url_usage",
|
||||
file_path: "me/decce/gnetum/YetAnotherFile.java",
|
||||
priority_score: 15,
|
||||
status: "approved",
|
||||
detected_at: "2025-02-03T12:00:00Z",
|
||||
} as DelphiReport,
|
||||
];
|
||||
|
||||
const { data: allReports } = await useAsyncData("moderation-tech-reviews", async () => {
|
||||
// TODO: replace with actual API call
|
||||
const delphiReports = mockDelphiReports;
|
||||
|
||||
if (delphiReports.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const teamIds = [...new Set(delphiReports.map((report) => report.project.team).filter(Boolean))];
|
||||
const orgIds = [
|
||||
...new Set(delphiReports.map((report) => report.project.organization).filter(Boolean)),
|
||||
];
|
||||
|
||||
const [teamsData, orgsData]: [TeamMember[][], Organization[]] = await Promise.all([
|
||||
teamIds.length > 0
|
||||
? fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
|
||||
: Promise.resolve([]),
|
||||
orgIds.length > 0
|
||||
? fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||
apiVersion: 3,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const orgTeamIds = orgsData.map((org) => org.team_id).filter(Boolean);
|
||||
const orgTeamsData: TeamMember[][] =
|
||||
orgTeamIds.length > 0
|
||||
? await fetchSegmented(orgTeamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`)
|
||||
: [];
|
||||
|
||||
const teamMap = new Map<string, TeamMember[]>();
|
||||
const orgMap = new Map<string, Organization>();
|
||||
|
||||
teamsData.forEach((team) => {
|
||||
let teamId = null;
|
||||
for (const member of team) {
|
||||
teamId = member.team_id;
|
||||
if (!teamMap.has(teamId)) {
|
||||
teamMap.set(teamId, team);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
orgTeamsData.forEach((team) => {
|
||||
let teamId = null;
|
||||
for (const member of team) {
|
||||
teamId = member.team_id;
|
||||
if (!teamMap.has(teamId)) {
|
||||
teamMap.set(teamId, team);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
orgsData.forEach((org: Organization) => {
|
||||
orgMap.set(org.id, org);
|
||||
});
|
||||
|
||||
const extendedReports: ExtendedDelphiReport[] = delphiReports.map((report) => {
|
||||
let target: OwnershipTarget | undefined;
|
||||
const project = report.project;
|
||||
|
||||
if (project) {
|
||||
let owner: TeamMember | null = null;
|
||||
let org: Organization | null = null;
|
||||
|
||||
if (project.team) {
|
||||
const teamMembers = teamMap.get(project.team);
|
||||
if (teamMembers) {
|
||||
owner = teamMembers.find((member) => member.role === "Owner") || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (project.organization) {
|
||||
org = orgMap.get(project.organization) || null;
|
||||
}
|
||||
|
||||
if (org) {
|
||||
target = {
|
||||
name: org.name,
|
||||
avatar_url: org.icon_url,
|
||||
type: "organization",
|
||||
slug: org.slug,
|
||||
};
|
||||
} else if (owner) {
|
||||
target = {
|
||||
name: owner.user.username,
|
||||
avatar_url: owner.user.avatar_url,
|
||||
type: "user",
|
||||
slug: owner.user.username,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...report,
|
||||
target,
|
||||
};
|
||||
});
|
||||
|
||||
extendedReports.sort((a, b) => b.priority_score - a.priority_score);
|
||||
|
||||
return extendedReports;
|
||||
});
|
||||
|
||||
const query = ref(route.query.q?.toString() || "");
|
||||
watch(
|
||||
query,
|
||||
(newQuery) => {
|
||||
const currentQuery = { ...route.query };
|
||||
if (newQuery) {
|
||||
currentQuery.q = newQuery;
|
||||
} else {
|
||||
delete currentQuery.q;
|
||||
}
|
||||
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: currentQuery,
|
||||
});
|
||||
},
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
(newQueryParam) => {
|
||||
const newValue = newQueryParam?.toString() || "";
|
||||
if (query.value !== newValue) {
|
||||
query.value = newValue;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const currentFilterType = useLocalStorage("moderation-tech-reviews-filter-type", () => "Pending");
|
||||
const filterTypes: readonly string[] = readonly(["All", "Pending", "Approved", "Rejected"]);
|
||||
|
||||
const currentSortType = useLocalStorage("moderation-tech-reviews-sort-type", () => "Priority");
|
||||
const sortTypes: readonly string[] = readonly(["Priority", "Oldest", "Newest"]);
|
||||
|
||||
const currentPage = ref(1);
|
||||
const itemsPerPage = 15;
|
||||
const totalPages = computed(() => Math.ceil((filteredReports.value?.length || 0) / itemsPerPage));
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (!allReports.value || allReports.value.length === 0) return null;
|
||||
return new Fuse(allReports.value, {
|
||||
keys: [
|
||||
{
|
||||
name: "version.id",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "version.version_number",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "project.title",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "project.slug",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "version.files.filename",
|
||||
weight: 2,
|
||||
},
|
||||
{
|
||||
name: "trace_type",
|
||||
weight: 2,
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
weight: 0.5,
|
||||
},
|
||||
"file_path",
|
||||
"project.id",
|
||||
"target.name",
|
||||
"target.slug",
|
||||
],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
});
|
||||
});
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
if (!allReports.value) return [];
|
||||
|
||||
let filtered;
|
||||
|
||||
if (query.value && fuse.value) {
|
||||
const results = fuse.value.search(query.value);
|
||||
filtered = results.map((result) => result.item);
|
||||
} else {
|
||||
filtered = [...allReports.value];
|
||||
}
|
||||
|
||||
if (currentFilterType.value === "Pending") {
|
||||
filtered = filtered.filter((report) => report.status === "pending");
|
||||
} else if (currentFilterType.value === "Approved") {
|
||||
filtered = filtered.filter((report) => report.status === "approved");
|
||||
} else if (currentFilterType.value === "Rejected") {
|
||||
filtered = filtered.filter((report) => report.status === "rejected");
|
||||
}
|
||||
|
||||
if (currentSortType.value === "Priority") {
|
||||
filtered.sort((a, b) => b.priority_score - a.priority_score);
|
||||
} else if (currentSortType.value === "Oldest") {
|
||||
filtered.sort((a, b) => {
|
||||
const dateA = new Date(a.detected_at).getTime();
|
||||
const dateB = new Date(b.detected_at).getTime();
|
||||
return dateA - dateB;
|
||||
});
|
||||
} else {
|
||||
filtered.sort((a, b) => {
|
||||
const dateA = new Date(a.detected_at).getTime();
|
||||
const dateB = new Date(b.detected_at).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const paginatedReports = computed(() => {
|
||||
if (!filteredReports.value) return [];
|
||||
const start = (currentPage.value - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
return filteredReports.value.slice(start, end);
|
||||
});
|
||||
|
||||
function updateSearchResults() {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page;
|
||||
}
|
||||
</script>
|
||||
3
apps/frontend/src/pages/moderation/technical-review.vue
Normal file
3
apps/frontend/src/pages/moderation/technical-review.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<p>Not yet implemented.</p>
|
||||
</template>
|
||||
@@ -96,16 +96,7 @@
|
||||
<UiServersServerListing
|
||||
v-for="server in filteredData"
|
||||
:key="server.server_id"
|
||||
:server_id="server.server_id"
|
||||
:name="server.name"
|
||||
:status="server.status"
|
||||
:game="server.game"
|
||||
:loader="server.loader"
|
||||
:loader_version="server.loader_version"
|
||||
:mc_version="server.mc_version"
|
||||
:upstream="server.upstream"
|
||||
:net="server.net"
|
||||
:flows="server.flows"
|
||||
v-bind="server"
|
||||
/>
|
||||
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
|
||||
</ul>
|
||||
|
||||
@@ -208,15 +208,7 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<UiServersServerListing
|
||||
v-if="subscription.serverInfo"
|
||||
:server_id="subscription.serverInfo.server_id"
|
||||
:name="subscription.serverInfo.name"
|
||||
:status="subscription.serverInfo.status"
|
||||
:game="subscription.serverInfo.game"
|
||||
:loader="subscription.serverInfo.loader"
|
||||
:loader_version="subscription.serverInfo.loader_version"
|
||||
:mc_version="subscription.serverInfo.mc_version"
|
||||
:upstream="subscription.serverInfo.upstream"
|
||||
:net="subscription.serverInfo.net"
|
||||
v-bind="subscription.serverInfo"
|
||||
/>
|
||||
<div v-else class="w-fit">
|
||||
<p>
|
||||
|
||||
98
apps/frontend/src/store/moderation.ts
Normal file
98
apps/frontend/src/store/moderation.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { defineStore, createPinia } from "pinia";
|
||||
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
|
||||
|
||||
export interface ModerationQueue {
|
||||
items: string[];
|
||||
total: number;
|
||||
completed: number;
|
||||
skipped: number;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
const EMPTY_QUEUE: Partial<ModerationQueue> = {
|
||||
items: [],
|
||||
|
||||
// TODO: Consider some form of displaying this in the checklist, maybe at the end
|
||||
total: 0,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
|
||||
function createEmptyQueue(): ModerationQueue {
|
||||
return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue;
|
||||
}
|
||||
|
||||
const pinia = createPinia();
|
||||
pinia.use(piniaPluginPersistedstate);
|
||||
|
||||
export const useModerationStore = defineStore("moderation", {
|
||||
state: () => ({
|
||||
currentQueue: createEmptyQueue(),
|
||||
}),
|
||||
|
||||
getters: {
|
||||
queueLength: (state) => state.currentQueue.items.length,
|
||||
hasItems: (state) => state.currentQueue.items.length > 0,
|
||||
progress: (state) => {
|
||||
if (state.currentQueue.total === 0) return 0;
|
||||
return (state.currentQueue.completed + state.currentQueue.skipped) / state.currentQueue.total;
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setQueue(projectIDs: string[]) {
|
||||
this.currentQueue = {
|
||||
items: [...projectIDs],
|
||||
total: projectIDs.length,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
},
|
||||
|
||||
setSingleProject(projectId: string) {
|
||||
this.currentQueue = {
|
||||
items: [projectId],
|
||||
total: 1,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
},
|
||||
|
||||
completeCurrentProject(projectId: string, status: "completed" | "skipped" = "completed") {
|
||||
if (status === "completed") {
|
||||
this.currentQueue.completed++;
|
||||
} else {
|
||||
this.currentQueue.skipped++;
|
||||
}
|
||||
|
||||
this.currentQueue.items = this.currentQueue.items.filter((id: string) => id !== projectId);
|
||||
this.currentQueue.lastUpdated = new Date();
|
||||
|
||||
return this.currentQueue.items.length > 0;
|
||||
},
|
||||
|
||||
getCurrentProjectId(): string | null {
|
||||
return this.currentQueue.items[0] || null;
|
||||
},
|
||||
|
||||
resetQueue() {
|
||||
this.currentQueue = createEmptyQueue();
|
||||
},
|
||||
},
|
||||
|
||||
persist: {
|
||||
key: "moderation-store",
|
||||
serializer: {
|
||||
serialize: JSON.stringify,
|
||||
deserialize: (value: string) => {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed.currentQueue?.lastUpdated) {
|
||||
parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated);
|
||||
}
|
||||
return parsed;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21\n )\n ",
|
||||
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -25,10 +25,11 @@
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55"
|
||||
"hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n LIMIT $1;\n ",
|
||||
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n OFFSET $2\n LIMIT $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -11,6 +11,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
@@ -18,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "29e171bd746ac5dc1fabae4c9f81c3d1df4e69c860b7d0f6a907377664199217"
|
||||
"hash": "1aea0d5e6936b043cb7727b779d60598aa812c8ef0f5895fa740859321092a1c"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
||||
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -122,6 +122,11 @@
|
||||
"ordinal": 23,
|
||||
"name": "allow_friend_requests",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "is_subscribed_to_newsletter",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -154,8 +159,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0"
|
||||
"hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n LIMIT $2;\n ",
|
||||
"query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n OFFSET $3\n LIMIT $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -11,6 +11,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
@@ -19,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f17a109913015a7a5ab847bb2e73794d6261a08d450de24b450222755e520881"
|
||||
"hash": "be8a5dd2b71fdc279a6fa68fe5384da31afd91d4b480527e2dd8402aef36f12c"
|
||||
}
|
||||
14
apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json
generated
Normal file
14
apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE users\n SET is_subscribed_to_newsletter = TRUE\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n LIMIT $2;\n ",
|
||||
"query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -12,6 +12,7 @@
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
@@ -19,5 +20,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3baabc9f08401801fa290866888c540746fc50c1d79911f08f3322b605ce5c30"
|
||||
"hash": "ccb0315ff52ea4402f53508334a7288fc9f8e77ffd7bce665441ff682384cbf9"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
CREATE INDEX reports_closed ON reports (closed);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN is_subscribed_to_newsletter BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -315,9 +315,13 @@ pub async fn filter_enlisted_version_ids(
|
||||
pub async fn is_visible_collection(
|
||||
collection_data: &DBCollection,
|
||||
user_option: &Option<User>,
|
||||
hide_unlisted: bool,
|
||||
) -> Result<bool, ApiError> {
|
||||
let mut authorized = !collection_data.status.is_hidden()
|
||||
&& !collection_data.projects.is_empty();
|
||||
let mut authorized = (if hide_unlisted {
|
||||
collection_data.status.is_searchable()
|
||||
} else {
|
||||
!collection_data.status.is_hidden()
|
||||
}) && !collection_data.projects.is_empty();
|
||||
if let Some(user) = &user_option {
|
||||
if !authorized
|
||||
&& (user.role.is_mod() || user.id == collection_data.user_id.into())
|
||||
@@ -331,12 +335,17 @@ pub async fn is_visible_collection(
|
||||
pub async fn filter_visible_collections(
|
||||
collections: Vec<DBCollection>,
|
||||
user_option: &Option<User>,
|
||||
hide_unlisted: bool,
|
||||
) -> Result<Vec<crate::models::collections::Collection>, ApiError> {
|
||||
let mut return_collections = Vec::new();
|
||||
let mut check_collections = Vec::new();
|
||||
|
||||
for collection in collections {
|
||||
if (!collection.status.is_hidden() && !collection.projects.is_empty())
|
||||
if ((if hide_unlisted {
|
||||
collection.status.is_searchable()
|
||||
} else {
|
||||
!collection.status.is_hidden()
|
||||
}) && !collection.projects.is_empty())
|
||||
|| user_option.as_ref().is_some_and(|x| x.role.is_mod())
|
||||
{
|
||||
return_collections.push(collection.into());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::AuthProvider;
|
||||
use crate::auth::AuthenticationError;
|
||||
use crate::database::models::user_item;
|
||||
use crate::database::models::{DBUser, user_item};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::User;
|
||||
@@ -44,17 +44,16 @@ where
|
||||
Ok(Some((scopes, User::from_full(db_user))))
|
||||
}
|
||||
|
||||
pub async fn get_user_from_headers<'a, E>(
|
||||
pub async fn get_full_user_from_headers<'a, E>(
|
||||
req: &HttpRequest,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
session_queue: &AuthQueue,
|
||||
required_scopes: Scopes,
|
||||
) -> Result<(Scopes, User), AuthenticationError>
|
||||
) -> Result<(Scopes, DBUser), AuthenticationError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
// Fetch DB user record and minos user from headers
|
||||
let (scopes, db_user) = get_user_record_from_bearer_token(
|
||||
req,
|
||||
None,
|
||||
@@ -65,13 +64,33 @@ where
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
let user = User::from_full(db_user);
|
||||
|
||||
if !scopes.contains(required_scopes) {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
Ok((scopes, user))
|
||||
Ok((scopes, db_user))
|
||||
}
|
||||
|
||||
pub async fn get_user_from_headers<'a, E>(
|
||||
req: &HttpRequest,
|
||||
executor: E,
|
||||
redis: &RedisPool,
|
||||
session_queue: &AuthQueue,
|
||||
required_scopes: Scopes,
|
||||
) -> Result<(Scopes, User), AuthenticationError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let (scopes, db_user) = get_full_user_from_headers(
|
||||
req,
|
||||
executor,
|
||||
redis,
|
||||
session_queue,
|
||||
required_scopes,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((scopes, User::from_full(db_user)))
|
||||
}
|
||||
|
||||
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
|
||||
|
||||
@@ -49,6 +49,8 @@ pub struct DBUser {
|
||||
pub badges: Badges,
|
||||
|
||||
pub allow_friend_requests: bool,
|
||||
|
||||
pub is_subscribed_to_newsletter: bool,
|
||||
}
|
||||
|
||||
impl DBUser {
|
||||
@@ -63,13 +65,13 @@ impl DBUser {
|
||||
avatar_url, raw_avatar_url, bio, created,
|
||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||
email_verified, password, paypal_id, paypal_country, paypal_email,
|
||||
venmo_handle, stripe_customer_id, allow_friend_requests
|
||||
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7,
|
||||
$8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, $22
|
||||
)
|
||||
",
|
||||
self.id as DBUserId,
|
||||
@@ -93,6 +95,7 @@ impl DBUser {
|
||||
self.venmo_handle,
|
||||
self.stripe_customer_id,
|
||||
self.allow_friend_requests,
|
||||
self.is_subscribed_to_newsletter,
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
@@ -178,7 +181,7 @@ impl DBUser {
|
||||
created, role, badges,
|
||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
|
||||
venmo_handle, stripe_customer_id, allow_friend_requests
|
||||
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
|
||||
FROM users
|
||||
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
|
||||
",
|
||||
@@ -212,6 +215,7 @@ impl DBUser {
|
||||
stripe_customer_id: u.stripe_customer_id,
|
||||
totp_secret: u.totp_secret,
|
||||
allow_friend_requests: u.allow_friend_requests,
|
||||
is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
|
||||
};
|
||||
|
||||
acc.insert(u.id, (Some(u.username), user));
|
||||
|
||||
@@ -92,7 +92,7 @@ impl CollectionStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Project pages + info cannot be viewed
|
||||
// Collection pages + info cannot be viewed
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
match self {
|
||||
CollectionStatus::Rejected => true,
|
||||
@@ -103,6 +103,11 @@ impl CollectionStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Collection can be displayed in on user page
|
||||
pub fn is_searchable(&self) -> bool {
|
||||
matches!(self, CollectionStatus::Listed)
|
||||
}
|
||||
|
||||
pub fn is_approved(&self) -> bool {
|
||||
match self {
|
||||
CollectionStatus::Listed => true,
|
||||
|
||||
@@ -276,7 +276,11 @@ pub async fn refund_charge(
|
||||
subscription_interval: charge.subscription_interval,
|
||||
payment_platform: charge.payment_platform,
|
||||
payment_platform_id: id,
|
||||
parent_charge_id: Some(charge.id),
|
||||
parent_charge_id: if refund_amount != 0 {
|
||||
Some(charge.id)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
net,
|
||||
}
|
||||
.upsert(&mut transaction)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::auth::email::send_email;
|
||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::auth::validate::{
|
||||
get_full_user_from_headers, get_user_record_from_bearer_token,
|
||||
};
|
||||
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
|
||||
use crate::database::models::DBUser;
|
||||
use crate::database::models::flow_item::DBFlow;
|
||||
@@ -232,6 +234,7 @@ impl TempUser {
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
allow_friend_requests: true,
|
||||
is_subscribed_to_newsletter: false,
|
||||
}
|
||||
.insert(transaction)
|
||||
.await?;
|
||||
@@ -1291,37 +1294,6 @@ pub async fn delete_auth_provider(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
pub async fn sign_up_sendy(email: &str) -> Result<(), AuthenticationError> {
|
||||
let url = dotenvy::var("SENDY_URL")?;
|
||||
let id = dotenvy::var("SENDY_LIST_ID")?;
|
||||
let api_key = dotenvy::var("SENDY_API_KEY")?;
|
||||
let site_url = dotenvy::var("SITE_URL")?;
|
||||
|
||||
if url.is_empty() || url == "none" {
|
||||
tracing::info!("Sendy URL not set, skipping signup");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut form = HashMap::new();
|
||||
|
||||
form.insert("api_key", &*api_key);
|
||||
form.insert("email", email);
|
||||
form.insert("list", &*id);
|
||||
form.insert("referrer", &*site_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(format!("{url}/subscribe"))
|
||||
.form(&form)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_sendy_subscription(
|
||||
email: &str,
|
||||
) -> Result<bool, AuthenticationError> {
|
||||
@@ -1456,6 +1428,9 @@ pub async fn create_account_with_password(
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
allow_friend_requests: true,
|
||||
is_subscribed_to_newsletter: new_account
|
||||
.sign_up_newsletter
|
||||
.unwrap_or(false),
|
||||
}
|
||||
.insert(&mut transaction)
|
||||
.await?;
|
||||
@@ -1476,10 +1451,6 @@ pub async fn create_account_with_password(
|
||||
&format!("Welcome to Modrinth, {}!", new_account.username),
|
||||
)?;
|
||||
|
||||
if new_account.sign_up_newsletter.unwrap_or(false) {
|
||||
sign_up_sendy(&new_account.email).await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(res))
|
||||
@@ -2420,15 +2391,24 @@ pub async fn subscribe_newsletter(
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if let Some(email) = user.email {
|
||||
sign_up_sendy(&email).await?;
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET is_subscribed_to_newsletter = TRUE
|
||||
WHERE id = $1
|
||||
",
|
||||
user.id.0 as i64,
|
||||
)
|
||||
.execute(&**pool)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"User does not have an email.".to_string(),
|
||||
))
|
||||
}
|
||||
crate::database::models::DBUser::clear_caches(
|
||||
&[(user.id.into(), None)],
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[get("email/subscribe")]
|
||||
@@ -2438,7 +2418,7 @@ pub async fn get_newsletter_subscription_status(
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
let user = get_full_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
@@ -2448,16 +2428,16 @@ pub async fn get_newsletter_subscription_status(
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if let Some(email) = user.email {
|
||||
let is_subscribed = check_sendy_subscription(&email).await?;
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"subscribed": is_subscribed
|
||||
})))
|
||||
} else {
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"subscribed": false
|
||||
})))
|
||||
}
|
||||
let is_subscribed = user.is_subscribed_to_newsletter
|
||||
|| if let Some(email) = user.email {
|
||||
check_sendy_subscription(&email).await?
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"subscribed": is_subscribed
|
||||
})))
|
||||
}
|
||||
|
||||
fn send_email_verify(
|
||||
|
||||
@@ -18,12 +18,14 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResultCount {
|
||||
pub struct ProjectsRequestOptions {
|
||||
#[serde(default = "default_count")]
|
||||
pub count: i16,
|
||||
pub count: u16,
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
}
|
||||
|
||||
fn default_count() -> i16 {
|
||||
fn default_count() -> u16 {
|
||||
100
|
||||
}
|
||||
|
||||
@@ -31,7 +33,7 @@ pub async fn get_projects(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
count: web::Query<ResultCount>,
|
||||
request_opts: web::Query<ProjectsRequestOptions>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_moderator_from_headers(
|
||||
@@ -50,10 +52,12 @@ pub async fn get_projects(
|
||||
SELECT id FROM mods
|
||||
WHERE status = $1
|
||||
ORDER BY queued ASC
|
||||
LIMIT $2;
|
||||
OFFSET $3
|
||||
LIMIT $2
|
||||
",
|
||||
ProjectStatus::Processing.as_str(),
|
||||
count.count as i64
|
||||
request_opts.count as i64,
|
||||
request_opts.offset as i64
|
||||
)
|
||||
.fetch(&**pool)
|
||||
.map_ok(|m| database::models::DBProjectId(m.id))
|
||||
|
||||
@@ -15,10 +15,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResultCount {
|
||||
#[serde(default = "default_count")]
|
||||
pub count: i16,
|
||||
pub count: u16,
|
||||
}
|
||||
|
||||
fn default_count() -> i16 {
|
||||
fn default_count() -> u16 {
|
||||
100
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ pub async fn get_projects(
|
||||
req,
|
||||
pool.clone(),
|
||||
redis.clone(),
|
||||
web::Query(internal::moderation::ResultCount { count: count.count }),
|
||||
web::Query(internal::moderation::ProjectsRequestOptions {
|
||||
count: count.count,
|
||||
offset: 0,
|
||||
}),
|
||||
session_queue,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -43,12 +43,12 @@ pub async fn report_create(
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReportsRequestOptions {
|
||||
#[serde(default = "default_count")]
|
||||
count: i16,
|
||||
count: u16,
|
||||
#[serde(default = "default_all")]
|
||||
all: bool,
|
||||
}
|
||||
|
||||
fn default_count() -> i16 {
|
||||
fn default_count() -> u16 {
|
||||
100
|
||||
}
|
||||
fn default_all() -> bool {
|
||||
@@ -60,7 +60,7 @@ pub async fn reports(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
count: web::Query<ReportsRequestOptions>,
|
||||
request_opts: web::Query<ReportsRequestOptions>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let response = v3::reports::reports(
|
||||
@@ -68,8 +68,9 @@ pub async fn reports(
|
||||
pool,
|
||||
redis,
|
||||
web::Query(v3::reports::ReportsRequestOptions {
|
||||
count: count.count,
|
||||
all: count.all,
|
||||
count: request_opts.count,
|
||||
offset: 0,
|
||||
all: request_opts.all,
|
||||
}),
|
||||
session_queue,
|
||||
)
|
||||
|
||||
@@ -163,7 +163,8 @@ pub async fn collections_get(
|
||||
.ok();
|
||||
|
||||
let collections =
|
||||
filter_visible_collections(collections_data, &user_option).await?;
|
||||
filter_visible_collections(collections_data, &user_option, false)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(collections))
|
||||
}
|
||||
@@ -192,7 +193,7 @@ pub async fn collection_get(
|
||||
.ok();
|
||||
|
||||
if let Some(data) = collection_data {
|
||||
if is_visible_collection(&data, &user_option).await? {
|
||||
if is_visible_collection(&data, &user_option, false).await? {
|
||||
return Ok(HttpResponse::Ok().json(Collection::from(data)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,12 +222,14 @@ pub async fn report_create(
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReportsRequestOptions {
|
||||
#[serde(default = "default_count")]
|
||||
pub count: i16,
|
||||
pub count: u16,
|
||||
#[serde(default)]
|
||||
pub offset: u32,
|
||||
#[serde(default = "default_all")]
|
||||
pub all: bool,
|
||||
}
|
||||
|
||||
fn default_count() -> i16 {
|
||||
fn default_count() -> u16 {
|
||||
100
|
||||
}
|
||||
fn default_all() -> bool {
|
||||
@@ -238,7 +240,7 @@ pub async fn reports(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
count: web::Query<ReportsRequestOptions>,
|
||||
request_opts: web::Query<ReportsRequestOptions>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
@@ -253,15 +255,17 @@ pub async fn reports(
|
||||
|
||||
use futures::stream::TryStreamExt;
|
||||
|
||||
let report_ids = if user.role.is_mod() && count.all {
|
||||
let report_ids = if user.role.is_mod() && request_opts.all {
|
||||
sqlx::query!(
|
||||
"
|
||||
SELECT id FROM reports
|
||||
WHERE closed = FALSE
|
||||
ORDER BY created ASC
|
||||
LIMIT $1;
|
||||
OFFSET $2
|
||||
LIMIT $1
|
||||
",
|
||||
count.count as i64
|
||||
request_opts.count as i64,
|
||||
request_opts.offset as i64
|
||||
)
|
||||
.fetch(&**pool)
|
||||
.map_ok(|m| crate::database::models::ids::DBReportId(m.id))
|
||||
@@ -273,10 +277,12 @@ pub async fn reports(
|
||||
SELECT id FROM reports
|
||||
WHERE closed = FALSE AND reporter = $1
|
||||
ORDER BY created ASC
|
||||
LIMIT $2;
|
||||
OFFSET $3
|
||||
LIMIT $2
|
||||
",
|
||||
user.id.0 as i64,
|
||||
count.count as i64
|
||||
request_opts.count as i64,
|
||||
request_opts.offset as i64
|
||||
)
|
||||
.fetch(&**pool)
|
||||
.map_ok(|m| crate::database::models::ids::DBReportId(m.id))
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
use super::{ApiError, oauth_clients::get_user_clients};
|
||||
use crate::file_hosting::FileHostPublicity;
|
||||
use crate::util::img::delete_old_images;
|
||||
use crate::{
|
||||
auth::{filter_visible_projects, get_user_from_headers},
|
||||
auth::{
|
||||
filter_visible_collections, filter_visible_projects,
|
||||
get_user_from_headers,
|
||||
},
|
||||
database::{models::DBUser, redis::RedisPool},
|
||||
file_hosting::FileHost,
|
||||
file_hosting::{FileHost, FileHostPublicity},
|
||||
models::{
|
||||
collections::{Collection, CollectionStatus},
|
||||
notifications::Notification,
|
||||
pats::Scopes,
|
||||
projects::Project,
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
},
|
||||
queue::session::AuthQueue,
|
||||
util::{
|
||||
routes::read_limited_from_payload,
|
||||
img::delete_old_images, routes::read_limited_from_payload,
|
||||
validate::validation_errors_to_string,
|
||||
},
|
||||
};
|
||||
@@ -244,27 +244,19 @@ pub async fn collections_list(
|
||||
let id_option = DBUser::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||
|
||||
if let Some(id) = id_option.map(|x| x.id) {
|
||||
let user_id: UserId = id.into();
|
||||
|
||||
let can_view_private =
|
||||
user.is_some_and(|y| y.role.is_mod() || y.id == user_id);
|
||||
|
||||
let project_data = DBUser::get_collections(id, &**pool).await?;
|
||||
let collection_data = DBUser::get_collections(id, &**pool).await?;
|
||||
|
||||
let response: Vec<_> = crate::database::models::DBCollection::get_many(
|
||||
&project_data,
|
||||
&collection_data,
|
||||
&**pool,
|
||||
&redis,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| {
|
||||
can_view_private || matches!(x.status, CollectionStatus::Listed)
|
||||
})
|
||||
.map(Collection::from)
|
||||
.collect();
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
let collections =
|
||||
filter_visible_collections(response, &user, true).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(collections))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ hashlink.workspace = true
|
||||
png.workspace = true
|
||||
bytemuck.workspace = true
|
||||
rgb.workspace = true
|
||||
phf.workspace = true
|
||||
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
daedalus.workspace = true
|
||||
|
||||
@@ -5,6 +5,8 @@ use crate::state::attached_world_data::AttachedWorldData;
|
||||
use crate::state::{
|
||||
Profile, ProfileInstallStage, attached_world_data, server_join_log,
|
||||
};
|
||||
use crate::util::protocol_version::OLD_PROTOCOL_VERSIONS;
|
||||
pub use crate::util::protocol_version::ProtocolVersion;
|
||||
pub use crate::util::server_ping::{
|
||||
ServerGameProfile, ServerPlayers, ServerStatus, ServerVersion,
|
||||
};
|
||||
@@ -835,7 +837,7 @@ mod servers_data {
|
||||
|
||||
pub async fn get_profile_protocol_version(
|
||||
profile: &str,
|
||||
) -> Result<Option<i32>> {
|
||||
) -> Result<Option<ProtocolVersion>> {
|
||||
let mut profile = super::profile::get(profile).await?.ok_or_else(|| {
|
||||
ErrorKind::UnmanagedProfileError(format!(
|
||||
"Could not find profile {profile}"
|
||||
@@ -846,7 +848,12 @@ pub async fn get_profile_protocol_version(
|
||||
}
|
||||
|
||||
if let Some(protocol_version) = profile.protocol_version {
|
||||
return Ok(Some(protocol_version));
|
||||
return Ok(Some(ProtocolVersion::modern(protocol_version)));
|
||||
}
|
||||
if let Some(protocol_version) =
|
||||
OLD_PROTOCOL_VERSIONS.get(&profile.game_version)
|
||||
{
|
||||
return Ok(Some(*protocol_version));
|
||||
}
|
||||
|
||||
let minecraft = crate::api::metadata::get_minecraft_versions().await?;
|
||||
@@ -854,7 +861,7 @@ pub async fn get_profile_protocol_version(
|
||||
.versions
|
||||
.iter()
|
||||
.position(|it| it.id == profile.game_version)
|
||||
.ok_or(crate::ErrorKind::LauncherError(format!(
|
||||
.ok_or(ErrorKind::LauncherError(format!(
|
||||
"Invalid game version: {}",
|
||||
profile.game_version
|
||||
)))?;
|
||||
@@ -890,16 +897,19 @@ pub async fn get_profile_protocol_version(
|
||||
profile.protocol_version = version;
|
||||
profile.upsert(&state.pool).await?;
|
||||
}
|
||||
Ok(version)
|
||||
Ok(version.map(ProtocolVersion::modern))
|
||||
}
|
||||
|
||||
pub async fn get_server_status(
|
||||
address: &str,
|
||||
protocol_version: Option<i32>,
|
||||
protocol_version: Option<ProtocolVersion>,
|
||||
) -> Result<ServerStatus> {
|
||||
let (original_host, original_port) = parse_server_address(address)?;
|
||||
let (host, port) =
|
||||
resolve_server_address(original_host, original_port).await?;
|
||||
tracing::debug!(
|
||||
"Pinging {address} with protocol version {protocol_version:?}"
|
||||
);
|
||||
server_ping::get_server_status(
|
||||
&(&host as &str, port),
|
||||
(original_host, original_port),
|
||||
|
||||
@@ -422,7 +422,7 @@ pub async fn install_minecraft(
|
||||
|
||||
pub async fn read_protocol_version_from_jar(
|
||||
path: PathBuf,
|
||||
) -> crate::Result<Option<i32>> {
|
||||
) -> crate::Result<Option<u32>> {
|
||||
let zip = async_zip::tokio::read::fs::ZipFileReader::new(path).await?;
|
||||
let Some(entry_index) = zip
|
||||
.file()
|
||||
@@ -435,7 +435,7 @@ pub async fn read_protocol_version_from_jar(
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct VersionData {
|
||||
protocol_version: Option<i32>,
|
||||
protocol_version: Option<u32>,
|
||||
}
|
||||
|
||||
let mut data = vec![];
|
||||
|
||||
@@ -32,7 +32,7 @@ pub struct Profile {
|
||||
pub icon_path: Option<String>,
|
||||
|
||||
pub game_version: String,
|
||||
pub protocol_version: Option<i32>,
|
||||
pub protocol_version: Option<u32>,
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
|
||||
@@ -320,7 +320,7 @@ impl TryFrom<ProfileQueryResult> for Profile {
|
||||
name: x.name,
|
||||
icon_path: x.icon_path,
|
||||
game_version: x.game_version,
|
||||
protocol_version: x.protocol_version.map(|x| x as i32),
|
||||
protocol_version: x.protocol_version.map(|x| x as u32),
|
||||
loader: ModLoader::from_string(&x.mod_loader),
|
||||
loader_version: x.mod_loader_version,
|
||||
groups: serde_json::from_value(x.groups).unwrap_or_default(),
|
||||
|
||||
@@ -4,4 +4,5 @@ pub mod io;
|
||||
pub mod jre;
|
||||
pub mod platform;
|
||||
pub mod utils; // [AR] Feature
|
||||
pub mod protocol_version;
|
||||
pub mod server_ping;
|
||||
|
||||
478
packages/app-lib/src/util/protocol_version.rs
Normal file
478
packages/app-lib/src/util/protocol_version.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
use phf::phf_map;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Copy, Clone)]
|
||||
pub struct ProtocolVersion {
|
||||
pub version: u32,
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
impl ProtocolVersion {
|
||||
pub const fn modern(version: u32) -> Self {
|
||||
Self {
|
||||
version,
|
||||
legacy: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn legacy(version: u32) -> Self {
|
||||
Self {
|
||||
version,
|
||||
legacy: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The map of protocol versions from before version.json was added. For newer versions, the
|
||||
/// protocol version can just be read from version.json.
|
||||
pub const OLD_PROTOCOL_VERSIONS: phf::Map<&str, ProtocolVersion> = phf_map! {
|
||||
// April fools versions
|
||||
"1.RV-Pre1" => ProtocolVersion::modern(108),
|
||||
"15w14a" => ProtocolVersion::modern(48),
|
||||
"2point0_purple" => ProtocolVersion::legacy(92),
|
||||
"2point0_red" => ProtocolVersion::legacy(91),
|
||||
"2point0_blue" => ProtocolVersion::legacy(90),
|
||||
|
||||
// Normal versions
|
||||
"18w47a" => ProtocolVersion::modern(446),
|
||||
"18w46a" => ProtocolVersion::modern(445),
|
||||
"18w45a" => ProtocolVersion::modern(444),
|
||||
"18w44a" => ProtocolVersion::modern(443),
|
||||
"18w43c" => ProtocolVersion::modern(442),
|
||||
"18w43b" => ProtocolVersion::modern(441),
|
||||
"18w43a" => ProtocolVersion::modern(440),
|
||||
"1.13.2" => ProtocolVersion::modern(404),
|
||||
"1.13.2-pre2" => ProtocolVersion::modern(403),
|
||||
"1.13.2-pre1" => ProtocolVersion::modern(402),
|
||||
"1.13.1" => ProtocolVersion::modern(401),
|
||||
"1.13.1-pre2" => ProtocolVersion::modern(400),
|
||||
"1.13.1-pre1" => ProtocolVersion::modern(399),
|
||||
"18w33a" => ProtocolVersion::modern(398),
|
||||
"18w32a" => ProtocolVersion::modern(397),
|
||||
"18w31a" => ProtocolVersion::modern(396),
|
||||
"18w30b" => ProtocolVersion::modern(395),
|
||||
"18w30a" => ProtocolVersion::modern(394),
|
||||
"1.13" => ProtocolVersion::modern(393),
|
||||
"1.13-pre10" => ProtocolVersion::modern(392),
|
||||
"1.13-pre9" => ProtocolVersion::modern(391),
|
||||
"1.13-pre8" => ProtocolVersion::modern(390),
|
||||
"1.13-pre7" => ProtocolVersion::modern(389),
|
||||
"1.13-pre6" => ProtocolVersion::modern(388),
|
||||
"1.13-pre5" => ProtocolVersion::modern(387),
|
||||
"1.13-pre4" => ProtocolVersion::modern(386),
|
||||
"1.13-pre3" => ProtocolVersion::modern(385),
|
||||
"1.13-pre2" => ProtocolVersion::modern(384),
|
||||
"1.13-pre1" => ProtocolVersion::modern(383),
|
||||
"18w22c" => ProtocolVersion::modern(382),
|
||||
"18w22b" => ProtocolVersion::modern(381),
|
||||
"18w22a" => ProtocolVersion::modern(380),
|
||||
"18w21b" => ProtocolVersion::modern(379),
|
||||
"18w21a" => ProtocolVersion::modern(378),
|
||||
"18w20c" => ProtocolVersion::modern(377),
|
||||
"18w20b" => ProtocolVersion::modern(376),
|
||||
"18w20a" => ProtocolVersion::modern(375),
|
||||
"18w19b" => ProtocolVersion::modern(374),
|
||||
"18w19a" => ProtocolVersion::modern(373),
|
||||
"18w16a" => ProtocolVersion::modern(372),
|
||||
"18w15a" => ProtocolVersion::modern(371),
|
||||
"18w14b" => ProtocolVersion::modern(370),
|
||||
"18w14a" => ProtocolVersion::modern(369),
|
||||
"18w11a" => ProtocolVersion::modern(368),
|
||||
"18w10d" => ProtocolVersion::modern(367),
|
||||
"18w10c" => ProtocolVersion::modern(366),
|
||||
"18w10b" => ProtocolVersion::modern(365),
|
||||
"18w10a" => ProtocolVersion::modern(364),
|
||||
"18w09a" => ProtocolVersion::modern(363),
|
||||
"18w08b" => ProtocolVersion::modern(362),
|
||||
"18w08a" => ProtocolVersion::modern(361),
|
||||
"18w07c" => ProtocolVersion::modern(360),
|
||||
"18w07b" => ProtocolVersion::modern(359),
|
||||
"18w07a" => ProtocolVersion::modern(358),
|
||||
"18w06a" => ProtocolVersion::modern(357),
|
||||
"18w05a" => ProtocolVersion::modern(356),
|
||||
"18w03b" => ProtocolVersion::modern(355),
|
||||
"18w03a" => ProtocolVersion::modern(354),
|
||||
"18w02a" => ProtocolVersion::modern(353),
|
||||
"18w01a" => ProtocolVersion::modern(352),
|
||||
"17w50a" => ProtocolVersion::modern(351),
|
||||
"17w49b" => ProtocolVersion::modern(350),
|
||||
"17w49a" => ProtocolVersion::modern(349),
|
||||
"17w48a" => ProtocolVersion::modern(348),
|
||||
"17w47b" => ProtocolVersion::modern(347),
|
||||
"17w47a" => ProtocolVersion::modern(346),
|
||||
"17w46a" => ProtocolVersion::modern(345),
|
||||
"17w45b" => ProtocolVersion::modern(344),
|
||||
"17w45a" => ProtocolVersion::modern(343),
|
||||
"17w43b" => ProtocolVersion::modern(342),
|
||||
"17w43a" => ProtocolVersion::modern(341),
|
||||
"1.12.2" => ProtocolVersion::modern(340),
|
||||
"1.21.2-pre2" => ProtocolVersion::modern(339),
|
||||
"1.21.2-pre1" => ProtocolVersion::modern(339),
|
||||
"1.12.1" => ProtocolVersion::modern(338),
|
||||
"1.12.1-pre1" => ProtocolVersion::modern(337),
|
||||
"17w31a" => ProtocolVersion::modern(336),
|
||||
"1.12" => ProtocolVersion::modern(335),
|
||||
"1.12-pre7" => ProtocolVersion::modern(334),
|
||||
"1.12-pre6" => ProtocolVersion::modern(333),
|
||||
"1.12-pre5" => ProtocolVersion::modern(332),
|
||||
"1.12-pre4" => ProtocolVersion::modern(331),
|
||||
"1.12-pre3" => ProtocolVersion::modern(330),
|
||||
"1.12-pre2" => ProtocolVersion::modern(329),
|
||||
"1.12-pre1" => ProtocolVersion::modern(328),
|
||||
"17w18b" => ProtocolVersion::modern(327),
|
||||
"17w18a" => ProtocolVersion::modern(326),
|
||||
"17w17b" => ProtocolVersion::modern(325),
|
||||
"17w17a" => ProtocolVersion::modern(324),
|
||||
"17w16b" => ProtocolVersion::modern(323),
|
||||
"17w16a" => ProtocolVersion::modern(322),
|
||||
"17w15a" => ProtocolVersion::modern(321),
|
||||
"17w14a" => ProtocolVersion::modern(320),
|
||||
"17w13b" => ProtocolVersion::modern(319),
|
||||
"17w13a" => ProtocolVersion::modern(318),
|
||||
"17w06a" => ProtocolVersion::modern(317),
|
||||
"1.11.2" => ProtocolVersion::modern(316),
|
||||
"1.11.1" => ProtocolVersion::modern(316),
|
||||
"16w50a" => ProtocolVersion::modern(316),
|
||||
"1.11" => ProtocolVersion::modern(315),
|
||||
"1.11-pre1" => ProtocolVersion::modern(314),
|
||||
"16w44a" => ProtocolVersion::modern(313),
|
||||
"16w43a" => ProtocolVersion::modern(313),
|
||||
"16w42a" => ProtocolVersion::modern(312),
|
||||
"16w41a" => ProtocolVersion::modern(311),
|
||||
"16w40a" => ProtocolVersion::modern(310),
|
||||
"16w39c" => ProtocolVersion::modern(309),
|
||||
"16w39b" => ProtocolVersion::modern(308),
|
||||
"16w39a" => ProtocolVersion::modern(307),
|
||||
"16w38a" => ProtocolVersion::modern(306),
|
||||
"16w36a" => ProtocolVersion::modern(305),
|
||||
"16w35a" => ProtocolVersion::modern(304),
|
||||
"16w33a" => ProtocolVersion::modern(303),
|
||||
"16w32b" => ProtocolVersion::modern(302),
|
||||
"16w32a" => ProtocolVersion::modern(301),
|
||||
"1.10.2" => ProtocolVersion::modern(210),
|
||||
"1.10.1" => ProtocolVersion::modern(210),
|
||||
"1.10" => ProtocolVersion::modern(210),
|
||||
"1.10-pre2" => ProtocolVersion::modern(205),
|
||||
"1.10-pre1" => ProtocolVersion::modern(204),
|
||||
"16w21b" => ProtocolVersion::modern(203),
|
||||
"16w21a" => ProtocolVersion::modern(202),
|
||||
"16w20a" => ProtocolVersion::modern(201),
|
||||
"1.9.4" => ProtocolVersion::modern(110),
|
||||
"1.9.3" => ProtocolVersion::modern(110),
|
||||
"1.9.3-pre3" => ProtocolVersion::modern(110),
|
||||
"1.9.3-pre2" => ProtocolVersion::modern(110),
|
||||
"1.9.3-pre1" => ProtocolVersion::modern(109),
|
||||
"16w15b" => ProtocolVersion::modern(109),
|
||||
"16w15a" => ProtocolVersion::modern(109),
|
||||
"16w14a" => ProtocolVersion::modern(109),
|
||||
"1.9.2" => ProtocolVersion::modern(109),
|
||||
"1.9.1" => ProtocolVersion::modern(108),
|
||||
"1.9.1-pre3" => ProtocolVersion::modern(108),
|
||||
"1.9.1-pre2" => ProtocolVersion::modern(108),
|
||||
"1.9.1-pre1" => ProtocolVersion::modern(107),
|
||||
"1.9" => ProtocolVersion::modern(107),
|
||||
"1.9-pre4" => ProtocolVersion::modern(106),
|
||||
"1.9-pre3" => ProtocolVersion::modern(105),
|
||||
"1.9-pre2" => ProtocolVersion::modern(104),
|
||||
"1.9-pre1" => ProtocolVersion::modern(103),
|
||||
"16w07b" => ProtocolVersion::modern(102),
|
||||
"16w07a" => ProtocolVersion::modern(101),
|
||||
"16w06a" => ProtocolVersion::modern(100),
|
||||
"16w05b" => ProtocolVersion::modern(99),
|
||||
"16w05a" => ProtocolVersion::modern(98),
|
||||
"16w04a" => ProtocolVersion::modern(97),
|
||||
"16w03a" => ProtocolVersion::modern(96),
|
||||
"16w02a" => ProtocolVersion::modern(95),
|
||||
"15w51b" => ProtocolVersion::modern(94),
|
||||
"15w51a" => ProtocolVersion::modern(93),
|
||||
"15w50a" => ProtocolVersion::modern(92),
|
||||
"15w49b" => ProtocolVersion::modern(91),
|
||||
"15w49a" => ProtocolVersion::modern(90),
|
||||
"15w47c" => ProtocolVersion::modern(89),
|
||||
"15w47b" => ProtocolVersion::modern(88),
|
||||
"15w47a" => ProtocolVersion::modern(87),
|
||||
"15w46a" => ProtocolVersion::modern(86),
|
||||
"15w45a" => ProtocolVersion::modern(85),
|
||||
"15w44b" => ProtocolVersion::modern(84),
|
||||
"15w44a" => ProtocolVersion::modern(83),
|
||||
"15w43c" => ProtocolVersion::modern(82),
|
||||
"15w43b" => ProtocolVersion::modern(81),
|
||||
"15w43a" => ProtocolVersion::modern(80),
|
||||
"15w42a" => ProtocolVersion::modern(79),
|
||||
"15w41b" => ProtocolVersion::modern(78),
|
||||
"15w41a" => ProtocolVersion::modern(77),
|
||||
"15w40b" => ProtocolVersion::modern(76),
|
||||
"15w40a" => ProtocolVersion::modern(75),
|
||||
"15w39c" => ProtocolVersion::modern(74),
|
||||
"15w39b" => ProtocolVersion::modern(74),
|
||||
"15w39a" => ProtocolVersion::modern(74),
|
||||
"15w38b" => ProtocolVersion::modern(73),
|
||||
"15w38a" => ProtocolVersion::modern(72),
|
||||
"15w37a" => ProtocolVersion::modern(71),
|
||||
"15w36d" => ProtocolVersion::modern(70),
|
||||
"15w36c" => ProtocolVersion::modern(69),
|
||||
"15w36b" => ProtocolVersion::modern(68),
|
||||
"15w36a" => ProtocolVersion::modern(67),
|
||||
"15w35e" => ProtocolVersion::modern(66),
|
||||
"15w35d" => ProtocolVersion::modern(65),
|
||||
"15w35c" => ProtocolVersion::modern(64),
|
||||
"15w35b" => ProtocolVersion::modern(63),
|
||||
"15w35a" => ProtocolVersion::modern(62),
|
||||
"15w34d" => ProtocolVersion::modern(61),
|
||||
"15w34c" => ProtocolVersion::modern(60),
|
||||
"15w34b" => ProtocolVersion::modern(59),
|
||||
"15w34a" => ProtocolVersion::modern(58),
|
||||
"15w33c" => ProtocolVersion::modern(57),
|
||||
"15w33b" => ProtocolVersion::modern(56),
|
||||
"15w33a" => ProtocolVersion::modern(55),
|
||||
"15w32c" => ProtocolVersion::modern(54),
|
||||
"15w32b" => ProtocolVersion::modern(53),
|
||||
"15w32a" => ProtocolVersion::modern(52),
|
||||
"15w31c" => ProtocolVersion::modern(51),
|
||||
"15w31b" => ProtocolVersion::modern(50),
|
||||
"15w31a" => ProtocolVersion::modern(49),
|
||||
"1.8.9" => ProtocolVersion::modern(47),
|
||||
"1.8.8" => ProtocolVersion::modern(47),
|
||||
"1.8.7" => ProtocolVersion::modern(47),
|
||||
"1.8.6" => ProtocolVersion::modern(47),
|
||||
"1.8.5" => ProtocolVersion::modern(47),
|
||||
"1.8.4" => ProtocolVersion::modern(47),
|
||||
"1.8.3" => ProtocolVersion::modern(47),
|
||||
"1.8.2" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre7" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre6" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre5" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre4" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre3" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre2" => ProtocolVersion::modern(47),
|
||||
"1.8.2-pre1" => ProtocolVersion::modern(47),
|
||||
"1.8.1" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre5" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre4" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre3" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre2" => ProtocolVersion::modern(47),
|
||||
"1.8.1-pre1" => ProtocolVersion::modern(47),
|
||||
"1.8" => ProtocolVersion::modern(47),
|
||||
"1.8-pre3" => ProtocolVersion::modern(46),
|
||||
"1.8-pre2" => ProtocolVersion::modern(45),
|
||||
"1.8-pre1" => ProtocolVersion::modern(44),
|
||||
"14w34d" => ProtocolVersion::modern(43),
|
||||
"14w34c" => ProtocolVersion::modern(42),
|
||||
"14w34b" => ProtocolVersion::modern(41),
|
||||
"14w34a" => ProtocolVersion::modern(40),
|
||||
"14w33c" => ProtocolVersion::modern(39),
|
||||
"14w33b" => ProtocolVersion::modern(38),
|
||||
"14w33a" => ProtocolVersion::modern(37),
|
||||
"14w32d" => ProtocolVersion::modern(36),
|
||||
"14w32c" => ProtocolVersion::modern(35),
|
||||
"14w32b" => ProtocolVersion::modern(34),
|
||||
"14w32a" => ProtocolVersion::modern(33),
|
||||
"14w31a" => ProtocolVersion::modern(32),
|
||||
"14w30c" => ProtocolVersion::modern(31),
|
||||
"14w30b" => ProtocolVersion::modern(30),
|
||||
"14w30a" => ProtocolVersion::modern(30),
|
||||
"14w29b" => ProtocolVersion::modern(29),
|
||||
"14w29a" => ProtocolVersion::modern(29),
|
||||
"14w28b" => ProtocolVersion::modern(28),
|
||||
"14w28a" => ProtocolVersion::modern(27),
|
||||
"14w27b" => ProtocolVersion::modern(26),
|
||||
"14w27a" => ProtocolVersion::modern(26),
|
||||
"14w26c" => ProtocolVersion::modern(25),
|
||||
"14w26b" => ProtocolVersion::modern(24),
|
||||
"14w26a" => ProtocolVersion::modern(23),
|
||||
"14w25b" => ProtocolVersion::modern(22),
|
||||
"14w25a" => ProtocolVersion::modern(21),
|
||||
"14w21b" => ProtocolVersion::modern(20),
|
||||
"14w21a" => ProtocolVersion::modern(19),
|
||||
"14w20b" => ProtocolVersion::modern(18),
|
||||
"14w20a" => ProtocolVersion::modern(18),
|
||||
"14w19a" => ProtocolVersion::modern(17),
|
||||
"14w18b" => ProtocolVersion::modern(16),
|
||||
"14w18a" => ProtocolVersion::modern(16),
|
||||
"14w17a" => ProtocolVersion::modern(15),
|
||||
"14w11b" => ProtocolVersion::modern(14),
|
||||
"14w11a" => ProtocolVersion::modern(14),
|
||||
"14w10c" => ProtocolVersion::modern(13),
|
||||
"14w10b" => ProtocolVersion::modern(13),
|
||||
"14w10a" => ProtocolVersion::modern(13),
|
||||
"14w08a" => ProtocolVersion::modern(12),
|
||||
"14w07a" => ProtocolVersion::modern(11),
|
||||
"14w06b" => ProtocolVersion::modern(10),
|
||||
"14w06a" => ProtocolVersion::modern(10),
|
||||
"14w05b" => ProtocolVersion::modern(9),
|
||||
"14w05a" => ProtocolVersion::modern(9),
|
||||
"14w04b" => ProtocolVersion::modern(8),
|
||||
"14w04a" => ProtocolVersion::modern(7),
|
||||
"14w03b" => ProtocolVersion::modern(6),
|
||||
"14w03a" => ProtocolVersion::modern(6),
|
||||
"14w02c" => ProtocolVersion::modern(5),
|
||||
"14w02b" => ProtocolVersion::modern(5),
|
||||
"14w02a" => ProtocolVersion::modern(5),
|
||||
"1.7.10" => ProtocolVersion::modern(5),
|
||||
"1.7.10-pre4" => ProtocolVersion::modern(5),
|
||||
"1.7.10-pre3" => ProtocolVersion::modern(5),
|
||||
"1.7.10-pre2" => ProtocolVersion::modern(5),
|
||||
"1.7.10-pre1" => ProtocolVersion::modern(5),
|
||||
"1.7.9" => ProtocolVersion::modern(5),
|
||||
"1.7.8" => ProtocolVersion::modern(5),
|
||||
"1.7.7" => ProtocolVersion::modern(5),
|
||||
"1.7.6" => ProtocolVersion::modern(5),
|
||||
"1.7.6-pre2" => ProtocolVersion::modern(5),
|
||||
"1.7.6-pre1" => ProtocolVersion::modern(5),
|
||||
"1.7.5" => ProtocolVersion::modern(4),
|
||||
"1.7.4" => ProtocolVersion::modern(4),
|
||||
"1.7.3" => ProtocolVersion::modern(4),
|
||||
"13w49a" => ProtocolVersion::modern(4),
|
||||
"13w48b" => ProtocolVersion::modern(4),
|
||||
"13w48a" => ProtocolVersion::modern(4),
|
||||
"13w47e" => ProtocolVersion::modern(4),
|
||||
"13w47d" => ProtocolVersion::modern(4),
|
||||
"13w47c" => ProtocolVersion::modern(4),
|
||||
"13w47b" => ProtocolVersion::modern(4),
|
||||
"13w47a" => ProtocolVersion::modern(4),
|
||||
"1.7.2" => ProtocolVersion::modern(4),
|
||||
"1.7.1" => ProtocolVersion::modern(3),
|
||||
"1.7" => ProtocolVersion::modern(3),
|
||||
"13w43a" => ProtocolVersion::modern(2),
|
||||
"13w42b" => ProtocolVersion::modern(1),
|
||||
"13w42a" => ProtocolVersion::modern(1),
|
||||
"13w41b" => ProtocolVersion::modern(0),
|
||||
"13w41a" => ProtocolVersion::modern(0),
|
||||
"13w39b" => ProtocolVersion::legacy(80),
|
||||
"13w39a" => ProtocolVersion::legacy(80),
|
||||
"13w38c" => ProtocolVersion::legacy(79),
|
||||
"13w38b" => ProtocolVersion::legacy(79),
|
||||
"13w38a" => ProtocolVersion::legacy(79),
|
||||
"1.6.4" => ProtocolVersion::legacy(78),
|
||||
"1.6.3" => ProtocolVersion::legacy(77),
|
||||
"13w37b" => ProtocolVersion::legacy(76),
|
||||
"13w37a" => ProtocolVersion::legacy(76),
|
||||
"13w36b" => ProtocolVersion::legacy(75),
|
||||
"13w36a" => ProtocolVersion::legacy(75),
|
||||
"1.6.2" => ProtocolVersion::legacy(74),
|
||||
"1.6.1" => ProtocolVersion::legacy(73),
|
||||
"1.6" => ProtocolVersion::legacy(72),
|
||||
"13w26a" => ProtocolVersion::legacy(72),
|
||||
"13w25c" => ProtocolVersion::legacy(71),
|
||||
"13w25b" => ProtocolVersion::legacy(71),
|
||||
"13w25a" => ProtocolVersion::legacy(71),
|
||||
"13w24b" => ProtocolVersion::legacy(70),
|
||||
"13w24a" => ProtocolVersion::legacy(69),
|
||||
"13w23b" => ProtocolVersion::legacy(68),
|
||||
"13w23a" => ProtocolVersion::legacy(67),
|
||||
"13w22a" => ProtocolVersion::legacy(67),
|
||||
"13w21b" => ProtocolVersion::legacy(67),
|
||||
"13w21a" => ProtocolVersion::legacy(67),
|
||||
"13w19a" => ProtocolVersion::legacy(66),
|
||||
"13w18c" => ProtocolVersion::legacy(65),
|
||||
"13w18b" => ProtocolVersion::legacy(65),
|
||||
"13w18a" => ProtocolVersion::legacy(65),
|
||||
"13w17a" => ProtocolVersion::legacy(64),
|
||||
"13w16b" => ProtocolVersion::legacy(63),
|
||||
"13w16a" => ProtocolVersion::legacy(62),
|
||||
"1.5.2" => ProtocolVersion::legacy(61),
|
||||
"1.5.1" => ProtocolVersion::legacy(60),
|
||||
"13w12~" => ProtocolVersion::legacy(60),
|
||||
"13w11a" => ProtocolVersion::legacy(60),
|
||||
"1.5" => ProtocolVersion::legacy(60),
|
||||
"13w10b" => ProtocolVersion::legacy(60),
|
||||
"13w10a" => ProtocolVersion::legacy(60),
|
||||
"13w09c" => ProtocolVersion::legacy(60),
|
||||
"13w09b" => ProtocolVersion::legacy(59),
|
||||
"13w09a" => ProtocolVersion::legacy(59),
|
||||
"13w07a" => ProtocolVersion::legacy(58),
|
||||
"13w06a" => ProtocolVersion::legacy(58),
|
||||
"13w05b" => ProtocolVersion::legacy(56),
|
||||
"13w05a" => ProtocolVersion::legacy(56),
|
||||
"13w04a" => ProtocolVersion::legacy(55),
|
||||
"13w03a" => ProtocolVersion::legacy(54),
|
||||
"13w02b" => ProtocolVersion::legacy(53),
|
||||
"13w02a" => ProtocolVersion::legacy(53),
|
||||
"13w01b" => ProtocolVersion::legacy(52),
|
||||
"13w01a" => ProtocolVersion::legacy(52),
|
||||
"1.4.7" => ProtocolVersion::legacy(51),
|
||||
"1.4.6" => ProtocolVersion::legacy(51),
|
||||
"12w50b" => ProtocolVersion::legacy(51),
|
||||
"12w50a" => ProtocolVersion::legacy(51),
|
||||
"12w49a" => ProtocolVersion::legacy(50),
|
||||
"1.4.5" => ProtocolVersion::legacy(49),
|
||||
"1.4.4" => ProtocolVersion::legacy(49),
|
||||
"1.4.3" => ProtocolVersion::legacy(48),
|
||||
"1.4.2" => ProtocolVersion::legacy(47),
|
||||
"1.4.1" => ProtocolVersion::legacy(47),
|
||||
"1.4" => ProtocolVersion::legacy(47),
|
||||
"12w42b" => ProtocolVersion::legacy(47),
|
||||
"12w42a" => ProtocolVersion::legacy(46),
|
||||
"12w41b" => ProtocolVersion::legacy(46),
|
||||
"12w41a" => ProtocolVersion::legacy(46),
|
||||
"12w40b" => ProtocolVersion::legacy(45),
|
||||
"12w40a" => ProtocolVersion::legacy(44),
|
||||
"12w39b" => ProtocolVersion::legacy(43),
|
||||
"12w39a" => ProtocolVersion::legacy(43),
|
||||
"12w38b" => ProtocolVersion::legacy(43),
|
||||
"12w38a" => ProtocolVersion::legacy(43),
|
||||
"12w37a" => ProtocolVersion::legacy(42),
|
||||
"12w36a" => ProtocolVersion::legacy(42),
|
||||
"12w34b" => ProtocolVersion::legacy(42),
|
||||
"12w34a" => ProtocolVersion::legacy(41),
|
||||
"12w32a" => ProtocolVersion::legacy(40),
|
||||
"1.3.2" => ProtocolVersion::legacy(39),
|
||||
"1.3.1" => ProtocolVersion::legacy(39),
|
||||
"1.3" => ProtocolVersion::legacy(39),
|
||||
"12w30e" => ProtocolVersion::legacy(39),
|
||||
"12w30d" => ProtocolVersion::legacy(39),
|
||||
"12w30c" => ProtocolVersion::legacy(39),
|
||||
"12w30b" => ProtocolVersion::legacy(38),
|
||||
"12w30a" => ProtocolVersion::legacy(38),
|
||||
"12w27a" => ProtocolVersion::legacy(38),
|
||||
"12w26a" => ProtocolVersion::legacy(37),
|
||||
"12w25a" => ProtocolVersion::legacy(37),
|
||||
"12w24a" => ProtocolVersion::legacy(36),
|
||||
"12w23b" => ProtocolVersion::legacy(35),
|
||||
"12w23a" => ProtocolVersion::legacy(35),
|
||||
"12w22a" => ProtocolVersion::legacy(34),
|
||||
"12w21b" => ProtocolVersion::legacy(33),
|
||||
"12w21a" => ProtocolVersion::legacy(33),
|
||||
"12w19a" => ProtocolVersion::legacy(32),
|
||||
"12w18a" => ProtocolVersion::legacy(32),
|
||||
"12w17a" => ProtocolVersion::legacy(31),
|
||||
"12w16a" => ProtocolVersion::legacy(30),
|
||||
"12w15a" => ProtocolVersion::legacy(29),
|
||||
"1.2.5" => ProtocolVersion::legacy(29),
|
||||
"1.2.4" => ProtocolVersion::legacy(29),
|
||||
"1.2.3" => ProtocolVersion::legacy(28),
|
||||
"1.2.2" => ProtocolVersion::legacy(28),
|
||||
"1.2.1" => ProtocolVersion::legacy(28),
|
||||
"1.2" => ProtocolVersion::legacy(28),
|
||||
"12w08a" => ProtocolVersion::legacy(28),
|
||||
"12w07b" => ProtocolVersion::legacy(27),
|
||||
"12w07a" => ProtocolVersion::legacy(27),
|
||||
"12w06a" => ProtocolVersion::legacy(25),
|
||||
"12w05b" => ProtocolVersion::legacy(24),
|
||||
"12w05a" => ProtocolVersion::legacy(24),
|
||||
"12w04a" => ProtocolVersion::legacy(24),
|
||||
"12w03a" => ProtocolVersion::legacy(24),
|
||||
"1.1" => ProtocolVersion::legacy(23),
|
||||
"12w01a" => ProtocolVersion::legacy(23),
|
||||
"11w50a" => ProtocolVersion::legacy(22),
|
||||
"11w49a" => ProtocolVersion::legacy(22),
|
||||
"11w48a" => ProtocolVersion::legacy(22),
|
||||
"11w47a" => ProtocolVersion::legacy(22),
|
||||
"1.0.1" => ProtocolVersion::legacy(22),
|
||||
"1.0.0" => ProtocolVersion::legacy(22),
|
||||
"1.0.0-rc2-1" => ProtocolVersion::legacy(22),
|
||||
"1.0.0-rc2-2" => ProtocolVersion::legacy(22),
|
||||
"1.0.0-rc2-3" => ProtocolVersion::legacy(22),
|
||||
"1.0.0-rc1" => ProtocolVersion::legacy(22),
|
||||
"b1.9-pre6" => ProtocolVersion::legacy(22),
|
||||
"b1.9-pre5" => ProtocolVersion::legacy(21),
|
||||
"b1.9-pre4" => ProtocolVersion::legacy(20),
|
||||
"b1.9-pre3" => ProtocolVersion::legacy(19),
|
||||
"b1.9-pre2" => ProtocolVersion::legacy(19),
|
||||
"b1.9-pre1" => ProtocolVersion::legacy(18),
|
||||
"b1.8.1" => ProtocolVersion::legacy(17),
|
||||
"b1.8" => ProtocolVersion::legacy(17),
|
||||
"b1.8-pre2" => ProtocolVersion::legacy(16),
|
||||
"b1.8-pre1-1" => ProtocolVersion::legacy(15),
|
||||
"b1.8-pre1-2" => ProtocolVersion::legacy(15),
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::ErrorKind;
|
||||
use crate::error::Result;
|
||||
use crate::util::protocol_version::ProtocolVersion;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use std::time::Duration;
|
||||
@@ -42,16 +43,23 @@ pub struct ServerGameProfile {
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct ServerVersion {
|
||||
pub name: String,
|
||||
pub protocol: i32,
|
||||
pub protocol: u32,
|
||||
#[serde(skip_deserializing)]
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
pub async fn get_server_status(
|
||||
address: &impl ToSocketAddrs,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<i32>,
|
||||
protocol_version: Option<ProtocolVersion>,
|
||||
) -> Result<ServerStatus> {
|
||||
select! {
|
||||
res = modern::status(address, original_address, protocol_version) => res,
|
||||
res = async {
|
||||
match protocol_version {
|
||||
Some(ProtocolVersion { legacy: true, version }) => legacy::status(address, original_address, Some(version as u8)).await,
|
||||
protocol => modern::status(address, original_address, protocol.map(|v| v.version)).await,
|
||||
}
|
||||
} => res,
|
||||
_ = tokio::time::sleep(Duration::from_secs(30)) => Err(ErrorKind::OtherError(
|
||||
format!("Ping of {}:{} timed out", original_address.0, original_address.1)
|
||||
).into())
|
||||
@@ -68,7 +76,7 @@ mod modern {
|
||||
pub async fn status(
|
||||
address: &impl ToSocketAddrs,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<i32>,
|
||||
protocol_version: Option<u32>,
|
||||
) -> crate::Result<ServerStatus> {
|
||||
let mut stream = TcpStream::connect(address).await?;
|
||||
handshake(&mut stream, original_address, protocol_version).await?;
|
||||
@@ -80,10 +88,10 @@ mod modern {
|
||||
async fn handshake(
|
||||
stream: &mut TcpStream,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<i32>,
|
||||
protocol_version: Option<u32>,
|
||||
) -> crate::Result<()> {
|
||||
let (host, port) = original_address;
|
||||
let protocol_version = protocol_version.unwrap_or(-1);
|
||||
let protocol_version = protocol_version.map_or(-1, |x| x as i32);
|
||||
|
||||
const PACKET_ID: i32 = 0;
|
||||
const NEXT_STATE: i32 = 1;
|
||||
@@ -221,3 +229,95 @@ mod modern {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod legacy {
|
||||
use super::ServerStatus;
|
||||
use crate::worlds::{ServerPlayers, ServerVersion};
|
||||
use crate::{Error, ErrorKind};
|
||||
use serde_json::value::to_raw_value;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, ToSocketAddrs};
|
||||
|
||||
pub async fn status(
|
||||
address: &impl ToSocketAddrs,
|
||||
original_address: (&str, u16),
|
||||
protocol_version: Option<u8>,
|
||||
) -> crate::Result<ServerStatus> {
|
||||
let protocol_version = protocol_version.unwrap_or(74);
|
||||
|
||||
let mut packet = vec![0xfe];
|
||||
if protocol_version >= 47 {
|
||||
packet.push(0x01);
|
||||
}
|
||||
if protocol_version >= 73 {
|
||||
packet.push(0xfa);
|
||||
write_legacy(&mut packet, "MC|PingHost");
|
||||
|
||||
let (host, port) = original_address;
|
||||
let len_index = packet.len();
|
||||
packet.push(protocol_version);
|
||||
write_legacy(&mut packet, host);
|
||||
packet.extend_from_slice(&(port as u32).to_be_bytes());
|
||||
packet.splice(
|
||||
len_index..len_index,
|
||||
((packet.len() - len_index) as u16).to_be_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut stream = TcpStream::connect(address).await?;
|
||||
stream.write_all(&packet).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let packet_id = stream.read_u8().await?;
|
||||
if packet_id != 0xff {
|
||||
return Err(Error::from(ErrorKind::InputError(
|
||||
"Unexpected legacy status response".to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
let data_length = stream.read_u16().await?;
|
||||
let mut data = vec![0u8; data_length as usize * 2];
|
||||
stream.read_exact(&mut data).await?;
|
||||
|
||||
drop(stream);
|
||||
|
||||
let data = String::from_utf16_lossy(
|
||||
&data
|
||||
.chunks_exact(2)
|
||||
.map(|a| u16::from_be_bytes([a[0], a[1]]))
|
||||
.collect::<Vec<u16>>(),
|
||||
);
|
||||
let mut ancient_server = false;
|
||||
let mut parts = data.split('\0');
|
||||
if parts.next() != Some("§1") {
|
||||
ancient_server = true;
|
||||
parts = data.split('§');
|
||||
}
|
||||
|
||||
Ok(ServerStatus {
|
||||
version: (!ancient_server).then(|| ServerVersion {
|
||||
protocol: parts
|
||||
.next()
|
||||
.and_then(|x| x.parse().ok())
|
||||
.unwrap_or(0),
|
||||
name: parts.next().unwrap_or("").to_owned(),
|
||||
legacy: true,
|
||||
}),
|
||||
description: parts.next().and_then(|x| to_raw_value(x).ok()),
|
||||
players: Some(ServerPlayers {
|
||||
online: parts.next().and_then(|x| x.parse().ok()).unwrap_or(-1),
|
||||
max: parts.next().and_then(|x| x.parse().ok()).unwrap_or(-1),
|
||||
sample: vec![],
|
||||
}),
|
||||
favicon: None,
|
||||
enforces_secure_chat: false,
|
||||
ping: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn write_legacy(out: &mut Vec<u8>, text: &str) {
|
||||
let encoded = text.encode_utf16().collect::<Vec<_>>();
|
||||
out.extend_from_slice(&(encoded.len() as u16).to_be_bytes());
|
||||
out.extend(encoded.into_iter().flat_map(u16::to_be_bytes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import _CodeIcon from './icons/code.svg?component'
|
||||
import _CoffeeIcon from './icons/coffee.svg?component'
|
||||
import _CogIcon from './icons/cog.svg?component'
|
||||
import _CoinsIcon from './icons/coins.svg?component'
|
||||
import _CollapseIcon from './icons/collapse.svg?component'
|
||||
import _CollectionIcon from './icons/collection.svg?component'
|
||||
import _CompassIcon from './icons/compass.svg?component'
|
||||
import _ContractIcon from './icons/contract.svg?component'
|
||||
@@ -52,6 +53,7 @@ import _DatabaseIcon from './icons/database.svg?component'
|
||||
import _DownloadIcon from './icons/download.svg?component'
|
||||
import _DropdownIcon from './icons/dropdown.svg?component'
|
||||
import _EditIcon from './icons/edit.svg?component'
|
||||
import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component'
|
||||
import _ExpandIcon from './icons/expand.svg?component'
|
||||
import _ExternalIcon from './icons/external.svg?component'
|
||||
import _EyeOffIcon from './icons/eye-off.svg?component'
|
||||
@@ -229,6 +231,7 @@ export const CodeIcon = _CodeIcon
|
||||
export const CoffeeIcon = _CoffeeIcon
|
||||
export const CogIcon = _CogIcon
|
||||
export const CoinsIcon = _CoinsIcon
|
||||
export const CollapseIcon = _CollapseIcon
|
||||
export const CollectionIcon = _CollectionIcon
|
||||
export const CompassIcon = _CompassIcon
|
||||
export const ContractIcon = _ContractIcon
|
||||
@@ -243,6 +246,7 @@ export const DatabaseIcon = _DatabaseIcon
|
||||
export const DownloadIcon = _DownloadIcon
|
||||
export const DropdownIcon = _DropdownIcon
|
||||
export const EditIcon = _EditIcon
|
||||
export const EllipsisVerticalIcon = _EllipsisVerticalIcon
|
||||
export const ExpandIcon = _ExpandIcon
|
||||
export const ExternalIcon = _ExternalIcon
|
||||
export const EyeOffIcon = _EyeOffIcon
|
||||
|
||||
8
packages/assets/icons/collapse.svg
Normal file
8
packages/assets/icons/collapse.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-minimize-icon lucide-minimize">
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 414 B |
1
packages/assets/icons/ellipsis-vertical.svg
Normal file
1
packages/assets/icons/ellipsis-vertical.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis-vertical-icon lucide-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1,2 @@
|
||||
**Client:** `%PROJECT_CLIENT_SIDE%` \
|
||||
**Server:** `%PROJECT_SERVER_SIDE%`
|
||||
3
packages/moderation/data/messages/reports/antivirus.md
Normal file
3
packages/moderation/data/messages/reports/antivirus.md
Normal file
@@ -0,0 +1,3 @@
|
||||
Unfortunately, anti-virus software has consistently been found to be an unreliable tool for Minecraft mods.
|
||||
|
||||
If you have evidence of malicious activity concerning a specific mod, or of malicious code decompiled from a mod on Modrinth, please create a new Report and provide the required details, thank you.
|
||||
@@ -0,0 +1,3 @@
|
||||
Thank you for your report.
|
||||
|
||||
This project was confirmed to be malicious after a detailed investigation. Luckily, thanks to your report and quick action from our team, we have reason to believe this did not impact a significant amount of users and we have taken precautions to prevent this malicious code from appearing on Modrinth again.
|
||||
@@ -0,0 +1,6 @@
|
||||
Unfortunately, the Moderation team is unable to assist with your issue.
|
||||
|
||||
The reporting system is exclusively for reporting issues to Moderation staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported. The members of the project you're reporting do not see that you have submitted a report.
|
||||
|
||||
If you are having issues with crashes, please check out [our FAQ section](https://support.modrinth.com/aen/articles/8792916) to learn how to diagnose and fix crashes.
|
||||
For other project-specific issues consider asking the project's own community, check for a Discord or Issues link on the project page.
|
||||
@@ -0,0 +1,5 @@
|
||||
Unfortunately, the Moderation team is unable to assist with your issue.
|
||||
|
||||
The reporting system is exclusively for reporting issues to Moderation staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported.
|
||||
|
||||
Please reach out to the [Modrinth Help Center](https://support.modrinth.com/) so we can better assist you and bring up your concerns with our platform tean,
|
||||
3
packages/moderation/data/messages/reports/spam.md
Normal file
3
packages/moderation/data/messages/reports/spam.md
Normal file
@@ -0,0 +1,3 @@
|
||||
The reporting system is exclusively for reporting issues to Modrinth staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported. The members of the project you're reporting do not see that you have submitted a report.
|
||||
|
||||
Please ensure you are using the Reports system appropriately, repeated misuse may result in account suspension.
|
||||
3
packages/moderation/data/messages/reports/stale.md
Normal file
3
packages/moderation/data/messages/reports/stale.md
Normal file
@@ -0,0 +1,3 @@
|
||||
We haven't received a response in some time, so we're closing this report thread.
|
||||
|
||||
If you have additional information to share we ask that you create a new report.
|
||||
@@ -1,3 +1,5 @@
|
||||
## Misuse of Slug
|
||||
## Misuse of custom URL
|
||||
|
||||
Per section 5.2 of %RULES% must accurately represent your project.
|
||||
We ask that you ensure your project's %PROJECT_SLUG_FLINK% accurately represents your project.
|
||||
Your current slug of `%PROJECT_SLUG%` may not accurately match your project's Name or contain excess information.
|
||||
A mismatched URL may make it more difficult for users to find your content. Abbreviations or similar are fine to use if applicable. If your preferred URL is not available, and you cannot find a matching public project, let us know in this moderation thread when you resubmit your project, and our moderation team may be able to free it up for your project.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
## Private Use
|
||||
|
||||
Under normal circumstances, your project would be rejected due to the issues listed above.
|
||||
However, since your project is not intended for for public use, these requirements will be waived and your project will be unlisted. This means it will remain accessible through a direct link without appearing in public search results, allowing you to share it privately.
|
||||
However, since your project is not intended for public use, these requirements will be waived and your project will be unlisted. This means it will remain accessible through a direct link without appearing in public search results, allowing you to share it privately.
|
||||
If you're okay with this, or submitted your project to be unlisted already, than no further action is necessary.
|
||||
If you would like to publish your project publicly, please address all moderation concerns before resubmitting this project.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
## Unsupported Project
|
||||
|
||||
Unfortunately, Modrinth does not currently support the upload of %INVALID_TYPE%.
|
||||
|
||||
If you would like to publish this project in the future and help Modrinth grow, consider creating an [issue](https://github.com/modrinth/code/issues) suggesting support for this type of content.
|
||||
|
||||
We appreciate your understanding and look forward to hosting your other creations.
|
||||
34
packages/moderation/data/report-quick-replies.ts
Normal file
34
packages/moderation/data/report-quick-replies.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ReportQuickReply } from '../types/reports'
|
||||
|
||||
export default [
|
||||
{
|
||||
label: 'Antivirus',
|
||||
message: async () => (await import('./messages/reports/antivirus.md?raw')).default,
|
||||
private: false,
|
||||
},
|
||||
{
|
||||
label: 'Spam',
|
||||
message: async () => (await import('./messages/reports/spam.md?raw')).default,
|
||||
private: false,
|
||||
},
|
||||
{
|
||||
label: 'Gameplay Issue',
|
||||
message: async () => (await import('./messages/reports/gameplay-issue.md?raw')).default,
|
||||
private: false,
|
||||
},
|
||||
{
|
||||
label: 'Platform Issue',
|
||||
message: async () => (await import('./messages/reports/platform-issue.md?raw')).default,
|
||||
private: false,
|
||||
},
|
||||
{
|
||||
label: 'Stale',
|
||||
message: async () => (await import('./messages/reports/stale.md?raw')).default,
|
||||
private: false,
|
||||
},
|
||||
{
|
||||
label: 'Confirmed Malware',
|
||||
message: async () => (await import('./messages/reports/confirmed-malware.md?raw')).default,
|
||||
private: false,
|
||||
},
|
||||
] as ReadonlyArray<ReportQuickReply>
|
||||
@@ -31,7 +31,9 @@ const categories: Stage = {
|
||||
weight: 701,
|
||||
suggestedStatus: 'flagged',
|
||||
severity: 'low',
|
||||
shouldShow: (project) => project.categories.includes('optimization'),
|
||||
shouldShow: (project) =>
|
||||
project.categories.includes('optimization') ||
|
||||
project.additional_categories.includes('optimization'),
|
||||
message: async () =>
|
||||
(await import('../messages/categories/inaccurate.md?raw')).default +
|
||||
(await import('../messages/categories/optimization_misused.md?raw')).default,
|
||||
|
||||
@@ -25,6 +25,7 @@ const gallery: Stage = {
|
||||
weight: 901,
|
||||
suggestedStatus: 'flagged',
|
||||
severity: 'low',
|
||||
shouldShow: (project) => project.gallery && project.gallery.length > 0,
|
||||
message: async () => (await import('../messages/gallery/not-relevant.md?raw')).default,
|
||||
} as ButtonAction,
|
||||
],
|
||||
|
||||
@@ -7,7 +7,8 @@ const sideTypes: Stage = {
|
||||
id: 'environment',
|
||||
icon: GlobeIcon,
|
||||
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
|
||||
navigate: '/settings#side-types',
|
||||
navigate: '/settings',
|
||||
text: async () => (await import('../messages/checklist-text/side_types.md?raw')).default,
|
||||
actions: [
|
||||
{
|
||||
id: 'side_types_inaccurate_modpack',
|
||||
|
||||
@@ -68,7 +68,7 @@ const versions: Stage = {
|
||||
message: async () => '',
|
||||
enablesActions: [
|
||||
{
|
||||
id: 'versions_incorrect_project_type_options',
|
||||
id: 'versions_alternate_versions_options',
|
||||
type: 'dropdown',
|
||||
label: 'How are the alternate versions distributed?',
|
||||
options: [
|
||||
@@ -150,7 +150,24 @@ const versions: Stage = {
|
||||
severity: `medium`,
|
||||
weight: 1004,
|
||||
message: async () => (await import('../messages/versions/broken_version.md?raw')).default,
|
||||
},
|
||||
} as ButtonAction,
|
||||
{
|
||||
id: 'unsupported_project_type',
|
||||
type: 'button',
|
||||
label: `Unsupported`,
|
||||
suggestedStatus: `rejected`,
|
||||
severity: `medium`,
|
||||
weight: 1005,
|
||||
message: async () =>
|
||||
(await import('../messages/versions/unsupported_project.md?raw')).default,
|
||||
relevantExtraInput: [
|
||||
{
|
||||
label: 'Project Type',
|
||||
required: true,
|
||||
variable: 'INVALID_TYPE',
|
||||
},
|
||||
],
|
||||
} as ButtonAction,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ export * from './types/actions'
|
||||
export * from './types/messages'
|
||||
export * from './types/stage'
|
||||
export * from './types/keybinds'
|
||||
export * from './types/reports'
|
||||
export * from './utils'
|
||||
export { finalPermissionMessages } from './data/modpack-permissions-stage'
|
||||
|
||||
export { finalPermissionMessages } from './data/modpack-permissions-stage'
|
||||
export { default as checklist } from './data/checklist'
|
||||
export { default as keybinds } from './data/keybinds'
|
||||
export { default as reportQuickReplies } from './data/report-quick-replies'
|
||||
|
||||
28
packages/moderation/types/reports.ts
Normal file
28
packages/moderation/types/reports.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Project, Report, Thread, User, Version, DelphiReport } from '@modrinth/utils'
|
||||
|
||||
export interface OwnershipTarget {
|
||||
name: string
|
||||
slug: string
|
||||
avatar_url?: string
|
||||
type: 'user' | 'organization'
|
||||
}
|
||||
|
||||
export interface ExtendedReport extends Report {
|
||||
thread: Thread
|
||||
reporter_user: User
|
||||
project?: Project
|
||||
user?: User
|
||||
version?: Version
|
||||
target?: OwnershipTarget
|
||||
}
|
||||
|
||||
export interface ExtendedDelphiReport extends DelphiReport {
|
||||
target?: OwnershipTarget
|
||||
}
|
||||
|
||||
export interface ReportQuickReply {
|
||||
label: string
|
||||
message: string | ((report: ExtendedReport) => Promise<string> | string)
|
||||
shouldShow?: (report: ExtendedReport) => boolean
|
||||
private?: boolean
|
||||
}
|
||||
@@ -317,6 +317,11 @@ export function flattenProjectVariables(project: Project): Record<string, string
|
||||
vars[`PROJECT_PERMANENT_LINK`] = `https://modrinth.com/project/${project.id}`
|
||||
vars[`PROJECT_SETTINGS_LINK`] = `https://modrinth.com/project/${project.id}/settings`
|
||||
vars[`PROJECT_SETTINGS_FLINK`] = `[Settings](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_TITLE_FLINK`] = `[Name](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_SLUG_FLINK`] = `[URL](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_SUMMARY_FLINK`] = `[Summary](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_ENVIRONMENT_FLINK`] =
|
||||
`[Environment Information](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_TAGS_LINK`] = `https://modrinth.com/project/${project.id}/settings/tags`
|
||||
vars[`PROJECT_TAGS_FLINK`] = `[Tags](https://modrinth.com/project/${project.id}/settings/tags)`
|
||||
vars[`PROJECT_DESCRIPTION_LINK`] =
|
||||
|
||||
97
packages/ui/src/components/base/CollapsibleRegion.vue
Normal file
97
packages/ui/src/components/base/CollapsibleRegion.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-xl border-[2px] border-solid border-divider shadow-lg"
|
||||
:class="{ 'max-h-32': isCollapsed }"
|
||||
>
|
||||
<div
|
||||
class="px-4 pt-4"
|
||||
:class="{
|
||||
'content-disabled pb-16': isCollapsed,
|
||||
'pb-4': !isCollapsed,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCollapsed"
|
||||
class="pointer-events-none absolute inset-0 bg-gradient-to-b from-transparent to-button-bg"
|
||||
></div>
|
||||
|
||||
<div class="absolute bottom-4 left-1/2 z-20 -translate-x-1/2">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button class="flex items-center gap-1 text-xs" @click="toggleCollapsed">
|
||||
<ExpandIcon v-if="isCollapsed" />
|
||||
<CollapseIcon v-else />
|
||||
{{ isCollapsed ? expandText : collapseText }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import { ExpandIcon, CollapseIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initiallyCollapsed?: boolean
|
||||
expandText?: string
|
||||
collapseText?: string
|
||||
}>(),
|
||||
{
|
||||
initiallyCollapsed: true,
|
||||
expandText: 'Expand',
|
||||
collapseText: 'Collapse',
|
||||
},
|
||||
)
|
||||
|
||||
const isCollapsed = ref(props.initiallyCollapsed)
|
||||
|
||||
function toggleCollapsed() {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
function setCollapsed(value: boolean) {
|
||||
isCollapsed.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
isCollapsed,
|
||||
setCollapsed,
|
||||
toggleCollapsed,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.content-disabled {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
|
||||
:deep(*) {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
|
||||
:deep(button),
|
||||
:deep(input),
|
||||
:deep(textarea),
|
||||
:deep(select),
|
||||
:deep(a),
|
||||
:deep([tabindex]) {
|
||||
tabindex: -1 !important;
|
||||
}
|
||||
|
||||
:deep(*:focus) {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -163,7 +163,6 @@ const onFocus = () => {
|
||||
}
|
||||
|
||||
const onBlur = (event) => {
|
||||
console.log(event)
|
||||
if (!isChildOfDropdown(event.relatedTarget)) {
|
||||
dropdownVisible.value = false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export { default as Card } from './base/Card.vue'
|
||||
export { default as Checkbox } from './base/Checkbox.vue'
|
||||
export { default as Chips } from './base/Chips.vue'
|
||||
export { default as Collapsible } from './base/Collapsible.vue'
|
||||
export { default as CollapsibleRegion } from './base/CollapsibleRegion.vue'
|
||||
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
|
||||
export { default as CopyCode } from './base/CopyCode.vue'
|
||||
export { default as DoubleIcon } from './base/DoubleIcon.vue'
|
||||
|
||||
@@ -18,7 +18,7 @@ export type DonationPlatform =
|
||||
| { short: 'ko-fi'; name: 'Ko-fi' }
|
||||
| { short: 'other'; name: 'Other' }
|
||||
|
||||
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader'
|
||||
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'plugin' | 'datapack'
|
||||
export type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
|
||||
|
||||
export type GameVersion = string
|
||||
@@ -65,7 +65,8 @@ export interface Project {
|
||||
client_side: Environment
|
||||
server_side: Environment
|
||||
|
||||
team: ModrinthId
|
||||
team?: ModrinthId
|
||||
team_id: ModrinthId
|
||||
thread_id: ModrinthId
|
||||
organization: ModrinthId
|
||||
|
||||
@@ -76,6 +77,7 @@ export interface Project {
|
||||
donation_urls: DonationLink<DonationPlatform>[]
|
||||
|
||||
published: string
|
||||
created?: string
|
||||
updated: string
|
||||
approved: string
|
||||
queued: string
|
||||
@@ -295,6 +297,60 @@ export type Report = {
|
||||
body: string
|
||||
}
|
||||
|
||||
// Threads
|
||||
export interface Thread {
|
||||
id: string
|
||||
type: ThreadType
|
||||
project_id: string | null
|
||||
report_id: string | null
|
||||
messages: ThreadMessage[]
|
||||
members: User[]
|
||||
}
|
||||
|
||||
export type ThreadType = 'project' | 'report' | 'direct_message'
|
||||
|
||||
export interface ThreadMessage {
|
||||
id: string | null
|
||||
author_id: string | null
|
||||
body: MessageBody
|
||||
created: string
|
||||
hide_identity: boolean
|
||||
}
|
||||
|
||||
export type MessageBody =
|
||||
| TextMessageBody
|
||||
| StatusChangeMessageBody
|
||||
| ThreadClosureMessageBody
|
||||
| ThreadReopenMessageBody
|
||||
| DeletedMessageBody
|
||||
|
||||
export interface TextMessageBody {
|
||||
type: 'text'
|
||||
body: string
|
||||
private: boolean
|
||||
replying_to: string | null
|
||||
associated_images: string[]
|
||||
}
|
||||
|
||||
export interface StatusChangeMessageBody {
|
||||
type: 'status_change'
|
||||
new_status: ProjectStatus
|
||||
old_status: ProjectStatus
|
||||
}
|
||||
|
||||
export interface ThreadClosureMessageBody {
|
||||
type: 'thread_closure'
|
||||
}
|
||||
|
||||
export interface ThreadReopenMessageBody {
|
||||
type: 'thread_reopen'
|
||||
}
|
||||
|
||||
export interface DeletedMessageBody {
|
||||
type: 'deleted'
|
||||
private: boolean
|
||||
}
|
||||
|
||||
// Moderation
|
||||
export interface ModerationModpackPermissionApprovalType {
|
||||
id:
|
||||
@@ -379,3 +435,38 @@ export interface ModerationJudgement {
|
||||
export interface ModerationJudgements {
|
||||
[sha1: string]: ModerationJudgement
|
||||
}
|
||||
|
||||
// Delphi
|
||||
export interface DelphiReport {
|
||||
id: string
|
||||
project: Project
|
||||
version: Version
|
||||
priority_score: number
|
||||
detected_at: string
|
||||
trace_type:
|
||||
| 'reflection_indirection'
|
||||
| 'xor_obfuscation'
|
||||
| 'included_libraries'
|
||||
| 'suspicious_binaries'
|
||||
| 'corrupt_classes'
|
||||
| 'suspicious_classes'
|
||||
| 'url_usage'
|
||||
| 'classloader_usage'
|
||||
| 'processbuilder_usage'
|
||||
| 'runtime_exec_usage'
|
||||
| 'jni_usage'
|
||||
| 'main_method'
|
||||
| 'native_loading'
|
||||
| 'malformed_jar'
|
||||
| 'nested_jar_too_deep'
|
||||
| 'failed_decompilation'
|
||||
| 'analysis_failure'
|
||||
| 'malware_easyforme'
|
||||
| 'malware_simplyloader'
|
||||
file_path: string
|
||||
// pending = not reviewed yet.
|
||||
// approved = approved as malicious, removed from modrinth
|
||||
// rejected = not approved as malicious, remains on modrinth?
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
content?: string
|
||||
}
|
||||
|
||||
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
@@ -284,6 +284,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
|
||||
pinia-plugin-persistedstate:
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(@pinia/nuxt@0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(pinia@2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))
|
||||
prettier:
|
||||
specifier: ^3.6.2
|
||||
version: 3.6.2
|
||||
@@ -296,6 +299,9 @@ importers:
|
||||
three:
|
||||
specifier: ^0.172.0
|
||||
version: 0.172.0
|
||||
vue-confetti-explosion:
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2(vue@3.5.13(typescript@5.5.4))
|
||||
vue-multiselect:
|
||||
specifier: 3.0.0-alpha.2
|
||||
version: 3.0.0-alpha.2
|
||||
@@ -3941,6 +3947,9 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
deep-pick-omit@1.2.1:
|
||||
resolution: {integrity: sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==}
|
||||
|
||||
deepmerge@4.3.1:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3990,6 +3999,9 @@ packages:
|
||||
destr@2.0.3:
|
||||
resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==}
|
||||
|
||||
destr@2.0.5:
|
||||
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
|
||||
|
||||
destroy@1.2.0:
|
||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
@@ -6253,6 +6265,20 @@ packages:
|
||||
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pinia-plugin-persistedstate@4.4.1:
|
||||
resolution: {integrity: sha512-lmuMPpXla2zJKjxEq34e1E9P9jxkWEhcVwwioCCE0izG45kkTOvQfCzvwhW3i38cvnaWC7T1eRdkd15Re59ldw==}
|
||||
peerDependencies:
|
||||
'@nuxt/kit': '>=3.0.0'
|
||||
'@pinia/nuxt': '>=0.10.0'
|
||||
pinia: '>=3.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@nuxt/kit':
|
||||
optional: true
|
||||
'@pinia/nuxt':
|
||||
optional: true
|
||||
pinia:
|
||||
optional: true
|
||||
|
||||
pinia@2.1.7:
|
||||
resolution: {integrity: sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==}
|
||||
peerDependencies:
|
||||
@@ -8089,6 +8115,12 @@ packages:
|
||||
vue-bundle-renderer@2.1.1:
|
||||
resolution: {integrity: sha512-+qALLI5cQncuetYOXp4yScwYvqh8c6SMXee3B+M7oTZxOgtESP0l4j/fXdEJoZ+EdMxkGWIj+aSEyjXkOdmd7g==}
|
||||
|
||||
vue-confetti-explosion@1.0.2:
|
||||
resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vue: ^3.0.5
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -12277,6 +12309,8 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deep-pick-omit@1.2.1: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
default-browser-id@5.0.0: {}
|
||||
@@ -12314,6 +12348,8 @@ snapshots:
|
||||
|
||||
destr@2.0.3: {}
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
destroy@1.2.0: {}
|
||||
|
||||
detect-libc@1.0.3: {}
|
||||
@@ -15509,6 +15545,16 @@ snapshots:
|
||||
|
||||
pify@4.0.1: {}
|
||||
|
||||
pinia-plugin-persistedstate@4.4.1(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(@pinia/nuxt@0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(pinia@2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))):
|
||||
dependencies:
|
||||
deep-pick-omit: 1.2.1
|
||||
defu: 6.1.4
|
||||
destr: 2.0.5
|
||||
optionalDependencies:
|
||||
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1)
|
||||
'@pinia/nuxt': 0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
|
||||
pinia: 2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
|
||||
|
||||
pinia@2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
@@ -17508,6 +17554,10 @@ snapshots:
|
||||
dependencies:
|
||||
ufo: 1.5.4
|
||||
|
||||
vue-confetti-explosion@1.0.2(vue@3.5.13(typescript@5.5.4)):
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.5.4)
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.5.4)):
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.5.4)
|
||||
|
||||
Reference in New Issue
Block a user