Compare commits
75 Commits
ntex
...
self-hoste
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a9fc2be30 | ||
|
|
3442baa356 | ||
|
|
d6e92e0434 | ||
|
|
c908065585 | ||
|
|
f9125d4060 | ||
|
|
153a46e9c1 | ||
|
|
eb9ec074de | ||
|
|
ab215c0bda | ||
|
|
01664f8cba | ||
|
|
6a99ea5808 | ||
|
|
fa6d55cd35 | ||
|
|
a7b40a0aa7 | ||
|
|
60b5605438 | ||
|
|
9574e8e639 | ||
|
|
14dacb2352 | ||
|
|
8baa2a72fb | ||
|
|
a5427f7287 | ||
|
|
9180f5c8d0 | ||
|
|
b5abce161f | ||
|
|
8b3ede4218 | ||
|
|
a4e024c690 | ||
|
|
e368e35e74 | ||
|
|
1e09305fb3 | ||
|
|
4d32bb2330 | ||
|
|
46710c9501 | ||
|
|
79d131c7eb | ||
|
|
b1955363a6 | ||
|
|
495dbbb7f8 | ||
|
|
e0d0736f7e | ||
|
|
12bfebd8b5 | ||
|
|
af791f78b7 | ||
|
|
0f4af98a21 | ||
|
|
75b357a069 | ||
|
|
d7814e115d | ||
|
|
24295ea482 | ||
|
|
701bf853d5 | ||
|
|
7fd3d737b8 | ||
|
|
497b0bca0b | ||
|
|
208015a911 | ||
|
|
8abe2283d7 | ||
|
|
9e97c068d8 | ||
|
|
abbfb3ca2f | ||
|
|
5c8e7a8b38 | ||
|
|
0d7934e3b8 | ||
|
|
e4cc8ef509 | ||
|
|
d670a5cbb6 | ||
|
|
227386bb0d | ||
|
|
abd679d716 | ||
|
|
ec5e3b0050 | ||
|
|
494616e9f2 | ||
|
|
82f81dc154 | ||
|
|
316fe72ea5 | ||
|
|
6266f29b99 | ||
|
|
fd9653e283 | ||
|
|
b2f4366415 | ||
|
|
f859c34442 | ||
|
|
c52d5e9a74 | ||
|
|
021fee616d | ||
|
|
0409fcec2f | ||
|
|
8e754cfeb5 | ||
|
|
adf3d9540d | ||
|
|
d5f2ada8f7 | ||
|
|
a01da5452c | ||
|
|
17d61277fa | ||
|
|
bb3de4b74b | ||
|
|
01fe08f079 | ||
|
|
8b7547ae38 | ||
|
|
0437503b75 | ||
|
|
2fea772ffb | ||
|
|
24765db045 | ||
|
|
82393f2ae7 | ||
|
|
c86c98d000 | ||
|
|
4d9741c424 | ||
|
|
81ec068747 | ||
|
|
4a031f7bbd |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
13
.github/workflows/theseus-release.yml
vendored
13
.github/workflows/theseus-release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform: [macos-latest, windows-latest, ubuntu-22.04]
|
platform: [macos-latest, self-hosted, ubuntu-22.04]
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
@@ -53,11 +53,11 @@ jobs:
|
|||||||
!target/release/bundle/*/*.app.tar.gz
|
!target/release/bundle/*/*.app.tar.gz
|
||||||
!target/release/bundle/*/*.app.tar.gz.sig
|
!target/release/bundle/*/*.app.tar.gz.sig
|
||||||
|
|
||||||
!target/release/bundle/*/*.AppImage
|
!target/release/bundle/appimage/*.AppImage
|
||||||
!target/release/bundle/*/*.AppImage.tar.gz
|
!target/release/bundle/appimage/*.AppImage.tar.gz
|
||||||
!target/release/bundle/*/*.AppImage.tar.gz.sig
|
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||||
!target/release/bundle/*/*.deb
|
!target/release/bundle/deb/*.deb
|
||||||
!target/release/bundle/*/*.rpm
|
!target/release/bundle/rpm/*.rpm
|
||||||
|
|
||||||
!target/release/bundle/msi/*.msi
|
!target/release/bundle/msi/*.msi
|
||||||
!target/release/bundle/msi/*.msi.zip
|
!target/release/bundle/msi/*.msi.zip
|
||||||
@@ -127,6 +127,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
|
DIGICERT_PASSWORD: ${{ secrets.DIGICERT_PASSWORD }}
|
||||||
|
|
||||||
- name: upload ${{ matrix.platform }}
|
- name: upload ${{ matrix.platform }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
178
Cargo.lock
generated
178
Cargo.lock
generated
@@ -550,7 +550,7 @@ dependencies = [
|
|||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-lite 2.3.0",
|
"futures-lite 2.3.0",
|
||||||
"parking",
|
"parking",
|
||||||
"polling 3.7.3",
|
"polling",
|
||||||
"rustix",
|
"rustix",
|
||||||
"slab",
|
"slab",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -1170,12 +1170,6 @@ dependencies = [
|
|||||||
"toml 0.8.19",
|
"toml 0.8.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "castaway"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.1.22"
|
version = "1.1.22"
|
||||||
@@ -1809,37 +1803,6 @@ dependencies = [
|
|||||||
"syn 2.0.90",
|
"syn 2.0.90",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "curl"
|
|
||||||
version = "0.4.47"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d9fb4d13a1be2b58f14d60adba57c9834b78c62fd86c3e76a148f732686e9265"
|
|
||||||
dependencies = [
|
|
||||||
"curl-sys",
|
|
||||||
"libc",
|
|
||||||
"openssl-probe",
|
|
||||||
"openssl-sys",
|
|
||||||
"schannel",
|
|
||||||
"socket2",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "curl-sys"
|
|
||||||
version = "0.4.77+curl-8.10.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f469e8a5991f277a208224f6c7ad72ecb5f986e36d09ae1f2c1bb9259478a480"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
"libnghttp2-sys",
|
|
||||||
"libz-sys",
|
|
||||||
"openssl-sys",
|
|
||||||
"pkg-config",
|
|
||||||
"vcpkg",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "daedalus"
|
name = "daedalus"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -3040,8 +3003,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if 1.0.0",
|
"cfg-if 1.0.0",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4016,33 +3981,6 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "isahc"
|
|
||||||
version = "1.7.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9"
|
|
||||||
dependencies = [
|
|
||||||
"async-channel 1.9.0",
|
|
||||||
"castaway",
|
|
||||||
"crossbeam-utils 0.8.20",
|
|
||||||
"curl",
|
|
||||||
"curl-sys",
|
|
||||||
"encoding_rs",
|
|
||||||
"event-listener 2.5.3",
|
|
||||||
"futures-lite 1.13.0",
|
|
||||||
"http 0.2.12",
|
|
||||||
"log",
|
|
||||||
"mime",
|
|
||||||
"once_cell",
|
|
||||||
"polling 2.8.0",
|
|
||||||
"slab",
|
|
||||||
"sluice",
|
|
||||||
"tracing",
|
|
||||||
"tracing-futures",
|
|
||||||
"url",
|
|
||||||
"waker-fn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iso8601"
|
name = "iso8601"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -4216,12 +4154,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jsonwebtoken"
|
name = "jsonwebtoken"
|
||||||
version = "8.3.0"
|
version = "9.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378"
|
checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"ring 0.16.20",
|
"js-sys",
|
||||||
|
"ring 0.17.8",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -4464,16 +4403,6 @@ version = "0.2.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
|
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libnghttp2-sys"
|
|
||||||
version = "0.1.10+1.61.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "959c25552127d2e1fa72f0e52548ec04fc386e827ba71a7bd01db46a447dc135"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
@@ -4506,18 +4435,6 @@ dependencies = [
|
|||||||
"glob",
|
"glob",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libz-sys"
|
|
||||||
version = "1.1.20"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
"pkg-config",
|
|
||||||
"vcpkg",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linked-hash-map"
|
name = "linked-hash-map"
|
||||||
version = "0.5.6"
|
version = "0.5.6"
|
||||||
@@ -4708,38 +4625,39 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meilisearch-index-setting-macro"
|
name = "meilisearch-index-setting-macro"
|
||||||
version = "0.24.3"
|
version = "0.27.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b1f2124b55b9cb28e6a08b28854f4e834a51333cbdc2f72935f401efa686c13c"
|
checksum = "056e8c0652af81cc6525e0d9c0e1037ea7bcd77955dcd4aef1a1441be7ad7e55"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convert_case 0.6.0",
|
"convert_case 0.6.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 1.0.109",
|
"structmeta",
|
||||||
|
"syn 2.0.90",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meilisearch-sdk"
|
name = "meilisearch-sdk"
|
||||||
version = "0.24.3"
|
version = "0.27.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2257ea8ed24b079c21570f473e58cccc3de23b46cee331fc513fccdc3f1ae5a1"
|
checksum = "66958255878d712b4f2dece377a8661b41dc976ff15f564b91bfce8b4a619304"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"bytes 1.7.2",
|
||||||
"either",
|
"either",
|
||||||
"futures 0.3.30",
|
"futures 0.3.30",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"isahc",
|
|
||||||
"iso8601",
|
"iso8601",
|
||||||
"js-sys",
|
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"log",
|
"log",
|
||||||
"meilisearch-index-setting-macro",
|
"meilisearch-index-setting-macro",
|
||||||
|
"pin-project-lite",
|
||||||
|
"reqwest 0.12.7",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 1.0.64",
|
"thiserror 1.0.64",
|
||||||
"time",
|
"time",
|
||||||
"uuid 1.10.0",
|
"uuid 1.10.0",
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"yaup",
|
"yaup",
|
||||||
@@ -6022,22 +5940,6 @@ dependencies = [
|
|||||||
"miniz_oxide 0.8.0",
|
"miniz_oxide 0.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "polling"
|
|
||||||
version = "2.8.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
|
|
||||||
dependencies = [
|
|
||||||
"autocfg",
|
|
||||||
"bitflags 1.3.2",
|
|
||||||
"cfg-if 1.0.0",
|
|
||||||
"concurrent-queue",
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"pin-project-lite",
|
|
||||||
"windows-sys 0.48.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.7.3"
|
version = "3.7.3"
|
||||||
@@ -7808,17 +7710,6 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sluice"
|
|
||||||
version = "0.5.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5"
|
|
||||||
dependencies = [
|
|
||||||
"async-channel 1.9.0",
|
|
||||||
"futures-core",
|
|
||||||
"futures-io",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "0.6.14"
|
version = "0.6.14"
|
||||||
@@ -8263,6 +8154,29 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "structmeta"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"structmeta-derive",
|
||||||
|
"syn 2.0.90",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "structmeta-derive"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.90",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strum"
|
name = "strum"
|
||||||
version = "0.26.3"
|
version = "0.26.3"
|
||||||
@@ -8956,7 +8870,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "theseus"
|
name = "theseus"
|
||||||
version = "0.9.0"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-recursion",
|
"async-recursion",
|
||||||
"async-tungstenite",
|
"async-tungstenite",
|
||||||
@@ -9007,7 +8921,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "theseus_gui"
|
name = "theseus_gui"
|
||||||
version = "0.9.0"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"cocoa 0.25.0",
|
"cocoa 0.25.0",
|
||||||
@@ -9912,6 +9826,7 @@ dependencies = [
|
|||||||
"getrandom 0.2.15",
|
"getrandom 0.2.15",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10865,7 +10780,7 @@ checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "wry"
|
name = "wry"
|
||||||
version = "0.47.2"
|
version = "0.47.2"
|
||||||
source = "git+https://github.com/modrinth/wry?rev=e88d4a1#e88d4a10286f58902f50d5dc6c11363220a8be10"
|
source = "git+https://github.com/modrinth/wry?rev=cdbf938#cdbf9384263db4e692e22dcf9bf6085e334a10f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"block2",
|
"block2",
|
||||||
@@ -11008,12 +10923,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yaup"
|
name = "yaup"
|
||||||
version = "0.2.1"
|
version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a59e7d27bed43f7c37c25df5192ea9d435a8092a902e02203359ac9ce3e429d9"
|
checksum = "b0144f1a16a199846cb21024da74edd930b43443463292f536b7110b4855b5c6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
"serde",
|
"serde",
|
||||||
"url",
|
"thiserror 1.0.64",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ strip = true # Remove debug symbols
|
|||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
wry = { git = "https://github.com/modrinth/wry", rev ="e88d4a1" }
|
wry = { git = "https://github.com/modrinth/wry", rev ="cdbf938" }
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@modrinth/app-frontend",
|
"name": "@modrinth/app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.0",
|
"version": "0.9.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onMounted, watch, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ArrowBigUpDashIcon,
|
ArrowBigUpDashIcon,
|
||||||
LogInIcon,
|
CompassIcon,
|
||||||
|
DownloadIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
|
LeftArrowIcon,
|
||||||
LibraryIcon,
|
LibraryIcon,
|
||||||
|
LogInIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
MaximizeIcon,
|
||||||
|
MinimizeIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
RestoreIcon,
|
||||||
|
RightArrowIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
DownloadIcon,
|
|
||||||
CompassIcon,
|
|
||||||
MinimizeIcon,
|
|
||||||
MaximizeIcon,
|
|
||||||
RestoreIcon,
|
|
||||||
LogOutIcon,
|
|
||||||
RightArrowIcon,
|
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
|
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
|
||||||
import { useLoading, useTheming } from '@/store/state'
|
import { useLoading, useTheming } from '@/store/state'
|
||||||
@@ -31,12 +32,12 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
|
|||||||
import { handleError, useNotifications } from '@/store/notifications.js'
|
import { handleError, useNotifications } from '@/store/notifications.js'
|
||||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||||
import { type } from '@tauri-apps/plugin-os'
|
import { type } from '@tauri-apps/plugin-os'
|
||||||
import { isDev, getOS, restartApp } from '@/helpers/utils.js'
|
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
|
||||||
import { initAnalytics, debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
|
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
|
||||||
import { install_from_file } from './helpers/pack'
|
import { create_profile_and_install_from_file } from './helpers/pack'
|
||||||
import { useError } from '@/store/error.js'
|
import { useError } from '@/store/error.js'
|
||||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||||
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
|
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
|
||||||
@@ -50,7 +51,7 @@ import { renderString } from '@modrinth/utils'
|
|||||||
import { useFetch } from '@/helpers/fetch.js'
|
import { useFetch } from '@/helpers/fetch.js'
|
||||||
import { check } from '@tauri-apps/plugin-updater'
|
import { check } from '@tauri-apps/plugin-updater'
|
||||||
import NavButton from '@/components/ui/NavButton.vue'
|
import NavButton from '@/components/ui/NavButton.vue'
|
||||||
import { get as getCreds, logout, login } from '@/helpers/mr_auth.js'
|
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||||
import { get_user } from '@/helpers/cache.js'
|
import { get_user } from '@/helpers/cache.js'
|
||||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -256,25 +257,19 @@ themeStore.$subscribe(() => {
|
|||||||
sidebarToggled.value = !themeStore.toggleSidebar
|
sidebarToggled.value = !themeStore.toggleSidebar
|
||||||
})
|
})
|
||||||
|
|
||||||
const forceSidebar = ref(false)
|
const forceSidebar = computed(
|
||||||
|
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
|
||||||
|
)
|
||||||
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
||||||
const showAd = computed(() => !(!sidebarVisible.value || hasPlus.value))
|
const showAd = computed(() => !(!sidebarVisible.value || hasPlus.value))
|
||||||
|
|
||||||
router.afterEach((to) => {
|
|
||||||
forceSidebar.value = to.path.startsWith('/browse') || to.path.startsWith('/project')
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentTimeout = ref(null)
|
|
||||||
watch(
|
watch(
|
||||||
showAd,
|
showAd,
|
||||||
() => {
|
() => {
|
||||||
if (!showAd.value) {
|
if (!showAd.value) {
|
||||||
if (currentTimeout.value) clearTimeout(currentTimeout.value)
|
|
||||||
hide_ads_window(true)
|
hide_ads_window(true)
|
||||||
} else {
|
} else {
|
||||||
currentTimeout.value = setTimeout(() => {
|
init_ads_window(true)
|
||||||
init_ads_window(true)
|
|
||||||
}, 400)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -301,7 +296,7 @@ async function handleCommand(e) {
|
|||||||
if (e.event === 'RunMRPack') {
|
if (e.event === 'RunMRPack') {
|
||||||
// RunMRPack should directly install a local mrpack given a path
|
// RunMRPack should directly install a local mrpack given a path
|
||||||
if (e.path.endsWith('.mrpack')) {
|
if (e.path.endsWith('.mrpack')) {
|
||||||
await install_from_file(e.path).catch(handleError)
|
await create_profile_and_install_from_file(e.path).catch(handleError)
|
||||||
trackEvent('InstanceCreate', {
|
trackEvent('InstanceCreate', {
|
||||||
source: 'CreationModalFileDrop',
|
source: 'CreationModalFileDrop',
|
||||||
})
|
})
|
||||||
@@ -443,6 +438,20 @@ function handleAuxClick(e) {
|
|||||||
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
|
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
|
||||||
<div data-tauri-drag-region class="flex p-3">
|
<div data-tauri-drag-region class="flex p-3">
|
||||||
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
|
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
|
||||||
|
<div class="flex items-center gap-1 ml-3">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||||
|
@click="router.back()"
|
||||||
|
>
|
||||||
|
<LeftArrowIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||||
|
@click="router.forward()"
|
||||||
|
>
|
||||||
|
<RightArrowIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<Breadcrumbs class="pt-[2px]" />
|
<Breadcrumbs class="pt-[2px]" />
|
||||||
</div>
|
</div>
|
||||||
<section class="flex ml-auto items-center">
|
<section class="flex ml-auto items-center">
|
||||||
@@ -704,7 +713,7 @@ function handleAuxClick(e) {
|
|||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 0px;
|
grid-template-columns: 1fr 0px;
|
||||||
transition: grid-template-columns 0.4s ease-in-out;
|
// transition: grid-template-columns 0.4s ease-in-out;
|
||||||
|
|
||||||
&.sidebar-enabled {
|
&.sidebar-enabled {
|
||||||
grid-template-columns: 1fr 300px;
|
grid-template-columns: 1fr 300px;
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onUnmounted, ref, computed, onMounted } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { SpinnerIcon, GameIcon, TimerIcon, StopCircleIcon, PlayIcon } from '@modrinth/assets'
|
import {
|
||||||
import { ButtonStyled, Avatar } from '@modrinth/ui'
|
DownloadIcon,
|
||||||
|
GameIcon,
|
||||||
|
PlayIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
StopCircleIcon,
|
||||||
|
TimerIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import { Avatar, ButtonStyled } from '@modrinth/ui'
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
import { kill, run } from '@/helpers/profile'
|
import { finish_install, kill, run } from '@/helpers/profile'
|
||||||
import { get_by_profile_path } from '@/helpers/process'
|
import { get_by_profile_path } from '@/helpers/process'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { handleError } from '@/store/state.js'
|
import { handleError } from '@/store/state.js'
|
||||||
@@ -35,12 +42,15 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const playing = ref(false)
|
const playing = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
const modLoading = computed(
|
const modLoading = computed(
|
||||||
() =>
|
() =>
|
||||||
currentEvent.value === 'installing' || (currentEvent.value === 'launched' && !playing.value),
|
loading.value ||
|
||||||
|
currentEvent.value === 'installing' ||
|
||||||
|
(currentEvent.value === 'launched' && !playing.value),
|
||||||
)
|
)
|
||||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
const installing = computed(() => props.instance.install_stage.includes('installing'))
|
||||||
|
const installed = computed(() => props.instance.install_stage === 'installed')
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -56,6 +66,7 @@ const checkProcess = async () => {
|
|||||||
|
|
||||||
const play = async (e, context) => {
|
const play = async (e, context) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
|
loading.value = true
|
||||||
await run(props.instance.path)
|
await run(props.instance.path)
|
||||||
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
|
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -65,6 +76,7 @@ const play = async (e, context) => {
|
|||||||
source: context,
|
source: context,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = async (e, context) => {
|
const stop = async (e, context) => {
|
||||||
@@ -80,6 +92,12 @@ const stop = async (e, context) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const repair = async (e) => {
|
||||||
|
e?.stopPropagation()
|
||||||
|
|
||||||
|
await finish_install(props.instance)
|
||||||
|
}
|
||||||
|
|
||||||
const openFolder = async () => {
|
const openFolder = async () => {
|
||||||
await showProfileInFolder(props.instance.path)
|
await showProfileInFolder(props.instance.path)
|
||||||
}
|
}
|
||||||
@@ -118,7 +136,7 @@ onUnmounted(() => unlisten())
|
|||||||
<template>
|
<template>
|
||||||
<template v-if="compact">
|
<template v-if="compact">
|
||||||
<div
|
<div
|
||||||
class="button-base card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer active:scale-[0.98] transition-transform"
|
class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all"
|
||||||
@click="seeInstance"
|
@click="seeInstance"
|
||||||
@mouseenter="checkProcess"
|
@mouseenter="checkProcess"
|
||||||
>
|
>
|
||||||
@@ -191,6 +209,15 @@ onUnmounted(() => unlisten())
|
|||||||
class="animate-spin w-8 h-8"
|
class="animate-spin w-8 h-8"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
/>
|
/>
|
||||||
|
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
|
||||||
|
<button
|
||||||
|
v-tooltip="'Repair'"
|
||||||
|
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
|
||||||
|
@click="(e) => repair(e)"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
<ButtonStyled v-else size="large" color="brand" circular>
|
<ButtonStyled v-else size="large" color="brand" circular>
|
||||||
<button
|
<button
|
||||||
v-tooltip="'Play'"
|
v-tooltip="'Play'"
|
||||||
|
|||||||
@@ -199,16 +199,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
import {
|
import {
|
||||||
PlusIcon,
|
|
||||||
UploadIcon,
|
|
||||||
XIcon,
|
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
InfoIcon,
|
|
||||||
FolderSearchIcon,
|
FolderSearchIcon,
|
||||||
|
InfoIcon,
|
||||||
|
PlusIcon,
|
||||||
UpdatedIcon,
|
UpdatedIcon,
|
||||||
|
UploadIcon,
|
||||||
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { Avatar, Button, Chips, Checkbox } from '@modrinth/ui'
|
import { Avatar, Button, Checkbox, Chips } from '@modrinth/ui'
|
||||||
import { computed, onUnmounted, ref, shallowRef } from 'vue'
|
import { computed, onUnmounted, ref, shallowRef } from 'vue'
|
||||||
import { get_loaders } from '@/helpers/tags'
|
import { get_loaders } from '@/helpers/tags'
|
||||||
import { create } from '@/helpers/profile'
|
import { create } from '@/helpers/profile'
|
||||||
@@ -218,7 +218,7 @@ import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
|
|||||||
import { handleError } from '@/store/notifications.js'
|
import { handleError } from '@/store/notifications.js'
|
||||||
import Multiselect from 'vue-multiselect'
|
import Multiselect from 'vue-multiselect'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import { install_from_file } from '@/helpers/pack.js'
|
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
|
||||||
import {
|
import {
|
||||||
get_default_launcher_path,
|
get_default_launcher_path,
|
||||||
get_importable_instances,
|
get_importable_instances,
|
||||||
@@ -263,7 +263,7 @@ defineExpose({
|
|||||||
hide()
|
hide()
|
||||||
const { paths } = event.payload
|
const { paths } = event.payload
|
||||||
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
|
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
|
||||||
await install_from_file(paths[0]).catch(handleError)
|
await create_profile_and_install_from_file(paths[0]).catch(handleError)
|
||||||
trackEvent('InstanceCreate', {
|
trackEvent('InstanceCreate', {
|
||||||
source: 'CreationModalFileDrop',
|
source: 'CreationModalFileDrop',
|
||||||
})
|
})
|
||||||
@@ -419,7 +419,7 @@ const openFile = async () => {
|
|||||||
const newProject = await open({ multiple: false })
|
const newProject = await open({ multiple: false })
|
||||||
if (!newProject) return
|
if (!newProject) return
|
||||||
hide()
|
hide()
|
||||||
await install_from_file(newProject.path ?? newProject).catch(handleError)
|
await create_profile_and_install_from_file(newProject.path ?? newProject).catch(handleError)
|
||||||
|
|
||||||
trackEvent('InstanceCreate', {
|
trackEvent('InstanceCreate', {
|
||||||
source: 'CreationModalFileOpen',
|
source: 'CreationModalFileOpen',
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const toTransparent = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="card-shadow button-base bg-bg-raised rounded-xl overflow-clip cursor-pointer active:scale-[0.98] transition-transform"
|
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
|
||||||
@click="router.push(`/project/${project.slug}`)"
|
@click="router.push(`/project/${project.slug}`)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { ChevronRightIcon } from '@modrinth/assets'
|
import { init_ads_window } from '@/helpers/ads.js'
|
||||||
import { init_ads_window, open_ads_link, record_ads_click } from '@/helpers/ads.js'
|
|
||||||
|
|
||||||
const adsWrapper = ref(null)
|
const adsWrapper = ref(null)
|
||||||
|
|
||||||
@@ -29,27 +28,12 @@ function updateAdPosition() {
|
|||||||
initDevicePixelRatioWatcher()
|
initDevicePixelRatioWatcher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPlusLink() {
|
|
||||||
await record_ads_click()
|
|
||||||
await open_ads_link('https://modrinth.com/plus', 'https://modrinth.com')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
|
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
|
||||||
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
|
||||||
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
<p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
|
||||||
<button
|
|
||||||
class="mt-auto items-center gap-1 text-purple hover:underline bg-transparent border-none text-left cursor-pointer outline-none"
|
|
||||||
@click="openPlusLink"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
Support creators and Modrinth ad-free with
|
|
||||||
<span class="font-bold">Modrinth+</span>
|
|
||||||
</span>
|
|
||||||
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="card-shadow button-base p-4 bg-bg-raised rounded-xl flex gap-3 group"
|
class="card-shadow p-4 bg-bg-raised rounded-xl flex gap-3 group cursor-pointer hover:brightness-90 transition-all"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
emit('open')
|
emit('open')
|
||||||
@@ -12,21 +12,7 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="icon w-[96px] h-[96px] relative">
|
<div class="icon w-[96px] h-[96px] relative">
|
||||||
<Avatar
|
<Avatar :src="project.icon_url" size="96px" class="search-icon origin-top transition-all" />
|
||||||
:src="project.icon_url"
|
|
||||||
size="96px"
|
|
||||||
class="search-icon origin-top transition-all"
|
|
||||||
:class="{ 'scale-[0.85]': installed, 'brightness-50': installing }"
|
|
||||||
/>
|
|
||||||
<div v-if="installing" class="rounded-2xl absolute inset-0 flex items-center justify-center">
|
|
||||||
<SpinnerIcon class="h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="installed"
|
|
||||||
class="absolute shadow-sm font-semibold bottom-0 w-full p-1 bg-button-bg rounded-full text-xs justify-center items-center flex gap-1 text-brand border-[1px] border-solid border-[--color-button-border]"
|
|
||||||
>
|
|
||||||
<CheckIcon class="shrink-0 stroke-[3px]" /> Installed
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 overflow-hidden">
|
<div class="flex flex-col gap-2 overflow-hidden">
|
||||||
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
|
<div class="gap-2 overflow-hidden no-wrap text-ellipsis">
|
||||||
@@ -40,6 +26,42 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
|
<div v-if="categories.length > 0" class="mt-auto flex items-center gap-1 no-wrap">
|
||||||
<TagsIcon class="h-4 w-4 shrink-0" />
|
<TagsIcon class="h-4 w-4 shrink-0" />
|
||||||
|
<div
|
||||||
|
v-if="project.project_type === 'mod' || project.project_type === 'modpack'"
|
||||||
|
class="text-sm font-semibold text-secondary flex gap-1 px-[0.375rem] py-0.5 bg-button-bg rounded-full"
|
||||||
|
>
|
||||||
|
<template v-if="project.client_side === 'optional' && project.server_side === 'optional'">
|
||||||
|
Client or server
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="
|
||||||
|
(project.client_side === 'optional' || project.client_side === 'required') &&
|
||||||
|
(project.server_side === 'optional' || project.server_side === 'unsupported')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Client
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="
|
||||||
|
(project.server_side === 'optional' || project.server_side === 'required') &&
|
||||||
|
(project.client_side === 'optional' || project.client_side === 'unsupported')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Server
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="
|
||||||
|
project.client_side === 'unsupported' && project.server_side === 'unsupported'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
Unsupported
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="project.client_side === 'required' && project.server_side === 'required'"
|
||||||
|
>
|
||||||
|
Client and server
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="tag in categories"
|
v-for="tag in categories"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
@@ -65,19 +87,8 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-auto relative">
|
<div class="mt-auto relative">
|
||||||
<div
|
<div class="absolute bottom-0 right-0 w-fit">
|
||||||
class="flex items-center gap-2 group-hover:-translate-y-3 group-hover:opacity-0 group-focus-within:opacity-0 group-hover:scale-95 group-focus-within:scale-95 transition-all"
|
<ButtonStyled color="brand" type="outlined">
|
||||||
>
|
|
||||||
<HistoryIcon class="shrink-0" />
|
|
||||||
<span>
|
|
||||||
<span class="text-secondary">Updated</span>
|
|
||||||
{{ dayjs(project.date_modified ?? project.updated).fromNow() }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="opacity-0 scale-95 translate-y-3 group-hover:translate-y-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:opacity-100 group-focus-within:scale-100 absolute bottom-0 right-0 transition-all w-fit"
|
|
||||||
>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button
|
<button
|
||||||
:disabled="installed || installing"
|
:disabled="installed || installing"
|
||||||
class="shrink-0 no-wrap"
|
class="shrink-0 no-wrap"
|
||||||
@@ -106,15 +117,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { TagsIcon, DownloadIcon, HeartIcon, PlusIcon, CheckIcon } from '@modrinth/assets'
|
||||||
SpinnerIcon,
|
|
||||||
TagsIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
HeartIcon,
|
|
||||||
PlusIcon,
|
|
||||||
CheckIcon,
|
|
||||||
HistoryIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import { ButtonStyled, Avatar } from '@modrinth/ui'
|
import { ButtonStyled, Avatar } from '@modrinth/ui'
|
||||||
import { formatNumber, formatCategory } from '@modrinth/utils'
|
import { formatNumber, formatCategory } from '@modrinth/utils'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
credentials: unknown | null
|
credentials: unknown | null
|
||||||
signIn: () => void2
|
signIn: () => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const userCredentials = computed(() => props.credentials)
|
const userCredentials = computed(() => props.credentials)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { XIcon, DownloadIcon } from '@modrinth/assets'
|
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||||
import { Button } from '@modrinth/ui'
|
import { Button } from '@modrinth/ui'
|
||||||
import { install as pack_install } from '@/helpers/pack'
|
import { create_profile_and_install as pack_install } from '@/helpers/pack'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import { handleError } from '@/store/state.js'
|
import { handleError } from '@/store/state.js'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
import { create } from './profile'
|
import { create } from './profile'
|
||||||
|
|
||||||
// Installs pack from a version ID
|
// Installs pack from a version ID
|
||||||
export async function install(projectId, versionId, packTitle, iconUrl) {
|
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
|
||||||
const location = {
|
const location = {
|
||||||
type: 'fromVersionId',
|
type: 'fromVersionId',
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
@@ -28,8 +28,18 @@ export async function install(projectId, versionId, packTitle, iconUrl) {
|
|||||||
return await invoke('plugin:pack|pack_install', { location, profile })
|
return await invoke('plugin:pack|pack_install', { location, profile })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
|
||||||
|
const location = {
|
||||||
|
type: 'fromVersionId',
|
||||||
|
project_id: projectId,
|
||||||
|
version_id: versionId,
|
||||||
|
title,
|
||||||
|
}
|
||||||
|
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
|
||||||
|
}
|
||||||
|
|
||||||
// Installs pack from a path
|
// Installs pack from a path
|
||||||
export async function install_from_file(path) {
|
export async function create_profile_and_install_from_file(path) {
|
||||||
const location = {
|
const location = {
|
||||||
type: 'fromFile',
|
type: 'fromFile',
|
||||||
path: path,
|
path: path,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* and deserialized into a usable JS object.
|
* and deserialized into a usable JS object.
|
||||||
*/
|
*/
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { install_to_existing_profile } from '@/helpers/pack.js'
|
||||||
|
import { handleError } from '@/store/notifications.js'
|
||||||
|
|
||||||
/// Add instance
|
/// Add instance
|
||||||
/*
|
/*
|
||||||
@@ -186,3 +188,17 @@ export async function edit(path, editProfile) {
|
|||||||
export async function edit_icon(path, iconPath) {
|
export async function edit_icon(path, iconPath) {
|
||||||
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
|
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function finish_install(instance) {
|
||||||
|
if (instance.install_stage !== 'pack_installed') {
|
||||||
|
let linkedData = instance.linked_data
|
||||||
|
await install_to_existing_profile(
|
||||||
|
linkedData.project_id,
|
||||||
|
linkedData.version_id,
|
||||||
|
instance.name,
|
||||||
|
instance.path,
|
||||||
|
).catch(handleError)
|
||||||
|
} else {
|
||||||
|
await install(instance.path, false).catch(handleError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
7
apps/app-frontend/src/helpers/types.d.ts
vendored
7
apps/app-frontend/src/helpers/types.d.ts
vendored
@@ -32,7 +32,12 @@ type GameInstance = {
|
|||||||
hooks: Hooks
|
hooks: Hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
type InstallStage = 'installed' | 'installing' | 'pack_installing' | 'not_installed'
|
type InstallStage =
|
||||||
|
| 'installed'
|
||||||
|
| 'minecraft_installing'
|
||||||
|
| 'pack_installed'
|
||||||
|
| 'pack_installing'
|
||||||
|
| 'not_installed'
|
||||||
|
|
||||||
type LinkedData = {
|
type LinkedData = {
|
||||||
project_id: ModrinthId
|
project_id: ModrinthId
|
||||||
|
|||||||
@@ -356,12 +356,6 @@ const messages = defineMessages({
|
|||||||
const options = ref(null)
|
const options = ref(null)
|
||||||
const handleRightClick = (event, result) => {
|
const handleRightClick = (event, result) => {
|
||||||
options.value.showMenu(event, result, [
|
options.value.showMenu(event, result, [
|
||||||
{
|
|
||||||
name: 'install',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'divider',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'open_link',
|
name: 'open_link',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,9 +30,23 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<ButtonStyled v-if="instance.install_stage !== 'installed'" color="brand" size="large">
|
<ButtonStyled
|
||||||
|
v-if="instance.install_stage.includes('installing')"
|
||||||
|
color="brand"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
<button disabled>Installing...</button>
|
<button disabled>Installing...</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
<ButtonStyled
|
||||||
|
v-else-if="instance.install_stage !== 'installed'"
|
||||||
|
color="brand"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<button @click="repairInstance()">
|
||||||
|
<DownloadIcon />
|
||||||
|
Repair
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||||
<button @click="stopInstance('InstancePage')">
|
<button @click="stopInstance('InstancePage')">
|
||||||
<StopCircleIcon />
|
<StopCircleIcon />
|
||||||
@@ -137,38 +151,39 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
ContentPageHeader,
|
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
OverflowMenu,
|
ContentPageHeader,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
|
OverflowMenu,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import {
|
import {
|
||||||
UserPlusIcon,
|
|
||||||
ServerIcon,
|
|
||||||
PackageIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
PlayIcon,
|
|
||||||
StopCircleIcon,
|
|
||||||
EditIcon,
|
|
||||||
FolderOpenIcon,
|
|
||||||
ClipboardCopyIcon,
|
|
||||||
PlusIcon,
|
|
||||||
ExternalIcon,
|
|
||||||
HashIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
EyeIcon,
|
|
||||||
XIcon,
|
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
UpdatedIcon,
|
ClipboardCopyIcon,
|
||||||
MoreVerticalIcon,
|
DownloadIcon,
|
||||||
|
EditIcon,
|
||||||
|
ExternalIcon,
|
||||||
|
EyeIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
GameIcon,
|
GameIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
HashIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
|
PackageIcon,
|
||||||
|
PlayIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ServerIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
StopCircleIcon,
|
||||||
TimerIcon,
|
TimerIcon,
|
||||||
|
UpdatedIcon,
|
||||||
|
UserPlusIcon,
|
||||||
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { get, get_full_path, kill, run } from '@/helpers/profile'
|
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
|
||||||
import { get_by_profile_path } from '@/helpers/process'
|
import { get_by_profile_path } from '@/helpers/process'
|
||||||
import { process_listener, profile_listener } from '@/helpers/events'
|
import { process_listener, profile_listener } from '@/helpers/events'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ref, onUnmounted, computed, watch } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
|
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
|
||||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
import { showProfileInFolder } from '@/helpers/utils.js'
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||||
@@ -294,6 +309,10 @@ const stopInstance = async (context) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const repairInstance = async () => {
|
||||||
|
await finish_install(instance.value)
|
||||||
|
}
|
||||||
|
|
||||||
const handleRightClick = (event) => {
|
const handleRightClick = (event) => {
|
||||||
const baseOptions = [
|
const baseOptions = [
|
||||||
{ name: 'add_content' },
|
{ name: 'add_content' },
|
||||||
|
|||||||
@@ -176,15 +176,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<div v-else class="w-[36px]"></div>
|
<div v-else class="w-[36px]"></div>
|
||||||
|
<Toggle
|
||||||
|
class="!mx-2"
|
||||||
|
:model-value="!item.data.disabled"
|
||||||
|
:checked="!item.data.disabled"
|
||||||
|
@update:model-value="toggleDisableMod(item.data)"
|
||||||
|
/>
|
||||||
<ButtonStyled type="transparent" circular>
|
<ButtonStyled type="transparent" circular>
|
||||||
<button
|
<button v-tooltip="'Remove'" @click="removeMod(item)">
|
||||||
v-tooltip="item.disabled ? `Enable` : `Disable`"
|
<TrashIcon />
|
||||||
@click="toggleDisableMod(item.data)"
|
|
||||||
>
|
|
||||||
<CheckCircleIcon v-if="item.disabled" />
|
|
||||||
<SlashIcon v-else />
|
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|
||||||
<ButtonStyled type="transparent" circular>
|
<ButtonStyled type="transparent" circular>
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
:options="[
|
:options="[
|
||||||
@@ -197,23 +200,12 @@
|
|||||||
shown: item.data !== undefined && item.data.slug !== undefined,
|
shown: item.data !== undefined && item.data.slug !== undefined,
|
||||||
action: () => copyModLink(item),
|
action: () => copyModLink(item),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
divider: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'remove',
|
|
||||||
color: 'red',
|
|
||||||
action: () => removeMod(item),
|
|
||||||
},
|
|
||||||
]"
|
]"
|
||||||
direction="left"
|
direction="left"
|
||||||
>
|
>
|
||||||
<MoreVerticalIcon />
|
<MoreVerticalIcon />
|
||||||
<template #show-file> <ExternalIcon /> Show file </template>
|
<template #show-file> <ExternalIcon /> Show file </template>
|
||||||
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
|
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
|
||||||
<template v-if="item.disabled" #toggle> <CheckCircleIcon /> Enable </template>
|
|
||||||
<template v-else #toggle> <SlashIcon /> Disable </template>
|
|
||||||
<template #remove> <TrashIcon /> Remove </template>
|
|
||||||
</OverflowMenu>
|
</OverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</template>
|
</template>
|
||||||
@@ -275,7 +267,14 @@ import {
|
|||||||
UpdatedIcon,
|
UpdatedIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { Button, ButtonStyled, ContentListPanel, OverflowMenu, Pagination } from '@modrinth/ui'
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonStyled,
|
||||||
|
ContentListPanel,
|
||||||
|
OverflowMenu,
|
||||||
|
Pagination,
|
||||||
|
Toggle,
|
||||||
|
} from '@modrinth/ui'
|
||||||
import { formatProjectType } from '@modrinth/utils'
|
import { formatProjectType } from '@modrinth/utils'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
@@ -462,6 +461,10 @@ const messages = defineMessages({
|
|||||||
id: 'instance.filter.updates-available',
|
id: 'instance.filter.updates-available',
|
||||||
defaultMessage: 'Updates available',
|
defaultMessage: 'Updates available',
|
||||||
},
|
},
|
||||||
|
disabledFilter: {
|
||||||
|
id: 'instance.filter.disabled',
|
||||||
|
defaultMessage: 'Disabled projects',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
|
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
|
||||||
@@ -488,19 +491,30 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (projects.value.some((m) => m.disabled)) {
|
||||||
|
options.push({
|
||||||
|
id: 'disabled',
|
||||||
|
formattedName: formatMessage(messages.disabledFilter),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedFilters = ref([])
|
const selectedFilters = ref([])
|
||||||
const filteredProjects = computed(() => {
|
const filteredProjects = computed(() => {
|
||||||
const updatesFilter = selectedFilters.value.includes('updates')
|
const updatesFilter = selectedFilters.value.includes('updates')
|
||||||
|
const disabledFilter = selectedFilters.value.includes('disabled')
|
||||||
|
|
||||||
const typeFilters = selectedFilters.value.filter((filter) => filter !== 'updates')
|
const typeFilters = selectedFilters.value.filter(
|
||||||
|
(filter) => filter !== 'updates' && filter !== 'disabled',
|
||||||
|
)
|
||||||
|
|
||||||
return projects.value.filter((project) => {
|
return projects.value.filter((project) => {
|
||||||
return (
|
return (
|
||||||
(typeFilters.length === 0 || typeFilters.includes(project.project_type)) &&
|
(typeFilters.length === 0 || typeFilters.includes(project.project_type)) &&
|
||||||
(!updatesFilter || project.outdated)
|
(!updatesFilter || project.outdated) &&
|
||||||
|
(!disabledFilter || project.disabled)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { defineStore } from 'pinia'
|
|||||||
import {
|
import {
|
||||||
add_project_from_version,
|
add_project_from_version,
|
||||||
check_installed,
|
check_installed,
|
||||||
list,
|
|
||||||
get,
|
get,
|
||||||
get_projects,
|
get_projects,
|
||||||
|
list,
|
||||||
remove_project,
|
remove_project,
|
||||||
} from '@/helpers/profile.js'
|
} from '@/helpers/profile.js'
|
||||||
import { handleError } from '@/store/notifications.js'
|
import { handleError } from '@/store/notifications.js'
|
||||||
import { get_project, get_version_many } from '@/helpers/cache.js'
|
import { get_project, get_version_many } from '@/helpers/cache.js'
|
||||||
import { install as packInstall } from '@/helpers/pack.js'
|
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
|
||||||
import { trackEvent } from '@/helpers/analytics.js'
|
import { trackEvent } from '@/helpers/analytics.js'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "theseus_gui"
|
name = "theseus_gui"
|
||||||
version = "0.9.0"
|
version = "0.9.2"
|
||||||
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
repository = "https://github.com/modrinth/code/apps/app/"
|
repository = "https://github.com/modrinth/code/apps/app/"
|
||||||
|
|||||||
@@ -21,3 +21,86 @@ document.addEventListener(
|
|||||||
window.open = (url, target, features) => {
|
window.open = (url, target, features) => {
|
||||||
window.top.postMessage({ modrinthOpenUrl: url }, 'https://modrinth.com')
|
window.top.postMessage({ modrinthOpenUrl: url }, 'https://modrinth.com')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function muteAudioContext() {
|
||||||
|
if (window.AudioContext || window.webkitAudioContext) {
|
||||||
|
const AudioContext = window.AudioContext || window.webkitAudioContext
|
||||||
|
const originalCreateMediaElementSource = AudioContext.prototype.createMediaElementSource
|
||||||
|
const originalCreateMediaStreamSource = AudioContext.prototype.createMediaStreamSource
|
||||||
|
const originalCreateMediaStreamTrackSource = AudioContext.prototype.createMediaStreamTrackSource
|
||||||
|
const originalCreateBufferSource = AudioContext.prototype.createBufferSource
|
||||||
|
const originalCreateOscillator = AudioContext.prototype.createOscillator
|
||||||
|
|
||||||
|
AudioContext.prototype.createGain = function () {
|
||||||
|
const gain = originalCreateGain.call(this)
|
||||||
|
gain.gain.value = 0
|
||||||
|
return gain
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioContext.prototype.createMediaElementSource = function (mediaElement) {
|
||||||
|
const source = originalCreateMediaElementSource.call(this, mediaElement)
|
||||||
|
source.connect(this.createGain())
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioContext.prototype.createMediaStreamSource = function (mediaStream) {
|
||||||
|
const source = originalCreateMediaStreamSource.call(this, mediaStream)
|
||||||
|
source.connect(this.createGain())
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioContext.prototype.createMediaStreamTrackSource = function (mediaStreamTrack) {
|
||||||
|
const source = originalCreateMediaStreamTrackSource.call(this, mediaStreamTrack)
|
||||||
|
source.connect(this.createGain())
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioContext.prototype.createBufferSource = function () {
|
||||||
|
const source = originalCreateBufferSource.call(this)
|
||||||
|
source.connect(this.createGain())
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioContext.prototype.createOscillator = function () {
|
||||||
|
const oscillator = originalCreateOscillator.call(this)
|
||||||
|
oscillator.connect(this.createGain())
|
||||||
|
return oscillator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function muteVideo(mediaElement) {
|
||||||
|
let count = Number(mediaElement.getAttribute('data-modrinth-muted-count') ?? 0)
|
||||||
|
|
||||||
|
if (!mediaElement.muted || mediaElement.volume !== 0) {
|
||||||
|
mediaElement.muted = true
|
||||||
|
mediaElement.volume = 0
|
||||||
|
|
||||||
|
mediaElement.setAttribute('data-modrinth-muted-count', count + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 5) {
|
||||||
|
// Video is detected as malicious, so it is removed from the page
|
||||||
|
mediaElement.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function muteVideos() {
|
||||||
|
document.querySelectorAll('video, audio').forEach(function (mediaElement) {
|
||||||
|
muteVideo(mediaElement)
|
||||||
|
|
||||||
|
if (!mediaElement.hasAttribute('data-modrinth-muted')) {
|
||||||
|
mediaElement.addEventListener('volumechange', () => muteVideo(mediaElement))
|
||||||
|
|
||||||
|
mediaElement.setAttribute('data-modrinth-muted', 'true')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
muteVideos()
|
||||||
|
muteAudioContext()
|
||||||
|
|
||||||
|
const observer = new MutationObserver(muteVideos)
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true })
|
||||||
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
)]
|
)]
|
||||||
|
|
||||||
use native_dialog::{MessageDialog, MessageType};
|
use native_dialog::{MessageDialog, MessageType};
|
||||||
|
use std::env;
|
||||||
use tauri::{Listener, Manager};
|
use tauri::{Listener, Manager};
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
|
|
||||||
@@ -29,7 +30,12 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
|||||||
theseus::EventState::init(app.clone()).await?;
|
theseus::EventState::init(app.clone()).await?;
|
||||||
|
|
||||||
#[cfg(feature = "updater")]
|
#[cfg(feature = "updater")]
|
||||||
{
|
'updater: {
|
||||||
|
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
|
||||||
|
State::init().await?;
|
||||||
|
break 'updater;
|
||||||
|
}
|
||||||
|
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
|
||||||
let updater = app.updater_builder().build()?;
|
let updater = app.updater_builder().build()?;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"createUpdaterArtifacts": "v1Compatible"
|
"createUpdaterArtifacts": "v1Compatible",
|
||||||
|
"windows": {
|
||||||
|
"certificateThumbprint": "6007d6ed9b1ba057aa29144d58ca3ed89ac4ef94",
|
||||||
|
"signCommand": "bash -c \"jsign.bat --storetype ETOKEN --alg SHA-256 --storepass $DIGICERT_PASSWORD --tsurl https://timestamp.digicert.com %1\""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"features": ["updater"]
|
"features": ["updater"]
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"productName": "Modrinth App",
|
"productName": "Modrinth App",
|
||||||
"version": "0.9.0",
|
"version": "0.9.2",
|
||||||
"mainBinaryName": "Modrinth App",
|
"mainBinaryName": "Modrinth App",
|
||||||
"identifier": "ModrinthApp",
|
"identifier": "ModrinthApp",
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ import { ChevronRightIcon } from "@modrinth/assets";
|
|||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
script: [
|
script: [
|
||||||
{
|
// {
|
||||||
// Clean.io
|
// // Clean.io
|
||||||
src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
|
// src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
// Aditude
|
// Aditude
|
||||||
src: "https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js",
|
src: "https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js",
|
||||||
|
|||||||
@@ -411,18 +411,21 @@ Per section 5.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
|||||||
name: "Insufficient",
|
name: "Insufficient",
|
||||||
resultingMessage: `## Insufficient Summary
|
resultingMessage: `## Insufficient Summary
|
||||||
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) Your project summary should provide a brief overview of your project that informs and entices users.
|
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) Your project summary should provide a brief overview of your project that informs and entices users.
|
||||||
|
|
||||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Repeat of title",
|
name: "Repeat of title",
|
||||||
resultingMessage: `## Insufficient Summary
|
resultingMessage: `## Insufficient Summary
|
||||||
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not be the same as your project's Title. Your project summary should provide a brief overview of your project that informs and entices users.
|
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not be the same as your project's Title. Your project summary should provide a brief overview of your project that informs and entices users.
|
||||||
|
|
||||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Formatting",
|
name: "Formatting",
|
||||||
resultingMessage: `## Insufficient Summary
|
resultingMessage: `## Insufficient Summary
|
||||||
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not include any extra formatting such as lists, or links. Your project summary should provide a brief overview of your project that informs and entices users.
|
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not include any extra formatting such as lists, or links. Your project summary should provide a brief overview of your project that informs and entices users.
|
||||||
|
|
||||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -559,7 +562,9 @@ Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
|||||||
name: "Inaccurate (modpack)",
|
name: "Inaccurate (modpack)",
|
||||||
resultingMessage: `## Incorrect Environment Information
|
resultingMessage: `## Incorrect Environment Information
|
||||||
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
|
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
|
||||||
|
|
||||||
For a brief rundown of how this works:
|
For a brief rundown of how this works:
|
||||||
|
|
||||||
Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/modpack/fabulously-optimized).
|
Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/modpack/fabulously-optimized).
|
||||||
Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Dying Light](https://modrinth.com/modpack/dying-light).
|
Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Dying Light](https://modrinth.com/modpack/dying-light).
|
||||||
When in doubt, test for yourself or check the requirements of the mods in your pack.`,
|
When in doubt, test for yourself or check the requirements of the mods in your pack.`,
|
||||||
@@ -568,10 +573,11 @@ When in doubt, test for yourself or check the requirements of the mods in your p
|
|||||||
name: "Inaccurate (mod)",
|
name: "Inaccurate (mod)",
|
||||||
resultingMessage: `## Environment Information
|
resultingMessage: `## Environment Information
|
||||||
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
|
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
|
||||||
|
|
||||||
For a brief rundown of how this works:
|
For a brief rundown of how this works:
|
||||||
**Client side** refers to a mod that is only required by the client, like [Sodium](https://modrinth.com/mod/sodium).
|
- **Client side** refers to a mod that is only required by the client, like [Sodium](https://modrinth.com/mod/sodium).
|
||||||
**Server side** mods change the behavior of the server without the client needing the mod, like Datapacks, recipes, or server-side behaviors, like [Falling Tree](https://modrinth.com/mod/fallingtree).
|
- **Server side** mods change the behavior of the server without the client needing the mod, like Datapacks, recipes, or server-side behaviors, like [Falling Tree](https://modrinth.com/mod/fallingtree).
|
||||||
A mod that adds features, entities, or new blocks and items, generally will be required on **both** the server and the client, for example [Cobblemon](https://modrinth.com/mod/cobblemon).`,
|
- A mod that adds features, entities, or new blocks and items, generally will be required on **both** the server and the client, for example [Cobblemon](https://modrinth.com/mod/cobblemon).`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -602,6 +608,7 @@ Per section 5.5 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
|||||||
name: "Incorrect additional files",
|
name: "Incorrect additional files",
|
||||||
resultingMessage: `## Incorrect Use of Additional Files
|
resultingMessage: `## Incorrect Use of Additional Files
|
||||||
It looks like you've uploaded multiple \`mod.jar\` files to one Version as Additional Files. Per section 5.7 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) each Version of your project must include only one \`mod.jar\` that corresponds to its respective Minecraft and loader versions. This allows users to easily find and download the file they need for the version they're on with ease. The Additional Files feature can be used for things like a \`Sources.jar\`.
|
It looks like you've uploaded multiple \`mod.jar\` files to one Version as Additional Files. Per section 5.7 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) each Version of your project must include only one \`mod.jar\` that corresponds to its respective Minecraft and loader versions. This allows users to easily find and download the file they need for the version they're on with ease. The Additional Files feature can be used for things like a \`Sources.jar\`.
|
||||||
|
|
||||||
Please upload each version of your mod separately, thank you.`,
|
Please upload each version of your mod separately, thank you.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -629,7 +636,9 @@ It looks like you've selected loaders for your Resource Pack that are causing it
|
|||||||
name: "Re-upload",
|
name: "Re-upload",
|
||||||
resultingMessage: `## Reuploads are forbidden
|
resultingMessage: `## Reuploads are forbidden
|
||||||
This project appears to contain content from %ORIGINAL_PROJECT% by %ORIGINAL_AUTHOR%.
|
This project appears to contain content from %ORIGINAL_PROJECT% by %ORIGINAL_AUTHOR%.
|
||||||
|
|
||||||
Per section 4 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) this is strictly forbidden.
|
Per section 4 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) this is strictly forbidden.
|
||||||
|
|
||||||
If you believe this is an error, or you can verify you are the creator and rightful owner of this content please let us know. Otherwise, we ask that you **do not resubmit this project**.`,
|
If you believe this is an error, or you can verify you are the creator and rightful owner of this content please let us know. Otherwise, we ask that you **do not resubmit this project**.`,
|
||||||
fillers: [
|
fillers: [
|
||||||
{
|
{
|
||||||
@@ -847,6 +856,7 @@ async function generateMessage() {
|
|||||||
for (const mod of mods) {
|
for (const mod of mods) {
|
||||||
message.value += `- ${mod}\n`;
|
message.value += `- ${mod}\n`;
|
||||||
}
|
}
|
||||||
|
message.value += "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modPackData.value && modPackData.value.length > 0) {
|
if (modPackData.value && modPackData.value.length > 0) {
|
||||||
@@ -913,7 +923,7 @@ async function generateMessage() {
|
|||||||
permanentNoMods.length > 0 ||
|
permanentNoMods.length > 0 ||
|
||||||
unidentifiedMods.length > 0
|
unidentifiedMods.length > 0
|
||||||
) {
|
) {
|
||||||
message.value += "## Copyrighted Content \n";
|
message.value += "## Copyrighted content \n";
|
||||||
|
|
||||||
printMods(
|
printMods(
|
||||||
attributeMods,
|
attributeMods,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<CompactChart
|
<CompactChart
|
||||||
v-if="analytics.formattedData.value.downloads"
|
v-if="analytics.formattedData.value.downloads"
|
||||||
ref="tinyDownloadChart"
|
ref="tinyDownloadChart"
|
||||||
:title="`Downloads since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
:title="`Downloads`"
|
||||||
color="var(--color-brand)"
|
color="var(--color-brand)"
|
||||||
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
|
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
|
||||||
:data="analytics.formattedData.value.downloads.chart.sumData"
|
:data="analytics.formattedData.value.downloads.chart.sumData"
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<CompactChart
|
<CompactChart
|
||||||
v-if="analytics.formattedData.value.views"
|
v-if="analytics.formattedData.value.views"
|
||||||
ref="tinyViewChart"
|
ref="tinyViewChart"
|
||||||
:title="`Page views since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
:title="`Views`"
|
||||||
color="var(--color-blue)"
|
color="var(--color-blue)"
|
||||||
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
|
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
|
||||||
:data="analytics.formattedData.value.views.chart.sumData"
|
:data="analytics.formattedData.value.views.chart.sumData"
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
<CompactChart
|
<CompactChart
|
||||||
v-if="analytics.formattedData.value.revenue"
|
v-if="analytics.formattedData.value.revenue"
|
||||||
ref="tinyRevenueChart"
|
ref="tinyRevenueChart"
|
||||||
:title="`Revenue since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
:title="`Revenue`"
|
||||||
color="var(--color-purple)"
|
color="var(--color-purple)"
|
||||||
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
|
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
|
||||||
:data="analytics.formattedData.value.revenue.chart.sumData"
|
:data="analytics.formattedData.value.revenue.chart.sumData"
|
||||||
@@ -71,6 +71,9 @@
|
|||||||
<span class="label__title">
|
<span class="label__title">
|
||||||
{{ formatCategoryHeader(selectedChart) }}
|
{{ formatCategoryHeader(selectedChart) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="label__subtitle">
|
||||||
|
{{ formattedCategorySubtitle }}
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="chart-controls__buttons">
|
<div class="chart-controls__buttons">
|
||||||
<Button v-tooltip="'Toggle project colors'" icon-only @click="onToggleColors">
|
<Button v-tooltip="'Toggle project colors'" icon-only @click="onToggleColors">
|
||||||
@@ -83,11 +86,12 @@
|
|||||||
<UpdatedIcon />
|
<UpdatedIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownSelect
|
<DropdownSelect
|
||||||
|
class="range-dropdown"
|
||||||
v-model="selectedRange"
|
v-model="selectedRange"
|
||||||
:options="selectableRanges"
|
:options="ranges"
|
||||||
name="Time range"
|
name="Time range"
|
||||||
:display-name="
|
:display-name="
|
||||||
(o: (typeof selectableRanges)[number] | undefined) => o?.label || 'Custom'
|
(o: RangeObject) => o?.getLabel([startDate, endDate]) ?? 'Loading...'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,7 +326,7 @@ const props = withDefaults(
|
|||||||
* @deprecated Use `ranges` instead
|
* @deprecated Use `ranges` instead
|
||||||
*/
|
*/
|
||||||
resoloutions?: Record<string, number>;
|
resoloutions?: Record<string, number>;
|
||||||
ranges?: Record<number, [string, number] | string>;
|
ranges?: RangeObject[];
|
||||||
personal?: boolean;
|
personal?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -335,12 +339,6 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const projects = ref(props.projects || []);
|
const projects = ref(props.projects || []);
|
||||||
|
|
||||||
const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) => ({
|
|
||||||
label: typeof extra === "string" ? extra : extra[0],
|
|
||||||
value: Number(duration),
|
|
||||||
res: typeof extra === "string" ? Number(duration) : extra[1],
|
|
||||||
}));
|
|
||||||
|
|
||||||
// const selectedChart = ref('downloads')
|
// const selectedChart = ref('downloads')
|
||||||
const selectedChart = computed({
|
const selectedChart = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
@@ -413,33 +411,78 @@ const isUsingProjectColors = computed({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const startDate = ref(dayjs().startOf("day"));
|
||||||
|
const endDate = ref(dayjs().endOf("day"));
|
||||||
|
const timeResolution = ref(30);
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
// Load cached data and range from localStorage - cache.
|
||||||
|
if (import.meta.client) {
|
||||||
|
const rangeLabel = localStorage.getItem("analyticsSelectedRange");
|
||||||
|
if (rangeLabel) {
|
||||||
|
const range = props.ranges.find((r) => r.getLabel([dayjs(), dayjs()]) === rangeLabel)!;
|
||||||
|
|
||||||
|
if (range !== undefined) {
|
||||||
|
internalRange.value = range;
|
||||||
|
const ranges = range.getDates(dayjs());
|
||||||
|
timeResolution.value = range.timeResolution;
|
||||||
|
startDate.value = ranges.startDate;
|
||||||
|
endDate.value = ranges.endDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (internalRange.value === null) {
|
||||||
|
internalRange.value = props.ranges.find(
|
||||||
|
(r) => r.getLabel([dayjs(), dayjs()]) === "Previous 30 days",
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = selectedRange.value.getDates(dayjs());
|
||||||
|
startDate.value = ranges.startDate;
|
||||||
|
endDate.value = ranges.endDate;
|
||||||
|
timeResolution.value = selectedRange.value.timeResolution;
|
||||||
|
});
|
||||||
|
|
||||||
|
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject);
|
||||||
|
|
||||||
|
const selectedRange = computed({
|
||||||
|
get: () => {
|
||||||
|
return internalRange.value;
|
||||||
|
},
|
||||||
|
set: (newRange) => {
|
||||||
|
const ranges = newRange.getDates(dayjs());
|
||||||
|
startDate.value = ranges.startDate;
|
||||||
|
endDate.value = ranges.endDate;
|
||||||
|
timeResolution.value = newRange.timeResolution;
|
||||||
|
|
||||||
|
internalRange.value = newRange;
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
localStorage.setItem(
|
||||||
|
"analyticsSelectedRange",
|
||||||
|
internalRange.value?.getLabel([dayjs(), dayjs()]) ?? "Previous 30 days",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const analytics = useFetchAllAnalytics(
|
const analytics = useFetchAllAnalytics(
|
||||||
resetCharts,
|
resetCharts,
|
||||||
projects,
|
projects,
|
||||||
selectedDisplayProjects,
|
selectedDisplayProjects,
|
||||||
props.personal,
|
props.personal,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timeResolution,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { startDate, endDate, timeRange, timeResolution } = analytics;
|
const formattedCategorySubtitle = computed(() => {
|
||||||
|
return (
|
||||||
const selectedRange = computed({
|
selectedRange.value?.getLabel([dayjs(startDate.value), dayjs(endDate.value)]) ?? "Loading..."
|
||||||
get: () => {
|
);
|
||||||
return (
|
|
||||||
selectableRanges.find((option) => option.value === timeRange.value) || {
|
|
||||||
label: "Custom",
|
|
||||||
value: timeRange.value,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
set: (newRange: { label: string; value: number; res?: number }) => {
|
|
||||||
timeRange.value = newRange.value;
|
|
||||||
startDate.value = Date.now() - timeRange.value * 60 * 1000;
|
|
||||||
endDate.value = Date.now();
|
|
||||||
|
|
||||||
if (newRange?.res) {
|
|
||||||
timeResolution.value = newRange.res;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedDataSet = computed(() => {
|
const selectedDataSet = computed(() => {
|
||||||
@@ -484,6 +527,9 @@ const onToggleColors = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* @deprecated Use `ranges` instead
|
||||||
|
*/
|
||||||
const defaultResoloutions: Record<string, number> = {
|
const defaultResoloutions: Record<string, number> = {
|
||||||
"5 minutes": 5,
|
"5 minutes": 5,
|
||||||
"30 minutes": 30,
|
"30 minutes": 30,
|
||||||
@@ -493,17 +539,169 @@ const defaultResoloutions: Record<string, number> = {
|
|||||||
"A week": 10080,
|
"A week": 10080,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRanges: Record<number, [string, number] | string> = {
|
type DateRange = { startDate: dayjs.Dayjs; endDate: dayjs.Dayjs };
|
||||||
30: ["Last 30 minutes", 1],
|
|
||||||
60: ["Last hour", 5],
|
type RangeObject = {
|
||||||
720: ["Last 12 hours", 15],
|
getLabel: (dateRange: [dayjs.Dayjs, dayjs.Dayjs]) => string;
|
||||||
1440: ["Last day", 60],
|
getDates: (currentDate: dayjs.Dayjs) => DateRange;
|
||||||
10080: ["Last week", 720],
|
// A time resolution in minutes.
|
||||||
43200: ["Last month", 1440],
|
timeResolution: number;
|
||||||
129600: ["Last quarter", 10080],
|
|
||||||
525600: ["Last year", 20160],
|
|
||||||
1051200: ["Last two years", 40320],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultRanges: RangeObject[] = [
|
||||||
|
{
|
||||||
|
getLabel: () => "Previous 30 minutes",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(30, "minute"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Previous hour",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(1, "hour"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Previous 12 hours",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(12, "hour"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Previous 24 hours",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(1, "day"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Today",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).startOf("day"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Yesterday",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(1, "day").startOf("day"),
|
||||||
|
endDate: dayjs(currentDate).startOf("day").subtract(1, "second"),
|
||||||
|
}),
|
||||||
|
timeResolution: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "This week",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).startOf("week").add(1, "hour"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 360,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Last week",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(1, "week").startOf("week").add(1, "hour"),
|
||||||
|
endDate: dayjs(currentDate).startOf("week").subtract(1, "second"),
|
||||||
|
}),
|
||||||
|
timeResolution: 1440,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Previous 7 days",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).startOf("day").subtract(7, "day").add(1, "hour"),
|
||||||
|
endDate: currentDate.startOf("day"),
|
||||||
|
}),
|
||||||
|
timeResolution: 720,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "This month",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).startOf("month").add(1, "hour"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 1440,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Last month",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(1, "month").startOf("month").add(1, "hour"),
|
||||||
|
endDate: dayjs(currentDate).startOf("month").subtract(1, "second"),
|
||||||
|
}),
|
||||||
|
timeResolution: 1440,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Previous 30 days",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).startOf("day").subtract(30, "day").add(1, "hour"),
|
||||||
|
endDate: currentDate.startOf("day"),
|
||||||
|
}),
|
||||||
|
timeResolution: 1440,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "This quarter",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).startOf("quarter").add(1, "hour"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 1440,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Last quarter",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(1, "quarter").startOf("quarter").add(1, "hour"),
|
||||||
|
endDate: dayjs(currentDate).startOf("quarter").subtract(1, "second"),
|
||||||
|
}),
|
||||||
|
timeResolution: 1440,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "This year",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).startOf("year"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 20160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Last year",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(1, "year").startOf("year"),
|
||||||
|
endDate: dayjs(currentDate).startOf("year").subtract(1, "second"),
|
||||||
|
}),
|
||||||
|
timeResolution: 20160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Previous year",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(1, "year"),
|
||||||
|
endDate: dayjs(currentDate),
|
||||||
|
}),
|
||||||
|
timeResolution: 40320,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "Previous two years",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(currentDate).subtract(2, "year"),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 40320,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
getLabel: () => "All Time",
|
||||||
|
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||||
|
startDate: dayjs(0),
|
||||||
|
endDate: currentDate,
|
||||||
|
}),
|
||||||
|
timeResolution: 40320,
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -524,6 +722,20 @@ const defaultRanges: Record<number, [string, number] | string> = {
|
|||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.label__subtitle {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-dropdown {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-area {
|
.chart-area {
|
||||||
@@ -688,6 +900,7 @@ const defaultRanges: Record<number, [string, number] | string> = {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--gap-xs);
|
gap: var(--gap-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.percentage-bar {
|
.percentage-bar {
|
||||||
grid-area: bar;
|
grid-area: bar;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -696,6 +909,7 @@ const defaultRanges: Record<number, [string, number] | string> = {
|
|||||||
border: 1px solid var(--color-button-bg);
|
border: 1px solid var(--color-button-bg);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ defineProps({
|
|||||||
const viewMode = ref("open");
|
const viewMode = ref("open");
|
||||||
const reports = ref([]);
|
const reports = ref([]);
|
||||||
|
|
||||||
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report"));
|
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
|
||||||
|
|
||||||
rawReports = rawReports.value.map((report) => {
|
rawReports = rawReports.value.map((report) => {
|
||||||
report.item_id = report.item_id.replace(/"/g, "");
|
report.item_id = report.item_id.replace(/"/g, "");
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="font-semibold text-contrast">Auto backup</div>
|
<div class="font-semibold text-contrast">Auto backup</div>
|
||||||
<p class="m-0">
|
<p class="m-0">
|
||||||
Automatically create a backup of your server every
|
Automatically create a backup of your server
|
||||||
<strong>{{ autoBackupInterval == 1 ? "hour" : `${autoBackupInterval} hours` }}</strong>
|
<strong>{{ backupIntervalsLabel.toLowerCase() }}</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -22,54 +22,19 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="font-semibold text-contrast">Interval</div>
|
<div class="font-semibold text-contrast">Interval</div>
|
||||||
<p class="m-0">
|
<p class="m-0">
|
||||||
The amount of hours between each backup. This will only backup your server if it has
|
The amount of time between each backup. This will only backup your server if it has been
|
||||||
been modified since the last backup.
|
modified since the last backup.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-contrast">
|
<UiServersTeleportDropdownMenu
|
||||||
<div
|
:id="'interval-field'"
|
||||||
class="flex w-fit items-center rounded-xl border border-solid border-button-border bg-table-alternateRow"
|
v-model="backupIntervalsLabel"
|
||||||
>
|
:disabled="!autoBackupEnabled || isSaving"
|
||||||
<button
|
name="interval"
|
||||||
class="rounded-l-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
:options="Object.keys(backupIntervals)"
|
||||||
:disabled="!autoBackupEnabled || isSaving"
|
placeholder="Backup interval"
|
||||||
@click="autoBackupInterval = Math.max(autoBackupInterval - 1, 1)"
|
/>
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="-2 0 18 2">
|
|
||||||
<path
|
|
||||||
d="M18,12H6"
|
|
||||||
transform="translate(-5 -11)"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
id="auto-backup-interval"
|
|
||||||
v-model="autoBackupInterval"
|
|
||||||
class="w-16 !appearance-none text-center [&&]:bg-transparent [&&]:focus:shadow-none"
|
|
||||||
type="number"
|
|
||||||
style="-moz-appearance: textfield; appearance: none"
|
|
||||||
min="1"
|
|
||||||
max="24"
|
|
||||||
step="1"
|
|
||||||
:disabled="!autoBackupEnabled || isSaving"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="rounded-r-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
|
||||||
:disabled="!autoBackupEnabled || isSaving"
|
|
||||||
@click="autoBackupInterval = Math.min(autoBackupInterval + 1, 24)"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{{ autoBackupInterval == 1 ? "hour" : "hours" }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex justify-start gap-4">
|
<div class="mt-4 flex justify-start gap-4">
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
@@ -92,7 +57,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
import { PlusIcon, XIcon, SaveIcon } from "@modrinth/assets";
|
import { XIcon, SaveIcon } from "@modrinth/assets";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import type { Server } from "~/composables/pyroServers";
|
import type { Server } from "~/composables/pyroServers";
|
||||||
|
|
||||||
@@ -104,19 +69,25 @@ const modal = ref<InstanceType<typeof NewModal>>();
|
|||||||
|
|
||||||
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
|
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
|
||||||
const autoBackupEnabled = ref(false);
|
const autoBackupEnabled = ref(false);
|
||||||
const autoBackupInterval = ref(6);
|
|
||||||
const isLoadingSettings = ref(true);
|
const isLoadingSettings = ref(true);
|
||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
|
|
||||||
const validatedBackupInterval = computed(() => {
|
const backupIntervals = {
|
||||||
const roundedValue = Math.round(autoBackupInterval.value);
|
"Every 3 hours": 3,
|
||||||
|
"Every 6 hours": 6,
|
||||||
|
"Every 12 hours": 12,
|
||||||
|
Daily: 24,
|
||||||
|
};
|
||||||
|
|
||||||
if (roundedValue < 1) {
|
const backupIntervalsLabel = ref<keyof typeof backupIntervals>("Every 6 hours");
|
||||||
return 1;
|
|
||||||
} else if (roundedValue > 24) {
|
const autoBackupInterval = computed({
|
||||||
return 24;
|
get: () => backupIntervals[backupIntervalsLabel.value],
|
||||||
}
|
set: (value) => {
|
||||||
return roundedValue;
|
const [label] =
|
||||||
|
Object.entries(backupIntervals).find(([_, interval]) => interval === value) || [];
|
||||||
|
if (label) backupIntervalsLabel.value = label as keyof typeof backupIntervals;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasChanges = computed(() => {
|
const hasChanges = computed(() => {
|
||||||
@@ -124,7 +95,7 @@ const hasChanges = computed(() => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
autoBackupEnabled.value !== initialSettings.value.enabled ||
|
autoBackupEnabled.value !== initialSettings.value.enabled ||
|
||||||
autoBackupInterval.value !== initialSettings.value.interval
|
(initialSettings.value.enabled && autoBackupInterval.value !== initialSettings.value.interval)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,10 +153,6 @@ const saveSettings = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(autoBackupInterval, () => {
|
|
||||||
autoBackupInterval.value = validatedBackupInterval.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show: async () => {
|
show: async () => {
|
||||||
await fetchSettings();
|
await fetchSettings();
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<div
|
||||||
|
v-if="isDragging"
|
||||||
|
:class="[
|
||||||
|
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white',
|
||||||
|
overlayClass,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<UploadIcon class="mx-auto h-16 w-16" />
|
||||||
|
<p class="mt-2 text-xl">
|
||||||
|
Drop {{ type ? type.toLocaleLowerCase() : "file" }}s here to upload
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { UploadIcon } from "@modrinth/assets";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "filesDropped", files: File[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
overlayClass?: string;
|
||||||
|
type?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragCounter = ref(0);
|
||||||
|
|
||||||
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
||||||
|
dragCounter.value++;
|
||||||
|
isDragging.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dragCounter.value--;
|
||||||
|
if (dragCounter.value === 0) {
|
||||||
|
isDragging.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging.value = false;
|
||||||
|
dragCounter.value = 0;
|
||||||
|
|
||||||
|
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
|
||||||
|
if (isInternalMove) return;
|
||||||
|
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
if (files) {
|
||||||
|
emit("filesDropped", Array.from(files));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
306
apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
Normal file
306
apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
<template>
|
||||||
|
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||||
|
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||||
|
<div
|
||||||
|
ref="statusContentRef"
|
||||||
|
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 font-bold">
|
||||||
|
<FolderOpenIcon class="size-4" />
|
||||||
|
<span>
|
||||||
|
<span class="capitalize">
|
||||||
|
{{ props.fileType ? props.fileType : "File" }} Uploads
|
||||||
|
</span>
|
||||||
|
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in uploadQueue"
|
||||||
|
:key="item.file.name"
|
||||||
|
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 items-center gap-2 truncate">
|
||||||
|
<transition-group name="status-icon" mode="out-in">
|
||||||
|
<UiServersPanelSpinner
|
||||||
|
v-show="item.status === 'uploading'"
|
||||||
|
key="spinner"
|
||||||
|
class="absolute !size-4"
|
||||||
|
/>
|
||||||
|
<CheckCircleIcon
|
||||||
|
v-show="item.status === 'completed'"
|
||||||
|
key="check"
|
||||||
|
class="absolute size-4 text-green"
|
||||||
|
/>
|
||||||
|
<XCircleIcon
|
||||||
|
v-show="
|
||||||
|
item.status === 'error' ||
|
||||||
|
item.status === 'cancelled' ||
|
||||||
|
item.status === 'incorrect-type'
|
||||||
|
"
|
||||||
|
key="error"
|
||||||
|
class="absolute size-4 text-red"
|
||||||
|
/>
|
||||||
|
</transition-group>
|
||||||
|
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||||
|
<span class="text-secondary">{{ item.size }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||||
|
<template v-if="item.status === 'completed'">
|
||||||
|
<span>Done</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'error'">
|
||||||
|
<span class="text-red">Failed - File already exists</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'incorrect-type'">
|
||||||
|
<span class="text-red">Failed - Incorrect file type</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="item.status === 'uploading'">
|
||||||
|
<span>{{ item.progress }}%</span>
|
||||||
|
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||||
|
<div
|
||||||
|
class="h-full bg-contrast transition-all duration-200"
|
||||||
|
:style="{ width: item.progress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||||
|
<button>Cancel</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'cancelled'">
|
||||||
|
<span class="text-red">Cancelled</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>{{ item.progress }}%</span>
|
||||||
|
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||||
|
<div
|
||||||
|
class="h-full bg-contrast transition-all duration-200"
|
||||||
|
:style="{ width: item.progress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||||
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
|
import { ref, computed, watch, nextTick } from "vue";
|
||||||
|
|
||||||
|
interface UploadItem {
|
||||||
|
file: File;
|
||||||
|
progress: number;
|
||||||
|
status: "pending" | "uploading" | "completed" | "error" | "cancelled" | "incorrect-type";
|
||||||
|
size: string;
|
||||||
|
uploader?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPath: string;
|
||||||
|
fileType?: string;
|
||||||
|
marginBottom?: number;
|
||||||
|
acceptedTypes?: Array<string>;
|
||||||
|
fs: FSModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "uploadComplete"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const uploadStatusRef = ref<HTMLElement | null>(null);
|
||||||
|
const statusContentRef = ref<HTMLElement | null>(null);
|
||||||
|
const uploadQueue = ref<UploadItem[]>([]);
|
||||||
|
|
||||||
|
const isUploading = computed(() => uploadQueue.value.length > 0);
|
||||||
|
const activeUploads = computed(() =>
|
||||||
|
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const onUploadStatusEnter = (el: Element) => {
|
||||||
|
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
|
||||||
|
(el as HTMLElement).style.height = "0";
|
||||||
|
// eslint-disable-next-line no-void
|
||||||
|
void (el as HTMLElement).offsetHeight;
|
||||||
|
(el as HTMLElement).style.height = `${height}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUploadStatusLeave = (el: Element) => {
|
||||||
|
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
|
||||||
|
(el as HTMLElement).style.height = `${height}px`;
|
||||||
|
// eslint-disable-next-line no-void
|
||||||
|
void (el as HTMLElement).offsetHeight;
|
||||||
|
(el as HTMLElement).style.height = "0";
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
uploadQueue,
|
||||||
|
() => {
|
||||||
|
if (!uploadStatusRef.value) return;
|
||||||
|
const el = uploadStatusRef.value;
|
||||||
|
const itemsHeight = uploadQueue.value.length * 32;
|
||||||
|
const headerHeight = 12;
|
||||||
|
const gap = 8;
|
||||||
|
const padding = 32;
|
||||||
|
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0);
|
||||||
|
el.style.height = `${totalHeight}px`;
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + " KB";
|
||||||
|
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + " MB";
|
||||||
|
return (bytes / 1024 ** 3).toFixed(1) + " GB";
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelUpload = (item: UploadItem) => {
|
||||||
|
if (item.uploader && item.status === "uploading") {
|
||||||
|
item.uploader.cancel();
|
||||||
|
item.status = "cancelled";
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
|
||||||
|
if (index !== -1) {
|
||||||
|
uploadQueue.value.splice(index, 1);
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const badFileTypeMsg = "Upload had incorrect file type";
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
const uploadItem: UploadItem = {
|
||||||
|
file,
|
||||||
|
progress: 0,
|
||||||
|
status: "pending",
|
||||||
|
size: formatFileSize(file.size),
|
||||||
|
};
|
||||||
|
|
||||||
|
uploadQueue.value.push(uploadItem);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
props.acceptedTypes &&
|
||||||
|
!props.acceptedTypes.includes(file.type) &&
|
||||||
|
!props.acceptedTypes.some((type) => file.name.endsWith(type))
|
||||||
|
) {
|
||||||
|
throw new Error(badFileTypeMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadItem.status = "uploading";
|
||||||
|
const filePath = `${props.currentPath}/${file.name}`.replace("//", "/");
|
||||||
|
const uploader = await props.fs.uploadFile(filePath, file);
|
||||||
|
uploadItem.uploader = uploader;
|
||||||
|
|
||||||
|
if (uploader?.onProgress) {
|
||||||
|
uploader.onProgress(({ progress }: { progress: number }) => {
|
||||||
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (index !== -1) {
|
||||||
|
uploadQueue.value[index].progress = Math.round(progress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploader?.promise;
|
||||||
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||||
|
uploadQueue.value[index].status = "completed";
|
||||||
|
uploadQueue.value[index].progress = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (removeIndex !== -1) {
|
||||||
|
uploadQueue.value.splice(removeIndex, 1);
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
emit("uploadComplete");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading file:", error);
|
||||||
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||||
|
uploadQueue.value[index].status =
|
||||||
|
error instanceof Error && error.message === badFileTypeMsg ? "incorrect-type" : "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||||
|
if (removeIndex !== -1) {
|
||||||
|
uploadQueue.value.splice(removeIndex, 1);
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message !== "Upload cancelled") {
|
||||||
|
addNotification({
|
||||||
|
group: "files",
|
||||||
|
title: "Upload failed",
|
||||||
|
text: `Failed to upload ${file.name}`,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
uploadFile,
|
||||||
|
cancelUpload,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-status {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status-enter-active,
|
||||||
|
.upload-status-leave-active {
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status-enter-from,
|
||||||
|
.upload-status-leave-to {
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-enter-active,
|
||||||
|
.status-icon-leave-active {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-enter-from,
|
||||||
|
.status-icon-leave-to {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-enter-to,
|
||||||
|
.status-icon-leave-from {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -61,7 +61,15 @@
|
|||||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="status === 'suspended'"
|
v-if="status === 'suspended' && suspension_reason === 'support'"
|
||||||
|
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||||
|
>
|
||||||
|
<HammerIcon />
|
||||||
|
You recently requested support for your server and we are actively working on it. It will be
|
||||||
|
back online shortly.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
|
||||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||||
>
|
>
|
||||||
<UiServersIconsPanelErrorIcon class="!size-5" />
|
<UiServersIconsPanelErrorIcon class="!size-5" />
|
||||||
@@ -72,7 +80,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ChevronRightIcon, LockIcon } from "@modrinth/assets";
|
import { ChevronRightIcon, HammerIcon, LockIcon } from "@modrinth/assets";
|
||||||
import type { Project, Server } from "~/types/servers";
|
import type { Project, Server } from "~/types/servers";
|
||||||
|
|
||||||
const props = defineProps<Partial<Server>>();
|
const props = defineProps<Partial<Server>>();
|
||||||
|
|||||||
@@ -72,6 +72,8 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||||
|
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
|
||||||
|
'rounded-t-xl': item.index === 0 && isRenderingUp,
|
||||||
}"
|
}"
|
||||||
:aria-selected="selectedValue === item.option"
|
:aria-selected="selectedValue === item.option"
|
||||||
@click="selectOption(item.option, item.index)"
|
@click="selectOption(item.option, item.index)"
|
||||||
|
|||||||
@@ -104,22 +104,15 @@ export const initAuth = async (oldToken = null) => {
|
|||||||
return auth;
|
return auth;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAuthUrl = (provider, redirect = "") => {
|
export const getAuthUrl = (provider, redirect = "/dashboard") => {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
|
|
||||||
if (redirect === "") {
|
const fullURL = route.query.launcher
|
||||||
redirect = route.path;
|
? "https://launcher-files.modrinth.com"
|
||||||
}
|
: `${config.public.siteUrl}/auth/sign-in?redirect=${redirect}`;
|
||||||
|
|
||||||
let fullURL;
|
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`;
|
||||||
if (route.query.launcher) {
|
|
||||||
fullURL = `https://launcher-files.modrinth.com`;
|
|
||||||
} else {
|
|
||||||
fullURL = `${config.public.siteUrl}${redirect}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${fullURL}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeAuthProvider = async (provider) => {
|
export const removeAuthProvider = async (provider) => {
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
|||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[PYROSERVERS]:", error);
|
console.error("[PyroServers/PyroFetch]:", error);
|
||||||
if (error instanceof FetchError) {
|
if (error instanceof FetchError) {
|
||||||
const statusCode = error.response?.status;
|
const statusCode = error.response?.status;
|
||||||
const statusText = error.response?.statusText || "Unknown error";
|
const statusText = error.response?.statusText || "[no status text available]";
|
||||||
const errorMessages: { [key: number]: string } = {
|
const errorMessages: { [key: number]: string } = {
|
||||||
400: "Bad Request",
|
400: "Bad Request",
|
||||||
401: "Unauthorized",
|
401: "Unauthorized",
|
||||||
@@ -80,15 +80,16 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
|||||||
429: "Too Many Requests",
|
429: "Too Many Requests",
|
||||||
500: "Internal Server Error",
|
500: "Internal Server Error",
|
||||||
502: "Bad Gateway",
|
502: "Bad Gateway",
|
||||||
|
503: "Service Unavailable",
|
||||||
};
|
};
|
||||||
const message =
|
const message =
|
||||||
statusCode && statusCode in errorMessages
|
statusCode && statusCode in errorMessages
|
||||||
? errorMessages[statusCode]
|
? errorMessages[statusCode]
|
||||||
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
|
: `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`;
|
||||||
throw new PyroFetchError(`[PYROSERVERS][PYRO] ${message}`, statusCode, error);
|
throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error);
|
||||||
}
|
}
|
||||||
throw new PyroFetchError(
|
throw new PyroFetchError(
|
||||||
"[PYROSERVERS][PYRO] An unexpected error occurred during the fetch operation.",
|
"[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.",
|
||||||
undefined,
|
undefined,
|
||||||
error as Error,
|
error as Error,
|
||||||
);
|
);
|
||||||
@@ -168,7 +169,15 @@ interface General {
|
|||||||
backup_quota: number;
|
backup_quota: number;
|
||||||
used_backup_quota: number;
|
used_backup_quota: number;
|
||||||
status: string;
|
status: string;
|
||||||
suspension_reason: string;
|
suspension_reason:
|
||||||
|
| "moderated"
|
||||||
|
| "paymentfailed"
|
||||||
|
| "cancelled"
|
||||||
|
| "other"
|
||||||
|
| "transferring"
|
||||||
|
| "upgrading"
|
||||||
|
| "support"
|
||||||
|
| (string & {});
|
||||||
loader: string;
|
loader: string;
|
||||||
loader_version: string;
|
loader_version: string;
|
||||||
mc_version: string;
|
mc_version: string;
|
||||||
@@ -198,14 +207,16 @@ interface Startup {
|
|||||||
jdk_build: "corretto" | "temurin" | "graal";
|
jdk_build: "corretto" | "temurin" | "graal";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Mod {
|
export interface Mod {
|
||||||
filename: string;
|
filename: string;
|
||||||
project_id: string;
|
project_id: string | undefined;
|
||||||
version_id: string;
|
version_id: string | undefined;
|
||||||
name: string;
|
name: string | undefined;
|
||||||
version_number: string;
|
version_number: string | undefined;
|
||||||
icon_url: string;
|
icon_url: string | undefined;
|
||||||
|
owner: string | undefined;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
installing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Backup {
|
interface Backup {
|
||||||
@@ -1364,7 +1375,7 @@ type ContentModule = { data: Mod[] } & ContentFunctions;
|
|||||||
type BackupsModule = { data: Backup[] } & BackupFunctions;
|
type BackupsModule = { data: Backup[] } & BackupFunctions;
|
||||||
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
|
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
|
||||||
type StartupModule = Startup & StartupFunctions;
|
type StartupModule = Startup & StartupFunctions;
|
||||||
type FSModule = { auth: JWTAuth } & FSFunctions;
|
export type FSModule = { auth: JWTAuth } & FSFunctions;
|
||||||
|
|
||||||
type ModulesMap = {
|
type ModulesMap = {
|
||||||
general: GeneralModule;
|
general: GeneralModule;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"admin.billing.error.not-found": {
|
||||||
|
"message": "User not found"
|
||||||
|
},
|
||||||
"auth.authorize.action.authorize": {
|
"auth.authorize.action.authorize": {
|
||||||
"message": "Authorize"
|
"message": "Authorize"
|
||||||
},
|
},
|
||||||
@@ -338,6 +341,12 @@
|
|||||||
"layout.nav.search": {
|
"layout.nav.search": {
|
||||||
"message": "Search"
|
"message": "Search"
|
||||||
},
|
},
|
||||||
|
"profile.button.billing": {
|
||||||
|
"message": "Manage user billing"
|
||||||
|
},
|
||||||
|
"profile.button.info": {
|
||||||
|
"message": "View user details"
|
||||||
|
},
|
||||||
"profile.button.manage-projects": {
|
"profile.button.manage-projects": {
|
||||||
"message": "Manage projects"
|
"message": "Manage projects"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -184,7 +184,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NewModal ref="downloadModal">
|
<NewModal
|
||||||
|
ref="downloadModal"
|
||||||
|
:on-show="
|
||||||
|
() => {
|
||||||
|
navigateTo({ query: route.query, hash: '#download' });
|
||||||
|
}
|
||||||
|
"
|
||||||
|
:on-hide="
|
||||||
|
() => {
|
||||||
|
navigateTo({ query: route.query, hash: '' });
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||||
<div class="truncate text-lg font-extrabold text-contrast">
|
<div class="truncate text-lg font-extrabold text-contrast">
|
||||||
@@ -275,7 +287,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<ScrollablePanel :class="project.game_versions.length > 4 ? 'h-[15rem]' : ''">
|
<ScrollablePanel :class="project.game_versions.length > 4 ? 'h-[15rem]' : ''">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
v-for="version in project.game_versions
|
v-for="gameVersion in project.game_versions
|
||||||
.filter(
|
.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
(versionFilter && x.includes(versionFilter)) ||
|
(versionFilter && x.includes(versionFilter)) ||
|
||||||
@@ -284,30 +296,39 @@
|
|||||||
)
|
)
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()"
|
.reverse()"
|
||||||
:key="version"
|
:key="gameVersion"
|
||||||
:color="currentGameVersion === version ? 'brand' : 'standard'"
|
:color="currentGameVersion === gameVersion ? 'brand' : 'standard'"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-tooltip="
|
v-tooltip="
|
||||||
!possibleGameVersions.includes(version)
|
!possibleGameVersions.includes(gameVersion)
|
||||||
? `${project.title} does not support ${version} for ${formatCategory(currentPlatform)}`
|
? `${project.title} does not support ${gameVersion} for ${formatCategory(currentPlatform)}`
|
||||||
: null
|
: null
|
||||||
"
|
"
|
||||||
:class="{
|
:class="{
|
||||||
'looks-disabled !text-brand-red': !possibleGameVersions.includes(version),
|
'looks-disabled !text-brand-red': !possibleGameVersions.includes(gameVersion),
|
||||||
}"
|
}"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
userSelectedGameVersion = version;
|
userSelectedGameVersion = gameVersion;
|
||||||
gameVersionAccordion.close();
|
gameVersionAccordion.close();
|
||||||
if (!currentPlatform && platformAccordion) {
|
if (!currentPlatform && platformAccordion) {
|
||||||
platformAccordion.open();
|
platformAccordion.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
navigateTo({
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
|
||||||
|
...(userSelectedPlatform && { loader: userSelectedPlatform }),
|
||||||
|
},
|
||||||
|
hash: route.hash,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ version }}
|
{{ gameVersion }}
|
||||||
<CheckIcon v-if="userSelectedGameVersion === version" />
|
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</ScrollablePanel>
|
</ScrollablePanel>
|
||||||
@@ -379,6 +400,15 @@
|
|||||||
if (!currentGameVersion && gameVersionAccordion) {
|
if (!currentGameVersion && gameVersionAccordion) {
|
||||||
gameVersionAccordion.open();
|
gameVersionAccordion.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
navigateTo({
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
|
||||||
|
...(userSelectedPlatform && { loader: userSelectedPlatform }),
|
||||||
|
},
|
||||||
|
hash: route.hash,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -506,7 +536,7 @@
|
|||||||
placeholder="Search collections..."
|
placeholder="Search collections..."
|
||||||
class="search-input menu-search"
|
class="search-input menu-search"
|
||||||
/>
|
/>
|
||||||
<div v-if="collections.length > 0" class="collections-list">
|
<div v-if="collections.length > 0" class="collections-list text-primary">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-for="option in collections
|
v-for="option in collections
|
||||||
.slice()
|
.slice()
|
||||||
@@ -772,6 +802,7 @@
|
|||||||
:reset-members="resetMembers"
|
:reset-members="resetMembers"
|
||||||
:route="route"
|
:route="route"
|
||||||
@on-download="triggerDownloadAnimation"
|
@on-download="triggerDownloadAnimation"
|
||||||
|
@delete-version="deleteVersion"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -785,31 +816,31 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
ScaleIcon,
|
|
||||||
AlignLeftIcon as DescriptionIcon,
|
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
|
BookTextIcon,
|
||||||
|
CalendarIcon,
|
||||||
ChartIcon,
|
ChartIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ClipboardCopyIcon,
|
ClipboardCopyIcon,
|
||||||
CopyrightIcon,
|
CopyrightIcon,
|
||||||
|
AlignLeftIcon as DescriptionIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
ExternalIcon,
|
ExternalIcon,
|
||||||
|
ImageIcon as GalleryIcon,
|
||||||
GameIcon,
|
GameIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
ImageIcon as GalleryIcon,
|
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
LinkIcon as LinksIcon,
|
LinkIcon as LinksIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ReportIcon,
|
ReportIcon,
|
||||||
|
ScaleIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
TagsIcon,
|
TagsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
VersionIcon,
|
VersionIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
BookTextIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -818,32 +849,33 @@ import {
|
|||||||
NewModal,
|
NewModal,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
PopoutMenu,
|
PopoutMenu,
|
||||||
ScrollablePanel,
|
ProjectBackgroundGradient,
|
||||||
ProjectHeader,
|
ProjectHeader,
|
||||||
ProjectSidebarCompatibility,
|
ProjectSidebarCompatibility,
|
||||||
ProjectSidebarCreators,
|
ProjectSidebarCreators,
|
||||||
ProjectSidebarLinks,
|
|
||||||
ProjectSidebarDetails,
|
ProjectSidebarDetails,
|
||||||
ProjectBackgroundGradient,
|
ProjectSidebarLinks,
|
||||||
|
ScrollablePanel,
|
||||||
} from "@modrinth/ui";
|
} from "@modrinth/ui";
|
||||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
|
||||||
import dayjs from "dayjs";
|
|
||||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||||
|
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||||
|
import { navigateTo } from "#app";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
|
||||||
|
import Accordion from "~/components/ui/Accordion.vue";
|
||||||
|
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||||
|
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||||
import Badge from "~/components/ui/Badge.vue";
|
import Badge from "~/components/ui/Badge.vue";
|
||||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||||
|
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||||
|
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||||
|
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||||
import NavStack from "~/components/ui/NavStack.vue";
|
import NavStack from "~/components/ui/NavStack.vue";
|
||||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||||
|
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||||
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
|
||||||
import { reportProject } from "~/utils/report-helpers.ts";
|
|
||||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
|
||||||
import { userCollectProject } from "~/composables/user.js";
|
import { userCollectProject } from "~/composables/user.js";
|
||||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
import { reportProject } from "~/utils/report-helpers.ts";
|
||||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
|
||||||
import Accordion from "~/components/ui/Accordion.vue";
|
|
||||||
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
|
|
||||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
|
||||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
|
||||||
|
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
@@ -1247,6 +1279,23 @@ if (!route.name.startsWith("type-id-settings")) {
|
|||||||
|
|
||||||
const onUserCollectProject = useClientTry(userCollectProject);
|
const onUserCollectProject = useClientTry(userCollectProject);
|
||||||
|
|
||||||
|
const { version, loader } = route.query;
|
||||||
|
if (version !== undefined && project.value.game_versions.includes(version)) {
|
||||||
|
userSelectedGameVersion.value = version;
|
||||||
|
}
|
||||||
|
if (loader !== undefined && project.value.loaders.includes(loader)) {
|
||||||
|
userSelectedPlatform.value = loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(downloadModal, (modal) => {
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
// route.hash returns everything in the hash string, including the # itself
|
||||||
|
if (route.hash === "#download") {
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function setProcessing() {
|
async function setProcessing() {
|
||||||
startLoading();
|
startLoading();
|
||||||
|
|
||||||
@@ -1403,6 +1452,20 @@ function onDownload(event) {
|
|||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteVersion(id) {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
startLoading();
|
||||||
|
|
||||||
|
await useBaseFetch(`version/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
versions.value = versions.value.filter((x) => x.id !== id);
|
||||||
|
|
||||||
|
stopLoading();
|
||||||
|
}
|
||||||
|
|
||||||
const navLinks = computed(() => {
|
const navLinks = computed(() => {
|
||||||
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`;
|
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`;
|
||||||
|
|
||||||
|
|||||||
@@ -381,6 +381,7 @@
|
|||||||
/>
|
/>
|
||||||
<ButtonStyled v-if="isEditing">
|
<ButtonStyled v-if="isEditing">
|
||||||
<button
|
<button
|
||||||
|
class="raised-button"
|
||||||
:disabled="primaryFile.hashes.sha1 === file.hashes.sha1"
|
:disabled="primaryFile.hashes.sha1 === file.hashes.sha1"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
@@ -821,6 +822,13 @@ export default defineNuxtComponent({
|
|||||||
if (route.query.version) {
|
if (route.query.version) {
|
||||||
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version));
|
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version));
|
||||||
}
|
}
|
||||||
|
if (versionList.length === 0) {
|
||||||
|
throw createError({
|
||||||
|
fatal: true,
|
||||||
|
statusCode: 404,
|
||||||
|
message: "No version matches the filters",
|
||||||
|
});
|
||||||
|
}
|
||||||
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b));
|
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b));
|
||||||
} else {
|
} else {
|
||||||
version = props.versions.find((x) => x.id === route.params.version);
|
version = props.versions.find((x) => x.id === route.params.version);
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<ConfirmModal
|
||||||
|
v-if="currentMember"
|
||||||
|
ref="deleteVersionModal"
|
||||||
|
title="Are you sure you want to delete this version?"
|
||||||
|
description="This will remove this version forever (like really forever)."
|
||||||
|
:has-to-type="false"
|
||||||
|
proceed-label="Delete"
|
||||||
|
@proceed="deleteVersion()"
|
||||||
|
/>
|
||||||
<section class="experimental-styles-within overflow-visible">
|
<section class="experimental-styles-within overflow-visible">
|
||||||
<div
|
<div
|
||||||
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
|
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
|
||||||
@@ -41,7 +50,7 @@
|
|||||||
:href="getPrimaryFile(version).url"
|
:href="getPrimaryFile(version).url"
|
||||||
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
|
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
@click="emits('onDownload')"
|
@click="emit('onDownload')"
|
||||||
>
|
>
|
||||||
<DownloadIcon aria-hidden="true" />
|
<DownloadIcon aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
@@ -57,7 +66,7 @@
|
|||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
link: getPrimaryFile(version).url,
|
link: getPrimaryFile(version).url,
|
||||||
action: () => {
|
action: () => {
|
||||||
emits('onDownload');
|
emit('onDownload');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,8 +110,11 @@
|
|||||||
id: 'delete',
|
id: 'delete',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
action: () => {},
|
action: () => {
|
||||||
shown: currentMember && false,
|
selectedVersion = version.id;
|
||||||
|
deleteVersionModal.show();
|
||||||
|
},
|
||||||
|
shown: currentMember,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
@@ -144,7 +156,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ButtonStyled, OverflowMenu, FileInput, ProjectPageVersions } from "@modrinth/ui";
|
import {
|
||||||
|
ButtonStyled,
|
||||||
|
OverflowMenu,
|
||||||
|
FileInput,
|
||||||
|
ProjectPageVersions,
|
||||||
|
ConfirmModal,
|
||||||
|
} from "@modrinth/ui";
|
||||||
import {
|
import {
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
@@ -185,7 +203,10 @@ const tags = useTags();
|
|||||||
const flags = useFeatureFlags();
|
const flags = useFeatureFlags();
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
|
|
||||||
const emits = defineEmits(["onDownload"]);
|
const deleteVersionModal = ref();
|
||||||
|
const selectedVersion = ref(null);
|
||||||
|
|
||||||
|
const emit = defineEmits(["onDownload", "deleteVersion"]);
|
||||||
|
|
||||||
const router = useNativeRouter();
|
const router = useNativeRouter();
|
||||||
|
|
||||||
@@ -212,4 +233,9 @@ async function handleFiles(files) {
|
|||||||
async function copyToClipboard(text) {
|
async function copyToClipboard(text) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteVersion() {
|
||||||
|
emit("deleteVersion", selectedVersion.value);
|
||||||
|
selectedVersion.value = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
220
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
220
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="refundModal">
|
||||||
|
<template #title>
|
||||||
|
<span class="text-lg font-extrabold text-contrast">Refund charge</span>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="visibility" class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-semibold text-contrast">
|
||||||
|
Refund type
|
||||||
|
<span class="text-brand-red">*</span>
|
||||||
|
</span>
|
||||||
|
<span> The type of refund to issue. </span>
|
||||||
|
</label>
|
||||||
|
<DropdownSelect
|
||||||
|
id="refund-type"
|
||||||
|
v-model="refundType"
|
||||||
|
:options="refundTypes"
|
||||||
|
name="Refund type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="refundType === 'partial'" class="flex flex-col gap-2">
|
||||||
|
<label for="amount" class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-semibold text-contrast">
|
||||||
|
Amount
|
||||||
|
<span class="text-brand-red">*</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Enter the amount in cents of USD. For example for $2, enter 200. (net
|
||||||
|
{{ selectedCharge.net }})
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input id="amount" v-model="refundAmount" type="number" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="unprovision" class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-semibold text-contrast">
|
||||||
|
Unprovision
|
||||||
|
<span class="text-brand-red">*</span>
|
||||||
|
</span>
|
||||||
|
<span> Whether or not the subscription should be unprovisioned on refund. </span>
|
||||||
|
</label>
|
||||||
|
<Toggle
|
||||||
|
id="unprovision"
|
||||||
|
:model-value="unprovision"
|
||||||
|
:checked="unprovision"
|
||||||
|
@update:model-value="() => (unprovision = !unprovision)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button :disabled="refunding" @click="refundCharge">
|
||||||
|
<CheckIcon aria-hidden="true" />
|
||||||
|
Refund charge
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="refundModal.hide()">
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
<div class="normal-page no-sidebar">
|
||||||
|
<h1>{{ user.username }}'s subscriptions</h1>
|
||||||
|
<div class="normal-page__content">
|
||||||
|
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
|
||||||
|
<span class="font-extrabold text-contrast">
|
||||||
|
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template>
|
||||||
|
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
||||||
|
Modrinth Servers
|
||||||
|
</template>
|
||||||
|
<template v-else> Unknown product </template>
|
||||||
|
<template v-if="subscription.interval">
|
||||||
|
{{ subscription.interval }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<div class="mb-4 mt-2 flex items-center gap-1">
|
||||||
|
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }}
|
||||||
|
<template v-if="subscription.metadata?.id"> ⋅ {{ subscription.metadata.id }}</template>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="charge in subscription.charges"
|
||||||
|
:key="charge.id"
|
||||||
|
class="universal-card recessed flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div class="flex w-full items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Badge
|
||||||
|
:color="charge.status === 'succeeded' ? 'green' : 'red'"
|
||||||
|
:type="charge.status"
|
||||||
|
/>
|
||||||
|
⋅
|
||||||
|
{{ charge.type }}
|
||||||
|
⋅
|
||||||
|
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
|
||||||
|
⋅
|
||||||
|
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||||
|
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
||||||
|
class="btn"
|
||||||
|
@click="showRefundModal(charge)"
|
||||||
|
>
|
||||||
|
Refund charge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui";
|
||||||
|
import { formatPrice } from "@modrinth/utils";
|
||||||
|
import { CheckIcon, XIcon } from "@modrinth/assets";
|
||||||
|
import { products } from "~/generated/state.json";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const data = useNuxtApp();
|
||||||
|
const vintl = useVIntl();
|
||||||
|
const { formatMessage } = vintl;
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
userNotFoundError: {
|
||||||
|
id: "admin.billing.error.not-found",
|
||||||
|
defaultMessage: "User not found",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: user } = await useAsyncData(`user/${route.params.id}`, () =>
|
||||||
|
useBaseFetch(`user/${route.params.id}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user.value) {
|
||||||
|
throw createError({
|
||||||
|
fatal: true,
|
||||||
|
statusCode: 404,
|
||||||
|
message: formatMessage(messages.userNotFoundError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscriptions, charges, refreshCharges;
|
||||||
|
try {
|
||||||
|
[{ data: subscriptions }, { data: charges, refresh: refreshCharges }] = await Promise.all([
|
||||||
|
useAsyncData(`billing/subscriptions?user_id=${route.params.id}`, () =>
|
||||||
|
useBaseFetch(`billing/subscriptions?user_id=${user.value.id}`, {
|
||||||
|
internal: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
useAsyncData(`billing/payments?user_id=${route.params.id}`, () =>
|
||||||
|
useBaseFetch(`billing/payments?user_id=${user.value.id}`, {
|
||||||
|
internal: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
throw createError({
|
||||||
|
fatal: true,
|
||||||
|
statusCode: 404,
|
||||||
|
message: formatMessage(messages.userNotFoundError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionCharges = computed(() => {
|
||||||
|
return subscriptions.value.map((subscription) => {
|
||||||
|
return {
|
||||||
|
...subscription,
|
||||||
|
charges: charges.value.filter((charge) => charge.subscription_id === subscription.id),
|
||||||
|
product: products.find((product) =>
|
||||||
|
product.prices.some((price) => price.id === subscription.price_id),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const refunding = ref(false);
|
||||||
|
const refundModal = ref();
|
||||||
|
const selectedCharge = ref(null);
|
||||||
|
const refundType = ref("full");
|
||||||
|
const refundTypes = ref(["full", "partial"]);
|
||||||
|
const refundAmount = ref(0);
|
||||||
|
const unprovision = ref(false);
|
||||||
|
|
||||||
|
function showRefundModal(charge) {
|
||||||
|
selectedCharge.value = charge;
|
||||||
|
refundType.value = "full";
|
||||||
|
refundAmount.value = 0;
|
||||||
|
unprovision.value = false;
|
||||||
|
refundModal.value.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refundCharge() {
|
||||||
|
refunding.value = true;
|
||||||
|
try {
|
||||||
|
await useBaseFetch(`billing/charge/${selectedCharge.value.id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: refundType.value,
|
||||||
|
amount: refundAmount.value,
|
||||||
|
unprovision: unprovision.value,
|
||||||
|
}),
|
||||||
|
internal: true,
|
||||||
|
});
|
||||||
|
await refreshCharges();
|
||||||
|
refundModal.value.hide();
|
||||||
|
} catch (err) {
|
||||||
|
data.$notify({
|
||||||
|
group: "main",
|
||||||
|
title: "Error refunding",
|
||||||
|
text: err.data?.description ?? err,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refunding.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ModalConfirm
|
<ConfirmModal
|
||||||
v-if="auth.user && auth.user.id === creator.id"
|
v-if="canEdit"
|
||||||
ref="deleteModal"
|
ref="deleteModal"
|
||||||
:title="formatMessage(messages.deleteModalTitle)"
|
:title="formatMessage(messages.deleteModalTitle)"
|
||||||
:description="formatMessage(messages.deleteModalDescription)"
|
:description="formatMessage(messages.deleteModalDescription)"
|
||||||
@@ -387,12 +387,13 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
|
ConfirmModal,
|
||||||
} from "@modrinth/ui";
|
} from "@modrinth/ui";
|
||||||
|
|
||||||
|
import { isAdmin } from "@modrinth/utils";
|
||||||
import WorldIcon from "assets/images/utils/world.svg";
|
import WorldIcon from "assets/images/utils/world.svg";
|
||||||
import UpToDate from "assets/images/illustrations/up_to_date.svg";
|
import UpToDate from "assets/images/illustrations/up_to_date.svg";
|
||||||
import { addNotification } from "~/composables/notifs.js";
|
import { addNotification } from "~/composables/notifs.js";
|
||||||
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
|
|
||||||
import NavRow from "~/components/ui/NavRow.vue";
|
import NavRow from "~/components/ui/NavRow.vue";
|
||||||
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
||||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||||
@@ -596,7 +597,7 @@ useSeoMeta({
|
|||||||
const canEdit = computed(
|
const canEdit = computed(
|
||||||
() =>
|
() =>
|
||||||
auth.value.user &&
|
auth.value.user &&
|
||||||
auth.value.user.id === collection.value.user &&
|
(auth.value.user.id === collection.value.user || isAdmin(auth.value.user)) &&
|
||||||
collection.value.id !== "following",
|
collection.value.id !== "following",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -685,7 +686,11 @@ async function deleteCollection() {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
apiVersion: 3,
|
apiVersion: 3,
|
||||||
});
|
});
|
||||||
await navigateTo("/dashboard/collections");
|
if (auth.value.user.id === collection.value.user) {
|
||||||
|
await navigateTo("/dashboard/collections");
|
||||||
|
} else {
|
||||||
|
await navigateTo(`/user/${collection.value.user}/collections`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "main",
|
group: "main",
|
||||||
|
|||||||
@@ -38,9 +38,13 @@
|
|||||||
<div class="withdraw-options-scroll">
|
<div class="withdraw-options-scroll">
|
||||||
<div class="withdraw-options">
|
<div class="withdraw-options">
|
||||||
<button
|
<button
|
||||||
v-for="method in payoutMethods.filter((x) =>
|
v-for="method in payoutMethods
|
||||||
x.name.toLowerCase().includes(search.toLowerCase()),
|
.filter((x) => x.name.toLowerCase().includes(search.toLowerCase()))
|
||||||
)"
|
.sort((a, b) =>
|
||||||
|
a.type !== 'tremendous'
|
||||||
|
? -1
|
||||||
|
: a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||||
|
)"
|
||||||
:key="method.id"
|
:key="method.id"
|
||||||
class="withdraw-option button-base"
|
class="withdraw-option button-base"
|
||||||
:class="{ selected: selectedMethodId === method.id }"
|
:class="{ selected: selectedMethodId === method.id }"
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
<div class="users-section">
|
<div class="users-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="section-label green">For Players</div>
|
<div class="section-label green">For Players</div>
|
||||||
<h2 class="section-tagline">Discover over 10,000 creations</h2>
|
<h2 class="section-tagline">Discover over 50,000 creations</h2>
|
||||||
<p class="section-description">
|
<p class="section-description">
|
||||||
From magical biomes to cursed dungeons, you can be sure to find content to bring your
|
From magical biomes to cursed dungeons, you can be sure to find content to bring your
|
||||||
gameplay to the next level.
|
gameplay to the next level.
|
||||||
|
|||||||
@@ -124,7 +124,7 @@
|
|||||||
We aim to be as transparent as possible with creator revenue. All of our code is open source,
|
We aim to be as transparent as possible with creator revenue. All of our code is open source,
|
||||||
including our
|
including our
|
||||||
<a href="https://github.com/modrinth/code/blob/main/apps/labrinth/src/queue/payouts.rs#L598">
|
<a href="https://github.com/modrinth/code/blob/main/apps/labrinth/src/queue/payouts.rs#L598">
|
||||||
revenue distribution system </a
|
revenue distribution system</a
|
||||||
>. We also have an
|
>. We also have an
|
||||||
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
|
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
|
||||||
to query exact daily revenue for the site.
|
to query exact daily revenue for the site.
|
||||||
|
|||||||
@@ -42,7 +42,11 @@
|
|||||||
Install content to server
|
Install content to server
|
||||||
</h1>
|
</h1>
|
||||||
</template>
|
</template>
|
||||||
<NavTabs v-if="!server" :links="selectableProjectTypes" class="hidden md:flex" />
|
<NavTabs
|
||||||
|
v-if="!server && !flags.projectTypesPrimaryNav"
|
||||||
|
:links="selectableProjectTypes"
|
||||||
|
class="hidden md:flex"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
<aside
|
<aside
|
||||||
:class="{
|
:class="{
|
||||||
@@ -164,7 +168,7 @@
|
|||||||
name="Sort by"
|
name="Sort by"
|
||||||
:options="sortTypes"
|
:options="sortTypes"
|
||||||
:display-name="(option) => option?.display"
|
:display-name="(option) => option?.display"
|
||||||
@change="updateSearchResults(1)"
|
@change="updateSearchResults()"
|
||||||
>
|
>
|
||||||
<span class="font-semibold text-primary">Sort by: </span>
|
<span class="font-semibold text-primary">Sort by: </span>
|
||||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||||
@@ -177,7 +181,7 @@
|
|||||||
:default-value="maxResults"
|
:default-value="maxResults"
|
||||||
:model-value="maxResults"
|
:model-value="maxResults"
|
||||||
class="!w-auto flex-grow md:flex-grow-0"
|
class="!w-auto flex-grow md:flex-grow-0"
|
||||||
@change="updateSearchResults(1)"
|
@change="updateSearchResults()"
|
||||||
>
|
>
|
||||||
<span class="font-semibold text-primary">View: </span>
|
<span class="font-semibold text-primary">View: </span>
|
||||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||||
@@ -202,7 +206,7 @@
|
|||||||
:page="currentPage"
|
:page="currentPage"
|
||||||
:count="pageCount"
|
:count="pageCount"
|
||||||
class="mx-auto sm:ml-auto sm:mr-0"
|
class="mx-auto sm:ml-auto sm:mr-0"
|
||||||
@switch-page="setPage"
|
@switch-page="updateSearchResults"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SearchFilterControl
|
<SearchFilterControl
|
||||||
@@ -292,7 +296,7 @@
|
|||||||
:page="currentPage"
|
:page="currentPage"
|
||||||
:count="pageCount"
|
:count="pageCount"
|
||||||
class="justify-end"
|
class="justify-end"
|
||||||
@switch-page="setPage"
|
@switch-page="updateSearchResults"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,11 +342,21 @@ const tags = useTags();
|
|||||||
const flags = useFeatureFlags();
|
const flags = useFeatureFlags();
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
|
|
||||||
const projectType = computed(() =>
|
const projectType = ref();
|
||||||
tags.value.projectTypes.find(
|
function setProjectType() {
|
||||||
|
const projType = tags.value.projectTypes.find(
|
||||||
(x) => x.id === route.path.replaceAll(/^\/|s\/?$/g, ""), // Removes prefix `/` and suffixes `s` and `s/`
|
(x) => x.id === route.path.replaceAll(/^\/|s\/?$/g, ""), // Removes prefix `/` and suffixes `s` and `s/`
|
||||||
),
|
);
|
||||||
);
|
|
||||||
|
if (projType) {
|
||||||
|
projectType.value = projType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setProjectType();
|
||||||
|
router.afterEach(() => {
|
||||||
|
setProjectType();
|
||||||
|
});
|
||||||
|
|
||||||
const projectTypes = computed(() => [projectType.value.id]);
|
const projectTypes = computed(() => [projectType.value.id]);
|
||||||
|
|
||||||
const server = ref();
|
const server = ref();
|
||||||
@@ -516,7 +530,7 @@ const {
|
|||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl;
|
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl;
|
||||||
|
|
||||||
return `${base}/search${requestParams.value}`;
|
return `${base}search${requestParams.value}`;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
transform: (hits) => {
|
transform: (hits) => {
|
||||||
@@ -531,19 +545,13 @@ const pageCount = computed(() =>
|
|||||||
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
|
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
function setPage(newPageNumber) {
|
|
||||||
currentPage.value = newPageNumber;
|
|
||||||
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
|
|
||||||
updateSearchResults();
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToTop(behavior = "smooth") {
|
function scrollToTop(behavior = "smooth") {
|
||||||
window.scrollTo({ top: 0, behavior });
|
window.scrollTo({ top: 0, behavior });
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSearchResults() {
|
function updateSearchResults(pageNumber) {
|
||||||
|
currentPage.value = pageNumber || 1;
|
||||||
|
scrollToTop();
|
||||||
noLoad.value = true;
|
noLoad.value = true;
|
||||||
|
|
||||||
if (query.value === null) {
|
if (query.value === null) {
|
||||||
@@ -576,8 +584,8 @@ function updateSearchResults() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([currentFilters, requestParams], () => {
|
watch([currentFilters], () => {
|
||||||
updateSearchResults();
|
updateSearchResults(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
function cycleSearchDisplayMode() {
|
function cycleSearchDisplayMode() {
|
||||||
|
|||||||
@@ -456,9 +456,9 @@
|
|||||||
Where are Modrinth Servers located? Can I choose a region?
|
Where are Modrinth Servers located? Can I choose a region?
|
||||||
</summary>
|
</summary>
|
||||||
<p class="m-0 !leading-[190%]">
|
<p class="m-0 !leading-[190%]">
|
||||||
Currently, Modrinth Servers are located in New York, Los Angeles, and Miami. More
|
Currently, Modrinth Servers are located in New York, Los Angeles, Seattle, and
|
||||||
regions are coming soon! Your server's location is currently chosen algorithmically,
|
Miami. More regions are coming soon! Your server's location is currently chosen
|
||||||
but you will be able to choose a region in the future.
|
algorithmically, but you will be able to choose a region in the future.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -512,9 +512,9 @@
|
|||||||
: "There's a plan for everyone! Choose the one that fits your needs."
|
: "There's a plan for everyone! Choose the one that fits your needs."
|
||||||
}}
|
}}
|
||||||
<span class="font-bold">
|
<span class="font-bold">
|
||||||
Servers are currently US only, in New York, Los Angeles, and Miami. More regions coming
|
Servers are currently US only, in New York, Los Angeles, Seattle, and Miami. More
|
||||||
soon!</span
|
regions coming soon!
|
||||||
>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul class="m-0 flex w-full flex-col gap-8 p-0 lg:flex-row">
|
<ul class="m-0 flex w-full flex-col gap-8 p-0 lg:flex-row">
|
||||||
@@ -533,9 +533,9 @@
|
|||||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||||
<p class="m-0">4 GB RAM</p>
|
<p class="m-0">4 GB RAM</p>
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
<p class="m-0">32 GB Storage</p>
|
<p class="m-0">4 vCPUs</p>
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
<p class="m-0">2 vCPUs</p>
|
<p class="m-0">32 GB Storage</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="m-0 text-3xl text-contrast">
|
<h2 class="m-0 text-3xl text-contrast">
|
||||||
$12<span class="text-sm font-normal text-secondary">/month</span>
|
$12<span class="text-sm font-normal text-secondary">/month</span>
|
||||||
@@ -585,9 +585,9 @@
|
|||||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||||
<p class="m-0">6 GB RAM</p>
|
<p class="m-0">6 GB RAM</p>
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
<p class="m-0">48 GB Storage</p>
|
<p class="m-0">6 vCPUs</p>
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
<p class="m-0">3 vCPUs</p>
|
<p class="m-0">48 GB Storage</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="m-0 text-3xl text-contrast">
|
<h2 class="m-0 text-3xl text-contrast">
|
||||||
$18<span class="text-sm font-normal text-secondary">/month</span>
|
$18<span class="text-sm font-normal text-secondary">/month</span>
|
||||||
@@ -626,9 +626,9 @@
|
|||||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||||
<p class="m-0">8 GB RAM</p>
|
<p class="m-0">8 GB RAM</p>
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
<p class="m-0">64 GB Storage</p>
|
<p class="m-0">8 vCPUs</p>
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
<p class="m-0">4 vCPUs</p>
|
<p class="m-0">64 GB Storage</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="m-0 text-3xl text-contrast">
|
<h2 class="m-0 text-3xl text-contrast">
|
||||||
$24<span class="text-sm font-normal text-secondary">/month</span>
|
$24<span class="text-sm font-normal text-secondary">/month</span>
|
||||||
@@ -656,11 +656,11 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left md:flex-row md:gap-0"
|
class="flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<h1 class="m-0">Build your own</h1>
|
<h1 class="m-0">Build your own</h1>
|
||||||
<h2 class="m-0 text-base font-normal">
|
<h2 class="m-0 text-base font-normal text-primary">
|
||||||
If you're a more technical server administrator, you can pick your own RAM and storage
|
If you're a more technical server administrator, you can pick your own RAM and storage
|
||||||
options.
|
options.
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -19,7 +19,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="serverData?.status === 'suspended'"
|
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'support'"
|
||||||
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
|
>
|
||||||
|
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||||
|
<TransferIcon class="size-12 text-blue" />
|
||||||
|
</div>
|
||||||
|
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg text-secondary">
|
||||||
|
You recently contacted Modrinth Support, and we're actively working on your server. It
|
||||||
|
will be back online shortly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'"
|
||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
>
|
>
|
||||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||||
@@ -69,6 +88,58 @@
|
|||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="server.error && server.error.message.includes('Service Unavailable')"
|
||||||
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
|
>
|
||||||
|
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<div class="grid place-content-center rounded-full bg-bg-red p-4">
|
||||||
|
<PanelErrorIcon class="size-12 text-red" />
|
||||||
|
</div>
|
||||||
|
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
||||||
|
</div>
|
||||||
|
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||||
|
Your server's node, where your Modrinth Server is physically hosted, is experiencing
|
||||||
|
issues. We are working with our datacenter to resolve the issue as quickly as possible.
|
||||||
|
</p>
|
||||||
|
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||||
|
Your data is safe and will not be lost, and your server will be back online as soon as
|
||||||
|
the issue is resolved.
|
||||||
|
</p>
|
||||||
|
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||||
|
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
|
||||||
|
bubble in the bottom right corner and we'll be happy to help.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<UiCopyCode :text="'Server ID: ' + server.serverId" />
|
||||||
|
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ButtonStyled
|
||||||
|
size="large"
|
||||||
|
color="standard"
|
||||||
|
@click="
|
||||||
|
() =>
|
||||||
|
navigateTo('https://discord.modrinth.com', {
|
||||||
|
external: true,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button class="mt-6 !w-full">Join Modrinth Discord</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled
|
||||||
|
:disabled="formattedTime !== '00'"
|
||||||
|
size="large"
|
||||||
|
color="standard"
|
||||||
|
@click="() => reloadNuxtApp()"
|
||||||
|
>
|
||||||
|
<button class="mt-3 !w-full">Reload</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="server.error"
|
v-else-if="server.error"
|
||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
@@ -324,6 +395,7 @@ import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
|
|||||||
import { reloadNuxtApp } from "#app";
|
import { reloadNuxtApp } from "#app";
|
||||||
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
|
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
|
||||||
import { usePyroConsole } from "~/store/console.ts";
|
import { usePyroConsole } from "~/store/console.ts";
|
||||||
|
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
|
||||||
|
|
||||||
const socket = ref<WebSocket | null>(null);
|
const socket = ref<WebSocket | null>(null);
|
||||||
const isReconnecting = ref(false);
|
const isReconnecting = ref(false);
|
||||||
|
|||||||
@@ -15,12 +15,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="props.server.general?.upstream" class="flex items-center gap-2">
|
<div v-if="props.server.general?.upstream" class="flex gap-2">
|
||||||
<InfoIcon class="hidden sm:block" />
|
<InfoIcon class="hidden sm:block" />
|
||||||
<span class="text-sm text-secondary">
|
<span class="text-sm text-secondary">
|
||||||
Your server was created from a modpack. Changing the mod version may cause unexpected
|
Changing the mod version may cause unexpected issues. Because your server was created
|
||||||
issues. You can update the modpack version in your server's Options > Platform
|
from a modpack, it is recommended to use the modpack's version of the mod.
|
||||||
settings.
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,9 +56,9 @@
|
|||||||
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
||||||
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
|
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
|
||||||
<div class="relative flex h-full w-full flex-col">
|
<div class="relative flex h-full w-full flex-col">
|
||||||
<div class="sticky top-0 z-20 -mt-4 flex items-center justify-between bg-bg py-4">
|
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
|
||||||
<div class="flex w-full flex-col items-center gap-2 sm:flex-row sm:gap-4">
|
<div class="flex w-full flex-col-reverse items-center gap-2 sm:flex-row">
|
||||||
<div class="flex w-full items-center gap-2 sm:gap-4">
|
<div class="flex w-full items-center gap-2">
|
||||||
<div class="relative flex-1 text-sm">
|
<div class="relative flex-1 text-sm">
|
||||||
<label class="sr-only" for="search">Search</label>
|
<label class="sr-only" for="search">Search</label>
|
||||||
<SearchIcon
|
<SearchIcon
|
||||||
@@ -73,7 +72,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
name="search"
|
name="search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:placeholder="`Search ${type.toLocaleLowerCase()}s...`"
|
:placeholder="`Search ${localMods.length} ${type.toLocaleLowerCase()}s...`"
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +87,7 @@
|
|||||||
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
|
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span class="whitespace-pre text-sm font-medium">
|
<span class="hidden whitespace-pre sm:block">
|
||||||
{{ filterMethodLabel }}
|
{{ filterMethodLabel }}
|
||||||
</span>
|
</span>
|
||||||
<FilterIcon aria-hidden="true" />
|
<FilterIcon aria-hidden="true" />
|
||||||
@@ -99,45 +98,71 @@
|
|||||||
</UiServersTeleportOverflowMenu>
|
</UiServersTeleportOverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<ButtonStyled v-if="hasMods" color="brand" type="outlined">
|
<div v-if="hasMods" class="flex w-full items-center gap-2 sm:w-fit">
|
||||||
<nuxt-link
|
<ButtonStyled>
|
||||||
class="w-full text-nowrap sm:w-fit"
|
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
|
||||||
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
<FileIcon />
|
||||||
>
|
Add file
|
||||||
<PlusIcon />
|
</button>
|
||||||
Add {{ type.toLocaleLowerCase() }}
|
</ButtonStyled>
|
||||||
</nuxt-link>
|
<ButtonStyled color="brand">
|
||||||
</ButtonStyled>
|
<nuxt-link
|
||||||
|
class="w-full text-nowrap sm:w-fit"
|
||||||
|
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add {{ type.toLocaleLowerCase() }}
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasMods" class="flex flex-col gap-2 transition-all">
|
<FilesUploadDropdown
|
||||||
<div ref="listContainer" class="relative w-full">
|
v-if="props.server.fs"
|
||||||
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
|
ref="uploadDropdownRef"
|
||||||
<div :style="{ position: 'absolute', top: `${visibleTop}px`, width: '100%' }">
|
class="rounded-xl bg-bg-raised"
|
||||||
<template v-for="mod in visibleItems.items" :key="mod.filename">
|
:margin-bottom="16"
|
||||||
<div
|
:file-type="type"
|
||||||
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
|
:current-path="'/mods'"
|
||||||
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
|
:fs="props.server.fs"
|
||||||
style="height: 64px"
|
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
||||||
>
|
@upload-complete="() => props.server.refresh(['content'])"
|
||||||
<NuxtLink
|
/>
|
||||||
:to="
|
<FilesUploadDragAndDrop
|
||||||
mod.project_id
|
v-if="server.general && localMods"
|
||||||
? `/project/${mod.project_id}/version/${mod.version_id}`
|
class="relative min-h-[50vh]"
|
||||||
: `files?path=mods`
|
overlay-class="rounded-xl border-2 border-dashed border-secondary"
|
||||||
"
|
:type="type"
|
||||||
class="group flex min-w-0 items-center rounded-xl p-2"
|
@files-dropped="handleDroppedFiles"
|
||||||
|
>
|
||||||
|
<div v-if="hasFilteredMods" class="flex flex-col gap-2 transition-all">
|
||||||
|
<div ref="listContainer" class="relative w-full">
|
||||||
|
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
|
||||||
|
<div :style="{ position: 'absolute', top: `${visibleTop}px`, width: '100%' }">
|
||||||
|
<template v-for="mod in visibleItems.items" :key="mod.filename">
|
||||||
|
<div
|
||||||
|
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
|
||||||
|
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
|
||||||
|
style="height: 64px"
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<NuxtLink
|
||||||
|
:to="
|
||||||
|
mod.project_id
|
||||||
|
? `/project/${mod.project_id}/version/${mod.version_id}`
|
||||||
|
: `files?path=mods`
|
||||||
|
"
|
||||||
|
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
<UiAvatar
|
<UiAvatar
|
||||||
:src="mod.icon_url"
|
:src="mod.icon_url"
|
||||||
size="sm"
|
size="sm"
|
||||||
alt="Server Icon"
|
alt="Server Icon"
|
||||||
:class="mod.disabled ? 'grayscale' : ''"
|
:class="mod.disabled ? 'opacity-75 grayscale' : ''"
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-col">
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
<span class="flex min-w-0 items-center gap-2 text-lg font-bold">
|
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
|
||||||
<span class="truncate">{{
|
<span class="truncate text-contrast">{{
|
||||||
mod.name || mod.filename.replace(".disabled", "")
|
mod.name || mod.filename.replace(".disabled", "")
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
@@ -146,132 +171,180 @@
|
|||||||
>Disabled</span
|
>Disabled</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span class="min-w-0 text-xs text-secondary">{{
|
<div class="min-w-0 text-xs text-secondary">
|
||||||
|
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
|
||||||
|
<span class="block font-semibold sm:hidden">
|
||||||
|
{{ mod.version_number || "External mod" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
|
||||||
|
<div class="truncate font-semibold text-contrast">
|
||||||
|
<span v-tooltip="'Mod version'">{{
|
||||||
mod.version_number || "External mod"
|
mod.version_number || "External mod"
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="truncate">
|
||||||
|
<span v-tooltip="'Mod file name'">{{ mod.filename }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
<div
|
||||||
<div class="flex items-center gap-2 pr-4 font-semibold text-contrast">
|
class="flex items-center justify-end gap-2 pr-4 font-semibold text-contrast sm:min-w-44"
|
||||||
<ButtonStyled v-if="mod.project_id" type="transparent">
|
>
|
||||||
<button
|
<ButtonStyled color="red" type="transparent">
|
||||||
v-tooltip="'Edit mod version'"
|
<button
|
||||||
:disabled="mod.changing"
|
v-tooltip="'Delete mod'"
|
||||||
class="!hidden sm:!block"
|
:disabled="mod.changing"
|
||||||
@click="beginChangeModVersion(mod)"
|
class="!hidden sm:!block"
|
||||||
>
|
@click="removeMod(mod)"
|
||||||
<template v-if="mod.changing">
|
|
||||||
<UiServersIconsLoadingIcon />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<EditIcon />
|
|
||||||
</template>
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button
|
|
||||||
v-tooltip="'Delete mod'"
|
|
||||||
:disabled="mod.changing"
|
|
||||||
class="!hidden sm:!block"
|
|
||||||
@click="removeMod(mod)"
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
|
|
||||||
<!-- Dropdown for mobile -->
|
|
||||||
<div class="mr-2 flex items-center sm:hidden">
|
|
||||||
<UiServersIconsLoadingIcon
|
|
||||||
v-if="mod.changing"
|
|
||||||
class="mr-2 h-5 w-5 animate-spin"
|
|
||||||
style="color: var(--color-base)"
|
|
||||||
/>
|
|
||||||
<ButtonStyled v-else circular type="transparent">
|
|
||||||
<UiServersTeleportOverflowMenu
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'edit',
|
|
||||||
action: () => beginChangeModVersion(mod),
|
|
||||||
shown: !!(mod.project_id && !mod.changing),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'delete',
|
|
||||||
action: () => removeMod(mod),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
<TrashIcon />
|
||||||
<template #edit>
|
</button>
|
||||||
<EditIcon class="h-5 w-5" />
|
</ButtonStyled>
|
||||||
<span>Edit</span>
|
<ButtonStyled type="transparent">
|
||||||
</template>
|
<button
|
||||||
<template #delete>
|
v-tooltip="
|
||||||
<TrashIcon class="h-5 w-5" />
|
mod.project_id ? 'Edit mod version' : 'External mods cannot be edited'
|
||||||
<span>Delete</span>
|
"
|
||||||
</template>
|
:disabled="mod.changing || !mod.project_id"
|
||||||
</UiServersTeleportOverflowMenu>
|
class="!hidden sm:!block"
|
||||||
|
@click="beginChangeModVersion(mod)"
|
||||||
|
>
|
||||||
|
<template v-if="mod.changing">
|
||||||
|
<UiServersIconsLoadingIcon />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<EditIcon />
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
<!-- Dropdown for mobile -->
|
||||||
:id="`toggle-${mod.filename}`"
|
<div class="mr-2 flex items-center sm:hidden">
|
||||||
:checked="!mod.disabled"
|
<UiServersIconsLoadingIcon
|
||||||
:disabled="mod.changing"
|
v-if="mod.changing"
|
||||||
class="switch stylized-toggle"
|
class="mr-2 h-5 w-5 animate-spin"
|
||||||
type="checkbox"
|
style="color: var(--color-base)"
|
||||||
@change="toggleMod(mod)"
|
/>
|
||||||
/>
|
<ButtonStyled v-else circular type="transparent">
|
||||||
|
<UiServersTeleportOverflowMenu
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
action: () => beginChangeModVersion(mod),
|
||||||
|
shown: !!(mod.project_id && !mod.changing),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
action: () => removeMod(mod),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<MoreVerticalIcon aria-hidden="true" />
|
||||||
|
<template #edit>
|
||||||
|
<EditIcon class="h-5 w-5" />
|
||||||
|
<span>Edit</span>
|
||||||
|
</template>
|
||||||
|
<template #delete>
|
||||||
|
<TrashIcon class="h-5 w-5" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</template>
|
||||||
|
</UiServersTeleportOverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
:id="`toggle-${mod.filename}`"
|
||||||
|
:checked="!mod.disabled"
|
||||||
|
:disabled="mod.changing"
|
||||||
|
class="switch stylized-toggle"
|
||||||
|
type="checkbox"
|
||||||
|
@change="toggleMod(mod)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- no mods has platform -->
|
||||||
<!-- no mods has platform -->
|
<div
|
||||||
<div
|
v-else-if="
|
||||||
v-else-if="
|
props.server.general?.loader &&
|
||||||
!hasMods &&
|
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
|
||||||
props.server.general?.loader &&
|
"
|
||||||
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
|
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||||
"
|
>
|
||||||
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
<div
|
||||||
>
|
v-if="!hasFilteredMods && hasMods"
|
||||||
<PackageClosedIcon class="size-24" />
|
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||||
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
>
|
||||||
<p class="m-0">
|
<SearchIcon class="size-24" />
|
||||||
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
<p class="m-0 font-bold text-contrast">
|
||||||
</p>
|
No {{ type.toLocaleLowerCase() }}s found for your query!
|
||||||
<ButtonStyled color="brand">
|
</p>
|
||||||
<NuxtLink :to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`">
|
<p class="m-0">Try another query, or show everything.</p>
|
||||||
<PlusIcon />
|
<ButtonStyled>
|
||||||
Add {{ type.toLocaleLowerCase() }}
|
<button @click="showAll">
|
||||||
</NuxtLink>
|
<ListIcon />
|
||||||
</ButtonStyled>
|
Show everything
|
||||||
</div>
|
</button>
|
||||||
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
|
</ButtonStyled>
|
||||||
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
|
</div>
|
||||||
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
|
<div
|
||||||
<p class="m-0">
|
v-else
|
||||||
Add content to your server by installing a modpack or choosing a different platform that
|
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
|
||||||
supports {{ type }}s.
|
>
|
||||||
</p>
|
<PackageClosedIcon class="size-24" />
|
||||||
<div class="flex flex-row items-center gap-4">
|
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
|
||||||
<ButtonStyled class="mt-8">
|
<p class="m-0">
|
||||||
<NuxtLink :to="`/modpacks?sid=${props.server.serverId}`">
|
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
|
||||||
<CompassIcon />
|
</p>
|
||||||
Find a modpack
|
<div class="flex flex-row items-center gap-4">
|
||||||
</NuxtLink>
|
<ButtonStyled type="outlined">
|
||||||
</ButtonStyled>
|
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
|
||||||
<div>or</div>
|
<FileIcon />
|
||||||
<ButtonStyled class="mt-8">
|
Add file
|
||||||
<NuxtLink :to="`/servers/manage/${props.server.serverId}/options/loader`">
|
</button>
|
||||||
<WrenchIcon />
|
</ButtonStyled>
|
||||||
Change platform
|
<ButtonStyled color="brand">
|
||||||
</NuxtLink>
|
<nuxt-link
|
||||||
</ButtonStyled>
|
class="w-full text-nowrap sm:w-fit"
|
||||||
|
:to="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Add {{ type.toLocaleLowerCase() }}
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
|
||||||
|
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
|
||||||
|
<p class="m-0">
|
||||||
|
Add content to your server by installing a modpack or choosing a different platform that
|
||||||
|
supports {{ type }}s.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-row items-center gap-4">
|
||||||
|
<ButtonStyled class="mt-8">
|
||||||
|
<NuxtLink :to="`/modpacks?sid=${props.server.serverId}`">
|
||||||
|
<CompassIcon />
|
||||||
|
Find a modpack
|
||||||
|
</NuxtLink>
|
||||||
|
</ButtonStyled>
|
||||||
|
<div>or</div>
|
||||||
|
<ButtonStyled class="mt-8">
|
||||||
|
<NuxtLink :to="`/servers/manage/${props.server.serverId}/options/loader`">
|
||||||
|
<WrenchIcon />
|
||||||
|
Change platform
|
||||||
|
</NuxtLink>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FilesUploadDragAndDrop>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -290,10 +363,15 @@ import {
|
|||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
|
ListIcon,
|
||||||
|
FileIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||||
|
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
|
||||||
|
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
|
||||||
import type { Server } from "~/composables/pyroServers";
|
import type { Server } from "~/composables/pyroServers";
|
||||||
|
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
@@ -304,14 +382,7 @@ const type = computed(() => {
|
|||||||
return loader === "paper" || loader === "purpur" ? "Plugin" : "Mod";
|
return loader === "paper" || loader === "purpur" ? "Plugin" : "Mod";
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Mod {
|
interface ContentItem extends Mod {
|
||||||
name?: string;
|
|
||||||
filename: string;
|
|
||||||
project_id?: string;
|
|
||||||
version_id?: string;
|
|
||||||
version_number?: string;
|
|
||||||
icon_url?: string;
|
|
||||||
disabled: boolean;
|
|
||||||
changing?: boolean;
|
changing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,12 +393,41 @@ const listContainer = ref<HTMLElement | null>(null);
|
|||||||
const windowScrollY = ref(0);
|
const windowScrollY = ref(0);
|
||||||
const windowHeight = ref(0);
|
const windowHeight = ref(0);
|
||||||
|
|
||||||
const localMods = ref<Mod[]>([]);
|
const localMods = ref<ContentItem[]>([]);
|
||||||
|
|
||||||
const searchInput = ref("");
|
const searchInput = ref("");
|
||||||
const modSearchInput = ref("");
|
const modSearchInput = ref("");
|
||||||
const filterMethod = ref("all");
|
const filterMethod = ref("all");
|
||||||
|
|
||||||
|
const uploadDropdownRef = ref();
|
||||||
|
|
||||||
|
const handleDroppedFiles = (files: File[]) => {
|
||||||
|
files.forEach((file) => {
|
||||||
|
uploadDropdownRef.value?.uploadFile(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initiateFileUpload = () => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = acceptFileFromProjectType(type.value.toLowerCase());
|
||||||
|
input.multiple = true;
|
||||||
|
input.onchange = () => {
|
||||||
|
if (input.files) {
|
||||||
|
Array.from(input.files).forEach((file) => {
|
||||||
|
uploadDropdownRef.value?.uploadFile(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showAll = () => {
|
||||||
|
searchInput.value = "";
|
||||||
|
modSearchInput.value = "";
|
||||||
|
filterMethod.value = "all";
|
||||||
|
};
|
||||||
|
|
||||||
const filterMethodLabel = computed(() => {
|
const filterMethodLabel = computed(() => {
|
||||||
switch (filterMethod.value) {
|
switch (filterMethod.value) {
|
||||||
case "disabled":
|
case "disabled":
|
||||||
@@ -419,14 +519,17 @@ const debouncedSearch = debounce(() => {
|
|||||||
modSearchInput.value = searchInput.value;
|
modSearchInput.value = searchInput.value;
|
||||||
|
|
||||||
if (pyroContentSentinel.value) {
|
if (pyroContentSentinel.value) {
|
||||||
pyroContentSentinel.value.scrollIntoView({
|
const sentinelRect = pyroContentSentinel.value.getBoundingClientRect();
|
||||||
behavior: "smooth",
|
if (sentinelRect.top < 0 || sentinelRect.bottom > window.innerHeight) {
|
||||||
block: "start",
|
pyroContentSentinel.value.scrollIntoView({
|
||||||
});
|
// behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
async function toggleMod(mod: Mod) {
|
async function toggleMod(mod: ContentItem) {
|
||||||
mod.changing = true;
|
mod.changing = true;
|
||||||
|
|
||||||
const originalFilename = mod.filename;
|
const originalFilename = mod.filename;
|
||||||
@@ -458,7 +561,7 @@ async function toggleMod(mod: Mod) {
|
|||||||
mod.changing = false;
|
mod.changing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMod(mod: Mod) {
|
async function removeMod(mod: ContentItem) {
|
||||||
mod.changing = true;
|
mod.changing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -515,6 +618,10 @@ async function changeModVersion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasMods = computed(() => {
|
const hasMods = computed(() => {
|
||||||
|
return localMods.value?.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasFilteredMods = computed(() => {
|
||||||
return filteredMods.value?.length > 0;
|
return filteredMods.value?.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,9 @@
|
|||||||
@delete="handleDeleteItem"
|
@delete="handleDeleteItem"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<FilesUploadDragAndDrop
|
||||||
class="relative flex w-full flex-col rounded-2xl border border-solid border-bg-raised"
|
class="relative flex w-full flex-col rounded-2xl border border-solid border-bg-raised"
|
||||||
@dragenter.prevent="handleDragEnter"
|
@files-dropped="handleDroppedFiles"
|
||||||
@dragover.prevent="handleDragOver"
|
|
||||||
@dragleave.prevent="handleDragLeave"
|
|
||||||
@drop.prevent="handleDrop"
|
|
||||||
>
|
>
|
||||||
<div ref="mainContent" class="relative isolate flex w-full flex-col">
|
<div ref="mainContent" class="relative isolate flex w-full flex-col">
|
||||||
<div v-if="!isEditing" class="contents">
|
<div v-if="!isEditing" class="contents">
|
||||||
@@ -44,94 +41,14 @@
|
|||||||
@upload="initiateFileUpload"
|
@upload="initiateFileUpload"
|
||||||
@update:search-query="searchQuery = $event"
|
@update:search-query="searchQuery = $event"
|
||||||
/>
|
/>
|
||||||
<Transition
|
<FilesUploadDropdown
|
||||||
name="upload-status"
|
v-if="props.server.fs"
|
||||||
@enter="onUploadStatusEnter"
|
ref="uploadDropdownRef"
|
||||||
@leave="onUploadStatusLeave"
|
class="rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow"
|
||||||
>
|
:current-path="currentPath"
|
||||||
<div
|
:fs="props.server.fs"
|
||||||
v-if="isUploading"
|
@upload-complete="refreshList()"
|
||||||
ref="uploadStatusRef"
|
/>
|
||||||
class="upload-status rounded-b-xl border-0 border-t border-solid border-bg bg-table-alternateRow text-contrast"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col p-4 text-sm">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2 font-bold">
|
|
||||||
<FolderOpenIcon class="size-4" />
|
|
||||||
<span>
|
|
||||||
File Uploads{{
|
|
||||||
activeUploads.length > 0 ? ` - ${activeUploads.length} left` : ""
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2 space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="item in uploadQueue"
|
|
||||||
:key="item.file.name"
|
|
||||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
|
||||||
>
|
|
||||||
<div class="flex flex-1 items-center gap-2 truncate">
|
|
||||||
<transition-group name="status-icon" mode="out-in">
|
|
||||||
<UiServersPanelSpinner
|
|
||||||
v-show="item.status === 'uploading'"
|
|
||||||
key="spinner"
|
|
||||||
class="absolute !size-4"
|
|
||||||
/>
|
|
||||||
<CheckCircleIcon
|
|
||||||
v-show="item.status === 'completed'"
|
|
||||||
key="check"
|
|
||||||
class="absolute size-4 text-green"
|
|
||||||
/>
|
|
||||||
<XCircleIcon
|
|
||||||
v-show="item.status === 'error' || item.status === 'cancelled'"
|
|
||||||
key="error"
|
|
||||||
class="absolute size-4 text-red"
|
|
||||||
/>
|
|
||||||
</transition-group>
|
|
||||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
|
||||||
<span class="text-secondary">{{ item.size }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
|
||||||
<template v-if="item.status === 'completed'">
|
|
||||||
<span>Done</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.status === 'error'">
|
|
||||||
<span class="text-red">Failed - File already exists</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<template v-if="item.status === 'uploading'">
|
|
||||||
<span>{{ item.progress }}%</span>
|
|
||||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
|
||||||
<div
|
|
||||||
class="h-full bg-contrast transition-all duration-200"
|
|
||||||
:style="{ width: item.progress + '%' }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
|
||||||
<button>Cancel</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.status === 'cancelled'">
|
|
||||||
<span class="text-red">Cancelled</span>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span>{{ item.progress }}%</span>
|
|
||||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
|
||||||
<div
|
|
||||||
class="h-full bg-contrast transition-all duration-200"
|
|
||||||
:style="{ width: item.progress + '%' }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UiServersFilesEditingNavbar
|
<UiServersFilesEditingNavbar
|
||||||
@@ -220,7 +137,7 @@
|
|||||||
<p class="mt-2 text-xl">Drop files here to upload</p>
|
<p class="mt-2 text-xl">Drop files here to upload</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FilesUploadDragAndDrop>
|
||||||
|
|
||||||
<UiServersFilesContextMenu
|
<UiServersFilesContextMenu
|
||||||
ref="contextMenu"
|
ref="contextMenu"
|
||||||
@@ -238,9 +155,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useInfiniteScroll } from "@vueuse/core";
|
import { useInfiniteScroll } from "@vueuse/core";
|
||||||
import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
|
||||||
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
|
import type { DirectoryResponse, DirectoryItem, Server } from "~/composables/pyroServers";
|
||||||
|
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
|
||||||
|
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
|
||||||
|
|
||||||
interface BaseOperation {
|
interface BaseOperation {
|
||||||
type: "move" | "rename";
|
type: "move" | "rename";
|
||||||
@@ -263,14 +181,6 @@ interface RenameOperation extends BaseOperation {
|
|||||||
|
|
||||||
type Operation = MoveOperation | RenameOperation;
|
type Operation = MoveOperation | RenameOperation;
|
||||||
|
|
||||||
interface UploadItem {
|
|
||||||
file: File;
|
|
||||||
progress: number;
|
|
||||||
status: "pending" | "uploading" | "completed" | "error" | "cancelled";
|
|
||||||
size: string;
|
|
||||||
uploader?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
}>();
|
}>();
|
||||||
@@ -312,46 +222,8 @@ const isEditingImage = ref(false);
|
|||||||
const imagePreview = ref();
|
const imagePreview = ref();
|
||||||
|
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
const dragCounter = ref(0);
|
|
||||||
|
|
||||||
const uploadStatusRef = ref<HTMLElement | null>(null);
|
const uploadDropdownRef = ref();
|
||||||
const isUploading = computed(() => uploadQueue.value.length > 0);
|
|
||||||
const uploadQueue = ref<UploadItem[]>([]);
|
|
||||||
|
|
||||||
const activeUploads = computed(() =>
|
|
||||||
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const onUploadStatusEnter = (el: Element) => {
|
|
||||||
const height = (el as HTMLElement).scrollHeight;
|
|
||||||
(el as HTMLElement).style.height = "0";
|
|
||||||
// eslint-disable-next-line no-void
|
|
||||||
void (el as HTMLElement).offsetHeight;
|
|
||||||
(el as HTMLElement).style.height = `${height}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUploadStatusLeave = (el: Element) => {
|
|
||||||
const height = (el as HTMLElement).scrollHeight;
|
|
||||||
(el as HTMLElement).style.height = `${height}px`;
|
|
||||||
// eslint-disable-next-line no-void
|
|
||||||
void (el as HTMLElement).offsetHeight;
|
|
||||||
(el as HTMLElement).style.height = "0";
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
uploadQueue,
|
|
||||||
() => {
|
|
||||||
if (!uploadStatusRef.value) return;
|
|
||||||
const el = uploadStatusRef.value;
|
|
||||||
const itemsHeight = uploadQueue.value.length * 32;
|
|
||||||
const headerHeight = 12;
|
|
||||||
const gap = 8;
|
|
||||||
const padding = 32;
|
|
||||||
const totalHeight = padding + headerHeight + gap + itemsHeight;
|
|
||||||
el.style.height = `${totalHeight}px`;
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = computed(() => props.server.general);
|
const data = computed(() => props.server.general);
|
||||||
|
|
||||||
@@ -917,135 +789,12 @@ const requestShareLink = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnter = (event: DragEvent) => {
|
const handleDroppedFiles = (files: File[]) => {
|
||||||
if (isEditing.value) return;
|
if (isEditing.value) return;
|
||||||
event.preventDefault();
|
|
||||||
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
|
||||||
dragCounter.value++;
|
|
||||||
isDragging.value = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
files.forEach((file) => {
|
||||||
if (isEditing.value) return;
|
uploadDropdownRef.value?.uploadFile(file);
|
||||||
event.preventDefault();
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (event: DragEvent) => {
|
|
||||||
if (isEditing.value) return;
|
|
||||||
event.preventDefault();
|
|
||||||
dragCounter.value--;
|
|
||||||
if (dragCounter.value === 0) {
|
|
||||||
isDragging.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line require-await
|
|
||||||
const handleDrop = async (event: DragEvent) => {
|
|
||||||
if (isEditing.value) return;
|
|
||||||
event.preventDefault();
|
|
||||||
isDragging.value = false;
|
|
||||||
dragCounter.value = 0;
|
|
||||||
|
|
||||||
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
|
|
||||||
if (isInternalMove) return;
|
|
||||||
|
|
||||||
const files = event.dataTransfer?.files;
|
|
||||||
if (files) {
|
|
||||||
Array.from(files).forEach((file) => {
|
|
||||||
uploadFile(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes < 1024) return bytes + " B";
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelUpload = (item: UploadItem) => {
|
|
||||||
if (item.uploader && item.status === "uploading") {
|
|
||||||
item.uploader.cancel();
|
|
||||||
item.status = "cancelled";
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
|
|
||||||
if (index !== -1) {
|
|
||||||
uploadQueue.value.splice(index, 1);
|
|
||||||
await nextTick();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFile = async (file: File) => {
|
|
||||||
const uploadItem: UploadItem = {
|
|
||||||
file,
|
|
||||||
progress: 0,
|
|
||||||
status: "pending",
|
|
||||||
size: formatFileSize(file.size),
|
|
||||||
};
|
|
||||||
|
|
||||||
uploadQueue.value.push(uploadItem);
|
|
||||||
|
|
||||||
try {
|
|
||||||
uploadItem.status = "uploading";
|
|
||||||
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
|
|
||||||
const uploader = await props.server.fs?.uploadFile(filePath, file);
|
|
||||||
uploadItem.uploader = uploader;
|
|
||||||
|
|
||||||
if (uploader?.onProgress) {
|
|
||||||
uploader.onProgress(({ progress }: { progress: number }) => {
|
|
||||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
|
||||||
if (index !== -1) {
|
|
||||||
uploadQueue.value[index].progress = Math.round(progress);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await uploader?.promise;
|
|
||||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
|
||||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
|
||||||
uploadQueue.value[index].status = "completed";
|
|
||||||
uploadQueue.value[index].progress = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
|
||||||
if (removeIndex !== -1) {
|
|
||||||
uploadQueue.value.splice(removeIndex, 1);
|
|
||||||
await nextTick();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
await refreshList();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error uploading file:", error);
|
|
||||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
|
||||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
|
||||||
uploadQueue.value[index].status = "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
|
||||||
if (removeIndex !== -1) {
|
|
||||||
uploadQueue.value.splice(removeIndex, 1);
|
|
||||||
await nextTick();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message !== "Upload cancelled") {
|
|
||||||
addNotification({
|
|
||||||
group: "files",
|
|
||||||
title: "Upload failed",
|
|
||||||
text: `Failed to upload ${file.name}`,
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initiateFileUpload = () => {
|
const initiateFileUpload = () => {
|
||||||
@@ -1055,7 +804,7 @@ const initiateFileUpload = () => {
|
|||||||
input.onchange = () => {
|
input.onchange = () => {
|
||||||
if (input.files) {
|
if (input.files) {
|
||||||
Array.from(input.files).forEach((file) => {
|
Array.from(input.files).forEach((file) => {
|
||||||
uploadFile(file);
|
uploadDropdownRef.value?.uploadFile(file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -237,24 +237,11 @@ interface ErrorData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inspectingError = ref<ErrorData | null>(null);
|
const inspectingError = ref<ErrorData | null>(null);
|
||||||
const mcError = ref<any>(null);
|
|
||||||
|
|
||||||
const inspectError = async () => {
|
const inspectError = async () => {
|
||||||
const log = await props.server.fs?.downloadFile("logs/latest.log");
|
const log = await props.server.fs?.downloadFile("logs/latest.log");
|
||||||
const response = (await $fetch("https://api.mclo.gs/1/log", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
content: log,
|
|
||||||
}),
|
|
||||||
})) as any;
|
|
||||||
|
|
||||||
mcError.value = response;
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const analysis = (await $fetch(`https://api.mclo.gs/1/insights/${response.id}`, {
|
const analysis = (await $fetch(`https://api.mclo.gs/1/analyse`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
@@ -269,7 +256,6 @@ const inspectError = async () => {
|
|||||||
|
|
||||||
const clearError = () => {
|
const clearError = () => {
|
||||||
inspectingError.value = null;
|
inspectingError.value = null;
|
||||||
mcError.value = null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -330,11 +330,11 @@
|
|||||||
<UploadIcon class="size-4" /> Upload .mrpack file
|
<UploadIcon class="size-4" /> Upload .mrpack file
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<DownloadIcon v-if="hasNewerVersion" color="brand">
|
<ButtonStyled v-if="hasNewerVersion" color="brand">
|
||||||
<button class="!w-full sm:!w-auto" @click="handleUpdateToLatest">
|
<button class="!w-full sm:!w-auto" @click="handleUpdateToLatest">
|
||||||
<UploadIcon class="size-4" /> Update modpack
|
<UploadIcon class="size-4" /> Update modpack
|
||||||
</button>
|
</button>
|
||||||
</DownloadIcon>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="data.upstream" class="contents">
|
<div v-if="data.upstream" class="contents">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
⋅
|
⋅
|
||||||
<span>{{ formatPrice(charge.amount, charge.currency_code) }}</span>
|
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
|
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, Badge } from "@modrinth/ui";
|
import { Breadcrumbs, Badge } from "@modrinth/ui";
|
||||||
|
import { formatPrice } from "@modrinth/utils";
|
||||||
import { products } from "~/generated/state.json";
|
import { products } from "~/generated/state.json";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
@@ -66,19 +67,4 @@ const { data: charges } = await useAsyncData(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO move to omorphia utils , duplicated from index
|
|
||||||
function formatPrice(price, currency) {
|
|
||||||
const formatter = new Intl.NumberFormat(vintl.locale, {
|
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxDigits = formatter.resolvedOptions().maximumFractionDigits;
|
|
||||||
|
|
||||||
const convertedPrice = price / Math.pow(10, maxDigits);
|
|
||||||
|
|
||||||
return formatter.format(convertedPrice);
|
|
||||||
}
|
|
||||||
console.log(charges);
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -257,7 +257,7 @@
|
|||||||
v-else-if="getPyroCharge(subscription).status === 'processing'"
|
v-else-if="getPyroCharge(subscription).status === 'processing'"
|
||||||
class="text-sm text-orange"
|
class="text-sm text-orange"
|
||||||
>
|
>
|
||||||
Your payment is being processed. Perks will activate once payment is
|
Your payment is being processed. Your server will activate once payment is
|
||||||
complete.
|
complete.
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -270,7 +270,8 @@
|
|||||||
v-else-if="getPyroCharge(subscription).status === 'failed'"
|
v-else-if="getPyroCharge(subscription).status === 'failed'"
|
||||||
class="text-sm text-red"
|
class="text-sm text-red"
|
||||||
>
|
>
|
||||||
Your subscription payment failed. Please update your payment method.
|
Your subscription payment failed. Please update your payment method, then
|
||||||
|
resubscribe.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +279,8 @@
|
|||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
v-if="
|
v-if="
|
||||||
getPyroCharge(subscription) &&
|
getPyroCharge(subscription) &&
|
||||||
getPyroCharge(subscription).status !== 'cancelled'
|
getPyroCharge(subscription).status !== 'cancelled' &&
|
||||||
|
getPyroCharge(subscription).status !== 'failed'
|
||||||
"
|
"
|
||||||
type="standard"
|
type="standard"
|
||||||
@click="showPyroCancelModal(subscription.id)"
|
@click="showPyroCancelModal(subscription.id)"
|
||||||
@@ -291,7 +293,8 @@
|
|||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
v-else-if="
|
v-else-if="
|
||||||
getPyroCharge(subscription) &&
|
getPyroCharge(subscription) &&
|
||||||
getPyroCharge(subscription).status === 'cancelled'
|
(getPyroCharge(subscription).status === 'cancelled' ||
|
||||||
|
getPyroCharge(subscription).status === 'failed')
|
||||||
"
|
"
|
||||||
type="standard"
|
type="standard"
|
||||||
color="green"
|
color="green"
|
||||||
|
|||||||
@@ -2,6 +2,57 @@
|
|||||||
<div v-if="user" class="experimental-styles-within">
|
<div v-if="user" class="experimental-styles-within">
|
||||||
<ModalCreation ref="modal_creation" />
|
<ModalCreation ref="modal_creation" />
|
||||||
<CollectionCreateModal ref="modal_collection_creation" />
|
<CollectionCreateModal ref="modal_collection_creation" />
|
||||||
|
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-bold text-primary">Email</span>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
v-tooltip="user.email_verified ? 'Email verified' : 'Email not verified'"
|
||||||
|
class="flex w-fit items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>{{ user.email }}</span>
|
||||||
|
<CheckIcon v-if="user.email_verified" class="h-4 w-4 text-brand" />
|
||||||
|
<XIcon v-else class="h-4 w-4 text-red" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-bold text-primary"> Auth providers </span>
|
||||||
|
<span>{{ user.auth_providers.join(", ") }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-bold text-primary"> Payment methods</span>
|
||||||
|
<span>
|
||||||
|
<template v-if="user.payout_data?.paypal_address">
|
||||||
|
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
|
||||||
|
</template>
|
||||||
|
<template v-if="user.payout_data?.paypal_address && user.payout_data?.venmo_address">
|
||||||
|
,
|
||||||
|
</template>
|
||||||
|
<template v-if="user.payout_data?.venmo_address">
|
||||||
|
Venmo ({{ user.payout_data.venmo_address }})
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-bold text-primary"> Has password </span>
|
||||||
|
<span>
|
||||||
|
{{ user.has_password ? "Yes" : "No" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-bold text-primary"> Has TOTP </span>
|
||||||
|
<span>
|
||||||
|
{{ user.has_totp ? "Yes" : "No" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
|
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
|
||||||
<div class="normal-page__header py-4">
|
<div class="normal-page__header py-4">
|
||||||
<ContentPageHeader>
|
<ContentPageHeader>
|
||||||
@@ -74,6 +125,16 @@
|
|||||||
shown: auth.user?.id !== user.id,
|
shown: auth.user?.id !== user.id,
|
||||||
},
|
},
|
||||||
{ id: 'copy-id', action: () => copyId() },
|
{ id: 'copy-id', action: () => copyId() },
|
||||||
|
{
|
||||||
|
id: 'open-billing',
|
||||||
|
action: () => navigateTo(`/admin/billing/${user.id}`),
|
||||||
|
shown: auth.user && isStaff(auth.user),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-info',
|
||||||
|
action: () => $refs.userDetailsModal.show(),
|
||||||
|
shown: auth.user && isStaff(auth.user),
|
||||||
|
},
|
||||||
]"
|
]"
|
||||||
aria-label="More options"
|
aria-label="More options"
|
||||||
>
|
>
|
||||||
@@ -90,6 +151,14 @@
|
|||||||
<ClipboardCopyIcon aria-hidden="true" />
|
<ClipboardCopyIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
{{ formatMessage(commonMessages.copyIdButton) }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #open-billing>
|
||||||
|
<CurrencyIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.billingButton) }}
|
||||||
|
</template>
|
||||||
|
<template #open-info>
|
||||||
|
<InfoIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.infoButton) }}
|
||||||
|
</template>
|
||||||
</OverflowMenu>
|
</OverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</template>
|
</template>
|
||||||
@@ -264,8 +333,18 @@ import {
|
|||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
ClipboardCopyIcon,
|
ClipboardCopyIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
|
CurrencyIcon,
|
||||||
|
InfoIcon,
|
||||||
|
CheckIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { OverflowMenu, ButtonStyled, ContentPageHeader, commonMessages } from "@modrinth/ui";
|
import {
|
||||||
|
OverflowMenu,
|
||||||
|
ButtonStyled,
|
||||||
|
ContentPageHeader,
|
||||||
|
commonMessages,
|
||||||
|
NewModal,
|
||||||
|
} from "@modrinth/ui";
|
||||||
|
import { isStaff } from "~/helpers/users.js";
|
||||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||||
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
||||||
import { reportUser } from "~/utils/report-helpers.ts";
|
import { reportUser } from "~/utils/report-helpers.ts";
|
||||||
@@ -367,6 +446,14 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
|
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
|
||||||
},
|
},
|
||||||
|
billingButton: {
|
||||||
|
id: "profile.button.billing",
|
||||||
|
defaultMessage: "Manage user billing",
|
||||||
|
},
|
||||||
|
infoButton: {
|
||||||
|
id: "profile.button.info",
|
||||||
|
defaultMessage: "View user details",
|
||||||
|
},
|
||||||
userNotFoundError: {
|
userNotFoundError: {
|
||||||
id: "profile.error.not-found",
|
id: "profile.error.not-found",
|
||||||
defaultMessage: "User not found",
|
defaultMessage: "User not found",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
||||||
|
|
||||||
|
dayjs.extend(quarterOfYear);
|
||||||
|
|
||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
export interface Mod {
|
// export interface Mod {
|
||||||
id: string;
|
// id: string;
|
||||||
filename: string;
|
// filename: string;
|
||||||
modrinth_ids: {
|
// modrinth_ids: {
|
||||||
project_id: string;
|
// project_id: string;
|
||||||
version_id: string;
|
// version_id: string;
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
|
|
||||||
interface License {
|
interface License {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -304,13 +304,10 @@ export const useFetchAllAnalytics = (
|
|||||||
projects,
|
projects,
|
||||||
selectedProjects,
|
selectedProjects,
|
||||||
personalRevenue = false,
|
personalRevenue = false,
|
||||||
|
startDate = ref(dayjs().subtract(30, "days")),
|
||||||
|
endDate = ref(dayjs()),
|
||||||
|
timeResolution = ref(1440),
|
||||||
) => {
|
) => {
|
||||||
const timeResolution = ref(1440); // 1 day
|
|
||||||
const timeRange = ref(43200); // 30 days
|
|
||||||
|
|
||||||
const startDate = ref(Date.now() - timeRange.value * 60 * 1000);
|
|
||||||
const endDate = ref(Date.now());
|
|
||||||
|
|
||||||
const downloadData = ref(null);
|
const downloadData = ref(null);
|
||||||
const viewData = ref(null);
|
const viewData = ref(null);
|
||||||
const revenueData = ref(null);
|
const revenueData = ref(null);
|
||||||
@@ -394,8 +391,8 @@ export const useFetchAllAnalytics = (
|
|||||||
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value],
|
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value],
|
||||||
async () => {
|
async () => {
|
||||||
const q = {
|
const q = {
|
||||||
start_date: dayjs(startDate.value).toISOString(),
|
start_date: startDate.value.toISOString(),
|
||||||
end_date: dayjs(endDate.value).toISOString(),
|
end_date: endDate.value.toISOString(),
|
||||||
resolution_minutes: timeResolution.value,
|
resolution_minutes: timeResolution.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -442,7 +439,6 @@ export const useFetchAllAnalytics = (
|
|||||||
return {
|
return {
|
||||||
// Configuration
|
// Configuration
|
||||||
timeResolution,
|
timeResolution,
|
||||||
timeRange,
|
|
||||||
|
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ PAYPAL_API_URL=https://api-m.sandbox.paypal.com/v1/
|
|||||||
PAYPAL_WEBHOOK_ID=none
|
PAYPAL_WEBHOOK_ID=none
|
||||||
PAYPAL_CLIENT_ID=none
|
PAYPAL_CLIENT_ID=none
|
||||||
PAYPAL_CLIENT_SECRET=none
|
PAYPAL_CLIENT_SECRET=none
|
||||||
|
PAYPAL_NVP_USERNAME=none
|
||||||
|
PAYPAL_NVP_PASSWORD=none
|
||||||
|
PAYPAL_NVP_SIGNATURE=none
|
||||||
|
|
||||||
STEAM_API_KEY=none
|
STEAM_API_KEY=none
|
||||||
|
|
||||||
@@ -106,4 +109,10 @@ STRIPE_WEBHOOK_SECRET=none
|
|||||||
|
|
||||||
ADITUDE_API_KEY=none
|
ADITUDE_API_KEY=none
|
||||||
|
|
||||||
PYRO_API_KEY=none
|
PYRO_API_KEY=none
|
||||||
|
|
||||||
|
BREX_API_URL=https://platform.brexapis.com/v2/
|
||||||
|
BREX_API_KEY=none
|
||||||
|
|
||||||
|
DELPHI_URL=none
|
||||||
|
DELPHI_SLACK_WEBHOOK=none
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b"
|
"hash": "109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
false,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
false,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634"
|
"hash": "6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
false,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc"
|
"hash": "7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc"
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1"
|
"hash": "8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1"
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
|
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b"
|
"hash": "bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async-trait = "0.1.70"
|
|||||||
dashmap = "5.4.0"
|
dashmap = "5.4.0"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
|
|
||||||
meilisearch-sdk = "0.24.3"
|
meilisearch-sdk = "0.27.1"
|
||||||
rust-s3 = "0.33.0"
|
rust-s3 = "0.33.0"
|
||||||
reqwest = { version = "0.11.18", features = ["json", "multipart"] }
|
reqwest = { version = "0.11.18", features = ["json", "multipart"] }
|
||||||
hyper = { version = "0.14", features = ["full"] }
|
hyper = { version = "0.14", features = ["full"] }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1.81.0 as build
|
FROM rust:1.84.0 as build
|
||||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||||
|
|
||||||
WORKDIR /usr/src/labrinth
|
WORKDIR /usr/src/labrinth
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
ALTER TABLE version_fields
|
||||||
|
DROP CONSTRAINT version_fields_enum_value_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE version_fields
|
||||||
|
ALTER COLUMN enum_value SET DEFAULT -1;
|
||||||
|
|
||||||
|
UPDATE version_fields SET enum_value = -1 WHERE enum_value IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE version_fields
|
||||||
|
ALTER COLUMN enum_value SET NOT NULL;
|
||||||
|
|
||||||
|
WITH CTE AS (
|
||||||
|
SELECT ctid,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY version_id, field_id, enum_value ORDER BY ctid) AS row_num
|
||||||
|
FROM version_fields
|
||||||
|
)
|
||||||
|
DELETE FROM version_fields
|
||||||
|
WHERE ctid IN (
|
||||||
|
SELECT ctid
|
||||||
|
FROM CTE
|
||||||
|
WHERE row_num > 1
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE version_fields
|
||||||
|
ADD PRIMARY KEY (version_id, field_id, enum_value);
|
||||||
|
|
||||||
|
ALTER TABLE loader_fields_loaders
|
||||||
|
ADD PRIMARY KEY (loader_id, loader_field_id);
|
||||||
@@ -757,7 +757,7 @@ impl VersionField {
|
|||||||
l.field_id.0,
|
l.field_id.0,
|
||||||
l.version_id.0,
|
l.version_id.0,
|
||||||
l.int_value,
|
l.int_value,
|
||||||
l.enum_value.as_ref().map(|e| e.0),
|
l.enum_value.as_ref().map(|e| e.0).unwrap_or(-1),
|
||||||
l.string_value.clone(),
|
l.string_value.clone(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -772,7 +772,7 @@ impl VersionField {
|
|||||||
&version_ids[..],
|
&version_ids[..],
|
||||||
&int_values[..] as &[Option<i32>],
|
&int_values[..] as &[Option<i32>],
|
||||||
&string_values[..] as &[Option<String>],
|
&string_values[..] as &[Option<String>],
|
||||||
&enum_values[..] as &[Option<i32>]
|
&enum_values[..] as &[i32]
|
||||||
)
|
)
|
||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -595,12 +595,12 @@ impl Project {
|
|||||||
version_id: VersionId(m.version_id),
|
version_id: VersionId(m.version_id),
|
||||||
field_id: LoaderFieldId(m.field_id),
|
field_id: LoaderFieldId(m.field_id),
|
||||||
int_value: m.int_value,
|
int_value: m.int_value,
|
||||||
enum_value: m.enum_value.map(LoaderFieldEnumValueId),
|
enum_value: if m.enum_value == -1 { None } else { Some(LoaderFieldEnumValueId(m.enum_value)) },
|
||||||
string_value: m.string_value,
|
string_value: m.string_value,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(enum_value) = m.enum_value {
|
if m.enum_value != -1 {
|
||||||
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(enum_value));
|
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(m.enum_value));
|
||||||
}
|
}
|
||||||
|
|
||||||
acc.entry(ProjectId(m.mod_id)).or_default().push(qvf);
|
acc.entry(ProjectId(m.mod_id)).or_default().push(qvf);
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ impl TeamMember {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete<'a, 'b>(
|
pub async fn delete(
|
||||||
id: TeamId,
|
id: TeamId,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
|||||||
@@ -499,12 +499,12 @@ impl Version {
|
|||||||
version_id: VersionId(m.version_id),
|
version_id: VersionId(m.version_id),
|
||||||
field_id: LoaderFieldId(m.field_id),
|
field_id: LoaderFieldId(m.field_id),
|
||||||
int_value: m.int_value,
|
int_value: m.int_value,
|
||||||
enum_value: m.enum_value.map(LoaderFieldEnumValueId),
|
enum_value: if m.enum_value == -1 { None } else { Some(LoaderFieldEnumValueId(m.enum_value)) },
|
||||||
string_value: m.string_value,
|
string_value: m.string_value,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(enum_value) = m.enum_value {
|
if m.enum_value != -1 {
|
||||||
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(enum_value));
|
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(m.enum_value));
|
||||||
}
|
}
|
||||||
|
|
||||||
acc.entry(VersionId(m.version_id)).or_default().push(qvf);
|
acc.entry(VersionId(m.version_id)).or_default().push(qvf);
|
||||||
|
|||||||
@@ -295,7 +295,12 @@ impl RedisPool {
|
|||||||
|
|
||||||
fetch_ids.iter().for_each(|key| {
|
fetch_ids.iter().for_each(|key| {
|
||||||
pipe.atomic().set_options(
|
pipe.atomic().set_options(
|
||||||
format!("{}_{namespace}:{}/lock", self.meta_namespace, key),
|
// We store locks in lowercase because they are case insensitive
|
||||||
|
format!(
|
||||||
|
"{}_{namespace}:{}/lock",
|
||||||
|
self.meta_namespace,
|
||||||
|
key.to_lowercase()
|
||||||
|
),
|
||||||
100,
|
100,
|
||||||
SetOptions::default()
|
SetOptions::default()
|
||||||
.get(true)
|
.get(true)
|
||||||
@@ -395,7 +400,9 @@ impl RedisPool {
|
|||||||
|
|
||||||
pipe.atomic().del(format!(
|
pipe.atomic().del(format!(
|
||||||
"{}_{namespace}:{}/lock",
|
"{}_{namespace}:{}/lock",
|
||||||
self.meta_namespace, actual_slug
|
// Locks are stored in lowercase
|
||||||
|
self.meta_namespace,
|
||||||
|
actual_slug.to_lowercase()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,8 +415,10 @@ impl RedisPool {
|
|||||||
ids.remove(&base62);
|
ids.remove(&base62);
|
||||||
|
|
||||||
pipe.atomic().del(format!(
|
pipe.atomic().del(format!(
|
||||||
"{}_{namespace}:{base62}/lock",
|
"{}_{namespace}:{}/lock",
|
||||||
self.meta_namespace
|
self.meta_namespace,
|
||||||
|
// Locks are stored in lowercase
|
||||||
|
base62.to_lowercase()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +432,11 @@ impl RedisPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (key, _) in ids {
|
for (key, _) in ids {
|
||||||
|
pipe.atomic().del(format!(
|
||||||
|
"{}_{namespace}:{}/lock",
|
||||||
|
self.meta_namespace,
|
||||||
|
key.to_lowercase()
|
||||||
|
));
|
||||||
pipe.atomic().del(format!(
|
pipe.atomic().del(format!(
|
||||||
"{}_{namespace}:{key}/lock",
|
"{}_{namespace}:{key}/lock",
|
||||||
self.meta_namespace
|
self.meta_namespace
|
||||||
@@ -451,7 +465,8 @@ impl RedisPool {
|
|||||||
format!(
|
format!(
|
||||||
"{}_{namespace}:{}/lock",
|
"{}_{namespace}:{}/lock",
|
||||||
self.meta_namespace,
|
self.meta_namespace,
|
||||||
x.key()
|
// We lowercase key because locks are stored in lowercase
|
||||||
|
x.key().to_lowercase()
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ impl FileHost for S3Host {
|
|||||||
content_type,
|
content_type,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|err| {
|
||||||
FileHostingError::S3Error(
|
FileHostingError::S3Error(format!(
|
||||||
"Error while uploading file to S3".to_string(),
|
"Error while uploading file {file_name} to S3: {err}"
|
||||||
)
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(UploadFileData {
|
Ok(UploadFileData {
|
||||||
@@ -100,10 +100,10 @@ impl FileHost for S3Host {
|
|||||||
self.bucket
|
self.bucket
|
||||||
.delete_object(format!("/{file_name}"))
|
.delete_object(format!("/{file_name}"))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|err| {
|
||||||
FileHostingError::S3Error(
|
FileHostingError::S3Error(format!(
|
||||||
"Error while deleting file from S3".to_string(),
|
"Error while deleting file {file_name} to S3: {err}"
|
||||||
)
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(DeleteFileData {
|
Ok(DeleteFileData {
|
||||||
|
|||||||
@@ -448,6 +448,9 @@ pub fn check_env_vars() -> bool {
|
|||||||
failed |= check_var::<String>("PAYPAL_WEBHOOK_ID");
|
failed |= check_var::<String>("PAYPAL_WEBHOOK_ID");
|
||||||
failed |= check_var::<String>("PAYPAL_CLIENT_ID");
|
failed |= check_var::<String>("PAYPAL_CLIENT_ID");
|
||||||
failed |= check_var::<String>("PAYPAL_CLIENT_SECRET");
|
failed |= check_var::<String>("PAYPAL_CLIENT_SECRET");
|
||||||
|
failed |= check_var::<String>("PAYPAL_NVP_USERNAME");
|
||||||
|
failed |= check_var::<String>("PAYPAL_NVP_PASSWORD");
|
||||||
|
failed |= check_var::<String>("PAYPAL_NVP_SIGNATURE");
|
||||||
|
|
||||||
failed |= check_var::<String>("HCAPTCHA_SECRET");
|
failed |= check_var::<String>("HCAPTCHA_SECRET");
|
||||||
|
|
||||||
@@ -482,9 +485,14 @@ pub fn check_env_vars() -> bool {
|
|||||||
failed |= check_var::<String>("STRIPE_API_KEY");
|
failed |= check_var::<String>("STRIPE_API_KEY");
|
||||||
failed |= check_var::<String>("STRIPE_WEBHOOK_SECRET");
|
failed |= check_var::<String>("STRIPE_WEBHOOK_SECRET");
|
||||||
|
|
||||||
failed |= check_var::<u64>("ADITUDE_API_KEY");
|
failed |= check_var::<String>("ADITUDE_API_KEY");
|
||||||
|
|
||||||
failed |= check_var::<String>("PYRO_API_KEY");
|
failed |= check_var::<String>("PYRO_API_KEY");
|
||||||
|
|
||||||
|
failed |= check_var::<String>("BREX_API_URL");
|
||||||
|
failed |= check_var::<String>("BREX_API_KEY");
|
||||||
|
|
||||||
|
failed |= check_var::<String>("DELPHI_URL");
|
||||||
|
|
||||||
failed
|
failed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
|
|
||||||
let prometheus = PrometheusMetricsBuilder::new("labrinth")
|
let prometheus = PrometheusMetricsBuilder::new("labrinth")
|
||||||
.endpoint("/metrics")
|
.endpoint("/metrics")
|
||||||
|
.exclude("/_internal/launcher_socket")
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create prometheus metrics middleware");
|
.expect("Failed to create prometheus metrics middleware");
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ pub struct Charge {
|
|||||||
pub id: ChargeId,
|
pub id: ChargeId,
|
||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
pub price_id: ProductPriceId,
|
pub price_id: ProductPriceId,
|
||||||
pub amount: u64,
|
pub amount: i64,
|
||||||
pub currency_code: String,
|
pub currency_code: String,
|
||||||
pub status: ChargeStatus,
|
pub status: ChargeStatus,
|
||||||
pub due: DateTime<Utc>,
|
pub due: DateTime<Utc>,
|
||||||
@@ -171,6 +171,9 @@ pub struct Charge {
|
|||||||
pub subscription_id: Option<UserSubscriptionId>,
|
pub subscription_id: Option<UserSubscriptionId>,
|
||||||
pub subscription_interval: Option<PriceDuration>,
|
pub subscription_interval: Option<PriceDuration>,
|
||||||
pub platform: PaymentPlatform,
|
pub platform: PaymentPlatform,
|
||||||
|
|
||||||
|
pub parent_charge_id: Option<ChargeId>,
|
||||||
|
pub net: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use std::io::{Cursor, Read};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use zip::ZipArchive;
|
use zip::ZipArchive;
|
||||||
|
|
||||||
const AUTOMOD_ID: i64 = 0;
|
pub const AUTOMOD_ID: i64 = 0;
|
||||||
|
|
||||||
pub struct ModerationMessages {
|
pub struct ModerationMessages {
|
||||||
pub messages: Vec<ModerationMessage>,
|
pub messages: Vec<ModerationMessage>,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub struct PayoutsQueue {
|
|||||||
payout_options: RwLock<Option<PayoutMethods>>,
|
payout_options: RwLock<Option<PayoutMethods>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Debug)]
|
||||||
struct PayPalCredentials {
|
struct PayPalCredentials {
|
||||||
access_token: String,
|
access_token: String,
|
||||||
token_type: String,
|
token_type: String,
|
||||||
@@ -36,6 +36,12 @@ struct PayoutMethods {
|
|||||||
expires: DateTime<Utc>,
|
expires: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AccountBalance {
|
||||||
|
pub available: Decimal,
|
||||||
|
pub pending: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for PayoutsQueue {
|
impl Default for PayoutsQueue {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
@@ -545,6 +551,136 @@ impl PayoutsQueue {
|
|||||||
|
|
||||||
Ok(options.options)
|
Ok(options.options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_brex_balance() -> Result<Option<AccountBalance>, ApiError>
|
||||||
|
{
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BrexBalance {
|
||||||
|
pub amount: i64,
|
||||||
|
// pub currency: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BrexAccount {
|
||||||
|
pub current_balance: BrexBalance,
|
||||||
|
pub available_balance: BrexBalance,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct BrexResponse {
|
||||||
|
pub items: Vec<BrexAccount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let res = client
|
||||||
|
.get(format!("{}accounts/cash", dotenvy::var("BREX_API_URL")?))
|
||||||
|
.bearer_auth(&dotenvy::var("BREX_API_KEY")?)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<BrexResponse>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Some(AccountBalance {
|
||||||
|
available: Decimal::from(
|
||||||
|
res.items
|
||||||
|
.iter()
|
||||||
|
.map(|x| x.available_balance.amount)
|
||||||
|
.sum::<i64>(),
|
||||||
|
) / Decimal::from(100),
|
||||||
|
pending: Decimal::from(
|
||||||
|
res.items
|
||||||
|
.iter()
|
||||||
|
.map(|x| {
|
||||||
|
x.current_balance.amount - x.available_balance.amount
|
||||||
|
})
|
||||||
|
.sum::<i64>(),
|
||||||
|
) / Decimal::from(100),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_paypal_balance() -> Result<Option<AccountBalance>, ApiError>
|
||||||
|
{
|
||||||
|
let api_username = dotenvy::var("PAYPAL_NVP_USERNAME")?;
|
||||||
|
let api_password = dotenvy::var("PAYPAL_NVP_PASSWORD")?;
|
||||||
|
let api_signature = dotenvy::var("PAYPAL_NVP_SIGNATURE")?;
|
||||||
|
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
params.insert("METHOD", "GetBalance");
|
||||||
|
params.insert("VERSION", "204");
|
||||||
|
params.insert("USER", &api_username);
|
||||||
|
params.insert("PWD", &api_password);
|
||||||
|
params.insert("SIGNATURE", &api_signature);
|
||||||
|
params.insert("RETURNALLCURRENCIES", "1");
|
||||||
|
|
||||||
|
let endpoint = "https://api-3t.paypal.com/nvp";
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client.post(endpoint).form(¶ms).send().await?;
|
||||||
|
|
||||||
|
let text = response.text().await?;
|
||||||
|
let body = urlencoding::decode(&text).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut key_value_map = HashMap::new();
|
||||||
|
|
||||||
|
for pair in body.split('&') {
|
||||||
|
let mut iter = pair.splitn(2, '=');
|
||||||
|
if let (Some(key), Some(value)) = (iter.next(), iter.next()) {
|
||||||
|
key_value_map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(amount) = key_value_map
|
||||||
|
.get("L_AMT0")
|
||||||
|
.and_then(|x| Decimal::from_str_exact(x).ok())
|
||||||
|
{
|
||||||
|
Ok(Some(AccountBalance {
|
||||||
|
available: amount,
|
||||||
|
pending: Decimal::ZERO,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tremendous_balance(
|
||||||
|
&self,
|
||||||
|
) -> Result<Option<AccountBalance>, ApiError> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FundingSourceMeta {
|
||||||
|
available_cents: u64,
|
||||||
|
pending_cents: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FundingSource {
|
||||||
|
method: String,
|
||||||
|
meta: FundingSourceMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FundingSourceRequest {
|
||||||
|
pub funding_sources: Vec<FundingSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let val = self
|
||||||
|
.make_tremendous_request::<(), FundingSourceRequest>(
|
||||||
|
Method::GET,
|
||||||
|
"funding_sources",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(val
|
||||||
|
.funding_sources
|
||||||
|
.into_iter()
|
||||||
|
.find(|x| x.method == "balance")
|
||||||
|
.map(|x| AccountBalance {
|
||||||
|
available: Decimal::from(x.meta.available_cents)
|
||||||
|
/ Decimal::from(100),
|
||||||
|
pending: Decimal::from(x.meta.pending_cents)
|
||||||
|
/ Decimal::from(100),
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||||
|
use crate::database::models::thread_item::ThreadMessageBuilder;
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
use crate::models::analytics::Download;
|
use crate::models::analytics::Download;
|
||||||
use crate::models::ids::ProjectId;
|
use crate::models::ids::ProjectId;
|
||||||
use crate::models::pats::Scopes;
|
use crate::models::pats::Scopes;
|
||||||
|
use crate::models::threads::MessageBody;
|
||||||
use crate::queue::analytics::AnalyticsQueue;
|
use crate::queue::analytics::AnalyticsQueue;
|
||||||
use crate::queue::maxmind::MaxMindIndexer;
|
use crate::queue::maxmind::MaxMindIndexer;
|
||||||
|
use crate::queue::moderation::AUTOMOD_ID;
|
||||||
|
use crate::queue::payouts::PayoutsQueue;
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
use crate::search::SearchConfig;
|
use crate::search::SearchConfig;
|
||||||
use crate::util::date::get_current_tenths_of_ms;
|
use crate::util::date::get_current_tenths_of_ms;
|
||||||
use crate::util::guards::admin_key_guard;
|
use crate::util::guards::admin_key_guard;
|
||||||
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
|
use actix_web::{get, patch, post, web, HttpRequest, HttpResponse};
|
||||||
|
use log::info;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -21,7 +26,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
cfg.service(
|
cfg.service(
|
||||||
web::scope("admin")
|
web::scope("admin")
|
||||||
.service(count_download)
|
.service(count_download)
|
||||||
.service(force_reindex),
|
.service(force_reindex)
|
||||||
|
.service(get_balances)
|
||||||
|
.service(delphi_result_ingest),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,3 +165,105 @@ pub async fn force_reindex(
|
|||||||
index_projects(pool.as_ref().clone(), redis.clone(), &config).await?;
|
index_projects(pool.as_ref().clone(), redis.clone(), &config).await?;
|
||||||
Ok(HttpResponse::NoContent().finish())
|
Ok(HttpResponse::NoContent().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/_balances", guard = "admin_key_guard")]
|
||||||
|
pub async fn get_balances(
|
||||||
|
payouts: web::Data<PayoutsQueue>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
let (paypal, brex, tremendous) = futures::future::try_join3(
|
||||||
|
PayoutsQueue::get_paypal_balance(),
|
||||||
|
PayoutsQueue::get_brex_balance(),
|
||||||
|
payouts.get_tremendous_balance(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
|
"paypal": paypal,
|
||||||
|
"brex": brex,
|
||||||
|
"tremendous": tremendous,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DelphiIngest {
|
||||||
|
pub url: String,
|
||||||
|
pub project_id: crate::models::ids::ProjectId,
|
||||||
|
pub version_id: crate::models::ids::VersionId,
|
||||||
|
pub issues: HashMap<String, HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/_delphi", guard = "admin_key_guard")]
|
||||||
|
pub async fn delphi_result_ingest(
|
||||||
|
pool: web::Data<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
body: web::Json<DelphiIngest>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
if body.issues.is_empty() {
|
||||||
|
info!("No issues found for file {}", body.url);
|
||||||
|
return Ok(HttpResponse::NoContent().finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
let webhook_url = dotenvy::var("DELPHI_SLACK_WEBHOOK")?;
|
||||||
|
|
||||||
|
let project = crate::database::models::Project::get_id(
|
||||||
|
body.project_id.into(),
|
||||||
|
&**pool,
|
||||||
|
&redis,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError::InvalidInput(format!(
|
||||||
|
"Project {} does not exist",
|
||||||
|
body.project_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut header = format!("Suspicious traces found at {}", body.url);
|
||||||
|
|
||||||
|
for (issue, trace) in &body.issues {
|
||||||
|
for (path, code) in trace {
|
||||||
|
header.push_str(&format!(
|
||||||
|
"\n issue {issue} found at file {}: \n ```\n{}\n```",
|
||||||
|
path, code
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::util::webhook::send_slack_webhook(
|
||||||
|
body.project_id,
|
||||||
|
&pool,
|
||||||
|
&redis,
|
||||||
|
webhook_url,
|
||||||
|
Some(header),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let mut thread_header = format!("Suspicious traces found at [version {}](https://modrinth.com/project/{}/version/{})", body.version_id, body.project_id, body.version_id);
|
||||||
|
|
||||||
|
for (issue, trace) in &body.issues {
|
||||||
|
for path in trace.keys() {
|
||||||
|
thread_header
|
||||||
|
.push_str(&format!("\n issue {issue} found at file {}", path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut transaction = pool.begin().await?;
|
||||||
|
ThreadMessageBuilder {
|
||||||
|
author_id: Some(crate::database::models::UserId(AUTOMOD_ID)),
|
||||||
|
body: MessageBody::Text {
|
||||||
|
body: thread_header,
|
||||||
|
private: true,
|
||||||
|
replying_to: None,
|
||||||
|
associated_images: vec![],
|
||||||
|
},
|
||||||
|
thread_id: project.thread_id,
|
||||||
|
hide_identity: false,
|
||||||
|
}
|
||||||
|
.insert(&mut transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
transaction.commit().await?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::NoContent().finish())
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,12 +83,18 @@ pub async fn products(
|
|||||||
Ok(HttpResponse::Ok().json(products))
|
Ok(HttpResponse::Ok().json(products))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SubscriptionsQuery {
|
||||||
|
pub user_id: Option<crate::models::ids::UserId>,
|
||||||
|
}
|
||||||
|
|
||||||
#[get("subscriptions")]
|
#[get("subscriptions")]
|
||||||
pub async fn subscriptions(
|
pub async fn subscriptions(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
redis: web::Data<RedisPool>,
|
redis: web::Data<RedisPool>,
|
||||||
session_queue: web::Data<AuthQueue>,
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
query: web::Query<SubscriptionsQuery>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let user = get_user_from_headers(
|
let user = get_user_from_headers(
|
||||||
&req,
|
&req,
|
||||||
@@ -102,7 +108,18 @@ pub async fn subscriptions(
|
|||||||
|
|
||||||
let subscriptions =
|
let subscriptions =
|
||||||
user_subscription_item::UserSubscriptionItem::get_all_user(
|
user_subscription_item::UserSubscriptionItem::get_all_user(
|
||||||
user.id.into(),
|
if let Some(user_id) = query.user_id {
|
||||||
|
if user.role.is_admin() {
|
||||||
|
user_id.into()
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::InvalidInput(
|
||||||
|
"You cannot see the subscriptions of other users!"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.id.into()
|
||||||
|
},
|
||||||
&**pool,
|
&**pool,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
@@ -573,12 +590,18 @@ pub async fn user_customer(
|
|||||||
Ok(HttpResponse::Ok().json(customer))
|
Ok(HttpResponse::Ok().json(customer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ChargesQuery {
|
||||||
|
pub user_id: Option<crate::models::ids::UserId>,
|
||||||
|
}
|
||||||
|
|
||||||
#[get("payments")]
|
#[get("payments")]
|
||||||
pub async fn charges(
|
pub async fn charges(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
redis: web::Data<RedisPool>,
|
redis: web::Data<RedisPool>,
|
||||||
session_queue: web::Data<AuthQueue>,
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
query: web::Query<ChargesQuery>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let user = get_user_from_headers(
|
let user = get_user_from_headers(
|
||||||
&req,
|
&req,
|
||||||
@@ -592,7 +615,18 @@ pub async fn charges(
|
|||||||
|
|
||||||
let charges =
|
let charges =
|
||||||
crate::database::models::charge_item::ChargeItem::get_from_user(
|
crate::database::models::charge_item::ChargeItem::get_from_user(
|
||||||
user.id.into(),
|
if let Some(user_id) = query.user_id {
|
||||||
|
if user.role.is_admin() {
|
||||||
|
user_id.into()
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::InvalidInput(
|
||||||
|
"You cannot see the subscriptions of other users!"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.id.into()
|
||||||
|
},
|
||||||
&**pool,
|
&**pool,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -604,7 +638,7 @@ pub async fn charges(
|
|||||||
id: x.id.into(),
|
id: x.id.into(),
|
||||||
user_id: x.user_id.into(),
|
user_id: x.user_id.into(),
|
||||||
price_id: x.price_id.into(),
|
price_id: x.price_id.into(),
|
||||||
amount: x.amount as u64,
|
amount: x.amount,
|
||||||
currency_code: x.currency_code,
|
currency_code: x.currency_code,
|
||||||
status: x.status,
|
status: x.status,
|
||||||
due: x.due,
|
due: x.due,
|
||||||
@@ -613,6 +647,8 @@ pub async fn charges(
|
|||||||
subscription_id: x.subscription_id.map(|x| x.into()),
|
subscription_id: x.subscription_id.map(|x| x.into()),
|
||||||
subscription_interval: x.subscription_interval,
|
subscription_interval: x.subscription_interval,
|
||||||
platform: x.payment_platform,
|
platform: x.payment_platform,
|
||||||
|
parent_charge_id: x.parent_charge_id.map(|x| x.into()),
|
||||||
|
net: if user.role.is_admin() { x.net } else { None },
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
))
|
))
|
||||||
@@ -880,11 +916,11 @@ pub async fn active_servers(
|
|||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let master_key = dotenvy::var("PYRO_API_KEY")?;
|
let master_key = dotenvy::var("PYRO_API_KEY")?;
|
||||||
|
|
||||||
if !req
|
if req
|
||||||
.head()
|
.head()
|
||||||
.headers()
|
.headers()
|
||||||
.get("X-Master-Key")
|
.get("X-Master-Key")
|
||||||
.map_or(false, |it| it.as_bytes() == master_key.as_bytes())
|
.is_none_or(|it| it.as_bytes() != master_key.as_bytes())
|
||||||
{
|
{
|
||||||
return Err(ApiError::CustomAuthentication(
|
return Err(ApiError::CustomAuthentication(
|
||||||
"Invalid master key".to_string(),
|
"Invalid master key".to_string(),
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use crate::queue::socket::ActiveSockets;
|
|||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
use actix_web::web::{Data, Payload};
|
use actix_web::web::{Data, Payload};
|
||||||
use actix_web::{get, web, HttpRequest, HttpResponse};
|
use actix_web::{get, web, HttpRequest, HttpResponse};
|
||||||
use actix_ws::AggregatedMessage;
|
use actix_ws::Message;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use futures_util::StreamExt;
|
use futures_util::{StreamExt, TryStreamExt};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
@@ -128,13 +128,13 @@ pub async fn ws_init(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut stream = msg_stream.aggregate_continuations();
|
let mut stream = msg_stream.into_stream();
|
||||||
|
|
||||||
actix_web::rt::spawn(async move {
|
actix_web::rt::spawn(async move {
|
||||||
// receive messages from websocket
|
// receive messages from websocket
|
||||||
while let Some(msg) = stream.next().await {
|
while let Some(msg) = stream.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(AggregatedMessage::Text(text)) => {
|
Ok(Message::Text(text)) => {
|
||||||
if let Ok(message) =
|
if let Ok(message) =
|
||||||
serde_json::from_str::<ClientToServerMessage>(&text)
|
serde_json::from_str::<ClientToServerMessage>(&text)
|
||||||
{
|
{
|
||||||
@@ -159,10 +159,14 @@ pub async fn ws_init(
|
|||||||
status.profile_name = profile_name;
|
status.profile_name = profile_name;
|
||||||
status.last_update = Utc::now();
|
status.last_update = Utc::now();
|
||||||
|
|
||||||
|
let user_status = status.clone();
|
||||||
|
// We drop the pair to avoid holding the lock for too long
|
||||||
|
drop(pair);
|
||||||
|
|
||||||
let _ = broadcast_friends(
|
let _ = broadcast_friends(
|
||||||
user.id,
|
user.id,
|
||||||
ServerToClientMessage::StatusUpdate {
|
ServerToClientMessage::StatusUpdate {
|
||||||
status: status.clone(),
|
status: user_status,
|
||||||
},
|
},
|
||||||
&pool,
|
&pool,
|
||||||
&db,
|
&db,
|
||||||
@@ -175,15 +179,14 @@ pub async fn ws_init(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(AggregatedMessage::Close(_)) => {
|
Ok(Message::Close(_)) => {
|
||||||
let _ = close_socket(user.id, &pool, &db).await;
|
let _ = close_socket(user.id, &pool, &db).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(AggregatedMessage::Ping(msg)) => {
|
Ok(Message::Ping(msg)) => {
|
||||||
if let Some(mut socket) = db.auth_sockets.get_mut(&user.id)
|
if let Some(socket) = db.auth_sockets.get(&user.id) {
|
||||||
{
|
let (_, socket) = socket.value();
|
||||||
let (_, socket) = socket.value_mut();
|
let _ = socket.clone().pong(&msg).await;
|
||||||
let _ = socket.pong(&msg).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,12 +221,11 @@ pub async fn broadcast_friends(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if friend.accepted {
|
if friend.accepted {
|
||||||
if let Some(mut socket) =
|
if let Some(socket) = sockets.auth_sockets.get(&friend_id.into()) {
|
||||||
sockets.auth_sockets.get_mut(&friend_id.into())
|
let (_, socket) = socket.value();
|
||||||
{
|
|
||||||
let (_, socket) = socket.value_mut();
|
|
||||||
|
|
||||||
let _ = socket.text(serde_json::to_string(&message)?).await;
|
let _ =
|
||||||
|
socket.clone().text(serde_json::to_string(&message)?).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,6 @@ pub async fn project_search(
|
|||||||
let facets: Option<Vec<Vec<String>>> = if let Some(facets) = info.facets {
|
let facets: Option<Vec<Vec<String>>> = if let Some(facets) = info.facets {
|
||||||
let facets = serde_json::from_str::<Vec<Vec<String>>>(&facets)?;
|
let facets = serde_json::from_str::<Vec<Vec<String>>>(&facets)?;
|
||||||
|
|
||||||
// These loaders specifically used to be combined with 'mod' to be a plugin, but now
|
|
||||||
// they are their own loader type. We will convert 'mod' to 'mod' OR 'plugin'
|
|
||||||
// as it essentially was before.
|
|
||||||
let facets = v2_reroute::convert_plugin_loader_facets_v3(facets);
|
|
||||||
|
|
||||||
Some(
|
Some(
|
||||||
facets
|
facets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -85,11 +85,13 @@ pub async fn users_get(
|
|||||||
|
|
||||||
#[get("{id}")]
|
#[get("{id}")]
|
||||||
pub async fn user_get(
|
pub async fn user_get(
|
||||||
|
req: HttpRequest,
|
||||||
info: web::Path<(String,)>,
|
info: web::Path<(String,)>,
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
redis: web::Data<RedisPool>,
|
redis: web::Data<RedisPool>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let response = v3::users::user_get(info, pool, redis)
|
let response = v3::users::user_get(req, info, pool, redis, session_queue)
|
||||||
.await
|
.await
|
||||||
.or_else(v2_reroute::flatten_404_error)?;
|
.or_else(v2_reroute::flatten_404_error)?;
|
||||||
|
|
||||||
|
|||||||
@@ -190,28 +190,6 @@ pub fn convert_side_types_v3(
|
|||||||
fields
|
fields
|
||||||
}
|
}
|
||||||
|
|
||||||
// Converts plugin loaders from v2 to v3, for search facets
|
|
||||||
// Within every 1st and 2nd level (the ones allowed in v2), we convert every instance of:
|
|
||||||
// "project_type:mod" to "project_type:plugin" OR "project_type:mod"
|
|
||||||
pub fn convert_plugin_loader_facets_v3(
|
|
||||||
facets: Vec<Vec<String>>,
|
|
||||||
) -> Vec<Vec<String>> {
|
|
||||||
facets
|
|
||||||
.into_iter()
|
|
||||||
.map(|inner_facets| {
|
|
||||||
if inner_facets == ["project_type:mod"] {
|
|
||||||
vec![
|
|
||||||
"project_type:plugin".to_string(),
|
|
||||||
"project_type:datapack".to_string(),
|
|
||||||
"project_type:mod".to_string(),
|
|
||||||
]
|
|
||||||
} else {
|
|
||||||
inner_facets
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert search facets from V3 back to v2
|
// Convert search facets from V3 back to v2
|
||||||
// this is not lossless. (See tests)
|
// this is not lossless. (See tests)
|
||||||
pub fn convert_side_types_v2(
|
pub fn convert_side_types_v2(
|
||||||
|
|||||||
@@ -78,12 +78,13 @@ pub async fn add_friend(
|
|||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
if let Some(pair) = sockets.auth_sockets.get(&user_id.into()) {
|
if let Some(pair) = sockets.auth_sockets.get(&user_id.into()) {
|
||||||
let (friend_status, _) = pair.value();
|
let (friend_status, _) = pair.value();
|
||||||
if let Some(mut socket) =
|
if let Some(socket) =
|
||||||
sockets.auth_sockets.get_mut(&friend_id.into())
|
sockets.auth_sockets.get(&friend_id.into())
|
||||||
{
|
{
|
||||||
let (_, socket) = socket.value_mut();
|
let (_, socket) = socket.value();
|
||||||
|
|
||||||
let _ = socket
|
let _ = socket
|
||||||
|
.clone()
|
||||||
.text(serde_json::to_string(
|
.text(serde_json::to_string(
|
||||||
&ServerToClientMessage::StatusUpdate {
|
&ServerToClientMessage::StatusUpdate {
|
||||||
status: friend_status.clone(),
|
status: friend_status.clone(),
|
||||||
@@ -120,11 +121,11 @@ pub async fn add_friend(
|
|||||||
.insert(&mut transaction)
|
.insert(&mut transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(mut socket) = db.auth_sockets.get_mut(&friend.id.into())
|
if let Some(socket) = db.auth_sockets.get(&friend.id.into()) {
|
||||||
{
|
let (_, socket) = socket.value();
|
||||||
let (_, socket) = socket.value_mut();
|
|
||||||
|
|
||||||
if socket
|
if socket
|
||||||
|
.clone()
|
||||||
.text(serde_json::to_string(
|
.text(serde_json::to_string(
|
||||||
&ServerToClientMessage::FriendRequest { from: user.id },
|
&ServerToClientMessage::FriendRequest { from: user.id },
|
||||||
)?)
|
)?)
|
||||||
@@ -177,10 +178,11 @@ pub async fn remove_friend(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(mut socket) = db.auth_sockets.get_mut(&friend.id.into()) {
|
if let Some(socket) = db.auth_sockets.get(&friend.id.into()) {
|
||||||
let (_, socket) = socket.value_mut();
|
let (_, socket) = socket.value();
|
||||||
|
|
||||||
let _ = socket
|
let _ = socket
|
||||||
|
.clone()
|
||||||
.text(serde_json::to_string(
|
.text(serde_json::to_string(
|
||||||
&ServerToClientMessage::FriendRequestRejected {
|
&ServerToClientMessage::FriendRequestRejected {
|
||||||
from: user.id,
|
from: user.id,
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ pub struct NewOAuthApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("app")]
|
#[post("app")]
|
||||||
pub async fn oauth_client_create<'a>(
|
pub async fn oauth_client_create(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
new_oauth_app: web::Json<NewOAuthApp>,
|
new_oauth_app: web::Json<NewOAuthApp>,
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
@@ -221,7 +221,7 @@ pub async fn oauth_client_create<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[delete("app/{id}")]
|
#[delete("app/{id}")]
|
||||||
pub async fn oauth_client_delete<'a>(
|
pub async fn oauth_client_delete(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
client_id: web::Path<ApiOAuthClientId>,
|
client_id: web::Path<ApiOAuthClientId>,
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
|
|||||||
@@ -86,8 +86,6 @@ pub enum CreateError {
|
|||||||
CustomAuthenticationError(String),
|
CustomAuthenticationError(String),
|
||||||
#[error("Image Parsing Error: {0}")]
|
#[error("Image Parsing Error: {0}")]
|
||||||
ImageError(#[from] ImageError),
|
ImageError(#[from] ImageError),
|
||||||
#[error("Reroute Error: {0}")]
|
|
||||||
RerouteError(#[from] reqwest::Error),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl actix_web::ResponseError for CreateError {
|
impl actix_web::ResponseError for CreateError {
|
||||||
@@ -119,7 +117,6 @@ impl actix_web::ResponseError for CreateError {
|
|||||||
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
|
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
|
||||||
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
|
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
|
||||||
CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
|
CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
|
||||||
CreateError::RerouteError(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +143,6 @@ impl actix_web::ResponseError for CreateError {
|
|||||||
CreateError::ValidationError(..) => "invalid_input",
|
CreateError::ValidationError(..) => "invalid_input",
|
||||||
CreateError::FileValidationError(..) => "invalid_input",
|
CreateError::FileValidationError(..) => "invalid_input",
|
||||||
CreateError::ImageError(..) => "invalid_image",
|
CreateError::ImageError(..) => "invalid_image",
|
||||||
CreateError::RerouteError(..) => "reroute_error",
|
|
||||||
},
|
},
|
||||||
description: self.to_string(),
|
description: self.to_string(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -128,14 +128,33 @@ pub async fn users_get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_get(
|
pub async fn user_get(
|
||||||
|
req: HttpRequest,
|
||||||
info: web::Path<(String,)>,
|
info: web::Path<(String,)>,
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
redis: web::Data<RedisPool>,
|
redis: web::Data<RedisPool>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let user_data = User::get(&info.into_inner().0, &**pool, &redis).await?;
|
let user_data = User::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||||
|
|
||||||
if let Some(data) = user_data {
|
if let Some(data) = user_data {
|
||||||
let response: crate::models::users::User = data.into();
|
let auth_user = get_user_from_headers(
|
||||||
|
&req,
|
||||||
|
&**pool,
|
||||||
|
&redis,
|
||||||
|
&session_queue,
|
||||||
|
Some(&[Scopes::SESSION_ACCESS]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|x| x.1)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let response: crate::models::users::User =
|
||||||
|
if auth_user.map(|x| x.role.is_admin()).unwrap_or(false) {
|
||||||
|
crate::models::users::User::from_full(data)
|
||||||
|
} else {
|
||||||
|
data.into()
|
||||||
|
};
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(response))
|
Ok(HttpResponse::Ok().json(response))
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::NotFound)
|
Err(ApiError::NotFound)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ use actix_web::{web, HttpRequest, HttpResponse};
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use futures::stream::StreamExt;
|
use futures::stream::StreamExt;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
use log::error;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::postgres::PgPool;
|
use sqlx::postgres::PgPool;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -980,6 +981,30 @@ pub async fn upload_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let url = format!("{cdn_url}/{file_path_encode}");
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let delphi_url = dotenvy::var("DELPHI_URL")?;
|
||||||
|
match client
|
||||||
|
.post(delphi_url)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"url": url,
|
||||||
|
"project_id": project_id,
|
||||||
|
"version_id": version_id,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) => {
|
||||||
|
if !res.status().is_success() {
|
||||||
|
error!("Failed to upload file to Delphi: {url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to upload file to Delphi: {url}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
version_files.push(VersionFileBuilder {
|
version_files.push(VersionFileBuilder {
|
||||||
filename: file_name.to_string(),
|
filename: file_name.to_string(),
|
||||||
url: format!("{cdn_url}/{file_path_encode}"),
|
url: format!("{cdn_url}/{file_path_encode}"),
|
||||||
|
|||||||
@@ -505,7 +505,11 @@ async fn index_versions(
|
|||||||
version_id: VersionId(m.version_id),
|
version_id: VersionId(m.version_id),
|
||||||
field_id: LoaderFieldId(m.field_id),
|
field_id: LoaderFieldId(m.field_id),
|
||||||
int_value: m.int_value,
|
int_value: m.int_value,
|
||||||
enum_value: m.enum_value.map(LoaderFieldEnumValueId),
|
enum_value: if m.enum_value == -1 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(LoaderFieldEnumValueId(m.enum_value))
|
||||||
|
},
|
||||||
string_value: m.string_value,
|
string_value: m.string_value,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ use crate::models::ids::base62_impl::to_base62;
|
|||||||
use crate::search::{SearchConfig, UploadSearchProject};
|
use crate::search::{SearchConfig, UploadSearchProject};
|
||||||
use local_import::index_local;
|
use local_import::index_local;
|
||||||
use log::info;
|
use log::info;
|
||||||
use meilisearch_sdk::client::Client;
|
use meilisearch_sdk::client::{Client, SwapIndexes};
|
||||||
use meilisearch_sdk::indexes::Index;
|
use meilisearch_sdk::indexes::Index;
|
||||||
use meilisearch_sdk::settings::{PaginationSetting, Settings};
|
use meilisearch_sdk::settings::{PaginationSetting, Settings};
|
||||||
use meilisearch_sdk::SwapIndexes;
|
|
||||||
use sqlx::postgres::PgPool;
|
use sqlx::postgres::PgPool;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
@@ -100,7 +99,7 @@ pub async fn swap_index(
|
|||||||
config: &SearchConfig,
|
config: &SearchConfig,
|
||||||
index_name: &str,
|
index_name: &str,
|
||||||
) -> Result<(), IndexingError> {
|
) -> Result<(), IndexingError> {
|
||||||
let client = config.make_client();
|
let client = config.make_client()?;
|
||||||
let index_name_next = config.get_index_name(index_name, true);
|
let index_name_next = config.get_index_name(index_name, true);
|
||||||
let index_name = config.get_index_name(index_name, false);
|
let index_name = config.get_index_name(index_name, false);
|
||||||
let swap_indices = SwapIndexes {
|
let swap_indices = SwapIndexes {
|
||||||
@@ -119,7 +118,7 @@ pub async fn get_indexes_for_indexing(
|
|||||||
config: &SearchConfig,
|
config: &SearchConfig,
|
||||||
next: bool, // Get the 'next' one
|
next: bool, // Get the 'next' one
|
||||||
) -> Result<Vec<Index>, meilisearch_sdk::errors::Error> {
|
) -> Result<Vec<Index>, meilisearch_sdk::errors::Error> {
|
||||||
let client = config.make_client();
|
let client = config.make_client()?;
|
||||||
let project_name = config.get_index_name("projects", next);
|
let project_name = config.get_index_name("projects", next);
|
||||||
let project_filtered_name =
|
let project_filtered_name =
|
||||||
config.get_index_name("projects_filtered", next);
|
config.get_index_name("projects_filtered", next);
|
||||||
@@ -285,7 +284,7 @@ pub async fn add_projects(
|
|||||||
additional_fields: Vec<String>,
|
additional_fields: Vec<String>,
|
||||||
config: &SearchConfig,
|
config: &SearchConfig,
|
||||||
) -> Result<(), IndexingError> {
|
) -> Result<(), IndexingError> {
|
||||||
let client = config.make_client();
|
let client = config.make_client()?;
|
||||||
for index in indices {
|
for index in indices {
|
||||||
update_and_add_to_index(&client, index, &projects, &additional_fields)
|
update_and_add_to_index(&client, index, &projects, &additional_fields)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -296,7 +295,7 @@ pub async fn add_projects(
|
|||||||
|
|
||||||
fn default_settings() -> Settings {
|
fn default_settings() -> Settings {
|
||||||
Settings::new()
|
Settings::new()
|
||||||
.with_distinct_attribute("project_id")
|
.with_distinct_attribute(Some("project_id"))
|
||||||
.with_displayed_attributes(DEFAULT_DISPLAYED_ATTRIBUTES)
|
.with_displayed_attributes(DEFAULT_DISPLAYED_ATTRIBUTES)
|
||||||
.with_searchable_attributes(DEFAULT_SEARCHABLE_ATTRIBUTES)
|
.with_searchable_attributes(DEFAULT_SEARCHABLE_ATTRIBUTES)
|
||||||
.with_sortable_attributes(DEFAULT_SORTABLE_ATTRIBUTES)
|
.with_sortable_attributes(DEFAULT_SORTABLE_ATTRIBUTES)
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ impl SearchConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn make_client(&self) -> Client {
|
pub fn make_client(
|
||||||
|
&self,
|
||||||
|
) -> Result<Client, meilisearch_sdk::errors::Error> {
|
||||||
Client::new(self.address.as_str(), Some(self.key.as_str()))
|
Client::new(self.address.as_str(), Some(self.key.as_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +192,7 @@ pub async fn search_for_project(
|
|||||||
info: &SearchRequest,
|
info: &SearchRequest,
|
||||||
config: &SearchConfig,
|
config: &SearchConfig,
|
||||||
) -> Result<SearchResults, SearchError> {
|
) -> Result<SearchResults, SearchError> {
|
||||||
let client = Client::new(&*config.address, Some(&*config.key));
|
let client = Client::new(&*config.address, Some(&*config.key))?;
|
||||||
|
|
||||||
let offset: usize = info.offset.as_deref().unwrap_or("0").parse()?;
|
let offset: usize = info.offset.as_deref().unwrap_or("0").parse()?;
|
||||||
let index = info.index.as_deref().unwrap_or("relevance");
|
let index = info.index.as_deref().unwrap_or("relevance");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
|
|
||||||
|
// Updating this? Remember to update the ratelimit CORS too!
|
||||||
pub fn default_cors() -> Cors {
|
pub fn default_cors() -> Cors {
|
||||||
Cors::default()
|
Cors::default()
|
||||||
.allow_any_origin()
|
.allow_any_origin()
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ pub fn admin_key_guard(ctx: &GuardContext) -> bool {
|
|||||||
ctx.head()
|
ctx.head()
|
||||||
.headers()
|
.headers()
|
||||||
.get(ADMIN_KEY_HEADER)
|
.get(ADMIN_KEY_HEADER)
|
||||||
.map_or(false, |it| it.as_bytes() == admin_key.as_bytes())
|
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,15 @@ where
|
|||||||
wait_time.as_secs().into(),
|
wait_time.as_secs().into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TODO: Sentralize CORS in the CORS util.
|
||||||
|
headers.insert(
|
||||||
|
actix_web::http::header::HeaderName::from_str(
|
||||||
|
"Access-Control-Allow-Origin",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"*".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
Box::pin(async {
|
Box::pin(async {
|
||||||
Ok(req.into_response(response.map_into_right_body()))
|
Ok(req.into_response(response.map_into_right_body()))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: labrinth
|
POSTGRES_PASSWORD: labrinth
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:v1.5.0
|
image: getmeili/meilisearch:v1.12.0
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
ports:
|
ports:
|
||||||
- '7700:7700'
|
- '7700:7700'
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user