Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
188ab15f1a | ||
|
|
6e06623e0f | ||
|
|
8f8e45a29d | ||
|
|
c063ca1ed5 | ||
|
|
25ca13b74c | ||
|
|
88b694976b | ||
|
|
98a17ae307 | ||
|
|
adb49b8b29 | ||
|
|
7b934b48a5 | ||
|
|
00aaa42be1 | ||
|
|
be38a1aee9 | ||
|
|
1f412c4bda | ||
|
|
0a31725c8f | ||
|
|
b10b6b86c7 | ||
|
|
01c023b9f4 | ||
|
|
2f618dbd1e | ||
|
|
7ca97c4864 | ||
|
|
7a9e6c451b | ||
|
|
2dfcc57b18 | ||
|
|
0fe42182bd | ||
|
|
5102efce2a | ||
|
|
69569bf468 | ||
|
|
304b8338f8 | ||
|
|
1454e5baa8 | ||
|
|
6bb69c8d52 | ||
|
|
5ad5fa829f | ||
|
|
eb86926b52 | ||
|
|
c943ba1f9b | ||
|
|
0897a6e782 | ||
|
|
26c4e710f8 | ||
|
|
2b0f698a0c | ||
|
|
9edbee374e | ||
|
|
2ea7c1ab40 | ||
|
|
d8c96c09a8 | ||
|
|
277615b079 | ||
|
|
c1b3476bd8 | ||
|
|
c0acc7e2a9 | ||
|
|
5daee756c2 | ||
|
|
c2fb6e168b | ||
|
|
ccb40de61c | ||
|
|
57c167e5d8 | ||
|
|
26a5a16612 | ||
|
|
068492aba4 | ||
|
|
281aea7996 | ||
|
|
4f2c053f7b | ||
|
|
f31477b4d7 | ||
|
|
b9f7bc7144 | ||
|
|
b33d442aa5 | ||
|
|
7c4190fb9c | ||
|
|
ebc7cac8b0 | ||
|
|
224c28f29d | ||
|
|
e192f6a0af | ||
|
|
2d6bce3d38 | ||
|
|
c938ca3ceb | ||
|
|
20adb0f16a | ||
|
|
9625a4221d | ||
|
|
354940a8c6 | ||
|
|
4c2e64e31f | ||
|
|
372e2bf47b | ||
|
|
36d786f504 | ||
|
|
1a1e1ea448 | ||
|
|
24240d514a | ||
|
|
158de019a6 | ||
|
|
54cc1898fa | ||
|
|
b96a1cfa58 | ||
|
|
59a794ebb1 | ||
|
|
b4380aee87 | ||
|
|
2cf71a53f2 | ||
|
|
44be49416b | ||
|
|
7f07e66e4f | ||
|
|
e271bb6878 | ||
|
|
de37804f54 | ||
|
|
907db9e64a | ||
|
|
944f99a14e | ||
|
|
aa7328b392 | ||
|
|
f1b907086e | ||
|
|
855f57b6b3 | ||
|
|
1a777fd6de | ||
|
|
4b30882e4b | ||
|
|
e64bc58d89 | ||
|
|
a79736dba9 | ||
|
|
46710c0b3e | ||
|
|
6f0181d52f | ||
|
|
70c544b048 | ||
|
|
5a80df3cd4 | ||
|
|
1e85ec8649 | ||
|
|
c2821d69d4 | ||
|
|
143e9b4b8b | ||
|
|
1dd613e459 | ||
|
|
e9ec976cd7 | ||
|
|
d632b7bdbc | ||
|
|
eb8141134a | ||
|
|
cc0d3bf42c | ||
|
|
5e155d69b6 | ||
|
|
707722ded6 | ||
|
|
dfc2ed3783 | ||
|
|
fbe70c6938 | ||
|
|
cd56b98975 | ||
|
|
b06b14e0fb | ||
|
|
a6d9bb7b3e | ||
|
|
e58af98f21 | ||
|
|
2e0d797bb0 | ||
|
|
508b96c27f | ||
|
|
0512e0b62b | ||
|
|
5c697f675c | ||
|
|
95f97a44ec | ||
|
|
054f1b42fd | ||
|
|
b4d681e713 | ||
|
|
11af2651ef | ||
|
|
dce20777ef | ||
|
|
56ff97ee6f | ||
|
|
d3839ff953 | ||
|
|
d2fb7114f6 | ||
|
|
aea761252b | ||
|
|
6c3208a7a5 | ||
|
|
46cbb3be0c | ||
|
|
14db1ae49c | ||
|
|
dfb23fd6ac | ||
|
|
184e6fb1ab | ||
|
|
96e2d95d02 | ||
|
|
07fd4fae5f | ||
|
|
3ca8a911da | ||
|
|
56eeb242de | ||
|
|
68f4b5d86a | ||
|
|
8f387aa9ea | ||
|
|
706d4c1a1e | ||
|
|
39fb75ebf7 | ||
|
|
6c86bb933b | ||
|
|
4bb20e560f | ||
|
|
5302e3a987 | ||
|
|
9b4c84ddd8 | ||
|
|
528a9b76bc | ||
|
|
d322a2b0c8 | ||
|
|
7ac1d17571 | ||
|
|
074699be45 | ||
|
|
7b2e5deb83 | ||
|
|
d0bdeb1bc6 | ||
|
|
832240a45f | ||
|
|
fc1aa28aa4 | ||
|
|
84b4f8e474 | ||
|
|
b14fad4237 | ||
|
|
ff1f86a0f4 | ||
|
|
e8f8722b70 | ||
|
|
518bcd414a | ||
|
|
79013c21c2 | ||
|
|
2e4a050151 | ||
|
|
f780736d89 | ||
|
|
916daa684d | ||
|
|
8ece0bd31c | ||
|
|
e2085c43c5 | ||
|
|
30e23e5cc2 | ||
|
|
8e44f20eec | ||
|
|
1ec6eef0ae | ||
|
|
932858b5f5 | ||
|
|
8b6f0ec695 | ||
|
|
ee2ad5f05b | ||
|
|
9bfa7c7e4a | ||
|
|
e52ae416a8 | ||
|
|
6319832465 | ||
|
|
7144f08f0f | ||
|
|
c579925f03 | ||
|
|
6b569e4ee4 | ||
|
|
6b62e3b480 | ||
|
|
ede847f84c | ||
|
|
543cb42210 | ||
|
|
86c6fb8239 | ||
|
|
14e0abb273 | ||
|
|
df653d3e43 | ||
|
|
4aeb6cf9ff | ||
|
|
90de608504 | ||
|
|
cb154e205b | ||
|
|
7782458ec9 | ||
|
|
f4086f2ec6 | ||
|
|
907599a69e | ||
|
|
b315c8acfa | ||
|
|
dad5970e8d | ||
|
|
1947d083b3 | ||
|
|
905204cc5f | ||
|
|
8cca911775 | ||
|
|
cafe58dc3c | ||
|
|
13e6ebf4e0 | ||
|
|
1884912126 | ||
|
|
818a9db9f7 | ||
|
|
c431d1fc89 | ||
|
|
4a09ee7ac9 | ||
|
|
31ef22767e | ||
|
|
706d46a6e2 | ||
|
|
ed9394f2f7 | ||
|
|
9e1e8788e2 | ||
|
|
7c4df7442b | ||
|
|
e1556a8ee3 | ||
|
|
1741d12b7b | ||
|
|
0b38cecc74 | ||
|
|
8680e61883 | ||
|
|
ea86db450b | ||
|
|
bbdaa171e3 | ||
|
|
af8daca059 | ||
|
|
2434f0c45b | ||
|
|
332dfe1074 | ||
|
|
c07727aa49 | ||
|
|
57a977f7f3 | ||
|
|
323d088ebd | ||
|
|
46f94bd067 | ||
|
|
e877167db7 | ||
|
|
623b51e6ea | ||
|
|
5face9e56a | ||
|
|
40aff1e8bb | ||
|
|
5f8837604d | ||
|
|
795dad040f | ||
|
|
9ca181dc1a | ||
|
|
c84b658e41 | ||
|
|
9006dce2b0 | ||
|
|
efdfb51149 | ||
|
|
79cc5fef72 | ||
|
|
ddefc28ca6 | ||
|
|
654757f9fb | ||
|
|
407641ee7c | ||
|
|
96ab120cd0 | ||
|
|
3ed75a146a | ||
|
|
5a1c2a46bc | ||
|
|
df8ebbd3e0 | ||
|
|
517c3d2d72 | ||
|
|
7f15772f59 | ||
|
|
0905ea72f6 | ||
|
|
8d20fd82db | ||
|
|
c831e38e32 | ||
|
|
40b8fb3a4a | ||
|
|
977bb2ff58 | ||
|
|
732bdfc79b | ||
|
|
1cce1eb248 | ||
|
|
76320f227f | ||
|
|
34228a5b6c | ||
|
|
6d66aee4ec | ||
|
|
fc7be043c7 | ||
|
|
7fa88e5d4d | ||
|
|
21508e0637 | ||
|
|
ad4912d64d | ||
|
|
0002ae2479 | ||
|
|
e4568330a7 | ||
|
|
b0108e0624 | ||
|
|
ac63abadb7 | ||
|
|
fa749b5aff | ||
|
|
a2adbe18c4 | ||
|
|
0fcbe51816 | ||
|
|
23cfeca91d | ||
|
|
db830ef65c | ||
|
|
08f44b3582 | ||
|
|
8ffc4e2599 | ||
|
|
58a5619073 | ||
|
|
4a6fa9fc3d | ||
|
|
b26d048a63 | ||
|
|
d5be2bd739 | ||
|
|
0dac5684af | ||
|
|
ca0b8d5ca4 | ||
|
|
b6651efe52 | ||
|
|
eb28121c0a | ||
|
|
a5ebc85356 | ||
|
|
c530671be1 | ||
|
|
bb6d8a6fc4 | ||
|
|
256e8f946a | ||
|
|
7e5590b8b9 | ||
|
|
d20f05fb04 | ||
|
|
a00a7971d1 | ||
|
|
26e98b7155 | ||
|
|
77b06f679e | ||
|
|
50e9472dd9 | ||
|
|
b569354793 | ||
|
|
b667b3ae9e | ||
|
|
16de6182e4 | ||
|
|
364fed52dd | ||
|
|
0fe695721a | ||
|
|
692f22b749 | ||
|
|
ed7792097d | ||
|
|
c56ce6c9ee | ||
|
|
91bc58b8ee | ||
|
|
38d757b66a | ||
|
|
862b54f00d | ||
|
|
c2f4835fe7 | ||
|
|
b97c191d75 |
@@ -1,5 +1,5 @@
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
[target.'cfg(windows)']
|
||||
[target."cfg(windows)"]
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
BUMP_TYPE: minor
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Setup Node
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
- name: Restore pnpm cache
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # 0.12.1
|
||||
uses: styfle/cancel-workflow-action@d07a454dad7609a92316b57b23c9ccfd4f59af66 # 0.13.1
|
||||
with:
|
||||
workflow_id: all
|
||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Post or update changelog comment
|
||||
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -12,14 +12,14 @@ jobs:
|
||||
typos:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: crate-ci/typos@6ac2ebd1b93eade61faf7e12688ad87a073fea59 # v1.46.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
|
||||
|
||||
# see <https://github.com/influxdata/datafusion-udf-wasm/pull/275>
|
||||
tombi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
|
||||
with:
|
||||
tool: tombi
|
||||
|
||||
@@ -6,14 +6,15 @@ on:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
SQLX_OFFLINE: true
|
||||
|
||||
jobs:
|
||||
shear:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
|
||||
- uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
|
||||
- run: cargo binstall --no-confirm cargo-shear
|
||||
- run: cargo shear
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check if workflow runs on an internal branch
|
||||
id: check-internal
|
||||
@@ -72,26 +72,15 @@ jobs:
|
||||
fi
|
||||
|
||||
docker:
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
env:
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
|
||||
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-medium-amd64' || 'ubuntu-latest' }}
|
||||
needs: [skip-if-clean]
|
||||
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
with:
|
||||
rustflags: ''
|
||||
cache: false
|
||||
@@ -99,26 +88,6 @@ jobs:
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Cache Cargo registry and index
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Mount sccache disk cache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
|
||||
with:
|
||||
key: ${{ github.repository }}-daedalus-sccache
|
||||
path: /mnt/sccache
|
||||
|
||||
- name: Setup sccache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Build daedalus_client
|
||||
run: cargo build --release --package daedalus_client
|
||||
|
||||
@@ -128,11 +97,11 @@ jobs:
|
||||
cp target/release/daedalus_client apps/daedalus_client/docker-stage/daedalus_client
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Generate Docker image metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
env:
|
||||
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
|
||||
with:
|
||||
@@ -147,14 +116,14 @@ jobs:
|
||||
org.opencontainers.image.licenses=MIT
|
||||
|
||||
- name: Login to GitHub Packages
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./apps/daedalus_client/docker-stage
|
||||
file: ./apps/daedalus_client/Dockerfile
|
||||
|
||||
@@ -12,10 +12,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -16,10 +16,10 @@ on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
environment:
|
||||
required: true
|
||||
type: string
|
||||
description: 'The environment to deploy to (staging-preview or production-preview)'
|
||||
preview:
|
||||
required: false
|
||||
type: boolean
|
||||
description: 'Whether this is a preview deployment'
|
||||
head_sha:
|
||||
required: false
|
||||
type: string
|
||||
@@ -28,44 +28,56 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
description: 'The branch name to deploy (defaults to github.ref_name)'
|
||||
branch_alias:
|
||||
required: false
|
||||
type: string
|
||||
description: 'The persistent preview alias for the branch'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.environment || 'push' }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.preview && 'preview' || 'push' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
runs-on: namespace-profile-modrinth-frontend
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure environment
|
||||
id: meta
|
||||
env:
|
||||
ENV_INPUT: ${{ inputs.environment }}
|
||||
PREVIEW_INPUT: ${{ inputs.preview }}
|
||||
REF: ${{ github.ref }}
|
||||
DEPLOY_SHA: ${{ inputs.head_sha || github.sha }}
|
||||
BRANCH_ALIAS: ${{ inputs.branch_alias }}
|
||||
run: |
|
||||
echo "cmd=deploy" >> $GITHUB_OUTPUT
|
||||
|
||||
SHA_SHORT="${DEPLOY_SHA::8}"
|
||||
|
||||
if [ "$ENV_INPUT" = "staging-preview" ]; then
|
||||
echo "env=staging" >> $GITHUB_OUTPUT
|
||||
echo "url=https://git-$SHA_SHORT-frontend-staging.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
|
||||
|
||||
elif [ "$ENV_INPUT" = "production-preview" ]; then
|
||||
if [ "$PREVIEW_INPUT" = "true" ]; then
|
||||
URL="https://git-$SHA_SHORT-frontend.modrinth.workers.dev"
|
||||
STAGING_URL="https://git-$SHA_SHORT-staging-frontend.modrinth.workers.dev"
|
||||
echo "env=production" >> $GITHUB_OUTPUT
|
||||
echo "url=https://git-$SHA_SHORT-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true" >> $GITHUB_OUTPUT
|
||||
echo "url=$URL" >> $GITHUB_OUTPUT
|
||||
echo "staging_url=$STAGING_URL" >> $GITHUB_OUTPUT
|
||||
echo "cmd=versions upload --preview-alias git-$SHA_SHORT --var PREVIEW:true --var CF_PAGES_URL:$URL" >> $GITHUB_OUTPUT
|
||||
|
||||
STAGING_VARS=$(jq -r '.env.staging.vars | to_entries[] | "--var \(.key):\(.value)"' ./apps/frontend/wrangler.jsonc | tr '\n' ' ')
|
||||
echo "staging_cmd=versions upload --preview-alias git-$SHA_SHORT-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:$STAGING_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ -n "$BRANCH_ALIAS" ]; then
|
||||
echo "branch_alias_url=https://$BRANCH_ALIAS-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "staging_branch_alias_url=https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "staging_branch_alias_cmd=versions upload --preview-alias $BRANCH_ALIAS-staging ${STAGING_VARS}--var PREVIEW:true --var CF_PAGES_URL:https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
elif [ "$REF" = "refs/heads/main" ]; then
|
||||
echo "env=staging" >> $GITHUB_OUTPUT
|
||||
@@ -78,24 +90,23 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Restore pnpm cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
- name: Set up caches
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-cache-
|
||||
cache: |
|
||||
rust
|
||||
pnpm
|
||||
apt
|
||||
|
||||
- name: Set up Turborepo caching
|
||||
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
|
||||
|
||||
- name: Inject build variables
|
||||
working-directory: ./apps/frontend
|
||||
@@ -120,11 +131,11 @@ jobs:
|
||||
CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }}
|
||||
CF_PAGES_URL: ${{ steps.meta.outputs.url }}
|
||||
BUILD_ENV: ${{ steps.meta.outputs.env }}
|
||||
PREVIEW: ${{ inputs.environment != '' && 'true' || 'false' }}
|
||||
PREVIEW: ${{ inputs.preview && 'true' || 'false' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
- name: Create Sentry release and upload sourcemaps
|
||||
uses: getsentry/action-release@5657c9e888b4e2cc85f4d29143ea4131fde4a73a # v3.6.0
|
||||
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: modrinth
|
||||
@@ -134,17 +145,53 @@ jobs:
|
||||
sourcemaps: ./apps/frontend/.output/server
|
||||
url_prefix: '~/'
|
||||
|
||||
- name: Deploy Cloudflare Worker
|
||||
id: wrangler
|
||||
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
|
||||
- name: Install Wrangler CLI
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
|
||||
workingDirectory: ./apps/frontend
|
||||
packageManager: pnpm
|
||||
wranglerVersion: '4.54.0'
|
||||
command: ${{ steps.meta.outputs.cmd }}
|
||||
wranglerVersion: '4.112.0'
|
||||
command: --version
|
||||
|
||||
- parallel:
|
||||
- name: Deploy Cloudflare Worker
|
||||
id: wrangler
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
environment: ${{ steps.meta.outputs.env != 'production' && steps.meta.outputs.env || '' }}
|
||||
workingDirectory: ./apps/frontend
|
||||
command: ${{ steps.meta.outputs.cmd }}
|
||||
|
||||
- name: Deploy staging Cloudflare Worker preview
|
||||
if: ${{ inputs.preview }}
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./apps/frontend
|
||||
command: ${{ steps.meta.outputs.staging_cmd }}
|
||||
|
||||
- name: Deploy persistent Cloudflare Worker preview
|
||||
if: ${{ inputs.preview && inputs.branch_alias != '' }}
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./apps/frontend
|
||||
command: versions upload --preview-alias ${{ inputs.branch_alias }} --var PREVIEW:true --var CF_PAGES_URL:${{ steps.meta.outputs.branch_alias_url }}
|
||||
|
||||
- name: Deploy persistent staging Cloudflare Worker preview
|
||||
if: ${{ inputs.preview && inputs.branch_alias != '' }}
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./apps/frontend
|
||||
command: ${{ steps.meta.outputs.staging_branch_alias_cmd }}
|
||||
|
||||
- name: Purge cache
|
||||
if: github.ref == 'refs/heads/prod'
|
||||
@@ -155,14 +202,23 @@ jobs:
|
||||
--data '{"hosts": ["modrinth.com", "www.modrinth.com", "staging.modrinth.com"]}' \
|
||||
https://api.cloudflare.com/client/v4/zones/e39df17b9c4ef44cbce2646346ee6d33/purge_cache
|
||||
|
||||
- name: Write deployment URL to file
|
||||
if: ${{ inputs.environment != '' }}
|
||||
- name: Write deployment URLs to files
|
||||
if: ${{ inputs.preview }}
|
||||
run: |
|
||||
echo "${{ steps.meta.outputs.url }}" > deployment-url-${{ inputs.environment }}.txt
|
||||
echo "${{ steps.meta.outputs.url }}" > deployment-url-production.txt
|
||||
echo "${{ steps.meta.outputs.staging_url }}" > deployment-url-staging.txt
|
||||
|
||||
- name: Upload deployment URL
|
||||
if: ${{ inputs.environment != '' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: deployment-url-${{ inputs.environment }}
|
||||
path: deployment-url-${{ inputs.environment }}.txt
|
||||
- parallel:
|
||||
- name: Upload production deployment URL
|
||||
if: ${{ inputs.preview }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: deployment-url-production
|
||||
path: deployment-url-production.txt
|
||||
|
||||
- name: Upload staging deployment URL
|
||||
if: ${{ inputs.preview }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: deployment-url-staging
|
||||
path: deployment-url-staging.txt
|
||||
|
||||
@@ -12,24 +12,34 @@ on:
|
||||
- '.github/workflows/frontend-preview.yml'
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
branch-alias: ${{ steps.meta.outputs.branch_alias }}
|
||||
steps:
|
||||
- name: Configure branch alias
|
||||
id: meta
|
||||
run: echo "branch_alias=pr-${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy:
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
needs: metadata
|
||||
uses: ./.github/workflows/frontend-deploy.yml
|
||||
secrets: inherit
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.environment }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-deploy
|
||||
cancel-in-progress: true
|
||||
strategy:
|
||||
matrix:
|
||||
environment: [staging-preview, production-preview]
|
||||
with:
|
||||
environment: ${{ matrix.environment }}
|
||||
preview: true
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
head_ref: ${{ github.event.pull_request.head.ref }}
|
||||
branch_alias: ${{ needs.metadata.outputs.branch-alias }}
|
||||
|
||||
deploy-storybook:
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
needs: metadata
|
||||
runs-on: namespace-profile-modrinth-frontend
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-storybook
|
||||
cancel-in-progress: true
|
||||
@@ -38,27 +48,21 @@ jobs:
|
||||
deployments: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Restore pnpm cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
- name: Set up caches
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-cache-
|
||||
cache: |
|
||||
pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./packages/ui
|
||||
@@ -66,6 +70,8 @@ jobs:
|
||||
|
||||
- name: Build Storybook
|
||||
working-directory: ./packages/ui
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
run: pnpm run build-storybook
|
||||
|
||||
- name: Configure short SHA
|
||||
@@ -74,20 +80,43 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: echo "sha_short=${HEAD_SHA::8}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Deploy Storybook preview
|
||||
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0
|
||||
- name: Install Wrangler CLI
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./packages/ui
|
||||
packageManager: pnpm
|
||||
wranglerVersion: '4.54.0'
|
||||
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
|
||||
wranglerVersion: '4.112.0'
|
||||
command: --version
|
||||
|
||||
- parallel:
|
||||
- name: Deploy Storybook preview
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./packages/ui
|
||||
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
|
||||
|
||||
- name: Deploy persistent Storybook preview
|
||||
uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
workingDirectory: ./packages/ui
|
||||
command: versions upload --preview-alias ${{ needs.metadata.outputs.branch-alias }}
|
||||
|
||||
comment:
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
if: |
|
||||
!cancelled()
|
||||
&& github.repository_owner == 'modrinth'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
&& needs.deploy.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy, deploy-storybook]
|
||||
needs: [metadata, deploy, deploy-storybook]
|
||||
steps:
|
||||
- name: Download deployment URLs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -99,16 +128,29 @@ jobs:
|
||||
id: urls
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BRANCH_ALIAS: ${{ needs.metadata.outputs.branch-alias }}
|
||||
STORYBOOK_RESULT: ${{ needs.deploy-storybook.result }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
|
||||
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
|
||||
STAGING_SHA_URL=$(cat deployment-url-staging.txt)
|
||||
PRODUCTION_SHA_URL=$(cat deployment-url-production.txt)
|
||||
STAGING_BRANCH_URL="https://$BRANCH_ALIAS-staging-frontend.modrinth.workers.dev"
|
||||
PRODUCTION_BRANCH_URL="https://$BRANCH_ALIAS-frontend.modrinth.workers.dev"
|
||||
|
||||
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
|
||||
echo "Staging preview URL: $STAGING_PREVIEW_URL"
|
||||
if [ "$STORYBOOK_RESULT" = "success" ]; then
|
||||
STORYBOOK_SHA_URL="https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev"
|
||||
STORYBOOK_BRANCH_URL="https://$BRANCH_ALIAS-storybook.modrinth.workers.dev"
|
||||
else
|
||||
STORYBOOK_SHA_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
|
||||
STORYBOOK_BRANCH_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
|
||||
fi
|
||||
|
||||
echo "staging-preview-url=$STAGING_PREVIEW_URL" >> $GITHUB_OUTPUT
|
||||
echo "production-preview-url=$PRODUCTION_PREVIEW_URL" >> $GITHUB_OUTPUT
|
||||
echo "storybook-preview-url=https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "staging-sha-url=$STAGING_SHA_URL" >> $GITHUB_OUTPUT
|
||||
echo "production-sha-url=$PRODUCTION_SHA_URL" >> $GITHUB_OUTPUT
|
||||
echo "storybook-sha-url=$STORYBOOK_SHA_URL" >> $GITHUB_OUTPUT
|
||||
echo "staging-branch-url=$STAGING_BRANCH_URL" >> $GITHUB_OUTPUT
|
||||
echo "production-branch-url=$PRODUCTION_BRANCH_URL" >> $GITHUB_OUTPUT
|
||||
echo "storybook-branch-url=$STORYBOOK_BRANCH_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find comment
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -131,9 +173,19 @@ jobs:
|
||||
|
||||
Last deployed commit is [${{ github.event.pull_request.head.sha }}](${{ github.event.pull_request.head.repo.html_url }}/commit/${{ github.event.pull_request.head.sha }})
|
||||
|
||||
### Persistent
|
||||
|
||||
| Environment | URL |
|
||||
|-------------|-----|
|
||||
| staging | ${{ steps.urls.outputs.staging-preview-url }} |
|
||||
| production | ${{ steps.urls.outputs.production-preview-url }} |
|
||||
| storybook | ${{ steps.urls.outputs.storybook-preview-url }} |
|
||||
| staging | ${{ steps.urls.outputs.staging-branch-url }} |
|
||||
| production | ${{ steps.urls.outputs.production-branch-url }} |
|
||||
| storybook | ${{ steps.urls.outputs.storybook-branch-url }} |
|
||||
|
||||
### Latest commit
|
||||
|
||||
| Environment | URL |
|
||||
|-------------|-----|
|
||||
| staging | ${{ steps.urls.outputs.staging-sha-url }} |
|
||||
| production | ${{ steps.urls.outputs.production-sha-url }} |
|
||||
| storybook | ${{ steps.urls.outputs.storybook-sha-url }} |
|
||||
edit-mode: replace
|
||||
|
||||
@@ -51,13 +51,13 @@ jobs:
|
||||
CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download translations from Crowdin
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
|
||||
@@ -56,13 +56,13 @@ jobs:
|
||||
CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
echo "safe_branch_name=$SAFE_BRANCH_NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload translations to Crowdin
|
||||
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check if workflow runs on an internal branch
|
||||
id: check-internal
|
||||
@@ -70,28 +70,21 @@ jobs:
|
||||
fi
|
||||
|
||||
docker:
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-labrinth' || 'ubuntu-latest' }}
|
||||
needs: [skip-if-clean]
|
||||
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
|
||||
env:
|
||||
SQLX_OFFLINE: 'true'
|
||||
GIT_HASH: ${{ github.sha }}
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
|
||||
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '20G' || '' }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
RUST_MIN_STACK: '16777216'
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
with:
|
||||
rustflags: ''
|
||||
cache: false
|
||||
@@ -99,32 +92,24 @@ jobs:
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Install build dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: cmake libcurl4-openssl-dev
|
||||
version: v2 # cache key
|
||||
|
||||
- name: Cache Cargo registry and index
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Mount sccache disk cache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
|
||||
with:
|
||||
key: ${{ github.repository }}-labrinth-sccache
|
||||
path: /mnt/sccache
|
||||
|
||||
- name: Setup sccache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Set up caches
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
cache: |
|
||||
rust
|
||||
apt
|
||||
|
||||
- name: Configure sccache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake libcurl4-openssl-dev
|
||||
|
||||
- name: Build labrinth
|
||||
run: cargo build --profile release-labrinth --package labrinth
|
||||
|
||||
@@ -136,11 +121,11 @@ jobs:
|
||||
cp -r apps/labrinth/assets apps/labrinth/docker-stage/assets
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Generate Docker image metadata
|
||||
id: docker-meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
env:
|
||||
# GitHub Packages requires annotations metadata in at least the index descriptor to show them
|
||||
# up properly in its UI it seems, but it's not clear about it, because the docs refer to the
|
||||
@@ -159,14 +144,14 @@ jobs:
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
|
||||
- name: Login to GitHub Packages
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: ./apps/labrinth/docker-stage
|
||||
file: ./apps/labrinth/Dockerfile
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
name: Prepare pnpm cache
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/prepare-pnpm-cache.yml
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
if: github.repository_owner == 'modrinth'
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pnpm
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm recursive install --frozen-lockfile
|
||||
@@ -44,42 +44,34 @@ jobs:
|
||||
name: Build
|
||||
env:
|
||||
VITE_STRIPE_PUBLISHABLE_KEY: pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5
|
||||
# SCCACHE_DIR: '/mnt/sccache'
|
||||
# SCCACHE_CACHE_SIZE: '10G'
|
||||
# SCCACHE_MULTILEVEL_CHAIN: 'disk,s3'
|
||||
SCCACHE_S3_KEY_PREFIX: '${{ github.repository }}/'
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: 'sccache'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [
|
||||
blacksmith-6vcpu-macos-26,
|
||||
blacksmith-8vcpu-windows-2025, # At time of writing, Windows 4 vCPU VMs don't seem to actually exist
|
||||
blacksmith-4vcpu-ubuntu-2404,
|
||||
platform:
|
||||
[
|
||||
namespace-profile-medium-amd64-macos,
|
||||
namespace-profile-medium-amd64-windows,
|
||||
namespace-profile-medium-amd64,
|
||||
]
|
||||
include:
|
||||
- platform: blacksmith-6vcpu-macos-26
|
||||
- platform: namespace-profile-medium-amd64-macos
|
||||
artifact-target-name: universal-apple-darwin
|
||||
- platform: blacksmith-8vcpu-windows-2025
|
||||
- platform: namespace-profile-medium-amd64-windows
|
||||
artifact-target-name: x86_64-pc-windows-msvc
|
||||
- platform: blacksmith-4vcpu-ubuntu-2404
|
||||
- platform: namespace-profile-medium-amd64
|
||||
artifact-target-name: x86_64-unknown-linux-gnu
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
with:
|
||||
rustflags: ''
|
||||
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
|
||||
@@ -91,14 +83,25 @@ jobs:
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Set up caches
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: 'pnpm'
|
||||
cache: |
|
||||
rust
|
||||
pnpm
|
||||
|
||||
- name: Configure sccache
|
||||
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'macos')
|
||||
run: nsc cache sccache setup --cache_name default >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate tauri-dev.conf.json
|
||||
shell: bash
|
||||
@@ -117,10 +120,15 @@ jobs:
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
packages: libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
version: v1 # cache key
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Dasel
|
||||
uses: jaxxstorm/action-install-gh-release@25e24d2d23ae098373794ef1d6faecb48ee52da8 # v3.0.0
|
||||
@@ -147,9 +155,6 @@ jobs:
|
||||
|
||||
cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env
|
||||
|
||||
- name: Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: namespace-profile-medium-amd64
|
||||
env:
|
||||
VERSION_TAG: ${{ github.event.workflow_run.head_branch }}
|
||||
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Verify ref is a tag
|
||||
env:
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Check if workflow runs on an internal branch
|
||||
id: check-internal
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
build:
|
||||
name: Lint and Test
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'namespace-profile-modrinth-turbo' || 'ubuntu-latest' }}
|
||||
needs: [skip-if-clean]
|
||||
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
|
||||
|
||||
@@ -76,54 +76,47 @@ jobs:
|
||||
# since we don't want warnings to become errors
|
||||
# while developing)
|
||||
RUSTFLAGS: -Dwarnings
|
||||
# sccache config (only populated for internal branches; secrets aren't
|
||||
# available to forked PRs, and blacksmith stickydisk requires a
|
||||
# blacksmith runner)
|
||||
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
|
||||
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
|
||||
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
|
||||
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
|
||||
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
|
||||
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
|
||||
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
|
||||
# Redis config for tests
|
||||
REDIS_TOPOLOGY: cluster
|
||||
REDIS_CONNECTION_TYPE: multiplexed
|
||||
REDIS_URL: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
|
||||
# Avoid stack overflows in tests
|
||||
RUST_MIN_STACK: 134217728
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Install build dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: cmake libcurl4-openssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
version: v2 # cache key
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Get pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Restore pnpm cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
- name: Set up caches
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.store-path }}
|
||||
key: pnpm-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-cache-
|
||||
cache: |
|
||||
rust
|
||||
pnpm
|
||||
apt
|
||||
|
||||
- name: Set up Turborepo caching
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: namespace-actions/setup-turbocache@f96bd6befa13ae74b2858a5be49573640d4636f5 # v0.0.2
|
||||
|
||||
- name: Install apt dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake libcurl4-openssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
with:
|
||||
rustflags: ''
|
||||
components: clippy, rustfmt
|
||||
@@ -132,28 +125,8 @@ jobs:
|
||||
- name: Setup mold
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Cache Cargo registry and index
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae #v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
~/.cargo/bin
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Mount sccache disk cache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
|
||||
with:
|
||||
key: ${{ github.repository }}-turbo-sccache
|
||||
path: /mnt/sccache
|
||||
|
||||
- name: Setup sccache
|
||||
if: needs.skip-if-clean.outputs.internal == 'true'
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: Setup binstall
|
||||
uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
|
||||
uses: cargo-bins/cargo-binstall@ead08b90bd7b2e6d81963fb9cf0b7239f66d5db4 # v1.21.0
|
||||
|
||||
- name: Setup nextest
|
||||
run: cargo binstall --no-confirm --secure --locked cargo-nextest@0.9.133
|
||||
@@ -161,16 +134,13 @@ jobs:
|
||||
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
|
||||
# back to a cached cargo install
|
||||
- name: Setup cargo-sqlx
|
||||
uses: taiki-e/cache-cargo-install-action@f9eed3e4680f27610dc6d8c67be1b88593f7dade # v3.0.6
|
||||
uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3.0.7
|
||||
with:
|
||||
tool: sqlx-cli@0.8.6
|
||||
locked: false
|
||||
no-default-features: true
|
||||
features: rustls,postgres
|
||||
|
||||
- name: Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
@@ -192,7 +162,7 @@ jobs:
|
||||
|
||||
- name: Start services
|
||||
if: steps.check-labrinth.outputs.needs_services == 'true'
|
||||
run: docker compose up --wait
|
||||
run: docker compose --profile clustered-redis up --wait
|
||||
|
||||
- name: Setup labrinth environment and database
|
||||
if: steps.check-labrinth.outputs.needs_services == 'true'
|
||||
|
||||
@@ -59,9 +59,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "actix-http"
|
||||
version = "3.11.2"
|
||||
version = "3.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49"
|
||||
checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2"
|
||||
dependencies = [
|
||||
"actix-codec",
|
||||
"actix-rt",
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"foldhash 0.1.5",
|
||||
"foldhash 0.2.0",
|
||||
"futures-core",
|
||||
"h2 0.3.27",
|
||||
"http 0.2.12",
|
||||
@@ -87,8 +87,8 @@ dependencies = [
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"rand 0.10.1",
|
||||
"sha1 0.11.0",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -146,9 +146,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "actix-router"
|
||||
version = "0.5.3"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8"
|
||||
checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7"
|
||||
dependencies = [
|
||||
"bytestring",
|
||||
"cfg-if",
|
||||
@@ -209,9 +209,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "actix-web"
|
||||
version = "4.11.0"
|
||||
version = "4.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a597b77b5c6d6a1e1097fddde329a83665e25c5437c696a3a9a4aa514a614dea"
|
||||
checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4"
|
||||
dependencies = [
|
||||
"actix-codec",
|
||||
"actix-http",
|
||||
@@ -228,7 +228,7 @@ dependencies = [
|
||||
"cookie 0.16.2",
|
||||
"derive_more 2.1.1",
|
||||
"encoding_rs",
|
||||
"foldhash 0.1.5",
|
||||
"foldhash 0.2.0",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"impl-more",
|
||||
@@ -244,7 +244,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"smallvec",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.5",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -458,6 +458,12 @@ version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "arg_enum_proc_macro"
|
||||
version = "0.3.4"
|
||||
@@ -1127,7 +1133,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"md-5",
|
||||
"pin-project-lite",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1499,7 +1505,7 @@ version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1511,6 +1517,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -1619,6 +1634,31 @@ dependencies = [
|
||||
"serde_with",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bon"
|
||||
version = "3.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561"
|
||||
dependencies = [
|
||||
"bon-macros",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bon-macros"
|
||||
version = "3.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"ident_case",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.5.7"
|
||||
@@ -2099,6 +2139,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color-eyre"
|
||||
version = "0.6.5"
|
||||
@@ -2224,6 +2273,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -2424,11 +2479,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d"
|
||||
dependencies = [
|
||||
"crc",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"rustversion",
|
||||
"spin 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc16"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -2515,6 +2576,15 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.29.6"
|
||||
@@ -2743,7 +2813,7 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
|
||||
source = "git+https://github.com/modrinth/deadpool?rev=c7209b086829572f368f1194aba54bdea8dc540b#c7209b086829572f368f1194aba54bdea8dc540b"
|
||||
dependencies = [
|
||||
"deadpool-runtime",
|
||||
"num_cpus",
|
||||
@@ -2753,8 +2823,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deadpool-redis"
|
||||
version = "0.22.1"
|
||||
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
|
||||
version = "0.23.0"
|
||||
source = "git+https://github.com/modrinth/deadpool?rev=c7209b086829572f368f1194aba54bdea8dc540b#c7209b086829572f368f1194aba54bdea8dc540b"
|
||||
dependencies = [
|
||||
"deadpool",
|
||||
"redis",
|
||||
@@ -2764,7 +2834,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "deadpool-runtime"
|
||||
version = "0.1.5"
|
||||
source = "git+https://github.com/modrinth/deadpool?rev=db5fb00b036ecc8fe5f18853c559b745ffe47bde#db5fb00b036ecc8fe5f18853c559b745ffe47bde"
|
||||
source = "git+https://github.com/modrinth/deadpool?rev=c7209b086829572f368f1194aba54bdea8dc540b#c7209b086829572f368f1194aba54bdea8dc540b"
|
||||
dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
@@ -2791,7 +2861,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2904,12 +2974,23 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "6.0.0"
|
||||
@@ -3127,7 +3208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||
dependencies = [
|
||||
"der",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"elliptic-curve",
|
||||
"rfc6979",
|
||||
"signature",
|
||||
@@ -3151,7 +3232,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"crypto-bigint",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array",
|
||||
"group",
|
||||
@@ -3199,6 +3280,18 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
@@ -3775,6 +3868,12 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.31"
|
||||
@@ -4086,6 +4185,29 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "governor"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dashmap",
|
||||
"futures-sink",
|
||||
"futures-timer",
|
||||
"futures-util",
|
||||
"getrandom 0.3.3",
|
||||
"hashbrown 0.16.0",
|
||||
"nonzero_ext",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"quanta",
|
||||
"rand 0.9.2",
|
||||
"smallvec",
|
||||
"spinning_top",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -4402,7 +4524,7 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4532,6 +4654,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.32"
|
||||
@@ -4675,7 +4806,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
@@ -4893,9 +5024,9 @@ checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||
|
||||
[[package]]
|
||||
name = "impl-more"
|
||||
version = "0.1.9"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
|
||||
checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
|
||||
|
||||
[[package]]
|
||||
name = "indenter"
|
||||
@@ -5016,17 +5147,6 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.2"
|
||||
@@ -5345,13 +5465,13 @@ dependencies = [
|
||||
"bytes",
|
||||
"censor",
|
||||
"chrono",
|
||||
"chumsky",
|
||||
"clap 4.5.48",
|
||||
"clickhouse",
|
||||
"color-eyre",
|
||||
"color-thief",
|
||||
"const_format",
|
||||
"dashmap",
|
||||
"deadpool-redis",
|
||||
"derive_more 2.1.1",
|
||||
"dotenv-build",
|
||||
"dotenvy",
|
||||
@@ -5376,8 +5496,10 @@ dependencies = [
|
||||
"modrinth-util",
|
||||
"muralpay",
|
||||
"murmur2",
|
||||
"neverbounce",
|
||||
"paste",
|
||||
"path-util",
|
||||
"postcard",
|
||||
"prometheus",
|
||||
"quick-xml 0.38.3",
|
||||
"rand 0.8.5",
|
||||
@@ -5390,12 +5512,14 @@ dependencies = [
|
||||
"rust_iso3166",
|
||||
"rustls 0.23.32",
|
||||
"rusty-money",
|
||||
"scalar_api_reference",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"spdx",
|
||||
"sqlx",
|
||||
"sqlx-tracing",
|
||||
@@ -5411,14 +5535,13 @@ dependencies = [
|
||||
"url",
|
||||
"urlencoding",
|
||||
"utoipa",
|
||||
"utoipa-actix-web",
|
||||
"utoipa-scalar",
|
||||
"uuid 1.23.3",
|
||||
"validator",
|
||||
"webauthn-rs",
|
||||
"webauthn-rs-proto",
|
||||
"webp",
|
||||
"woothee",
|
||||
"xredis",
|
||||
"yaserde",
|
||||
"zip 6.0.0",
|
||||
"zxcvbn",
|
||||
@@ -5484,7 +5607,7 @@ dependencies = [
|
||||
"quoted_printable",
|
||||
"rustls 0.23.32",
|
||||
"rustls-native-certs 0.8.1",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.5",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"url",
|
||||
@@ -5522,9 +5645,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.177"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
@@ -5822,7 +5945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5938,14 +6061,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.0.4"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6156,6 +6279,14 @@ dependencies = [
|
||||
"jni-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neverbounce"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.24",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@@ -6200,6 +6331,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "noop_proc_macro"
|
||||
version = "0.3.0"
|
||||
@@ -7424,6 +7561,18 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"embedded-io 0.4.0",
|
||||
"embedded-io 0.6.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.3"
|
||||
@@ -7788,6 +7937,21 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quanta"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"raw-cpuid",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"web-sys",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quartz_nbt"
|
||||
version = "0.2.9"
|
||||
@@ -7852,7 +8016,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.32",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -7889,7 +8053,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -8115,6 +8279,15 @@ dependencies = [
|
||||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -8174,26 +8347,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.32.7"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
|
||||
checksum = "b0b9503711b03773e43b31668c7b5bd279ee7cd9b7d18cff7c23a42cc1d08e5a"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"arcstr",
|
||||
"async-lock",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"crc16",
|
||||
"futures-util",
|
||||
"itoa",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"r2d2",
|
||||
"rand 0.10.1",
|
||||
"ryu",
|
||||
"sha1_smol",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.5",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8494,8 +8673,8 @@ version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
@@ -8508,6 +8687,40 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
|
||||
dependencies = [
|
||||
"rust-embed-impl",
|
||||
"rust-embed-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-impl"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rust-embed-utils",
|
||||
"syn 2.0.106",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-embed-utils"
|
||||
version = "8.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
@@ -8827,6 +9040,18 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scalar_api_reference"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d67c85edd56c41f364603eff688a330f01dee99bdfc84c005d3bc3d0721d6b2"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scc"
|
||||
version = "2.4.0"
|
||||
@@ -9252,15 +9477,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.145"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9455,7 +9680,18 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9472,7 +9708,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9505,7 +9741,7 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
@@ -9589,12 +9825,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.1"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9669,6 +9905,15 @@ version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
|
||||
|
||||
[[package]]
|
||||
name = "spinning_top"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
@@ -9782,7 +10027,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"futures-channel",
|
||||
@@ -9803,7 +10048,7 @@ dependencies = [
|
||||
"rsa",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
@@ -10749,6 +10994,7 @@ dependencies = [
|
||||
"async-walkdir",
|
||||
"async_zip",
|
||||
"base64 0.22.1",
|
||||
"bon",
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
"chardetng",
|
||||
@@ -10770,8 +11016,10 @@ dependencies = [
|
||||
"fs4",
|
||||
"futures",
|
||||
"futures-lite 2.6.1",
|
||||
"governor",
|
||||
"heck 0.5.0",
|
||||
"hickory-resolver 0.25.2",
|
||||
"httpdate",
|
||||
"indicatif",
|
||||
"itertools 0.14.0",
|
||||
"modrinth-content-management",
|
||||
@@ -11032,30 +11280,27 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.47.1"
|
||||
version = "1.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038"
|
||||
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
"io-uring",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.5",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.5.0"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
|
||||
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -11259,7 +11504,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2 0.6.1",
|
||||
"socket2 0.6.5",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
@@ -11290,7 +11535,7 @@ dependencies = [
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
@@ -11505,7 +11750,7 @@ dependencies = [
|
||||
"rand 0.9.2",
|
||||
"rustls 0.23.32",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"thiserror 2.0.17",
|
||||
"utf-8",
|
||||
]
|
||||
@@ -11524,9 +11769,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
@@ -11757,17 +12002,6 @@ dependencies = [
|
||||
"utoipa-gen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-actix-web"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f"
|
||||
dependencies = [
|
||||
"actix-service",
|
||||
"actix-web",
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-gen"
|
||||
version = "5.4.0"
|
||||
@@ -11782,18 +12016,6 @@ dependencies = [
|
||||
"uuid 1.23.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utoipa-scalar"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
|
||||
dependencies = [
|
||||
"actix-web",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "0.8.2"
|
||||
@@ -13212,6 +13434,31 @@ version = "0.13.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
|
||||
|
||||
[[package]]
|
||||
name = "xredis"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"deadpool-redis",
|
||||
"futures",
|
||||
"lz4_flex",
|
||||
"prometheus",
|
||||
"redis",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985eec839aaf2a1270af8f4ebcf63cf9401cfd90f0902f97c28d9f104ffbde72"
|
||||
|
||||
[[package]]
|
||||
name = "yaserde"
|
||||
version = "0.12.0"
|
||||
@@ -13450,6 +13697,12 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.2"
|
||||
|
||||
@@ -13,7 +13,9 @@ members = [
|
||||
"packages/modrinth-log",
|
||||
"packages/modrinth-maxmind",
|
||||
"packages/modrinth-util",
|
||||
"packages/neverbounce",
|
||||
"packages/path-util",
|
||||
"packages/xredis",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -51,11 +53,13 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [
|
||||
] }
|
||||
base64 = "0.22.1"
|
||||
bitflags = "2.9.4"
|
||||
bon = "3.9.3"
|
||||
bytemuck = "1.24.0"
|
||||
bytes = "1.10.1"
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.42"
|
||||
chumsky = "0.9.3"
|
||||
cidre = { version = "0.15.0", default-features = false, features = [
|
||||
"macos_15_0"
|
||||
] }
|
||||
@@ -70,7 +74,7 @@ daedalus = { path = "packages/daedalus" }
|
||||
darling = { version = "0.23" }
|
||||
dashmap = "6.1.0"
|
||||
data-url = "0.3.2"
|
||||
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" }
|
||||
deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "c7209b086829572f368f1194aba54bdea8dc540b", version = "0.23.0" }
|
||||
derive_more = "2.1.1"
|
||||
directories = "6.0.0"
|
||||
dirs = "6.0.0"
|
||||
@@ -87,10 +91,12 @@ fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-lite = "2.6.1"
|
||||
futures-util = "0.3.31"
|
||||
governor = "0.10.4"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
httpdate = "1.0.3"
|
||||
hyper = "1.7.0"
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||
"aws-lc-rs",
|
||||
@@ -117,6 +123,12 @@ lettre = { version = "0.11.19", default-features = false, features = [
|
||||
"tokio1",
|
||||
"tokio1-rustls",
|
||||
] }
|
||||
lz4_flex = { version = "0.11.5", default-features = false, features = [
|
||||
"checked-decode",
|
||||
"safe-decode",
|
||||
"safe-encode",
|
||||
"std",
|
||||
] }
|
||||
maxminddb = "0.26.0"
|
||||
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
||||
modrinth-content-management = { path = "packages/modrinth-content-management" }
|
||||
@@ -125,6 +137,7 @@ modrinth-util = { path = "packages/modrinth-util" }
|
||||
muralpay = { path = "packages/muralpay" }
|
||||
murmur2 = "0.1.0"
|
||||
native-dialog = "0.9.2"
|
||||
neverbounce = { path = "packages/neverbounce" }
|
||||
notify = { version = "8.2.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.7.0", default-features = false }
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false }
|
||||
@@ -134,6 +147,7 @@ paste = "1.0.15"
|
||||
path-util = { path = "packages/path-util" }
|
||||
phf = { version = "0.13.1", features = ["macros"] }
|
||||
png = "0.18.0"
|
||||
postcard = { version = "1.1.3", default-features = false, features = ["alloc"] }
|
||||
proc-macro2 = { version = "1.0" }
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
@@ -142,7 +156,7 @@ quote = { version = "1.0" }
|
||||
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||
rdkafka = { version = "0.36.2", features = ["cmake-build"] }
|
||||
redis = "0.32.7"
|
||||
redis = "1.4.1"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.24", default-features = false }
|
||||
rgb = "0.8.52"
|
||||
@@ -158,6 +172,7 @@ rust-s3 = { version = "0.37.0", default-features = false, features = [
|
||||
] }
|
||||
rustls = "0.23.32"
|
||||
rusty-money = "0.4.1"
|
||||
scalar_api_reference = { version = "0.2.2", default-features = false }
|
||||
secrecy = "0.10.3"
|
||||
sentry = { version = "0.45.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
@@ -178,6 +193,7 @@ sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
sha2 = "0.10.9"
|
||||
shlex = "1.3.0"
|
||||
smallvec = "1.15.1"
|
||||
spdx = "0.12.0"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sqlx-tracing = { path = "packages/sqlx-tracing" }
|
||||
@@ -217,8 +233,6 @@ typed-path = "0.12.0"
|
||||
url = "2.5.7"
|
||||
urlencoding = "2.1.3"
|
||||
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "decimal"] }
|
||||
utoipa-actix-web = { version = "0.1.2" }
|
||||
utoipa-scalar = { version = "0.3.0", default-features = false }
|
||||
uuid = "1.18.1"
|
||||
validator = "0.20.0"
|
||||
webauthn-rs = "0.5.5"
|
||||
@@ -230,6 +244,7 @@ windows = "=0.61.3" # Locked on 0.61 until we can update windows-core to 0.62
|
||||
windows-core = "=0.61.2" # Locked on 0.61 until webview2-com updates to 0.62
|
||||
winreg = "0.55.0"
|
||||
woothee = "0.13.0"
|
||||
xredis = { path = "packages/xredis" }
|
||||
yaserde = "0.12.0"
|
||||
zbus = "5.11.0"
|
||||
zip = { version = "6.0.0", default-features = false, features = [
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Modrinth Monorepo
|
||||
|
||||
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors!
|
||||
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains  lines of code and has  contributors!
|
||||
|
||||
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ extend-exclude = [
|
||||
"packages/blog/",
|
||||
# contains licenses like `CC-BY-ND-4.0`
|
||||
"packages/moderation/src/data/stages/license.ts",
|
||||
"packages/moderation/src/utils.ts",
|
||||
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
|
||||
"apps/labrinth/src/queue/payouts/mod.rs",
|
||||
# contains domain names with deliberate typos
|
||||
"apps/labrinth/assets/disposable_email_blocklist.txt"
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/vue": "^0.5.0",
|
||||
"@modrinth/api-client": "workspace:^",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
|
||||
@@ -13,13 +13,12 @@ import {
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
ExternalIcon,
|
||||
HomeIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
NewspaperIcon,
|
||||
NotepadTextIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
RightArrowIcon,
|
||||
@@ -76,31 +75,47 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
|
||||
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
||||
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
|
||||
import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
|
||||
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { config } from '@/config'
|
||||
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
import {
|
||||
ads_consent_listener,
|
||||
hide_ads_window,
|
||||
init_ads_window,
|
||||
perform_ads_consent_action,
|
||||
should_show_ads_consent_popup,
|
||||
show_ads_window,
|
||||
} from '@/helpers/ads.js'
|
||||
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable } from '@/helpers/auth.js'
|
||||
import { get_user, get_version } from '@/helpers/cache.js'
|
||||
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
|
||||
import { list, run } from '@/helpers/instance'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||
import {
|
||||
can_current_user_use_shared_instances,
|
||||
get as getInstance,
|
||||
list,
|
||||
run,
|
||||
} from '@/helpers/instance'
|
||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||
import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
|
||||
import { parse_modrinth_user_link } from '@/helpers/users'
|
||||
import {
|
||||
areUpdatesEnabled,
|
||||
enqueueUpdateForInstallation,
|
||||
@@ -147,6 +162,7 @@ const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
|
||||
const credentials = ref()
|
||||
let credentialsRefreshId = 0
|
||||
const sidebarToggled = ref(true)
|
||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||
sidebarToggled.value = !themeStore.toggleSidebar
|
||||
@@ -184,12 +200,15 @@ const { handleError, addNotification } = notificationManager
|
||||
const popupNotificationManager = new AppPopupNotificationManager()
|
||||
providePopupNotificationManager(popupNotificationManager)
|
||||
const { addPopupNotification } = popupNotificationManager
|
||||
let adsConsentPopupId = null
|
||||
let unlistenAdsConsent
|
||||
|
||||
const appVersion = getVersion()
|
||||
const tauriApiClient = new TauriModrinthClient({
|
||||
userAgent: async () => `modrinth/theseus/${await appVersion} (support@modrinth.com)`,
|
||||
labrinthBaseUrl: config.labrinthBaseUrl,
|
||||
archonBaseUrl: config.archonBaseUrl,
|
||||
sharedInstancesBaseUrl: config.sharedInstancesBaseUrl,
|
||||
features: [
|
||||
new NodeAuthFeature({
|
||||
getAuth: () => nodeAuthState.getAuth?.() ?? null,
|
||||
@@ -213,9 +232,30 @@ const { data: authenticatedModrinthUser } = useQuery({
|
||||
enabled: () => !!credentials.value?.session,
|
||||
retry: false,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]),
|
||||
queryFn: can_current_user_use_shared_instances,
|
||||
enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
const hasPlus = computed(
|
||||
() =>
|
||||
!!credentials.value?.user &&
|
||||
(hasMidasBadge(credentials.value.user) ||
|
||||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||
)
|
||||
const showAd = computed(
|
||||
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||
)
|
||||
const adConsentAvailable = computed(() => credentials.value !== undefined && !hasPlus.value)
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(true),
|
||||
showAds: ref(false),
|
||||
showAds: showAd,
|
||||
adConsentAvailable,
|
||||
floatingActionBarOffsets: {
|
||||
left: ref(APP_LEFT_NAV_WIDTH),
|
||||
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
|
||||
@@ -226,7 +266,7 @@ providePageContext({
|
||||
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
|
||||
),
|
||||
},
|
||||
openExternalUrl: (url) => openUrl(url),
|
||||
openExternalUrl: (url) => void openUrl(url),
|
||||
})
|
||||
provideModalBehavior({
|
||||
noblur: computed(() => !themeStore.advancedRendering),
|
||||
@@ -240,17 +280,22 @@ const {
|
||||
fetchExistingInstanceNames,
|
||||
handleCreate,
|
||||
handleBrowseModpacks,
|
||||
searchModpacks,
|
||||
getProjectVersions,
|
||||
searchProjects,
|
||||
getLoaderManifest,
|
||||
setModpackAlreadyInstalledModal,
|
||||
handleModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance,
|
||||
onboardingChecklist,
|
||||
} = setupProviders(notificationManager, popupNotificationManager)
|
||||
const { hasLoggedIntoMinecraft, hasLoggedIntoModrinth, showChecklist } = onboardingChecklist
|
||||
const showFriendsList = computed(() => !showChecklist.value || hasLoggedIntoModrinth.value)
|
||||
|
||||
const news = ref([])
|
||||
const availableSurvey = ref(false)
|
||||
const displayedServerInviteNotifications = new Set()
|
||||
const serverInvitePopupNotificationIds = new Set()
|
||||
let liveNotificationGeneration = 0
|
||||
let liveNotificationsEnabled = true
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
@@ -260,7 +305,6 @@ window.addEventListener('online', () => {
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
const showOnboarding = ref(false)
|
||||
const nativeDecorations = ref(false)
|
||||
|
||||
const os = ref('')
|
||||
@@ -295,6 +339,12 @@ const authUnreachable = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
await useCheckDisableMouseover()
|
||||
try {
|
||||
unlistenAdsConsent = await ads_consent_listener(handleAdsConsentRequired)
|
||||
handleAdsConsentRequired(await should_show_ads_consent_popup())
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
document.querySelector('body').addEventListener('click', handleClick)
|
||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||
@@ -308,6 +358,7 @@ onUnmounted(async () => {
|
||||
unsubscribeSidebarToggle()
|
||||
clearDelayedUpdatePopup()
|
||||
|
||||
await unlistenAdsConsent?.()
|
||||
await unlistenUpdateDownload?.()
|
||||
})
|
||||
|
||||
@@ -332,9 +383,80 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'Minecraft authentication servers may be down right now. Check your internet connection and try again later.',
|
||||
},
|
||||
adsConsentTitle: {
|
||||
id: 'app.ads-consent.title',
|
||||
defaultMessage: 'Your privacy and how ads support Modrinth',
|
||||
},
|
||||
adsConsentBody: {
|
||||
id: 'app.ads-consent.body',
|
||||
defaultMessage:
|
||||
'Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance.',
|
||||
},
|
||||
adsConsentManage: {
|
||||
id: 'app.ads-consent.manage',
|
||||
defaultMessage: 'Manage preferences',
|
||||
},
|
||||
adsConsentReject: {
|
||||
id: 'app.ads-consent.reject',
|
||||
defaultMessage: 'Reject all',
|
||||
},
|
||||
adsConsentAccept: {
|
||||
id: 'app.ads-consent.accept',
|
||||
defaultMessage: 'Accept all',
|
||||
},
|
||||
})
|
||||
|
||||
function handleAdsConsentRequired(required) {
|
||||
if (!required) {
|
||||
if (adsConsentPopupId !== null) {
|
||||
popupNotificationManager.removeNotification(adsConsentPopupId)
|
||||
adsConsentPopupId = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
adsConsentPopupId !== null &&
|
||||
popupNotificationManager.getNotifications().some((item) => item.id === adsConsentPopupId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const notification = addPopupNotification({
|
||||
title: formatMessage(messages.adsConsentTitle),
|
||||
text: formatMessage(messages.adsConsentBody),
|
||||
type: 'info',
|
||||
hideIcon: true,
|
||||
autoCloseMs: null,
|
||||
dismissible: false,
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(messages.adsConsentManage),
|
||||
action: () => perform_ads_consent_action('manage').catch(handleError),
|
||||
color: 'standard',
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.adsConsentReject),
|
||||
action: () => perform_ads_consent_action('reject').catch(handleError),
|
||||
color: 'brand',
|
||||
keepOpen: true,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.adsConsentAccept),
|
||||
action: () => perform_ads_consent_action('accept').catch(handleError),
|
||||
color: 'brand',
|
||||
keepOpen: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
adsConsentPopupId = notification.id
|
||||
}
|
||||
|
||||
async function setupApp() {
|
||||
await onboardingChecklist.initialize()
|
||||
|
||||
const {
|
||||
native_decorations,
|
||||
theme,
|
||||
@@ -343,8 +465,6 @@ async function setupApp() {
|
||||
collapsed_navigation,
|
||||
hide_nametag_skins_page,
|
||||
advanced_rendering,
|
||||
onboarded,
|
||||
default_page,
|
||||
toggle_sidebar,
|
||||
developer_mode,
|
||||
feature_flags,
|
||||
@@ -356,16 +476,10 @@ async function setupApp() {
|
||||
i18n.global.locale.value = locale
|
||||
}
|
||||
|
||||
if (default_page === 'Library') {
|
||||
await router.push('/library')
|
||||
}
|
||||
|
||||
os.value = await getOS()
|
||||
const dev = await isDev()
|
||||
isDevEnvironment.value = dev
|
||||
const version = await getVersion()
|
||||
showOnboarding.value = !onboarded
|
||||
|
||||
nativeDecorations.value = native_decorations
|
||||
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
|
||||
|
||||
@@ -387,7 +501,7 @@ async function setupApp() {
|
||||
if (telemetry) {
|
||||
initAnalytics()
|
||||
if (dev) debugAnalytics()
|
||||
trackEvent('Launched', { version, dev, onboarded })
|
||||
trackEvent('Launched', { version, dev })
|
||||
}
|
||||
|
||||
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
|
||||
@@ -403,7 +517,7 @@ async function setupApp() {
|
||||
addNotification({
|
||||
title: 'Warning',
|
||||
text: e.message,
|
||||
type: 'warn',
|
||||
type: 'warning',
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -588,6 +702,7 @@ watch(stateInitialized, (ready) => {
|
||||
const error = useError()
|
||||
const errorModal = ref()
|
||||
const minecraftAuthErrorModal = ref()
|
||||
const minecraftRequiredModal = ref()
|
||||
|
||||
const contentInstall = createContentInstall({ router, handleError })
|
||||
provideContentInstall(contentInstall)
|
||||
@@ -603,6 +718,7 @@ const {
|
||||
projectInfo: contentInstallProjectInfo,
|
||||
handleInstallToInstance,
|
||||
handleCreateAndInstall,
|
||||
prepareNewInstance,
|
||||
handleNavigate: handleContentInstallNavigate,
|
||||
handleCancel: handleContentInstallCancel,
|
||||
setContentInstallModal,
|
||||
@@ -622,6 +738,35 @@ const {
|
||||
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
|
||||
} = contentInstall
|
||||
|
||||
async function prepareCreationProjectInstall(projectId, projectType) {
|
||||
if (projectType === 'modpack') {
|
||||
await contentInstall.install(
|
||||
projectId,
|
||||
null,
|
||||
null,
|
||||
'CreationModalProject',
|
||||
undefined,
|
||||
(instanceId) => void router.push(`/instance/${encodeURIComponent(instanceId)}`),
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
await prepareNewInstance(projectId)
|
||||
const info = contentInstallProjectInfo.value
|
||||
if (!info) throw new Error(`Project information is unavailable: '${projectId}'`)
|
||||
|
||||
return {
|
||||
projectId,
|
||||
title: info.title,
|
||||
iconUrl: info.iconUrl,
|
||||
link: info.link,
|
||||
owner: info.owner,
|
||||
compatibleLoaders: [...contentInstallLoaders.value],
|
||||
gameVersions: [...contentInstallGameVersions.value],
|
||||
releaseGameVersions: new Set(contentInstallReleaseGameVersions.value),
|
||||
}
|
||||
}
|
||||
|
||||
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
|
||||
provideServerInstall(serverInstall)
|
||||
const {
|
||||
@@ -637,9 +782,10 @@ const contentInstallModpackAlreadyInstalledModal = ref()
|
||||
const addServerToInstanceModal = ref()
|
||||
const incompatibilityWarningModal = ref()
|
||||
const installToPlayModal = ref()
|
||||
const sharedInstanceInviteHandler = ref()
|
||||
const updateToPlayModal = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
const modrinthLoginModal = ref()
|
||||
|
||||
watch(incompatibilityWarningModal, (modal) => {
|
||||
if (modal) {
|
||||
@@ -647,8 +793,12 @@ watch(incompatibilityWarningModal, (modal) => {
|
||||
}
|
||||
})
|
||||
|
||||
setupAuthProvider(credentials, async (_redirectPath) => {
|
||||
await signIn()
|
||||
setupAuthProvider(credentials, async (_redirectPath, flow, options) => {
|
||||
if (options?.showModal === false) {
|
||||
await signIn(flow)
|
||||
} else {
|
||||
await requestSignIn(flow)
|
||||
}
|
||||
})
|
||||
|
||||
async function validateSession(sessionToken) {
|
||||
@@ -665,23 +815,35 @@ async function validateSession(sessionToken) {
|
||||
}
|
||||
|
||||
async function fetchCredentials() {
|
||||
const hadSession = !!credentials.value?.session
|
||||
const refreshId = ++credentialsRefreshId
|
||||
credentials.value = undefined
|
||||
|
||||
const creds = await getCreds().catch(handleError)
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
if (!creds && hadSession) clearLiveNotifications()
|
||||
|
||||
if (creds && creds.user_id) {
|
||||
if (creds.session && !(await validateSession(creds.session))) {
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
|
||||
clearLiveNotifications()
|
||||
await logout().catch(handleError)
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
|
||||
credentials.value = null
|
||||
return
|
||||
}
|
||||
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
|
||||
if (refreshId !== credentialsRefreshId) return
|
||||
}
|
||||
credentials.value = creds ?? null
|
||||
liveNotificationsEnabled = !!creds?.session
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
modrinthLoginFlowWaitModal.value.show()
|
||||
|
||||
async function signIn(flow = 'sign-in') {
|
||||
try {
|
||||
await login()
|
||||
await login(flow)
|
||||
await fetchCredentials()
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -693,27 +855,31 @@ async function signIn() {
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
} finally {
|
||||
modrinthLoginFlowWaitModal.value.hide()
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSignIn(flow = 'sign-in') {
|
||||
await modrinthLoginModal.value?.showSigningIn(flow)
|
||||
}
|
||||
|
||||
async function requestModrinthAuth(flow = 'sign-in') {
|
||||
await signIn(flow)
|
||||
return !!credentials.value?.session
|
||||
}
|
||||
|
||||
async function logOut() {
|
||||
await performLogOut()
|
||||
}
|
||||
|
||||
async function performLogOut() {
|
||||
credentialsRefreshId++
|
||||
credentials.value = undefined
|
||||
clearLiveNotifications()
|
||||
|
||||
await logout().catch(handleError)
|
||||
await fetchCredentials()
|
||||
}
|
||||
|
||||
const hasPlus = computed(
|
||||
() =>
|
||||
!!credentials.value?.user &&
|
||||
(hasMidasBadge(credentials.value.user) ||
|
||||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||
)
|
||||
|
||||
const showAd = computed(
|
||||
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||
)
|
||||
|
||||
async function fetchIntercomToken() {
|
||||
const creds = await getCreds()
|
||||
if (!creds?.session) {
|
||||
@@ -740,19 +906,28 @@ async function fetchIntercomToken() {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
watch(showAd, () => {
|
||||
if (!showAd.value) {
|
||||
hide_ads_window(true)
|
||||
} else {
|
||||
init_ads_window(true)
|
||||
}
|
||||
})
|
||||
watch(
|
||||
[showAd, adConsentAvailable],
|
||||
async ([showAds, canManageConsent]) => {
|
||||
if (showAds) {
|
||||
await init_ads_window(true)
|
||||
return
|
||||
}
|
||||
|
||||
await hide_ads_window(true)
|
||||
if (canManageConsent) {
|
||||
await init_ads_window()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
invoke('show_window')
|
||||
|
||||
error.setErrorModal(errorModal.value)
|
||||
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
|
||||
error.setMinecraftRequiredModal(minecraftRequiredModal.value)
|
||||
|
||||
setContentIncompatibilityWarningModal(incompatibilityWarningModal.value)
|
||||
setContentInstallModal(modInstallModal.value)
|
||||
@@ -817,34 +992,52 @@ async function declineServerInviteNotification(notification) {
|
||||
|
||||
function openServerInviteInviterProfile(inviterName) {
|
||||
if (!inviterName) return
|
||||
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
|
||||
void router.push(`/user/${encodeURIComponent(inviterName)}`)
|
||||
}
|
||||
|
||||
async function handleLiveNotification(notification) {
|
||||
if (notification?.body?.type !== 'server_invite' || notification.read) return
|
||||
if (displayedServerInviteNotifications.has(notification.id)) return
|
||||
if (!liveNotificationsEnabled || !notification?.body || notification.read) return
|
||||
if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) return
|
||||
|
||||
displayedServerInviteNotifications.add(notification.id)
|
||||
if (notification.body.type === 'server_invite') {
|
||||
if (displayedServerInviteNotifications.has(notification.id)) return
|
||||
|
||||
const serverName =
|
||||
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
|
||||
const inviterId = notification.body.invited_by
|
||||
const invitedBy =
|
||||
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
|
||||
const generation = liveNotificationGeneration
|
||||
displayedServerInviteNotifications.add(notification.id)
|
||||
|
||||
addPopupNotification({
|
||||
title: serverName,
|
||||
autoCloseMs: null,
|
||||
toast: {
|
||||
type: 'server-invite',
|
||||
actorName: invitedBy?.username ?? null,
|
||||
actorAvatarUrl: invitedBy?.avatar_url ?? null,
|
||||
entityName: serverName,
|
||||
onAccept: () => acceptServerInviteNotification(notification),
|
||||
onDecline: () => declineServerInviteNotification(notification),
|
||||
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
|
||||
},
|
||||
})
|
||||
const serverName =
|
||||
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
|
||||
const inviterId = notification.body.invited_by
|
||||
const invitedBy =
|
||||
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
|
||||
if (generation !== liveNotificationGeneration) return
|
||||
|
||||
const popupNotification = addPopupNotification({
|
||||
title: serverName,
|
||||
autoCloseMs: null,
|
||||
toast: {
|
||||
type: 'server-invite',
|
||||
actorName: invitedBy?.username ?? null,
|
||||
actorAvatarUrl: invitedBy?.avatar_url ?? null,
|
||||
entityName: serverName,
|
||||
onAccept: () => acceptServerInviteNotification(notification),
|
||||
onDecline: () => declineServerInviteNotification(notification),
|
||||
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
|
||||
},
|
||||
})
|
||||
serverInvitePopupNotificationIds.add(popupNotification.id)
|
||||
}
|
||||
}
|
||||
|
||||
function clearLiveNotifications() {
|
||||
liveNotificationGeneration++
|
||||
liveNotificationsEnabled = false
|
||||
for (const id of serverInvitePopupNotificationIds) {
|
||||
popupNotificationManager.removeNotification(id)
|
||||
}
|
||||
displayedServerInviteNotifications.clear()
|
||||
serverInvitePopupNotificationIds.clear()
|
||||
sharedInstanceInviteHandler.value?.clearNotifications()
|
||||
}
|
||||
|
||||
async function handleCommand(e) {
|
||||
@@ -855,12 +1048,13 @@ async function handleCommand(e) {
|
||||
if (e.path.endsWith('.mrpack')) {
|
||||
const location = { type: 'fromFile', path: e.path }
|
||||
const preview = await install_get_modpack_preview(location).catch(handleError)
|
||||
if (preview?.unknownFile) {
|
||||
if (preview?.unknownFile || preview?.externalFilesInModpack.length > 0) {
|
||||
const splitPath = e.path.split(/[\\/]/)
|
||||
const fileName = splitPath ? splitPath[splitPath.length - 1] : e.path
|
||||
unknownPackWarningModal.value?.show(
|
||||
() => install_create_modpack_instance(location).then(() => undefined),
|
||||
fileName,
|
||||
preview.externalFilesInModpack,
|
||||
)
|
||||
} else {
|
||||
await install_create_modpack_instance(location).catch(handleError)
|
||||
@@ -870,6 +1064,9 @@ async function handleCommand(e) {
|
||||
})
|
||||
}
|
||||
} else if (e.event === 'LaunchInstance') {
|
||||
const instance = await getInstance(e.id).catch(handleError)
|
||||
if (!instance || instance.quarantined) return
|
||||
|
||||
if (e.server) {
|
||||
await start_join_server(e.id, e.server).catch(handleError)
|
||||
} else if (e.singleplayer_world) {
|
||||
@@ -877,6 +1074,8 @@ async function handleCommand(e) {
|
||||
} else {
|
||||
await run(e.id).catch(handleError)
|
||||
}
|
||||
} else if (e.event === 'InstallSharedInstanceInvite') {
|
||||
await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
|
||||
} else if (e.event === 'InstallServer') {
|
||||
await router.push(`/project/${e.id}`)
|
||||
await playServerProject(e.id).catch(handleError)
|
||||
@@ -1234,8 +1433,11 @@ function handleClick(e) {
|
||||
!target.href.startsWith('https://tauri.localhost') &&
|
||||
!target.href.startsWith('http://tauri.localhost')
|
||||
) {
|
||||
const userPath = parse_modrinth_user_link(target.href)
|
||||
const parsed = parseModrinthLink(target.href)
|
||||
if (target.target !== '_blank' && parsed) {
|
||||
if (userPath) {
|
||||
void router.push(userPath)
|
||||
} else if (target.target !== '_blank' && parsed) {
|
||||
void openModrinthProjectLinkInApp(parsed)
|
||||
} else {
|
||||
openUrl(target.href)
|
||||
@@ -1401,15 +1603,16 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<AppSettingsModal ref="settingsModal" />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
|
||||
<ModrinthAccountRequiredModal ref="modrinthLoginModal" :request-auth="requestModrinthAuth" />
|
||||
</Suspense>
|
||||
<CreationFlowModal
|
||||
ref="installationModal"
|
||||
type="instance"
|
||||
show-snapshot-toggle
|
||||
:fetch-existing-instance-names="fetchExistingInstanceNames"
|
||||
:search-modpacks="searchModpacks"
|
||||
:get-project-versions="getProjectVersions"
|
||||
:search-projects="searchProjects"
|
||||
:prepare-project-install="prepareCreationProjectInstall"
|
||||
:create-project-install="handleCreateAndInstall"
|
||||
:get-loader-manifest="getLoaderManifest"
|
||||
@create="handleCreate"
|
||||
@browse-modpacks="handleBrowseModpacks"
|
||||
@@ -1418,8 +1621,16 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<div
|
||||
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.5rem] w-[--left-bar-width]"
|
||||
>
|
||||
<NavButton v-tooltip.right="'Home'" to="/">
|
||||
<HomeIcon />
|
||||
<NavButton
|
||||
v-tooltip.right="'Home'"
|
||||
to="/"
|
||||
:is-primary="(route) => route.path === '/'"
|
||||
:is-subpage="
|
||||
() =>
|
||||
(route.path.startsWith('/browse') || route.path.startsWith('/project')) && route.query.i
|
||||
"
|
||||
>
|
||||
<PlayIcon />
|
||||
</NavButton>
|
||||
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
|
||||
<WorldIcon />
|
||||
@@ -1435,19 +1646,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Library'"
|
||||
to="/library"
|
||||
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
|
||||
:is-subpage="
|
||||
() =>
|
||||
route.path.startsWith('/instance') ||
|
||||
((route.path.startsWith('/browse') || route.path.startsWith('/project')) &&
|
||||
route.query.i)
|
||||
"
|
||||
>
|
||||
<LibraryIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Modrinth Hosting'"
|
||||
to="/hosting/manage"
|
||||
@@ -1481,7 +1679,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
|
||||
action: () => router.push(`/user/${encodeURIComponent(credentials.user.username)}`),
|
||||
},
|
||||
{
|
||||
id: 'sign-out',
|
||||
@@ -1505,7 +1703,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</template>
|
||||
<template #sign-out> <LogOutIcon /> Sign out </template>
|
||||
</OverflowMenu>
|
||||
<NavButton v-else v-tooltip.right="'Sign in to a Modrinth account'" :to="() => signIn()">
|
||||
<NavButton
|
||||
v-else
|
||||
v-tooltip.right="'Sign in to a Modrinth account'"
|
||||
:to="() => requestSignIn()"
|
||||
>
|
||||
<LogInIcon class="text-brand" />
|
||||
</NavButton>
|
||||
</div>
|
||||
@@ -1642,17 +1844,28 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
:class="{ 'pb-12': !hasPlus }"
|
||||
data-overlayscrollbars-initialize
|
||||
>
|
||||
<OnboardingChecklist
|
||||
@create-instance="installationModal?.show()"
|
||||
@login-minecraft="accounts?.login()"
|
||||
@login-modrinth="signIn"
|
||||
/>
|
||||
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
|
||||
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<div
|
||||
v-show="hasLoggedIntoMinecraft"
|
||||
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
>
|
||||
<h3 class="text-base text-primary font-medium m-0">Playing as</h3>
|
||||
<suspense>
|
||||
<AccountsCard ref="accounts" />
|
||||
</suspense>
|
||||
</div>
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<div
|
||||
v-show="showFriendsList"
|
||||
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
>
|
||||
<suspense>
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
<FriendsList :credentials="credentials" :sign-in="() => requestSignIn()" />
|
||||
</suspense>
|
||||
</div>
|
||||
<PrideFundraiserBanner
|
||||
@@ -1693,6 +1906,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<PopupNotificationPanel :has-sidebar="sidebarVisible" />
|
||||
<ErrorModal ref="errorModal" />
|
||||
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
|
||||
<MinecraftRequiredModal ref="minecraftRequiredModal" />
|
||||
<ContentInstallModal
|
||||
ref="modInstallModal"
|
||||
:instances="contentInstallInstances"
|
||||
@@ -1736,8 +1950,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
|
||||
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
|
||||
/>
|
||||
<InstallToPlayModal ref="installToPlayModal" />
|
||||
<UpdateToPlayModal ref="updateToPlayModal" />
|
||||
<SharedInstanceInviteHandler ref="sharedInstanceInviteHandler" />
|
||||
<InstallToPlayModal ref="installToPlayModal" :show-external-warnings="false" />
|
||||
<UpdateToPlayModal ref="updateToPlayModal" :show-external-warnings="false" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="30.8333" height="20.8333" viewBox="0 0 30.8333 20.8333" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path id="Vector" d="M1.6228 20.8333C2.51907 20.8333 3.24562 20.1158 3.24562 19.2308L3.24563 17.5253C3.24562 17.0803 3.2459 16.016 3.7136 15.2292C4.00182 14.7442 4.37085 14.339 4.74993 14.1326C5.36078 13.8001 6.0611 13.6111 6.80555 13.6111C7.05077 13.6111 7.3144 13.6219 7.6104 13.6356L7.7201 13.6408C7.97828 13.6529 8.25927 13.6661 8.5395 13.6719C9.17298 13.6851 9.91222 13.6651 10.6258 13.4739C12.0158 13.1014 13.1015 12.0158 13.4738 10.6258C13.665 9.91222 13.685 9.17298 13.672 8.5395C13.6662 8.25937 13.6528 7.9782 13.6408 7.7201L13.6357 7.6104C13.6218 7.3144 13.6112 7.05077 13.6112 6.80555C13.6112 6.02607 13.8182 5.29497 14.1805 4.66428C14.3917 4.29663 14.7595 3.96015 15.2675 3.66463C16.0377 3.21645 16.96 3.2123 17.5237 3.20975C18.0872 3.20722 18.6818 3.20527 19.1787 3.20527C22.2357 3.20527 23.347 3.2282 24.1857 3.5193C25.6395 4.02402 26.7495 5.07765 27.2667 6.36838C27.3963 6.69185 27.4878 7.1167 27.5368 7.85255C27.5868 8.60265 27.5877 9.56012 27.5877 10.9399V19.2308C27.5877 20.1158 28.3143 20.8333 29.2105 20.8333C30.1068 20.8333 30.8333 20.1158 30.8333 19.2308V10.8854C30.8333 9.5729 30.8333 8.50977 30.7755 7.6419C30.7158 6.74778 30.5898 5.95072 30.2847 5.18902C29.4037 2.99072 27.5568 1.29222 25.262 0.495583C23.8303 -0.0014166 22.1092 -0.000816752 19.5178 8.32478e-05L17.939 0.000200073L17.9167 0L17.8582 0.000249942C13.6775 0.00308328 11.0862 0.0528001 8.93633 0.79915C5.23583 2.08378 2.2801 4.81432 0.87485 8.32102C0.40655 9.48963 0.199033 10.7449 0.0986334 12.2503C-1.66334e-05 13.7293 0 15.5563 0 17.8857V19.2308C0 20.1158 0.72655 20.8333 1.6228 20.8333Z" fill="#B0BAC5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 304 B |
@@ -0,0 +1,39 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20.3429" height="7.68571" viewBox="0 0 20.3429 7.68571" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame 50789">
|
||||
<g id="Ellipse 1140" filter="url(#filter0_dii_0_10)">
|
||||
<circle cx="3.84286" cy="3.4619" r="2.5" fill="url(#paint0_linear_0_10)" fill-opacity="0.9" shape-rendering="crispEdges"/>
|
||||
<circle cx="3.84286" cy="3.4619" r="2.6" stroke="black" stroke-opacity="0.1" stroke-width="0.2" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<circle id="Ellipse 1141" cx="10.8429" cy="3.4619" r="2.5" fill="black" fill-opacity="0.37"/>
|
||||
<circle id="Ellipse 1142" cx="17.8429" cy="3.4619" r="2.5" fill="black" fill-opacity="0.37"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_dii_0_10" x="4.47035e-08" y="1.49012e-08" width="7.68571" height="7.68571" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.380952"/>
|
||||
<feGaussianBlur stdDeviation="0.571429"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_10"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_10" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.4"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.3 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_0_10"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.4"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0"/>
|
||||
<feBlend mode="normal" in2="effect2_innerShadow_0_10" result="effect3_innerShadow_0_10"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_0_10" x1="3.84286" y1="0.961905" x2="3.84286" y2="5.9619" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1BD9A9"/>
|
||||
<stop offset="0.45" stop-color="#1BD96A"/>
|
||||
<stop offset="1" stop-color="#1BD951"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,32 @@
|
||||
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="74.0007" height="74" viewBox="0 0 74.0007 74" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 49552">
|
||||
<g id="Vector">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.7343 46.6308C74.0593 41.6962 74.3484 36.5409 73.5835 31.4891C72.8185 26.4374 71.016 21.5985 68.2894 17.2769C65.5627 12.9553 61.9709 9.2445 57.7399 6.37785C53.5089 3.5112 48.7302 1.55077 43.7044 0.619851C38.6786 -0.311064 33.5144 -0.192312 28.5368 0.968637C23.5591 2.12959 18.8757 4.30761 14.781 7.36571C10.6863 10.4238 7.26903 14.2958 4.74408 18.7382C2.21913 23.1805 0.641152 28.0971 0.109401 33.1787H6.37602C7.23467 26.3912 10.338 20.0852 15.193 15.2629C20.048 10.4405 26.376 7.37843 33.1716 6.56315C39.9671 5.74787 46.8403 7.22619 52.699 10.7632C58.5577 14.3002 63.0657 19.693 65.5068 26.0846L59.4177 27.7133C58.0562 24.4413 55.9951 21.5067 53.379 19.1153C50.7629 16.7238 47.655 14.9334 44.2734 13.8697L43.1506 20.2016C46.469 21.4193 49.3529 23.5909 51.4394 26.4432C53.5258 29.2955 54.7216 32.701 54.8763 36.2311C55.0309 39.7612 54.1375 43.2581 52.3084 46.2818C50.4793 49.3055 47.7964 51.7208 44.5972 53.2237L46.263 59.4356C51.1999 57.392 55.325 53.7784 57.9996 49.1543C60.6741 44.5302 61.7489 39.1537 61.0575 33.8573L67.1257 32.2391C67.7664 36.2855 67.5781 40.4199 66.5721 44.3914L72.7343 46.6308Z" fill="url(#paint0_linear_0_14)"/>
|
||||
<path d="M28.5938 1.21191C33.5378 0.058849 38.6674 -0.0593858 43.6592 0.865234C48.651 1.78989 53.3972 3.73771 57.5996 6.58496C61.8021 9.43226 65.3699 13.1178 68.0782 17.4102C70.7863 21.7025 72.5762 26.5088 73.336 31.5264C74.0821 36.4539 73.8157 41.4799 72.5596 46.3008L66.8672 44.2324C67.8334 40.2956 68.0063 36.2054 67.3721 32.2002L67.3291 31.9258L67.0615 31.9971L60.9932 33.6162L60.7813 33.6719L60.8096 33.8896C61.4939 39.1315 60.4302 44.4528 57.7832 49.0293C55.1838 53.5234 51.1993 57.0519 46.4297 59.0928L44.8916 53.3564C48.0518 51.827 50.7041 49.4171 52.5225 46.4111C54.377 43.3453 55.2828 39.7991 55.126 36.2197C54.9691 32.6407 53.757 29.1878 51.6416 26.2959C49.5672 23.46 46.714 21.2894 43.4317 20.042L44.4688 14.1973C47.7105 15.2594 50.6909 16.9971 53.21 19.2998C55.7992 21.6667 57.8391 24.5713 59.1865 27.8096L59.2705 28.0117L59.4824 27.9551L65.5713 26.3262L65.8389 26.2549L65.7403 25.9951C63.2793 19.5515 58.7345 14.1146 52.8282 10.5488C46.9218 6.9831 39.9924 5.49255 33.1416 6.31445C26.2908 7.13639 19.9111 10.2243 15.0166 15.0859C10.1745 19.8957 7.06234 26.1694 6.15823 32.9287H0.390651C0.946388 27.9796 2.49885 23.1931 4.96096 18.8613C7.46884 14.449 10.8637 10.6038 14.9307 7.56641C18.9977 4.52894 23.6497 2.36503 28.5938 1.21191Z" stroke="url(#paint1_linear_0_14)" stroke-opacity="0.4" stroke-width="0.5"/>
|
||||
</g>
|
||||
<g id="Vector_2">
|
||||
<path d="M46.5453 72.7337C41.2782 74.1472 35.7639 74.3806 30.3962 73.4174C25.0284 72.4542 19.9396 70.318 15.4933 67.1615C11.047 64.005 7.35277 59.9061 4.67454 55.1574C1.99632 50.4086 0.400136 45.1274 0 39.6907H6.26662C6.42964 41.4382 6.7406 43.1688 7.19617 44.8638C7.67146 46.6415 8.3059 48.3729 9.09182 50.0369L14.6691 46.6908C13.0374 42.9123 12.3917 38.7817 12.7928 34.6857C13.1938 30.5898 14.6283 26.6626 16.9619 23.272C19.2956 19.8815 22.452 17.1385 26.1355 15.3C29.8191 13.4615 33.9092 12.5877 38.0227 12.7604L36.8895 19.0923C34.1456 19.1048 31.4411 19.7458 28.9837 20.966C26.5263 22.1863 24.3815 23.9533 22.7138 26.1314C21.0462 28.3095 19.9001 30.8407 19.3638 33.5306C18.8274 36.2205 18.915 38.9974 19.6197 41.6482C19.7816 42.2381 19.954 42.8227 20.1785 43.3447L27.312 39.0695L25.1761 33.3901L31.9128 26.4735L40.4301 24.6412L42.8793 27.6845L38.9523 31.657L35.5317 32.7323L33.0825 35.2484L34.2836 38.5788C34.2836 38.5788 36.7067 41.1575 36.7119 41.1575L40.1429 40.2492L42.5817 37.5713L47.9083 35.88L49.475 39.4506L44.0021 46.1792L34.7902 49.0868L30.6542 44.4879L23.4528 48.8154C25.3066 50.9244 27.6287 52.5701 30.2332 53.6207C32.8376 54.6713 35.652 55.0976 38.4509 54.8654L40.1168 61.093C35.96 61.6355 31.7334 61.0954 27.8468 59.5251C23.9601 57.9548 20.5452 55.4075 17.933 52.1301L12.3766 55.4605C15.55 59.6249 19.736 62.909 24.5369 65.0007C29.3377 67.0923 34.5939 67.922 39.806 67.411C45.018 66.8999 50.0128 65.065 54.3155 62.0808C58.6183 59.0965 62.0861 55.0619 64.3895 50.3605L70.5517 52.5999C68.2702 57.509 64.9374 61.8569 60.789 65.3361C56.6406 68.8153 51.778 71.3407 46.5453 72.7337Z" fill="url(#paint2_linear_0_14)"/>
|
||||
<path d="M37.7246 13.0026L36.6797 18.8464C33.9693 18.8898 31.3013 19.5356 28.8721 20.7419C26.3807 21.979 24.2064 23.771 22.5156 25.9792C20.8248 28.1876 19.662 30.7547 19.1182 33.4821C18.5744 36.2093 18.6634 39.025 19.3779 41.7126L19.3789 41.7145C19.541 42.3051 19.7173 42.9048 19.9492 43.444L20.0615 43.7057L20.3066 43.5593L27.4404 39.2839L27.6191 39.1764L27.5459 38.9811L25.4658 33.4499L32.0391 26.7009L40.332 24.9177L42.5439 27.6667L38.8174 31.4372L35.457 32.4938L35.3965 32.5124L35.3525 32.5583L32.9033 35.0739L32.7939 35.1862L32.8477 35.3337L34.0488 38.6637L34.0664 38.7126L34.1016 38.7497V38.7507L34.1035 38.7516C34.1047 38.7529 34.1061 38.7551 34.1084 38.7575C34.113 38.7624 34.1201 38.7696 34.1289 38.779C34.1467 38.7979 34.173 38.8258 34.2061 38.861C34.2723 38.9315 34.367 39.0323 34.4805 39.153C34.7077 39.3948 35.0103 39.7174 35.3135 40.0397C35.6167 40.3621 35.9204 40.6846 36.1484 40.9264C36.2623 41.0473 36.3579 41.1478 36.4248 41.2184C36.4582 41.2537 36.4845 41.2822 36.5029 41.3014C36.512 41.3109 36.5199 41.3183 36.5254 41.3239C36.5279 41.3265 36.5305 41.33 36.5332 41.3327C36.5343 41.3338 36.5369 41.3366 36.54 41.3395C36.5412 41.3406 36.5462 41.3446 36.5518 41.3493C36.5552 41.3521 36.5642 41.3596 36.5703 41.3639C36.5845 41.3729 36.6539 41.4011 36.7119 41.4079H36.7441L36.7764 41.3991L40.207 40.4909L40.2783 40.4723L40.3281 40.4177L42.7217 37.7878L47.7695 36.1852L49.1846 39.4108L43.8545 45.9636L34.8672 48.7995L30.8398 44.321L30.7021 44.1677L30.5254 44.2741L23.3242 48.6012L23.0674 48.7555L23.2646 48.9802C25.1442 51.1184 27.499 52.787 30.1396 53.8522C32.7153 54.8912 35.4932 55.3261 38.2617 55.1286L39.7998 60.8776C35.7775 61.354 31.6985 60.812 27.9404 59.2936C24.0938 57.7395 20.7143 55.2179 18.1289 51.9743L17.9922 51.8034L17.8047 51.9157L12.248 55.2458L12.0088 55.3893L12.1777 55.612C15.3769 59.8102 19.5967 63.1215 24.4365 65.2302C29.2764 67.3388 34.5757 68.175 39.8301 67.6598C45.0845 67.1446 50.1202 65.2944 54.458 62.2858C58.734 59.3201 62.1908 55.3249 64.5127 50.6716L70.2061 52.7399C67.9437 57.5147 64.6786 61.747 60.6279 65.1442C56.5078 68.5997 51.6786 71.1083 46.4814 72.4919H46.4805C41.249 73.8958 35.7719 74.1283 30.4404 73.1716C25.1089 72.2148 20.054 70.0929 15.6377 66.9577C11.2216 63.8226 7.55266 59.7513 4.89258 55.0348C2.27662 50.3965 0.702069 45.2462 0.272461 39.9411H6.04102C6.18839 41.4053 6.43742 42.8572 6.78906 44.2858L6.95508 44.9284C7.43423 46.7205 8.07394 48.4658 8.86621 50.1432L8.9834 50.3932L9.2207 50.2516L14.7979 46.905L14.9854 46.7927L14.8984 46.5915C13.2835 42.8519 12.6451 38.7634 13.042 34.7096C13.4389 30.656 14.8584 26.7693 17.168 23.4137C19.4776 20.0582 22.6015 17.3437 26.2471 15.5241C29.8069 13.7473 33.7504 12.8833 37.7246 13.0026Z" stroke="url(#paint3_linear_0_14)" stroke-opacity="0.4" stroke-width="0.5"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_0_14" x1="27.7452" y1="-15.7583" x2="40.9431" y2="62.6745" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1BD9AA"/>
|
||||
<stop offset="0.450385" stop-color="#1BD96A"/>
|
||||
<stop offset="1" stop-color="#1BD951"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_0_14" x1="37.055" y1="3.26749e-09" x2="37.055" y2="59.4356" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_0_14" x1="26.3868" y1="-3.50338" x2="41.0049" y2="76.972" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1BD9AA"/>
|
||||
<stop offset="0.450385" stop-color="#1BD96A"/>
|
||||
<stop offset="1" stop-color="#1BD951"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_0_14" x1="35.2758" y1="12.7389" x2="35.2758" y2="74" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 937 KiB |
@@ -1,357 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
DropdownSelect,
|
||||
formatLoader,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { remove } from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
const instanceOptions = ref(null)
|
||||
const instanceComponents = ref(null)
|
||||
|
||||
const currentDeleteInstance = ref(null)
|
||||
const confirmModal = ref(null)
|
||||
|
||||
async function deleteInstance() {
|
||||
if (currentDeleteInstance.value) {
|
||||
instanceComponents.value = instanceComponents.value.filter(
|
||||
(x) => x.instance.id !== currentDeleteInstance.value,
|
||||
)
|
||||
await remove(currentDeleteInstance.value).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleRightClick = (event, instanceId) => {
|
||||
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
|
||||
const baseOptions = [
|
||||
{ name: 'add_content' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open' },
|
||||
{ name: 'copy' },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
name: 'delete',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
instanceOptions.value.showMenu(
|
||||
event,
|
||||
item,
|
||||
item.playing
|
||||
? [
|
||||
{
|
||||
name: 'stop',
|
||||
color: 'danger',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
...baseOptions,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
args.item.play(null, 'InstanceGridContextMenu')
|
||||
break
|
||||
case 'stop':
|
||||
args.item.stop(null, 'InstanceGridContextMenu')
|
||||
break
|
||||
case 'add_content':
|
||||
await args.item.addContent()
|
||||
break
|
||||
case 'edit':
|
||||
await args.item.seeInstance()
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.instance.install_stage == 'installed')
|
||||
await duplicateInstance(args.item.instance.id)
|
||||
break
|
||||
case 'open':
|
||||
await args.item.openFolder()
|
||||
break
|
||||
case 'copy':
|
||||
await navigator.clipboard.writeText(args.item.instance.id)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item.instance.id
|
||||
confirmModal.value.show()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const state = useStorage(
|
||||
`${props.label}-grid-display-state`,
|
||||
{
|
||||
group: 'Group',
|
||||
sortBy: 'Name',
|
||||
collapsedGroups: [],
|
||||
},
|
||||
localStorage,
|
||||
{ mergeDefaults: true },
|
||||
)
|
||||
|
||||
const search = ref('')
|
||||
const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? []))
|
||||
|
||||
const getSectionKey = (sectionName) => `${state.value.group}:${sectionName}`
|
||||
|
||||
const isSectionCollapsed = (sectionName) => {
|
||||
return collapsedSectionKeys.value.has(getSectionKey(sectionName))
|
||||
}
|
||||
|
||||
const setSectionCollapsed = (sectionName, collapsed) => {
|
||||
const sectionKey = getSectionKey(sectionName)
|
||||
const collapsedSections = new Set(state.value.collapsedGroups ?? [])
|
||||
|
||||
if (collapsed) {
|
||||
collapsedSections.add(sectionKey)
|
||||
} else {
|
||||
collapsedSections.delete(sectionKey)
|
||||
}
|
||||
|
||||
state.value.collapsedGroups = [...collapsedSections]
|
||||
}
|
||||
|
||||
const filteredResults = computed(() => {
|
||||
const { group = 'Group', sortBy = 'Name' } = state.value
|
||||
|
||||
const instances = props.instances.filter((instance) => {
|
||||
return instance.name.toLowerCase().includes(search.value.toLowerCase())
|
||||
})
|
||||
|
||||
if (sortBy === 'Name') {
|
||||
instances.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
if (sortBy === 'Game version') {
|
||||
instances.sort((a, b) => {
|
||||
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
|
||||
})
|
||||
}
|
||||
|
||||
if (sortBy === 'Last played') {
|
||||
instances.sort((a, b) => {
|
||||
return dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))
|
||||
})
|
||||
}
|
||||
|
||||
if (sortBy === 'Date created') {
|
||||
instances.sort((a, b) => {
|
||||
return dayjs(b.date_created).diff(dayjs(a.date_created))
|
||||
})
|
||||
}
|
||||
|
||||
if (sortBy === 'Date modified') {
|
||||
instances.sort((a, b) => {
|
||||
return dayjs(b.date_modified).diff(dayjs(a.date_modified))
|
||||
})
|
||||
}
|
||||
|
||||
const instanceMap = new Map()
|
||||
|
||||
if (group === 'Loader') {
|
||||
instances.forEach((instance) => {
|
||||
const loader = formatLoader(formatMessage, instance.loader)
|
||||
if (!instanceMap.has(loader)) {
|
||||
instanceMap.set(loader, [])
|
||||
}
|
||||
|
||||
instanceMap.get(loader).push(instance)
|
||||
})
|
||||
} else if (group === 'Game version') {
|
||||
instances.forEach((instance) => {
|
||||
if (!instanceMap.has(instance.game_version)) {
|
||||
instanceMap.set(instance.game_version, [])
|
||||
}
|
||||
|
||||
instanceMap.get(instance.game_version).push(instance)
|
||||
})
|
||||
} else if (group === 'Group') {
|
||||
instances.forEach((instance) => {
|
||||
if (instance.groups.length === 0) {
|
||||
instance.groups.push('None')
|
||||
}
|
||||
|
||||
for (const category of instance.groups) {
|
||||
if (!instanceMap.has(category)) {
|
||||
instanceMap.set(category, [])
|
||||
}
|
||||
|
||||
instanceMap.get(category).push(instance)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return instanceMap.set('None', instances)
|
||||
}
|
||||
|
||||
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
|
||||
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
|
||||
if (sortBy === 'Name') {
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
// None should always be first
|
||||
if (a[0] === 'None' && b[0] !== 'None') {
|
||||
return -1
|
||||
}
|
||||
if (a[0] !== 'None' && b[0] === 'None') {
|
||||
return 1
|
||||
}
|
||||
return a[0].localeCompare(b[0])
|
||||
})
|
||||
instanceMap.clear()
|
||||
sortedEntries.forEach((entry) => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
|
||||
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
|
||||
if (group === 'Game version') {
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
return a[0].localeCompare(b[0], undefined, { numeric: true })
|
||||
})
|
||||
instanceMap.clear()
|
||||
sortedEntries.forEach((entry) => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
|
||||
return instanceMap
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
clearable
|
||||
wrapper-class="flex-1"
|
||||
/>
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="state.sortBy"
|
||||
name="Sort Dropdown"
|
||||
class="max-w-[16rem]"
|
||||
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
|
||||
placeholder="Select..."
|
||||
>
|
||||
<span class="font-semibold text-primary">Sort by: </span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
</DropdownSelect>
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="state.group"
|
||||
class="max-w-[16rem]"
|
||||
name="Group Dropdown"
|
||||
:options="['Group', 'Loader', 'Game version', 'None']"
|
||||
placeholder="Select..."
|
||||
>
|
||||
<span class="font-semibold text-primary">Group by: </span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
</DropdownSelect>
|
||||
</div>
|
||||
<Accordion
|
||||
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}))"
|
||||
:key="instanceSection.key"
|
||||
:divider="instanceSection.key !== 'None'"
|
||||
:open-by-default="!isSectionCollapsed(instanceSection.key)"
|
||||
class="row"
|
||||
@on-open="setSectionCollapsed(instanceSection.key, false)"
|
||||
@on-close="setSectionCollapsed(instanceSection.key, true)"
|
||||
>
|
||||
<template v-if="instanceSection.key !== 'None'" #title>
|
||||
<span class="text-base">{{ instanceSection.key }}</span>
|
||||
</template>
|
||||
<section class="instances">
|
||||
<Instance
|
||||
v-for="instance in instanceSection.value"
|
||||
ref="instanceComponents"
|
||||
:key="instance.id + instance.install_stage"
|
||||
:instance="instance"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.id)"
|
||||
/>
|
||||
</section>
|
||||
</Accordion>
|
||||
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteInstance" />
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EyeIcon /> View instance </template>
|
||||
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
<template #open> <FolderOpenIcon /> Open folder </template>
|
||||
<template #copy> <ClipboardCopyIcon /> Copy path </template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.instances {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
width: 100%;
|
||||
gap: 0.75rem;
|
||||
margin-right: auto;
|
||||
scroll-behavior: smooth;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,368 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { kill, remove, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process.js'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
canPaginate: Boolean,
|
||||
})
|
||||
|
||||
const actualInstances = computed(() =>
|
||||
props.instances.filter(
|
||||
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
|
||||
),
|
||||
)
|
||||
|
||||
const modsRow = ref(null)
|
||||
const instanceOptions = ref(null)
|
||||
const instanceComponents = ref(null)
|
||||
const rows = ref(null)
|
||||
const deleteConfirmModal = ref(null)
|
||||
|
||||
const currentDeleteInstance = ref(null)
|
||||
|
||||
async function deleteInstance() {
|
||||
if (currentDeleteInstance.value) {
|
||||
await remove(currentDeleteInstance.value).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleInstanceRightClick = async (event, passedInstance) => {
|
||||
const baseOptions = [
|
||||
{ name: 'add_content' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open_folder' },
|
||||
{ name: 'copy_path' },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
name: 'delete',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError)
|
||||
|
||||
const options =
|
||||
runningProcesses.length > 0
|
||||
? [
|
||||
{
|
||||
name: 'stop',
|
||||
color: 'danger',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
|
||||
instanceOptions.value.showMenu(event, passedInstance, options)
|
||||
}
|
||||
|
||||
const handleProjectClick = (event, passedInstance) => {
|
||||
instanceOptions.value.showMenu(event, passedInstance, [
|
||||
{
|
||||
name: 'install',
|
||||
color: 'primary',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
name: 'open_link',
|
||||
},
|
||||
{
|
||||
name: 'copy_link',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
await run(args.item.id).catch((err) => handleSevereError(err, { instanceId: args.item.id }))
|
||||
trackEvent('InstanceStart', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
})
|
||||
break
|
||||
case 'stop':
|
||||
await kill(args.item.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
})
|
||||
break
|
||||
case 'add_content':
|
||||
await router.push({
|
||||
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: args.item.id },
|
||||
})
|
||||
break
|
||||
case 'edit':
|
||||
await router.push({
|
||||
path: `/instance/${encodeURIComponent(args.item.id)}`,
|
||||
})
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item.id
|
||||
deleteConfirmModal.value.show()
|
||||
break
|
||||
case 'open_folder':
|
||||
await showInstanceInFolder(args.item.id)
|
||||
break
|
||||
case 'copy_path':
|
||||
await navigator.clipboard.writeText(args.item.id)
|
||||
break
|
||||
case 'install': {
|
||||
await installVersion(
|
||||
args.item.project_id,
|
||||
null,
|
||||
null,
|
||||
'ProjectCardContextMenu',
|
||||
() => {},
|
||||
() => {},
|
||||
).catch(handleError)
|
||||
|
||||
break
|
||||
}
|
||||
case 'open_link':
|
||||
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
|
||||
break
|
||||
case 'copy_link':
|
||||
await navigator.clipboard.writeText(
|
||||
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const maxInstancesPerCompactRow = ref(1)
|
||||
const maxInstancesPerRow = ref(1)
|
||||
const maxProjectsPerRow = ref(1)
|
||||
|
||||
const calculateCardsPerRow = () => {
|
||||
if (rows.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate how many cards fit in one row
|
||||
const containerWidth = rows.value[0].clientWidth
|
||||
// Convert container width from pixels to rem
|
||||
const containerWidthInRem =
|
||||
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
|
||||
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
|
||||
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
|
||||
if (maxInstancesPerRow.value < 5) {
|
||||
maxInstancesPerRow.value *= 2
|
||||
}
|
||||
if (maxInstancesPerCompactRow.value < 5) {
|
||||
maxInstancesPerCompactRow.value *= 2
|
||||
}
|
||||
if (maxProjectsPerRow.value < 3) {
|
||||
maxProjectsPerRow.value *= 2
|
||||
}
|
||||
}
|
||||
|
||||
const rowContainer = ref(null)
|
||||
const resizeObserver = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
calculateCardsPerRow()
|
||||
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.observe(rowContainer.value)
|
||||
}
|
||||
window.addEventListener('resize', calculateCardsPerRow)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateCardsPerRow)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.unobserve(rowContainer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteInstance" />
|
||||
<div ref="rowContainer" class="flex flex-col gap-4">
|
||||
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
|
||||
<HeadingLink class="mt-1" :to="row.route">
|
||||
{{ row.label }}
|
||||
</HeadingLink>
|
||||
<section
|
||||
v-if="row.instance"
|
||||
ref="modsRow"
|
||||
class="instances"
|
||||
:class="{ compact: row.compact }"
|
||||
>
|
||||
<Instance
|
||||
v-for="(instance, instanceIndex) in row.instances.slice(
|
||||
0,
|
||||
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
|
||||
)"
|
||||
:key="row.label + instance.id"
|
||||
:instance="instance"
|
||||
:compact="row.compact"
|
||||
:first="instanceIndex === 0"
|
||||
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
|
||||
/>
|
||||
</section>
|
||||
<section v-else ref="modsRow" class="projects">
|
||||
<LegacyProjectCard
|
||||
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
|
||||
:key="project?.project_id"
|
||||
ref="instanceComponents"
|
||||
class="item"
|
||||
:project="project"
|
||||
@contextmenu.prevent.stop="(event) => handleProjectClick(event, project)"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EyeIcon /> View instance </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
<template #open_folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #install> <DownloadIcon /> Install </template>
|
||||
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
gap: var(--gap-xs);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
a {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: bolder;
|
||||
white-space: nowrap;
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: var(--color-base);
|
||||
}
|
||||
}
|
||||
|
||||
.instances {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-gap: 0.75rem;
|
||||
width: 100%;
|
||||
|
||||
&.compact {
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.projects {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
||||
grid-gap: 0.75rem;
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -182,6 +182,7 @@ defineExpose({
|
||||
refreshValues,
|
||||
setEquippedSkin,
|
||||
setLoginDisabled,
|
||||
login,
|
||||
loginDisabled,
|
||||
})
|
||||
|
||||
|
||||
@@ -14,31 +14,7 @@
|
||||
<UnplugIcon class="text-secondary" />
|
||||
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
|
||||
</div>
|
||||
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
|
||||
<button
|
||||
v-if="showUpdatePill"
|
||||
type="button"
|
||||
class="!h-[34px] overflow-hidden text-sm !transition-[width,opacity,transform,background-color,color,filter] !duration-200 ease-out"
|
||||
:class="[
|
||||
updatePillWidthClass,
|
||||
{
|
||||
'update-pill-ready-hidden': finishedDownloading && !animateReadyPill,
|
||||
'update-pill-ready-visible': finishedDownloading && animateReadyPill,
|
||||
},
|
||||
]"
|
||||
:disabled="isUpdateDownloading"
|
||||
:aria-busy="isUpdateDownloading"
|
||||
@click="handleUpdateClick"
|
||||
>
|
||||
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
|
||||
<DownloadIcon v-else />
|
||||
<span v-if="isUpdateDownloading">
|
||||
{{ formatMessage(messages.downloadingUpdate) }}
|
||||
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
|
||||
</span>
|
||||
<span v-else>{{ updateLabel }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<AppUpdateButton />
|
||||
<div
|
||||
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
|
||||
>
|
||||
@@ -144,7 +120,6 @@ import {
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
RefreshCwIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
@@ -161,9 +136,10 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
@@ -172,11 +148,6 @@ import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/p
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
installAvailableAppUpdate,
|
||||
} from '@/providers/app-update'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
@@ -241,96 +212,8 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.view-active-downloads',
|
||||
defaultMessage: 'View active downloads',
|
||||
},
|
||||
update: {
|
||||
id: 'app.action-bar.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
downloadingUpdate: {
|
||||
id: 'app.action-bar.downloading-update',
|
||||
defaultMessage: 'Downloading update',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.action-bar.reload-to-update',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
downloading,
|
||||
downloadPercent,
|
||||
downloadProgress,
|
||||
finishedDownloading,
|
||||
isVisible: isUpdateVisible,
|
||||
metered,
|
||||
restarting,
|
||||
} = appUpdateState
|
||||
|
||||
const isUpdateDownloading = computed(
|
||||
() =>
|
||||
downloading.value ||
|
||||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
|
||||
)
|
||||
const showUpdatePill = computed(
|
||||
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
|
||||
)
|
||||
const animateReadyPill = ref(false)
|
||||
const updateLabel = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return formatMessage(messages.downloadingUpdate)
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return formatMessage(messages.reloadToUpdate)
|
||||
}
|
||||
|
||||
return formatMessage(messages.update)
|
||||
})
|
||||
const updatePillWidthClass = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return 'w-[219px]'
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return 'w-[166px]'
|
||||
}
|
||||
|
||||
return '!w-[96px]'
|
||||
})
|
||||
let readyPillAnimationFrame: number | null = null
|
||||
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
readyPillAnimationFrame = null
|
||||
}
|
||||
|
||||
if (!show || !ready) {
|
||||
animateReadyPill.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (wasShown && wasReady) {
|
||||
return
|
||||
}
|
||||
|
||||
animateReadyPill.value = false
|
||||
await nextTick()
|
||||
readyPillAnimationFrame = requestAnimationFrame(() => {
|
||||
animateReadyPill.value = true
|
||||
readyPillAnimationFrame = null
|
||||
})
|
||||
})
|
||||
async function handleUpdateClick() {
|
||||
if (isUpdateDownloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
await installAvailableAppUpdate()
|
||||
} else {
|
||||
await downloadAvailableAppUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
const currentProcesses = ref<RunningProcess[]>([])
|
||||
const selectedProcess = ref<RunningProcess | undefined>()
|
||||
|
||||
@@ -619,20 +502,5 @@ onBeforeUnmount(() => {
|
||||
unlistenProcess()
|
||||
unlistenLoading()
|
||||
installJobNotifications.dispose()
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-pill-ready-hidden {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.update-pill-ready-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,9 +34,18 @@ const options = ref([])
|
||||
const left = ref('0px')
|
||||
const top = ref('0px')
|
||||
const shown = ref(false)
|
||||
const contextMenuId = Symbol()
|
||||
const contextMenuOpenEvent = 'modrinth-context-menu-open'
|
||||
|
||||
const hideContextMenu = () => {
|
||||
shown.value = false
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showMenu: (event, passedItem, passedOptions) => {
|
||||
window.dispatchEvent(new CustomEvent(contextMenuOpenEvent, { detail: contextMenuId }))
|
||||
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
|
||||
@@ -62,6 +71,7 @@ defineExpose({
|
||||
}
|
||||
})
|
||||
},
|
||||
hideMenu: hideContextMenu,
|
||||
})
|
||||
|
||||
const isInstanceLink = (item) => {
|
||||
@@ -73,11 +83,6 @@ const isInstanceLink = (item) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const hideContextMenu = () => {
|
||||
shown.value = false
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
const optionClicked = (option) => {
|
||||
emit('option-clicked', {
|
||||
item: item.value,
|
||||
@@ -92,6 +97,12 @@ const onEscKeyRelease = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleContextMenuOpen = (event) => {
|
||||
if (shown.value && event.detail !== contextMenuId) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
if (
|
||||
@@ -105,11 +116,13 @@ const handleClickOutside = (event) => {
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener(contextMenuOpenEvent, handleContextMenuOpen)
|
||||
document.body.addEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener(contextMenuOpenEvent, handleContextMenuOpen)
|
||||
document.removeEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -52,10 +52,6 @@ defineExpose({
|
||||
if (errorVal.message.includes('because the target machine actively refused it')) {
|
||||
metadata.value.hostsFile = true
|
||||
}
|
||||
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
|
||||
title.value = 'Sign in to Minecraft'
|
||||
errorType.value = 'minecraft_sign_in'
|
||||
supportLink.value = 'https://support.modrinth.com'
|
||||
} else if (errorVal.message && errorVal.message.includes('Move directory error:')) {
|
||||
title.value = 'Could not change app directory'
|
||||
errorType.value = 'directory_move'
|
||||
@@ -243,20 +239,6 @@ async function copyToClipboard(text) {
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="errorType === 'minecraft_sign_in'">
|
||||
<p>
|
||||
To play this instance, you must sign in through Microsoft below. If you don't have a
|
||||
Minecraft account, you can purchase the game on the
|
||||
<a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
|
||||
>Minecraft website</a
|
||||
>.
|
||||
</p>
|
||||
<div class="cta-button">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> Sign in to Minecraft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="errorType === 'state_init'">
|
||||
<p>
|
||||
Modrinth App failed to load correctly. This may be because of a corrupted file, or
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<script setup>
|
||||
import { WrenchIcon, XIcon } from '@modrinth/assets'
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FileTreeSelect,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { readDir, stat } from '@tauri-apps/plugin-fs'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { PackageIcon, VersionIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
|
||||
import { PackageIcon } from '@/assets/icons'
|
||||
import {
|
||||
export_instance_mrpack,
|
||||
get_full_path,
|
||||
get_pack_export_candidates,
|
||||
} from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
|
||||
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack Name' },
|
||||
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack name' },
|
||||
modpackNamePlaceholder: {
|
||||
id: 'app.export-modal.modpack-name-placeholder',
|
||||
defaultMessage: 'Modpack name',
|
||||
@@ -39,15 +43,7 @@ const messages = defineMessages({
|
||||
id: 'app.export-modal.description-placeholder',
|
||||
defaultMessage: 'Enter modpack description...',
|
||||
},
|
||||
selectFilesLabel: {
|
||||
id: 'app.export-modal.select-files-label',
|
||||
defaultMessage: 'Configure which files are included in this export',
|
||||
},
|
||||
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
|
||||
includeFile: {
|
||||
id: 'app.export-modal.include-file-accessibility-label',
|
||||
defaultMessage: 'Include "{file}"?',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
@@ -59,8 +55,9 @@ const props = defineProps({
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
resetExportState()
|
||||
exportModal.value.show()
|
||||
initFiles()
|
||||
void initFiles().catch(handleError)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -69,62 +66,33 @@ const nameInput = ref(props.instance.name)
|
||||
const exportDescription = ref('')
|
||||
const versionInput = ref('1.0.0')
|
||||
const files = ref([])
|
||||
const folders = ref([])
|
||||
const selectedFilePaths = ref([])
|
||||
const fileTreeKey = ref(0)
|
||||
const filesLoadId = ref(0)
|
||||
const instanceRoot = ref('')
|
||||
const loadedDirectories = ref(new Set())
|
||||
|
||||
const initFiles = async () => {
|
||||
const newFolders = new Map()
|
||||
const sep = '/'
|
||||
files.value = []
|
||||
await get_pack_export_candidates(props.instance.id).then((filePaths) =>
|
||||
filePaths
|
||||
.map((folder) => ({
|
||||
path: folder,
|
||||
name: folder.split(sep).pop(),
|
||||
selected:
|
||||
folder.startsWith('mods') ||
|
||||
folder.startsWith('datapacks') ||
|
||||
folder.startsWith('resourcepacks') ||
|
||||
folder.startsWith('shaderpacks') ||
|
||||
folder.startsWith('config'),
|
||||
disabled:
|
||||
folder === 'profile.json' ||
|
||||
folder.startsWith('modrinth_logs') ||
|
||||
folder.startsWith('.fabric') ||
|
||||
folder.startsWith('__MACOSX'),
|
||||
}))
|
||||
.forEach((pathData) => {
|
||||
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
|
||||
if (parent !== '') {
|
||||
if (newFolders.has(parent)) {
|
||||
newFolders.get(parent).push(pathData)
|
||||
} else {
|
||||
newFolders.set(parent, [pathData])
|
||||
}
|
||||
} else {
|
||||
files.value.push(pathData)
|
||||
}
|
||||
}),
|
||||
)
|
||||
folders.value = [...newFolders.entries()].map(([name, value]) => [
|
||||
{
|
||||
name,
|
||||
showingMore: false,
|
||||
},
|
||||
value,
|
||||
async function initFiles() {
|
||||
const loadId = ++filesLoadId.value
|
||||
const [filePaths, root] = await Promise.all([
|
||||
get_pack_export_candidates(props.instance.id),
|
||||
get_full_path(props.instance.id),
|
||||
])
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
instanceRoot.value = root
|
||||
const exportCandidates = await Promise.all(
|
||||
filePaths.map((path) => buildExportCandidateItem(root, path)),
|
||||
)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
files.value = exportCandidates
|
||||
selectedFilePaths.value = files.value
|
||||
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
|
||||
.map((file) => file.path)
|
||||
}
|
||||
|
||||
await initFiles()
|
||||
|
||||
const exportPack = async () => {
|
||||
const filesToExport = files.value.filter((file) => file.selected).map((file) => file.path)
|
||||
folders.value.forEach((args) => {
|
||||
args[1].forEach((child) => {
|
||||
if (child.selected) {
|
||||
filesToExport.push(child.path)
|
||||
}
|
||||
})
|
||||
})
|
||||
const outputPath = await save({
|
||||
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
|
||||
filters: [
|
||||
@@ -139,7 +107,7 @@ const exportPack = async () => {
|
||||
export_instance_mrpack(
|
||||
props.instance.id,
|
||||
outputPath,
|
||||
filesToExport,
|
||||
selectedFilePaths.value,
|
||||
versionInput.value,
|
||||
exportDescription.value,
|
||||
nameInput.value,
|
||||
@@ -147,94 +115,176 @@ const exportPack = async () => {
|
||||
exportModal.value.hide()
|
||||
}
|
||||
}
|
||||
|
||||
function resetExportState() {
|
||||
nameInput.value = props.instance.name
|
||||
exportDescription.value = ''
|
||||
versionInput.value = '1.0.0'
|
||||
files.value = []
|
||||
selectedFilePaths.value = []
|
||||
fileTreeKey.value += 1
|
||||
instanceRoot.value = ''
|
||||
loadedDirectories.value = new Set()
|
||||
}
|
||||
|
||||
async function loadExportDirectory(path) {
|
||||
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
|
||||
|
||||
const loadId = filesLoadId.value
|
||||
loadedDirectories.value.add(path)
|
||||
|
||||
try {
|
||||
const entries = await readDir(`${instanceRoot.value}/${path}`)
|
||||
const childItems = await Promise.all(
|
||||
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
|
||||
)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
appendExportItems(childItems)
|
||||
} catch {
|
||||
loadedDirectories.value.delete(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildExportCandidateItem(instanceRoot, path) {
|
||||
try {
|
||||
const entries = await readDir(`${instanceRoot}/${path}`)
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return {
|
||||
path,
|
||||
type: 'directory',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
modified: metadata.modified,
|
||||
count: entries.length,
|
||||
}
|
||||
} catch {
|
||||
return buildExportFileItem(instanceRoot, path)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
|
||||
const path = `${parentPath}/${entry.name}`
|
||||
if (entry.isDirectory) {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return {
|
||||
path,
|
||||
type: 'directory',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
modified: metadata.modified,
|
||||
}
|
||||
}
|
||||
|
||||
return buildExportFileItem(instanceRoot, path)
|
||||
}
|
||||
|
||||
async function buildExportFileItem(instanceRoot, path) {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return {
|
||||
path,
|
||||
type: 'file',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
size: metadata.size,
|
||||
modified: metadata.modified,
|
||||
}
|
||||
}
|
||||
|
||||
function appendExportItems(items) {
|
||||
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
|
||||
for (const item of items) {
|
||||
nextFiles.set(normalizeExportPath(item.path), item)
|
||||
}
|
||||
files.value = [...nextFiles.values()]
|
||||
}
|
||||
|
||||
async function getExportCandidateMetadata(instanceRoot, path) {
|
||||
try {
|
||||
const metadata = await stat(`${instanceRoot}/${path}`)
|
||||
return {
|
||||
size: metadata.size,
|
||||
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExportPath(path) {
|
||||
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
|
||||
}
|
||||
|
||||
function isDefaultSelectedExportCandidate(path) {
|
||||
return (
|
||||
path.startsWith('mods') ||
|
||||
path.startsWith('datapacks') ||
|
||||
path.startsWith('resourcepacks') ||
|
||||
path.startsWith('shaderpacks') ||
|
||||
path.startsWith('config')
|
||||
)
|
||||
}
|
||||
|
||||
function isExportCandidateDisabled(path) {
|
||||
return (
|
||||
path === 'profile.json' ||
|
||||
path.startsWith('modrinth_logs') ||
|
||||
path.startsWith('.fabric') ||
|
||||
path.startsWith('__MACOSX')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
|
||||
<div class="flex flex-col gap-4 w-[40rem]">
|
||||
<NewModal
|
||||
ref="exportModal"
|
||||
:header="formatMessage(messages.header)"
|
||||
scrollable
|
||||
width="46rem"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="labeled_input">
|
||||
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="nameInput"
|
||||
:icon="PackageIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="labeled_input">
|
||||
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.versionNumberLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
v-model="versionInput"
|
||||
:icon="VersionIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<p class="m-0 text-contrast font-semibold">
|
||||
{{ formatMessage(commonMessages.descriptionLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Accordion
|
||||
class="w-full bg-surface-4 border border-solid border-surface-5 rounded-2xl overflow-clip"
|
||||
button-class="p-4 w-full border-b border-solid border-b-surface-5 bg-surface-2 -mb-px hover:brightness-[--hover-brightness] group"
|
||||
>
|
||||
<template #title>
|
||||
<span class="flex items-center gap-3 text-contrast group-active:scale-[0.98]">
|
||||
<WrenchIcon aria-hidden="true" class="size-5 text-secondary" />
|
||||
Configure which files are included in this export
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col [&>*:nth-child(even)]:bg-surface-3">
|
||||
<div v-for="[path, children] in folders" :key="path.name" class="flex flex-col">
|
||||
<Accordion
|
||||
class="flex flex-col"
|
||||
button-class="flex gap-3 pr-4 hover:bg-surface-5 group"
|
||||
>
|
||||
<template #title>
|
||||
<Checkbox
|
||||
:model-value="children.every((child) => child.selected)"
|
||||
:indeterminate="
|
||||
!children.every((child) => child.selected) &&
|
||||
children.some((child) => child.selected)
|
||||
"
|
||||
:description="formatMessage(messages.includeFile, { file: path.name })"
|
||||
class="pl-4 py-2"
|
||||
:disabled="children.every((x) => x.disabled)"
|
||||
@update:model-value="
|
||||
(newValue) => children.forEach((child) => (child.selected = newValue))
|
||||
"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="ml-2 group-active:scale-95">{{ path.name }}/</span>
|
||||
</template>
|
||||
<div v-for="child in children" :key="child.path">
|
||||
<Checkbox
|
||||
v-model="child.selected"
|
||||
:label="child.name"
|
||||
class="w-full px-8 py-2 hover:bg-surface-4 text-primary"
|
||||
:disabled="child.disabled"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
<Checkbox
|
||||
v-for="file in files"
|
||||
:key="file.path"
|
||||
v-model="file.selected"
|
||||
:label="file.name"
|
||||
:disabled="file.disabled"
|
||||
class="w-full px-4 py-2 hover:bg-surface-4 text-primary"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
<FileTreeSelect
|
||||
:key="fileTreeKey"
|
||||
v-model="selectedFilePaths"
|
||||
class="min-w-0"
|
||||
:items="files"
|
||||
@navigate="loadExportDirectory"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="exportModal.hide">
|
||||
@@ -249,6 +299,6 @@ const exportPack = async () => {
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
DownloadIcon,
|
||||
GameIcon,
|
||||
PlayIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
first: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const modLoading = computed(
|
||||
() =>
|
||||
loading.value ||
|
||||
currentEvent.value === 'installing' ||
|
||||
(currentEvent.value === 'launched' && !playing.value),
|
||||
)
|
||||
const installing = computed(() => props.instance.install_stage.includes('installing'))
|
||||
const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const seeInstance = async () => {
|
||||
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
|
||||
}
|
||||
|
||||
const checkProcess = async () => {
|
||||
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
const play = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
loading.value = true
|
||||
await run(props.instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
})
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const stop = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
playing.value = false
|
||||
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
|
||||
const repair = async (e) => {
|
||||
e?.stopPropagation()
|
||||
|
||||
if (
|
||||
props.instance.install_stage !== 'pack_installed' &&
|
||||
(props.instance.link?.type === 'modrinth_modpack' ||
|
||||
props.instance.link?.type === 'server_project_modpack')
|
||||
) {
|
||||
await install_pack_to_existing_instance(props.instance.id, {
|
||||
type: 'fromVersionId',
|
||||
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
|
||||
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
|
||||
title: props.instance.name,
|
||||
}).catch(handleError)
|
||||
} else {
|
||||
await install_existing_instance(props.instance.id, false).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
const openFolder = async () => {
|
||||
await showInstanceInFolder(props.instance.id)
|
||||
}
|
||||
|
||||
const addContent = async () => {
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: props.instance.id },
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
play,
|
||||
stop,
|
||||
seeInstance,
|
||||
openFolder,
|
||||
addContent,
|
||||
instance: props.instance,
|
||||
})
|
||||
|
||||
const currentEvent = ref(null)
|
||||
|
||||
const unlisten = await process_listener((e) => {
|
||||
if (e.instance_id === props.instance.id) {
|
||||
currentEvent.value = e.event
|
||||
if (e.event === 'finished') {
|
||||
playing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => checkProcess())
|
||||
onUnmounted(() => unlisten())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="compact">
|
||||
<div
|
||||
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"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:tint-by="instance.id"
|
||||
alt="Mod card"
|
||||
/>
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
<span class="line-clamp-2">{{ instance.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ButtonStyled v-if="playing" color="red" circular @mousehover="checkProcess">
|
||||
<button v-tooltip="'Stop'" @click="(e) => stop(e, 'InstanceCard')">
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="modLoading" color="standard" circular>
|
||||
<button v-tooltip="'Instance is loading...'" disabled>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
|
||||
<button
|
||||
v-tooltip="'Play'"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<!-- Translate for optical centering -->
|
||||
<PlayIcon class="translate-x-[1px]" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||
<TimerIcon />
|
||||
<span class="text-sm">
|
||||
<template v-if="instance.last_played">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else>
|
||||
<div
|
||||
class="button-base bg-bg-raised p-4 rounded-xl flex gap-3 group"
|
||||
@click="seeInstance"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<div class="relative flex items-center justify-center">
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:tint-by="instance.id"
|
||||
alt="Mod card"
|
||||
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<ButtonStyled v-if="playing" size="large" color="red" circular>
|
||||
<button
|
||||
v-tooltip="'Stop'"
|
||||
:class="{ 'scale-100 opacity-100': playing }"
|
||||
class="transition-all scale-75 origin-bottom opacity-0 card-shadow"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<SpinnerIcon
|
||||
v-else-if="modLoading || installing"
|
||||
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
|
||||
class="animate-spin w-8 h-8"
|
||||
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>
|
||||
<button
|
||||
v-tooltip="'Play'"
|
||||
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) => play(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<PlayIcon class="translate-x-[2px]" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
|
||||
{{ instance.name }}
|
||||
</p>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
|
||||
<GameIcon class="shrink-0" />
|
||||
<span class="text-sm capitalize">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ImportIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { inject, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import frog from '../../assets/welcome/frog.png'
|
||||
import iconLights from '../../assets/welcome/icon-lights.svg?url'
|
||||
import iconLogo from '../../assets/welcome/icon-logo.svg?url'
|
||||
import iconTexture from '../../assets/welcome/icon-texture.png'
|
||||
|
||||
const showCreationModal = inject<() => void>('showCreationModal')
|
||||
const showImportModal = inject<() => void>('showImportModal')
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
|
||||
function handleOffline() {
|
||||
offline.value = true
|
||||
}
|
||||
|
||||
function handleOnline() {
|
||||
offline.value = false
|
||||
}
|
||||
|
||||
function handleQuickCreate(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (
|
||||
event.key.toLowerCase() !== 'n' ||
|
||||
event.repeat ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
target?.isContentEditable ||
|
||||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? '')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!offline.value) {
|
||||
event.preventDefault()
|
||||
showCreationModal?.()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('offline', handleOffline)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('keydown', handleQuickCreate)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('keydown', handleQuickCreate)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-h-full grid-rows-[30.8125rem_1fr_auto] px-6 pb-6 pt-16">
|
||||
<div class="relative flex items-end justify-center">
|
||||
<div
|
||||
class="dot-pattern pointer-events-none absolute left-1/2 top-0 h-[29.875rem] w-[min(25.9375rem,80vw)] -translate-x-1/2 rounded-2xl [@media(max-height:700px)]:h-[23rem]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="relative flex flex-col items-center gap-6">
|
||||
<div
|
||||
class="welcome-icon relative h-[6.25rem] w-[6.25rem] overflow-hidden rounded-3xl border-[1.5px] border-solid bg-surface-1"
|
||||
style="border-color: color-mix(in srgb, var(--color-text-primary) 15%, transparent)"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-[linear-gradient(180deg,rgb(37_114_79)_0%,rgb(15_64_36)_100%)] opacity-50"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-[linear-gradient(-14deg,rgb(0_0_0_/_37%)_8%,transparent_86%)]"
|
||||
/>
|
||||
<img
|
||||
:src="iconTexture"
|
||||
alt=""
|
||||
class="absolute left-1/2 top-1/2 h-[12.70625rem] w-[19.96875rem] max-w-none -translate-x-1/2 -translate-y-1/2 object-cover opacity-40 mix-blend-luminosity"
|
||||
/>
|
||||
<img :src="iconLogo" alt="" class="absolute inset-[13%] h-[74%] w-[74%]" />
|
||||
<img
|
||||
:src="iconLights"
|
||||
alt=""
|
||||
class="absolute left-[0.4775rem] top-[0.4775rem] h-[0.4804rem] w-[1.2714rem]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<h1 class="m-0 flex items-center gap-2 text-2xl font-semibold leading-8 text-contrast">
|
||||
Welcome to Modrinth
|
||||
<img :src="frog" alt="" class="h-8 w-8 [image-rendering:pixelated]" />
|
||||
</h1>
|
||||
<p class="m-0 text-center text-base leading-6 text-primary">
|
||||
Minecraft was never meant to fit in one jar.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex w-72 flex-col items-center gap-4">
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<button class="!shadow-none" :disabled="offline" @click="showCreationModal?.()">
|
||||
<PlusIcon />
|
||||
Create an instance
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span class="flex items-center gap-1 text-sm leading-5 text-secondary">
|
||||
Press
|
||||
<kbd
|
||||
class="inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-solid border-surface-5 bg-button-bg px-1 text-xs font-normal leading-4 text-primary"
|
||||
>N</kbd
|
||||
>
|
||||
to quick create an instance
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-end gap-4 text-sm leading-5 text-secondary">
|
||||
<span class="whitespace-nowrap">Escaping another launcher?</span>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!h-10 !gap-2 !rounded-[0.875rem] !px-4 !py-2.5 !font-medium"
|
||||
:disabled="offline"
|
||||
@click="showImportModal?.()"
|
||||
>
|
||||
<ImportIcon />
|
||||
Import from launcher
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dot-pattern {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--color-text-primary) 25%, transparent) 0.5px,
|
||||
transparent 0.75px
|
||||
);
|
||||
background-size: 0.5625rem 0.5625rem;
|
||||
opacity: 0.8;
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
|
||||
mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
box-shadow:
|
||||
0 0 0 1.5px #07180d,
|
||||
0 1.5rem 3rem rgb(0 0 0 / 3%),
|
||||
0 0.625rem 1.125rem rgb(0 0 0 / 3%),
|
||||
0 0.3125rem 0.5rem rgb(0 0 0 / 4%),
|
||||
0 0.125rem 0.25rem rgb(0 0 0 / 4%),
|
||||
0 0.625rem 13.75rem color-mix(in srgb, var(--color-brand) 32%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
|
||||
<button
|
||||
v-if="showUpdatePill"
|
||||
type="button"
|
||||
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out"
|
||||
:class="{
|
||||
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
|
||||
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
|
||||
}"
|
||||
:disabled="isUpdateDownloading"
|
||||
:aria-busy="isUpdateDownloading"
|
||||
@click="handleUpdateClick"
|
||||
>
|
||||
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
|
||||
<DownloadIcon v-else />
|
||||
<span v-if="isUpdateDownloading">
|
||||
{{ formatMessage(messages.downloadingUpdate) }}
|
||||
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
|
||||
</span>
|
||||
<span v-else>{{ updateLabel }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
installAvailableAppUpdate,
|
||||
} from '@/providers/app-update'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
update: {
|
||||
id: 'app.action-bar.update',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
downloadingUpdate: {
|
||||
id: 'app.action-bar.downloading-update',
|
||||
defaultMessage: 'Downloading update',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.action-bar.reload-to-update',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
downloading,
|
||||
downloadPercent,
|
||||
downloadProgress,
|
||||
finishedDownloading,
|
||||
isVisible: isUpdateVisible,
|
||||
metered,
|
||||
restarting,
|
||||
} = appUpdateState
|
||||
|
||||
const isUpdateDownloading = computed(
|
||||
() =>
|
||||
downloading.value ||
|
||||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
|
||||
)
|
||||
const showUpdatePill = computed(
|
||||
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
|
||||
)
|
||||
const animateReadyPill = ref(false)
|
||||
const updateLabel = computed(() => {
|
||||
if (isUpdateDownloading.value) {
|
||||
return formatMessage(messages.downloadingUpdate)
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
return formatMessage(messages.reloadToUpdate)
|
||||
}
|
||||
|
||||
return formatMessage(messages.update)
|
||||
})
|
||||
let readyPillAnimationFrame: number | null = null
|
||||
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
readyPillAnimationFrame = null
|
||||
}
|
||||
|
||||
if (!show || !ready) {
|
||||
animateReadyPill.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (wasShown && wasReady) {
|
||||
return
|
||||
}
|
||||
|
||||
animateReadyPill.value = false
|
||||
await nextTick()
|
||||
readyPillAnimationFrame = requestAnimationFrame(() => {
|
||||
animateReadyPill.value = true
|
||||
readyPillAnimationFrame = null
|
||||
})
|
||||
})
|
||||
async function handleUpdateClick() {
|
||||
if (isUpdateDownloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedDownloading.value) {
|
||||
await installAvailableAppUpdate()
|
||||
} else {
|
||||
await downloadAvailableAppUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (readyPillAnimationFrame !== null) {
|
||||
cancelAnimationFrame(readyPillAnimationFrame)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
@@ -10,18 +10,12 @@ import {
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { friend_listener } from '@/helpers/events'
|
||||
import {
|
||||
add_friend,
|
||||
friends,
|
||||
type FriendWithUserData,
|
||||
remove_friend,
|
||||
transformFriends,
|
||||
} from '@/helpers/friends.ts'
|
||||
import { useFriends } from '@/composables/use-friends'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -35,36 +29,23 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const userCredentials = computed(() => props.credentials)
|
||||
const {
|
||||
friends: userFriends,
|
||||
loading,
|
||||
requestFriend,
|
||||
acceptFriend,
|
||||
removeFriend: removeFriendRecord,
|
||||
} = useFriends({
|
||||
currentUserId: () => userCredentials.value?.user_id,
|
||||
getCredentials: () => userCredentials.value,
|
||||
onError: handleError,
|
||||
})
|
||||
|
||||
const search = ref('')
|
||||
const friendInvitesModal = ref()
|
||||
|
||||
const username = ref('')
|
||||
const addFriendModal = ref()
|
||||
async function addFriendFromModal() {
|
||||
addFriendModal.value.hide()
|
||||
await add_friend(username.value).catch(handleError)
|
||||
username.value = ''
|
||||
await loadFriends()
|
||||
}
|
||||
|
||||
async function addFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await add_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await remove_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
const userFriends = ref<FriendWithUserData[]>([])
|
||||
const sortedFriends = computed<FriendWithUserData[]>(() =>
|
||||
userFriends.value.slice().sort((a, b) => {
|
||||
if (a.last_updated === null && b.last_updated === null) {
|
||||
@@ -108,42 +89,22 @@ const incomingRequests = computed(() =>
|
||||
.sort((a, b) => b.created.diff(a.created)),
|
||||
)
|
||||
|
||||
const loading = ref(true)
|
||||
async function loadFriends(timeout = false) {
|
||||
loading.value = timeout
|
||||
function addFriendFromModal() {
|
||||
const target = username.value.trim()
|
||||
if (!target) return
|
||||
|
||||
try {
|
||||
const friendsList = await friends()
|
||||
userFriends.value = await transformFriends(friendsList, userCredentials.value)
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error('Error loading friends', e)
|
||||
if (timeout) {
|
||||
setTimeout(() => loadFriends(), 15 * 1000)
|
||||
}
|
||||
}
|
||||
addFriendModal.value.hide()
|
||||
requestFriend({ id: target, username: target })
|
||||
username.value = ''
|
||||
}
|
||||
|
||||
watch(
|
||||
userCredentials,
|
||||
() => {
|
||||
if (userCredentials.value === undefined) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else if (userCredentials.value === null) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else {
|
||||
loadFriends(true)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
function addFriend(friend: FriendWithUserData) {
|
||||
acceptFriend(friend)
|
||||
}
|
||||
|
||||
const unlisten = await friend_listener(() => loadFriends())
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
})
|
||||
function removeFriend(friend: FriendWithUserData) {
|
||||
removeFriendRecord(friend)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
addFriend: {
|
||||
@@ -301,11 +262,12 @@ const messages = defineMessages({
|
||||
</ButtonStyled>
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchFriends)"
|
||||
clearable
|
||||
variant="outlined"
|
||||
wrapper-class="flex-1"
|
||||
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
|
||||
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
|
||||
@keyup.esc="search = ''"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -54,7 +55,7 @@ function createContextMenuOptions(friend: FriendWithUserData) {
|
||||
}
|
||||
|
||||
function openProfile(username: string) {
|
||||
openUrl('https://modrinth.com/user/' + username)
|
||||
void router.push(`/user/${encodeURIComponent(username)}`)
|
||||
}
|
||||
|
||||
const friendOptions = useTemplateRef('friendOptions')
|
||||
|
||||
@@ -1,108 +1,41 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" :on-hide="reset">
|
||||
<div class="max-w-[31rem] flex flex-col gap-6">
|
||||
<Admonition
|
||||
type="warning"
|
||||
:header="formatMessage(messages.warningTitle)"
|
||||
:body="formatMessage(messages.warningBody)"
|
||||
/>
|
||||
<div v-if="fileName" class="overflow-x-auto whitespace-nowrap text-sm text-secondary">
|
||||
{{ fileName }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="mt-0 leading-tight">
|
||||
{{ formatMessage(messages.body) }}
|
||||
</p>
|
||||
<p class="text-orange font-semibold mb-0 leading-tight">
|
||||
{{ formatMessage(messages.malwareStatement) }}
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="cancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="isProceeding" @click="proceed">
|
||||
<SpinnerIcon v-if="isProceeding" class="animate-spin" />
|
||||
<CircleArrowRightIcon v-else />
|
||||
{{ formatMessage(messages.installAnyway) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<UnknownFileWarningModal
|
||||
ref="modal"
|
||||
:mode="externalFilesInModpack.length > 0 ? 'modpack' : 'mod'"
|
||||
:file-name="fileName"
|
||||
:external-files-in-modpack="externalFilesInModpack"
|
||||
@cancel="reset"
|
||||
@continue="proceed"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CircleArrowRightIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { UnknownFileWarningModal } from '@modrinth/ui'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings'
|
||||
import { useTheming } from '@/store/state'
|
||||
import type { FeatureFlag } from '@/store/theme.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
|
||||
|
||||
const dontShowAgain = ref(false)
|
||||
const modal = useTemplateRef('modal')
|
||||
const onProceed = ref<() => Promise<void>>()
|
||||
const isProceeding = ref(false)
|
||||
const fileName = ref('')
|
||||
const externalFilesInModpack = ref<string[]>([])
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'unknown-pack-warning-modal.header',
|
||||
defaultMessage: 'Confirm installation',
|
||||
},
|
||||
warningTitle: {
|
||||
id: 'unknown-pack-warning-modal.warning.title',
|
||||
defaultMessage: 'Unknown file warning',
|
||||
},
|
||||
warningBody: {
|
||||
id: 'unknown-pack-warning-modal.warning.body',
|
||||
defaultMessage: `We couldn't find this file on Modrinth. We strongly recommend only installing files from sources you trust.`,
|
||||
},
|
||||
body: {
|
||||
id: 'unknown-pack-warning-modal.body',
|
||||
defaultMessage: `A file is only reviewed if it’s uploaded to Modrinth, regardless of its file format (including .mrpack).`,
|
||||
},
|
||||
malwareStatement: {
|
||||
id: 'unknown-pack-warning-modal.malware-statement',
|
||||
defaultMessage: `Malware is often distributed through modpack files by sharing them on platforms like Discord.`,
|
||||
},
|
||||
dontShowAgain: {
|
||||
id: 'unknown-pack-warning-modal.dont-show-again',
|
||||
defaultMessage: `Don't show this warning again`,
|
||||
},
|
||||
installAnyway: {
|
||||
id: 'unknown-pack-warning-modal.install-anyway',
|
||||
defaultMessage: `Install anyway`,
|
||||
},
|
||||
})
|
||||
|
||||
function show(createInstance: () => Promise<void>, selectedFileName = '') {
|
||||
function show(
|
||||
createInstance: () => Promise<void>,
|
||||
selectedFileName = '',
|
||||
selectedExternalFiles: string[] = [],
|
||||
) {
|
||||
onProceed.value = createInstance
|
||||
fileName.value = selectedFileName
|
||||
dontShowAgain.value = false
|
||||
externalFilesInModpack.value = selectedExternalFiles
|
||||
|
||||
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
|
||||
// noinspection ES6MissingAwait
|
||||
createInstance()
|
||||
void createInstance()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,18 +45,11 @@ function show(createInstance: () => Promise<void>, selectedFileName = '') {
|
||||
function reset() {
|
||||
onProceed.value = undefined
|
||||
fileName.value = ''
|
||||
externalFilesInModpack.value = []
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
async function proceed() {
|
||||
if (!onProceed.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dontShowAgain.value) {
|
||||
async function proceed(dontShowAgain: boolean) {
|
||||
if (dontShowAgain) {
|
||||
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
|
||||
const settings = await getSettings()
|
||||
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
|
||||
@@ -131,9 +57,8 @@ async function proceed() {
|
||||
}
|
||||
|
||||
const createInstance = onProceed.value
|
||||
modal.value?.hide()
|
||||
// noinspection ES6MissingAwait
|
||||
createInstance()
|
||||
reset()
|
||||
if (createInstance) void createInstance()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<PageHeader :title="instance.name">
|
||||
<template #leading>
|
||||
<Avatar :src="iconSrc" :alt="instance.name" size="64px" :tint-by="instance.id" />
|
||||
</template>
|
||||
|
||||
<template v-if="instance.shared_instance || instance.quarantined" #badges>
|
||||
<PageHeaderBadgeItem
|
||||
v-if="instance.quarantined"
|
||||
:icon="LockIcon"
|
||||
aria-label="Locked instance information"
|
||||
class="!border-orange !bg-highlight-orange !text-orange"
|
||||
>
|
||||
Locked
|
||||
</PageHeaderBadgeItem>
|
||||
<PageHeaderBadgeItem
|
||||
v-else
|
||||
:icon="UnknownIcon"
|
||||
:tooltip="sharedInstanceTooltip"
|
||||
aria-label="Shared instance information"
|
||||
class="!border-blue !bg-highlight-blue !text-blue"
|
||||
>
|
||||
Shared
|
||||
</PageHeaderBadgeItem>
|
||||
</template>
|
||||
|
||||
<template #metadata>
|
||||
<div v-if="isServerInstance" class="flex flex-wrap items-center gap-2">
|
||||
<InstanceHeaderServerMetadata
|
||||
:loading-server-ping="loadingServerPing"
|
||||
:players-online="playersOnline"
|
||||
:status-online="statusOnline"
|
||||
:recent-plays="recentPlays"
|
||||
:ping="ping"
|
||||
:minecraft-server="minecraftServer"
|
||||
:linked-project-v3="linkedProjectV3"
|
||||
:instance-id="instance.id"
|
||||
/>
|
||||
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
|
||||
{{ sharedInstanceManagerLabel }}
|
||||
<Avatar
|
||||
:src="sharedInstanceManager.avatarUrl"
|
||||
:alt="sharedInstanceManager.name"
|
||||
:tint-by="sharedInstanceManager.tintBy"
|
||||
size="24px"
|
||||
:circle="sharedInstanceManager.type === 'user'"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
|
||||
</PageHeaderMetadataItem>
|
||||
</div>
|
||||
<PageHeaderMetadata v-else>
|
||||
<PageHeaderMetadataItem :icon="Gamepad2Icon" tooltip="Minecraft version">
|
||||
Minecraft {{ instance.game_version }}
|
||||
</PageHeaderMetadataItem>
|
||||
<PageHeaderMetadataItem
|
||||
v-if="sharedInstanceManager?.type !== 'user'"
|
||||
:icon="ServerLoaderIcon"
|
||||
:icon-props="{ loader: loaderDisplayName }"
|
||||
tooltip="Mod loader"
|
||||
>
|
||||
{{ loaderLabel }}
|
||||
</PageHeaderMetadataItem>
|
||||
<PageHeaderMetadataItem
|
||||
v-if="showInstancePlayTime"
|
||||
:icon="TimerIcon"
|
||||
tooltip="Total playtime"
|
||||
>
|
||||
{{ playtimeLabel }}
|
||||
</PageHeaderMetadataItem>
|
||||
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
|
||||
{{ sharedInstanceManagerLabel }}
|
||||
<Avatar
|
||||
:src="sharedInstanceManager.avatarUrl"
|
||||
:alt="sharedInstanceManager.name"
|
||||
:tint-by="sharedInstanceManager.tintBy"
|
||||
size="24px"
|
||||
:circle="sharedInstanceManager.type === 'user'"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
|
||||
</PageHeaderMetadataItem>
|
||||
</PageHeaderMetadata>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<PageHeaderActions>
|
||||
<ButtonStyled v-if="isInstalling" color="brand" size="large">
|
||||
<button type="button" disabled>
|
||||
{{ formatMessage(commonMessages.installingLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="playing" color="red" size="large">
|
||||
<button type="button" :disabled="stopping" @click="emit('stop')">
|
||||
<StopCircleIcon />
|
||||
{{
|
||||
stopping ? formatMessage(messages.stopping) : formatMessage(commonMessages.stopButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="instance.quarantined" color="brand" size="large">
|
||||
<button v-tooltip="formatMessage(messages.lockedPlayTooltip)" type="button" disabled>
|
||||
<PlayIcon />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="instance.install_stage !== 'installed'" color="brand" size="large">
|
||||
<button type="button" @click="emit('repair')">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.repair) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<JoinedButtons
|
||||
v-else-if="!loading && isServerInstance"
|
||||
:actions="serverPlayActions"
|
||||
color="brand"
|
||||
size="large"
|
||||
/>
|
||||
<ButtonStyled v-else-if="!loading" color="brand" size="large">
|
||||
<button type="button" @click="emit('play')">
|
||||
<PlayIcon />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" size="large">
|
||||
<button type="button" disabled>{{ formatMessage(messages.starting) }}</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled circular size="large">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.instanceSettings)"
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.instanceSettings)"
|
||||
@click="emit('settings')"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="large" type="transparent">
|
||||
<TeleportOverflowMenu
|
||||
:options="moreActions"
|
||||
:tooltip="formatMessage(messages.moreActions)"
|
||||
:aria-label="formatMessage(messages.moreActions)"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</PageHeaderActions>
|
||||
</template>
|
||||
</PageHeader>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
FolderOpenIcon,
|
||||
LockIcon,
|
||||
MoreVerticalIcon,
|
||||
PackageIcon,
|
||||
PlayIcon,
|
||||
ReportIcon,
|
||||
SettingsIcon,
|
||||
StopCircleIcon,
|
||||
TagCategoryGamepad2Icon as Gamepad2Icon,
|
||||
TimerIcon,
|
||||
UnknownIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
type JoinedButtonAction,
|
||||
JoinedButtons,
|
||||
LoaderIcon as ServerLoaderIcon,
|
||||
PageHeader,
|
||||
PageHeaderActions,
|
||||
PageHeaderBadgeItem,
|
||||
PageHeaderMetadata,
|
||||
PageHeaderMetadataItem,
|
||||
type ServerLoader,
|
||||
TeleportOverflowMenu,
|
||||
type TeleportOverflowMenuItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import InstanceHeaderServerMetadata from './instance-page-header-server-metadata.vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
createShortcut: {
|
||||
id: 'instance.action.create-shortcut',
|
||||
defaultMessage: 'Create shortcut',
|
||||
},
|
||||
exportModpack: {
|
||||
id: 'instance.action.export-modpack',
|
||||
defaultMessage: 'Export modpack',
|
||||
},
|
||||
instanceSettings: {
|
||||
id: 'instance.action.settings',
|
||||
defaultMessage: 'Instance settings',
|
||||
},
|
||||
launchInstance: {
|
||||
id: 'instance.action.launch-instance',
|
||||
defaultMessage: 'Launch instance',
|
||||
},
|
||||
moreActions: {
|
||||
id: 'instance.action.more-actions',
|
||||
defaultMessage: 'More actions',
|
||||
},
|
||||
neverPlayed: {
|
||||
id: 'instance.playtime.never-played',
|
||||
defaultMessage: 'Never played',
|
||||
},
|
||||
openFolder: {
|
||||
id: 'instance.action.open-folder',
|
||||
defaultMessage: 'Open folder',
|
||||
},
|
||||
repair: {
|
||||
id: 'instance.action.repair',
|
||||
defaultMessage: 'Repair',
|
||||
},
|
||||
lockedPlayTooltip: {
|
||||
id: 'instance.locked.play-tooltip',
|
||||
defaultMessage: 'This instance has been locked',
|
||||
},
|
||||
starting: {
|
||||
id: 'instance.action.starting',
|
||||
defaultMessage: 'Starting...',
|
||||
},
|
||||
stopping: {
|
||||
id: 'instance.action.stopping',
|
||||
defaultMessage: 'Stopping...',
|
||||
},
|
||||
sharedInstanceTooltip: {
|
||||
id: 'instance.shared-instance.tooltip',
|
||||
defaultMessage: "This instance's content is being managed by someone else.",
|
||||
},
|
||||
sharedInstanceOwnerTooltip: {
|
||||
id: 'instance.shared-instance.owner-tooltip',
|
||||
defaultMessage: "This instance's content is being shared to other users.",
|
||||
},
|
||||
})
|
||||
const router = useRouter()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
iconSrc?: string | null
|
||||
isServerInstance?: boolean
|
||||
showInstancePlayTime?: boolean
|
||||
timePlayed?: number
|
||||
playing?: boolean
|
||||
loading?: boolean
|
||||
stopping?: boolean
|
||||
loadingServerPing?: boolean
|
||||
playersOnline?: number
|
||||
statusOnline?: boolean
|
||||
recentPlays?: number
|
||||
ping?: number
|
||||
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
|
||||
linkedProjectV3?: Labrinth.Projects.v3.Project
|
||||
sharedInstanceManager?: {
|
||||
type: 'user' | 'server'
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
tintBy: string
|
||||
} | null
|
||||
}>(),
|
||||
{
|
||||
iconSrc: null,
|
||||
isServerInstance: false,
|
||||
showInstancePlayTime: false,
|
||||
timePlayed: 0,
|
||||
playing: false,
|
||||
loading: false,
|
||||
stopping: false,
|
||||
loadingServerPing: false,
|
||||
playersOnline: undefined,
|
||||
statusOnline: false,
|
||||
recentPlays: undefined,
|
||||
ping: undefined,
|
||||
minecraftServer: undefined,
|
||||
linkedProjectV3: undefined,
|
||||
sharedInstanceManager: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
repair: []
|
||||
stop: []
|
||||
play: []
|
||||
playServer: []
|
||||
settings: []
|
||||
openFolder: []
|
||||
export: []
|
||||
createShortcut: []
|
||||
report: [event?: MouseEvent]
|
||||
}>()
|
||||
|
||||
const installingStages = [
|
||||
'installing',
|
||||
'pack_installing',
|
||||
'pack_installed',
|
||||
'not_installed',
|
||||
'minecraft_installing',
|
||||
]
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const isInstalling = computed(() => installingStages.includes(props.instance.install_stage))
|
||||
const loaderDisplayName = computed(() => formatLoaderLabel(props.instance.loader) as ServerLoader)
|
||||
const loaderLabel = computed(() =>
|
||||
[loaderDisplayName.value, props.instance.loader_version].filter(Boolean).join(' '),
|
||||
)
|
||||
const sharedInstanceTooltip = computed(() =>
|
||||
formatMessage(
|
||||
props.instance.shared_instance?.role === 'owner'
|
||||
? messages.sharedInstanceOwnerTooltip
|
||||
: messages.sharedInstanceTooltip,
|
||||
),
|
||||
)
|
||||
const sharedInstanceManagerLabel = computed(() =>
|
||||
props.sharedInstanceManager?.type === 'server' ? 'Linked to' : 'Managed by',
|
||||
)
|
||||
const sharedInstanceManagerAction = computed(() => {
|
||||
const manager = props.sharedInstanceManager
|
||||
if (manager?.type !== 'user') return undefined
|
||||
return () => router.push(`/user/${encodeURIComponent(manager.name)}`)
|
||||
})
|
||||
const playtimeLabel = computed(() => {
|
||||
if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed)
|
||||
|
||||
const hours = Math.floor(props.timePlayed / 3600)
|
||||
if (hours >= 1) {
|
||||
return `${hours} hour${hours > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(props.timePlayed / 60)
|
||||
if (minutes >= 1) {
|
||||
return `${minutes} minute${minutes > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
const seconds = Math.floor(props.timePlayed)
|
||||
return `${seconds} second${seconds > 1 ? 's' : ''}`
|
||||
})
|
||||
const serverPlayActions = computed<JoinedButtonAction[]>(() => [
|
||||
{
|
||||
id: 'join_server',
|
||||
label: formatMessage(commonMessages.playButton),
|
||||
icon: PlayIcon,
|
||||
action: () => emit('playServer'),
|
||||
},
|
||||
{
|
||||
id: 'launch_instance',
|
||||
label: formatMessage(messages.launchInstance),
|
||||
icon: PlayIcon,
|
||||
action: () => emit('play'),
|
||||
},
|
||||
])
|
||||
const moreActions = computed<TeleportOverflowMenuItem[]>(() => {
|
||||
const actions: TeleportOverflowMenuItem[] = [
|
||||
{
|
||||
id: 'open-folder',
|
||||
label: formatMessage(messages.openFolder),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => emit('openFolder'),
|
||||
},
|
||||
]
|
||||
|
||||
if (!props.instance.quarantined) {
|
||||
actions.push(
|
||||
{
|
||||
id: 'export-mrpack',
|
||||
label: formatMessage(messages.exportModpack),
|
||||
icon: PackageIcon,
|
||||
action: () => emit('export'),
|
||||
},
|
||||
{
|
||||
id: 'create-shortcut',
|
||||
label: formatMessage(messages.createShortcut),
|
||||
icon: ExternalIcon,
|
||||
action: () => emit('createShortcut'),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (props.instance.shared_instance?.role === 'member') {
|
||||
actions.push(
|
||||
{ divider: true },
|
||||
{
|
||||
id: 'report-shared-instance',
|
||||
label: formatMessage(commonMessages.reportButton),
|
||||
icon: ReportIcon,
|
||||
color: 'red',
|
||||
action: (event) => emit('report', event),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return actions
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<template v-if="loadingServerPing">
|
||||
<ServerOnlinePlayers
|
||||
v-if="playersOnline !== undefined"
|
||||
:online="playersOnline"
|
||||
:status-online="statusOnline"
|
||||
hide-label
|
||||
/>
|
||||
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
|
||||
<div
|
||||
v-if="
|
||||
(playersOnline !== undefined || recentPlays !== undefined) &&
|
||||
(minecraftServer?.region || ping)
|
||||
"
|
||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
<ServerPing v-if="ping" :ping="ping" />
|
||||
</template>
|
||||
|
||||
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
|
||||
|
||||
<div v-if="minecraftServer?.region || ping" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div v-if="linkedProjectV3" class="flex gap-1.5 items-center font-medium text-primary">
|
||||
Linked to
|
||||
<Avatar
|
||||
:src="linkedProjectV3.icon_url"
|
||||
:alt="linkedProjectV3.name"
|
||||
:tint-by="instanceId"
|
||||
size="24px"
|
||||
/>
|
||||
<router-link
|
||||
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
||||
class="hover:underline text-primary truncate"
|
||||
>
|
||||
{{ linkedProjectV3.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
Avatar,
|
||||
ServerOnlinePlayers,
|
||||
ServerPing,
|
||||
ServerRecentPlays,
|
||||
ServerRegion,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
loadingServerPing?: boolean
|
||||
playersOnline?: number
|
||||
statusOnline?: boolean
|
||||
recentPlays?: number
|
||||
ping?: number
|
||||
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
|
||||
linkedProjectV3?: Labrinth.Projects.v3.Project
|
||||
instanceId?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<StackedAdmonitions v-bind="$attrs" :items="stackItems" class="w-full">
|
||||
<template #item="{ item, dismissible }">
|
||||
<InstanceAdmonitionsSharedInstanceStale
|
||||
v-if="item.kind === 'shared-instance-stale'"
|
||||
:instance="instance"
|
||||
@published="emit('published')"
|
||||
/>
|
||||
<InstanceAdmonitionsSharedInstanceWrongAccount
|
||||
v-else-if="item.kind === 'shared-instance-wrong-account'"
|
||||
:expected-user-id="sharedInstanceExpectedUserId"
|
||||
:role="sharedInstanceRole"
|
||||
:signed-out="sharedInstanceSignedOut"
|
||||
/>
|
||||
<InstanceAdmonitionsSharedInstanceUnavailable
|
||||
v-else-if="item.kind === 'shared-instance-unavailable'"
|
||||
:reason="displayedSharedInstanceUnavailableReason"
|
||||
:manager="sharedInstanceUnavailableManager"
|
||||
:dismissible="dismissible"
|
||||
@dismiss="sharedInstanceUnavailableDismissed = true"
|
||||
@delete="emit('delete')"
|
||||
/>
|
||||
</template>
|
||||
</StackedAdmonitions>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { StackedAdmonitions } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
|
||||
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
|
||||
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
|
||||
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
sharedInstanceUnavailableReason?: SharedInstanceUnavailableReason | null
|
||||
sharedInstanceUnavailableManager?: string | null
|
||||
sharedInstanceWrongAccount?: boolean
|
||||
sharedInstanceExpectedUserId?: string | null
|
||||
sharedInstanceRole?: SharedInstanceRole | null
|
||||
sharedInstanceSignedOut?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
published: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
|
||||
const displayedSharedInstanceUnavailableReason = computed<SharedInstanceUnavailableReason | null>(
|
||||
() =>
|
||||
props.instance.quarantined ? 'quarantined' : (props.sharedInstanceUnavailableReason ?? null),
|
||||
)
|
||||
const sharedInstanceUnavailableDismissed = ref(false)
|
||||
const showSharedInstancePublishAdmonition = computed(
|
||||
() =>
|
||||
!sharedInstanceWrongAccount.value &&
|
||||
props.instance.install_stage === 'installed' &&
|
||||
props.instance.shared_instance?.role === 'owner' &&
|
||||
props.instance.shared_instance.status === 'stale',
|
||||
)
|
||||
|
||||
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
|
||||
const items: InstanceAdmonitionItem[] = []
|
||||
|
||||
if (sharedInstanceWrongAccount.value) {
|
||||
items.push({
|
||||
id: 'shared-instance-wrong-account',
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
kind: 'shared-instance-wrong-account',
|
||||
})
|
||||
}
|
||||
|
||||
const unavailableReason = displayedSharedInstanceUnavailableReason.value
|
||||
const sharedInstanceQuarantined = unavailableReason === 'quarantined'
|
||||
if (
|
||||
unavailableReason &&
|
||||
(sharedInstanceQuarantined || !sharedInstanceUnavailableDismissed.value)
|
||||
) {
|
||||
items.push({
|
||||
id: 'shared-instance-unavailable',
|
||||
type: 'warning',
|
||||
dismissible: !sharedInstanceQuarantined,
|
||||
kind: 'shared-instance-unavailable',
|
||||
})
|
||||
}
|
||||
|
||||
if (showSharedInstancePublishAdmonition.value) {
|
||||
items.push({
|
||||
id: 'shared-instance-stale',
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
kind: 'shared-instance-stale',
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.instance.id, displayedSharedInstanceUnavailableReason.value],
|
||||
() => {
|
||||
sharedInstanceUnavailableDismissed.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineMessages } from '@modrinth/ui'
|
||||
|
||||
export const instanceAdmonitionsMessages = defineMessages({
|
||||
sharedInstanceChangesHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.changes-header',
|
||||
defaultMessage: "Your changes haven't been shared yet",
|
||||
},
|
||||
sharedInstanceChangesBody: {
|
||||
id: 'app.instance.admonitions.shared-instance.changes-body',
|
||||
defaultMessage: "Your local instance is ahead of the users you've shared it with.",
|
||||
},
|
||||
sharedInstancePublishButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.publish-button',
|
||||
defaultMessage: 'Push update',
|
||||
},
|
||||
sharedInstancePublishingButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.publishing-button',
|
||||
defaultMessage: 'Pushing...',
|
||||
},
|
||||
sharedInstanceReviewingButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.reviewing-button',
|
||||
defaultMessage: 'Reviewing...',
|
||||
},
|
||||
sharedInstanceReviewHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-header',
|
||||
defaultMessage: 'Review changes',
|
||||
},
|
||||
sharedInstanceReviewAdmonitionHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-admonition-header',
|
||||
defaultMessage: 'Push update',
|
||||
},
|
||||
sharedInstanceReviewDescription: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-description',
|
||||
defaultMessage:
|
||||
'Review the content changes that will be shared with everyone using this instance.',
|
||||
},
|
||||
sharedInstanceAddedLabel: {
|
||||
id: 'app.instance.admonitions.shared-instance.added-label',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
sharedInstanceRemovedLabel: {
|
||||
id: 'app.instance.admonitions.shared-instance.removed-label',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
sharedInstanceWrongAccountHeader: {
|
||||
id: 'app.instance.shared-instance-wrong-account.warning-header',
|
||||
defaultMessage: 'You are using the wrong Modrinth account',
|
||||
},
|
||||
sharedInstanceSignedOutHeader: {
|
||||
id: 'app.instance.shared-instance-wrong-account.signed-out-header',
|
||||
defaultMessage: 'You need to sign in to Modrinth',
|
||||
},
|
||||
sharedInstanceWrongAccountSignInAs: {
|
||||
id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
|
||||
defaultMessage: 'Sign in as',
|
||||
},
|
||||
sharedInstanceWrongAccountUserBody: {
|
||||
id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
|
||||
defaultMessage: 'to receive updates for this shared instance.',
|
||||
},
|
||||
sharedInstanceWrongAccountOwnerBody: {
|
||||
id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
|
||||
defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
|
||||
},
|
||||
sharedInstanceWrongAccountFallbackUsername: {
|
||||
id: 'app.instance.shared-instance-wrong-account.fallback-username',
|
||||
defaultMessage: 'the linked account',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<Admonition
|
||||
type="info"
|
||||
inline-actions
|
||||
:header="formatMessage(messages.sharedInstanceChangesHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.sharedInstanceChangesBody) }}
|
||||
<template #actions>
|
||||
<ButtonStyled color="blue">
|
||||
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
|
||||
<SpinnerIcon
|
||||
v-if="isReviewingPublish || isPublishing"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<UploadIcon v-else aria-hidden="true" />
|
||||
{{
|
||||
isPublishing
|
||||
? formatMessage(messages.sharedInstancePublishingButton)
|
||||
: isReviewingPublish
|
||||
? formatMessage(messages.sharedInstanceReviewingButton)
|
||||
: formatMessage(messages.sharedInstancePublishButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<SharedInstancePublishModal
|
||||
ref="publishModal"
|
||||
:instance="instance"
|
||||
@published="emit('published')"
|
||||
@state-change="publishState = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon, UploadIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
published: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const publishModal = ref<InstanceType<typeof SharedInstancePublishModal>>()
|
||||
const publishState = ref<'idle' | 'reviewing' | 'publishing'>('idle')
|
||||
const isPublishing = computed(() => publishState.value === 'publishing')
|
||||
const isReviewingPublish = computed(() => publishState.value === 'reviewing')
|
||||
|
||||
const isPublishButtonDisabled = computed(() => isPublishing.value || isReviewingPublish.value)
|
||||
|
||||
function reviewChanges(e?: MouseEvent) {
|
||||
publishModal.value?.show(e)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<Admonition
|
||||
type="warning"
|
||||
:inline-actions="reason === 'quarantined'"
|
||||
:header="formatMessage(sharedInstanceUnavailableTitleMessage(reason ?? null))"
|
||||
:dismissible="dismissible"
|
||||
@dismiss="emit('dismiss')"
|
||||
>
|
||||
{{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
|
||||
<template v-if="reason === 'quarantined'" #actions>
|
||||
<ButtonStyled color="orange">
|
||||
<button class="!h-10" @click="emit('delete')">
|
||||
<TrashIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.deleteInstance) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
|
||||
import {
|
||||
sharedInstanceUnavailableTitleMessage,
|
||||
useSharedInstanceErrors,
|
||||
} from '@/helpers/shared-instance-errors'
|
||||
|
||||
defineProps<{
|
||||
reason?: SharedInstanceUnavailableReason | null
|
||||
manager?: string | null
|
||||
dismissible?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { formatSharedInstanceUnavailable } = useSharedInstanceErrors()
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteInstance: {
|
||||
id: 'instance.locked.delete-button',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<Admonition type="warning" :header="formatMessage(headerMessage)">
|
||||
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<span>{{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}</span>
|
||||
<span
|
||||
v-if="sharedInstanceExpectedUser"
|
||||
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-contrast"
|
||||
>
|
||||
<Avatar
|
||||
:src="sharedInstanceExpectedUser.avatarUrl"
|
||||
:alt="sharedInstanceExpectedUsername"
|
||||
:tint-by="sharedInstanceExpectedUser.tintBy"
|
||||
size="20px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ sharedInstanceExpectedUsername }}</span>
|
||||
</span>
|
||||
<span v-else class="font-semibold">{{ sharedInstanceExpectedUsername }}</span>
|
||||
<span>{{ formatMessage(bodyMessage) }}</span>
|
||||
</span>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Admonition, Avatar, useVIntl } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { get_user } from '@/helpers/cache'
|
||||
|
||||
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
|
||||
import type { SharedInstanceRole } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
expectedUserId?: string | null
|
||||
role?: SharedInstanceRole | null
|
||||
signedOut?: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const expectedUserId = computed(() => props.expectedUserId ?? null)
|
||||
const expectedUserQuery = useQuery({
|
||||
queryKey: computed(() => ['user', expectedUserId.value]),
|
||||
queryFn: async () => {
|
||||
if (!expectedUserId.value) return null
|
||||
|
||||
return await get_user(expectedUserId.value, 'bypass').catch(() => null)
|
||||
},
|
||||
enabled: () => !!expectedUserId.value,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const sharedInstanceExpectedUser = computed(() => {
|
||||
const user = expectedUserQuery.data.value
|
||||
if (!user) return null
|
||||
|
||||
return {
|
||||
username: user.username,
|
||||
avatarUrl: user.avatar_url ?? undefined,
|
||||
tintBy: user.id,
|
||||
}
|
||||
})
|
||||
const sharedInstanceExpectedUsername = computed(
|
||||
() =>
|
||||
sharedInstanceExpectedUser.value?.username ||
|
||||
formatMessage(messages.sharedInstanceWrongAccountFallbackUsername),
|
||||
)
|
||||
const headerMessage = computed(() =>
|
||||
props.signedOut
|
||||
? messages.sharedInstanceSignedOutHeader
|
||||
: messages.sharedInstanceWrongAccountHeader,
|
||||
)
|
||||
const bodyMessage = computed(() =>
|
||||
props.role === 'owner'
|
||||
? messages.sharedInstanceWrongAccountOwnerBody
|
||||
: messages.sharedInstanceWrongAccountUserBody,
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { StackedAdmonitionItem } from '@modrinth/ui'
|
||||
|
||||
export type InstanceAdmonitionKind =
|
||||
| 'shared-instance-stale'
|
||||
| 'shared-instance-unavailable'
|
||||
| 'shared-instance-wrong-account'
|
||||
|
||||
export type InstanceAdmonitionItem = StackedAdmonitionItem & {
|
||||
kind: InstanceAdmonitionKind
|
||||
}
|
||||
|
||||
export type SharedInstanceRole = 'owner' | 'member'
|
||||
@@ -19,8 +19,10 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { toError } from '@/helpers/errors'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, list, remove } from '@/helpers/instance'
|
||||
import { edit, edit_icon, remove } from '@/helpers/instance'
|
||||
import { create_group, list_groups } from '@/helpers/instance-groups'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
@@ -38,7 +40,7 @@ const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
|
||||
|
||||
const title = ref(instance.value.name)
|
||||
const icon: Ref<string | undefined> = ref(instance.value.icon_path)
|
||||
const groups = ref([...instance.value.groups])
|
||||
const groupIds = ref([...instance.value.group_ids])
|
||||
const savingReleaseChannel = ref(false)
|
||||
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.update_channel)
|
||||
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
|
||||
@@ -57,10 +59,7 @@ async function duplicateInstance() {
|
||||
})
|
||||
}
|
||||
|
||||
const allInstances = ref((await list()) as GameInstance[])
|
||||
const availableGroups = computed(() => [
|
||||
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
|
||||
])
|
||||
const availableGroups = ref(await list_groups())
|
||||
|
||||
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
||||
switch (channel) {
|
||||
@@ -135,28 +134,34 @@ async function setIcon() {
|
||||
|
||||
const editInstanceObject = computed(() => ({
|
||||
name: title.value.trim().substring(0, 32) ?? 'Instance',
|
||||
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
|
||||
group_ids: groupIds.value,
|
||||
}))
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
if (groups.value.includes(group)) {
|
||||
groups.value = groups.value.filter((x) => x !== group)
|
||||
const toggleGroup = (groupId: string) => {
|
||||
if (groupIds.value.includes(groupId)) {
|
||||
groupIds.value = groupIds.value.filter((id) => id !== groupId)
|
||||
} else {
|
||||
groups.value.push(group)
|
||||
groupIds.value.push(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
const addCategory = () => {
|
||||
const addCategory = async () => {
|
||||
const text = newCategoryInput.value.trim()
|
||||
|
||||
if (text.length > 0) {
|
||||
groups.value.push(text.substring(0, 32))
|
||||
newCategoryInput.value = ''
|
||||
try {
|
||||
const group = await create_group(text.substring(0, 128))
|
||||
availableGroups.value.push(group)
|
||||
groupIds.value.push(group.id)
|
||||
newCategoryInput.value = ''
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[title, groups, groups],
|
||||
[title, groupIds],
|
||||
async () => {
|
||||
if (removing.value) return
|
||||
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
|
||||
@@ -369,15 +374,16 @@ const messages = defineMessages({
|
||||
<div class="flex flex-col gap-1">
|
||||
<Checkbox
|
||||
v-for="group in availableGroups"
|
||||
:key="group"
|
||||
:model-value="groups.includes(group)"
|
||||
:label="group"
|
||||
@click="toggleGroup(group)"
|
||||
:key="group.id"
|
||||
:model-value="groupIds.includes(group.id)"
|
||||
:label="group.name"
|
||||
@click="toggleGroup(group.id)"
|
||||
/>
|
||||
<div class="flex gap-2 items-center">
|
||||
<StyledInput
|
||||
v-model="newCategoryInput"
|
||||
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
|
||||
:maxlength="128"
|
||||
class="w-full max-w-[300px]"
|
||||
@submit="() => addCategory"
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
InstallationSettingsLayout,
|
||||
provideAppBackup,
|
||||
provideInstallationSettings,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
@@ -16,24 +15,25 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
|
||||
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||
import {
|
||||
install_duplicate_instance,
|
||||
install_existing_instance,
|
||||
install_pack_to_existing_instance,
|
||||
installJobInstanceId,
|
||||
wait_for_install_job,
|
||||
} from '@/helpers/install'
|
||||
import {
|
||||
edit,
|
||||
get_linked_modpack_info,
|
||||
list,
|
||||
unlink_shared_instance,
|
||||
update_managed_modrinth_version,
|
||||
update_repair_modrinth,
|
||||
} from '@/helpers/instance'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { provideInstanceBackup } from '@/providers/instance-backup'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
@@ -47,6 +47,7 @@ const debug = useDebugLogger('AppInstallationSettings')
|
||||
const themeStore = useTheming()
|
||||
|
||||
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
|
||||
const managedContentPolicy = useManagedContentPolicy(instance)
|
||||
const skipNonEssentialWarnings = computed(() =>
|
||||
themeStore.getFeatureFlag('skip_non_essential_warnings'),
|
||||
)
|
||||
@@ -111,9 +112,14 @@ debug('metadata queries configured', {
|
||||
const isModrinthLinkedModpack = computed(
|
||||
() =>
|
||||
instance.value.link?.type === 'modrinth_modpack' ||
|
||||
instance.value.link?.type === 'server_project_modpack',
|
||||
instance.value.link?.type === 'server_project_modpack' ||
|
||||
(instance.value.link?.type === 'shared_instance' &&
|
||||
!!instance.value.link.modpack_project_id &&
|
||||
!!instance.value.link.modpack_version_id),
|
||||
)
|
||||
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
|
||||
const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
|
||||
const canUnlinkSharedInstance = managedContentPolicy.canUnlink
|
||||
|
||||
const modpackInfoQuery = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
|
||||
@@ -124,12 +130,43 @@ const modpackInfo = modpackInfoQuery.data
|
||||
|
||||
const repairing = ref(false)
|
||||
const reinstalling = ref(false)
|
||||
const unlinkingSharedInstance = ref(false)
|
||||
const installationSettingsBusy = computed(
|
||||
() =>
|
||||
instance.value.quarantined ||
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
repairing.value ||
|
||||
reinstalling.value ||
|
||||
unlinkingSharedInstance.value ||
|
||||
!!offline,
|
||||
)
|
||||
const installationSettingsBusyMessage = computed(() =>
|
||||
instance.value.quarantined ? formatMessage(messages.locked) : null,
|
||||
)
|
||||
|
||||
async function unlinkSharedInstance() {
|
||||
unlinkingSharedInstance.value = true
|
||||
try {
|
||||
await unlink_shared_instance(instance.value.id)
|
||||
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
|
||||
onUnlinked()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
unlinkingSharedInstance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
loaderVersion: {
|
||||
id: 'instance.settings.tabs.installation.loader-version',
|
||||
defaultMessage: '{loader} version',
|
||||
},
|
||||
locked: {
|
||||
id: 'instance.settings.tabs.installation.locked',
|
||||
defaultMessage: 'Installation settings are unavailable while this instance is locked.',
|
||||
},
|
||||
})
|
||||
|
||||
function getManifest(loader: string) {
|
||||
@@ -162,27 +199,7 @@ async function installLocalModpackFromPicker() {
|
||||
return !!completed
|
||||
}
|
||||
|
||||
provideAppBackup({
|
||||
async createBackup() {
|
||||
debug('createBackup: start', {
|
||||
instanceId: instance.value.id,
|
||||
instanceName: instance.value.name,
|
||||
})
|
||||
const allInstances = await list()
|
||||
const prefix = `${instance.value.name} - Backup #`
|
||||
const existingNums = allInstances
|
||||
.filter((p) => p.name.startsWith(prefix))
|
||||
.map((p) => parseInt(p.name.slice(prefix.length), 10))
|
||||
.filter((n) => !isNaN(n))
|
||||
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
|
||||
const job = await install_duplicate_instance(instance.value.id)
|
||||
const newInstanceId = installJobInstanceId(job)
|
||||
if (newInstanceId) {
|
||||
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
|
||||
}
|
||||
debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
|
||||
},
|
||||
})
|
||||
provideInstanceBackup(instance)
|
||||
|
||||
provideInstallationSettings({
|
||||
closeSettings: closeModal,
|
||||
@@ -208,14 +225,14 @@ provideInstallationSettings({
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value),
|
||||
isBusy: computed(
|
||||
isLinked: computed(
|
||||
() =>
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
repairing.value ||
|
||||
reinstalling.value ||
|
||||
!!offline,
|
||||
isModrinthLinkedModpack.value ||
|
||||
isImportedModpack.value ||
|
||||
isSharedInstanceManagedModpack.value,
|
||||
),
|
||||
isBusy: installationSettingsBusy,
|
||||
busyMessage: installationSettingsBusyMessage,
|
||||
skipNonEssentialWarnings,
|
||||
modpack: computed(() => {
|
||||
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
|
||||
@@ -269,16 +286,31 @@ provideInstallationSettings({
|
||||
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const result = manifest.gameVersions[0]?.loaders ?? []
|
||||
debug('resolveLoaderVersions: fabric/quilt result', {
|
||||
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
|
||||
if (entry?.versionGroup) {
|
||||
const result =
|
||||
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
|
||||
debug('resolveLoaderVersions: version group result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
versionGroup: entry.versionGroup,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
|
||||
if (placeholder) {
|
||||
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
|
||||
? placeholder.loaders
|
||||
: []
|
||||
debug('resolveLoaderVersions: placeholder result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const result = manifest.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
||||
const result = entry?.loaders ?? []
|
||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||
return result
|
||||
},
|
||||
@@ -437,14 +469,28 @@ provideInstallationSettings({
|
||||
isServer: false,
|
||||
isApp: true,
|
||||
showModpackVersionActions: computed(
|
||||
() => isModrinthLinkedModpack.value && !isMinecraftServer.value,
|
||||
() =>
|
||||
isModrinthLinkedModpack.value &&
|
||||
!isMinecraftServer.value &&
|
||||
!isSharedInstanceManagedModpack.value,
|
||||
),
|
||||
isLocalFile: isImportedModpack,
|
||||
isManagedModpack: isSharedInstanceManagedModpack,
|
||||
managedModpackWarning: managedContentPolicy.managedModpackWarning,
|
||||
repairing,
|
||||
reinstalling,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InstallationSettingsLayout />
|
||||
<InstallationSettingsLayout>
|
||||
<template #extra>
|
||||
<SharedInstanceInstallationSettingsControls
|
||||
:can-unlink="canUnlinkSharedInstance"
|
||||
:busy="installationSettingsBusy"
|
||||
:unlinking="unlinkingSharedInstance"
|
||||
:unlink="unlinkSharedInstance"
|
||||
/>
|
||||
</template>
|
||||
</InstallationSettingsLayout>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div>
|
||||
<SharedInstanceInstallationSettingsControls
|
||||
can-unpublish
|
||||
:busy="isBusy"
|
||||
:unpublishing="unpublishing"
|
||||
:unpublish="unpublishSharedInstance"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
|
||||
import { unpublish_shared_instance } from '@/helpers/instance'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
const { instance, offline, onUnlinked } = injectInstanceSettings()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const unpublishing = ref(false)
|
||||
const isBusy = computed(
|
||||
() => instance.value.install_stage !== 'installed' || unpublishing.value || !!offline,
|
||||
)
|
||||
|
||||
async function unpublishSharedInstance() {
|
||||
unpublishing.value = true
|
||||
try {
|
||||
await unpublish_shared_instance(instance.value.id)
|
||||
queryClient.setQueryData(['sharedInstanceUsers', instance.value.id], [])
|
||||
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
|
||||
onUnlinked()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
unpublishing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<FloatingActionBar
|
||||
:shown="selectedInstanceCount > 0"
|
||||
:aria-label="formatMessage(messages.ariaLabel)"
|
||||
hide-when-modal-open
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ formatMessage(messages.selectedCount, { count: selectedLibraryInstances.size }) }}
|
||||
</span>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="!text-primary"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="clearLibraryInstanceSelection"
|
||||
>
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-0.5">
|
||||
<ButtonStyled v-if="displayState.group === 'Group'" type="transparent">
|
||||
<button type="button" :disabled="busy" @click="createGroupFromSelection">
|
||||
<SquarePlusIcon />
|
||||
<span class="bar-label">{{ formatMessage(messages.newGroup) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="selectedGroupedInstances.length > 0" type="transparent">
|
||||
<button type="button" :disabled="busy" @click="removeSelectedInstancesFromGroups">
|
||||
<MinusIcon />
|
||||
<span class="bar-label">{{ formatMessage(messages.removeFromGroup) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent" color="red" color-fill="text" hover-color-fill="background">
|
||||
<button
|
||||
v-tooltip="deleting ? formatMessage(messages.deleting) : undefined"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="confirmDeleteModal?.show()"
|
||||
>
|
||||
<TrashIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="confirmDeleteModal"
|
||||
:count="selectedInstanceIds.size"
|
||||
@delete="deleteSelectedInstances"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MinusIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FloatingActionBar,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { toError } from '@/helpers/errors'
|
||||
import { edit, remove } from '@/helpers/instance'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const {
|
||||
instances,
|
||||
selectedLibraryInstances,
|
||||
clearLibraryInstanceSelection,
|
||||
setSelectedLibraryInstances,
|
||||
creatingGroup,
|
||||
createDefaultGroup,
|
||||
displayState,
|
||||
} = useLibrary()
|
||||
|
||||
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
|
||||
const deleting = ref(false)
|
||||
const removingFromGroup = ref(false)
|
||||
const selectedInstanceCount = computed(() => selectedLibraryInstances.value.size)
|
||||
const selectedInstanceIds = computed(
|
||||
() =>
|
||||
new Set([...selectedLibraryInstances.value.values()].map((selection) => selection.instanceId)),
|
||||
)
|
||||
const selectedGroupedInstances = computed(() =>
|
||||
[...selectedLibraryInstances.value.values()].flatMap((selection) => {
|
||||
const instance = instances.value.find((candidate) => candidate.id === selection.instanceId)
|
||||
return instance?.group_ids.includes(selection.groupId) ? [{ instance, selection }] : []
|
||||
}),
|
||||
)
|
||||
const busy = computed(() => deleting.value || removingFromGroup.value || creatingGroup.value)
|
||||
|
||||
const messages = defineMessages({
|
||||
ariaLabel: {
|
||||
id: 'app.library.selection.aria-label',
|
||||
defaultMessage: 'Selected instances',
|
||||
},
|
||||
selectedCount: {
|
||||
id: 'app.library.selection.selected-count',
|
||||
defaultMessage: '{count} selected',
|
||||
},
|
||||
deleting: {
|
||||
id: 'app.library.selection.deleting',
|
||||
defaultMessage: 'Deleting selected instances',
|
||||
},
|
||||
newGroup: {
|
||||
id: 'app.library.selection.new-group',
|
||||
defaultMessage: 'New group',
|
||||
},
|
||||
removeFromGroup: {
|
||||
id: 'app.library.selection.remove-from-group',
|
||||
defaultMessage: 'Remove from group',
|
||||
},
|
||||
})
|
||||
|
||||
async function createGroupFromSelection() {
|
||||
if (busy.value) return
|
||||
|
||||
const instanceIds = selectedInstanceIds.value
|
||||
clearLibraryInstanceSelection()
|
||||
await createDefaultGroup(instanceIds)
|
||||
}
|
||||
|
||||
async function removeSelectedInstancesFromGroups() {
|
||||
if (busy.value || selectedGroupedInstances.value.length === 0) return
|
||||
|
||||
removingFromGroup.value = true
|
||||
const selectedGroupIdsByInstanceId = new Map<string, Set<string>>()
|
||||
for (const { instance, selection } of selectedGroupedInstances.value) {
|
||||
const groupIds = selectedGroupIdsByInstanceId.get(instance.id) ?? new Set()
|
||||
groupIds.add(selection.groupId)
|
||||
selectedGroupIdsByInstanceId.set(instance.id, groupIds)
|
||||
}
|
||||
const operations = [...selectedGroupIdsByInstanceId].flatMap(([instanceId, groupIds]) => {
|
||||
const instance = instances.value.find((candidate) => candidate.id === instanceId)
|
||||
return instance ? [{ instance, groupIds }] : []
|
||||
})
|
||||
const results = await Promise.allSettled(
|
||||
operations.map(({ instance, groupIds }) => {
|
||||
return edit(instance.id, {
|
||||
group_ids: instance.group_ids.filter((groupId) => !groupIds.has(groupId)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === 'rejected') {
|
||||
handleError(toError(result.reason))
|
||||
} else {
|
||||
for (const groupId of operations[index].groupIds) {
|
||||
nextSelectedInstances.delete(
|
||||
getLibraryInstanceSelectionKey({
|
||||
instanceId: operations[index].instance.id,
|
||||
groupId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedLibraryInstances(nextSelectedInstances.values())
|
||||
removingFromGroup.value = false
|
||||
}
|
||||
|
||||
async function deleteSelectedInstances() {
|
||||
if (busy.value || selectedInstanceCount.value === 0) return
|
||||
|
||||
deleting.value = true
|
||||
const instanceIds = [...selectedInstanceIds.value]
|
||||
const results = await Promise.allSettled(instanceIds.map((instanceId) => remove(instanceId)))
|
||||
const deletedInstanceIds = new Set<string>()
|
||||
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === 'rejected') {
|
||||
handleError(toError(result.reason))
|
||||
} else {
|
||||
deletedInstanceIds.add(instanceIds[index])
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedLibraryInstances(
|
||||
[...selectedLibraryInstances.value.values()].filter(
|
||||
(selection) => !deletedInstanceIds.has(selection.instanceId),
|
||||
),
|
||||
)
|
||||
deleting.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
no-padding
|
||||
scrollable
|
||||
actions-divider
|
||||
max-width="560px"
|
||||
width="560px"
|
||||
:on-hide="closeGroupInstancesModal"
|
||||
>
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{
|
||||
formatMessage(messages.title, {
|
||||
groupName: groupInstancesModalGroup?.name ?? '',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
|
||||
<div class="px-6">
|
||||
<StyledInput
|
||||
v-model="groupInstancesSearch"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="groupInstances.length === 0"
|
||||
class="flex items-center justify-center py-12 text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noInstancesFound) }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="instance in groupInstances"
|
||||
:key="instance.id"
|
||||
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
|
||||
:class="{ 'opacity-60': selectedGroupInstanceIds.has(instance.id) }"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
:tint-by="instance.id"
|
||||
:alt="instance.name"
|
||||
size="2rem"
|
||||
rounded="md"
|
||||
/>
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
</div>
|
||||
<ButtonStyled :type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'standard'">
|
||||
<button type="button" @click="toggleGroupInstance(instance.id)">
|
||||
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
|
||||
{{
|
||||
formatMessage(
|
||||
selectedGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="savingGroupInstances" @click="save">
|
||||
<SpinnerIcon v-if="savingGroupInstances" class="animate-spin" />
|
||||
<CheckIcon v-else />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.library.group.instances-modal.title',
|
||||
defaultMessage: 'Add instances to "{groupName}"',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'app.library.group.instances-modal.search-placeholder',
|
||||
defaultMessage: 'Search instance',
|
||||
},
|
||||
noInstancesFound: {
|
||||
id: 'app.library.group.instances-modal.no-instances-found',
|
||||
defaultMessage: 'No instances found',
|
||||
},
|
||||
add: {
|
||||
id: 'app.library.group.instances-modal.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
added: {
|
||||
id: 'app.library.group.instances-modal.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
isGroupInstancesModalOpen,
|
||||
groupInstancesModalGroup,
|
||||
groupInstancesSearch,
|
||||
groupInstances,
|
||||
selectedGroupInstanceIds,
|
||||
savingGroupInstances,
|
||||
closeGroupInstancesModal,
|
||||
toggleGroupInstance,
|
||||
saveGroupInstances,
|
||||
} = useLibrary()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
watch(isGroupInstancesModalOpen, (open) => {
|
||||
if (open) {
|
||||
modal.value?.show()
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (await saveGroupInstances()) {
|
||||
modal.value?.hide()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
MinusIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref, toRef, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
|
||||
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
|
||||
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
|
||||
import LibraryToolbar from '@/components/ui/library/library-toolbar/index.vue'
|
||||
import LibrarySelectionActionBar from '@/components/ui/library/LibrarySelectionActionBar.vue'
|
||||
import { getLibraryInstanceSelectionKey, provideLibrary } from '@/components/ui/library/use-library'
|
||||
import { FAVORITES_GROUP_ID } from '@/helpers/instance-groups'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
}>()
|
||||
|
||||
const {
|
||||
instanceGroups,
|
||||
filters,
|
||||
instanceOptions,
|
||||
confirmDeleteModal,
|
||||
deleteInstance,
|
||||
handleInstanceOption,
|
||||
selectedLibraryInstances,
|
||||
setSelectedLibraryInstances,
|
||||
toggleLibraryInstanceSelection,
|
||||
} = provideLibrary(toRef(props, 'instances'))
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
Object.values(filters.value).some((selectedValues) => selectedValues.length > 0),
|
||||
)
|
||||
|
||||
const visibleInstanceGroups = computed(() =>
|
||||
instanceGroups.value.filter((instanceGroup) =>
|
||||
instanceGroup.id === FAVORITES_GROUP_ID
|
||||
? instanceGroup.instances.length > 0
|
||||
: instanceGroup.instances.length > 0 ||
|
||||
(!hasActiveFilters.value && instanceGroup.key !== 'None'),
|
||||
),
|
||||
)
|
||||
|
||||
const anchorInstance = ref<{ groupId: string; instanceId: string } | null>(null)
|
||||
|
||||
function handleToggleInstance(groupId: string, instanceId: string, shiftKey: boolean) {
|
||||
const displayedInstances = visibleInstanceGroups.value.flatMap((group) =>
|
||||
group.instances.map((instance) => ({
|
||||
groupId: group.id,
|
||||
instanceId: instance.id,
|
||||
})),
|
||||
)
|
||||
const anchor = anchorInstance.value
|
||||
|
||||
if (shiftKey && anchor && displayedInstances.length) {
|
||||
const anchorIndex = displayedInstances.findIndex(
|
||||
(instance) =>
|
||||
instance.groupId === anchor.groupId && instance.instanceId === anchor.instanceId,
|
||||
)
|
||||
const targetIndex = displayedInstances.findIndex(
|
||||
(instance) => instance.groupId === groupId && instance.instanceId === instanceId,
|
||||
)
|
||||
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
toggleLibraryInstanceSelection({ groupId, instanceId })
|
||||
return
|
||||
}
|
||||
|
||||
const start = Math.min(anchorIndex, targetIndex)
|
||||
const end = Math.max(anchorIndex, targetIndex)
|
||||
const range = displayedInstances.slice(start, end + 1)
|
||||
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
|
||||
const targetKey = getLibraryInstanceSelectionKey({ groupId, instanceId })
|
||||
|
||||
if (nextSelectedInstances.has(targetKey)) {
|
||||
for (const instance of range) {
|
||||
nextSelectedInstances.delete(getLibraryInstanceSelectionKey(instance))
|
||||
}
|
||||
} else {
|
||||
for (const instance of range) {
|
||||
nextSelectedInstances.set(getLibraryInstanceSelectionKey(instance), instance)
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedLibraryInstances(nextSelectedInstances.values())
|
||||
anchorInstance.value = null
|
||||
return
|
||||
}
|
||||
|
||||
toggleLibraryInstanceSelection({ groupId, instanceId })
|
||||
anchorInstance.value = { groupId, instanceId }
|
||||
}
|
||||
|
||||
function setInstanceOptions(component: unknown) {
|
||||
instanceOptions.value = component as InstanceType<typeof ContextMenu> | null
|
||||
}
|
||||
|
||||
function setConfirmDeleteModal(component: unknown) {
|
||||
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
|
||||
}
|
||||
|
||||
watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
if (selectedInstances.size === 0) {
|
||||
anchorInstance.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
anchorInstance.value &&
|
||||
!selectedInstances.has(getLibraryInstanceSelectionKey(anchorInstance.value))
|
||||
) {
|
||||
anchorInstance.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InstanceGroupDnd :instances="instances">
|
||||
<section data-library-page-background class="flex flex-col gap-3 pb-16">
|
||||
<h2 class="m-0 text-2xl font-semibold text-contrast">Library</h2>
|
||||
<LibraryToolbar />
|
||||
<TransitionGroup
|
||||
data-library-page-background
|
||||
tag="div"
|
||||
class="flex flex-col"
|
||||
move-class="transition-transform duration-200 ease-out"
|
||||
enter-active-class="transition-[opacity,transform] duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<div v-for="instanceGroup in visibleInstanceGroups" :key="instanceGroup.id" class="min-w-0">
|
||||
<InstanceGroup
|
||||
:hide-header="instanceGroup.key === 'None' && visibleInstanceGroups.length === 1"
|
||||
:instance-group="instanceGroup"
|
||||
:selection-anchor-instance-id="
|
||||
anchorInstance?.groupId === instanceGroup.id ? anchorInstance.instanceId : null
|
||||
"
|
||||
@toggle-selection="
|
||||
(instanceId: string, shiftKey: boolean) =>
|
||||
handleToggleInstance(instanceGroup.id, instanceId, shiftKey)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
</InstanceGroupDnd>
|
||||
<LibrarySelectionActionBar />
|
||||
<GroupInstancesModal />
|
||||
<ConfirmDeleteInstanceModal :ref="setConfirmDeleteModal" @delete="deleteInstance" />
|
||||
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_to_favorites> <StarIcon /> Add to favorites </template>
|
||||
<template #remove_from_favorites>
|
||||
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" /> Remove
|
||||
from favorites
|
||||
</template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EyeIcon /> View instance </template>
|
||||
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
<template #open> <FolderOpenIcon /> Open folder </template>
|
||||
<template #copy> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #remove_from_group> <MinusIcon /> Remove from group </template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import DragPreview from '@/components/ui/library/instance-group/drag-preview.vue'
|
||||
import type { InstanceDragGatherItem } from '@/components/ui/library/instance-group/use-instance-drag-gather'
|
||||
|
||||
const props = defineProps<{
|
||||
items: InstanceDragGatherItem[]
|
||||
target: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const gatherDuration = 500
|
||||
|
||||
const gathered = ref(false)
|
||||
const finishing = ref(false)
|
||||
let animationFrame: number | undefined
|
||||
let completionTimer: number | undefined
|
||||
|
||||
const itemContainerStyles = computed(() =>
|
||||
props.items.map((_, index) => ({
|
||||
left: `${props.target.x}px`,
|
||||
top: `${props.target.y}px`,
|
||||
zIndex: `${index}`,
|
||||
})),
|
||||
)
|
||||
|
||||
const itemStyles = computed(() =>
|
||||
props.items.map((item, index) => {
|
||||
const originX = item.rect.left - props.target.x
|
||||
const originY = item.rect.top - props.target.y
|
||||
|
||||
return {
|
||||
width: `${item.rect.width}px`,
|
||||
height: `${item.rect.height}px`,
|
||||
transform: gathered.value
|
||||
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
|
||||
: `translate3d(${originX}px, ${originY}px, 0)`,
|
||||
transitionDuration: `${gatherDuration}ms`,
|
||||
transitionDelay: `${Math.min(index, 8) * 10}ms`,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
gathered.value = true
|
||||
completionTimer = window.setTimeout(
|
||||
() => {
|
||||
finishing.value = true
|
||||
emit('complete')
|
||||
},
|
||||
Math.max(gatherDuration - 250, 0),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animationFrame !== undefined) {
|
||||
cancelAnimationFrame(animationFrame)
|
||||
}
|
||||
if (completionTimer !== undefined) {
|
||||
clearTimeout(completionTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
|
||||
:class="finishing ? 'opacity-0' : 'opacity-100'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.instance.id"
|
||||
class="fixed"
|
||||
:style="itemContainerStyles[index]"
|
||||
>
|
||||
<div
|
||||
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
|
||||
:style="itemStyles[index]"
|
||||
>
|
||||
<DragPreview :instance="item.instance" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { TagItem } from '@modrinth/ui'
|
||||
|
||||
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
count?: number
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-hidden="true" class="relative w-full select-none">
|
||||
<div
|
||||
v-if="count > 1"
|
||||
class="absolute inset-x-3 -bottom-2 top-2 rounded-[20px] border border-solid border-surface-5 bg-surface-2 opacity-60 shadow-md"
|
||||
/>
|
||||
<div
|
||||
v-if="count > 1"
|
||||
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-[20px] border border-solid border-surface-4 bg-surface-3 opacity-80 shadow-md"
|
||||
/>
|
||||
<InstanceCardView :instance="instance" class="opacity-90 shadow-lg">
|
||||
<template #overlay>
|
||||
<TagItem
|
||||
v-if="count > 1"
|
||||
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-2 font-semibold tabular-nums text-contrast"
|
||||
>
|
||||
{{ count }}
|
||||
</TagItem>
|
||||
</template>
|
||||
</InstanceCardView>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
deleting?: boolean
|
||||
onAddToGroup: () => void
|
||||
onDeleteGroup: () => void
|
||||
onEditGroupName: () => void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
addToGroup: {
|
||||
id: 'app.library.group.context-menu.add-to-group',
|
||||
defaultMessage: 'Add instances to group',
|
||||
},
|
||||
editGroupName: {
|
||||
id: 'app.library.group.edit-name',
|
||||
defaultMessage: 'Edit group name',
|
||||
},
|
||||
deleteGroup: {
|
||||
id: 'app.library.group.delete',
|
||||
defaultMessage: 'Delete group',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
<ButtonStyled circular type="transparent" size="standard">
|
||||
<button
|
||||
class="!size-8 !min-w-8"
|
||||
v-tooltip="formatMessage(messages.editGroupName)"
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.editGroupName)"
|
||||
@click.stop="onEditGroupName"
|
||||
>
|
||||
<EditIcon class="!min-w-4 !min-h-4 !size-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent" size="standard">
|
||||
<button
|
||||
class="!size-8 !min-w-8"
|
||||
v-tooltip="formatMessage(messages.addToGroup)"
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.addToGroup)"
|
||||
@click.stop="onAddToGroup"
|
||||
>
|
||||
<SquarePlusIcon class="!min-w-4 !min-h-4 !size-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent" size="standard">
|
||||
<button
|
||||
class="!size-8 !min-w-8"
|
||||
v-tooltip="formatMessage(messages.deleteGroup)"
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.deleteGroup)"
|
||||
:disabled="deleting"
|
||||
@click.stop="onDeleteGroup"
|
||||
>
|
||||
<TrashIcon class="!min-w-4 !min-h-4 !size-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,479 @@
|
||||
<script setup lang="ts">
|
||||
import { useDroppable } from '@dnd-kit/vue'
|
||||
import {
|
||||
DropdownIcon,
|
||||
EditIcon,
|
||||
PlusIcon,
|
||||
SquarePlusIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
InlineEditableText,
|
||||
NewModal,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
|
||||
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
|
||||
import type {
|
||||
InstanceCard as InstanceCardExposed,
|
||||
InstanceGroup as InstanceGroupType,
|
||||
} from '@/components/ui/library/use-library'
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
import { FAVORITES_GROUP_ID } from '@/helpers/instance-groups'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
hideHeader?: boolean
|
||||
instanceGroup: InstanceGroupType
|
||||
selectionAnchorInstanceId?: string | null
|
||||
}>(),
|
||||
{
|
||||
hideHeader: false,
|
||||
selectionAnchorInstanceId: null,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
isSectionCollapsed,
|
||||
setSectionCollapsed,
|
||||
deleteGroup,
|
||||
renameGroup,
|
||||
groupIdPendingNameEdit,
|
||||
completePendingGroupNameEdit,
|
||||
handleInstanceContextMenu,
|
||||
openGroupInstancesModal,
|
||||
displayState,
|
||||
activeInstanceGroupDrag,
|
||||
instanceGroupDragTarget,
|
||||
getInstanceGroupDropState,
|
||||
} = useLibrary()
|
||||
const showCreationModal = inject<() => void>('showCreationModal')
|
||||
|
||||
const instanceComponents = new Map<string, InstanceCardExposed>()
|
||||
const groupDropTarget = ref<HTMLElement>()
|
||||
const groupAccordion = ref<InstanceType<typeof Accordion>>()
|
||||
const groupOptions = ref<InstanceType<typeof ContextMenu>>()
|
||||
const groupNameInput = ref<InstanceType<typeof InlineEditableText>>()
|
||||
const confirmDeleteGroupModal = ref<InstanceType<typeof NewModal>>()
|
||||
const deletingGroup = ref(false)
|
||||
const groupName = ref(props.instanceGroup.key)
|
||||
const isUngrouped = computed(() => props.instanceGroup.id === 'group:none')
|
||||
const isFavorites = computed(() => props.instanceGroup.id === FAVORITES_GROUP_ID)
|
||||
const isCustomGroup = computed(
|
||||
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
|
||||
)
|
||||
const groupContextMenuOpen = ref(false)
|
||||
const isGroupToggleBlocked = computed(
|
||||
() => groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
|
||||
)
|
||||
let shouldSkipGroupToggle = false
|
||||
let groupToggleEventToSkip: MouseEvent | undefined
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-selection', instanceId: string, shiftKey: boolean): void
|
||||
}>()
|
||||
|
||||
useDroppable({
|
||||
id: computed(() => `instance-group:${props.instanceGroup.id}`),
|
||||
element: groupDropTarget,
|
||||
disabled: computed(() => displayState.value.group !== 'Group'),
|
||||
data: computed(() => ({
|
||||
groupId: props.instanceGroup.id,
|
||||
})),
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
ungrouped: {
|
||||
id: 'app.library.group.ungrouped',
|
||||
defaultMessage: 'Ungrouped',
|
||||
},
|
||||
favorites: {
|
||||
id: 'app.library.group.favorites',
|
||||
defaultMessage: 'Favorites',
|
||||
},
|
||||
deleteGroup: {
|
||||
id: 'app.library.group.delete',
|
||||
defaultMessage: 'Delete group',
|
||||
},
|
||||
editGroupName: {
|
||||
id: 'app.library.group.edit-name',
|
||||
defaultMessage: 'Edit group name',
|
||||
},
|
||||
renameGroupFailed: {
|
||||
id: 'app.library.group.rename-failed',
|
||||
defaultMessage: 'Unable to rename group',
|
||||
},
|
||||
groupNameEmpty: {
|
||||
id: 'app.library.group.name-empty',
|
||||
defaultMessage: 'Group names cannot be empty.',
|
||||
},
|
||||
groupNameTooLong: {
|
||||
id: 'app.library.group.name-too-long',
|
||||
defaultMessage: 'Group names cannot be longer than 32 characters.',
|
||||
},
|
||||
groupNameReserved: {
|
||||
id: 'app.library.group.name-reserved',
|
||||
defaultMessage: '"None" is reserved and cannot be used as a group name.',
|
||||
},
|
||||
deleteGroupDescription: {
|
||||
id: 'app.library.group.delete-description',
|
||||
defaultMessage: 'Instances in this group will be ungrouped.',
|
||||
},
|
||||
newInstance: {
|
||||
id: 'app.library.context-menu.create-instance',
|
||||
defaultMessage: 'New instance',
|
||||
},
|
||||
addToGroup: {
|
||||
id: 'app.library.group.context-menu.add-to-group',
|
||||
defaultMessage: 'Add to group',
|
||||
},
|
||||
emptyGroup: {
|
||||
id: 'app.library.group.empty',
|
||||
defaultMessage: 'Drag and drop to add instances.',
|
||||
},
|
||||
})
|
||||
|
||||
function openInstanceContextMenu(event: MouseEvent, instanceId: string, instanceGroupId: string) {
|
||||
const instanceComponent = instanceComponents.get(instanceId)
|
||||
if (!instanceComponent) return
|
||||
|
||||
handleInstanceContextMenu(event, instanceComponent, instanceGroupId)
|
||||
}
|
||||
|
||||
function setInstanceComponent(instanceId: string, component: unknown) {
|
||||
if (component) {
|
||||
instanceComponents.set(instanceId, component as InstanceCardExposed)
|
||||
} else {
|
||||
instanceComponents.delete(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGroup() {
|
||||
if (deletingGroup.value) return
|
||||
|
||||
deletingGroup.value = true
|
||||
const deleted = await deleteGroup(props.instanceGroup.id)
|
||||
deletingGroup.value = false
|
||||
|
||||
if (deleted) {
|
||||
confirmDeleteGroupModal.value?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
function requestGroupDeletion() {
|
||||
if (isUngrouped.value || isFavorites.value) return
|
||||
|
||||
if (props.instanceGroup.instances.length > 0) {
|
||||
confirmDeleteGroupModal.value?.show()
|
||||
} else {
|
||||
void removeGroup()
|
||||
}
|
||||
}
|
||||
|
||||
function openGroupContextMenu(event: MouseEvent) {
|
||||
if (displayState.value.group !== 'Group') return
|
||||
|
||||
groupContextMenuOpen.value = true
|
||||
groupOptions.value?.showMenu(
|
||||
event,
|
||||
props.instanceGroup,
|
||||
isFavorites.value
|
||||
? [{ name: 'new_instance' }]
|
||||
: isCustomGroup.value
|
||||
? [
|
||||
{ name: 'new_instance' },
|
||||
{ name: 'add_instances' },
|
||||
{ name: 'edit_name' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'delete_group', color: 'danger' },
|
||||
]
|
||||
: [{ name: 'new_instance' }],
|
||||
)
|
||||
}
|
||||
|
||||
function handleGroupOption({ option }: { option: string }) {
|
||||
if (option === 'new_instance') {
|
||||
showCreationModal?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (option === 'add_instances') {
|
||||
openGroupInstancesModal(props.instanceGroup.id)
|
||||
return
|
||||
}
|
||||
|
||||
if (option === 'edit_name') {
|
||||
void groupNameInput.value?.startEditing()
|
||||
return
|
||||
}
|
||||
|
||||
if (option === 'delete_group') {
|
||||
requestGroupDeletion()
|
||||
}
|
||||
}
|
||||
|
||||
function prepareGroupToggle(event: PointerEvent) {
|
||||
const editor = groupNameInput.value
|
||||
const contextMenuWasOpen = groupContextMenuOpen.value
|
||||
if (contextMenuWasOpen) {
|
||||
groupOptions.value?.hideMenu()
|
||||
}
|
||||
|
||||
shouldSkipGroupToggle = Boolean(
|
||||
contextMenuWasOpen ||
|
||||
(editor?.isEditing && event.target instanceof Node && !editor.$el.contains(event.target)),
|
||||
)
|
||||
}
|
||||
|
||||
function captureGroupClick(event: MouseEvent) {
|
||||
groupToggleEventToSkip = shouldSkipGroupToggle ? event : undefined
|
||||
shouldSkipGroupToggle = false
|
||||
}
|
||||
|
||||
function toggleGroup(event: MouseEvent) {
|
||||
if (event === groupToggleEventToSkip) {
|
||||
groupToggleEventToSkip = undefined
|
||||
return
|
||||
}
|
||||
|
||||
if (groupAccordion.value?.isOpen) {
|
||||
groupAccordion.value.close()
|
||||
} else {
|
||||
groupAccordion.value?.open()
|
||||
}
|
||||
}
|
||||
|
||||
function validateGroupName(value: string) {
|
||||
const normalizedGroupName = value.trim()
|
||||
let reason: string | undefined
|
||||
|
||||
if (normalizedGroupName.length === 0) {
|
||||
reason = formatMessage(messages.groupNameEmpty)
|
||||
} else if (normalizedGroupName.length > 32) {
|
||||
reason = formatMessage(messages.groupNameTooLong)
|
||||
} else if (normalizedGroupName.toLowerCase() === 'none') {
|
||||
reason = formatMessage(messages.groupNameReserved)
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.renameGroupFailed),
|
||||
text: reason,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function updateGroupName(value: string) {
|
||||
return await renameGroup(props.instanceGroup.id, value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.instanceGroup.key,
|
||||
(value) => {
|
||||
groupName.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.instanceGroup.id, groupIdPendingNameEdit],
|
||||
async ([groupId, pendingGroupId]) => {
|
||||
if (groupId !== pendingGroupId) return
|
||||
|
||||
await nextTick()
|
||||
groupDropTarget.value?.scrollIntoView({ block: 'nearest' })
|
||||
await groupNameInput.value?.startEditing()
|
||||
completePendingGroupNameEdit(groupId)
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.hideHeader,
|
||||
(hideHeader) => {
|
||||
if (hideHeader) {
|
||||
groupAccordion.value?.open()
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="groupDropTarget"
|
||||
class="instance-group group/instance-container relative select-none pb-3"
|
||||
@contextmenu.prevent.stop="openGroupContextMenu"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150 ease-out"
|
||||
enter-from-class="!opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="!opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
activeInstanceGroupDrag &&
|
||||
instanceGroupDragTarget === instanceGroup.id &&
|
||||
getInstanceGroupDropState(instanceGroup.id).canDrop
|
||||
"
|
||||
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 opacity-40 border-dashed border-contrast bg-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
v-if="!hideHeader"
|
||||
class="group/header h-10 flex w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||
>
|
||||
<div
|
||||
class="group/open-target flex min-w-0 items-center gap-2"
|
||||
:class="isGroupToggleBlocked ? 'cursor-default' : 'cursor-pointer'"
|
||||
@click="toggleGroup"
|
||||
@click.capture="captureGroupClick"
|
||||
@pointerdown.capture="prepareGroupToggle"
|
||||
>
|
||||
<button
|
||||
class="flex shrink-0 items-center border-0 bg-transparent p-0"
|
||||
:class="isGroupToggleBlocked ? 'cursor-default' : 'cursor-pointer'"
|
||||
type="button"
|
||||
:aria-expanded="groupAccordion?.isOpen"
|
||||
:aria-label="groupAccordion?.isOpen ? 'Collapse group' : 'Expand group'"
|
||||
@click.stop="toggleGroup"
|
||||
>
|
||||
<DropdownIcon
|
||||
class="size-5 shrink-0 text-secondary transition-all duration-300 group-hover/open-target:text-primary"
|
||||
:class="{ 'rotate-180': groupAccordion?.isOpen }"
|
||||
/>
|
||||
</button>
|
||||
<InlineEditableText
|
||||
v-if="!isUngrouped && !isFavorites"
|
||||
ref="groupNameInput"
|
||||
v-model="groupName"
|
||||
activation-mode="manual"
|
||||
class="text-base font-semibold !h-10 text-primary select-none group-hover/open-target:text-contrast"
|
||||
:edit-label="formatMessage(commonMessages.renameButton)"
|
||||
max-width="24rem"
|
||||
icon-text-class="select-none"
|
||||
:max-length="32"
|
||||
:on-change="updateGroupName"
|
||||
:validate="validateGroupName"
|
||||
/>
|
||||
<span
|
||||
v-else-if="isUngrouped"
|
||||
class="text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.ungrouped) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1.5 text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.favorites) }}
|
||||
</span>
|
||||
<TagItem
|
||||
v-if="instanceGroup.instances.length"
|
||||
class="shrink-0 border-surface-3 bg-surface-2"
|
||||
>
|
||||
{{ instanceGroup.instances.length }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<GroupActionButtons
|
||||
v-if="!isUngrouped && !isFavorites"
|
||||
:deleting="deletingGroup"
|
||||
:on-add-to-group="() => openGroupInstancesModal(instanceGroup.id)"
|
||||
:on-delete-group="requestGroupDeletion"
|
||||
:on-edit-group-name="() => groupNameInput?.startEditing()"
|
||||
/>
|
||||
</div>
|
||||
<Accordion
|
||||
ref="groupAccordion"
|
||||
:open-by-default="hideHeader || !isSectionCollapsed(instanceGroup.id)"
|
||||
class="w-full"
|
||||
@on-open="setSectionCollapsed(instanceGroup.id, false)"
|
||||
@on-close="setSectionCollapsed(instanceGroup.id, true)"
|
||||
>
|
||||
<section
|
||||
class="grid min-h-[45px] mt-2.5 w-full grid-cols-[repeat(auto-fill,minmax(min(20rem,100%),1fr))] gap-3 overflow-y-auto scroll-smooth"
|
||||
>
|
||||
<div v-for="instance in instanceGroup.instances" :key="instance.id" class="min-w-0 w-full">
|
||||
<InstanceCard
|
||||
:ref="(component: unknown) => setInstanceComponent(instance.id, component)"
|
||||
:instance="instance"
|
||||
:instance-group-id="instanceGroup.id"
|
||||
:is-selection-anchor="selectionAnchorInstanceId === instance.id"
|
||||
@toggle-selection="
|
||||
(shiftKey: boolean) => emit('toggle-selection', instance.id, shiftKey)
|
||||
"
|
||||
@contextmenu.prevent.stop="
|
||||
(event: MouseEvent) => openInstanceContextMenu(event, instance.id, instanceGroup.id)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="instanceGroup.instances.length === 0"
|
||||
class="col-span-full m-0 pt-1 pl-0.5 text-base font-base text-secondary opacity-80"
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</section>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
ref="groupOptions"
|
||||
@menu-closed="groupContextMenuOpen = false"
|
||||
@option-clicked="handleGroupOption"
|
||||
>
|
||||
<template #new_instance> <PlusIcon /> {{ formatMessage(messages.newInstance) }} </template>
|
||||
<template #add_instances>
|
||||
<SquarePlusIcon /> {{ formatMessage(messages.addToGroup) }}
|
||||
</template>
|
||||
<template #edit_name> <EditIcon /> {{ formatMessage(messages.editGroupName) }} </template>
|
||||
<template #delete_group> <TrashIcon /> {{ formatMessage(messages.deleteGroup) }} </template>
|
||||
</ContextMenu>
|
||||
|
||||
<NewModal
|
||||
ref="confirmDeleteGroupModal"
|
||||
:header="formatMessage(messages.deleteGroup)"
|
||||
fade="danger"
|
||||
width="500px"
|
||||
>
|
||||
<p class="m-0 text-base text-primary">
|
||||
{{ formatMessage(messages.deleteGroupDescription) }}
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" @click="confirmDeleteGroupModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button type="button" :disabled="deletingGroup" @click="removeGroup">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteGroup) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { PageRoundIcon } from '@modrinth/assets'
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, type CSSProperties, ref } from 'vue'
|
||||
|
||||
import InstanceFileIcon from '@/assets/icons/instance-file.svg'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const avatarSize = '84px'
|
||||
const avatarStyles: CSSProperties = {
|
||||
top: '-26px',
|
||||
right: '20px',
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
borderRadius: '22px 22px 38px 22px',
|
||||
}
|
||||
const avatar = ref<InstanceType<typeof Avatar> | null>(null)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
selected?: boolean
|
||||
}>(),
|
||||
{
|
||||
selected: false,
|
||||
},
|
||||
)
|
||||
|
||||
const instanceType = computed(() => {
|
||||
if (
|
||||
props.instance.link?.type === 'server_project' ||
|
||||
props.instance.link?.type === 'server_project_modpack'
|
||||
) {
|
||||
return 'SRV'
|
||||
}
|
||||
|
||||
return props.instance.link?.type === 'modrinth_modpack' ? 'MPK' : 'CST'
|
||||
})
|
||||
|
||||
const iconSrc = computed(() =>
|
||||
props.instance.icon_path ? convertFileSrc(props.instance.icon_path) : undefined,
|
||||
)
|
||||
const avatarFailed = computed(() => avatar.value?.failed ?? false)
|
||||
const hasVisibleIcon = computed(() => Boolean(iconSrc.value) && !avatarFailed.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex min-h-[76px] w-full select-none items-center justify-center gap-2 overflow-clip rounded-[20px] border border-solid bg-surface-3 p-4 text-left transition-all"
|
||||
:class="{
|
||||
'[border-color:color-mix(in_srgb,var(--color-text-primary)_20%,transparent)] brightness-110':
|
||||
selected,
|
||||
'border-surface-4': !selected,
|
||||
}"
|
||||
>
|
||||
<PageRoundIcon
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -top-[52px] right-[0px] size-[124px] [mask-image:linear-gradient(135deg,transparent_16%,black_100%)]"
|
||||
:class="{ 'opacity-[0.03]': hasVisibleIcon, 'opacity-5': !hasVisibleIcon }"
|
||||
/>
|
||||
<div
|
||||
v-if="hasVisibleIcon"
|
||||
class="pointer-events-none absolute bg-surface-3"
|
||||
:style="avatarStyles"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="iconSrc"
|
||||
ref="avatar"
|
||||
class="pointer-events-none !border-none ![background-color:color-mix(in_srgb,var(--color-contrast)_5%,transparent)] !p-0 !absolute opacity-50 [mask-image:linear-gradient(135deg,transparent_16%,black_100%)]"
|
||||
:class="{ hidden: avatarFailed }"
|
||||
:style="avatarStyles"
|
||||
:size="avatarSize"
|
||||
:src="iconSrc"
|
||||
:tint-by="instance.id"
|
||||
alt=""
|
||||
no-shadow
|
||||
/>
|
||||
<div class="relative z-[1] flex min-w-0 flex-1 items-center gap-2 pr-20">
|
||||
<slot name="leading" :instance-type="instanceType">
|
||||
<div
|
||||
class="flex size-10 shrink-0 flex-col items-center gap-px overflow-clip rounded-[14px] px-[3px] py-0.5 text-primary"
|
||||
>
|
||||
<InstanceFileIcon class="h-[21px] w-[31px] shrink-0 text-primary [&_path]:fill-current" />
|
||||
<span class="h-3.5 text-sm font-extrabold leading-[13px]">
|
||||
{{ instanceType }}
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center gap-1">
|
||||
<p class="m-0 truncate text-base font-semibold leading-5 text-contrast">
|
||||
{{ instance.name }}
|
||||
</p>
|
||||
<p class="m-0 truncate text-sm font-medium capitalize leading-[18px] text-primary">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { useDraggable } from '@dnd-kit/vue'
|
||||
import { CheckIcon, DownloadIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import { useEventListener, useMagicKeys } from '@vueuse/core'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceFileIcon from '@/assets/icons/instance-file.svg'
|
||||
import InstanceCardView from '@/components/ui/library/instance-group/instance-card-view.vue'
|
||||
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
type ProcessEvent = 'installing' | 'launched' | 'finished'
|
||||
|
||||
type ProcessEventPayload = {
|
||||
instance_id: string
|
||||
event: ProcessEvent
|
||||
}
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const {
|
||||
displayState,
|
||||
selectedLibraryInstances,
|
||||
isLibraryInstanceSelectionActive,
|
||||
activeDraggedInstanceKeys,
|
||||
} = useLibrary()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
instanceGroupId: string
|
||||
isSelectionAnchor?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-selection', shiftKey: boolean): void
|
||||
}>()
|
||||
|
||||
const instanceCardElement = ref<InstanceType<typeof InstanceCardView> | null>(null)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentEvent = ref<ProcessEvent | null>(null)
|
||||
const isPrimaryPointerDown = ref(false)
|
||||
const modLoading = computed(
|
||||
() =>
|
||||
loading.value ||
|
||||
currentEvent.value === 'installing' ||
|
||||
(currentEvent.value === 'launched' && !playing.value),
|
||||
)
|
||||
const installing = computed(() => props.instance.install_stage.includes('installing'))
|
||||
const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
const selectionKey = computed(() =>
|
||||
getLibraryInstanceSelectionKey({
|
||||
instanceId: props.instance.id,
|
||||
groupId: props.instanceGroupId,
|
||||
}),
|
||||
)
|
||||
const selected = computed(() => selectedLibraryInstances.value.has(selectionKey.value))
|
||||
const keys = useMagicKeys()
|
||||
const holdingShift = computed(() => keys.shift.value)
|
||||
const isPartOfActiveDrag = computed(() => activeDraggedInstanceKeys.value.has(selectionKey.value))
|
||||
const { isDragging } = useDraggable({
|
||||
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
|
||||
element: instanceCardElement,
|
||||
disabled: computed(() => displayState.value.group !== 'Group'),
|
||||
data: computed(() => ({
|
||||
instanceId: props.instance.id,
|
||||
fromGroup: props.instanceGroupId,
|
||||
})),
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const seeInstance = async () => {
|
||||
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
|
||||
}
|
||||
|
||||
const toggleSelection = (event?: MouseEvent) => {
|
||||
emit('toggle-selection', event?.shiftKey ?? false)
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
isPrimaryPointerDown.value =
|
||||
event.button === 0 &&
|
||||
!(event.target instanceof Element && event.target.closest('.selection-button'))
|
||||
}
|
||||
|
||||
const resetPrimaryPointer = () => {
|
||||
isPrimaryPointerDown.value = false
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointerup', resetPrimaryPointer)
|
||||
useEventListener(window, 'pointercancel', resetPrimaryPointer)
|
||||
useEventListener(window, 'blur', resetPrimaryPointer)
|
||||
|
||||
const activateCard = (event: MouseEvent) => {
|
||||
if (isLibraryInstanceSelectionActive.value || event.shiftKey) {
|
||||
toggleSelection(event)
|
||||
} else {
|
||||
void seeInstance()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardKeydown = (event: KeyboardEvent) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (isLibraryInstanceSelectionActive.value) {
|
||||
toggleSelection()
|
||||
} else {
|
||||
void seeInstance()
|
||||
}
|
||||
} else if (event.key === ' ' && isLibraryInstanceSelectionActive.value) {
|
||||
event.preventDefault()
|
||||
toggleSelection()
|
||||
}
|
||||
}
|
||||
|
||||
const checkProcess = async () => {
|
||||
const runningProcesses = (await get_by_instance_id(props.instance.id).catch(handleError)) ?? []
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
const play = async (event: MouseEvent | null, context: string) => {
|
||||
event?.stopPropagation()
|
||||
if (props.instance.quarantined) return
|
||||
loading.value = true
|
||||
await run(props.instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
})
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const stop = async (event: MouseEvent | null, context: string) => {
|
||||
event?.stopPropagation()
|
||||
playing.value = false
|
||||
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
|
||||
const repair = async (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (props.instance.quarantined) return
|
||||
|
||||
if (
|
||||
props.instance.install_stage !== 'pack_installed' &&
|
||||
(props.instance.link?.type === 'modrinth_modpack' ||
|
||||
props.instance.link?.type === 'server_project_modpack')
|
||||
) {
|
||||
await install_pack_to_existing_instance(props.instance.id, {
|
||||
type: 'fromVersionId',
|
||||
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
|
||||
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
|
||||
title: props.instance.name,
|
||||
}).catch(handleError)
|
||||
} else {
|
||||
await install_existing_instance(props.instance.id, false).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
const openFolder = async () => {
|
||||
await showInstanceInFolder(props.instance.id)
|
||||
}
|
||||
|
||||
const addContent = async () => {
|
||||
if (props.instance.quarantined) return
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: props.instance.id },
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
play,
|
||||
stop,
|
||||
seeInstance,
|
||||
openFolder,
|
||||
addContent,
|
||||
instance: props.instance,
|
||||
})
|
||||
|
||||
const unlisten = await process_listener((event: ProcessEventPayload) => {
|
||||
if (event.instance_id === props.instance.id) {
|
||||
currentEvent.value = event.event
|
||||
if (event.event === 'finished') {
|
||||
playing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
checkProcess()
|
||||
})
|
||||
onUnmounted(() => unlisten())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InstanceCardView
|
||||
ref="instanceCardElement"
|
||||
class="group/card cursor-pointer -outline-offset-2 focus-visible:!outline-2 hover:brightness-110"
|
||||
:class="{
|
||||
'!scale-100': isDragging,
|
||||
'opacity-50': isPartOfActiveDrag,
|
||||
'scale-[0.95]': isPrimaryPointerDown && !isLibraryInstanceSelectionActive,
|
||||
}"
|
||||
:instance="instance"
|
||||
:selected="selected"
|
||||
data-library-instance-card
|
||||
:data-instance-id="instance.id"
|
||||
:data-instance-group="instanceGroupId"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
isLibraryInstanceSelectionActive
|
||||
? `${selected ? 'Deselect' : 'Select'} ${instance.name}`
|
||||
: `Open ${instance.name}`
|
||||
"
|
||||
:aria-pressed="isLibraryInstanceSelectionActive ? selected : undefined"
|
||||
@click="activateCard"
|
||||
@keydown="handleCardKeydown"
|
||||
@mouseenter="checkProcess"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<template #leading="{ instanceType }">
|
||||
<div class="relative flex size-10 shrink-0 items-center justify-center">
|
||||
<div
|
||||
v-if="!playing && !modLoading && !installing"
|
||||
class="flex w-10 flex-col items-center gap-px overflow-clip rounded-[14px] px-[3px] py-0.5 text-primary transition-opacity"
|
||||
:class="{
|
||||
'group-hover/card:scale-75 group-hover/card:opacity-0':
|
||||
!instance.quarantined && !isLibraryInstanceSelectionActive,
|
||||
}"
|
||||
>
|
||||
<InstanceFileIcon class="h-[21px] w-[31px] shrink-0 text-primary [&_path]:fill-current" />
|
||||
<span class="h-3.5 text-sm font-extrabold leading-[13px]">
|
||||
{{ instanceType }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<ButtonStyled v-if="playing" color="red" circular>
|
||||
<button
|
||||
v-tooltip="'Stop'"
|
||||
class="card-shadow"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<SpinnerIcon
|
||||
v-else-if="modLoading || installing"
|
||||
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
|
||||
class="size-8 animate-spin"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<ButtonStyled
|
||||
v-else-if="!isLibraryInstanceSelectionActive && !installed && !instance.quarantined"
|
||||
color="brand"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
v-tooltip="'Repair'"
|
||||
class="card-shadow origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="!isLibraryInstanceSelectionActive && !instance.quarantined"
|
||||
color="brand"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
v-tooltip="'Play'"
|
||||
class="card-shadow origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
<PlayIcon class="translate-x-px" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #overlay>
|
||||
<button
|
||||
type="button"
|
||||
class="selection-button group/selection absolute right-0 top-0 z-[2] flex size-[50px] h-full cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
|
||||
:aria-label="selected ? 'Deselect instance' : 'Select instance'"
|
||||
:aria-pressed="selected"
|
||||
@click.stop="toggleSelection"
|
||||
>
|
||||
<span
|
||||
v-tooltip="selected ? 'Deselect instance' : 'Select instance'"
|
||||
class="relative flex size-[24px] items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover/card:opacity-100 group-hover/selection:brightness-125"
|
||||
:class="{
|
||||
'border-0 !opacity-100': selected,
|
||||
'border-2 border-solid border-primary bg-transparent': !selected,
|
||||
'[outline:3px_solid_var(--color-purple)] outline-offset-1':
|
||||
holdingShift && isSelectionAnchor,
|
||||
}"
|
||||
>
|
||||
<span v-if="selected" class="absolute inset-0 rounded-full bg-primary" />
|
||||
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3] top-px" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</InstanceCardView>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DragDropProvider,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/vue'
|
||||
import { computed, onBeforeUnmount, ref, toRef, watch } from 'vue'
|
||||
|
||||
import DragGather from '@/components/ui/library/instance-group/drag-gather.vue'
|
||||
import DragPreview from '@/components/ui/library/instance-group/drag-preview.vue'
|
||||
import { useInstanceDragGather } from '@/components/ui/library/instance-group/use-instance-drag-gather'
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
type InstanceDragData = {
|
||||
instanceId: string
|
||||
fromGroup: string
|
||||
}
|
||||
|
||||
type InstanceGroupDndDropData = {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
}>()
|
||||
|
||||
const {
|
||||
activeInstanceGroupDrag,
|
||||
instanceGroupDragPointer,
|
||||
instanceGroupDragStatus,
|
||||
startInstanceGroupDrag,
|
||||
updateInstanceGroupDrag,
|
||||
finishInstanceGroupDrag,
|
||||
setInstanceGroupDragTarget,
|
||||
getInstanceGroupDropState,
|
||||
moveDraggedInstancesToGroup,
|
||||
} = useLibrary()
|
||||
|
||||
const draggedInstances = computed(() => {
|
||||
const drag = activeInstanceGroupDrag.value
|
||||
if (!drag) return []
|
||||
|
||||
const draggedInstanceIds = new Set(drag.instances.map((selection) => selection.instanceId))
|
||||
return props.instances.filter((instance) => draggedInstanceIds.has(instance.id))
|
||||
})
|
||||
const draggedInstance = computed(() => {
|
||||
const drag = activeInstanceGroupDrag.value
|
||||
return drag
|
||||
? props.instances.find((instance) => instance.id === drag.primaryInstanceId)
|
||||
: undefined
|
||||
})
|
||||
const {
|
||||
items: gatherItems,
|
||||
target: gatherTarget,
|
||||
isGathering,
|
||||
start: startGather,
|
||||
updateTarget: updateGatherTarget,
|
||||
clear: clearGather,
|
||||
finish: finishGather,
|
||||
} = useInstanceDragGather(toRef(props, 'instances'))
|
||||
|
||||
const instanceGroupDragStatusPopover = ref<HTMLElement>()
|
||||
let statusPopoverFrame: number | undefined
|
||||
|
||||
const instanceGroupDragStatusStyle = computed(() => ({
|
||||
left: `${Math.min(instanceGroupDragPointer.value.x + 4, window.innerWidth - 220)}px`,
|
||||
top: `${Math.min(instanceGroupDragPointer.value.y + 4, window.innerHeight - 48)}px`,
|
||||
}))
|
||||
|
||||
watch(
|
||||
activeInstanceGroupDrag,
|
||||
(drag) => {
|
||||
if (statusPopoverFrame !== undefined) {
|
||||
cancelAnimationFrame(statusPopoverFrame)
|
||||
statusPopoverFrame = undefined
|
||||
}
|
||||
|
||||
const popover = instanceGroupDragStatusPopover.value
|
||||
if (!drag) {
|
||||
if (popover?.matches(':popover-open')) {
|
||||
popover.hidePopover()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
statusPopoverFrame = requestAnimationFrame(() => {
|
||||
statusPopoverFrame = undefined
|
||||
const currentPopover = instanceGroupDragStatusPopover.value
|
||||
if (activeInstanceGroupDrag.value && !currentPopover?.matches(':popover-open')) {
|
||||
currentPopover?.showPopover()
|
||||
}
|
||||
})
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (statusPopoverFrame !== undefined) {
|
||||
cancelAnimationFrame(statusPopoverFrame)
|
||||
}
|
||||
})
|
||||
|
||||
function isAltKeyPressed(event?: Event) {
|
||||
return event instanceof MouseEvent || event instanceof KeyboardEvent ? event.altKey : false
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
const sourceData = event.operation.source?.data as InstanceDragData | undefined
|
||||
if (!sourceData) return
|
||||
|
||||
const pointer = event.operation.position.current
|
||||
startInstanceGroupDrag(
|
||||
sourceData.instanceId,
|
||||
sourceData.fromGroup,
|
||||
pointer,
|
||||
isAltKeyPressed(event.nativeEvent),
|
||||
)
|
||||
startGather(activeInstanceGroupDrag.value, sourceData, pointer)
|
||||
}
|
||||
|
||||
function handleDragMove(event: DragMoveEvent) {
|
||||
const pointer = event.to ?? event.operation.position.current
|
||||
updateInstanceGroupDrag(pointer, isAltKeyPressed(event.nativeEvent))
|
||||
if (gatherItems.value.length > 0) {
|
||||
updateGatherTarget(pointer)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
const targetData = event.operation.target?.data as InstanceGroupDndDropData | undefined
|
||||
setInstanceGroupDragTarget(targetData?.groupId ?? null)
|
||||
}
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const targetData = event.operation.target?.data as InstanceGroupDndDropData | undefined
|
||||
if (!event.canceled && targetData) {
|
||||
const dropState = getInstanceGroupDropState(targetData.groupId)
|
||||
if (dropState.canDrop) {
|
||||
void moveDraggedInstancesToGroup(targetData.groupId, dropState.operation === 'add')
|
||||
}
|
||||
}
|
||||
|
||||
clearGather()
|
||||
finishInstanceGroupDrag()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DragDropProvider
|
||||
@drag-start="handleDragStart"
|
||||
@drag-move="handleDragMove"
|
||||
@drag-over="handleDragOver"
|
||||
@drag-end="handleDragEnd"
|
||||
>
|
||||
<slot />
|
||||
<Teleport to="body">
|
||||
<div class="pointer-events-none fixed inset-0 z-[9999]">
|
||||
<DragOverlay :drop-animation="null">
|
||||
<div
|
||||
v-if="draggedInstance"
|
||||
class="w-full transition-all duration-150 ease-out"
|
||||
:class="isGathering ? 'scale-[0.975]' : 'scale-100'"
|
||||
>
|
||||
<DragPreview :instance="draggedInstance" :count="draggedInstances.length" />
|
||||
</div>
|
||||
</DragOverlay>
|
||||
</div>
|
||||
</Teleport>
|
||||
</DragDropProvider>
|
||||
<DragGather
|
||||
v-if="gatherItems.length > 0"
|
||||
:items="gatherItems"
|
||||
:target="gatherTarget"
|
||||
@complete="finishGather"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
ref="instanceGroupDragStatusPopover"
|
||||
popover="manual"
|
||||
class="pointer-events-none fixed inset-auto z-[9999] m-0 rounded-xl border border-solid border-surface-5 bg-surface-4 px-2 py-1.5 text-sm font-semibold text-contrast empty:hidden"
|
||||
:style="instanceGroupDragStatusStyle"
|
||||
>
|
||||
{{ instanceGroupDragStatus }}
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,128 @@
|
||||
import { onBeforeUnmount, type Ref, ref } from 'vue'
|
||||
|
||||
import type { ActiveInstanceGroupDrag } from '@/components/ui/library/use-library'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
type Point = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type InstanceDragData = {
|
||||
instanceId: string
|
||||
fromGroup: string
|
||||
}
|
||||
|
||||
export type InstanceDragGatherItem = {
|
||||
instance: GameInstance
|
||||
rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
export function useInstanceDragGather(instances: Ref<GameInstance[]>) {
|
||||
const items = ref<InstanceDragGatherItem[]>([])
|
||||
const target = ref<Point>({ x: 0, y: 0 })
|
||||
const targetOffset = ref<Point>({ x: 0, y: 0 })
|
||||
const isGathering = ref(false)
|
||||
let cleanupTimer: number | undefined
|
||||
|
||||
const clearCleanupTimer = () => {
|
||||
if (cleanupTimer !== undefined) {
|
||||
clearTimeout(cleanupTimer)
|
||||
cleanupTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
clearCleanupTimer()
|
||||
items.value = []
|
||||
targetOffset.value = { x: 0, y: 0 }
|
||||
isGathering.value = false
|
||||
}
|
||||
|
||||
const updateTarget = (pointer: Point) => {
|
||||
target.value = {
|
||||
x: pointer.x + targetOffset.value.x,
|
||||
y: pointer.y + targetOffset.value.y,
|
||||
}
|
||||
}
|
||||
|
||||
const start = (
|
||||
drag: ActiveInstanceGroupDrag | null,
|
||||
source: InstanceDragData,
|
||||
pointer: Point,
|
||||
) => {
|
||||
clear()
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
if (!drag || drag.instances.length < 2 || reduceMotion) return
|
||||
|
||||
const instanceCards = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-library-instance-card]'),
|
||||
)
|
||||
const sourceCard = instanceCards.find(
|
||||
(card) =>
|
||||
card.dataset.instanceId === source.instanceId &&
|
||||
card.dataset.instanceGroup === source.fromGroup,
|
||||
)
|
||||
|
||||
if (sourceCard) {
|
||||
const sourceRect = sourceCard.getBoundingClientRect()
|
||||
targetOffset.value = {
|
||||
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
|
||||
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
|
||||
}
|
||||
}
|
||||
|
||||
items.value = drag.instances.flatMap(({ instanceId, groupId }) => {
|
||||
const instance = instances.value.find((candidate) => candidate.id === instanceId)
|
||||
const matchingCards = instanceCards.filter(
|
||||
(card) => card.dataset.instanceId === instanceId && card.getClientRects().length > 0,
|
||||
)
|
||||
const card =
|
||||
matchingCards.find((candidate) => candidate.dataset.instanceGroup === groupId) ??
|
||||
matchingCards[0]
|
||||
if (!instance || !card) return []
|
||||
|
||||
const rect = card.getBoundingClientRect()
|
||||
return [
|
||||
{
|
||||
instance,
|
||||
rect: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
updateTarget(pointer)
|
||||
isGathering.value = items.value.length > 0
|
||||
}
|
||||
|
||||
const finish = () => {
|
||||
isGathering.value = false
|
||||
clearCleanupTimer()
|
||||
cleanupTimer = window.setTimeout(() => {
|
||||
items.value = []
|
||||
cleanupTimer = undefined
|
||||
}, 400)
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearCleanupTimer)
|
||||
|
||||
return {
|
||||
items,
|
||||
target,
|
||||
isGathering,
|
||||
start,
|
||||
updateTarget,
|
||||
clear,
|
||||
finish,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownFilterBar,
|
||||
type DropdownFilterBarCategory,
|
||||
formatLoader,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
|
||||
const { filters, instances } = useLibrary()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const filterCategories = computed<DropdownFilterBarCategory[]>(() => [
|
||||
{
|
||||
key: 'instanceType',
|
||||
label: 'Instance type',
|
||||
options: [
|
||||
{ value: 'modpack', label: 'Modpack' },
|
||||
{ value: 'server', label: 'Server' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'gameVersion',
|
||||
label: 'Game version',
|
||||
searchable: true,
|
||||
options: [...new Set(instances.value.map((instance) => instance.game_version))]
|
||||
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
|
||||
.map((version) => ({ value: version, label: version })),
|
||||
},
|
||||
{
|
||||
key: 'loader',
|
||||
label: 'Loader',
|
||||
options: [...new Set(instances.value.map((instance) => instance.loader))]
|
||||
.map((loader) => ({
|
||||
value: loader,
|
||||
label: formatLoader(formatMessage, loader),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownFilterBar
|
||||
v-model="filters"
|
||||
:categories="filterCategories"
|
||||
use-filter-icon
|
||||
label="Filter by"
|
||||
add-label="Add filter"
|
||||
clear-label="Clear filters"
|
||||
apply-immediately
|
||||
checkbox-position="right"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, SearchIcon, SquarePlusIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, StyledInput } from '@modrinth/ui'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import FilterMenu from '@/components/ui/library/library-toolbar/filter-menu.vue'
|
||||
import NewGroupModal from '@/components/ui/library/library-toolbar/new-group-modal.vue'
|
||||
import SortMenu from '@/components/ui/library/library-toolbar/sort-menu.vue'
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
|
||||
const { search, selectedLibraryInstances, openNewGroupModal } = useLibrary()
|
||||
const showCreationModal = inject<() => void>('showCreationModal')
|
||||
const selectedInstanceIds = computed(
|
||||
() =>
|
||||
new Set([...selectedLibraryInstances.value.values()].map((selection) => selection.instanceId)),
|
||||
)
|
||||
|
||||
function openNewGroup() {
|
||||
openNewGroupModal(selectedInstanceIds.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
clearable
|
||||
wrapper-class="min-w-[16rem] flex-1"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="openNewGroup">
|
||||
<SquarePlusIcon />
|
||||
New group
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" @click="showCreationModal?.()">
|
||||
<PlusIcon />
|
||||
New instance
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<SortMenu />
|
||||
<div class="mx-2 h-6 w-px bg-surface-5" />
|
||||
<FilterMenu />
|
||||
</div>
|
||||
</div>
|
||||
<NewGroupModal />
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
no-padding
|
||||
scrollable
|
||||
actions-divider
|
||||
max-width="560px"
|
||||
width="560px"
|
||||
:on-hide="closeNewGroupModal"
|
||||
>
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-contrast">Create group</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2.5 p-6">
|
||||
<label for="new-group-name" class="font-semibold text-contrast">Group name</label>
|
||||
<StyledInput
|
||||
id="new-group-name"
|
||||
ref="groupNameInput"
|
||||
v-model="newGroupName"
|
||||
placeholder="Enter group name"
|
||||
:maxlength="128"
|
||||
@click="groupNameInput?.select()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-divider" />
|
||||
|
||||
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
|
||||
<div class="px-6">
|
||||
<StyledInput
|
||||
v-model="newGroupSearch"
|
||||
:icon="SearchIcon"
|
||||
placeholder="Search instance"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newGroupInstances.length === 0"
|
||||
class="flex items-center justify-center py-12 text-secondary"
|
||||
>
|
||||
No instances found
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="instance in newGroupInstances"
|
||||
:key="instance.id"
|
||||
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
|
||||
:class="{ 'opacity-60': selectedNewGroupInstanceIds.has(instance.id) }"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
:tint-by="instance.id"
|
||||
:alt="instance.name"
|
||||
size="2rem"
|
||||
rounded="md"
|
||||
/>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
<TagItem v-if="instance.group_ids[0]" class="shrink-0">
|
||||
{{ groupNamesById.get(instance.group_ids[0]) ?? 'Unknown group' }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
:type="selectedNewGroupInstanceIds.has(instance.id) ? 'outlined' : 'standard'"
|
||||
>
|
||||
<button @click="toggleNewGroupInstance(instance.id)">
|
||||
<CheckIcon v-if="selectedNewGroupInstanceIds.has(instance.id)" />
|
||||
{{ selectedNewGroupInstanceIds.has(instance.id) ? 'Added' : 'Add' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canCreateGroup" @click="handleCreateGroup">
|
||||
<SpinnerIcon v-if="creatingGroup" class="animate-spin" />
|
||||
<PlusIcon v-else />
|
||||
Create group
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, NewModal, StyledInput, TagItem } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
|
||||
const {
|
||||
isNewGroupModalOpen,
|
||||
libraryGroups,
|
||||
newGroupName,
|
||||
newGroupSearch,
|
||||
selectedNewGroupInstanceIds,
|
||||
creatingGroup,
|
||||
newGroupInstances,
|
||||
canCreateGroup,
|
||||
closeNewGroupModal,
|
||||
toggleNewGroupInstance,
|
||||
createGroup,
|
||||
clearLibraryInstanceSelection,
|
||||
} = useLibrary()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
|
||||
const groupNamesById = computed(
|
||||
() => new Map(libraryGroups.value.map((group) => [group.id, group.name])),
|
||||
)
|
||||
|
||||
watch(isNewGroupModalOpen, (open) => {
|
||||
if (open) {
|
||||
modal.value?.show()
|
||||
}
|
||||
})
|
||||
|
||||
async function handleCreateGroup() {
|
||||
if (await createGroup()) {
|
||||
clearLibraryInstanceSelection()
|
||||
modal.value?.hide()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpDownIcon, LayoutGridIcon } from '@modrinth/assets'
|
||||
import { Combobox, type ComboboxOption } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
type LibraryGroupBy,
|
||||
libraryGroupOptions,
|
||||
type LibrarySort,
|
||||
librarySortOptions,
|
||||
useLibrary,
|
||||
} from '@/components/ui/library/use-library'
|
||||
|
||||
const { displayState } = useLibrary()
|
||||
|
||||
const sortOptions: ComboboxOption<LibrarySort>[] = librarySortOptions.map((option) => ({
|
||||
value: option,
|
||||
label: option,
|
||||
}))
|
||||
const groupOptions: ComboboxOption<LibraryGroupBy>[] = [...libraryGroupOptions]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Combobox
|
||||
v-model="displayState.sortBy"
|
||||
class="w-max"
|
||||
:options="sortOptions"
|
||||
:show-icon-in-selected="false"
|
||||
dropdown-min-width="160px"
|
||||
>
|
||||
<template #prefix>
|
||||
<ArrowUpDownIcon class="size-5 text-primary" aria-label="Sort by" />
|
||||
</template>
|
||||
<template #selected="{ label }">
|
||||
<span>{{ label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<Combobox
|
||||
v-model="displayState.group"
|
||||
class="w-max"
|
||||
:options="groupOptions"
|
||||
:show-icon-in-selected="false"
|
||||
>
|
||||
<template #prefix>
|
||||
<LayoutGridIcon class="size-5 text-primary" aria-label="Group by" />
|
||||
</template>
|
||||
<template #selected="{ label }">
|
||||
<span>{{ label }}</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" max-width="544px" no-padding>
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2.5 h-[154px] px-7 pt-4 pb-1 pr-9">
|
||||
<div class="flex flex-col gap-2.5 items-start justify-center h-min mt-5">
|
||||
<div class="font-semibold text-xl text-contrast">
|
||||
{{ formatMessage(messages.descriptionHeader) }}
|
||||
</div>
|
||||
<div class="text-secondary leading-6">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-full w-[96px] overflow-hidden mx-3">
|
||||
<div class="absolute top-0 left-0 z-0 w-full flex grow-0 flex-col items-end p-0">
|
||||
<img :src="steveImage" alt="" class="self-stretch" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute left-0 bottom-0 z-10 order-1 h-6 w-[120px] shrink-0 grow-0 bg-[linear-gradient(180deg,rgba(39,41,46,0)_0%,#27292E_80%,#27292E_100%)]"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 px-6 pb-6">
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<a class="w-full !shadow-none" href="https://support.modrinth.com" @click="modal?.hide()">
|
||||
<MessagesSquareIcon />
|
||||
{{ formatMessage(messages.getSupport) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button class="w-full !shadow-none" :disabled="loadingSignIn" @click="signIn">
|
||||
<SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
|
||||
<svg
|
||||
v-else
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
|
||||
<rect x="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
|
||||
<rect y="10.75" width="9.25" height="9.25" fill="black" fill-opacity="0.9" />
|
||||
<rect
|
||||
x="10.75"
|
||||
y="10.75"
|
||||
width="9.25"
|
||||
height="9.25"
|
||||
fill="black"
|
||||
fill-opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
{{ formatMessage(messages.signIn) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.dontHaveAccount) }}
|
||||
<a
|
||||
class="text-blue font-medium hover:underline"
|
||||
href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
|
||||
>
|
||||
{{ formatMessage(messages.getMinecraft) }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { inject, type Ref, ref } from 'vue'
|
||||
|
||||
import steveImage from '@/assets/steve-look-up-left.webp'
|
||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'minecraft-required.header',
|
||||
defaultMessage: 'Minecraft required',
|
||||
},
|
||||
descriptionHeader: {
|
||||
id: 'minecraft-required.description-header',
|
||||
defaultMessage: 'Sign in to a Microsoft account',
|
||||
},
|
||||
description: {
|
||||
id: 'minecraft-required.description',
|
||||
defaultMessage:
|
||||
'You need a Microsoft account that owns Minecraft before you can launch and play.',
|
||||
},
|
||||
getSupport: {
|
||||
id: 'minecraft-required.get-support',
|
||||
defaultMessage: 'Get support',
|
||||
},
|
||||
signIn: {
|
||||
id: 'minecraft-required.sign-in',
|
||||
defaultMessage: 'Sign in to Microsoft',
|
||||
},
|
||||
dontHaveAccount: {
|
||||
id: 'minecraft-required.dont-have-account',
|
||||
defaultMessage: 'Don’t have an account?',
|
||||
},
|
||||
getMinecraft: {
|
||||
id: 'minecraft-required.get-minecraft',
|
||||
defaultMessage: 'Get Minecraft',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const loadingSignIn = ref(false)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
loadingSignIn.value = true
|
||||
|
||||
try {
|
||||
const loggedIn = await loginFlow()
|
||||
if (!loggedIn) return
|
||||
|
||||
await set_default_user(loggedIn.profile.id)
|
||||
await accountsCard.value?.refreshValues()
|
||||
await trackEvent('AccountLogIn', { source: 'MinecraftRequiredModal' })
|
||||
modal.value?.hide()
|
||||
} catch (error) {
|
||||
handleSevereError(error)
|
||||
} finally {
|
||||
loadingSignIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
defineProps({
|
||||
onFlowCancel: {
|
||||
type: Function,
|
||||
default() {
|
||||
return async () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @hide="onFlowCancel">
|
||||
<template #title>
|
||||
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
|
||||
<LogInIcon /> Sign in
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center gap-2">
|
||||
<SpinnerIcon class="w-12 h-12 animate-spin" />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Please sign in at the browser window that just opened to continue.
|
||||
</p>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
|
||||
<NewModal ref="modal" :header="modalHeader" fade="danger" max-width="500px">
|
||||
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
{{ admonitionBody }}
|
||||
</Admonition>
|
||||
|
||||
<template #actions>
|
||||
@@ -15,7 +15,7 @@
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirm">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteButton) }}
|
||||
{{ deleteButtonLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -42,6 +42,10 @@ const messages = defineMessages({
|
||||
id: 'app.instance.confirm-delete.header',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
bulkHeader: {
|
||||
id: 'app.instance.confirm-delete.bulk-header',
|
||||
defaultMessage: 'Delete {count} instances',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'app.instance.confirm-delete.admonition-header',
|
||||
defaultMessage: 'This action cannot be undone',
|
||||
@@ -51,17 +55,43 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'All data for your instance will be permanently deleted, including your worlds, configs, and all installed content.',
|
||||
},
|
||||
bulkAdmonitionBody: {
|
||||
id: 'app.instance.confirm-delete.bulk-admonition-body',
|
||||
defaultMessage:
|
||||
'All data for these {count} instances will be permanently deleted, including their worlds, configs, and all installed content.',
|
||||
},
|
||||
deleteButton: {
|
||||
id: 'app.instance.confirm-delete.delete-button',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
bulkDeleteButton: {
|
||||
id: 'app.instance.confirm-delete.bulk-delete-button',
|
||||
defaultMessage: 'Delete {count} instances',
|
||||
},
|
||||
})
|
||||
|
||||
const { count = 1 } = defineProps<{
|
||||
count?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const modalHeader = computed(() =>
|
||||
count === 1 ? formatMessage(messages.header) : formatMessage(messages.bulkHeader, { count }),
|
||||
)
|
||||
const admonitionBody = computed(() =>
|
||||
count === 1
|
||||
? formatMessage(messages.admonitionBody)
|
||||
: formatMessage(messages.bulkAdmonitionBody, { count }),
|
||||
)
|
||||
const deleteButtonLabel = computed(() =>
|
||||
count === 1
|
||||
? formatMessage(messages.deleteButton)
|
||||
: formatMessage(messages.bulkDeleteButton, { count }),
|
||||
)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
|
||||
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
|
||||
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
|
||||
{{ formatMessage(messages.serverRequiresMods) }}
|
||||
</Admonition>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.installToPlay)"
|
||||
:closable="true"
|
||||
:on-hide="show_ads_window"
|
||||
max-width="544px"
|
||||
width="544px"
|
||||
>
|
||||
<div v-if="requiredContentProject" class="flex w-full flex-col gap-6">
|
||||
<p class="m-0 text-primary">
|
||||
{{ formatMessage(messages.inviteWarning) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.requiredModpack)
|
||||
}}</span>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.sharedInstance) }}
|
||||
</span>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="openViewContents">
|
||||
@@ -19,14 +26,16 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
|
||||
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
|
||||
<Avatar
|
||||
:src="requiredContentProject.icon_url"
|
||||
:alt="requiredContentProject.title"
|
||||
size="48px"
|
||||
size="56px"
|
||||
no-shadow
|
||||
class="!rounded-2xl"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">
|
||||
<template v-if="usingCustomModpack && modpackVersion">
|
||||
{{ modpackVersion.name }}
|
||||
</template>
|
||||
@@ -34,7 +43,7 @@
|
||||
{{ requiredContentProject.title }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
<span class="truncate text-sm font-medium text-secondary">
|
||||
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
|
||||
<template v-if="modCount">
|
||||
· {{ formatMessage(messages.modCount, { count: modCount }) }}
|
||||
@@ -43,24 +52,105 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDecline">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleAccept">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<Admonition
|
||||
v-if="hasExternalFiles"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.unknownFilesWarning)"
|
||||
>
|
||||
{{ formatMessage(messages.unknownFilesDescription) }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="hasExternalFiles" class="relative w-full">
|
||||
<div
|
||||
ref="externalFileTable"
|
||||
class="max-h-[242px] overflow-y-auto rounded-2xl"
|
||||
@scroll="checkTableScrollState"
|
||||
>
|
||||
<Table
|
||||
:columns="externalFileColumns"
|
||||
:data="externalFileRows"
|
||||
row-key="id"
|
||||
virtualized
|
||||
:virtual-row-height="48"
|
||||
class="shadow-sm"
|
||||
>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="block truncate" :title="String(value)">{{ value }}</span>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTableTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTableBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="hasExternalFiles" class="m-0 text-primary">
|
||||
{{ formatMessage(messages.reviewedFiles) }}
|
||||
</p>
|
||||
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<ButtonStyled type="transparent" color="red">
|
||||
<button @click="handleReport">
|
||||
<ReportIcon />
|
||||
{{ formatMessage(commonMessages.reportButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="hasExternalFiles">
|
||||
<ButtonStyled type="transparent" color="orange">
|
||||
<button @click="handleAccept">
|
||||
{{ formatMessage(messages.installAnyway) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleDecline">
|
||||
<BanIcon />
|
||||
{{ formatMessage(messages.dontInstall) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ButtonStyled>
|
||||
<button @click="handleDecline">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleAccept">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<ModpackContentModal
|
||||
@@ -72,33 +162,56 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
import { BanIcon, DownloadIcon, EyeIcon, ReportIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
type ContentItem,
|
||||
defineMessages,
|
||||
formatLoader,
|
||||
ModpackContentModal,
|
||||
NewModal,
|
||||
Table,
|
||||
type TableColumn,
|
||||
useScrollIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
|
||||
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
|
||||
type ExternalFileColumn = 'name'
|
||||
type ExternalFileRow = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const project = ref<Labrinth.Projects.v3.Project | null>(null)
|
||||
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
|
||||
const externalFiles = ref<string[]>([])
|
||||
const externalFileTable = ref<HTMLElement | null>(null)
|
||||
const onInstallComplete = ref<() => void>(() => {})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
showExternalWarnings?: boolean
|
||||
}>()
|
||||
|
||||
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
const {
|
||||
showTopFade: showTableTopFade,
|
||||
showBottomFade: showTableBottomFade,
|
||||
checkScrollState: checkTableScrollState,
|
||||
forceCheck: forceCheckTableScroll,
|
||||
} = useScrollIndicator(externalFileTable)
|
||||
|
||||
const usingCustomModpack = computed(() => {
|
||||
return requiredContentProject.value?.id === project.value?.id
|
||||
@@ -111,13 +224,36 @@ const loaderDisplay = computed(() => {
|
||||
})
|
||||
|
||||
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
|
||||
const hasExternalFiles = computed(
|
||||
() => Boolean(props.showExternalWarnings) && externalFiles.value.length > 0,
|
||||
)
|
||||
const externalFileRows = computed<ExternalFileRow[]>(() =>
|
||||
externalFiles.value.map((name, index) => ({
|
||||
id: `${index}-${name}`,
|
||||
name,
|
||||
})),
|
||||
)
|
||||
|
||||
async function fetchData(versionId: string) {
|
||||
// cache is making version null for some reason so bypassing for now
|
||||
modpackVersion.value = await get_version(versionId, 'bypass')
|
||||
const version = await get_version(versionId, 'bypass')
|
||||
modpackVersion.value = version
|
||||
|
||||
if (modpackVersion.value?.project_id) {
|
||||
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
|
||||
if (version?.project_id) {
|
||||
requiredContentProject.value = await get_project(version.project_id, 'bypass')
|
||||
externalFiles.value = [
|
||||
...new Set(
|
||||
(version.dependencies ?? [])
|
||||
.filter(
|
||||
(dependency) =>
|
||||
dependency.dependency_type === 'embedded' &&
|
||||
!dependency.project_id &&
|
||||
!dependency.version_id &&
|
||||
dependency.file_name,
|
||||
)
|
||||
.flatMap((dependency) => (dependency.file_name ? [dependency.file_name] : [])),
|
||||
),
|
||||
].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +275,12 @@ function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleReport() {
|
||||
if (project.value?.id) {
|
||||
openUrl(`https://modrinth.com/report?item=project&itemID=${project.value.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
|
||||
|
||||
async function openViewContents() {
|
||||
@@ -217,17 +359,19 @@ async function show(
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
modpackVersion.value = null
|
||||
requiredContentProject.value = null
|
||||
externalFiles.value = []
|
||||
onInstallComplete.value = callback
|
||||
|
||||
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
|
||||
|
||||
hide_ads_window()
|
||||
modal.value?.show(e)
|
||||
await nextTick()
|
||||
forceCheckTableScroll()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
show_ads_window()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -235,22 +379,10 @@ const messages = defineMessages({
|
||||
id: 'app.modal.install-to-play.header',
|
||||
defaultMessage: 'Install to play',
|
||||
},
|
||||
sharedServerInstance: {
|
||||
id: 'app.modal.install-to-play.shared-server-instance',
|
||||
defaultMessage: 'Shared server instance',
|
||||
},
|
||||
contentRequired: {
|
||||
id: 'app.modal.install-to-play.content-required',
|
||||
defaultMessage: 'Content required',
|
||||
},
|
||||
serverRequiresMods: {
|
||||
id: 'app.modal.install-to-play.server-requires-mods',
|
||||
inviteWarning: {
|
||||
id: 'app.modal.install-to-play.invite-warning',
|
||||
defaultMessage:
|
||||
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
|
||||
},
|
||||
requiredModpack: {
|
||||
id: 'app.modal.install-to-play.required-modpack',
|
||||
defaultMessage: 'Required modpack',
|
||||
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
|
||||
},
|
||||
sharedInstance: {
|
||||
id: 'app.modal.install-to-play.shared-instance',
|
||||
@@ -268,7 +400,41 @@ const messages = defineMessages({
|
||||
id: 'app.modal.install-to-play.view-contents',
|
||||
defaultMessage: 'View contents',
|
||||
},
|
||||
unknownFilesWarning: {
|
||||
id: 'app.modal.install-to-play.unknown-files-warning',
|
||||
defaultMessage: 'Unknown files warning',
|
||||
},
|
||||
unknownFilesDescription: {
|
||||
id: 'app.modal.install-to-play.unknown-files-description',
|
||||
defaultMessage:
|
||||
'This server modpack contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust.',
|
||||
},
|
||||
unrecognizedFiles: {
|
||||
id: 'app.modal.install-to-play.unrecognized-files',
|
||||
defaultMessage: 'Unrecognized files',
|
||||
},
|
||||
reviewedFiles: {
|
||||
id: 'app.modal.install-to-play.reviewed-files',
|
||||
defaultMessage:
|
||||
'A file is only reviewed if it’s published to Modrinth, regardless of its file format (including .mrpack).',
|
||||
},
|
||||
installAnyway: {
|
||||
id: 'app.modal.install-to-play.install-anyway',
|
||||
defaultMessage: 'Install anyway',
|
||||
},
|
||||
dontInstall: {
|
||||
id: 'app.modal.install-to-play.external-files-dont-install',
|
||||
defaultMessage: "Don't install",
|
||||
},
|
||||
})
|
||||
|
||||
const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: formatMessage(messages.unrecognizedFiles),
|
||||
cellClass: '!h-12',
|
||||
},
|
||||
])
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CoffeeIcon,
|
||||
InfoIcon,
|
||||
MonitorIcon,
|
||||
UsersIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.v
|
||||
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
|
||||
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||
import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue'
|
||||
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
|
||||
import { get_project_v3 } from '@/helpers/cache'
|
||||
import { get_linked_modpack_info } from '@/helpers/instance'
|
||||
@@ -97,6 +99,15 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
||||
icon: WrenchIcon,
|
||||
content: InstallationSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.sharing',
|
||||
defaultMessage: 'Sharing',
|
||||
}),
|
||||
icon: UsersIcon,
|
||||
content: SharingSettings,
|
||||
shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.window',
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(authenticating ? messages.signingInHeader : messages.header)"
|
||||
:on-hide="handleHide"
|
||||
no-padding
|
||||
max-width="548px"
|
||||
width="100%"
|
||||
>
|
||||
<div v-if="authenticating === null" class="flex w-full flex-col gap-6 p-6">
|
||||
<div class="flex flex-col gap-2 px-3">
|
||||
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
|
||||
{{ formatMessage(messages.signInHeading) }}
|
||||
</h2>
|
||||
<p class="m-0 text-base leading-6 text-primary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<ButtonStyled>
|
||||
<button class="w-full !shadow-none" type="button" @click="authenticate('sign-up')">
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createAccountButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button class="w-full" type="button" @click="authenticate('sign-in')">
|
||||
<LogInIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.signInButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
|
||||
<IntlFormatted :message-id="messages.supportPrompt">
|
||||
<template #support="{ children }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
|
||||
@click="openSupport"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</button>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex w-full flex-col gap-6 p-6">
|
||||
<div class="flex flex-col gap-2.5 px-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
|
||||
{{ formatMessage(messages.continueInBrowserHeading) }}
|
||||
</h2>
|
||||
<p class="m-0 text-base leading-6 text-primary">
|
||||
{{ formatMessage(messages.browserDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-primary">
|
||||
<SpinnerIcon aria-hidden="true" class="h-5 w-5 shrink-0 animate-spin" />
|
||||
<span class="text-base leading-6">
|
||||
{{ formatMessage(messages.waitingForBrowser) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="w-full" type="button" @click="modal?.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="w-full !shadow-none"
|
||||
type="button"
|
||||
:disabled="reopeningBrowser"
|
||||
@click="reopenBrowser"
|
||||
>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.openBrowserAgainButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
|
||||
<IntlFormatted :message-id="messages.supportPrompt">
|
||||
<template #support="{ children }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
|
||||
@click="openSupport"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</button>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, RefreshCwIcon, SpinnerIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cancelLogin, type ModrinthAuthFlow } from '@/helpers/mr_auth'
|
||||
|
||||
const props = defineProps<{
|
||||
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const authenticating = ref<ModrinthAuthFlow | null>(null)
|
||||
const reopeningBrowser = ref(false)
|
||||
let resolveShow: ((signedIn: boolean) => void) | undefined
|
||||
let authenticationId = 0
|
||||
let activeAuthentication: Promise<void> | undefined
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
resetAuthentication(true)
|
||||
resolveShow?.(false)
|
||||
const modalInstance = modal.value
|
||||
if (!modalInstance) return Promise.resolve(false)
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveShow = resolve
|
||||
modalInstance.show(event)
|
||||
})
|
||||
}
|
||||
|
||||
function showSigningIn(flow: ModrinthAuthFlow = 'sign-in', event?: MouseEvent) {
|
||||
const result = show(event)
|
||||
authenticate(flow)
|
||||
return result
|
||||
}
|
||||
|
||||
function finish(signedIn: boolean) {
|
||||
resolveShow?.(signedIn)
|
||||
resolveShow = undefined
|
||||
}
|
||||
|
||||
function authenticate(flow: ModrinthAuthFlow) {
|
||||
const id = ++authenticationId
|
||||
authenticating.value = flow
|
||||
|
||||
const authentication = (async () => {
|
||||
try {
|
||||
if ((await props.requestAuth(flow)) && authenticationId === id) {
|
||||
authenticating.value = null
|
||||
activeAuthentication = undefined
|
||||
finish(true)
|
||||
modal.value?.hide()
|
||||
}
|
||||
} finally {
|
||||
if (authenticationId === id) {
|
||||
authenticating.value = null
|
||||
activeAuthentication = undefined
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
activeAuthentication = authentication
|
||||
}
|
||||
|
||||
async function reopenBrowser() {
|
||||
const flow = authenticating.value
|
||||
if (!flow || reopeningBrowser.value) return
|
||||
|
||||
reopeningBrowser.value = true
|
||||
const previousAuthentication = activeAuthentication
|
||||
++authenticationId
|
||||
|
||||
try {
|
||||
await cancelLogin()
|
||||
await previousAuthentication?.catch(() => undefined)
|
||||
if (authenticating.value === flow) authenticate(flow)
|
||||
} finally {
|
||||
reopeningBrowser.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAuthentication(cancelActive: boolean) {
|
||||
const wasAuthenticating = authenticating.value !== null
|
||||
++authenticationId
|
||||
activeAuthentication = undefined
|
||||
authenticating.value = null
|
||||
reopeningBrowser.value = false
|
||||
|
||||
if (cancelActive && wasAuthenticating) void cancelLogin()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
resetAuthentication(true)
|
||||
finish(false)
|
||||
}
|
||||
|
||||
function openSupport() {
|
||||
openUrl('https://support.modrinth.com')
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'modal.modrinth-account-required.header',
|
||||
defaultMessage: 'Account required',
|
||||
},
|
||||
signingInHeader: {
|
||||
id: 'modal.modrinth-account-required.signing-in-header',
|
||||
defaultMessage: 'Signing in',
|
||||
},
|
||||
signInHeading: {
|
||||
id: 'modal.modrinth-account-required.sign-in-heading',
|
||||
defaultMessage: 'Sign in to a Modrinth account',
|
||||
},
|
||||
description: {
|
||||
id: 'modal.modrinth-account-required.description',
|
||||
defaultMessage:
|
||||
"You'll need to sign into your Modrinth account before you can use this feature.",
|
||||
},
|
||||
createAccountButton: {
|
||||
id: 'modal.modrinth-account-required.create-account-button',
|
||||
defaultMessage: 'Create an account',
|
||||
},
|
||||
signInButton: {
|
||||
id: 'modal.modrinth-account-required.sign-in-button',
|
||||
defaultMessage: 'Sign in to Modrinth',
|
||||
},
|
||||
continueInBrowserHeading: {
|
||||
id: 'modal.modrinth-account-required.continue-in-browser-heading',
|
||||
defaultMessage: 'Continue in your browser',
|
||||
},
|
||||
browserDescription: {
|
||||
id: 'modal.modrinth-account-required.browser-description',
|
||||
defaultMessage:
|
||||
'A new tab opened to sign in. Complete the sign in there, then return to the app.',
|
||||
},
|
||||
waitingForBrowser: {
|
||||
id: 'modal.modrinth-account-required.waiting-for-browser',
|
||||
defaultMessage: 'Waiting for browser confirmation...',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'modal.modrinth-account-required.cancel-button',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
openBrowserAgainButton: {
|
||||
id: 'modal.modrinth-account-required.open-browser-again-button',
|
||||
defaultMessage: 'Open browser again',
|
||||
},
|
||||
supportPrompt: {
|
||||
id: 'modal.modrinth-account-required.support-prompt',
|
||||
defaultMessage: 'Having trouble signing in? <support>Get support</support>',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, showSigningIn })
|
||||
</script>
|
||||
@@ -2,17 +2,18 @@
|
||||
<ContentDiffModal
|
||||
ref="diffModal"
|
||||
:header="formatMessage(messages.updateToPlay)"
|
||||
:admonition-header="formatMessage(messages.updateRequired)"
|
||||
:description="
|
||||
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
|
||||
"
|
||||
:diffs="normalizedDiffs"
|
||||
:version-date="versionDate"
|
||||
:show-external-warnings="showExternalWarnings"
|
||||
:external-warning-description="formatMessage(messages.externalWarningDescription)"
|
||||
:confirm-label="formatMessage(commonMessages.updateButton)"
|
||||
:confirm-icon="DownloadIcon"
|
||||
:show-report-button="true"
|
||||
:removed-label="formatMessage(messages.removed)"
|
||||
@confirm="handleUpdate"
|
||||
@cancel="handleDecline"
|
||||
@report="handleReport"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -26,9 +27,8 @@ import {
|
||||
defineMessages,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { wait_for_install_job } from '@/helpers/install'
|
||||
@@ -77,6 +77,15 @@ const { formatMessage } = useVIntl()
|
||||
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
type UpdateCompleteCallback = () => void | Promise<void>
|
||||
|
||||
const { showExternalWarnings } = defineProps<{
|
||||
showExternalWarnings?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const instance = ref<GameInstance | null>(null)
|
||||
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
|
||||
@@ -84,14 +93,21 @@ const diffs = ref<DependencyDiff[]>([])
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Version | null>(null)
|
||||
|
||||
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
|
||||
diffs.value.map((diff) => ({
|
||||
const normalizedDiffs = computed<ContentDiffItem[]>(() => {
|
||||
return diffs.value.map((diff) => ({
|
||||
type: diff.type,
|
||||
external: Boolean(diff.fileName && !diff.project),
|
||||
projectName: diff.project?.title,
|
||||
fileName: diff.fileName,
|
||||
currentVersionName: diff.currentVersion?.version_number,
|
||||
newVersionName: diff.newVersion?.version_number,
|
||||
})),
|
||||
}))
|
||||
})
|
||||
|
||||
const versionDate = computed(() =>
|
||||
modpackVersion.value?.date_published
|
||||
? dayjs(modpackVersion.value.date_published).format('MMMM D, YYYY')
|
||||
: undefined,
|
||||
)
|
||||
|
||||
async function computeDependencyDiffs(
|
||||
@@ -190,6 +206,10 @@ async function computeDependencyDiffs(
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aExternal = Boolean(a.fileName && !a.project)
|
||||
const bExternal = Boolean(b.fileName && !b.project)
|
||||
if (aExternal !== bExternal) return aExternal ? -1 : 1
|
||||
|
||||
const typeOrder = { added: 0, updated: 1, removed: 2 }
|
||||
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
|
||||
if (typeCompare !== 0) return typeCompare
|
||||
@@ -227,18 +247,7 @@ async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[
|
||||
return null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => instance.value,
|
||||
async (newInstance) => {
|
||||
if (!newInstance) return
|
||||
const result = await checkUpdateAvailable(newInstance)
|
||||
diffs.value = result || []
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
async function handleUpdate() {
|
||||
hide()
|
||||
const serverProjectId = instance.value?.link?.project_id
|
||||
if (serverProjectId) startInstallingServer(serverProjectId)
|
||||
try {
|
||||
@@ -251,17 +260,12 @@ async function handleUpdate() {
|
||||
console.error('Error updating instance:', error)
|
||||
} finally {
|
||||
if (serverProjectId) stopInstallingServer(serverProjectId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReport() {
|
||||
if (instance.value?.link?.project_id) {
|
||||
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.value.link.project_id}`)
|
||||
emit('complete')
|
||||
}
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function show(
|
||||
@@ -272,8 +276,15 @@ function show(
|
||||
) {
|
||||
instance.value = instanceVal
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
modpackVersion.value = null
|
||||
diffs.value = []
|
||||
onUpdateComplete.value = callback
|
||||
diffModal.value?.show(e)
|
||||
void checkUpdateAvailable(instanceVal).then((result) => {
|
||||
if (instance.value?.id === instanceVal.id && modpackVersionId.value === modpackVersionIdVal) {
|
||||
diffs.value = result || []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
@@ -285,14 +296,19 @@ const messages = defineMessages({
|
||||
id: 'app.modal.update-to-play.header',
|
||||
defaultMessage: 'Update to play',
|
||||
},
|
||||
updateRequired: {
|
||||
id: 'app.modal.update-to-play.update-required',
|
||||
defaultMessage: 'Update required',
|
||||
},
|
||||
updateRequiredDescription: {
|
||||
id: 'app.modal.update-to-play.update-required-description',
|
||||
defaultMessage:
|
||||
'An update is required to play {name}. Please update to the latest version to launch the game.',
|
||||
'An update is required to play {name}. Please update to latest version to launch the game.',
|
||||
},
|
||||
removed: {
|
||||
id: 'app.modal.update-to-play.removed',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
externalWarningDescription: {
|
||||
id: 'app.modal.update-to-play.server-modpack-unknown-files-description',
|
||||
defaultMessage:
|
||||
'This server modpack update contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust.',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, RadioButtonIcon } from '@modrinth/assets'
|
||||
import { Accordion, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { injectOnboardingChecklist } from '@/providers/onboarding-checklist'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'create-instance': []
|
||||
'login-minecraft': []
|
||||
'login-modrinth': []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const {
|
||||
hasCreatedInstance,
|
||||
hasLoggedIntoMinecraft,
|
||||
hasLoggedIntoModrinth,
|
||||
isReady,
|
||||
showChecklist,
|
||||
} = injectOnboardingChecklist()
|
||||
const collapsedCornersVisible = ref(false)
|
||||
let collapseTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'onboarding-checklist.title',
|
||||
defaultMessage: 'Getting started',
|
||||
},
|
||||
createInstance: {
|
||||
id: 'onboarding-checklist.create-instance',
|
||||
defaultMessage: 'Create first instance',
|
||||
},
|
||||
loginMinecraft: {
|
||||
id: 'onboarding-checklist.login-minecraft',
|
||||
defaultMessage: 'Sign in to Minecraft',
|
||||
},
|
||||
loginModrinth: {
|
||||
id: 'onboarding-checklist.login-modrinth',
|
||||
defaultMessage: 'Sign in to Modrinth',
|
||||
},
|
||||
})
|
||||
|
||||
const steps = computed(() => [
|
||||
{
|
||||
id: 'create-instance',
|
||||
label: formatMessage(messages.createInstance),
|
||||
complete: hasCreatedInstance.value,
|
||||
action: () => emit('create-instance'),
|
||||
},
|
||||
{
|
||||
id: 'login-minecraft',
|
||||
label: formatMessage(messages.loginMinecraft),
|
||||
complete: hasLoggedIntoMinecraft.value,
|
||||
action: () => emit('login-minecraft'),
|
||||
},
|
||||
{
|
||||
id: 'login-modrinth',
|
||||
label: formatMessage(messages.loginModrinth),
|
||||
complete: hasLoggedIntoModrinth.value,
|
||||
action: () => emit('login-modrinth'),
|
||||
},
|
||||
])
|
||||
|
||||
const accordionButtonClass = computed(
|
||||
() =>
|
||||
`flex w-full cursor-pointer items-center justify-between rounded-t-2xl border border-solid border-button-border bg-button-bg p-3 text-left text-contrast transition-[filter] hover:brightness-110${collapsedCornersVisible.value ? ' rounded-b-2xl' : ''}`,
|
||||
)
|
||||
|
||||
function handleAccordionOpen() {
|
||||
clearTimeout(collapseTimer)
|
||||
collapsedCornersVisible.value = false
|
||||
}
|
||||
|
||||
function handleAccordionClose() {
|
||||
clearTimeout(collapseTimer)
|
||||
collapseTimer = setTimeout(() => {
|
||||
collapsedCornersVisible.value = true
|
||||
}, 300)
|
||||
}
|
||||
|
||||
onUnmounted(() => clearTimeout(collapseTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isReady && showChecklist"
|
||||
class="border-0 border-b-[1px] border-solid border-[--brand-gradient-border] px-3 p-4"
|
||||
>
|
||||
<Accordion
|
||||
open-by-default
|
||||
class="w-full overflow-hidden rounded-2xl text-base"
|
||||
:button-class="accordionButtonClass"
|
||||
content-class="flex flex-col gap-2 rounded-b-2xl border border-t-0 border-solid border-surface-5 bg-button-bg p-3"
|
||||
@on-open="handleAccordionOpen"
|
||||
@on-close="handleAccordionClose"
|
||||
>
|
||||
<template #title>
|
||||
<span class="font-semibold leading-6">{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<button
|
||||
v-for="step in steps"
|
||||
:key="step.id"
|
||||
type="button"
|
||||
class="flex h-10 w-full items-center gap-2 rounded-xl border border-solid border-button-border bg-button-bg px-4 py-2.5 text-left text-primary shadow-[0_1px_0.5px_rgb(0_0_0_/_12%)] transition-[filter]"
|
||||
:class="
|
||||
step.complete
|
||||
? '!cursor-default opacity-50'
|
||||
: 'cursor-pointer hover:brightness-110 active:brightness-90'
|
||||
"
|
||||
:disabled="step.complete"
|
||||
@click="step.action"
|
||||
>
|
||||
<span
|
||||
v-if="step.complete"
|
||||
class="flex size-[18px] items-center justify-center rounded-full bg-primary mr-0.5 relative left-px"
|
||||
>
|
||||
<CheckIcon class="size-3 invert [stroke-width:3] top-px" />
|
||||
</span>
|
||||
<RadioButtonIcon v-else class="size-5 shrink-0" />
|
||||
<span
|
||||
class="min-w-0 truncate font-medium leading-5"
|
||||
:class="{ 'text-secondary line-through': step.complete }"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
</button>
|
||||
</Accordion>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Combobox, defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { defineMessages, ThemeSelector, Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
@@ -57,26 +57,6 @@ const messages = defineMessages({
|
||||
id: 'app.appearance-settings.minimize-launcher.description',
|
||||
defaultMessage: 'Minimize the launcher when a Minecraft process starts.',
|
||||
},
|
||||
defaultLandingPageTitle: {
|
||||
id: 'app.appearance-settings.default-landing-page.title',
|
||||
defaultMessage: 'Default landing page',
|
||||
},
|
||||
defaultLandingPageDescription: {
|
||||
id: 'app.appearance-settings.default-landing-page.description',
|
||||
defaultMessage: 'Change the page to which the launcher opens on.',
|
||||
},
|
||||
defaultLandingPageHome: {
|
||||
id: 'app.appearance-settings.default-landing-page.home',
|
||||
defaultMessage: 'Home',
|
||||
},
|
||||
defaultLandingPageLibrary: {
|
||||
id: 'app.appearance-settings.default-landing-page.library',
|
||||
defaultMessage: 'Library',
|
||||
},
|
||||
selectOption: {
|
||||
id: 'app.appearance-settings.select-option',
|
||||
defaultMessage: 'Select an option',
|
||||
},
|
||||
jumpBackIntoWorldsTitle: {
|
||||
id: 'app.appearance-settings.jump-back-into-worlds.title',
|
||||
defaultMessage: 'Jump back into worlds',
|
||||
@@ -230,32 +210,6 @@ watch(
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.defaultLandingPageTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.defaultLandingPageDescription) }}</p>
|
||||
</div>
|
||||
<Combobox
|
||||
id="opening-page"
|
||||
v-model="settings.default_page"
|
||||
name="Opening page dropdown"
|
||||
class="max-w-40"
|
||||
:options="[
|
||||
{
|
||||
value: 'Home',
|
||||
label: formatMessage(messages.defaultLandingPageHome),
|
||||
},
|
||||
{
|
||||
value: 'Library',
|
||||
label: formatMessage(messages.defaultLandingPageLibrary),
|
||||
},
|
||||
]"
|
||||
:display-value="settings.default_page ?? 'Select an option'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { Toggle } from '@modrinth/ui'
|
||||
import { Settings2Icon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPageContext,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { open_ads_consent_preferences } from '@/helpers/ads.js'
|
||||
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { adConsentAvailable } = injectPageContext()
|
||||
const settings = ref(await get())
|
||||
|
||||
const messages = defineMessages({
|
||||
adsConsentTitle: {
|
||||
id: 'app.ads-consent.title',
|
||||
defaultMessage: 'Your privacy and how ads support Modrinth',
|
||||
},
|
||||
adsConsentIntro: {
|
||||
id: 'app.settings.privacy.ads-consent.intro',
|
||||
defaultMessage:
|
||||
'Ads make Modrinth possible and fund creator payouts. Our partners may store or access cookies in the app to personalize ads and measure performance. You can opt out or manage your preferences below.',
|
||||
},
|
||||
adsConsentManage: {
|
||||
id: 'app.ads-consent.manage',
|
||||
defaultMessage: 'Manage preferences',
|
||||
},
|
||||
})
|
||||
|
||||
async function manageAdsPreferences() {
|
||||
await open_ads_consent_preferences().catch(handleError)
|
||||
}
|
||||
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
@@ -23,18 +55,26 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Personalized ads</h2>
|
||||
<p class="m-0 mt-1 text-sm">
|
||||
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
|
||||
option, you opt out and ads will no longer be shown based on your interests.
|
||||
</p>
|
||||
<div v-if="adConsentAvailable">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.adsConsentTitle) }}
|
||||
</h2>
|
||||
<div class="mt-1 flex flex-col gap-2.5 items-start">
|
||||
<div class="flex flex-col gap-1 items-start">
|
||||
<div class="text-sm">
|
||||
{{ formatMessage(messages.adsConsentIntro) }}
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button class="!shadow-none" @click="manageAdsPreferences">
|
||||
<Settings2Icon aria-hidden="true" />
|
||||
{{ formatMessage(messages.adsConsentManage) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Toggle id="personalized-ads" v-model="settings.personalized_ads" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-4">
|
||||
<div class="mt-8 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
|
||||
<p class="m-0 mt-1 text-sm">
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<script setup>
|
||||
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager, Slider, StyledInput } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
Slider,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
@@ -11,9 +19,23 @@ import { showAppDbBackupsFolder } from '@/helpers/utils.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
const settings = ref(await get())
|
||||
const purgeCacheConfirmModal = ref(null)
|
||||
const alwaysShowCopyDetailsFlag = 'always_show_copy_details'
|
||||
|
||||
const messages = defineMessages({
|
||||
alwaysShowCopyDetailsTitle: {
|
||||
id: 'app.resource-management-settings.always-show-copy-details.title',
|
||||
defaultMessage: 'Always show copy details',
|
||||
},
|
||||
alwaysShowCopyDetailsDescription: {
|
||||
id: 'app.resource-management-settings.always-show-copy-details.description',
|
||||
defaultMessage:
|
||||
'Show the Copy details action while an install is queued or running. It is always available for failed or interrupted installs.',
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
settings,
|
||||
@@ -154,6 +176,28 @@ async function findLauncherDir() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.alwaysShowCopyDetailsTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">
|
||||
{{ formatMessage(messages.alwaysShowCopyDetailsDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="always-show-copy-details"
|
||||
:model-value="themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag(alwaysShowCopyDetailsFlag)
|
||||
themeStore.featureFlags[alwaysShowCopyDetailsFlag] = newValue
|
||||
settings.feature_flags[alwaysShowCopyDetailsFlag] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">App database backups</h2>
|
||||
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder">
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
|
||||
<p class="m-0 text-secondary">
|
||||
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
|
||||
<template #bold="{ children }">
|
||||
<span class="font-medium text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleGoToInstance">
|
||||
{{ formatMessage(messages.instance) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="handleInstallAnyway">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.installAnyway) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instance.shared-instance-already-installed.header',
|
||||
defaultMessage: 'Shared instance already installed',
|
||||
},
|
||||
body: {
|
||||
id: 'app.instance.shared-instance-already-installed.body',
|
||||
defaultMessage:
|
||||
'This shared instance is already installed as <bold>{instanceName}</bold>. Are you sure you want to install another copy?',
|
||||
},
|
||||
instance: {
|
||||
id: 'app.instance.shared-instance-already-installed.instance',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
installAnyway: {
|
||||
id: 'app.instance.shared-instance-already-installed.install-anyway',
|
||||
defaultMessage: 'Install anyway',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancel' | 'go-to-instance' | 'install-anyway'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const instanceName = ref('')
|
||||
|
||||
function show(name: string) {
|
||||
instanceName.value = name
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function handleGoToInstance() {
|
||||
modal.value?.hide()
|
||||
emit('go-to-instance')
|
||||
}
|
||||
|
||||
function handleInstallAnyway() {
|
||||
modal.value?.hide()
|
||||
emit('install-anyway')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div v-if="canUnpublish" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
|
||||
<div>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="busy" @click="unpublishModal?.show()">
|
||||
<SpinnerIcon v-if="unpublishing" class="animate-spin" />
|
||||
<UnlinkIcon v-else class="size-5" />
|
||||
{{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-primary">{{ formatMessage(messages.unpublishDescription) }}</span>
|
||||
</div>
|
||||
<div v-if="canUnlink" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">{{
|
||||
formatMessage(messages.linkedTitle)
|
||||
}}</span>
|
||||
<div>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="busy" @click="unlinkModal?.show()">
|
||||
<SpinnerIcon v-if="unlinking" class="animate-spin" />
|
||||
<UnlinkIcon v-else class="size-5" />
|
||||
{{ formatMessage(unlinking ? messages.unlinkingButton : messages.unlinkButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-primary">{{ formatMessage(messages.unlinkDescription) }}</span>
|
||||
</div>
|
||||
|
||||
<NewModal
|
||||
ref="unpublishModal"
|
||||
:header="formatMessage(messages.unpublishModalHeader)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
>
|
||||
<Admonition type="warning" :header="formatMessage(messages.unpublishModalAdmonitionHeader)">{{
|
||||
formatMessage(messages.unpublishModalBody)
|
||||
}}</Admonition>
|
||||
<template #actions
|
||||
><div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined"
|
||||
><button class="!border" @click="unpublishModal?.hide()">
|
||||
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button></ButtonStyled
|
||||
><ButtonStyled color="orange"
|
||||
><button :disabled="busy" @click="confirmUnpublish">
|
||||
<UnlinkIcon />{{ formatMessage(messages.unpublishButton) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div></template
|
||||
>
|
||||
</NewModal>
|
||||
<NewModal
|
||||
ref="unlinkModal"
|
||||
:header="formatMessage(messages.unlinkModalHeader)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.unlinkModalAdmonitionHeader)">{{
|
||||
formatMessage(messages.unlinkModalBody)
|
||||
}}</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before unlinking shared instance"
|
||||
@update:buttons-disabled="backupBusy = $event"
|
||||
/>
|
||||
</div>
|
||||
<template #actions
|
||||
><div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined"
|
||||
><button class="!border" @click="unlinkModal?.hide()">
|
||||
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button></ButtonStyled
|
||||
><ButtonStyled color="orange"
|
||||
><button :disabled="busy || backupBusy" @click="confirmUnlink">
|
||||
<UnlinkIcon />{{ formatMessage(messages.unlinkButton) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div></template
|
||||
>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon, UnlinkIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
InlineBackupCreator,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
canUnpublish?: boolean
|
||||
canUnlink?: boolean
|
||||
busy: boolean
|
||||
unpublishing?: boolean
|
||||
unlinking?: boolean
|
||||
unpublish?: () => Promise<void>
|
||||
unlink?: () => Promise<void>
|
||||
}>(),
|
||||
{
|
||||
canUnpublish: false,
|
||||
canUnlink: false,
|
||||
unpublishing: false,
|
||||
unlinking: false,
|
||||
unpublish: undefined,
|
||||
unlink: undefined,
|
||||
},
|
||||
)
|
||||
const { formatMessage } = useVIntl()
|
||||
const unpublishModal = ref<InstanceType<typeof NewModal>>()
|
||||
const unlinkModal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const backupBusy = ref(false)
|
||||
|
||||
async function confirmUnpublish() {
|
||||
unpublishModal.value?.hide()
|
||||
await props.unpublish?.()
|
||||
}
|
||||
async function confirmUnlink() {
|
||||
unlinkModal.value?.hide()
|
||||
await props.unlink?.()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'installation-settings.shared-instance.title',
|
||||
defaultMessage: 'Unpublish instance',
|
||||
},
|
||||
linkedTitle: {
|
||||
id: 'installation-settings.shared-instance.linked-title',
|
||||
defaultMessage: 'Linked shared instance',
|
||||
},
|
||||
unpublishButton: {
|
||||
id: 'installation-settings.shared-instance.unpublish-button',
|
||||
defaultMessage: 'Unpublish shared instance',
|
||||
},
|
||||
unpublishingButton: {
|
||||
id: 'installation-settings.shared-instance.unpublishing-button',
|
||||
defaultMessage: 'Unpublishing...',
|
||||
},
|
||||
unpublishDescription: {
|
||||
id: 'installation-settings.shared-instance.unpublish-description',
|
||||
defaultMessage:
|
||||
'Remove this shared instance from Modrinth and stop sending updates to anyone using it. Your local instance will not be affected.',
|
||||
},
|
||||
unlinkButton: {
|
||||
id: 'installation-settings.shared-instance.unlink-button',
|
||||
defaultMessage: 'Unlink shared instance',
|
||||
},
|
||||
unlinkingButton: {
|
||||
id: 'installation-settings.shared-instance.unlinking-button',
|
||||
defaultMessage: 'Unlinking...',
|
||||
},
|
||||
unlinkDescription: {
|
||||
id: 'installation-settings.shared-instance.unlink-description',
|
||||
defaultMessage: 'Disconnect this local instance from future shared updates.',
|
||||
},
|
||||
unpublishModalHeader: {
|
||||
id: 'installation-settings.unpublish-shared-instance.modal.header',
|
||||
defaultMessage: 'Unpublish shared instance',
|
||||
},
|
||||
unpublishModalAdmonitionHeader: {
|
||||
id: 'installation-settings.unpublish-shared-instance.modal.admonition-header',
|
||||
defaultMessage: 'Unpublishing shared instance',
|
||||
},
|
||||
unpublishModalBody: {
|
||||
id: 'installation-settings.unpublish-shared-instance.modal.admonition-body',
|
||||
defaultMessage:
|
||||
"This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device.",
|
||||
},
|
||||
unlinkModalHeader: {
|
||||
id: 'installation-settings.unlink-shared-instance.modal.header',
|
||||
defaultMessage: 'Unlink shared instance',
|
||||
},
|
||||
unlinkModalAdmonitionHeader: {
|
||||
id: 'installation-settings.unlink-shared-instance.modal.admonition-header',
|
||||
defaultMessage: 'Unlinking shared instance',
|
||||
},
|
||||
unlinkModalBody: {
|
||||
id: 'installation-settings.unlink-shared-instance.modal.admonition-body',
|
||||
defaultMessage:
|
||||
'This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<ContentDiffModal
|
||||
ref="publishReviewModal"
|
||||
:header="formatMessage(messages.header)"
|
||||
:admonition-header="formatMessage(messages.admonitionHeader)"
|
||||
:description="formatMessage(messages.description)"
|
||||
:diffs="publishDiffs"
|
||||
:confirm-label="formatMessage(messages.publishButton)"
|
||||
:confirm-icon="UploadIcon"
|
||||
:confirm-disabled="publishDiffs.length === 0 && selectedConfigPaths.length === 0"
|
||||
:added-label="formatMessage(messages.addedLabel)"
|
||||
:removed-label="formatMessage(messages.removedLabel)"
|
||||
@confirm="publishChanges"
|
||||
@cancel="finishReview"
|
||||
>
|
||||
<template #additional-content>
|
||||
<Accordion
|
||||
class="min-w-0"
|
||||
overflow-visible
|
||||
button-class="w-full bg-transparent m-0 p-0 border-none"
|
||||
>
|
||||
<template #title>
|
||||
<FileCogIcon class="size-4 shrink-0 text-primary" />
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.configTitle) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex min-w-0 flex-col gap-3 pt-4">
|
||||
<div class="max-h-[292px] overflow-y-auto rounded-[20px]">
|
||||
<FileTreeSelect
|
||||
v-model="selectedConfigPaths"
|
||||
:items="configFileItems"
|
||||
:show-size="false"
|
||||
:show-modified="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</template>
|
||||
</ContentDiffModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileCogIcon, UploadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
type ContentDiffItem,
|
||||
ContentDiffModal,
|
||||
defineMessages,
|
||||
FileTreeSelect,
|
||||
type FileTreeSelectItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
getSharedInstanceUnavailableReason,
|
||||
isSharedInstanceUnavailableError,
|
||||
} from '@/helpers/install'
|
||||
import { get_shared_instance_publish_preview, publish_shared_instance } from '@/helpers/instance'
|
||||
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
type SharedInstancePublishState = 'idle' | 'reviewing' | 'publishing'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
published: []
|
||||
'state-change': [state: SharedInstancePublishState]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
|
||||
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const publishDiffs = ref<ContentDiffItem[]>([])
|
||||
const configFilePaths = ref<string[]>([])
|
||||
const selectedConfigPaths = ref<string[]>([])
|
||||
const state = ref<SharedInstancePublishState>('idle')
|
||||
const configFileItems = computed<FileTreeSelectItem[]>(() =>
|
||||
configFilePaths.value.map((path) => ({ path, type: 'file' })),
|
||||
)
|
||||
|
||||
async function show(e?: MouseEvent) {
|
||||
if (state.value !== 'idle') return
|
||||
|
||||
setState('reviewing')
|
||||
let reviewOpened = false
|
||||
try {
|
||||
const preview = await get_shared_instance_publish_preview(props.instance.id)
|
||||
if (!preview) {
|
||||
notifySharedInstanceUnavailable()
|
||||
emit('published')
|
||||
return
|
||||
}
|
||||
|
||||
publishDiffs.value = preview.diffs.map((diff) => ({
|
||||
type: diff.type,
|
||||
projectName: diff.projectName ?? undefined,
|
||||
fileName: diff.fileName ?? undefined,
|
||||
currentVersionName: diff.currentVersionName ?? undefined,
|
||||
newVersionName: diff.newVersionName ?? undefined,
|
||||
fileCount: diff.configFileCount ?? undefined,
|
||||
disabled: diff.disabled,
|
||||
}))
|
||||
configFilePaths.value = preview.configFiles
|
||||
selectedConfigPaths.value = []
|
||||
if (!publishReviewModal.value) return
|
||||
|
||||
publishReviewModal.value.show(e)
|
||||
reviewOpened = true
|
||||
} catch (error) {
|
||||
handlePublishError(error)
|
||||
} finally {
|
||||
if (!reviewOpened) finishReview()
|
||||
}
|
||||
}
|
||||
|
||||
async function publishChanges() {
|
||||
if (state.value !== 'reviewing') return
|
||||
|
||||
setState('publishing')
|
||||
try {
|
||||
await publish_shared_instance(props.instance.id, selectedConfigPaths.value)
|
||||
emit('published')
|
||||
} catch (error) {
|
||||
handlePublishError(error)
|
||||
} finally {
|
||||
setState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
function finishReview() {
|
||||
if (state.value === 'reviewing') {
|
||||
setState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
function handlePublishError(error: unknown) {
|
||||
if (isSharedInstanceUnavailableError(error)) {
|
||||
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
|
||||
emit('published')
|
||||
return
|
||||
}
|
||||
|
||||
notifySharedInstanceError(error)
|
||||
}
|
||||
|
||||
function setState(nextState: SharedInstancePublishState) {
|
||||
state.value = nextState
|
||||
emit('state-change', nextState)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'instance.shared-instance.publish-review.header',
|
||||
defaultMessage: 'Review changes',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'instance.shared-instance.publish-review.admonition-header',
|
||||
defaultMessage: 'Push update to players',
|
||||
},
|
||||
description: {
|
||||
id: 'instance.shared-instance.publish-review.description',
|
||||
defaultMessage:
|
||||
'Review the content changes and choose any config files to include in this update.',
|
||||
},
|
||||
publishButton: {
|
||||
id: 'instance.shared-instance.publish-review.publish-button',
|
||||
defaultMessage: 'Push update',
|
||||
},
|
||||
addedLabel: {
|
||||
id: 'instance.shared-instance.publish-review.added-label',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
removedLabel: {
|
||||
id: 'instance.shared-instance.publish-review.removed-label',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
configTitle: {
|
||||
id: 'instance.shared-instance.publish-review.config-title-v2',
|
||||
defaultMessage: 'Select config files',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<ContentDiffModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.updateToPlay)"
|
||||
:admonition-header="formatMessage(messages.updateRequired)"
|
||||
:description="instance ? formatMessage(messages.description, { name: instance.name }) : ''"
|
||||
:diffs="diffs"
|
||||
:confirm-label="formatMessage(commonMessages.updateButton)"
|
||||
:confirm-icon="DownloadIcon"
|
||||
:added-label="formatMessage(messages.addedLabel)"
|
||||
:removed-label="formatMessage(messages.removedLabel)"
|
||||
show-report-button
|
||||
show-external-warnings
|
||||
:external-warning-description="formatMessage(messages.externalWarningDescription)"
|
||||
@confirm="update"
|
||||
@cancel="emit('cancel')"
|
||||
@report="emit('report', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
type ContentDiffItem,
|
||||
ContentDiffModal,
|
||||
defineMessages,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
getSharedInstanceUnavailableReason,
|
||||
install_update_shared_instance,
|
||||
isSharedInstanceUnavailableError,
|
||||
type SharedInstanceUnavailableReason,
|
||||
type SharedInstanceUpdatePreview,
|
||||
wait_for_install_job,
|
||||
} from '@/helpers/install'
|
||||
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
type UpdateCompleteCallback = () => void | Promise<void>
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: []
|
||||
complete: []
|
||||
report: [event?: MouseEvent]
|
||||
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
|
||||
}>()
|
||||
const modal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const instance = ref<GameInstance | null>(null)
|
||||
const preview = ref<SharedInstanceUpdatePreview | null>(null)
|
||||
const onComplete = ref<UpdateCompleteCallback>(() => {})
|
||||
const { formatMessage } = useVIntl()
|
||||
const { notifySharedInstanceError } = useSharedInstanceErrors()
|
||||
const diffs = computed<ContentDiffItem[]>(
|
||||
() =>
|
||||
preview.value?.diffs
|
||||
// TODO: This is TEMP!!! Hashing needs to be done on backend
|
||||
.filter((diff) => !(diff.type === 'updated' && !diff.projectId && diff.fileName))
|
||||
.map((diff) => ({
|
||||
type: diff.type,
|
||||
projectName: diff.projectName ?? undefined,
|
||||
fileName: diff.fileName ?? undefined,
|
||||
currentVersionName: diff.currentVersionName ?? undefined,
|
||||
newVersionName: diff.newVersionName ?? undefined,
|
||||
fileCount: diff.configFileCount ?? undefined,
|
||||
disabled: diff.disabled,
|
||||
external: diff.type === 'added' && !diff.projectId && !!diff.fileName,
|
||||
})) ?? [],
|
||||
)
|
||||
|
||||
async function update() {
|
||||
try {
|
||||
if (instance.value) {
|
||||
const job = await install_update_shared_instance(instance.value.id)
|
||||
await wait_for_install_job(job.job_id)
|
||||
await onComplete.value()
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSharedInstanceUnavailableError(error)) {
|
||||
emit('sharedInstanceUnavailable', getSharedInstanceUnavailableReason(error))
|
||||
return
|
||||
}
|
||||
notifySharedInstanceError(error)
|
||||
} finally {
|
||||
emit('complete')
|
||||
}
|
||||
}
|
||||
|
||||
function show(
|
||||
instanceValue: GameInstance,
|
||||
previewValue: SharedInstanceUpdatePreview,
|
||||
callback: UpdateCompleteCallback = () => {},
|
||||
event?: MouseEvent,
|
||||
) {
|
||||
instance.value = instanceValue
|
||||
preview.value = previewValue
|
||||
onComplete.value = callback
|
||||
modal.value?.show(event)
|
||||
}
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
updateToPlay: { id: 'app.modal.update-to-play.header', defaultMessage: 'Update to play' },
|
||||
updateRequired: {
|
||||
id: 'app.modal.update-to-play.update-required',
|
||||
defaultMessage: 'Update required',
|
||||
},
|
||||
description: {
|
||||
id: 'app.modal.update-to-play.update-required-description',
|
||||
defaultMessage:
|
||||
'An update is required to play {name}. Please update to latest version to launch the game.',
|
||||
},
|
||||
addedLabel: {
|
||||
id: 'app.modal.update-to-play.shared-instance-added-label',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
removedLabel: {
|
||||
id: 'app.modal.update-to-play.shared-instance-removed-label',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
externalWarningDescription: {
|
||||
id: 'app.modal.update-to-play.shared-instance-unknown-files-description',
|
||||
defaultMessage:
|
||||
'This shared instance update contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust.',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
reportMode
|
||||
? formatMessage(messages.reportSharedInstance)
|
||||
: formatMessage(messages.installToPlay)
|
||||
"
|
||||
:closable="!submitLoading"
|
||||
:on-hide="handleHide"
|
||||
:max-width="reportMode ? '816px' : '544px'"
|
||||
:width="reportMode ? '816px' : '544px'"
|
||||
:no-padding="reportMode"
|
||||
:scrollable="reportMode"
|
||||
>
|
||||
<div v-if="preview" class="flex w-full flex-col gap-6" :class="{ 'p-6 pb-2': reportMode }">
|
||||
<Admonition v-if="reportMode" type="info">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0">
|
||||
<IntlFormatted :message-id="messages.reportDescription">
|
||||
<template #rules-link="{ children }">
|
||||
<AutoLink class="text-link hover:underline" to="https://modrinth.com/legal/rules">
|
||||
<component :is="() => children" />
|
||||
</AutoLink>
|
||||
</template>
|
||||
<template #terms-link="{ children }">
|
||||
<AutoLink class="text-link hover:underline" to="https://modrinth.com/legal/terms">
|
||||
<component :is="() => children" />
|
||||
</AutoLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<ul class="m-0 list-disc pl-5">
|
||||
<li>
|
||||
<IntlFormatted :message-id="messages.supportAndBugReports">
|
||||
<template #support-link="{ children }">
|
||||
<AutoLink class="text-link hover:underline" to="https://support.modrinth.com">
|
||||
<component :is="() => children" />
|
||||
</AutoLink>
|
||||
</template>
|
||||
<template #github-link="{ children }">
|
||||
<AutoLink
|
||||
class="text-link hover:underline"
|
||||
to="https://github.com/modrinth/code/issues"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</AutoLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</li>
|
||||
<li>
|
||||
<IntlFormatted :message-id="messages.legalClaims">
|
||||
<template #copyright-link="{ children }">
|
||||
<AutoLink
|
||||
class="text-link hover:underline"
|
||||
to="https://modrinth.com/legal/copyright"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</AutoLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Admonition>
|
||||
<p v-else class="m-0 text-primary">
|
||||
{{ formatMessage(messages.inviteWarning) }}
|
||||
</p>
|
||||
<SharedInstanceInstallSummary
|
||||
:preview="preview"
|
||||
:heading="reportMode ? formatMessage(messages.contentYouAreReporting) : undefined"
|
||||
@view-contents="openViewContents"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="overflow-hidden transition-all duration-200 ease-out"
|
||||
enter-from-class="max-h-0 opacity-0"
|
||||
enter-to-class="max-h-[400px] opacity-100"
|
||||
leave-active-class="overflow-hidden transition-all duration-200 ease-in"
|
||||
leave-from-class="max-h-[400px] opacity-100"
|
||||
leave-to-class="max-h-0 opacity-0"
|
||||
>
|
||||
<div v-if="reportMode" class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.reportReason) }}
|
||||
</span>
|
||||
<Combobox v-model="reportReason" :options="reportReasonOptions" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.additionalContext) }}
|
||||
</span>
|
||||
<MarkdownEditor
|
||||
v-model="additionalContext"
|
||||
:placeholder="formatMessage(messages.additionalContextPlaceholder)"
|
||||
:on-image-upload="onImageUpload"
|
||||
:min-height="120"
|
||||
:max-height="240"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="reportOnly" class="flex flex-col gap-2">
|
||||
<Checkbox v-model="deleteInstance" :label="formatMessage(messages.deleteInstance)" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Admonition
|
||||
v-if="!reportMode && hasExternalFiles"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.unknownFilesWarning)"
|
||||
>
|
||||
{{ formatMessage(messages.unknownFilesDescription) }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="!reportMode && hasExternalFiles" class="relative w-full">
|
||||
<div
|
||||
ref="externalFileTable"
|
||||
class="max-h-[242px] overflow-y-auto rounded-2xl"
|
||||
@scroll="checkTableScrollState"
|
||||
>
|
||||
<Table
|
||||
:columns="externalFileColumns"
|
||||
:data="externalFileRows"
|
||||
row-key="id"
|
||||
virtualized
|
||||
:virtual-row-height="48"
|
||||
class="shadow-sm"
|
||||
>
|
||||
<template #cell-name="{ value }">
|
||||
<span class="block truncate" :title="String(value)">{{ value }}</span>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTableTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTableBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<p v-if="!reportMode && hasExternalFiles" class="m-0 text-primary">
|
||||
{{ formatMessage(messages.reviewedFiles) }}
|
||||
</p>
|
||||
<div v-if="!reportMode" class="flex w-full items-center justify-between gap-2">
|
||||
<ButtonStyled color="red" type="transparent">
|
||||
<button @click="reportMode = true">
|
||||
<ReportIcon />{{ formatMessage(commonMessages.reportButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="hasExternalFiles">
|
||||
<ButtonStyled type="transparent" color="orange">
|
||||
<button @click="accept">
|
||||
{{ formatMessage(messages.installAnyway) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleCancel">
|
||||
<BanIcon />{{ formatMessage(messages.dontInstall) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border" @click="handleCancel">
|
||||
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="accept">
|
||||
<DownloadIcon />{{ formatMessage(messages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="reportMode" #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border" :disabled="submitLoading" @click="handleCancel">
|
||||
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canSubmitReport" @click="submitReport">
|
||||
<SpinnerIcon v-if="submitLoading" class="animate-spin" />
|
||||
<SendIcon v-else />
|
||||
{{ formatMessage(commonMessages.reportButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
<ModpackContentModal
|
||||
ref="contentModal"
|
||||
:header="formatMessage(messages.sharedInstanceContent)"
|
||||
:modpack-name="preview?.name ?? ''"
|
||||
:modpack-icon-url="preview?.iconUrl ?? undefined"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { BanIcon, DownloadIcon, ReportIcon, SendIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
AutoLink,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
MarkdownEditor,
|
||||
ModpackContentModal,
|
||||
NewModal,
|
||||
Table,
|
||||
type TableColumn,
|
||||
useScrollIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
|
||||
import { toError } from '@/helpers/errors'
|
||||
import type { SharedInstanceInstallPreview } from '@/helpers/install'
|
||||
import { create_report } from '@/helpers/reports'
|
||||
|
||||
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
|
||||
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
|
||||
|
||||
type ExternalFileColumn = 'name'
|
||||
type ExternalFileRow = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const contentModal = ref<InstanceType<typeof ModpackContentModal>>()
|
||||
const externalFileTable = ref<HTMLElement | null>(null)
|
||||
const preview = ref<SharedInstanceInstallPreview | null>(null)
|
||||
const install = ref<() => void | Promise<void>>(() => {})
|
||||
const reportMode = ref(false)
|
||||
const reportOnly = ref(false)
|
||||
type ReportReason = 'malicious' | 'inappropriate' | 'spam'
|
||||
const reportReason = ref<ReportReason>('malicious')
|
||||
const additionalContext = ref('')
|
||||
const deleteInstance = ref(true)
|
||||
const submitLoading = ref(false)
|
||||
const uploadedImageIDs = ref<string[]>([])
|
||||
const emit = defineEmits<{
|
||||
reported: [deleteInstance: boolean]
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const { load } = useSharedInstancePreviewContent()
|
||||
const {
|
||||
showTopFade: showTableTopFade,
|
||||
showBottomFade: showTableBottomFade,
|
||||
checkScrollState: checkTableScrollState,
|
||||
forceCheck: forceCheckTableScroll,
|
||||
} = useScrollIndicator(externalFileTable)
|
||||
const hasExternalFiles = computed(() => Boolean(preview.value?.externalFiles.length))
|
||||
const externalFileRows = computed<ExternalFileRow[]>(() =>
|
||||
(preview.value?.externalFiles ?? [])
|
||||
.map((file, index) => ({
|
||||
id: `${index}-${file.fileType}-${file.fileName}`,
|
||||
name: file.fileName,
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
)
|
||||
const reportReasonOptions = computed<ComboboxOption<ReportReason>[]>(() => [
|
||||
{ value: 'malicious', label: formatMessage(messages.maliciousReason) },
|
||||
{ value: 'inappropriate', label: formatMessage(messages.inappropriateReason) },
|
||||
{ value: 'spam', label: formatMessage(messages.spamReason) },
|
||||
])
|
||||
const canSubmitReport = computed(
|
||||
() => Boolean(preview.value && additionalContext.value.trim()) && !submitLoading.value,
|
||||
)
|
||||
|
||||
async function accept() {
|
||||
hide()
|
||||
try {
|
||||
await install.value()
|
||||
} catch (error) {
|
||||
console.error('Failed to install shared instance:', error)
|
||||
}
|
||||
}
|
||||
async function openViewContents() {
|
||||
if (!preview.value) return
|
||||
contentModal.value?.showLoading()
|
||||
try {
|
||||
contentModal.value?.show(await load(preview.value))
|
||||
} catch (error) {
|
||||
console.error('Failed to load shared instance contents:', error)
|
||||
contentModal.value?.show([])
|
||||
}
|
||||
}
|
||||
async function submitReport() {
|
||||
const reportPreview = preview.value
|
||||
const body = additionalContext.value.trim()
|
||||
if (!reportPreview || !body || submitLoading.value) return
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const uploadedImages = uploadedImageIDs.value.slice(-10)
|
||||
await create_report({
|
||||
report_type: reportReason.value,
|
||||
item_type: 'shared-instance',
|
||||
item_id: `${reportPreview.sharedInstanceId}/${reportPreview.version}`,
|
||||
body,
|
||||
uploaded_images: uploadedImages,
|
||||
})
|
||||
|
||||
const shouldDeleteInstance = reportOnly.value && deleteInstance.value
|
||||
hide()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.reportSubmitted),
|
||||
})
|
||||
emit('reported', shouldDeleteInstance)
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
async function onImageUpload(file: File) {
|
||||
const imageExtensionByType: Partial<Record<string, Labrinth.Images.v3.ImageExtension>> = {
|
||||
'image/gif': 'gif',
|
||||
'image/jpeg': 'jpeg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
}
|
||||
const extension = imageExtensionByType[file.type]
|
||||
if (!extension) {
|
||||
throw new Error(formatMessage(messages.invalidImageType))
|
||||
}
|
||||
if (file.size > 1024 * 1024) {
|
||||
throw new Error(formatMessage(messages.imageTooLarge))
|
||||
}
|
||||
|
||||
const image = await client.labrinth.images_v3.uploadImage(file, extension, {
|
||||
context: 'report',
|
||||
}).promise
|
||||
uploadedImageIDs.value.push(image.id)
|
||||
return image.url
|
||||
}
|
||||
function handleCancel() {
|
||||
if (reportMode.value && !reportOnly.value) {
|
||||
reportMode.value = false
|
||||
void nextTick(() => forceCheckTableScroll())
|
||||
return
|
||||
}
|
||||
hide()
|
||||
}
|
||||
function handleHide() {
|
||||
resetReportState()
|
||||
show_ads_window()
|
||||
}
|
||||
function resetReportState() {
|
||||
reportMode.value = false
|
||||
reportOnly.value = false
|
||||
reportReason.value = 'malicious'
|
||||
additionalContext.value = ''
|
||||
deleteInstance.value = true
|
||||
submitLoading.value = false
|
||||
uploadedImageIDs.value = []
|
||||
}
|
||||
function show(
|
||||
previewValue: SharedInstanceInstallPreview,
|
||||
installValue: () => void | Promise<void>,
|
||||
event?: MouseEvent,
|
||||
) {
|
||||
resetReportState()
|
||||
install.value = installValue
|
||||
showPreview(previewValue, event)
|
||||
}
|
||||
function showReport(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
|
||||
resetReportState()
|
||||
reportMode.value = true
|
||||
reportOnly.value = true
|
||||
install.value = () => {}
|
||||
showPreview(previewValue, event)
|
||||
}
|
||||
function showPreview(previewValue: SharedInstanceInstallPreview, event?: MouseEvent) {
|
||||
preview.value = previewValue
|
||||
hide_ads_window()
|
||||
modal.value?.show(event)
|
||||
void nextTick(() => forceCheckTableScroll())
|
||||
}
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
installToPlay: { id: 'app.modal.install-to-play.header', defaultMessage: 'Install to play' },
|
||||
reportSharedInstance: {
|
||||
id: 'app.modal.install-to-play.report-shared-instance-header',
|
||||
defaultMessage: 'Report shared instance',
|
||||
},
|
||||
reportSubmitted: {
|
||||
id: 'app.modal.install-to-play.report-submitted',
|
||||
defaultMessage: 'Report submitted',
|
||||
},
|
||||
sharedInstanceContent: {
|
||||
id: 'app.modal.install-to-play.shared-instance-content',
|
||||
defaultMessage: 'Shared instance content',
|
||||
},
|
||||
inviteWarning: {
|
||||
id: 'app.modal.install-to-play.invite-warning',
|
||||
defaultMessage:
|
||||
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
|
||||
},
|
||||
reportDescription: {
|
||||
id: 'app.modal.install-to-play.report-description',
|
||||
defaultMessage:
|
||||
'Use this form to report instances that may violate our <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>.',
|
||||
},
|
||||
supportAndBugReports: {
|
||||
id: 'app.modal.install-to-play.report-support-and-bugs',
|
||||
defaultMessage:
|
||||
'For support requests, contact our <support-link>support team</support-link>. For bug reports, open a <github-link>GitHub issue</github-link>.',
|
||||
},
|
||||
legalClaims: {
|
||||
id: 'app.modal.install-to-play.report-legal-claims',
|
||||
defaultMessage:
|
||||
'For DMCA notices or other legal claims, see our <copyright-link>Copyright Policy</copyright-link>.',
|
||||
},
|
||||
contentYouAreReporting: {
|
||||
id: 'app.modal.install-to-play.content-you-are-reporting',
|
||||
defaultMessage: 'Instance you’re reporting',
|
||||
},
|
||||
reportReason: {
|
||||
id: 'app.modal.install-to-play.report-reason',
|
||||
defaultMessage: 'Which rule does this instance violate?',
|
||||
},
|
||||
maliciousReason: {
|
||||
id: 'app.modal.install-to-play.report-reason.malicious',
|
||||
defaultMessage: 'Malicious',
|
||||
},
|
||||
inappropriateReason: {
|
||||
id: 'app.modal.install-to-play.report-reason.inappropriate',
|
||||
defaultMessage: 'Inappropriate',
|
||||
},
|
||||
spamReason: {
|
||||
id: 'app.modal.install-to-play.report-reason.spam',
|
||||
defaultMessage: 'Spam',
|
||||
},
|
||||
additionalContext: {
|
||||
id: 'app.modal.install-to-play.additional-context',
|
||||
defaultMessage: 'Additional context',
|
||||
},
|
||||
additionalContextPlaceholder: {
|
||||
id: 'app.modal.install-to-play.additional-context-placeholder',
|
||||
defaultMessage: 'Include links and images if possible and relevant',
|
||||
},
|
||||
invalidImageType: {
|
||||
id: 'app.modal.install-to-play.report-image-invalid-type',
|
||||
defaultMessage: 'File is not an accepted image type',
|
||||
},
|
||||
imageTooLarge: {
|
||||
id: 'app.modal.install-to-play.report-image-too-large',
|
||||
defaultMessage: 'File exceeds the 1 MiB size limit',
|
||||
},
|
||||
deleteInstance: {
|
||||
id: 'app.modal.install-to-play.delete-instance',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
unknownFilesWarning: {
|
||||
id: 'app.modal.install-to-play.unknown-files-warning',
|
||||
defaultMessage: 'Unknown files warning',
|
||||
},
|
||||
unknownFilesDescription: {
|
||||
id: 'app.modal.install-to-play.shared-instance-unknown-files-description',
|
||||
defaultMessage:
|
||||
'This shared instance contains files that aren’t published on Modrinth. We strongly recommend only installing files from sources you trust.',
|
||||
},
|
||||
unrecognizedFiles: {
|
||||
id: 'app.modal.install-to-play.unrecognized-files',
|
||||
defaultMessage: 'Unrecognized files',
|
||||
},
|
||||
reviewedFiles: {
|
||||
id: 'app.modal.install-to-play.reviewed-files',
|
||||
defaultMessage:
|
||||
'A file is only reviewed if it’s published to Modrinth, regardless of its file format (including .mrpack).',
|
||||
},
|
||||
installAnyway: {
|
||||
id: 'app.modal.install-to-play.install-anyway',
|
||||
defaultMessage: 'Install anyway',
|
||||
},
|
||||
dontInstall: {
|
||||
id: 'app.modal.install-to-play.external-files-dont-install',
|
||||
defaultMessage: "Don't install",
|
||||
},
|
||||
installButton: { id: 'app.modal.install-to-play.install-button', defaultMessage: 'Install' },
|
||||
})
|
||||
|
||||
const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: formatMessage(messages.unrecognizedFiles),
|
||||
cellClass: '!h-12',
|
||||
},
|
||||
])
|
||||
|
||||
defineExpose({ show, showReport, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
heading ?? formatMessage(messages.sharedInstance)
|
||||
}}</span>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="emit('viewContents')">
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.viewContents) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-2xl bg-surface-2 p-3">
|
||||
<Avatar
|
||||
:src="preview.iconUrl"
|
||||
:alt="preview.name"
|
||||
size="56px"
|
||||
no-shadow
|
||||
class="!rounded-2xl"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ preview.name }}</span>
|
||||
<span class="truncate text-sm font-medium text-secondary">
|
||||
{{ loaderDisplay }} {{ preview.gameVersion }}
|
||||
<template v-if="preview.modCount">
|
||||
· {{ formatMessage(messages.modCount, { count: preview.modCount }) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EyeIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, defineMessages, formatLoader, useVIntl } from '@modrinth/ui'
|
||||
import { computed, toRefs } from 'vue'
|
||||
|
||||
import type { SharedInstanceInstallPreview } from '@/helpers/install'
|
||||
|
||||
const props = defineProps<{ preview: SharedInstanceInstallPreview; heading?: string }>()
|
||||
const { preview, heading } = toRefs(props)
|
||||
const emit = defineEmits<{ viewContents: [] }>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const loaderDisplay = computed(() =>
|
||||
preview.value.loader ? formatLoader(formatMessage, preview.value.loader) : '',
|
||||
)
|
||||
const messages = defineMessages({
|
||||
sharedInstance: {
|
||||
id: 'app.modal.install-to-play.shared-instance',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
viewContents: { id: 'app.modal.install-to-play.view-contents', defaultMessage: 'View contents' },
|
||||
modCount: {
|
||||
id: 'app.modal.install-to-play.mod-count',
|
||||
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
|
||||
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import type { SharedInstanceInstallPreview } from '@/helpers/install'
|
||||
|
||||
type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
|
||||
|
||||
export function useSharedInstancePreviewContent() {
|
||||
async function load(preview: SharedInstanceInstallPreview): Promise<ContentItem[]> {
|
||||
return [
|
||||
...preview.externalFiles.map(externalFileContentItem),
|
||||
...(await modpackContentItems(preview)),
|
||||
...(await contentItemsFromVersionIds(
|
||||
preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
|
||||
if (!preview.modpackVersionId) return []
|
||||
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
|
||||
return await contentItemsFromDependencies(version?.dependencies ?? [])
|
||||
}
|
||||
|
||||
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
|
||||
const deps = dependencies as VersionDependency[]
|
||||
const projectIds = unique(deps.map((dep) => dep.project_id).filter((id): id is string => !!id))
|
||||
const versionIds = unique(deps.map((dep) => dep.version_id).filter((id): id is string => !!id))
|
||||
const [projects, versions]: [Labrinth.Projects.v2.Project[], Labrinth.Versions.v2.Version[]] =
|
||||
await Promise.all([
|
||||
projectIds.length ? get_project_many(projectIds, 'must_revalidate') : [],
|
||||
versionIds.length ? get_version_many(versionIds, 'must_revalidate') : [],
|
||||
])
|
||||
const projectMap = new Map(projects.map((project) => [project.id, project]))
|
||||
const versionMap = new Map(versions.map((version) => [version.id, version]))
|
||||
|
||||
return deps.map((dependency): ContentItem => {
|
||||
const project = dependency.project_id ? projectMap.get(dependency.project_id) : null
|
||||
const version = dependency.version_id ? versionMap.get(dependency.version_id) : null
|
||||
const fileName =
|
||||
version?.files?.[0]?.filename ?? dependency.file_name ?? project?.title ?? 'Unknown'
|
||||
return contentItem(
|
||||
version?.id ?? project?.id ?? fileName,
|
||||
fileName,
|
||||
project,
|
||||
version,
|
||||
!project && !version,
|
||||
dependency.project_id ?? fileName,
|
||||
dependency.file_name ?? fileName,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function contentItemsFromVersionIds(versionIds: string[]) {
|
||||
const versions: Labrinth.Versions.v2.Version[] = versionIds.length
|
||||
? await get_version_many(unique(versionIds), 'must_revalidate')
|
||||
: []
|
||||
const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
|
||||
const projects: Labrinth.Projects.v2.Project[] = projectIds.length
|
||||
? await get_project_many(projectIds, 'must_revalidate')
|
||||
: []
|
||||
const projectMap = new Map(projects.map((project) => [project.id, project]))
|
||||
return versions.map((version): ContentItem => {
|
||||
const project = projectMap.get(version.project_id)
|
||||
const fileName = version.files?.[0]?.filename ?? project?.title ?? version.name ?? 'Unknown'
|
||||
return contentItem(
|
||||
version.id,
|
||||
fileName,
|
||||
project,
|
||||
version,
|
||||
false,
|
||||
version.project_id,
|
||||
version.name,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return { load }
|
||||
}
|
||||
|
||||
function contentItem(
|
||||
id: string,
|
||||
fileName: string,
|
||||
project?: Labrinth.Projects.v2.Project | null,
|
||||
version?: Labrinth.Versions.v2.Version | null,
|
||||
external = false,
|
||||
fallbackProjectId = id,
|
||||
fallbackTitle = fileName,
|
||||
projectType = project?.project_type ?? 'mod',
|
||||
): ContentItem {
|
||||
return {
|
||||
id,
|
||||
file_name: fileName,
|
||||
project_type: projectType,
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
external,
|
||||
project: {
|
||||
id: project?.id ?? fallbackProjectId,
|
||||
slug: project?.slug ?? fallbackProjectId,
|
||||
title: project?.title ?? fallbackTitle,
|
||||
icon_url: project?.icon_url ?? undefined,
|
||||
},
|
||||
...(version
|
||||
? {
|
||||
version: {
|
||||
id: version.id,
|
||||
file_name: fileName,
|
||||
version_number: version.version_number ?? undefined,
|
||||
date_published: version.date_published ?? undefined,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function externalFileContentItem(
|
||||
file: SharedInstanceInstallPreview['externalFiles'][number],
|
||||
): ContentItem {
|
||||
return contentItem(
|
||||
`external:${file.fileType}:${file.fileName}`,
|
||||
file.fileName,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
file.fileName,
|
||||
file.fileName,
|
||||
file.fileType,
|
||||
)
|
||||
}
|
||||
|
||||
function unique<T>(values: T[]) {
|
||||
return Array.from(new Set(values))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
|
||||
<SharedInstanceInstallModal ref="installModal" />
|
||||
<SharedInstanceAlreadyInstalledModal
|
||||
ref="alreadyInstalledModal"
|
||||
@cancel="handleAlreadyInstalledCancel"
|
||||
@go-to-instance="handleAlreadyInstalledGoToInstance"
|
||||
@install-anyway="handleAlreadyInstalledInstallAnyway"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { injectAuth } from '@modrinth/ui'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
|
||||
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
|
||||
import SharedInstanceAlreadyInstalledModal from '@/components/ui/shared-instances/SharedInstanceAlreadyInstalledModal.vue'
|
||||
import type { ModrinthAuthFlow } from '@/helpers/mr_auth'
|
||||
|
||||
import type { SharedInstanceInviteHandler } from './shared-instance-invite-types'
|
||||
import { useSharedInstanceInviteHandler } from './use-shared-instance-invite-handler'
|
||||
|
||||
const auth = injectAuth()
|
||||
const installModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
|
||||
const alreadyInstalledModal = ref<InstanceType<typeof SharedInstanceAlreadyInstalledModal>>()
|
||||
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
|
||||
const {
|
||||
handleNotification,
|
||||
installFromInviteId,
|
||||
clearNotifications,
|
||||
handleAlreadyInstalledCancel,
|
||||
handleAlreadyInstalledGoToInstance,
|
||||
handleAlreadyInstalledInstallAnyway,
|
||||
} = useSharedInstanceInviteHandler(installModal, alreadyInstalledModal, accountRequiredModal)
|
||||
|
||||
async function requestAuth(flow: ModrinthAuthFlow) {
|
||||
await auth.requestSignIn('', flow, { showModal: false })
|
||||
await nextTick()
|
||||
return !!auth.session_token.value
|
||||
}
|
||||
|
||||
defineExpose<SharedInstanceInviteHandler>({
|
||||
handleNotification,
|
||||
installFromInviteId,
|
||||
clearNotifications,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
AppNotification,
|
||||
SharedInstanceInvite,
|
||||
SharedInstanceInviteNotificationBody,
|
||||
} from './shared-instance-invite-types'
|
||||
|
||||
function optionalString(value: unknown) {
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
export function parseSharedInstanceInviteNotification(
|
||||
notification: AppNotification,
|
||||
): SharedInstanceInvite | null {
|
||||
if (notification.body?.type !== 'shared_instance_invite') return null
|
||||
|
||||
const body = notification.body as SharedInstanceInviteNotificationBody
|
||||
const sharedInstanceId = optionalString(body.shared_instance_id)
|
||||
const sharedInstanceName = optionalString(body.shared_instance_name)
|
||||
if (!sharedInstanceId || !sharedInstanceName) return null
|
||||
|
||||
return {
|
||||
sharedInstanceId,
|
||||
sharedInstanceName,
|
||||
invitedById: optionalString(body.invited_by),
|
||||
invitedByUsername: optionalString(body.invited_by_username),
|
||||
invitedByAvatarUrl: optionalString(body.invited_by_avatar_url),
|
||||
instanceIconUrl: optionalString(body.shared_instance_icon),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export type SharedInstanceInviteNotificationBody = {
|
||||
type: 'shared_instance_invite'
|
||||
shared_instance_id?: unknown
|
||||
shared_instance_name?: unknown
|
||||
invited_by?: unknown
|
||||
invited_by_username?: unknown
|
||||
invited_by_avatar_url?: unknown
|
||||
shared_instance_icon?: unknown
|
||||
}
|
||||
|
||||
export type AppNotification = {
|
||||
id: string | number
|
||||
read?: boolean
|
||||
body?: { type?: unknown } & Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SharedInstanceInvite = {
|
||||
sharedInstanceId: string
|
||||
sharedInstanceName: string
|
||||
invitedById: string | null
|
||||
invitedByUsername: string | null
|
||||
invitedByAvatarUrl: string | null
|
||||
instanceIconUrl: string | null
|
||||
}
|
||||
|
||||
export type SharedInstanceInviteHandler = {
|
||||
handleNotification(notification: AppNotification): Promise<boolean>
|
||||
installFromInviteId(inviteId: string): Promise<void>
|
||||
clearNotifications(): void
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { ModrinthApiError } from '@modrinth/api-client'
|
||||
import {
|
||||
injectAuth,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { type Ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { get_user } from '@/helpers/cache'
|
||||
import { toError } from '@/helpers/errors'
|
||||
import {
|
||||
install_accept_shared_instance_invite,
|
||||
install_get_shared_instance_preview,
|
||||
install_shared_instance,
|
||||
} from '@/helpers/install'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
|
||||
import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
|
||||
|
||||
type InstallModal = {
|
||||
show(
|
||||
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
|
||||
install: () => Promise<void>,
|
||||
): void
|
||||
}
|
||||
|
||||
type AccountRequiredModal = {
|
||||
show(event?: MouseEvent): Promise<boolean>
|
||||
}
|
||||
|
||||
type AlreadyInstalledModal = {
|
||||
show(instanceName: string): void
|
||||
}
|
||||
|
||||
export function useSharedInstanceInviteHandler(
|
||||
installModal: Ref<InstallModal | undefined>,
|
||||
alreadyInstalledModal: Ref<AlreadyInstalledModal | undefined>,
|
||||
accountRequiredModal: Ref<AccountRequiredModal | undefined>,
|
||||
) {
|
||||
const auth = injectAuth()
|
||||
const client = injectModrinthClient()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
const themeStore = useTheming()
|
||||
const displayedNotifications = new Set<string | number>()
|
||||
const displayedNotificationKeys = new Set<string>()
|
||||
const popupNotificationIds = new Set<string | number>()
|
||||
let notificationGeneration = 0
|
||||
let pendingAlreadyInstalled:
|
||||
| {
|
||||
instanceId: string
|
||||
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>
|
||||
install: () => Promise<void>
|
||||
onGoToInstance?: () => void | Promise<void>
|
||||
}
|
||||
| undefined
|
||||
|
||||
async function markNotificationRead(notification: AppNotification) {
|
||||
try {
|
||||
await client.labrinth.notifications_v2.markAsRead(String(notification.id))
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthApiError && error.statusCode === 404) return
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInvite(invite: SharedInstanceInvite) {
|
||||
const [invitedBy, sharedInstance] = await Promise.all([
|
||||
!invite.invitedByUsername && invite.invitedById
|
||||
? get_user(invite.invitedById, 'bypass').catch(() => null)
|
||||
: null,
|
||||
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null),
|
||||
])
|
||||
|
||||
return {
|
||||
...invite,
|
||||
invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
|
||||
invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
|
||||
instanceIconUrl: sharedInstance ? sharedInstance.icon : invite.instanceIconUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function showInstall(
|
||||
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
|
||||
install: () => Promise<void>,
|
||||
) {
|
||||
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
|
||||
installModal.value.show(preview, install)
|
||||
}
|
||||
|
||||
async function showInstallOrAlreadyInstalled(
|
||||
sharedInstanceId: string,
|
||||
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
|
||||
install: () => Promise<void>,
|
||||
onGoToInstance?: () => void | Promise<void>,
|
||||
) {
|
||||
const existingInstance = (await list()).find(
|
||||
(instance) => instance.shared_instance?.id === sharedInstanceId,
|
||||
)
|
||||
|
||||
if (!existingInstance || themeStore.getFeatureFlag('skip_non_essential_warnings')) {
|
||||
showInstall(preview, install)
|
||||
return
|
||||
}
|
||||
|
||||
if (!alreadyInstalledModal.value) {
|
||||
throw new Error('Shared instance already installed modal is not available.')
|
||||
}
|
||||
|
||||
pendingAlreadyInstalled = {
|
||||
instanceId: existingInstance.id,
|
||||
preview,
|
||||
install,
|
||||
onGoToInstance,
|
||||
}
|
||||
alreadyInstalledModal.value.show(existingInstance.name)
|
||||
}
|
||||
|
||||
function handleAlreadyInstalledCancel() {
|
||||
pendingAlreadyInstalled = undefined
|
||||
}
|
||||
|
||||
async function handleAlreadyInstalledGoToInstance() {
|
||||
const pending = pendingAlreadyInstalled
|
||||
pendingAlreadyInstalled = undefined
|
||||
if (!pending) return
|
||||
|
||||
if (pending.onGoToInstance) {
|
||||
try {
|
||||
await pending.onGoToInstance()
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
}
|
||||
}
|
||||
await router.push(`/instance/${encodeURIComponent(pending.instanceId)}/`)
|
||||
}
|
||||
|
||||
function handleAlreadyInstalledInstallAnyway() {
|
||||
const pending = pendingAlreadyInstalled
|
||||
pendingAlreadyInstalled = undefined
|
||||
if (!pending) return
|
||||
showInstall(pending.preview, pending.install)
|
||||
}
|
||||
|
||||
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
|
||||
try {
|
||||
const preview = await install_get_shared_instance_preview(
|
||||
invite.sharedInstanceId,
|
||||
invite.sharedInstanceName,
|
||||
)
|
||||
if (invite.instanceIconUrl) preview.iconUrl = invite.instanceIconUrl
|
||||
|
||||
await showInstallOrAlreadyInstalled(
|
||||
invite.sharedInstanceId,
|
||||
preview,
|
||||
async () => {
|
||||
await install_shared_instance(
|
||||
invite.sharedInstanceId,
|
||||
invite.sharedInstanceName,
|
||||
invite.invitedById,
|
||||
null,
|
||||
null,
|
||||
invite.instanceIconUrl,
|
||||
)
|
||||
await markNotificationRead(notification)
|
||||
await queryClient.invalidateQueries({ queryKey: ['instances'] })
|
||||
},
|
||||
() => markNotificationRead(notification),
|
||||
)
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotification(notification: AppNotification) {
|
||||
const parsedInvite = parseSharedInstanceInviteNotification(notification)
|
||||
if (!parsedInvite) return false
|
||||
if (displayedNotifications.has(notification.id)) return true
|
||||
|
||||
const generation = notificationGeneration
|
||||
displayedNotifications.add(notification.id)
|
||||
const invite = await resolveInvite(parsedInvite)
|
||||
if (generation !== notificationGeneration) return true
|
||||
|
||||
const notificationKey = JSON.stringify([
|
||||
invite.invitedById ?? invite.invitedByUsername,
|
||||
invite.sharedInstanceName,
|
||||
invite.instanceIconUrl,
|
||||
])
|
||||
if (displayedNotificationKeys.has(notificationKey)) {
|
||||
await markNotificationRead(notification).catch((error) => handleError(toError(error)))
|
||||
return true
|
||||
}
|
||||
|
||||
displayedNotificationKeys.add(notificationKey)
|
||||
const popupNotification = popupNotificationManager.addPopupNotification({
|
||||
title: invite.sharedInstanceName,
|
||||
autoCloseMs: null,
|
||||
toast: {
|
||||
type: 'instance-invite',
|
||||
actorName: invite.invitedByUsername,
|
||||
actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
|
||||
entityName: invite.sharedInstanceName,
|
||||
entityIconUrl: invite.instanceIconUrl ?? undefined,
|
||||
onAccept: () => acceptNotification(notification, invite),
|
||||
onDecline: () =>
|
||||
markNotificationRead(notification).catch((error) => handleError(toError(error))),
|
||||
onOpenActor: () => {
|
||||
if (invite.invitedByUsername) {
|
||||
void router.push(`/user/${encodeURIComponent(invite.invitedByUsername)}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
popupNotificationIds.add(popupNotification.id)
|
||||
return true
|
||||
}
|
||||
|
||||
function clearNotifications() {
|
||||
notificationGeneration++
|
||||
for (const id of popupNotificationIds) {
|
||||
popupNotificationManager.removeNotification(id)
|
||||
}
|
||||
displayedNotifications.clear()
|
||||
displayedNotificationKeys.clear()
|
||||
popupNotificationIds.clear()
|
||||
pendingAlreadyInstalled = undefined
|
||||
}
|
||||
|
||||
async function requireAccount() {
|
||||
if (!auth.isReady?.value) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const stop = watch(auth.isReady!, (ready) => {
|
||||
if (ready) {
|
||||
stop()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
if (auth.session_token.value) return true
|
||||
return (await accountRequiredModal.value?.show()) ?? false
|
||||
}
|
||||
|
||||
async function installFromInviteId(inviteId: string) {
|
||||
try {
|
||||
if (!(await requireAccount())) return
|
||||
const invite = await install_accept_shared_instance_invite(inviteId)
|
||||
await showInstallOrAlreadyInstalled(invite.sharedInstanceId, invite.preview, async () => {
|
||||
await install_shared_instance(
|
||||
invite.sharedInstanceId,
|
||||
invite.preview.name,
|
||||
invite.managerId,
|
||||
invite.serverManagerName,
|
||||
invite.serverManagerIconUrl,
|
||||
invite.instanceIconUrl,
|
||||
)
|
||||
await queryClient.invalidateQueries({ queryKey: ['instances'] })
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleNotification,
|
||||
installFromInviteId,
|
||||
clearNotifications,
|
||||
handleAlreadyInstalledCancel,
|
||||
handleAlreadyInstalledGoToInstance,
|
||||
handleAlreadyInstalledInstallAnyway,
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<SkinPreviewRenderer
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:ears-texture-src="uploadedTextureUrl?.original ?? currentSkin?.texture"
|
||||
:cape-src="selectedCapeTexture"
|
||||
framing="modal"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
|
||||
@@ -80,6 +80,7 @@ const playing = ref(false)
|
||||
|
||||
const play = async (event: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
if (props.instance.quarantined) return
|
||||
loading.value = true
|
||||
await run(props.instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
|
||||
@@ -192,8 +193,14 @@ onUnmounted(() => {
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="playing ? 'Instance is already open' : null"
|
||||
:disabled="playing || loading"
|
||||
v-tooltip="
|
||||
instance.quarantined
|
||||
? 'This instance has been locked'
|
||||
: playing
|
||||
? 'Instance is already open'
|
||||
: null
|
||||
"
|
||||
:disabled="instance.quarantined || playing || loading"
|
||||
@click="play"
|
||||
>
|
||||
<SpinnerIcon v-if="loading" class="animate-spin" />
|
||||
|
||||
@@ -176,6 +176,7 @@ function refreshServer(address: string, instanceId: string) {
|
||||
}
|
||||
|
||||
async function joinWorld(world: WorldWithInstance, instance?: GameInstance) {
|
||||
if (instance?.quarantined) return
|
||||
console.log(`Joining world ${getWorldIdentifier(world)}`)
|
||||
if (world.type === 'server') {
|
||||
await start_join_server(world.instance_id, world.address).catch(handleError)
|
||||
@@ -192,6 +193,7 @@ async function joinWorld(world: WorldWithInstance, instance?: GameInstance) {
|
||||
}
|
||||
|
||||
async function playInstance(instance: GameInstance) {
|
||||
if (instance.quarantined) return
|
||||
await run(instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: instance.id }))
|
||||
.finally(() => {
|
||||
@@ -258,7 +260,9 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="flex flex-col gap-2">
|
||||
<span class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold">
|
||||
<span
|
||||
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
|
||||
>
|
||||
Jump back in
|
||||
</span>
|
||||
<div class="text-center py-4">
|
||||
@@ -271,7 +275,7 @@ onUnmounted(() => {
|
||||
</HeadingLink>
|
||||
<span
|
||||
v-else
|
||||
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
|
||||
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
|
||||
>
|
||||
Jump back in
|
||||
</span>
|
||||
@@ -300,6 +304,7 @@ onUnmounted(() => {
|
||||
item.world.type === 'singleplayer' &&
|
||||
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version || '')
|
||||
"
|
||||
:quarantined="item.instance.quarantined"
|
||||
:server-status="
|
||||
item.world.type === 'server' ? serverData[item.world.address].status : undefined
|
||||
"
|
||||
|
||||
@@ -77,6 +77,7 @@ const props = withDefaults(
|
||||
startingInstance?: boolean
|
||||
supportsServerQuickPlay?: boolean
|
||||
supportsWorldQuickPlay?: boolean
|
||||
quarantined?: boolean
|
||||
currentProtocol?: ProtocolVersion | null
|
||||
highlighted?: boolean
|
||||
|
||||
@@ -105,6 +106,7 @@ const props = withDefaults(
|
||||
startingInstance: false,
|
||||
supportsServerQuickPlay: true,
|
||||
supportsWorldQuickPlay: false,
|
||||
quarantined: false,
|
||||
currentProtocol: null,
|
||||
|
||||
refreshing: false,
|
||||
@@ -139,7 +141,7 @@ const managed = computed(() => props.managed)
|
||||
const shortcutInstanceId = computed(() => props.shortcutInstanceId ?? props.instanceId)
|
||||
|
||||
async function createShortcut() {
|
||||
if (!shortcutInstanceId.value) return
|
||||
if (!shortcutInstanceId.value || props.quarantined) return
|
||||
|
||||
try {
|
||||
const shortcutPath = await createInstanceShortcut(
|
||||
@@ -422,23 +424,26 @@ const messages = defineMessages({
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
world.type === 'server'
|
||||
? !supportsServerQuickPlay
|
||||
? formatMessage(messages.noServerQuickPlay)
|
||||
: playingOtherWorld
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: !serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: null
|
||||
: !supportsWorldQuickPlay
|
||||
? formatMessage(messages.noSingleplayerQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
quarantined
|
||||
? 'This instance has been locked'
|
||||
: world.type === 'server'
|
||||
? !supportsServerQuickPlay
|
||||
? formatMessage(messages.noServerQuickPlay)
|
||||
: playingOtherWorld
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: !serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: null
|
||||
: !supportsWorldQuickPlay
|
||||
? formatMessage(messages.noSingleplayerQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
"
|
||||
:disabled="
|
||||
quarantined ||
|
||||
playingOtherWorld ||
|
||||
startingInstance ||
|
||||
(world.type == 'server' && !supportsServerQuickPlay) ||
|
||||
@@ -457,7 +462,7 @@ const messages = defineMessages({
|
||||
{
|
||||
id: 'play-instance',
|
||||
shown: !!instanceId,
|
||||
disabled: playingInstance,
|
||||
disabled: playingInstance || quarantined,
|
||||
action: () => emit('play-instance'),
|
||||
},
|
||||
{
|
||||
@@ -511,7 +516,7 @@ const messages = defineMessages({
|
||||
},
|
||||
{
|
||||
id: 'create-shortcut',
|
||||
shown: !!shortcutInstanceId,
|
||||
shown: !!shortcutInstanceId && !quarantined,
|
||||
action: () => createShortcut(),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UpdatedIcon } from '@modrinth/assets'
|
||||
import { CheckIcon, CopyIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
type PopupNotificationButton,
|
||||
@@ -15,12 +15,15 @@ import {
|
||||
install_job_dismiss,
|
||||
install_job_list,
|
||||
install_job_retry,
|
||||
install_job_support_details,
|
||||
installJobInstanceId,
|
||||
type InstallJobSnapshot,
|
||||
type InstallJobStatus,
|
||||
type InstallPhaseId,
|
||||
type InstallProgress,
|
||||
} from '@/helpers/install'
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const messages = defineMessages({
|
||||
installs: {
|
||||
@@ -31,6 +34,14 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.install.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
copyDetails: {
|
||||
id: 'app.action-bar.install.copy-details',
|
||||
defaultMessage: 'Copy details',
|
||||
},
|
||||
copied: {
|
||||
id: 'app.action-bar.install.copied-details',
|
||||
defaultMessage: 'Copied',
|
||||
},
|
||||
dismiss: {
|
||||
id: 'app.action-bar.install.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
@@ -39,32 +50,20 @@ const messages = defineMessages({
|
||||
id: 'app.action-bar.install.open-instance',
|
||||
defaultMessage: 'Open instance',
|
||||
},
|
||||
installFailed: {
|
||||
id: 'app.action-bar.install.failed',
|
||||
defaultMessage: 'Install failed',
|
||||
},
|
||||
installFailedAppClosed: {
|
||||
id: 'app.action-bar.install.failed-app-closed',
|
||||
defaultMessage: 'Installation failed due to app closing.',
|
||||
},
|
||||
installFailedNetwork: {
|
||||
id: 'app.action-bar.install.failed-network',
|
||||
defaultMessage: 'Installation failed due to a network error.',
|
||||
},
|
||||
installFailedUnknown: {
|
||||
id: 'app.action-bar.install.failed-unknown',
|
||||
defaultMessage: 'Installation failed due to an unknown error.',
|
||||
},
|
||||
unknownInstance: {
|
||||
id: 'app.action-bar.install.unknown-instance',
|
||||
defaultMessage: 'Unknown instance',
|
||||
},
|
||||
updatingSharedContent: {
|
||||
id: 'app.action-bar.install.updating-shared-content',
|
||||
defaultMessage: 'Updating shared content',
|
||||
},
|
||||
})
|
||||
|
||||
const phaseMessages = defineMessages({
|
||||
preparing_instance: {
|
||||
id: 'app.install.phase.preparing_instance',
|
||||
defaultMessage: 'Preparing instance',
|
||||
defaultMessage: 'Queued to install',
|
||||
},
|
||||
resolving_pack: {
|
||||
id: 'app.install.phase.resolving_pack',
|
||||
@@ -139,6 +138,89 @@ const javaStepMessages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const failureSummaryMessages = defineMessages({
|
||||
canceled: {
|
||||
id: 'app.action-bar.install.summary.canceled',
|
||||
defaultMessage: 'Canceled',
|
||||
},
|
||||
appClosed: {
|
||||
id: 'app.action-bar.install.summary.app-closing',
|
||||
defaultMessage: 'Canceled due to app closing',
|
||||
},
|
||||
downloadFailed: {
|
||||
id: 'app.action-bar.install.summary.download-failed',
|
||||
defaultMessage: "Download couldn't finish",
|
||||
},
|
||||
modrinthUnreachable: {
|
||||
id: 'app.action-bar.install.summary.modrinth-unreachable',
|
||||
defaultMessage: "Couldn't reach Modrinth",
|
||||
},
|
||||
packDownloadFailed: {
|
||||
id: 'app.action-bar.install.summary.pack-download-failed',
|
||||
defaultMessage: "Couldn't download pack",
|
||||
},
|
||||
badModpackFile: {
|
||||
id: 'app.action-bar.install.summary.bad-modpack-file',
|
||||
defaultMessage: "Couldn't read modpack",
|
||||
},
|
||||
invalidModpack: {
|
||||
id: 'app.action-bar.install.summary.invalid-modpack',
|
||||
defaultMessage: 'Modpack data invalid',
|
||||
},
|
||||
contentDownloadFailed: {
|
||||
id: 'app.action-bar.install.summary.content-download-failed',
|
||||
defaultMessage: "Couldn't download files",
|
||||
},
|
||||
corruptDownload: {
|
||||
id: 'app.action-bar.install.summary.corrupt-download',
|
||||
defaultMessage: 'Downloaded file is corrupt',
|
||||
},
|
||||
invalidModpackFiles: {
|
||||
id: 'app.action-bar.install.summary.invalid-modpack-files',
|
||||
defaultMessage: 'Modpack files have invalid metadata',
|
||||
},
|
||||
noWritePermission: {
|
||||
id: 'app.action-bar.install.summary.no-write-permission',
|
||||
defaultMessage: 'No permission to write',
|
||||
},
|
||||
couldNotSaveFiles: {
|
||||
id: 'app.action-bar.install.summary.could-not-save-files',
|
||||
defaultMessage: "Couldn't save files",
|
||||
},
|
||||
invalidFilePath: {
|
||||
id: 'app.action-bar.install.summary.invalid-file-path',
|
||||
defaultMessage: 'File path is invalid',
|
||||
},
|
||||
instanceNotFound: {
|
||||
id: 'app.action-bar.install.summary.instance-not-found',
|
||||
defaultMessage: "Instance couldn't be found",
|
||||
},
|
||||
cleanupIncomplete: {
|
||||
id: 'app.action-bar.install.summary.cleanup-incomplete',
|
||||
defaultMessage: "Cleanup didn't finish",
|
||||
},
|
||||
javaSetupFailed: {
|
||||
id: 'app.action-bar.install.summary.java-setup-failed',
|
||||
defaultMessage: "Java setup couldn't finish",
|
||||
},
|
||||
minecraftSetupFailed: {
|
||||
id: 'app.action-bar.install.summary.minecraft-setup-failed',
|
||||
defaultMessage: 'Minecraft setup failed',
|
||||
},
|
||||
loaderSetupFailed: {
|
||||
id: 'app.action-bar.install.summary.loader-setup-failed',
|
||||
defaultMessage: 'Loader setup failed',
|
||||
},
|
||||
localDataError: {
|
||||
id: 'app.action-bar.install.summary.local-data-error',
|
||||
defaultMessage: "Couldn't update local data",
|
||||
},
|
||||
unexpectedError: {
|
||||
id: 'app.action-bar.install.summary.unexpected-error',
|
||||
defaultMessage: 'Something went wrong',
|
||||
},
|
||||
})
|
||||
|
||||
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
|
||||
|
||||
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
@@ -153,13 +235,16 @@ export async function useInstallJobNotifications(opts: {
|
||||
onChange: () => void
|
||||
}) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const themeStore = useTheming()
|
||||
const jobs = ref<InstallJobSnapshot[]>([])
|
||||
const iconUrls = ref<Record<string, string | null>>({})
|
||||
const instanceNames = ref<Record<string, string>>({})
|
||||
const copiedJobIds = ref<Set<string>>(new Set())
|
||||
const jobOrder = new Map<string, number>()
|
||||
let refreshRequest = 0
|
||||
let metadataRequest = 0
|
||||
let nextJobOrder = 0
|
||||
const copiedResetTimeouts = new Map<string, number>()
|
||||
|
||||
function getTitle(job: InstallJobSnapshot): string {
|
||||
if (job.display?.title) return job.display.title
|
||||
@@ -174,22 +259,117 @@ export async function useInstallJobNotifications(opts: {
|
||||
|
||||
function getText(job: InstallJobSnapshot): string {
|
||||
if (job.status === 'failed' || job.status === 'interrupted') {
|
||||
if (job.error?.code === 'interrupted') {
|
||||
return formatMessage(messages.installFailedAppClosed)
|
||||
}
|
||||
if (job.error?.code === 'network_error') {
|
||||
return formatMessage(messages.installFailedNetwork)
|
||||
}
|
||||
return formatMessage(messages.installFailedUnknown)
|
||||
return getFailureSummary(job)
|
||||
}
|
||||
if (job.phase === 'preparing_java' && job.details.type === 'java') {
|
||||
return formatMessage(javaStepMessages[job.details.step], {
|
||||
version: job.details.major_version,
|
||||
})
|
||||
}
|
||||
if (job.kind === 'update_shared_instance' && job.phase === 'downloading_content') {
|
||||
return formatMessage(messages.updatingSharedContent)
|
||||
}
|
||||
return formatMessage(phaseMessages[job.phase])
|
||||
}
|
||||
|
||||
function getFailureSummary(job: InstallJobSnapshot): string {
|
||||
const code = job.error?.code
|
||||
const phase = job.error?.phase ?? job.phase
|
||||
|
||||
if (code === 'app_closed' || (job.status === 'interrupted' && code === 'interrupted')) {
|
||||
return formatMessage(failureSummaryMessages.appClosed)
|
||||
}
|
||||
if (code === 'canceled') {
|
||||
return formatMessage(failureSummaryMessages.canceled)
|
||||
}
|
||||
if (job.rollback_error || code === 'rollback_error') {
|
||||
return formatMessage(failureSummaryMessages.cleanupIncomplete)
|
||||
}
|
||||
if (hasPermissionError(job)) {
|
||||
return formatMessage(failureSummaryMessages.noWritePermission)
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'network_error':
|
||||
return formatMessage(
|
||||
phase === 'downloading_pack_file'
|
||||
? failureSummaryMessages.packDownloadFailed
|
||||
: failureSummaryMessages.downloadFailed,
|
||||
)
|
||||
case 'api_error':
|
||||
return formatMessage(failureSummaryMessages.modrinthUnreachable)
|
||||
case 'pack_error':
|
||||
return formatMessage(
|
||||
phase === 'downloading_pack_file'
|
||||
? failureSummaryMessages.packDownloadFailed
|
||||
: failureSummaryMessages.invalidModpack,
|
||||
)
|
||||
case 'archive_error':
|
||||
return formatMessage(failureSummaryMessages.badModpackFile)
|
||||
case 'parse_error':
|
||||
return formatMessage(failureSummaryMessages.invalidModpack)
|
||||
case 'content_error':
|
||||
return formatMessage(failureSummaryMessages.invalidModpackFiles)
|
||||
case 'hash_error':
|
||||
return formatMessage(failureSummaryMessages.corruptDownload)
|
||||
case 'filesystem_error':
|
||||
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
|
||||
case 'path_error':
|
||||
return formatMessage(failureSummaryMessages.invalidFilePath)
|
||||
case 'instance_error':
|
||||
return formatMessage(failureSummaryMessages.instanceNotFound)
|
||||
case 'java_error':
|
||||
return formatMessage(failureSummaryMessages.javaSetupFailed)
|
||||
case 'loader_error':
|
||||
case 'processor_error':
|
||||
return formatMessage(failureSummaryMessages.loaderSetupFailed)
|
||||
case 'database_error':
|
||||
return formatMessage(failureSummaryMessages.localDataError)
|
||||
case 'launcher_error':
|
||||
case 'metadata_error':
|
||||
return getFailureSummaryForPhase(phase)
|
||||
default:
|
||||
return getFailureSummaryForPhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
function getFailureSummaryForPhase(phase: InstallPhaseId): string {
|
||||
switch (phase) {
|
||||
case 'downloading_pack_file':
|
||||
return formatMessage(failureSummaryMessages.packDownloadFailed)
|
||||
case 'resolving_pack':
|
||||
case 'reading_pack_manifest':
|
||||
return formatMessage(failureSummaryMessages.invalidModpack)
|
||||
case 'downloading_content':
|
||||
return formatMessage(failureSummaryMessages.contentDownloadFailed)
|
||||
case 'extracting_overrides':
|
||||
return formatMessage(failureSummaryMessages.couldNotSaveFiles)
|
||||
case 'resolving_minecraft':
|
||||
case 'downloading_minecraft':
|
||||
return formatMessage(failureSummaryMessages.minecraftSetupFailed)
|
||||
case 'resolving_loader':
|
||||
case 'running_loader_processors':
|
||||
return formatMessage(failureSummaryMessages.loaderSetupFailed)
|
||||
case 'preparing_java':
|
||||
return formatMessage(failureSummaryMessages.javaSetupFailed)
|
||||
case 'preparing_instance':
|
||||
return formatMessage(failureSummaryMessages.instanceNotFound)
|
||||
case 'rolling_back':
|
||||
return formatMessage(failureSummaryMessages.cleanupIncomplete)
|
||||
default:
|
||||
return formatMessage(failureSummaryMessages.unexpectedError)
|
||||
}
|
||||
}
|
||||
|
||||
function hasPermissionError(job: InstallJobSnapshot): boolean {
|
||||
const message = job.error?.message.toLowerCase() ?? ''
|
||||
return (
|
||||
message.includes('permission denied') ||
|
||||
message.includes('access is denied') ||
|
||||
message.includes('operation not permitted')
|
||||
)
|
||||
}
|
||||
|
||||
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
|
||||
if (!getEffectiveProgress(job)) return undefined
|
||||
if (
|
||||
@@ -235,11 +415,63 @@ export async function useInstallJobNotifications(opts: {
|
||||
return job.status === 'failed' || job.status === 'interrupted'
|
||||
}
|
||||
|
||||
function getTerminalButtons(job: InstallJobSnapshot): PopupNotificationButton[] | undefined {
|
||||
if (!isTerminalJob(job)) return undefined
|
||||
function getJobSortRank(job: InstallJobSnapshot): number {
|
||||
if (isTerminalJob(job)) return 0
|
||||
if (job.status === 'queued' || job.phase === 'preparing_instance') return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
function shouldShowCopyDetails(job: InstallJobSnapshot): boolean {
|
||||
return isTerminalJob(job) || themeStore.getFeatureFlag('always_show_copy_details')
|
||||
}
|
||||
|
||||
function isCopied(job: InstallJobSnapshot): boolean {
|
||||
return copiedJobIds.value.has(job.job_id)
|
||||
}
|
||||
|
||||
function setCopied(job: InstallJobSnapshot) {
|
||||
copiedJobIds.value = new Set([...copiedJobIds.value, job.job_id])
|
||||
const existingTimeout = copiedResetTimeouts.get(job.job_id)
|
||||
if (existingTimeout != null) {
|
||||
window.clearTimeout(existingTimeout)
|
||||
}
|
||||
copiedResetTimeouts.set(
|
||||
job.job_id,
|
||||
window.setTimeout(() => {
|
||||
copiedResetTimeouts.delete(job.job_id)
|
||||
if (!copiedJobIds.value.has(job.job_id)) {
|
||||
return
|
||||
}
|
||||
const nextCopiedJobIds = new Set(copiedJobIds.value)
|
||||
nextCopiedJobIds.delete(job.job_id)
|
||||
copiedJobIds.value = nextCopiedJobIds
|
||||
opts.onChange()
|
||||
}, 1_000),
|
||||
)
|
||||
opts.onChange()
|
||||
}
|
||||
|
||||
async function copyJobDetails(job: InstallJobSnapshot) {
|
||||
const details = await install_job_support_details(job.job_id).catch((error) => {
|
||||
opts.handleError(error)
|
||||
return null
|
||||
})
|
||||
if (!details) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(details)
|
||||
setCopied(job)
|
||||
} catch (error) {
|
||||
opts.handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function getButtons(job: InstallJobSnapshot): PopupNotificationButton[] {
|
||||
const buttons: PopupNotificationButton[] = []
|
||||
|
||||
if (isTerminalJob(job)) {
|
||||
buttons.push({
|
||||
label: formatMessage(messages.retry),
|
||||
icon: UpdatedIcon,
|
||||
color: 'brand',
|
||||
@@ -248,8 +480,33 @@ export async function useInstallJobNotifications(opts: {
|
||||
await install_job_retry(job.job_id).catch(opts.handleError)
|
||||
await refresh()
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowCopyDetails(job)) {
|
||||
const copied = isCopied(job)
|
||||
buttons.push({
|
||||
label: formatMessage(copied ? messages.copied : messages.copyDetails),
|
||||
icon: copied ? CheckIcon : CopyIcon,
|
||||
color: 'standard',
|
||||
keepOpen: true,
|
||||
action: async () => {
|
||||
await copyJobDetails(job)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
function getDismissHandler(job: InstallJobSnapshot): (() => Promise<void>) | undefined {
|
||||
if (isTerminalJob(job)) {
|
||||
return async () => {
|
||||
await install_job_dismiss(job.job_id).catch(opts.handleError)
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function setJobs(nextJobs: InstallJobSnapshot[]) {
|
||||
@@ -259,13 +516,14 @@ export async function useInstallJobNotifications(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
jobs.value = nextJobs
|
||||
.filter((job) => visibleJobStatuses.has(job.status))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.created.localeCompare(b.created) ||
|
||||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
|
||||
)
|
||||
const visibleJobs = nextJobs.filter((job) => visibleJobStatuses.has(job.status))
|
||||
|
||||
jobs.value = visibleJobs.sort(
|
||||
(a, b) =>
|
||||
getJobSortRank(a) - getJobSortRank(b) ||
|
||||
a.created.localeCompare(b.created) ||
|
||||
(jobOrder.get(a.job_id) ?? 0) - (jobOrder.get(b.job_id) ?? 0),
|
||||
)
|
||||
}
|
||||
|
||||
const progressItems = computed<PopupNotificationProgressItem[]>(() =>
|
||||
@@ -284,13 +542,9 @@ export async function useInstallJobNotifications(opts: {
|
||||
progressType: isTerminalJob(job) ? undefined : getProgressType(job),
|
||||
progressCurrent: isTerminalJob(job) ? undefined : progress?.current,
|
||||
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
|
||||
buttons: getTerminalButtons(job),
|
||||
onDismiss: isTerminalJob(job)
|
||||
? async () => {
|
||||
await install_job_dismiss(job.job_id).catch(opts.handleError)
|
||||
await refresh()
|
||||
}
|
||||
: undefined,
|
||||
buttons: getButtons(job),
|
||||
dismissible: isTerminalJob(job),
|
||||
onDismiss: getDismissHandler(job),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -373,8 +627,8 @@ export async function useInstallJobNotifications(opts: {
|
||||
void refreshMetadata()
|
||||
}
|
||||
|
||||
await refresh(false)
|
||||
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job))
|
||||
await refresh(false)
|
||||
|
||||
return {
|
||||
active: computed(() => jobs.value.length > 0),
|
||||
@@ -382,6 +636,11 @@ export async function useInstallJobNotifications(opts: {
|
||||
progressItems,
|
||||
buttons,
|
||||
refresh,
|
||||
dispose: () => unlisten(),
|
||||
dispose: () => {
|
||||
for (const timeout of copiedResetTimeouts.values()) {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
unlisten()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
import { onUnmounted, ref, shallowRef } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { kill, list as listInstances } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
|
||||
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
|
||||
|
||||
interface BrowseServerInstance {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
@@ -22,14 +27,14 @@ interface BrowseServerInstance {
|
||||
interface ContextMenuHandle {
|
||||
showMenu: (
|
||||
event: MouseEvent,
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
result: Labrinth.Search.v3.ResultSearchProject,
|
||||
options: { name: string }[],
|
||||
) => void
|
||||
}
|
||||
|
||||
interface ContextMenuOptionClick {
|
||||
option: 'open_link' | 'copy_link'
|
||||
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject
|
||||
item: Labrinth.Search.v3.ResultSearchProject
|
||||
}
|
||||
|
||||
export interface UseAppServerBrowseOptions {
|
||||
@@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
const debugLog = useDebugLogger('BrowseServer')
|
||||
const serverPings = shallowRef<Record<string, number | undefined>>({})
|
||||
const serverPingCache = new Map<string, number | undefined>()
|
||||
const pendingServerPings = new Map<string, Promise<number | undefined>>()
|
||||
const runningServerProjects = ref<Record<string, string>>({})
|
||||
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
|
||||
const contextMenuRef = ref<ContextMenuHandle | null>(null)
|
||||
let serverPingCacheActive = true
|
||||
let serverPingsActive = true
|
||||
let unlistenProcesses: (() => void) | null = null
|
||||
|
||||
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
@@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
})
|
||||
const nextPings = { ...serverPings.value }
|
||||
for (const { hit, address } of pingsToFetch) {
|
||||
if (serverPingCache.has(address)) {
|
||||
nextPings[hit.project_id] = serverPingCache.get(address)
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
|
||||
if (cachedStatus) {
|
||||
nextPings[hit.project_id] = cachedStatus.ping
|
||||
}
|
||||
}
|
||||
serverPings.value = nextPings
|
||||
|
||||
await Promise.all(
|
||||
pingsToFetch.map(async ({ hit, address }) => {
|
||||
if (serverPingCache.has(address)) return
|
||||
if (getFreshCachedServerStatus(queryClient, address)) return
|
||||
|
||||
let pending = pendingServerPings.get(address)
|
||||
if (!pending) {
|
||||
pending = getServerLatency(address)
|
||||
.then((latency) => {
|
||||
if (serverPingCacheActive) serverPingCache.set(address, latency)
|
||||
return latency
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to ping server ${address}:`, error)
|
||||
if (serverPingCacheActive) serverPingCache.set(address, undefined)
|
||||
return undefined
|
||||
})
|
||||
.finally(() => {
|
||||
pendingServerPings.delete(address)
|
||||
})
|
||||
pendingServerPings.set(address, pending)
|
||||
try {
|
||||
const status = await fetchCachedServerStatus(queryClient, address)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping server ${address}:`, error)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
|
||||
}
|
||||
|
||||
const latency = await pending
|
||||
if (!serverPingCacheActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -271,10 +263,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
return actions
|
||||
}
|
||||
|
||||
function handleRightClick(
|
||||
event: MouseEvent,
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
) {
|
||||
function handleRightClick(event: MouseEvent, result: Labrinth.Search.v3.ResultSearchProject) {
|
||||
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
|
||||
}
|
||||
|
||||
@@ -308,10 +297,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
.catch(options.handleError)
|
||||
|
||||
onUnmounted(() => {
|
||||
serverPingCacheActive = false
|
||||
serverPingsActive = false
|
||||
unlistenProcesses?.()
|
||||
serverPingCache.clear()
|
||||
pendingServerPings.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -325,9 +312,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectUrl(
|
||||
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
) {
|
||||
const projectType = 'project_types' in item ? item.project_types?.[0] : item.project_type
|
||||
function getProjectUrl(item: Labrinth.Search.v3.ResultSearchProject) {
|
||||
const projectType = item.project_types?.[0]
|
||||
return `https://modrinth.com/${projectType ?? 'project'}/${item.slug ?? item.project_id}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { type ContentActionWarning, type ContentItem, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const managedSourceKinds = new Set(['shared_instance', 'modrinth_modpack', 'imported_modpack'])
|
||||
|
||||
export function useManagedContentPolicy(instance: Ref<GameInstance>) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const isManagedModpack = computed(() => instance.value.shared_instance?.role === 'member')
|
||||
const isQuarantined = computed(() => instance.value.quarantined)
|
||||
const canUnpublish = computed(() => instance.value.shared_instance?.role === 'owner')
|
||||
const canUnlink = computed(() => instance.value.shared_instance?.role === 'member')
|
||||
const managedModpackWarning = computed(() => ({
|
||||
admonitionHeader: formatMessage(messages.warningHeader),
|
||||
changeVersionBody: formatMessage(messages.changeVersionBody),
|
||||
unlinkBody: formatMessage(messages.unlinkBody),
|
||||
}))
|
||||
|
||||
function isManagedContent(item: ContentItem) {
|
||||
return (
|
||||
isQuarantined.value ||
|
||||
(isManagedModpack.value && !!item.source_kind && managedSourceKinds.has(item.source_kind))
|
||||
)
|
||||
}
|
||||
|
||||
function canMutateContent(item: ContentItem) {
|
||||
return !isManagedContent(item)
|
||||
}
|
||||
|
||||
function canUpdateContent(item: ContentItem) {
|
||||
return (
|
||||
canMutateContent(item) && !!item.file_path && !!item.has_update && !!item.update_version_id
|
||||
)
|
||||
}
|
||||
|
||||
function deleteWarning(items: ContentItem[]): ContentActionWarning | null {
|
||||
if (!items.some(isManagedContent)) return null
|
||||
return {
|
||||
admonitionHeader: formatMessage(messages.warningHeader),
|
||||
admonitionBody: formatMessage(
|
||||
items.length === 1 ? messages.deleteSingleBody : messages.deleteBulkBody,
|
||||
),
|
||||
actionLabel: formatMessage(
|
||||
items.length === 1 ? messages.deleteButton : messages.deleteManyButton,
|
||||
{ count: items.length },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function disableWarning(items: ContentItem[]): ContentActionWarning | null {
|
||||
if (!items.some(isManagedContent)) return null
|
||||
return {
|
||||
admonitionHeader: formatMessage(messages.warningHeader),
|
||||
admonitionBody: formatMessage(
|
||||
items.length === 1 ? messages.disableSingleBody : messages.disableBulkBody,
|
||||
),
|
||||
actionLabel: formatMessage(
|
||||
items.length === 1 ? messages.disableButton : messages.disableManyButton,
|
||||
{ count: items.length },
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isManagedModpack,
|
||||
isQuarantined,
|
||||
canUnpublish,
|
||||
canUnlink,
|
||||
managedModpackWarning,
|
||||
isManagedContent,
|
||||
canMutateContent,
|
||||
canUpdateContent,
|
||||
deleteWarning,
|
||||
disableWarning,
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
warningHeader: {
|
||||
id: 'content.shared-instance.warning-header',
|
||||
defaultMessage: 'This is part of the shared instance',
|
||||
},
|
||||
changeVersionBody: {
|
||||
id: 'content.shared-instance.change-version-body',
|
||||
defaultMessage:
|
||||
'Changing the version only changes your local copy. Future shared instance updates may restore or change it again.',
|
||||
},
|
||||
unlinkBody: {
|
||||
id: 'content.shared-instance.unlink-body',
|
||||
defaultMessage:
|
||||
'Unlinking only changes your local copy. Future shared instance updates may restore or change it again.',
|
||||
},
|
||||
deleteSingleBody: {
|
||||
id: 'content.shared-instance.delete-single-body',
|
||||
defaultMessage:
|
||||
'Deleting it only changes your local copy. Future shared instance updates may restore or change it again.',
|
||||
},
|
||||
deleteBulkBody: {
|
||||
id: 'content.shared-instance.delete-bulk-body',
|
||||
defaultMessage:
|
||||
'Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again.',
|
||||
},
|
||||
deleteButton: { id: 'content.shared-instance.delete-button', defaultMessage: 'Delete anyway' },
|
||||
deleteManyButton: {
|
||||
id: 'content.shared-instance.delete-many-button',
|
||||
defaultMessage: 'Delete {count, number} projects anyway',
|
||||
},
|
||||
disableSingleBody: {
|
||||
id: 'content.shared-instance.disable-single-body',
|
||||
defaultMessage:
|
||||
'Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again.',
|
||||
},
|
||||
disableBulkBody: {
|
||||
id: 'content.shared-instance.disable-bulk-body',
|
||||
defaultMessage:
|
||||
'Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again.',
|
||||
},
|
||||
disableButton: { id: 'content.shared-instance.disable-button', defaultMessage: 'Disable anyway' },
|
||||
disableManyButton: {
|
||||
id: 'content.shared-instance.disable-many-button',
|
||||
defaultMessage: 'Disable {count, number} projects anyway',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { QueryClient } from '@tanstack/vue-query'
|
||||
|
||||
import {
|
||||
get_server_status,
|
||||
normalizeServerAddress,
|
||||
type ProtocolVersion,
|
||||
type ServerStatus,
|
||||
} from '@/helpers/worlds'
|
||||
|
||||
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
|
||||
|
||||
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
|
||||
if (!protocolVersion) return 'default'
|
||||
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
|
||||
}
|
||||
|
||||
export function getServerStatusQueryKey(
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
return [
|
||||
'minecraft-server-status',
|
||||
normalizeServerAddress(address) || address.trim().toLowerCase(),
|
||||
getProtocolVersionKey(protocolVersion),
|
||||
] as const
|
||||
}
|
||||
|
||||
export function getFreshCachedServerStatus(
|
||||
queryClient: QueryClient,
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
const queryKey = getServerStatusQueryKey(address, protocolVersion)
|
||||
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
|
||||
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
|
||||
return queryClient.getQueryData<ServerStatus>(queryKey)
|
||||
}
|
||||
|
||||
export async function fetchCachedServerStatus(
|
||||
queryClient: QueryClient,
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
) {
|
||||
return await queryClient.fetchQuery({
|
||||
queryKey: getServerStatusQueryKey(address, protocolVersion),
|
||||
queryFn: () => get_server_status(address, protocolVersion),
|
||||
staleTime: SERVER_STATUS_CACHE_MS,
|
||||
gcTime: SERVER_STATUS_CACHE_MS,
|
||||
})
|
||||
}
|
||||