mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
293
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d30421878 | ||
|
|
b95a2d875a | ||
|
|
1067b02830 | ||
|
|
ebf4a6ef5f | ||
|
|
e4376c2b15 | ||
|
|
5fe07de187 | ||
|
|
80d66d3c6e | ||
|
|
64f7fb811e | ||
|
|
734720e11e | ||
|
|
ef4044534f | ||
|
|
6fc741f7c0 | ||
|
|
527757d158 | ||
|
|
f4a2999b20 | ||
|
|
586cdc90f9 | ||
|
|
e56f3ae18a | ||
|
|
c392c85917 | ||
|
|
29110890e1 | ||
|
|
a18f67bf0c | ||
|
|
623e01dbff | ||
|
|
14c728c8e3 | ||
|
|
a1b2073f57 | ||
|
|
45c417e406 | ||
|
|
47c60788ff | ||
|
|
3c60cc6f08 | ||
|
|
d1e8a9c574 | ||
|
|
7bf85239de | ||
|
|
06353bbc14 | ||
|
|
6380dd16d5 | ||
|
|
2376d571bb | ||
|
|
e7926083fb | ||
|
|
a686a93858 | ||
|
|
2876890fe5 | ||
|
|
4c869d2a37 | ||
|
|
525f60c389 | ||
|
|
042a27a17c | ||
|
|
a747c2f290 | ||
|
|
d756e5ab93 | ||
|
|
1f8b1bd2bb | ||
|
|
23edb2351d | ||
|
|
2c53e1240e | ||
|
|
b7d097d74d | ||
|
|
0a0ef1ce81 | ||
|
|
d7917048d5 | ||
|
|
a0017cf58f | ||
|
|
b188bbf617 | ||
|
|
79ce1adf9d | ||
|
|
c6ef10e798 | ||
|
|
be65589e4d | ||
|
|
86a224419c | ||
|
|
49dd844fe1 | ||
|
|
56536abb24 | ||
|
|
ac5daad280 | ||
|
|
87eec7741b | ||
|
|
4c9ee5b1c4 | ||
|
|
caeab46ca6 | ||
|
|
8e6004fdd5 | ||
|
|
50b2b9567c | ||
|
|
e0ea14226e | ||
|
|
d2b85c9f8e | ||
|
|
d33f00d2b1 | ||
|
|
b3257a0614 | ||
|
|
486b467af2 | ||
|
|
336050f4df | ||
|
|
2bb1ef775c | ||
|
|
5ed322d281 | ||
|
|
8591bc8ebc | ||
|
|
01fd18d550 | ||
|
|
3aaa2ef071 | ||
|
|
a3aeeac2c3 | ||
|
|
dfba212be4 | ||
|
|
3877999e53 | ||
|
|
46a7cf490d | ||
|
|
b5f7406998 | ||
|
|
3679d2c786 | ||
|
|
f48a2f64db | ||
|
|
fe80ac10fc | ||
|
|
cf0d260802 | ||
|
|
503d34ee0f | ||
|
|
bfbe66f73b | ||
|
|
64e17c7c1b | ||
|
|
c082594825 | ||
|
|
e5831d38eb | ||
|
|
1cabfe3e85 | ||
|
|
36423eb5b5 | ||
|
|
7d15fd3ac0 | ||
|
|
c1780eef7d | ||
|
|
d2a66bb2b0 | ||
|
|
98b1730e19 | ||
|
|
180cef6eaa | ||
|
|
b828fa17de | ||
|
|
72a4e86c26 | ||
|
|
93f8da1666 | ||
|
|
f474940321 | ||
|
|
83b0586fd2 | ||
|
|
543d25e2d6 | ||
|
|
bc5a761312 | ||
|
|
3258d7dbdf | ||
|
|
b5d1aeda85 | ||
|
|
1cedbe5fda | ||
|
|
2c9bf58d1f | ||
|
|
a92b5b08df | ||
|
|
01d3fb47c4 | ||
|
|
9404d46782 | ||
|
|
926c72de42 | ||
|
|
9729737d7d | ||
|
|
97a8c11b50 | ||
|
|
4d654162eb | ||
|
|
33b1419bdf | ||
|
|
c28ba2e6a4 | ||
|
|
c7ba6ba8b2 | ||
|
|
7366c32df3 | ||
|
|
707e219ff8 | ||
|
|
dfe12d4ecb | ||
|
|
c653228fe7 | ||
|
|
352a196795 | ||
|
|
cef9b1efe5 | ||
|
|
7d6c54cff9 | ||
|
|
bd97ace974 | ||
|
|
58ad58f958 | ||
|
|
d907083d83 | ||
|
|
8371ff641a | ||
|
|
b1cd16f966 | ||
|
|
40a06921ea | ||
|
|
a7dc063e08 | ||
|
|
64b61d8fd0 | ||
|
|
5e7d4cc838 | ||
|
|
a0c80b13a4 | ||
|
|
3c051f5b1d | ||
|
|
940a796ba5 | ||
|
|
6ee5e4df19 | ||
|
|
d61397097c | ||
|
|
cfe45b368c | ||
|
|
6b0a0c1897 | ||
|
|
f27691340a | ||
|
|
c3a58aba9e | ||
|
|
1550dfb3f0 | ||
|
|
4762a0a725 | ||
|
|
cf82943766 | ||
|
|
b527e8f8f2 | ||
|
|
3c53b5793b | ||
|
|
e6afc6f4f3 | ||
|
|
05699a90d6 | ||
|
|
142d560f76 | ||
|
|
e80e27884e | ||
|
|
75788938b5 | ||
|
|
2570cf1bd7 | ||
|
|
b0cca873b6 | ||
|
|
325926ad9b | ||
|
|
34b87991bc | ||
|
|
cc8d556448 | ||
|
|
71286f7b2b | ||
|
|
c29973ec1a | ||
|
|
8c95f0bb81 | ||
|
|
627ab6734b | ||
|
|
36e1cbbf3e | ||
|
|
93fe87e57f | ||
|
|
02363c27a2 | ||
|
|
67e1743d6c | ||
|
|
cef20abceb | ||
|
|
62cedab6dd | ||
|
|
ed5a74a9d3 | ||
|
|
e2318ee776 | ||
|
|
a29c1c61c4 | ||
|
|
206813b74c | ||
|
|
99611d22c7 | ||
|
|
11b2b6e6c0 | ||
|
|
f49951084e | ||
|
|
047b8c3bf7 | ||
|
|
5c1ffd9ff2 | ||
|
|
670b3c17b6 | ||
|
|
ec7c538888 | ||
|
|
b88341e3a8 | ||
|
|
2048d8008a | ||
|
|
527f2f800b | ||
|
|
c7d3229fd3 | ||
|
|
ec49a3b051 | ||
|
|
cf1d948030 | ||
|
|
fe8fa4b6f7 | ||
|
|
2c62cf1d12 | ||
|
|
84b91f32f8 | ||
|
|
64edf2ddeb | ||
|
|
2f248027d6 | ||
|
|
f96520638b | ||
|
|
9e5d29ced6 | ||
|
|
3e15d0b287 | ||
|
|
bcce7e28fd | ||
|
|
3889d0f5ec | ||
|
|
6f44c5b039 | ||
|
|
ea967845d9 | ||
|
|
a6967cf9cb | ||
|
|
5bf92863b0 | ||
|
|
71b6ecc10c | ||
|
|
ed28bc7551 | ||
|
|
3e4197db7c | ||
|
|
e12230ff59 | ||
|
|
aeb9f5a075 | ||
|
|
8b17441f40 | ||
|
|
f9d47e8edc | ||
|
|
a58bc3dc21 | ||
|
|
1e46444fb0 | ||
|
|
1511e55597 | ||
|
|
5727e156ed | ||
|
|
657186398d | ||
|
|
3ab2273782 | ||
|
|
893ec00fc6 | ||
|
|
aa7dd1d210 | ||
|
|
f8733b0488 | ||
|
|
d077d44540 | ||
|
|
4e1a61d8b6 | ||
|
|
71dee4de40 | ||
|
|
f74fad0cae | ||
|
|
07e81ac036 | ||
|
|
6e7835fb35 | ||
|
|
2f95c4c441 | ||
|
|
451b2d0e44 | ||
|
|
215643c846 | ||
|
|
d8b1415f9c | ||
|
|
3eeb549d20 | ||
|
|
c3fe7b4232 | ||
|
|
079a10bba9 | ||
|
|
3c3d5702ba | ||
|
|
a34576a2c3 | ||
|
|
d8e4915a31 | ||
|
|
ed723fa186 | ||
|
|
d6c8d4475b | ||
|
|
302b60d89c | ||
|
|
f106dc580f | ||
|
|
244c263e40 | ||
|
|
48bb44155d | ||
|
|
3f2e76ae7e | ||
|
|
6479eca0e9 | ||
|
|
8767bc9184 | ||
|
|
d1185414b6 | ||
|
|
510439acbf | ||
|
|
c564495e11 | ||
|
|
8dd1490c8a | ||
|
|
b72bc18a6b | ||
|
|
cee942dcef | ||
|
|
02a7774722 | ||
|
|
e9eb98f97e | ||
|
|
e5bbd9d409 | ||
|
|
83dddfd512 | ||
|
|
0ffdabb2a3 | ||
|
|
d87f93fdd5 | ||
|
|
f6fa486dea | ||
|
|
78d978b22e | ||
|
|
71559d62c8 | ||
|
|
538eda6976 | ||
|
|
882b01c7c9 | ||
|
|
a192f7857e | ||
|
|
3bf0f91cf0 | ||
|
|
3083dcd932 | ||
|
|
c8c79a6c74 | ||
|
|
f5462b6dd8 | ||
|
|
1ddbae40b7 | ||
|
|
a4f3c63fcc | ||
|
|
02e10be4db | ||
|
|
e0056bfc40 | ||
|
|
612934bf34 | ||
|
|
86d377b915 | ||
|
|
ad99ac039b | ||
|
|
6d3fdb680c | ||
|
|
840b556c51 | ||
|
|
12e5f02e57 | ||
|
|
ca1b36efde | ||
|
|
a5417e0851 | ||
|
|
45398c546c | ||
|
|
7e769c720b | ||
|
|
c1c86e3b72 | ||
|
|
c7602602e5 | ||
|
|
07f9e3aedc | ||
|
|
a79b8e0777 | ||
|
|
671f6d264a | ||
|
|
e231df1f97 | ||
|
|
3052a14d95 | ||
|
|
e8665f43ca | ||
|
|
cba4550be4 | ||
|
|
384556a810 | ||
|
|
a082e8597c | ||
|
|
7048a35e9f | ||
|
|
c166ce52b3 | ||
|
|
9c99518497 | ||
|
|
758ed818c8 | ||
|
|
83e45d7a5c | ||
|
|
77b30b27fe | ||
|
|
3d7aea5a45 | ||
|
|
fd5d2797b3 | ||
|
|
ec85d9de1c | ||
|
|
22415a4cc6 | ||
|
|
871672d8bf | ||
|
|
e8dc3c3150 | ||
|
|
56dae8f104 | ||
|
|
ae9ca4db18 |
@@ -9,8 +9,6 @@ body:
|
||||
options:
|
||||
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests
|
||||
required: true
|
||||
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: projects
|
||||
attributes:
|
||||
|
||||
@@ -81,6 +81,6 @@ Please follow these rules precisely:
|
||||
Use existing patterns from our codebase:
|
||||
|
||||
- Variables/plurals: see `apps/frontend/src/pages/frog.vue`
|
||||
- Rich-text link tags: see `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`
|
||||
- Rich-text link tags: see `apps/frontend/src/error.vue`
|
||||
|
||||
When you finish, there should be no hard-coded English strings left in the template—everything comes from `formatMessage` or `<IntlFormatted>`.
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: taiki-e/install-action@b5fddbb5361bce8a06fb168c9d403a6cc552b084 # v2.75.29
|
||||
- uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
|
||||
with:
|
||||
tool: tombi
|
||||
- run: tombi lint
|
||||
|
||||
@@ -96,6 +96,9 @@ jobs:
|
||||
rustflags: ''
|
||||
cache: false
|
||||
|
||||
- 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:
|
||||
|
||||
@@ -20,6 +20,14 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
description: 'The environment to deploy to (staging-preview or production-preview)'
|
||||
head_sha:
|
||||
required: false
|
||||
type: string
|
||||
description: 'The commit SHA to deploy (defaults to github.sha)'
|
||||
head_ref:
|
||||
required: false
|
||||
type: string
|
||||
description: 'The branch name to deploy (defaults to github.ref_name)'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.environment || 'push' }}
|
||||
@@ -40,12 +48,14 @@ jobs:
|
||||
|
||||
- name: Configure environment
|
||||
id: meta
|
||||
env:
|
||||
ENV_INPUT: ${{ inputs.environment }}
|
||||
REF: ${{ github.ref }}
|
||||
DEPLOY_SHA: ${{ inputs.head_sha || github.sha }}
|
||||
run: |
|
||||
echo "cmd=deploy" >> $GITHUB_OUTPUT
|
||||
|
||||
ENV_INPUT="${{ inputs.environment }}"
|
||||
REF="${{ github.ref }}"
|
||||
SHA_SHORT="${GITHUB_SHA::8}"
|
||||
SHA_SHORT="${DEPLOY_SHA::8}"
|
||||
|
||||
if [ "$ENV_INPUT" = "staging-preview" ]; then
|
||||
echo "env=staging" >> $GITHUB_OUTPUT
|
||||
@@ -106,8 +116,8 @@ jobs:
|
||||
working-directory: ./apps/frontend
|
||||
run: pnpm build
|
||||
env:
|
||||
CF_PAGES_BRANCH: ${{ github.ref_name }}
|
||||
CF_PAGES_COMMIT_SHA: ${{ github.sha }}
|
||||
CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }}
|
||||
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' }}
|
||||
|
||||
@@ -24,6 +24,8 @@ jobs:
|
||||
environment: [staging-preview, production-preview]
|
||||
with:
|
||||
environment: ${{ matrix.environment }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
head_ref: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
deploy-storybook:
|
||||
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
@@ -68,7 +70,9 @@ jobs:
|
||||
|
||||
- name: Configure short SHA
|
||||
id: meta
|
||||
run: echo "sha_short=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
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
|
||||
@@ -93,6 +97,8 @@ jobs:
|
||||
|
||||
- name: Read deployment URLs
|
||||
id: urls
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
|
||||
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
|
||||
@@ -102,7 +108,7 @@ jobs:
|
||||
|
||||
echo "staging-preview-url=$STAGING_PREVIEW_URL" >> $GITHUB_OUTPUT
|
||||
echo "production-preview-url=$PRODUCTION_PREVIEW_URL" >> $GITHUB_OUTPUT
|
||||
echo "storybook-preview-url=https://git-${GITHUB_SHA::8}-storybook.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
echo "storybook-preview-url=https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find comment
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -123,7 +129,7 @@ jobs:
|
||||
body: |
|
||||
## Frontend previews
|
||||
|
||||
Last deployed commit is [${{ github.sha }}](${{ github.event.pull_request.head.repo.html_url }}/commit/${{ github.sha }})
|
||||
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 }})
|
||||
|
||||
| Environment | URL |
|
||||
|-------------|-----|
|
||||
|
||||
@@ -56,6 +56,17 @@ jobs:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ secrets.CROWDIN_GH_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install script dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Configure Git author
|
||||
id: git-author
|
||||
uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2
|
||||
@@ -95,6 +106,9 @@ jobs:
|
||||
shell: bash
|
||||
run: sudo chown -R $USER:$USER .
|
||||
|
||||
- name: Prune stale ICU translations
|
||||
run: pnpm scripts i18n-icu-contract prune-local
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
|
||||
@@ -4,11 +4,14 @@ on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- '.github/workflows/i18n.push.yml'
|
||||
- '.github/workflows/i18n-push.yml'
|
||||
- 'apps/*/src/locales/en-US/**'
|
||||
- 'apps/*/locales/en-US/**'
|
||||
- 'packages/*/src/locales/en-US/**'
|
||||
- 'packages/*/locales/en-US/**'
|
||||
- 'scripts/i18n-icu-contract.ts'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'crowdin.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -56,6 +59,18 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
|
||||
- name: Install script dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Query branch name
|
||||
id: branch-name
|
||||
@@ -79,3 +94,9 @@ jobs:
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Clear stale ICU translations in Crowdin
|
||||
run: pnpm scripts i18n-icu-contract clear-crowdin-changed --base-ref HEAD^ --crowdin-branch "[${{ github.repository_owner }}.${{ github.event.repository.name }}] ${{ steps.branch-name.outputs.safe_branch_name }}"
|
||||
env:
|
||||
CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -99,6 +99,12 @@ 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:
|
||||
@@ -164,7 +170,7 @@ jobs:
|
||||
with:
|
||||
context: ./apps/labrinth/docker-stage
|
||||
file: ./apps/labrinth/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||
annotations: ${{ steps.docker-meta.outputs.annotations }}
|
||||
|
||||
@@ -30,6 +30,10 @@ on:
|
||||
- prod-with-staging-archon
|
||||
default: prod
|
||||
required: false
|
||||
app-version-override:
|
||||
description: Temporary app version override for updater testing
|
||||
type: string
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -80,6 +84,10 @@ jobs:
|
||||
rustflags: ''
|
||||
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Setup mold
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
@@ -126,7 +134,10 @@ jobs:
|
||||
- name: Set application version and environment
|
||||
shell: bash
|
||||
run: |
|
||||
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
|
||||
APP_VERSION="${{ inputs.app-version-override }}"
|
||||
if [ -z "$APP_VERSION" ]; then
|
||||
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
|
||||
fi
|
||||
BUILD_ENVIRONMENT="${{ inputs.environment || 'prod' }}"
|
||||
echo "Setting application version to $APP_VERSION"
|
||||
echo "Using environment $BUILD_ENVIRONMENT"
|
||||
@@ -153,7 +164,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build macOS app
|
||||
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
|
||||
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-dev.conf.json' }}
|
||||
if: contains(matrix.platform, 'macos')
|
||||
env:
|
||||
TAURI_BUNDLER_DMG_IGNORE_CI: 'true'
|
||||
@@ -168,7 +179,7 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: Build Linux app
|
||||
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
|
||||
run: ${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json' }}
|
||||
if: contains(matrix.platform, 'ubuntu')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
@@ -179,7 +190,7 @@ jobs:
|
||||
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
||||
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_17_X64"
|
||||
${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
|
||||
${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || inputs.app-version-override != '') && 'pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles "nsis,updater"' || 'pnpm --filter=@modrinth/app run tauri build --config tauri-dev.conf.json --verbose --bundles "nsis,updater"' }}
|
||||
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
|
||||
if: contains(matrix.platform, 'windows')
|
||||
env:
|
||||
|
||||
@@ -57,8 +57,11 @@ jobs:
|
||||
|
||||
- name: Extract app changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ env.VERSION_TAG }}
|
||||
run: npx --yes tsx scripts/build-theseus-release-notes.ts
|
||||
run: |
|
||||
LAST_GITHUB_RELEASE_PUBLISHED_AT=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.published_at // ""' 2>/dev/null || true)
|
||||
LAST_GITHUB_RELEASE_PUBLISHED_AT="$LAST_GITHUB_RELEASE_PUBLISHED_AT" npx --yes tsx scripts/build-theseus-release-notes.ts
|
||||
|
||||
- name: Generate version manifest
|
||||
run: |
|
||||
|
||||
@@ -99,8 +99,8 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
|
||||
with:
|
||||
packages: libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
version: v1 # cache key
|
||||
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
|
||||
@@ -129,6 +129,9 @@ jobs:
|
||||
components: clippy, rustfmt
|
||||
cache: false
|
||||
|
||||
- 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:
|
||||
@@ -153,7 +156,7 @@ jobs:
|
||||
uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
|
||||
|
||||
- name: Setup nextest
|
||||
run: cargo binstall --no-confirm --secure cargo-nextest@0.9.133
|
||||
run: cargo binstall --no-confirm --secure --locked cargo-nextest@0.9.133
|
||||
|
||||
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
|
||||
# back to a cached cargo install
|
||||
@@ -204,4 +207,5 @@ jobs:
|
||||
- name: Verify intl:extract has been run
|
||||
run: |
|
||||
pnpm turbo run intl:extract --force
|
||||
git diff --exit-code --color */*/src/locales/en-US/index.json
|
||||
pnpm scripts i18n-icu-contract prune-local --check
|
||||
git diff --exit-code --color */*/src/locales/*/index.json
|
||||
|
||||
@@ -22,6 +22,7 @@ node_modules
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/i18n-ally-custom-framework.yml
|
||||
|
||||
# IDE - IntelliJ
|
||||
.idea/*
|
||||
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "Vue.volar", "rust-lang.rust-analyzer"]
|
||||
"recommendations": ["esbenp.prettier-vscode", "Vue.volar", "rust-lang.rust-analyzer", "lokalise.i18n-ally"]
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
languageIds:
|
||||
- vue
|
||||
- typescript
|
||||
- javascript
|
||||
- typescriptreact
|
||||
|
||||
usageMatchRegex:
|
||||
- id:\s*['"]({key})['"]
|
||||
|
||||
monopoly: true
|
||||
Vendored
+12
-2
@@ -9,7 +9,7 @@
|
||||
"files.insertFinalNewline": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "always"
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[vue]": {
|
||||
@@ -31,5 +31,15 @@
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
},
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"scss.lint.unknownAtRules": "ignore"
|
||||
"scss.lint.unknownAtRules": "ignore",
|
||||
"i18n-ally.localesPaths": [
|
||||
"packages/ui/src/locales",
|
||||
"apps/frontend/src/locales",
|
||||
"packages/moderation/src/locales"
|
||||
],
|
||||
"i18n-ally.pathMatcher": "{locale}/index.{ext}",
|
||||
"i18n-ally.keystyle": "flat",
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"i18n-ally.namespace": false,
|
||||
"i18n-ally.includeSubfolders": true
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ Run these from the **root** folder before opening a pull request - do not run th
|
||||
- **App frontend:** `pnpm prepr:frontend:app`
|
||||
- **Frontend libs:** `pnpm prepr:frontend:lib`
|
||||
- **All frontend (app+web):** `pnpm prepr`
|
||||
- **Labrinth (backend):** See `apps/labrinth/CLAUDE.md`
|
||||
- **Labrinth (backend):** See `apps/labrinth/AGENTS.md`
|
||||
|
||||
The website and app `prepr` commands
|
||||
|
||||
@@ -62,9 +62,9 @@ The website and app `prepr` commands
|
||||
|
||||
## Project-Specific Instructions
|
||||
|
||||
Each project may have its own `CLAUDE.md` with detailed instructions:
|
||||
Each project may have its own file with detailed instructions:
|
||||
|
||||
- [`apps/labrinth/CLAUDE.md`](apps/labrinth/CLAUDE.md) — Backend API
|
||||
- [`apps/labrinth/AGENTS.md`](apps/labrinth/AGENTS.md) — Backend API
|
||||
- [`apps/frontend/CLAUDE.md`](apps/frontend/CLAUDE.md) - Frontend Website
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
Generated
+549
-86
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -9,6 +9,7 @@ members = [
|
||||
"packages/ariadne",
|
||||
"packages/daedalus",
|
||||
"packages/labrinth-derive",
|
||||
"packages/modrinth-content-management",
|
||||
"packages/modrinth-log",
|
||||
"packages/modrinth-maxmind",
|
||||
"packages/modrinth-util",
|
||||
@@ -32,7 +33,6 @@ actix-ws = "0.3.0"
|
||||
arc-swap = "1.7.1"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async-compression = { version = "0.4.32", default-features = false }
|
||||
async-minecraft-ping = { path = "packages/async-minecraft-ping" }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
@@ -64,6 +64,8 @@ clickhouse = "0.14.0"
|
||||
color-eyre = "0.6.5"
|
||||
color-thief = "0.2.2"
|
||||
const_format = "0.2.34"
|
||||
core-foundation = "0.10.1"
|
||||
core-graphics = "0.24.0"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
darling = { version = "0.23" }
|
||||
dashmap = "6.1.0"
|
||||
@@ -77,13 +79,13 @@ dotenv-build = "0.1.1"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.5"
|
||||
either = "1.15.0"
|
||||
elasticsearch = "9.1.0-alpha.1"
|
||||
encoding_rs = "0.8.35"
|
||||
enumset = "1.1.10"
|
||||
eyre = "0.6.12"
|
||||
flate2 = "1.1.4"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = "0.3.31"
|
||||
futures-lite = "2.6.1"
|
||||
futures-util = "0.3.31"
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
@@ -117,6 +119,7 @@ lettre = { version = "0.11.19", default-features = false, features = [
|
||||
] }
|
||||
maxminddb = "0.26.0"
|
||||
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
||||
modrinth-content-management = { path = "packages/modrinth-content-management" }
|
||||
modrinth-log = { path = "packages/modrinth-log" }
|
||||
modrinth-util = { path = "packages/modrinth-util" }
|
||||
muralpay = { path = "packages/muralpay" }
|
||||
@@ -124,6 +127,7 @@ murmur2 = "0.1.0"
|
||||
native-dialog = "0.9.2"
|
||||
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 }
|
||||
p256 = "0.13.2"
|
||||
parking_lot = "0.12.5"
|
||||
paste = "1.0.15"
|
||||
@@ -137,6 +141,7 @@ quick-xml = "0.38.3"
|
||||
quote = { version = "1.0" }
|
||||
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||
rdkafka = { version = "0.36.2", features = ["cmake-build"] }
|
||||
redis = "0.32.7"
|
||||
regex = "1.12.2"
|
||||
reqwest = { version = "0.12.24", default-features = false }
|
||||
@@ -189,7 +194,7 @@ tauri-plugin-http = "2.5.7"
|
||||
tauri-plugin-opener = "2.5.0"
|
||||
tauri-plugin-os = "2.3.1"
|
||||
tauri-plugin-single-instance = "2.3.4"
|
||||
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
|
||||
tauri-plugin-updater = { git = "https://github.com/modrinth/plugins-workspace", rev = "0d30f2aa28ec668ce187d527da1c475da3c01cbc", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
] }
|
||||
@@ -216,6 +221,8 @@ 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"
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
webp = { version = "0.3.1", default-features = false }
|
||||
webview2-com = "0.38.0" # Should be updated in lockstep with wry
|
||||
whoami = "1.6.1"
|
||||
@@ -266,6 +273,7 @@ redundant_type_annotations = "warn"
|
||||
result_large_err = "allow"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
uninlined_format_args = "warn"
|
||||
unnested_or_patterns = "warn"
|
||||
wildcard_dependencies = "warn"
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend",
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@modrinth/api-client": "workspace:^",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@sentry/vue": "^8.27.0",
|
||||
"@sfirew/minecraft-motd-parser": "^1.1.6",
|
||||
"@tanstack/vue-query": "^5.90.7",
|
||||
"@tanstack/vue-query": "5.90.7",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
@@ -44,7 +44,8 @@
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^10.0.0",
|
||||
"vue-router": "^4.6.0",
|
||||
"vue-virtual-scroller": "v2.0.0-beta.8"
|
||||
"vue-virtual-scroller": "v2.0.0-beta.8",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.1.1",
|
||||
|
||||
+428
-231
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
|
||||
import {
|
||||
AuthFeature,
|
||||
ModrinthApiError,
|
||||
NodeAuthFeature,
|
||||
nodeAuthState,
|
||||
PanelVersionFeature,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
HomeIcon,
|
||||
LeftArrowIcon,
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
ContentInstallModal,
|
||||
ContentUpdaterModal,
|
||||
CreationFlowModal,
|
||||
defineMessages,
|
||||
I18nDebugPanel,
|
||||
@@ -44,16 +44,17 @@ import {
|
||||
NotificationPanel,
|
||||
OverflowMenu,
|
||||
PopupNotificationPanel,
|
||||
ProgressSpinner,
|
||||
provideModalBehavior,
|
||||
provideModrinthClient,
|
||||
provideNotificationManager,
|
||||
providePageContext,
|
||||
providePopupNotificationManager,
|
||||
useDebugLogger,
|
||||
useFormatBytes,
|
||||
useHostingIntercom,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatBytes, renderString } from '@modrinth/utils'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
@@ -73,7 +74,6 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import ErrorModal from '@/components/ui/ErrorModal.vue'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
|
||||
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
|
||||
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
|
||||
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
@@ -82,23 +82,25 @@ import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
import { useIntercomPositioning } from '@/composables/intercom-positioning'
|
||||
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 { 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, warning_listener } from '@/helpers/events.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 { create_profile_and_install_from_file } from '@/helpers/pack'
|
||||
import { list } from '@/helpers/profile.js'
|
||||
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 {
|
||||
areUpdatesEnabled,
|
||||
enqueueUpdateForInstallation,
|
||||
@@ -106,8 +108,20 @@ import {
|
||||
getUpdateSize,
|
||||
isDev,
|
||||
isNetworkMetered,
|
||||
setRestartAfterPendingUpdate,
|
||||
} from '@/helpers/utils.js'
|
||||
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
|
||||
import i18n from '@/i18n.config'
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
getNextAppUpdatePopupTime,
|
||||
installAvailableAppUpdate,
|
||||
markAppUpdateActionable,
|
||||
markAppUpdatePopupShown,
|
||||
openAppUpdateChangelog,
|
||||
setAppUpdateActions,
|
||||
} from '@/providers/app-update.ts'
|
||||
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
|
||||
import {
|
||||
provideAppUpdateDownloadProgress,
|
||||
@@ -128,15 +142,40 @@ import { AppPopupNotificationManager } from './providers/app-popup-notifications
|
||||
const themeStore = useTheming()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const intercomBubblePositioning = useIntercomPositioning({ route, themeStore })
|
||||
const {
|
||||
sidebarToggled,
|
||||
forceSidebar,
|
||||
sidebarVisible,
|
||||
intercomBubblePosition,
|
||||
updateIntercomBubbleStyles,
|
||||
clearIntercomBubbleStyles,
|
||||
} = intercomBubblePositioning
|
||||
const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
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()
|
||||
const sidebarToggled = ref(true)
|
||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||
sidebarToggled.value = !themeStore.toggleSidebar
|
||||
})
|
||||
const forceSidebar = computed(
|
||||
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
|
||||
)
|
||||
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
||||
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
|
||||
const prideFundraiserEnabled = computed(
|
||||
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
|
||||
)
|
||||
const hostingIntercomIdentityKey = computed(() => {
|
||||
const rawServerId = route.params.id
|
||||
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
|
||||
const userId = credentials.value?.user_id ?? credentials.value?.user?.id ?? 'anonymous'
|
||||
return `${userId}:${serverId ?? 'hosting'}`
|
||||
})
|
||||
const hostingIntercom = useHostingIntercom({
|
||||
enabled: computed(() => hostingRouteActive.value && !!credentials.value?.session),
|
||||
appId: 'ykeritl9',
|
||||
fetchToken: fetchIntercomToken,
|
||||
identityKey: hostingIntercomIdentityKey,
|
||||
horizontalPadding: computed(() =>
|
||||
sidebarVisible.value
|
||||
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
|
||||
: INTERCOM_BUBBLE_DEFAULT_PADDING,
|
||||
),
|
||||
})
|
||||
|
||||
const notificationManager = new AppNotificationManager()
|
||||
provideNotificationManager(notificationManager)
|
||||
@@ -146,8 +185,9 @@ const popupNotificationManager = new AppPopupNotificationManager()
|
||||
providePopupNotificationManager(popupNotificationManager)
|
||||
const { addPopupNotification } = popupNotificationManager
|
||||
|
||||
const appVersion = getVersion()
|
||||
const tauriApiClient = new TauriModrinthClient({
|
||||
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
|
||||
userAgent: async () => `modrinth/theseus/${await appVersion} (support@modrinth.com)`,
|
||||
labrinthBaseUrl: config.labrinthBaseUrl,
|
||||
archonBaseUrl: config.archonBaseUrl,
|
||||
features: [
|
||||
@@ -167,10 +207,20 @@ const tauriApiClient = new TauriModrinthClient({
|
||||
],
|
||||
})
|
||||
provideModrinthClient(tauriApiClient)
|
||||
const { data: authenticatedModrinthUser } = useQuery({
|
||||
queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]),
|
||||
queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(),
|
||||
enabled: () => !!credentials.value?.session,
|
||||
retry: false,
|
||||
})
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(true),
|
||||
showAds: ref(false),
|
||||
...intercomBubblePositioning.pageContext,
|
||||
floatingActionBarOffsets: {
|
||||
left: ref(APP_LEFT_NAV_WIDTH),
|
||||
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
|
||||
},
|
||||
intercomBubble: hostingIntercom.intercomBubble,
|
||||
featureFlags: {
|
||||
serverRamAsBytesAlwaysOn: computed(() =>
|
||||
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
|
||||
@@ -200,6 +250,7 @@ const {
|
||||
|
||||
const news = ref([])
|
||||
const availableSurvey = ref(false)
|
||||
const displayedServerInviteNotifications = new Set()
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
@@ -254,13 +305,15 @@ onMounted(async () => {
|
||||
onUnmounted(async () => {
|
||||
document.querySelector('body').removeEventListener('click', handleClick)
|
||||
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
|
||||
shutdownHostingIntercom()
|
||||
clearIntercomBubbleStyles()
|
||||
unsubscribeSidebarToggle()
|
||||
clearDelayedUpdatePopup()
|
||||
|
||||
await unlistenUpdateDownload?.()
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const messages = defineMessages({
|
||||
updateInstalledToastTitle: {
|
||||
id: 'app.update.complete-toast.title',
|
||||
@@ -270,18 +323,6 @@ const messages = defineMessages({
|
||||
id: 'app.update.complete-toast.text',
|
||||
defaultMessage: 'Click here to view the changelog.',
|
||||
},
|
||||
reloadToUpdate: {
|
||||
id: 'app.update.reload-to-update',
|
||||
defaultMessage: 'Reload to install update',
|
||||
},
|
||||
downloadUpdate: {
|
||||
id: 'app.update.download-update',
|
||||
defaultMessage: 'Download update',
|
||||
},
|
||||
downloadingUpdate: {
|
||||
id: 'app.update.downloading-update',
|
||||
defaultMessage: 'Downloading update ({percent}%)',
|
||||
},
|
||||
authUnreachableHeader: {
|
||||
id: 'app.auth-servers.unreachable.header',
|
||||
defaultMessage: 'Cannot reach authentication servers',
|
||||
@@ -300,6 +341,7 @@ async function setupApp() {
|
||||
locale,
|
||||
telemetry,
|
||||
collapsed_navigation,
|
||||
hide_nametag_skins_page,
|
||||
advanced_rendering,
|
||||
onboarded,
|
||||
default_page,
|
||||
@@ -330,6 +372,7 @@ async function setupApp() {
|
||||
themeStore.setThemeState(theme)
|
||||
themeStore.collapsedNavigation = collapsed_navigation
|
||||
themeStore.advancedRendering = advanced_rendering
|
||||
themeStore.hideNametagSkinsPage = hide_nametag_skins_page
|
||||
themeStore.toggleSidebar = toggle_sidebar
|
||||
themeStore.devMode = developer_mode
|
||||
themeStore.featureFlags = feature_flags
|
||||
@@ -567,6 +610,16 @@ const {
|
||||
handleModpackDuplicateCreateAnyway: handleContentInstallModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance: handleContentInstallModpackDuplicateGoToInstance,
|
||||
setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
|
||||
incompatibilityWarningVersions: contentInstallIncompatibilityWarningVersions,
|
||||
incompatibilityWarningCurrentGameVersion: contentInstallIncompatibilityWarningCurrentGameVersion,
|
||||
incompatibilityWarningCurrentLoader: contentInstallIncompatibilityWarningCurrentLoader,
|
||||
incompatibilityWarningProjectType: contentInstallIncompatibilityWarningProjectType,
|
||||
incompatibilityWarningProjectIconUrl: contentInstallIncompatibilityWarningProjectIconUrl,
|
||||
incompatibilityWarningProjectName: contentInstallIncompatibilityWarningProjectName,
|
||||
incompatibilityWarningMessage: contentInstallIncompatibilityWarningMessage,
|
||||
incompatibilityWarningInstalling: contentInstallIncompatibilityWarningInstalling,
|
||||
handleIncompatibilityWarningInstall: handleContentInstallIncompatibilityWarningInstall,
|
||||
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
|
||||
} = contentInstall
|
||||
|
||||
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
|
||||
@@ -586,10 +639,14 @@ const incompatibilityWarningModal = ref()
|
||||
const installToPlayModal = ref()
|
||||
const updateToPlayModal = ref()
|
||||
|
||||
const credentials = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
|
||||
watch(incompatibilityWarningModal, (modal) => {
|
||||
if (modal) {
|
||||
setContentIncompatibilityWarningModal(modal)
|
||||
}
|
||||
})
|
||||
|
||||
setupAuthProvider(credentials, async (_redirectPath) => {
|
||||
await signIn()
|
||||
})
|
||||
@@ -646,21 +703,16 @@ async function logOut() {
|
||||
await fetchCredentials()
|
||||
}
|
||||
|
||||
const MIDAS_BITFLAG = 1 << 0
|
||||
const hasPlus = computed(
|
||||
() =>
|
||||
credentials.value &&
|
||||
credentials.value.user &&
|
||||
(credentials.value.user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG,
|
||||
!!credentials.value?.user &&
|
||||
(hasMidasBadge(credentials.value.user) ||
|
||||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||
)
|
||||
|
||||
const showAd = computed(
|
||||
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||
)
|
||||
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
|
||||
|
||||
let intercomBooting = false
|
||||
let intercomBooted = false
|
||||
|
||||
async function fetchIntercomToken() {
|
||||
const creds = await getCreds()
|
||||
@@ -669,8 +721,10 @@ async function fetchIntercomToken() {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (route.path.startsWith('/hosting/manage/') && typeof route.params.id === 'string') {
|
||||
params.set('server_id', route.params.id)
|
||||
const rawServerId = route.params.id
|
||||
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
|
||||
if (route.path.startsWith('/hosting/manage/') && typeof serverId === 'string') {
|
||||
params.set('server_id', serverId)
|
||||
}
|
||||
const query = params.size > 0 ? `?${params.toString()}` : ''
|
||||
|
||||
@@ -686,69 +740,6 @@ async function fetchIntercomToken() {
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function bootIntercom() {
|
||||
if (
|
||||
intercomBooting ||
|
||||
intercomBooted ||
|
||||
!hostingRouteActive.value ||
|
||||
!credentials.value?.session
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
intercomBooting = true
|
||||
console.debug('[APP][INTERCOM] initializing secure support chat')
|
||||
try {
|
||||
const { token } = await fetchIntercomToken()
|
||||
Intercom({
|
||||
app_id: 'ykeritl9',
|
||||
intercom_user_jwt: token,
|
||||
session_duration: 1000 * 60 * 60 * 24,
|
||||
alignment: 'right',
|
||||
horizontal_padding: intercomBubblePosition.value.horizontalPadding,
|
||||
vertical_padding: intercomBubblePosition.value.verticalPadding,
|
||||
})
|
||||
intercomBooted = true
|
||||
} catch (error) {
|
||||
console.warn('[APP][INTERCOM] failed to initialize secure support chat', error)
|
||||
} finally {
|
||||
intercomBooting = false
|
||||
}
|
||||
}
|
||||
|
||||
function shutdownHostingIntercom() {
|
||||
if (!intercomBooted && !intercomBooting) return
|
||||
shutdownIntercom()
|
||||
intercomBooting = false
|
||||
intercomBooted = false
|
||||
}
|
||||
|
||||
watch(
|
||||
intercomBubblePosition,
|
||||
(position) => {
|
||||
updateIntercomBubbleStyles(position)
|
||||
if (intercomBooted) {
|
||||
window.Intercom?.('update', {
|
||||
horizontal_padding: position.horizontalPadding,
|
||||
vertical_padding: position.verticalPadding,
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[hostingRouteActive, credentials],
|
||||
([active]) => {
|
||||
if (active) {
|
||||
void bootIntercom()
|
||||
} else {
|
||||
shutdownHostingIntercom()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(showAd, () => {
|
||||
if (!showAd.value) {
|
||||
hide_ads_window(true)
|
||||
@@ -776,19 +767,116 @@ const accounts = ref(null)
|
||||
provide('accountsCard', accounts)
|
||||
|
||||
command_listener(handleCommand)
|
||||
notification_listener(handleLiveNotification)
|
||||
|
||||
async function markLiveNotificationRead(notification) {
|
||||
try {
|
||||
await tauriApiClient.labrinth.notifications_v2.markAsRead(notification.id)
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthApiError && error.statusCode === 404) {
|
||||
console.warn(`notification ${notification.id} could not be marked as read`, error)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function respondToServerInvite(notification, action) {
|
||||
const serverId = notification.body?.server_id
|
||||
if (typeof serverId !== 'string') {
|
||||
throw new Error('Missing server ID for invite notification.')
|
||||
}
|
||||
|
||||
await tauriApiClient.request(`/servers/${serverId}/invites/${action}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
})
|
||||
await markLiveNotificationRead(notification)
|
||||
|
||||
return serverId
|
||||
}
|
||||
|
||||
async function acceptServerInviteNotification(notification) {
|
||||
try {
|
||||
const serverId = await respondToServerInvite(notification, 'accept')
|
||||
await router.push(`/hosting/manage/${encodeURIComponent(serverId)}`)
|
||||
queryClient.invalidateQueries({ queryKey: ['servers'] })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function declineServerInviteNotification(notification) {
|
||||
try {
|
||||
await respondToServerInvite(notification, 'decline')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function openServerInviteInviterProfile(inviterName) {
|
||||
if (!inviterName) return
|
||||
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
|
||||
}
|
||||
|
||||
async function handleLiveNotification(notification) {
|
||||
if (notification?.body?.type !== 'server_invite' || notification.read) return
|
||||
if (displayedServerInviteNotifications.has(notification.id)) return
|
||||
|
||||
displayedServerInviteNotifications.add(notification.id)
|
||||
|
||||
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
|
||||
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCommand(e) {
|
||||
if (!e) return
|
||||
|
||||
if (e.event === 'RunMRPack') {
|
||||
// RunMRPack should directly install a local mrpack given a path
|
||||
if (e.path.endsWith('.mrpack')) {
|
||||
await create_profile_and_install_from_file(e.path, (createProfile, fileName) =>
|
||||
unknownPackWarningModal.value?.show(createProfile, fileName),
|
||||
).catch(handleError)
|
||||
const location = { type: 'fromFile', path: e.path }
|
||||
const preview = await install_get_modpack_preview(location).catch(handleError)
|
||||
if (preview?.unknownFile) {
|
||||
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,
|
||||
)
|
||||
} else {
|
||||
await install_create_modpack_instance(location).catch(handleError)
|
||||
}
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
}
|
||||
} else if (e.event === 'LaunchInstance') {
|
||||
if (e.server) {
|
||||
await start_join_server(e.id, e.server).catch(handleError)
|
||||
} else if (e.singleplayer_world) {
|
||||
await start_join_singleplayer_world(e.id, e.singleplayer_world).catch(handleError)
|
||||
} else {
|
||||
await run(e.id).catch(handleError)
|
||||
}
|
||||
} else if (e.event === 'InstallServer') {
|
||||
await router.push(`/project/${e.id}`)
|
||||
await playServerProject(e.id).catch(handleError)
|
||||
@@ -809,20 +897,21 @@ async function handleCommand(e) {
|
||||
}
|
||||
|
||||
const appUpdateDownload = {
|
||||
progress: ref(0),
|
||||
progress: appUpdateState.progress,
|
||||
version: ref(),
|
||||
}
|
||||
let unlistenUpdateDownload
|
||||
|
||||
const downloadProgress = computed(() => appUpdateDownload.progress.value)
|
||||
const downloadPercent = computed(() => Math.trunc(appUpdateDownload.progress.value * 100))
|
||||
|
||||
const metered = ref(true)
|
||||
const finishedDownloading = ref(false)
|
||||
const restarting = ref(false)
|
||||
const availableUpdate = ref(null)
|
||||
const updateSize = ref(null)
|
||||
const updatesEnabled = ref(true)
|
||||
const {
|
||||
metered,
|
||||
finishedDownloading,
|
||||
downloading,
|
||||
restarting,
|
||||
availableUpdate,
|
||||
updateSize,
|
||||
updatesEnabled,
|
||||
} = appUpdateState
|
||||
let delayedUpdatePopupTimeout = null
|
||||
|
||||
const updatePopupMessages = defineMessages({
|
||||
updateAvailable: {
|
||||
@@ -833,11 +922,6 @@ const updatePopupMessages = defineMessages({
|
||||
id: 'app.update-popup.download-complete',
|
||||
defaultMessage: 'Download complete',
|
||||
},
|
||||
body: {
|
||||
id: 'app.update-popup.body',
|
||||
defaultMessage:
|
||||
'Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App.',
|
||||
},
|
||||
meteredBody: {
|
||||
id: 'app.update-popup.body.metered',
|
||||
defaultMessage: `Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it.`,
|
||||
@@ -853,7 +937,7 @@ const updatePopupMessages = defineMessages({
|
||||
},
|
||||
reload: {
|
||||
id: 'app.update-popup.reload',
|
||||
defaultMessage: 'Reload',
|
||||
defaultMessage: 'Reload to update',
|
||||
},
|
||||
download: {
|
||||
id: 'app.update-popup.download',
|
||||
@@ -865,6 +949,106 @@ const updatePopupMessages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
function clearDelayedUpdatePopup() {
|
||||
if (delayedUpdatePopupTimeout !== null) {
|
||||
clearTimeout(delayedUpdatePopupTimeout)
|
||||
delayedUpdatePopupTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentUpdatePromptStage() {
|
||||
return finishedDownloading.value ? 'downloaded' : 'available'
|
||||
}
|
||||
|
||||
function scheduleDelayedUpdatePopup() {
|
||||
clearDelayedUpdatePopup()
|
||||
|
||||
const version = availableUpdate.value?.version
|
||||
if (!version) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextPopupTime = getNextAppUpdatePopupTime(version, getCurrentUpdatePromptStage())
|
||||
if (nextPopupTime === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const delay = nextPopupTime - Date.now()
|
||||
if (delay <= 0) {
|
||||
showDelayedUpdatePopup()
|
||||
return
|
||||
}
|
||||
|
||||
delayedUpdatePopupTimeout = setTimeout(showDelayedUpdatePopup, Math.min(delay, 2_147_483_647))
|
||||
}
|
||||
|
||||
function showDelayedUpdatePopup() {
|
||||
const update = availableUpdate.value
|
||||
if (!update) {
|
||||
return
|
||||
}
|
||||
|
||||
const stage = getCurrentUpdatePromptStage()
|
||||
const nextPopupTime = getNextAppUpdatePopupTime(update.version, stage)
|
||||
if (nextPopupTime === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Date.now() < nextPopupTime) {
|
||||
scheduleDelayedUpdatePopup()
|
||||
return
|
||||
}
|
||||
|
||||
if (metered.value && !finishedDownloading.value) {
|
||||
addPopupNotification({
|
||||
title: formatMessage(updatePopupMessages.updateAvailable),
|
||||
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
|
||||
type: 'info',
|
||||
autoCloseMs: null,
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.download, {
|
||||
size: formatBytes(updateSize.value ?? 0),
|
||||
}),
|
||||
action: () => downloadAvailableAppUpdate(),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.changelog),
|
||||
action: () => openAppUpdateChangelog(),
|
||||
keepOpen: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
} else if (finishedDownloading.value) {
|
||||
addPopupNotification({
|
||||
title: formatMessage(updatePopupMessages.downloadComplete),
|
||||
text: formatMessage(updatePopupMessages.downloadedBody, {
|
||||
version: update.version,
|
||||
}),
|
||||
type: 'success',
|
||||
autoCloseMs: null,
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.reload),
|
||||
action: () => installAvailableAppUpdate(),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.changelog),
|
||||
action: () => openAppUpdateChangelog(),
|
||||
keepOpen: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
scheduleDelayedUpdatePopup()
|
||||
return
|
||||
}
|
||||
|
||||
markAppUpdatePopupShown(update.version, stage)
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
if (!(await areUpdatesEnabled())) {
|
||||
console.log('Skipping update check as updates are disabled in this build or environment')
|
||||
@@ -888,11 +1072,15 @@ async function checkUpdates() {
|
||||
|
||||
if (isExistingUpdate) {
|
||||
console.log('Update is already known')
|
||||
scheduleDelayedUpdatePopup()
|
||||
return
|
||||
}
|
||||
|
||||
appUpdateDownload.progress.value = 0
|
||||
finishedDownloading.value = false
|
||||
downloading.value = false
|
||||
updateSize.value = null
|
||||
availableUpdate.value = update
|
||||
|
||||
console.log(`Update ${update.version} is available.`)
|
||||
|
||||
@@ -902,34 +1090,11 @@ async function checkUpdates() {
|
||||
downloadUpdate(update)
|
||||
} else {
|
||||
console.log(`Metered connection detected, not auto-downloading update.`)
|
||||
getUpdateSize(update.rid).then((size) => {
|
||||
updateSize.value = size
|
||||
addPopupNotification({
|
||||
title: formatMessage(updatePopupMessages.updateAvailable),
|
||||
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
|
||||
type: 'info',
|
||||
autoCloseMs: null,
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.download, {
|
||||
size: formatBytes(updateSize.value ?? 0),
|
||||
}),
|
||||
action: () => downloadAvailableUpdate(),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.changelog),
|
||||
action: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
|
||||
keepOpen: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
markAppUpdateActionable(update.version)
|
||||
scheduleDelayedUpdatePopup()
|
||||
}
|
||||
|
||||
getUpdateSize(update.rid).then((size) => (updateSize.value = size))
|
||||
|
||||
availableUpdate.value = update
|
||||
}
|
||||
|
||||
await performCheck()
|
||||
@@ -951,12 +1116,17 @@ async function checkLinuxUpdates() {
|
||||
const latestVersion = updates?.version
|
||||
|
||||
if (latestVersion && latestVersion !== currentVersion) {
|
||||
addPopupNotification({
|
||||
title: formatMessage(updatePopupMessages.updateAvailable),
|
||||
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
|
||||
type: 'info',
|
||||
autoCloseMs: null,
|
||||
})
|
||||
markAppUpdateActionable(latestVersion)
|
||||
const nextPopupTime = getNextAppUpdatePopupTime(latestVersion)
|
||||
if (nextPopupTime !== null && Date.now() >= nextPopupTime) {
|
||||
addPopupNotification({
|
||||
title: formatMessage(updatePopupMessages.updateAvailable),
|
||||
text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }),
|
||||
type: 'info',
|
||||
autoCloseMs: null,
|
||||
})
|
||||
markAppUpdatePopupShown(latestVersion)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check for updates:', e)
|
||||
@@ -970,60 +1140,88 @@ async function downloadAvailableUpdate() {
|
||||
async function downloadUpdate(versionToDownload) {
|
||||
if (!versionToDownload) {
|
||||
handleError(`Failed to download update: no version available`)
|
||||
return
|
||||
}
|
||||
|
||||
if (appUpdateDownload.progress.value !== 0) {
|
||||
if (downloading.value || appUpdateDownload.progress.value !== 0) {
|
||||
console.error(`Update ${versionToDownload.version} already downloading`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Downloading update ${versionToDownload.version}`)
|
||||
downloading.value = true
|
||||
|
||||
try {
|
||||
enqueueUpdateForInstallation(versionToDownload.rid).then(() => {
|
||||
finishedDownloading.value = true
|
||||
unlistenUpdateDownload?.().then(() => {
|
||||
unlistenUpdateDownload = null
|
||||
enqueueUpdateForInstallation(versionToDownload.rid)
|
||||
.then(() => {
|
||||
downloading.value = false
|
||||
finishedDownloading.value = true
|
||||
unlistenUpdateDownload?.().then(() => {
|
||||
unlistenUpdateDownload = null
|
||||
})
|
||||
console.log('Finished downloading!')
|
||||
markAppUpdateActionable(versionToDownload.version, 'downloaded')
|
||||
scheduleDelayedUpdatePopup()
|
||||
})
|
||||
console.log('Finished downloading!')
|
||||
|
||||
addPopupNotification({
|
||||
title: formatMessage(updatePopupMessages.downloadComplete),
|
||||
text: formatMessage(updatePopupMessages.downloadedBody, {
|
||||
version: versionToDownload.version,
|
||||
}),
|
||||
type: 'success',
|
||||
autoCloseMs: null,
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.reload),
|
||||
action: () => installUpdate(),
|
||||
color: 'brand',
|
||||
},
|
||||
{
|
||||
label: formatMessage(updatePopupMessages.changelog),
|
||||
action: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
|
||||
keepOpen: true,
|
||||
},
|
||||
],
|
||||
.catch((e) => {
|
||||
downloading.value = false
|
||||
appUpdateDownload.progress.value = 0
|
||||
handleError(e)
|
||||
})
|
||||
})
|
||||
unlistenUpdateDownload = await subscribeToDownloadProgress(
|
||||
appUpdateDownload,
|
||||
versionToDownload.version,
|
||||
)
|
||||
} catch (e) {
|
||||
downloading.value = false
|
||||
appUpdateDownload.progress.value = 0
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
restarting.value = true
|
||||
|
||||
try {
|
||||
await setRestartAfterPendingUpdate(true)
|
||||
} catch (e) {
|
||||
restarting.value = false
|
||||
handleError(e)
|
||||
return
|
||||
}
|
||||
setTimeout(async () => {
|
||||
await handleClose()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
setAppUpdateActions({
|
||||
download: downloadAvailableUpdate,
|
||||
install: installUpdate,
|
||||
changelog: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
|
||||
})
|
||||
|
||||
async function openModrinthProjectLinkInApp(parsed) {
|
||||
const { slug, pathSuffix, url } = parsed
|
||||
const loadToken = loading.begin()
|
||||
try {
|
||||
const { id } = await tauriApiClient.labrinth.projects_v2.check(slug)
|
||||
const query = mergeUrlQuery(route.query, url)
|
||||
await router.push({
|
||||
path: `/project/${id}${pathSuffix}`,
|
||||
query,
|
||||
hash: url.hash || undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof ModrinthApiError && err.statusCode === 404) {
|
||||
openUrl(url.href)
|
||||
} else {
|
||||
handleError(err)
|
||||
}
|
||||
} finally {
|
||||
loading.end(loadToken)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
let target = e.target
|
||||
while (target != null) {
|
||||
@@ -1036,7 +1234,12 @@ function handleClick(e) {
|
||||
!target.href.startsWith('https://tauri.localhost') &&
|
||||
!target.href.startsWith('http://tauri.localhost')
|
||||
) {
|
||||
openUrl(target.href)
|
||||
const parsed = parseModrinthLink(target.href)
|
||||
if (target.target !== '_blank' && parsed) {
|
||||
void openModrinthProjectLinkInApp(parsed)
|
||||
} else {
|
||||
openUrl(target.href)
|
||||
}
|
||||
}
|
||||
e.preventDefault()
|
||||
break
|
||||
@@ -1138,12 +1341,11 @@ async function processPendingSurveys() {
|
||||
const creds = await getCreds().catch(handleError)
|
||||
const userId = creds?.user_id
|
||||
|
||||
const instances = await list().catch(handleError)
|
||||
const isActivePlayer =
|
||||
instances.findIndex(
|
||||
(instance) =>
|
||||
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
|
||||
) >= 0
|
||||
const instances = (await list().catch(handleError)) ?? []
|
||||
const isActivePlayer = instances.some(
|
||||
(instance) =>
|
||||
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
|
||||
)
|
||||
|
||||
let surveys = []
|
||||
try {
|
||||
@@ -1230,7 +1432,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<CompassIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
@@ -1266,33 +1468,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<PlusIcon />
|
||||
</NavButton>
|
||||
<div class="flex flex-grow"></div>
|
||||
<Transition name="nav-button-animated">
|
||||
<div v-if="availableUpdate && !restarting && (finishedDownloading || metered)">
|
||||
<NavButton
|
||||
v-tooltip.right="
|
||||
formatMessage(
|
||||
finishedDownloading
|
||||
? messages.reloadToUpdate
|
||||
: downloadProgress === 0
|
||||
? messages.downloadUpdate
|
||||
: messages.downloadingUpdate,
|
||||
{
|
||||
percent: downloadPercent,
|
||||
},
|
||||
)
|
||||
"
|
||||
:to="finishedDownloading ? installUpdate : downloadAvailableUpdate"
|
||||
>
|
||||
<ProgressSpinner
|
||||
v-if="downloadProgress > 0 && downloadProgress < 1"
|
||||
class="text-brand"
|
||||
:progress="downloadProgress"
|
||||
/>
|
||||
<RefreshCwIcon v-else-if="finishedDownloading" class="text-brand" />
|
||||
<DownloadIcon v-else class="text-brand" />
|
||||
</NavButton>
|
||||
</div>
|
||||
</Transition>
|
||||
<NavButton
|
||||
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
|
||||
:to="() => $refs.settingsModal.show()"
|
||||
@@ -1480,6 +1655,10 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
</suspense>
|
||||
</div>
|
||||
<PrideFundraiserBanner
|
||||
v-if="prideFundraiserEnabled"
|
||||
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
/>
|
||||
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
|
||||
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
|
||||
<div class="space-y-4 flex flex-col items-center w-full">
|
||||
@@ -1536,7 +1715,22 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@go-to-instance="handleModpackDuplicateGoToInstance"
|
||||
/>
|
||||
<AddServerToInstanceModal ref="addServerToInstanceModal" />
|
||||
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
|
||||
<ContentUpdaterModal
|
||||
ref="incompatibilityWarningModal"
|
||||
mode="incompatibility-warning"
|
||||
:versions="contentInstallIncompatibilityWarningVersions"
|
||||
:current-game-version="contentInstallIncompatibilityWarningCurrentGameVersion"
|
||||
:current-loader="contentInstallIncompatibilityWarningCurrentLoader"
|
||||
current-version-id=""
|
||||
:is-app="true"
|
||||
:project-type="contentInstallIncompatibilityWarningProjectType"
|
||||
:project-icon-url="contentInstallIncompatibilityWarningProjectIconUrl"
|
||||
:project-name="contentInstallIncompatibilityWarningProjectName"
|
||||
:warning="contentInstallIncompatibilityWarningMessage"
|
||||
:action-loading="contentInstallIncompatibilityWarningInstalling"
|
||||
@update="handleContentInstallIncompatibilityWarningInstall"
|
||||
@cancel="handleContentInstallIncompatibilityWarningCancel"
|
||||
/>
|
||||
<ModpackAlreadyInstalledModal
|
||||
ref="contentInstallModpackAlreadyInstalledModal"
|
||||
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
|
||||
@@ -1567,11 +1761,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
|
||||
.app-grid-navbar {
|
||||
grid-area: nav;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.app-grid-statusbar {
|
||||
grid-area: status;
|
||||
padding-right: var(--window-controls-width, 0px);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
[data-tauri-drag-region-exclude] {
|
||||
@@ -1639,6 +1837,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
&.app-contents::before {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
*,
|
||||
:deep(*) {
|
||||
box-shadow: none !important;
|
||||
--tw-drop-shadow:;
|
||||
}
|
||||
}
|
||||
|
||||
.app-sidebar::before {
|
||||
@@ -1657,10 +1861,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.app-contents::before {
|
||||
z-index: 1;
|
||||
z-index: 30;
|
||||
content: '';
|
||||
position: fixed;
|
||||
left: var(--left-bar-width);
|
||||
@@ -1770,14 +1975,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
--os-handle-bg-active: var(--color-scrollbar) !important;
|
||||
}
|
||||
|
||||
.intercom-lightweight-app-launcher,
|
||||
.intercom-launcher-frame,
|
||||
iframe[name='intercom-launcher-frame'] {
|
||||
right: var(--app-support-launcher-right, 20px) !important;
|
||||
bottom: var(--app-support-launcher-bottom, 20px) !important;
|
||||
z-index: 9 !important;
|
||||
}
|
||||
|
||||
.mac {
|
||||
.app-grid-statusbar {
|
||||
padding-left: 5rem;
|
||||
|
||||
@@ -24,7 +24,8 @@ 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 { duplicate, remove } from '@/helpers/profile.js'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { remove } from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
@@ -48,21 +49,21 @@ const instanceComponents = ref(null)
|
||||
const currentDeleteInstance = ref(null)
|
||||
const confirmModal = ref(null)
|
||||
|
||||
async function deleteProfile() {
|
||||
async function deleteInstance() {
|
||||
if (currentDeleteInstance.value) {
|
||||
instanceComponents.value = instanceComponents.value.filter(
|
||||
(x) => x.instance.path !== currentDeleteInstance.value,
|
||||
(x) => x.instance.id !== currentDeleteInstance.value,
|
||||
)
|
||||
await remove(currentDeleteInstance.value).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateProfile(p) {
|
||||
await duplicate(p).catch(handleError)
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleRightClick = (event, profilePathId) => {
|
||||
const item = instanceComponents.value.find((x) => x.instance.path === profilePathId)
|
||||
const handleRightClick = (event, instanceId) => {
|
||||
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
|
||||
const baseOptions = [
|
||||
{ name: 'add_content' },
|
||||
{ type: 'divider' },
|
||||
@@ -114,16 +115,16 @@ const handleOptionsClick = async (args) => {
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.instance.install_stage == 'installed')
|
||||
await duplicateProfile(args.item.instance.path)
|
||||
await duplicateInstance(args.item.instance.id)
|
||||
break
|
||||
case 'open':
|
||||
await args.item.openFolder()
|
||||
break
|
||||
case 'copy':
|
||||
await navigator.clipboard.writeText(args.item.instance.path)
|
||||
await navigator.clipboard.writeText(args.item.instance.id)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item.instance.path
|
||||
currentDeleteInstance.value = args.item.instance.id
|
||||
confirmModal.value.show()
|
||||
break
|
||||
}
|
||||
@@ -321,13 +322,13 @@ const filteredResults = computed(() => {
|
||||
<Instance
|
||||
v-for="instance in instanceSection.value"
|
||||
ref="instanceComponents"
|
||||
:key="instance.path + instance.install_stage"
|
||||
:key="instance.id + instance.install_stage"
|
||||
:instance="instance"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.id)"
|
||||
/>
|
||||
</section>
|
||||
</Accordion>
|
||||
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteProfile" />
|
||||
<ConfirmDeleteInstanceModal ref="confirmModal" @delete="deleteInstance" />
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
|
||||
@@ -21,9 +21,10 @@ 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 { get_by_profile_path } from '@/helpers/process.js'
|
||||
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
|
||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
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'
|
||||
|
||||
@@ -60,14 +61,14 @@ const deleteConfirmModal = ref(null)
|
||||
|
||||
const currentDeleteInstance = ref(null)
|
||||
|
||||
async function deleteProfile() {
|
||||
async function deleteInstance() {
|
||||
if (currentDeleteInstance.value) {
|
||||
await remove(currentDeleteInstance.value).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateProfile(p) {
|
||||
await duplicate(p).catch(handleError)
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleInstanceRightClick = async (event, passedInstance) => {
|
||||
@@ -85,7 +86,7 @@ const handleInstanceRightClick = async (event, passedInstance) => {
|
||||
},
|
||||
]
|
||||
|
||||
const runningProcesses = await get_by_profile_path(passedInstance.path).catch(handleError)
|
||||
const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError)
|
||||
|
||||
const options =
|
||||
runningProcesses.length > 0
|
||||
@@ -126,16 +127,14 @@ const handleProjectClick = (event, passedInstance) => {
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
await run(args.item.path).catch((err) =>
|
||||
handleSevereError(err, { profilePath: args.item.path }),
|
||||
)
|
||||
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.path).catch(handleError)
|
||||
await kill(args.item.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
@@ -144,26 +143,26 @@ const handleOptionsClick = async (args) => {
|
||||
case 'add_content':
|
||||
await router.push({
|
||||
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: args.item.path },
|
||||
query: { i: args.item.id },
|
||||
})
|
||||
break
|
||||
case 'edit':
|
||||
await router.push({
|
||||
path: `/instance/${encodeURIComponent(args.item.path)}/`,
|
||||
path: `/instance/${encodeURIComponent(args.item.id)}`,
|
||||
})
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path)
|
||||
if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item.path
|
||||
currentDeleteInstance.value = args.item.id
|
||||
deleteConfirmModal.value.show()
|
||||
break
|
||||
case 'open_folder':
|
||||
await showProfileInFolder(args.item.path)
|
||||
await showInstanceInFolder(args.item.id)
|
||||
break
|
||||
case 'copy_path':
|
||||
await navigator.clipboard.writeText(args.item.path)
|
||||
await navigator.clipboard.writeText(args.item.id)
|
||||
break
|
||||
case 'install': {
|
||||
await installVersion(
|
||||
@@ -239,7 +238,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteProfile" />
|
||||
<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">
|
||||
@@ -256,7 +255,7 @@ onUnmounted(() => {
|
||||
0,
|
||||
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
|
||||
)"
|
||||
:key="row.label + instance.path"
|
||||
:key="row.label + instance.id"
|
||||
:instance="instance"
|
||||
:compact="row.compact"
|
||||
:first="instanceIndex === 0"
|
||||
|
||||
@@ -150,7 +150,10 @@ async function refreshValues() {
|
||||
if (equippedSkin.value) {
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
||||
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
|
||||
headUrlCache.value = new Map(headUrlCache.value).set(
|
||||
equippedSkin.value.texture_key,
|
||||
headUrl,
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
@@ -160,12 +163,24 @@ async function refreshValues() {
|
||||
}
|
||||
}
|
||||
|
||||
async function setEquippedSkin(skin: Skin) {
|
||||
equippedSkin.value = skin
|
||||
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(skin)
|
||||
headUrlCache.value = new Map(headUrlCache.value).set(skin.texture_key, headUrl)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function setLoginDisabled(value: boolean) {
|
||||
loginDisabled.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
setEquippedSkin,
|
||||
setLoginDisabled,
|
||||
loginDisabled,
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { add_project_from_path } from '@/helpers/profile.js'
|
||||
import { add_project_from_path } from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
@@ -22,14 +22,14 @@ const handleAddContentFromFile = async () => {
|
||||
if (!newProject) return
|
||||
|
||||
for (const project of newProject) {
|
||||
await add_project_from_path(props.instance.path, project.path ?? project).catch(handleError)
|
||||
await add_project_from_path(props.instance.id, project.path ?? project).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchContent = async () => {
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
|
||||
query: { i: props.instance.path },
|
||||
query: { i: props.instance.id },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<ButtonStyled
|
||||
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts"
|
||||
color="brand"
|
||||
@@ -14,6 +14,31 @@
|
||||
<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>
|
||||
<div
|
||||
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
|
||||
>
|
||||
@@ -22,28 +47,28 @@
|
||||
<div class="text-contrast flex items-center gap-2">
|
||||
<router-link
|
||||
v-tooltip="formatMessage(messages.viewInstance)"
|
||||
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
|
||||
:to="`/instance/${encodeURIComponent(selectedProcess.instance.id)}`"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ selectedProcess.profile.name }}
|
||||
{{ selectedProcess.instance.name }}
|
||||
</router-link>
|
||||
<Dropdown
|
||||
v-if="currentProcesses.length > 1"
|
||||
placement="bottom"
|
||||
:triggers="['click']"
|
||||
:hide-triggers="['click']"
|
||||
@show="showProfiles = true"
|
||||
@hide="showProfiles = false"
|
||||
@show="showInstances = true"
|
||||
@hide="showInstances = false"
|
||||
>
|
||||
<ButtonStyled type="transparent" circular size="small">
|
||||
<button
|
||||
v-tooltip="
|
||||
showProfiles
|
||||
showInstances
|
||||
? formatMessage(messages.hideMoreRunningInstances)
|
||||
: formatMessage(messages.showMoreRunningInstances)
|
||||
"
|
||||
>
|
||||
<DropdownIcon :class="{ 'rotate-180': !!showProfiles }" />
|
||||
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
@@ -68,7 +93,7 @@
|
||||
>
|
||||
<OnlineIndicatorIcon />
|
||||
<span class="mr-auto text-contrast flex items-center gap-2">
|
||||
{{ process.profile.name }}
|
||||
{{ process.instance.name }}
|
||||
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
|
||||
</span>
|
||||
</button>
|
||||
@@ -82,7 +107,7 @@
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewLogs)"
|
||||
class="active:scale-95 flex"
|
||||
@click.stop="goToTerminal(process.profile.path)"
|
||||
@click.stop="goToTerminal(process.instance.id)"
|
||||
>
|
||||
<TerminalSquareIcon class="text-secondary size-5" />
|
||||
</button>
|
||||
@@ -119,6 +144,7 @@ import {
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
RefreshCwIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
@@ -133,17 +159,24 @@ import {
|
||||
type PopupNotificationProgressItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||
import { get_many as getInstances } from '@/helpers/profile.js'
|
||||
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()
|
||||
@@ -151,12 +184,12 @@ const { formatMessage } = useVIntl()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const showProfiles = ref(false)
|
||||
const showInstances = ref(false)
|
||||
|
||||
interface RunningProcess {
|
||||
uuid: string
|
||||
profile_path: string
|
||||
profile: GameInstance
|
||||
instance_id: string
|
||||
instance: GameInstance
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -208,8 +241,96 @@ 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>()
|
||||
|
||||
@@ -217,22 +338,22 @@ const refresh = async () => {
|
||||
const processes = ((await getRunningProcesses().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})) ?? []) as Array<{ uuid: string; profile_path: string }>
|
||||
const paths = processes.map((process) => process.profile_path)
|
||||
const profiles: GameInstance[] = await getInstances(paths).catch((error) => {
|
||||
})) ?? []) as Array<{ uuid: string; instance_id: string }>
|
||||
const instanceIds = processes.map((process) => process.instance_id)
|
||||
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
|
||||
currentProcesses.value = processes
|
||||
.map((process) => {
|
||||
const profile = profiles.find((item) => process.profile_path === item.path)
|
||||
if (!profile) {
|
||||
const instance = instances.find((item) => process.instance_id === item.id)
|
||||
if (!instance) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...process,
|
||||
profile,
|
||||
instance,
|
||||
}
|
||||
})
|
||||
.filter((process): process is RunningProcess => process !== null)
|
||||
@@ -265,8 +386,8 @@ const stop = async (process: RunningProcess) => {
|
||||
await killProcess(process.uuid).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: process.profile.loader,
|
||||
game_version: process.profile.game_version,
|
||||
loader: process.instance.loader,
|
||||
game_version: process.instance.game_version,
|
||||
source: 'AppBar',
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -275,15 +396,16 @@ const stop = async (process: RunningProcess) => {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function goToTerminal(path?: string) {
|
||||
const selectedPath = path ?? selectedProcess.value?.profile.path
|
||||
if (!selectedPath) {
|
||||
function goToTerminal(instanceId?: string) {
|
||||
const selectedInstanceId = instanceId ?? selectedProcess.value?.instance.id
|
||||
if (!selectedInstanceId) {
|
||||
return
|
||||
}
|
||||
router.push(`/instance/${encodeURIComponent(selectedPath)}/logs`)
|
||||
router.push(`/instance/${encodeURIComponent(selectedInstanceId)}/logs`)
|
||||
}
|
||||
|
||||
const currentLoadingBars = ref<LoadingBar[]>([])
|
||||
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
|
||||
const notificationId = ref<string | number | null>(null)
|
||||
const dismissed = ref(false)
|
||||
|
||||
@@ -299,8 +421,17 @@ function getLoadingProgress(loadingBar: LoadingBar): number {
|
||||
}
|
||||
|
||||
function getLoadingText(loadingBar: LoadingBar): string {
|
||||
const percent = Math.floor(getLoadingProgress(loadingBar) * 100)
|
||||
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
|
||||
return loadingBar.message ?? ''
|
||||
}
|
||||
|
||||
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
if (!icon) {
|
||||
return null
|
||||
}
|
||||
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
|
||||
return icon
|
||||
}
|
||||
return convertFileSrc(icon)
|
||||
}
|
||||
|
||||
function getNotification(): PopupNotification | null {
|
||||
@@ -322,24 +453,33 @@ function removeNotification(): void {
|
||||
}
|
||||
|
||||
function buildDownloadItems(): PopupNotificationProgressItem[] {
|
||||
return currentLoadingBars.value.map((bar) => ({
|
||||
id: getLoadingBarKey(bar),
|
||||
title: bar.title ?? '',
|
||||
text: getLoadingText(bar),
|
||||
progress: getLoadingProgress(bar),
|
||||
waiting: !bar.total || bar.total <= 0,
|
||||
}))
|
||||
return [
|
||||
...installJobNotifications.progressItems.value,
|
||||
...currentLoadingBars.value.map((bar) => ({
|
||||
id: getLoadingBarKey(bar),
|
||||
title: bar.title ?? '',
|
||||
text: getLoadingText(bar),
|
||||
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
|
||||
progress: getLoadingProgress(bar),
|
||||
waiting: !bar.total || bar.total <= 0,
|
||||
progressType: 'percentage',
|
||||
progressCurrent: bar.current,
|
||||
progressTotal: bar.total,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
const hasVisibleActiveDownloadToasts = computed(() => !!getNotification())
|
||||
const hasActiveLoadingBars = computed(() => currentLoadingBars.value.length > 0)
|
||||
const hasActiveLoadingBars = computed(
|
||||
() => currentLoadingBars.value.length > 0 || installJobNotifications.active.value,
|
||||
)
|
||||
|
||||
function updateNotification(resummon = false): void {
|
||||
if (resummon) {
|
||||
dismissed.value = false
|
||||
}
|
||||
|
||||
if (currentLoadingBars.value.length === 0) {
|
||||
if (currentLoadingBars.value.length === 0 && !installJobNotifications.active.value) {
|
||||
removeNotification()
|
||||
dismissed.value = false
|
||||
return
|
||||
@@ -358,17 +498,23 @@ function updateNotification(resummon = false): void {
|
||||
const progressItems = buildDownloadItems()
|
||||
|
||||
if (notif) {
|
||||
notif.title = formatMessage(messages.downloads)
|
||||
notif.title = installJobNotifications.active.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads)
|
||||
notif.text = undefined
|
||||
notif.progressItems = progressItems
|
||||
notif.buttons = installJobNotifications.buttons.value
|
||||
notif.progress = undefined
|
||||
notif.waiting = undefined
|
||||
} else {
|
||||
notif = popupNotificationManager.addPopupNotification({
|
||||
title: formatMessage(messages.downloads),
|
||||
title: installJobNotifications.active.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads),
|
||||
type: 'download',
|
||||
autoCloseMs: null,
|
||||
progressItems,
|
||||
buttons: installJobNotifications.buttons.value,
|
||||
})
|
||||
notificationId.value = notif.id
|
||||
}
|
||||
@@ -381,8 +527,8 @@ function formatLoadingBars(loadingBar: LoadingBar): LoadingBar {
|
||||
version: formatted.bar_type.version,
|
||||
})
|
||||
}
|
||||
if (formatted.bar_type?.profile_path) {
|
||||
formatted.title = formatted.bar_type.profile_path
|
||||
if (formatted.bar_type?.instance_id) {
|
||||
formatted.title = formatted.bar_type.instance_name ?? formatted.bar_type.instance_id
|
||||
}
|
||||
if (formatted.bar_type?.pack_name) {
|
||||
formatted.title = formatted.bar_type.pack_name
|
||||
@@ -398,7 +544,43 @@ async function refreshLoadingBars() {
|
||||
|
||||
currentLoadingBars.value = Object.values(bars)
|
||||
.map(formatLoadingBars)
|
||||
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
|
||||
.filter(
|
||||
(bar) =>
|
||||
bar?.bar_type?.type !== 'launcher_update' &&
|
||||
![
|
||||
'java_download',
|
||||
'pack_file_download',
|
||||
'pack_download',
|
||||
'minecraft_download',
|
||||
'copy_instance',
|
||||
].includes(bar?.bar_type?.type ?? ''),
|
||||
)
|
||||
|
||||
const instanceIds = Array.from(
|
||||
new Set(
|
||||
currentLoadingBars.value
|
||||
.map((bar) => bar.bar_type?.instance_id)
|
||||
.filter((instanceId): instanceId is string => !!instanceId),
|
||||
),
|
||||
)
|
||||
const instances = instanceIds.length
|
||||
? await getInstances(instanceIds).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
: []
|
||||
const instanceIconUrls = new Map(
|
||||
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
|
||||
)
|
||||
currentLoadingBarIconUrls.value = Object.fromEntries(
|
||||
currentLoadingBars.value.map((bar) => {
|
||||
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
|
||||
const instanceIconUrl = bar.bar_type?.instance_id
|
||||
? instanceIconUrls.get(bar.bar_type.instance_id)
|
||||
: null
|
||||
return [getLoadingBarKey(bar), barIconUrl ?? instanceIconUrl ?? null]
|
||||
}),
|
||||
)
|
||||
|
||||
currentLoadingBars.value.sort((a, b) => {
|
||||
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
|
||||
@@ -409,6 +591,12 @@ async function refreshLoadingBars() {
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
const installJobNotifications = await useInstallJobNotifications({
|
||||
router,
|
||||
handleError,
|
||||
onChange: updateNotification,
|
||||
})
|
||||
|
||||
await refreshLoadingBars()
|
||||
|
||||
const unlistenLoading = await loading_listener(async () => {
|
||||
@@ -430,5 +618,21 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
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>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
|
||||
<hr v-if="option.type === 'divider'" class="divider" />
|
||||
<div
|
||||
v-else-if="!(isLinkedData(item) && option.name === `add_content`)"
|
||||
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
|
||||
class="item clickable"
|
||||
:class="[option.color ?? 'base']"
|
||||
>
|
||||
@@ -64,10 +64,10 @@ defineExpose({
|
||||
},
|
||||
})
|
||||
|
||||
const isLinkedData = (item) => {
|
||||
if (item.instance != undefined && item.instance.linked_data) {
|
||||
const isInstanceLink = (item) => {
|
||||
if (item.instance != undefined && item.instance.link) {
|
||||
return true
|
||||
} else if (item != undefined && item.linked_data) {
|
||||
} else if (item != undefined && item.link) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ChatIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { install } from '@/helpers/profile.js'
|
||||
import { install_existing_instance } from '@/helpers/install'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
@@ -72,7 +72,7 @@ defineExpose({
|
||||
title.value = 'No loader selected'
|
||||
errorType.value = 'no_loader_version'
|
||||
supportLink.value = 'https://support.modrinth.com'
|
||||
metadata.value.profilePath = context.profilePath
|
||||
metadata.value.instanceId = context.instanceId
|
||||
} else if (source === 'state_init') {
|
||||
title.value = 'Error initializing Modrinth App'
|
||||
errorType.value = 'state_init'
|
||||
@@ -125,7 +125,7 @@ const loadingRepair = ref(false)
|
||||
async function repairInstance() {
|
||||
loadingRepair.value = true
|
||||
try {
|
||||
await install(metadata.value.profilePath, false)
|
||||
await install_existing_instance(metadata.value.instanceId, false)
|
||||
errorModal.value.hide()
|
||||
} catch (err) {
|
||||
handleSevereError(err)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { WrenchIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
@@ -9,12 +10,12 @@ import {
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { PackageIcon, VersionIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
|
||||
import { export_instance_mrpack, get_pack_export_candidates } from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -40,9 +41,13 @@ const messages = defineMessages({
|
||||
},
|
||||
selectFilesLabel: {
|
||||
id: 'app.export-modal.select-files-label',
|
||||
defaultMessage: 'Select files and folders to include in pack',
|
||||
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({
|
||||
@@ -65,13 +70,12 @@ const exportDescription = ref('')
|
||||
const versionInput = ref('1.0.0')
|
||||
const files = ref([])
|
||||
const folders = ref([])
|
||||
const showingFiles = ref(false)
|
||||
|
||||
const initFiles = async () => {
|
||||
const newFolders = new Map()
|
||||
const sep = '/'
|
||||
files.value = []
|
||||
await get_pack_export_candidates(props.instance.path).then((filePaths) =>
|
||||
await get_pack_export_candidates(props.instance.id).then((filePaths) =>
|
||||
filePaths
|
||||
.map((folder) => ({
|
||||
path: folder,
|
||||
@@ -85,9 +89,9 @@ const initFiles = async () => {
|
||||
disabled:
|
||||
folder === 'profile.json' ||
|
||||
folder.startsWith('modrinth_logs') ||
|
||||
folder.startsWith('.fabric'),
|
||||
folder.startsWith('.fabric') ||
|
||||
folder.startsWith('__MACOSX'),
|
||||
}))
|
||||
.filter((pathData) => !pathData.path.includes('.DS_Store'))
|
||||
.forEach((pathData) => {
|
||||
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
|
||||
if (parent !== '') {
|
||||
@@ -121,15 +125,20 @@ const exportPack = async () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
const outputPath = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
const outputPath = await save({
|
||||
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
|
||||
filters: [
|
||||
{
|
||||
name: 'Modrinth Modpack',
|
||||
extensions: ['mrpack'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (outputPath) {
|
||||
export_profile_mrpack(
|
||||
props.instance.path,
|
||||
outputPath + `/${nameInput.value} ${versionInput.value}.mrpack`,
|
||||
export_instance_mrpack(
|
||||
props.instance.id,
|
||||
outputPath,
|
||||
filesToExport,
|
||||
versionInput.value,
|
||||
exportDescription.value,
|
||||
@@ -142,97 +151,91 @@ const exportPack = async () => {
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="exportModal" :header="formatMessage(messages.header)">
|
||||
<div class="modal-body">
|
||||
<div class="labeled_input">
|
||||
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="nameInput"
|
||||
:icon="PackageIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="labeled_input">
|
||||
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="versionInput"
|
||||
:icon="VersionIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<div class="flex flex-col gap-4 w-[40rem]">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="labeled_input">
|
||||
<p>{{ formatMessage(commonMessages.descriptionLabel) }}</p>
|
||||
|
||||
<p>{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
v-model="nameInput"
|
||||
:icon="PackageIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="labeled_input">
|
||||
<p>{{ formatMessage(messages.versionNumberLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="versionInput"
|
||||
:icon="VersionIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table">
|
||||
<div class="table-head">
|
||||
<div class="table-cell row-wise">
|
||||
{{ formatMessage(messages.selectFilesLabel) }}
|
||||
<ButtonStyled circular>
|
||||
<button @click="() => (showingFiles = !showingFiles)">
|
||||
<PlusIcon v-if="!showingFiles" />
|
||||
<XIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showingFiles" class="table-content">
|
||||
<div v-for="[path, children] in folders" :key="path.name" class="table-row">
|
||||
<div class="table-cell file-entry">
|
||||
<div class="file-primary">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0">{{ formatMessage(commonMessages.descriptionLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
/>
|
||||
</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)"
|
||||
:label="path.name"
|
||||
class="select-checkbox"
|
||||
: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="path.showingMore"
|
||||
class="select-checkbox dropdown"
|
||||
collapsing-toggle-style
|
||||
v-model="child.selected"
|
||||
:label="child.name"
|
||||
class="w-full px-8 py-2 hover:bg-surface-4 text-primary"
|
||||
:disabled="child.disabled"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="path.showingMore" class="file-secondary">
|
||||
<div v-for="child in children" :key="child.path" class="file-secondary-row">
|
||||
<Checkbox
|
||||
v-model="child.selected"
|
||||
:label="child.name"
|
||||
class="select-checkbox"
|
||||
:disabled="child.disabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="file in files" :key="file.path" class="table-row">
|
||||
<div class="table-cell file-entry">
|
||||
<div class="file-primary">
|
||||
<Checkbox
|
||||
v-model="file.selected"
|
||||
:label="file.name"
|
||||
:disabled="file.disabled"
|
||||
class="select-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<div class="button-row push-right">
|
||||
</Accordion>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="exportModal.hide">
|
||||
<XIcon />
|
||||
@@ -249,83 +252,3 @@ const exportPack = async () => {
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.labeled_input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.select-checkbox {
|
||||
gap: var(--gap-sm);
|
||||
|
||||
button.checkbox {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.dropdown {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.table-content {
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
border: 1px solid var(--color-bg);
|
||||
}
|
||||
|
||||
.file-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.file-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.file-secondary {
|
||||
margin-left: var(--gap-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
height: 100%;
|
||||
vertical-align: center;
|
||||
}
|
||||
|
||||
.file-secondary-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-wise {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,9 +15,10 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import { finish_install, kill, run } from '@/helpers/profile'
|
||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
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()
|
||||
@@ -54,11 +55,11 @@ const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
const router = useRouter()
|
||||
|
||||
const seeInstance = async () => {
|
||||
await router.push(`/instance/${encodeURIComponent(props.instance.path)}`)
|
||||
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
|
||||
}
|
||||
|
||||
const checkProcess = async () => {
|
||||
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
|
||||
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
}
|
||||
@@ -66,8 +67,8 @@ const checkProcess = async () => {
|
||||
const play = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
loading.value = true
|
||||
await run(props.instance.path)
|
||||
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
|
||||
await run(props.instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
@@ -82,7 +83,7 @@ const stop = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
playing.value = false
|
||||
|
||||
await kill(props.instance.path).catch(handleError)
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
@@ -94,17 +95,30 @@ const stop = async (e, context) => {
|
||||
const repair = async (e) => {
|
||||
e?.stopPropagation()
|
||||
|
||||
await finish_install(props.instance).catch(handleError)
|
||||
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 showProfileInFolder(props.instance.path)
|
||||
await showInstanceInFolder(props.instance.id)
|
||||
}
|
||||
|
||||
const addContent = async () => {
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: props.instance.path },
|
||||
query: { i: props.instance.id },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,7 +134,7 @@ defineExpose({
|
||||
const currentEvent = ref(null)
|
||||
|
||||
const unlisten = await process_listener((e) => {
|
||||
if (e.profile_path_id === props.instance.path) {
|
||||
if (e.instance_id === props.instance.id) {
|
||||
currentEvent.value = e.event
|
||||
if (e.event === 'finished') {
|
||||
playing.value = false
|
||||
@@ -142,7 +156,7 @@ onUnmounted(() => unlisten())
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:tint-by="instance.path"
|
||||
:tint-by="instance.id"
|
||||
alt="Mod card"
|
||||
/>
|
||||
<div class="h-full flex items-center font-bold text-contrast leading-normal">
|
||||
@@ -191,7 +205,7 @@ onUnmounted(() => unlisten())
|
||||
<Avatar
|
||||
size="48px"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
:tint-by="instance.path"
|
||||
:tint-by="instance.id"
|
||||
alt="Mod card"
|
||||
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const instanceLink = computed(() => {
|
||||
const base = `/instance/${encodeURIComponent(props.instance.path)}`
|
||||
const base = `/instance/${encodeURIComponent(props.instance.id)}`
|
||||
return props.backTab ? `${base}/${props.backTab}` : base
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
<template>
|
||||
<ModalWrapper ref="detectJavaModal" header="Select java version" :show-ad-on-close="false">
|
||||
<div class="auto-detect-modal">
|
||||
<div class="table">
|
||||
<div class="table-row table-head">
|
||||
<div class="table-cell table-text">Version</div>
|
||||
<div class="table-cell table-text">Path</div>
|
||||
<div class="table-cell table-text">Actions</div>
|
||||
</div>
|
||||
<div v-for="javaInstall in chosenInstallOptions" :key="javaInstall.path" class="table-row">
|
||||
<div class="table-cell table-text">
|
||||
<span>{{ javaInstall.version }}</span>
|
||||
</div>
|
||||
<div v-tooltip="javaInstall.path" class="table-cell table-text">
|
||||
<span>{{ javaInstall.path }}</span>
|
||||
</div>
|
||||
<div class="table-cell table-text manage">
|
||||
<ButtonStyled v-if="currentSelected.path === javaInstall.path">
|
||||
<button disabled><CheckIcon /> Selected</button>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
|
||||
<template #cell-version="{ value }">
|
||||
<span class="font-semibold text-primary">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-path="{ value }">
|
||||
<span v-tooltip="value" class="block truncate font-mono text-xs">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end">
|
||||
<ButtonStyled v-if="currentSelected.path === row.path">
|
||||
<button class="!shadow-none" disabled><CheckIcon /> Selected</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button @click="setJavaInstall(javaInstall)"><PlusIcon /> Select</button>
|
||||
<button class="!shadow-none" @click="setJavaInstall(row)"><PlusIcon /> Select</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="chosenInstallOptions.length === 0" class="table-row entire-row">
|
||||
<div class="table-cell table-text">No java installations found!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
</template>
|
||||
<template #empty-state>
|
||||
<div class="p-4 text-secondary">No java installations found!</div>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="flex justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="$refs.detectJavaModal.hide()">
|
||||
<button
|
||||
class="!shadow-none !border-surface-4 !border"
|
||||
@click="$refs.detectJavaModal.hide()"
|
||||
>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
@@ -40,7 +38,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import { ButtonStyled, injectNotificationManager, Table } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
@@ -52,6 +50,11 @@ const { handleError } = injectNotificationManager()
|
||||
const chosenInstallOptions = ref([])
|
||||
const detectJavaModal = ref(null)
|
||||
const currentSelected = ref({})
|
||||
const javaInstallColumns = [
|
||||
{ key: 'version', label: 'Version', width: '9rem' },
|
||||
{ key: 'path', label: 'Path' },
|
||||
{ key: 'actions', label: 'Actions', align: 'right', width: '10rem' },
|
||||
]
|
||||
|
||||
defineExpose({
|
||||
show: async (version, currentSelectedJava) => {
|
||||
@@ -77,25 +80,3 @@ function setJavaInstall(javaInstall) {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.auto-detect-modal {
|
||||
.table {
|
||||
.table-row {
|
||||
grid-template-columns: 1fr 4fr min-content;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inherit;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.manage {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,85 +1,104 @@
|
||||
<template>
|
||||
<JavaDetectionModal ref="detectJavaModal" @submit="(val) => emit('update:modelValue', val)" />
|
||||
<div class="toggle-setting" :class="{ compact }">
|
||||
<StyledInput
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:model-value="props.modelValue ? props.modelValue.path : ''"
|
||||
:placeholder="placeholder ?? '/path/to/java'"
|
||||
wrapper-class="installation-input"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
path: val,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div :id="props.id" class="toggle-setting" :class="{ compact }">
|
||||
<div class="input-with-status">
|
||||
<StyledInput
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:model-value="props.modelValue ? props.modelValue.path : ''"
|
||||
:placeholder="placeholder ?? '/path/to/java'"
|
||||
wrapper-class="installation-input"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
path: val,
|
||||
})
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ButtonStyled
|
||||
:color="
|
||||
!hoveringTest && !testingJava
|
||||
? testingJavaSuccess === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard'
|
||||
"
|
||||
color-fill="text"
|
||||
>
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:disabled="testingJava || props.disabled"
|
||||
@click="runTest(props.modelValue?.path)"
|
||||
@mouseenter="!props.disabled && (hoveringTest = true)"
|
||||
@mouseleave="hoveringTest = false"
|
||||
>
|
||||
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
|
||||
<CheckCircleIcon
|
||||
v-else-if="testingJavaSuccess === true && !hoveringTest"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
|
||||
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="installation-buttons">
|
||||
<ButtonStyled v-if="props.version">
|
||||
<button :disabled="props.disabled || installingJava" @click="reinstallJava">
|
||||
<button
|
||||
v-tooltip="testingJavaSuccess === true ? 'Already installed' : undefined"
|
||||
class="!shadow-none"
|
||||
:disabled="props.disabled || installingJava || testingJavaSuccess === true"
|
||||
@click="reinstallJava"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ installingJava ? 'Installing...' : 'Install recommended' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="props.disabled" @click="autoDetect">
|
||||
<button class="!shadow-none" :disabled="props.disabled" @click="autoDetect">
|
||||
<SearchIcon />
|
||||
Detect
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="props.disabled" @click="handleJavaFileInput()">
|
||||
<button class="!shadow-none" :disabled="props.disabled" @click="handleJavaFileInput()">
|
||||
<FolderSearchIcon />
|
||||
Browse
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="testingJava">
|
||||
<button disabled>Testing...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="testingJavaSuccess === true">
|
||||
<button disabled>
|
||||
<CheckIcon />
|
||||
Success
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="testingJavaSuccess === false">
|
||||
<button disabled>
|
||||
<XIcon />
|
||||
Failed
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button :disabled="props.disabled" @click="testJava">
|
||||
<PlayIcon />
|
||||
Test
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
CheckCircleIcon,
|
||||
DownloadIcon,
|
||||
FolderSearchIcon,
|
||||
PlayIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
XIcon,
|
||||
SpinnerIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
import useJavaTest from '@/composables/useJavaTest'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
|
||||
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
version: {
|
||||
type: Number,
|
||||
required: false,
|
||||
@@ -110,29 +129,36 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const testingJava = ref(false)
|
||||
const testingJavaSuccess = ref(null)
|
||||
const {
|
||||
testingJava,
|
||||
javaTestResult: testingJavaSuccess,
|
||||
testJavaInstallationDebounced,
|
||||
testJavaInstallation,
|
||||
} = useJavaTest()
|
||||
|
||||
const installingJava = ref(false)
|
||||
const hoveringTest = ref(false)
|
||||
let hasInitialized = false
|
||||
|
||||
async function testJava() {
|
||||
testingJava.value = true
|
||||
testingJavaSuccess.value = await test_jre(
|
||||
props.modelValue ? props.modelValue.path : '',
|
||||
props.version,
|
||||
)
|
||||
testingJava.value = false
|
||||
|
||||
trackEvent('JavaTest', {
|
||||
path: props.modelValue ? props.modelValue.path : '',
|
||||
success: testingJavaSuccess.value,
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
testingJavaSuccess.value = null
|
||||
}, 2000)
|
||||
async function runTest(path) {
|
||||
await testJavaInstallation(path, props.version, true)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue?.path,
|
||||
(newPath) => {
|
||||
if (newPath) {
|
||||
if (!hasInitialized) {
|
||||
testJavaInstallation(newPath, props.version, false)
|
||||
hasInitialized = true
|
||||
} else {
|
||||
testJavaInstallationDebounced(newPath, props.version)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function handleJavaFileInput() {
|
||||
const filePath = await open()
|
||||
|
||||
@@ -142,6 +168,7 @@ async function handleJavaFileInput() {
|
||||
result = {
|
||||
path: filePath.path ?? filePath,
|
||||
version: props.version.toString(),
|
||||
parsed_version: props.version,
|
||||
architecture: 'x86',
|
||||
}
|
||||
}
|
||||
@@ -175,6 +202,7 @@ async function reinstallJava() {
|
||||
result = {
|
||||
path: path,
|
||||
version: props.version.toString(),
|
||||
parsed_version: props.version,
|
||||
architecture: 'x86',
|
||||
}
|
||||
}
|
||||
@@ -186,13 +214,23 @@ async function reinstallJava() {
|
||||
|
||||
emit('update:modelValue', result)
|
||||
installingJava.value = false
|
||||
runTest(result.path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.input-with-status {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.installation-input {
|
||||
width: 100% !important;
|
||||
flex-grow: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggle-setting {
|
||||
@@ -215,12 +253,4 @@ async function reinstallJava() {
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.test-success {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.test-fail {
|
||||
color: var(--color-red);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { update_managed_modrinth_version } from '@/helpers/profile'
|
||||
import { update_managed_modrinth_version } from '@/helpers/instance'
|
||||
import { releaseColor } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -32,14 +32,14 @@ const filteredVersions = computed(() => {
|
||||
})
|
||||
|
||||
const modpackVersionModal = ref(null)
|
||||
const installedVersion = computed(() => props.instance?.linked_data?.version_id)
|
||||
const installedVersion = computed(() => props.instance?.link?.version_id)
|
||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
||||
const inProgress = ref(false)
|
||||
|
||||
const switchVersion = async (versionId) => {
|
||||
modpackVersionModal.value.hide()
|
||||
inProgress.value = true
|
||||
await update_managed_modrinth_version(props.instance.path, versionId)
|
||||
await update_managed_modrinth_version(props.instance.id, versionId)
|
||||
inProgress.value = false
|
||||
emit('finish-install')
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const onHide = () => {
|
||||
:on-hide="onHide"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<div v-if="instance.linked_data" class="mod-card">
|
||||
<div v-if="instance.link" class="mod-card">
|
||||
<div class="table">
|
||||
<div class="table-row with-columns table-head">
|
||||
<div class="table-cell table-text download-cell" />
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarIcon, UsersIcon, XIcon } from '@modrinth/assets'
|
||||
import { injectModrinthClient, ProgressBar } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const DISMISSED_STORAGE_KEY = 'pride-fundraiser-2026-dismissed'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const dismissed = ref(localStorage.getItem(DISMISSED_STORAGE_KEY) === 'true')
|
||||
|
||||
const { data: campaignInfo } = useQuery({
|
||||
queryKey: ['campaign', 'pride-26'],
|
||||
queryFn: () => client.labrinth.campaign_internal.getPride26(),
|
||||
enabled: () => !dismissed.value,
|
||||
staleTime: 15 * 60 * 1000,
|
||||
refetchInterval: 15 * 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
const shouldShowBanner = computed(
|
||||
() => !dismissed.value && Number(campaignInfo.value?.target_usd) > 0,
|
||||
)
|
||||
|
||||
async function openPrideFundraiser() {
|
||||
await openUrl('https://modrinth.com/pride?from=app')
|
||||
}
|
||||
|
||||
function dismissBanner() {
|
||||
dismissed.value = true
|
||||
localStorage.setItem(DISMISSED_STORAGE_KEY, 'true')
|
||||
}
|
||||
|
||||
function formatUsd(amount: string | number) {
|
||||
return Number(amount).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function daysLeft() {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.ceil((new Date('2026-07-01T00:00:00Z').getTime() - Date.now()) / (24 * 60 * 60 * 1000)),
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShowBanner && campaignInfo">
|
||||
<section
|
||||
role="link"
|
||||
tabindex="0"
|
||||
class="flex w-full cursor-pointer flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-button-bg p-3 text-primary transition-[border-color,filter] hover:border-surface-6 hover:brightness-125 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
aria-label="Open Pride fundraiser"
|
||||
@click="openPrideFundraiser"
|
||||
@keydown.enter="openPrideFundraiser"
|
||||
@keydown.space.prevent="openPrideFundraiser"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<h2 class="m-0 min-w-0 truncate text-base font-semibold text-contrast">
|
||||
Pride Fundraiser 2026
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="m-0 flex size-5 shrink-0 cursor-pointer items-center justify-center border-0 bg-transparent p-0 text-primary transition-colors hover:text-contrast focus-visible:text-contrast"
|
||||
aria-label="Dismiss Pride fundraiser"
|
||||
@click.stop="dismissBanner"
|
||||
@keydown.stop
|
||||
>
|
||||
<XIcon aria-hidden="true" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-px w-full bg-surface-5" />
|
||||
<div class="flex w-full flex-col gap-2.5">
|
||||
<div class="flex items-end gap-1 whitespace-nowrap">
|
||||
<span class="text-base font-semibold leading-5 text-contrast">
|
||||
{{ formatUsd(campaignInfo.total_donations_usd) }}
|
||||
</span>
|
||||
<span class="text-xs font-medium leading-4">
|
||||
of {{ formatUsd(campaignInfo.target_usd) }}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
class="pride-fundraiser-banner__progress"
|
||||
:progress="Number(campaignInfo.total_donations_usd)"
|
||||
:max="Number(campaignInfo.target_usd)"
|
||||
color="purple"
|
||||
full-width
|
||||
:gradient-border="false"
|
||||
:aria-label="`${formatUsd(campaignInfo.total_donations_usd)} of ${formatUsd(
|
||||
campaignInfo.target_usd,
|
||||
)} raised`"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs font-medium leading-4">
|
||||
<span class="flex items-center gap-1">
|
||||
<UsersIcon aria-hidden="true" class="size-4 shrink-0" />
|
||||
{{ campaignInfo.num_donators.toLocaleString('en-US') }}
|
||||
{{ campaignInfo.num_donators === 1 ? 'supporter' : 'supporters' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<CalendarIcon aria-hidden="true" class="size-4 shrink-0" />
|
||||
{{ daysLeft() }} {{ daysLeft() === 1 ? 'day left' : 'days left' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pride-fundraiser-banner__progress :deep(.progress-bar) {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-red) 0%,
|
||||
var(--color-orange) 20%,
|
||||
var(--color-green) 50%,
|
||||
var(--color-blue) 75%,
|
||||
var(--color-purple) 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -37,6 +37,6 @@ defineProps({
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,16 +6,16 @@ import dayjs from 'dayjs'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { profile_listener } from '@/helpers/events.js'
|
||||
import { list } from '@/helpers/profile'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import { list } from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const recentInstances = ref([])
|
||||
const getInstances = async () => {
|
||||
const profiles = await list().catch(handleError)
|
||||
const instances = await list().catch(handleError)
|
||||
|
||||
recentInstances.value = profiles
|
||||
recentInstances.value = instances
|
||||
.sort((a, b) => {
|
||||
const dateACreated = dayjs(a.created)
|
||||
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
|
||||
@@ -37,24 +37,24 @@ const getInstances = async () => {
|
||||
|
||||
await getInstances()
|
||||
|
||||
const unlistenProfile = await profile_listener(async (event) => {
|
||||
const unlistenInstance = await instance_listener(async (event) => {
|
||||
if (event.event !== 'synced') {
|
||||
await getInstances()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
unlistenInstance()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
|
||||
<NavButton :to="`/instance/${encodeURIComponent(instance.path)}`" class="relative">
|
||||
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
size="28px"
|
||||
:tint-by="instance.path"
|
||||
:tint-by="instance.id"
|
||||
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
/>
|
||||
<div
|
||||
@@ -65,10 +65,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</NavButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="instances && recentInstances.length > 0"
|
||||
class="h-px w-6 mx-auto my-2 bg-divider"
|
||||
></div>
|
||||
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@@ -126,7 +126,7 @@ watch(
|
||||
function fakeLoadingIncrease() {
|
||||
if (loadingProgress.value < 95) {
|
||||
setTimeout(() => {
|
||||
loadingProgress.value += 1
|
||||
loadingProgress.value += 2
|
||||
fakeLoadingIncrease()
|
||||
}, 5)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
hover-color-fill="background"
|
||||
circular
|
||||
>
|
||||
<button class="relative expanded-button" @click="handleClose">
|
||||
<button class="relative expanded-button close-button" @click="handleClose">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -82,8 +82,12 @@ const handleClose = async () => {
|
||||
</script>
|
||||
<style scoped>
|
||||
.expanded-button::before {
|
||||
inset: -6px;
|
||||
inset: -9px -6px;
|
||||
content: '';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.expanded-button.close-button::before {
|
||||
inset: -9px -9px -9px -6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -393,7 +393,6 @@ const messages = defineMessages({
|
||||
<FriendsSection
|
||||
v-if="pendingFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="pendingFriends"
|
||||
:heading="formatMessage(messages.pending)"
|
||||
:remove-friend="removeFriend"
|
||||
|
||||
@@ -7,26 +7,28 @@ import {
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { list } from '@/helpers/profile'
|
||||
import { add_server_to_profile, get_profile_worlds } from '@/helpers/worlds.ts'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const modal = ref()
|
||||
const searchFilter = ref('')
|
||||
const profiles = ref([])
|
||||
const instances = ref([])
|
||||
|
||||
const serverName = ref('')
|
||||
const serverAddress = ref('')
|
||||
|
||||
const shownProfiles = computed(() =>
|
||||
profiles.value.filter((profile) => {
|
||||
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
|
||||
const shownInstances = computed(() =>
|
||||
instances.value.filter((instance) => {
|
||||
return instance.name.toLowerCase().includes(searchFilter.value.toLowerCase())
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -36,15 +38,15 @@ defineExpose({
|
||||
serverAddress.value = address
|
||||
searchFilter.value = ''
|
||||
|
||||
const profilesVal = await list().catch(handleError)
|
||||
const instanceValues = await list().catch(handleError)
|
||||
await Promise.allSettled(
|
||||
profilesVal.map(async (profile) => {
|
||||
profile.adding = false
|
||||
profile.added = false
|
||||
instanceValues.map(async (instance) => {
|
||||
instance.adding = false
|
||||
instance.added = false
|
||||
|
||||
try {
|
||||
const worlds = await get_profile_worlds(profile.path)
|
||||
profile.added = worlds.some(
|
||||
const worlds = await get_instance_worlds(instance.id)
|
||||
instance.added = worlds.some(
|
||||
(w) => w.type === 'server' && w.address === serverAddress.value,
|
||||
)
|
||||
} catch {
|
||||
@@ -53,28 +55,29 @@ defineExpose({
|
||||
}),
|
||||
)
|
||||
|
||||
profiles.value = profilesVal
|
||||
instances.value = instanceValues
|
||||
modal.value.show()
|
||||
|
||||
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
|
||||
},
|
||||
})
|
||||
|
||||
async function addServer(profile) {
|
||||
profile.adding = true
|
||||
async function addServer(instance) {
|
||||
instance.adding = true
|
||||
try {
|
||||
await add_server_to_profile(profile.path, serverName.value, serverAddress.value, 'prompt')
|
||||
profile.added = true
|
||||
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
|
||||
instance.added = true
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
|
||||
|
||||
trackEvent('AddServerToInstance', {
|
||||
server_name: serverName.value,
|
||||
instance_name: profile.name,
|
||||
instance_name: instance.name,
|
||||
source: 'AddServerToInstanceModal',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
profile.adding = false
|
||||
instance.adding = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -91,26 +94,26 @@ async function addServer(profile) {
|
||||
/>
|
||||
<div class="max-h-[21rem] overflow-y-auto">
|
||||
<div
|
||||
v-for="profile in shownProfiles"
|
||||
:key="profile.path"
|
||||
v-for="instance in shownInstances"
|
||||
:key="instance.id"
|
||||
class="flex w-full items-center justify-between gap-2 bg-bg-raised text-icon shadow-none"
|
||||
>
|
||||
<router-link
|
||||
class="btn btn-transparent p-2 text-left"
|
||||
:to="`/instance/${encodeURIComponent(profile.path)}`"
|
||||
:to="`/instance/${encodeURIComponent(instance.id)}`"
|
||||
@click="modal.hide()"
|
||||
>
|
||||
<Avatar
|
||||
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
|
||||
class="mr-2 [--size:2rem]"
|
||||
/>
|
||||
{{ profile.name }}
|
||||
{{ instance.name }}
|
||||
</router-link>
|
||||
<ButtonStyled>
|
||||
<button :disabled="profile.added || profile.adding" @click="addServer(profile)">
|
||||
<PlusIcon v-if="!profile.added && !profile.adding" />
|
||||
<CheckIcon v-else-if="profile.added" />
|
||||
{{ profile.adding ? 'Adding...' : profile.added ? 'Added' : 'Add' }}
|
||||
<button :disabled="instance.added || instance.adding" @click="addServer(instance)">
|
||||
<PlusIcon v-if="!instance.added && !instance.adding" />
|
||||
<CheckIcon v-else-if="instance.added" />
|
||||
{{ instance.adding ? 'Adding...' : instance.added ? 'Added' : 'Add' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<template>
|
||||
<ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall">
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
|
||||
you're trying to install it on. Are you sure you want to continue? Dependencies will not be
|
||||
installed.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="header">
|
||||
<th>{{ instance?.name }}</th>
|
||||
<th>{{ project.title }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="content">
|
||||
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
|
||||
<td>
|
||||
<Combobox
|
||||
v-if="versions?.length > 1"
|
||||
v-model="selectedVersionId"
|
||||
:options="versionOptions"
|
||||
:searchable="true"
|
||||
placeholder="Select version"
|
||||
force-direction="up"
|
||||
:max-height="150"
|
||||
/>
|
||||
<span v-else>
|
||||
<span>{{ selectedVersionLabel }}</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="button-group">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="() => incompatibleModal.hide()"><XIcon />Cancel</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="installing" @click="install()">
|
||||
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
formatLoader,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { add_project_from_version as installMod } from '@/helpers/profile'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const instance = ref(null)
|
||||
const project = ref(null)
|
||||
const versions = ref(null)
|
||||
const selectedVersion = ref(null)
|
||||
const incompatibleModal = ref(null)
|
||||
const installing = ref(false)
|
||||
|
||||
const onInstall = ref(() => {})
|
||||
|
||||
const selectedVersionLabel = computed(() => {
|
||||
if (!selectedVersion.value) return ''
|
||||
return `${selectedVersion.value.name} (${selectedVersion.value.loaders
|
||||
.map((name) => formatLoader(formatMessage, name))
|
||||
.join(', ')} - ${selectedVersion.value.game_versions.join(', ')})`
|
||||
})
|
||||
|
||||
const versionOptions = computed(() =>
|
||||
(versions.value ?? []).map((version) => ({
|
||||
value: version.id,
|
||||
label: `${version.name} (${version.loaders
|
||||
.map((name) => formatLoader(formatMessage, name))
|
||||
.join(', ')} - ${version.game_versions.join(', ')})`,
|
||||
})),
|
||||
)
|
||||
|
||||
const selectedVersionId = computed({
|
||||
get: () => selectedVersion.value?.id ?? null,
|
||||
set: (value) => {
|
||||
if (!value) return
|
||||
selectedVersion.value = (versions.value ?? []).find((version) => version.id === value) ?? null
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
|
||||
instance.value = instanceVal
|
||||
versions.value = projectVersions ?? []
|
||||
selectedVersion.value = selected ?? projectVersions?.[0] ?? null
|
||||
|
||||
project.value = projectVal
|
||||
|
||||
onInstall.value = callback
|
||||
installing.value = false
|
||||
|
||||
incompatibleModal.value.show()
|
||||
|
||||
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
|
||||
},
|
||||
})
|
||||
|
||||
const install = async () => {
|
||||
installing.value = true
|
||||
await installMod(instance.value.path, selectedVersion.value.id, 'standalone').catch(handleError)
|
||||
installing.value = false
|
||||
onInstall.value(selectedVersion.value.id)
|
||||
incompatibleModal.value.hide()
|
||||
|
||||
trackEvent('ProjectInstall', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
id: project.value,
|
||||
version_id: selectedVersion.value.id,
|
||||
project_type: project.value.project_type,
|
||||
title: project.value.title,
|
||||
source: 'ProjectIncompatibilityWarningModal',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
border-collapse: collapse;
|
||||
box-shadow: 0 0 0 1px var(--color-button-bg);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg);
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--color-button-bg);
|
||||
}
|
||||
|
||||
th:first-child {
|
||||
border-top-left-radius: var(--radius-lg);
|
||||
border-right: 1px solid var(--color-button-bg);
|
||||
}
|
||||
|
||||
th:last-child {
|
||||
border-top-right-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
border-right: 1px solid var(--color-button-bg);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
Chips,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
@@ -17,7 +19,8 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, list, remove } from '@/helpers/instance'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
@@ -25,21 +28,29 @@ import type { GameInstance } from '../../../helpers/types'
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteConfirmModal = ref()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
type ReleaseChannel = GameInstance['update_channel']
|
||||
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 savingReleaseChannel = ref(false)
|
||||
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.update_channel)
|
||||
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
|
||||
savingReleaseChannel.value ? [...releaseChannelOptions] : [],
|
||||
)
|
||||
|
||||
const newCategoryInput = ref('')
|
||||
|
||||
const installing = computed(() => instance.value.install_stage !== 'installed')
|
||||
|
||||
async function duplicateProfile() {
|
||||
await duplicate(instance.value.path).catch(handleError)
|
||||
async function duplicateInstance() {
|
||||
await install_duplicate_instance(instance.value.id).catch(handleError)
|
||||
trackEvent('InstanceDuplicate', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
@@ -51,9 +62,55 @@ const availableGroups = computed(() => [
|
||||
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
|
||||
])
|
||||
|
||||
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
||||
switch (channel) {
|
||||
case 'release':
|
||||
return formatMessage(messages.updateChannelRelease)
|
||||
case 'beta':
|
||||
return formatMessage(messages.updateChannelBeta)
|
||||
case 'alpha':
|
||||
return formatMessage(messages.updateChannelAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
function formatReleaseChannelDescription(channel: ReleaseChannel) {
|
||||
switch (channel) {
|
||||
case 'release':
|
||||
return formatMessage(messages.updateChannelReleaseDescription)
|
||||
case 'beta':
|
||||
return formatMessage(messages.updateChannelBetaDescription)
|
||||
case 'alpha':
|
||||
return formatMessage(messages.updateChannelAlphaDescription)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [instance.value.id, instance.value.update_channel] as const,
|
||||
() => {
|
||||
if (!savingReleaseChannel.value) {
|
||||
selectedReleaseChannel.value = instance.value.update_channel
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
||||
const previousReleaseChannel = previousChannel ?? instance.value.update_channel
|
||||
if (channel === instance.value.update_channel) return
|
||||
|
||||
savingReleaseChannel.value = true
|
||||
const instanceId = instance.value.id
|
||||
await edit(instanceId, { update_channel: channel })
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instanceId] }))
|
||||
.catch((error) => {
|
||||
selectedReleaseChannel.value = previousReleaseChannel
|
||||
handleError(error)
|
||||
})
|
||||
savingReleaseChannel.value = false
|
||||
})
|
||||
|
||||
async function resetIcon() {
|
||||
icon.value = undefined
|
||||
await edit_icon(instance.value.path, null).catch(handleError)
|
||||
await edit_icon(instance.value.id, null).catch(handleError)
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
}
|
||||
|
||||
@@ -71,12 +128,12 @@ async function setIcon() {
|
||||
if (!value) return
|
||||
|
||||
icon.value = value
|
||||
await edit_icon(instance.value.path, icon.value).catch(handleError)
|
||||
await edit_icon(instance.value.id, icon.value).catch(handleError)
|
||||
|
||||
trackEvent('InstanceSetIcon')
|
||||
}
|
||||
|
||||
const editProfileObject = computed(() => ({
|
||||
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),
|
||||
}))
|
||||
@@ -102,15 +159,15 @@ watch(
|
||||
[title, groups, groups],
|
||||
async () => {
|
||||
if (removing.value) return
|
||||
await edit(instance.value.path, editProfileObject.value).catch(handleError)
|
||||
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const removing = ref(false)
|
||||
async function removeProfile() {
|
||||
async function removeInstance() {
|
||||
removing.value = true
|
||||
const path = instance.value.path
|
||||
const path = instance.value.id
|
||||
|
||||
trackEvent('InstanceRemove', {
|
||||
loader: instance.value.loader,
|
||||
@@ -175,6 +232,38 @@ const messages = defineMessages({
|
||||
id: 'instance.settings.tabs.general.duplicate-button',
|
||||
defaultMessage: 'Duplicate',
|
||||
},
|
||||
updateChannel: {
|
||||
id: 'instance.settings.tabs.general.update-channel',
|
||||
defaultMessage: 'Update channel',
|
||||
},
|
||||
updateChannelReleaseDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.release.description',
|
||||
defaultMessage: 'Only release versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelBetaDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.beta.description',
|
||||
defaultMessage: 'Release and beta versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelAlphaDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.alpha.description',
|
||||
defaultMessage: 'Release, beta, and alpha versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelRelease: {
|
||||
id: 'instance.settings.tabs.general.update-channel.release',
|
||||
defaultMessage: 'Release',
|
||||
},
|
||||
updateChannelBeta: {
|
||||
id: 'instance.settings.tabs.general.update-channel.beta',
|
||||
defaultMessage: 'Beta',
|
||||
},
|
||||
updateChannelAlpha: {
|
||||
id: 'instance.settings.tabs.general.update-channel.alpha',
|
||||
defaultMessage: 'Alpha',
|
||||
},
|
||||
selectUpdateChannelAriaLabel: {
|
||||
id: 'instance.settings.tabs.general.update-channel.select',
|
||||
defaultMessage: 'Select update channel',
|
||||
},
|
||||
deleteInstance: {
|
||||
id: 'instance.settings.tabs.general.delete',
|
||||
defaultMessage: 'Delete instance',
|
||||
@@ -196,7 +285,7 @@ const messages = defineMessages({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeProfile" />
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeInstance" />
|
||||
<div class="block">
|
||||
<div class="float-end ml-10 relative group w-fit">
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -222,7 +311,7 @@ const messages = defineMessages({
|
||||
:src="icon ? convertFileSrc(icon) : icon"
|
||||
size="108px"
|
||||
class="transition-[filter] group-hover:brightness-75"
|
||||
:tint-by="instance.path"
|
||||
:tint-by="instance.id"
|
||||
no-shadow
|
||||
/>
|
||||
<div
|
||||
@@ -262,7 +351,7 @@ const messages = defineMessages({
|
||||
aria-labelledby="duplicate-instance-label"
|
||||
:disabled="installing"
|
||||
class="w-max !shadow-none"
|
||||
@click="duplicateProfile"
|
||||
@click="duplicateInstance"
|
||||
>
|
||||
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
|
||||
</button>
|
||||
@@ -304,6 +393,23 @@ const messages = defineMessages({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.updateChannel) }}
|
||||
</h2>
|
||||
<Chips
|
||||
v-model="selectedReleaseChannel"
|
||||
:items="releaseChannelOptions"
|
||||
:format-label="formatReleaseChannelLabel"
|
||||
:capitalize="false"
|
||||
:disabled-items="releaseChannelDisabledItems"
|
||||
:aria-label="formatMessage(messages.selectUpdateChannelAriaLabel)"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatReleaseChannelDescription(selectedReleaseChannel) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.deleteInstance) }}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/profile'
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
@@ -28,21 +28,21 @@ const overrideHooks = ref(
|
||||
)
|
||||
const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
const editProfile: {
|
||||
const editInstanceObject = computed(() => {
|
||||
const editInstancePatch: {
|
||||
hooks?: Hooks
|
||||
} = {}
|
||||
|
||||
// When hooks are not overridden per-instance, we want to clear them
|
||||
editProfile.hooks = overrideHooks.value ? hooks.value : {}
|
||||
editInstancePatch.hooks = overrideHooks.value ? hooks.value : {}
|
||||
|
||||
return editProfile
|
||||
return editInstancePatch
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideHooks, hooks],
|
||||
async () => {
|
||||
await edit(instance.value.path, editProfileObject.value)
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
@@ -4,80 +4,124 @@ import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
InstallationSettingsLayout,
|
||||
provideAppBackup,
|
||||
provideInstallationSettings,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import {
|
||||
duplicate,
|
||||
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,
|
||||
install,
|
||||
list,
|
||||
update_managed_modrinth_version,
|
||||
update_repair_modrinth,
|
||||
} from '@/helpers/profile'
|
||||
} from '@/helpers/instance'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
import type { Manifest } from '../../../helpers/types'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const filePicker = injectFilePicker()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const debug = useDebugLogger('AppInstallationSettings')
|
||||
const themeStore = useTheming()
|
||||
|
||||
const { instance, offline, isMinecraftServer, onUnlinked } = injectInstanceSettings()
|
||||
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
|
||||
const skipNonEssentialWarnings = computed(() =>
|
||||
themeStore.getFeatureFlag('skip_non_essential_warnings'),
|
||||
)
|
||||
|
||||
const [
|
||||
fabric_versions,
|
||||
forge_versions,
|
||||
quilt_versions,
|
||||
neoforge_versions,
|
||||
all_game_versions,
|
||||
loaders,
|
||||
] = await Promise.all([
|
||||
get_loader_versions('fabric')
|
||||
.then((manifest: Manifest) => shallowRef(manifest))
|
||||
.catch(handleError),
|
||||
get_loader_versions('forge')
|
||||
.then((manifest: Manifest) => shallowRef(manifest))
|
||||
.catch(handleError),
|
||||
get_loader_versions('quilt')
|
||||
.then((manifest: Manifest) => shallowRef(manifest))
|
||||
.catch(handleError),
|
||||
get_loader_versions('neo')
|
||||
.then((manifest: Manifest) => shallowRef(manifest))
|
||||
.catch(handleError),
|
||||
get_game_versions()
|
||||
.then((gameVersions: GameVersionTag[]) => shallowRef(gameVersions))
|
||||
.catch(handleError),
|
||||
get_loaders()
|
||||
.then((value: PlatformTag[]) =>
|
||||
value
|
||||
.filter(
|
||||
(item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla',
|
||||
)
|
||||
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
|
||||
)
|
||||
.then((loader: PlatformTag[]) => ref(loader))
|
||||
.catch(handleError),
|
||||
])
|
||||
|
||||
const { data: modpackInfo } = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.path]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'),
|
||||
enabled: computed(() => !!instance.value.linked_data?.project_id && !offline),
|
||||
debug('metadata load: start', {
|
||||
instanceId: instance.value.id,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
installStage: instance.value.install_stage,
|
||||
})
|
||||
|
||||
function getSupportedModpackLoaders() {
|
||||
return get_loaders().then((value: PlatformTag[]) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
|
||||
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
|
||||
)
|
||||
}
|
||||
|
||||
const fabricVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'fabric'],
|
||||
queryFn: () => get_loader_versions('fabric') as Promise<Manifest>,
|
||||
})
|
||||
const forgeVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'forge'],
|
||||
queryFn: () => get_loader_versions('forge') as Promise<Manifest>,
|
||||
})
|
||||
const quiltVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'quilt'],
|
||||
queryFn: () => get_loader_versions('quilt') as Promise<Manifest>,
|
||||
})
|
||||
const neoforgeVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'neo'],
|
||||
queryFn: () => get_loader_versions('neo') as Promise<Manifest>,
|
||||
})
|
||||
const gameVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'game-versions'],
|
||||
queryFn: () => get_game_versions() as Promise<GameVersionTag[]>,
|
||||
})
|
||||
const loadersQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loaders', 'modpack'],
|
||||
queryFn: getSupportedModpackLoaders,
|
||||
})
|
||||
|
||||
const metadataLoading = computed(() =>
|
||||
[
|
||||
fabricVersionsQuery,
|
||||
forgeVersionsQuery,
|
||||
quiltVersionsQuery,
|
||||
neoforgeVersionsQuery,
|
||||
gameVersionsQuery,
|
||||
loadersQuery,
|
||||
].some((query) => query.isLoading.value),
|
||||
)
|
||||
|
||||
debug('metadata queries configured', {
|
||||
instanceId: instance.value.id,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
})
|
||||
|
||||
const isModrinthLinkedModpack = computed(
|
||||
() =>
|
||||
instance.value.link?.type === 'modrinth_modpack' ||
|
||||
instance.value.link?.type === 'server_project_modpack',
|
||||
)
|
||||
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
|
||||
|
||||
const modpackInfoQuery = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.id, 'must_revalidate'),
|
||||
enabled: computed(() => isModrinthLinkedModpack.value && !offline),
|
||||
})
|
||||
const modpackInfo = modpackInfoQuery.data
|
||||
|
||||
const repairing = ref(false)
|
||||
const reinstalling = ref(false)
|
||||
|
||||
@@ -89,31 +133,60 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
function getManifest(loader: string) {
|
||||
const map: Record<string, typeof fabric_versions> = {
|
||||
fabric: fabric_versions,
|
||||
forge: forge_versions,
|
||||
quilt: quilt_versions,
|
||||
neoforge: neoforge_versions,
|
||||
const map: Record<string, Manifest | undefined> = {
|
||||
fabric: fabricVersionsQuery.data.value,
|
||||
forge: forgeVersionsQuery.data.value,
|
||||
quilt: quiltVersionsQuery.data.value,
|
||||
neoforge: neoforgeVersionsQuery.data.value,
|
||||
}
|
||||
return map[loader]
|
||||
const manifest = map[loader]
|
||||
debug('getManifest:', {
|
||||
loader,
|
||||
hasManifest: !!manifest,
|
||||
gameVersions: manifest?.gameVersions?.length ?? 0,
|
||||
})
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function installLocalModpackFromPicker() {
|
||||
const picked = await filePicker.pickModpackFile({ readFile: false })
|
||||
if (!picked?.path) return false
|
||||
|
||||
const job = await install_pack_to_existing_instance(instance.value.id, {
|
||||
type: 'fromFile',
|
||||
path: picked.path,
|
||||
}).catch(handleError)
|
||||
if (!job) return false
|
||||
|
||||
const completed = await wait_for_install_job(job.job_id).catch(handleError)
|
||||
return !!completed
|
||||
}
|
||||
|
||||
provideAppBackup({
|
||||
async createBackup() {
|
||||
const allProfiles = await list()
|
||||
debug('createBackup: start', {
|
||||
instanceId: instance.value.id,
|
||||
instanceName: instance.value.name,
|
||||
})
|
||||
const allInstances = await list()
|
||||
const prefix = `${instance.value.name} - Backup #`
|
||||
const existingNums = allProfiles
|
||||
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 newPath = await duplicate(instance.value.path)
|
||||
await edit(newPath, { name: `${prefix}${nextNum}` })
|
||||
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}` })
|
||||
},
|
||||
})
|
||||
|
||||
provideInstallationSettings({
|
||||
loading: ref(false),
|
||||
closeSettings: closeModal,
|
||||
loading: computed(() => metadataLoading.value || modpackInfoQuery.isLoading.value),
|
||||
installationInfo: computed(() => {
|
||||
const rows = [
|
||||
{
|
||||
@@ -135,7 +208,7 @@ provideInstallationSettings({
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(() => !!instance.value.linked_data?.locked),
|
||||
isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value),
|
||||
isBusy: computed(
|
||||
() =>
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
@@ -143,7 +216,16 @@ provideInstallationSettings({
|
||||
reinstalling.value ||
|
||||
!!offline,
|
||||
),
|
||||
skipNonEssentialWarnings,
|
||||
modpack: computed(() => {
|
||||
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
|
||||
return {
|
||||
iconUrl: instance.value.icon_path,
|
||||
title: instance.value.link.name ?? instance.value.name,
|
||||
versionNumber: instance.value.link.version_number ?? undefined,
|
||||
filename: instance.value.link.filename ?? undefined,
|
||||
}
|
||||
}
|
||||
if (!modpackInfo.value) return null
|
||||
return {
|
||||
iconUrl: modpackInfo.value.project.icon_url,
|
||||
@@ -155,114 +237,197 @@ provideInstallationSettings({
|
||||
currentPlatform: computed(() => instance.value.loader),
|
||||
currentGameVersion: computed(() => instance.value.game_version),
|
||||
currentLoaderVersion: computed(() => instance.value.loader_version ?? ''),
|
||||
availablePlatforms: loaders?.value?.map((x) => x.name) ?? [],
|
||||
availablePlatforms: computed(() => loadersQuery.data.value?.map((x) => x.name) ?? []),
|
||||
|
||||
resolveGameVersions(loader, showSnapshots) {
|
||||
const versions = all_game_versions?.value ?? []
|
||||
const versions = gameVersionsQuery.data.value ?? []
|
||||
const filtered = versions.filter((item) => {
|
||||
if (loader === 'vanilla') return true
|
||||
const manifest = getManifest(loader)
|
||||
return !!manifest?.value?.gameVersions?.some((x) => item.version === x.id)
|
||||
return !!manifest?.gameVersions?.some((x) => item.version === x.id)
|
||||
})
|
||||
return (showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')).map(
|
||||
(x) => ({ value: x.version, label: x.version }),
|
||||
)
|
||||
const result = (
|
||||
showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')
|
||||
).map((x) => ({ value: x.version, label: x.version }))
|
||||
debug('resolveGameVersions:', {
|
||||
loader,
|
||||
showSnapshots,
|
||||
totalVersions: versions.length,
|
||||
filteredVersions: filtered.length,
|
||||
resultVersions: result.length,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
resolveLoaderVersions(loader, gameVersion) {
|
||||
if (loader === 'vanilla' || !gameVersion) return []
|
||||
const manifest = getManifest(loader)
|
||||
if (!manifest?.value) return []
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
return manifest.value.gameVersions[0]?.loaders ?? []
|
||||
if (loader === 'vanilla' || !gameVersion) {
|
||||
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
return manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
||||
const manifest = getManifest(loader)
|
||||
if (!manifest) {
|
||||
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
if (loader === 'fabric' || loader === 'quilt') {
|
||||
const result = manifest.gameVersions[0]?.loaders ?? []
|
||||
debug('resolveLoaderVersions: fabric/quilt result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const result = manifest.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
|
||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||
return result
|
||||
},
|
||||
|
||||
resolveHasSnapshots(loader) {
|
||||
const versions = all_game_versions?.value ?? []
|
||||
if (loader === 'vanilla') return versions.some((x) => x.version_type !== 'release')
|
||||
const versions = gameVersionsQuery.data.value ?? []
|
||||
if (loader === 'vanilla') {
|
||||
const result = versions.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots: vanilla', { loader, result })
|
||||
return result
|
||||
}
|
||||
const manifest = getManifest(loader)
|
||||
const supported = versions.filter(
|
||||
(item) => !!manifest?.value?.gameVersions?.some((x) => item.version === x.id),
|
||||
(item) => !!manifest?.gameVersions?.some((x) => item.version === x.id),
|
||||
)
|
||||
return supported.some((x) => x.version_type !== 'release')
|
||||
const result = supported.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots:', {
|
||||
loader,
|
||||
totalVersions: versions.length,
|
||||
supportedVersions: supported.length,
|
||||
result,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
async save(platform, gameVersion, loaderVersionId) {
|
||||
const editProfile: Record<string, string | undefined> = {
|
||||
debug('save: called', {
|
||||
instanceId: instance.value.id,
|
||||
platform,
|
||||
gameVersion,
|
||||
loaderVersionId,
|
||||
})
|
||||
const editInstancePatch: Record<string, string | undefined> = {
|
||||
loader: platform,
|
||||
game_version: gameVersion,
|
||||
}
|
||||
if (platform !== 'vanilla' && loaderVersionId) {
|
||||
editProfile.loader_version = loaderVersionId
|
||||
editInstancePatch.loader_version = loaderVersionId
|
||||
}
|
||||
await edit(instance.value.path, editProfile).catch(handleError)
|
||||
await edit(instance.value.id, editInstancePatch).catch(handleError)
|
||||
debug('save: edit complete', { editInstancePatch })
|
||||
},
|
||||
|
||||
afterSave: async () => {
|
||||
await install(instance.value.path, false).catch(handleError)
|
||||
debug('afterSave: installing', { instanceId: instance.value.id })
|
||||
await install_existing_instance(instance.value.id, false).catch(handleError)
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('afterSave: done')
|
||||
},
|
||||
|
||||
async repair() {
|
||||
debug('repair: called', { instanceId: instance.value.id })
|
||||
repairing.value = true
|
||||
await install(instance.value.path, true).catch(handleError)
|
||||
await install_existing_instance(instance.value.id, true).catch(handleError)
|
||||
repairing.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('repair: done')
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
await update_repair_modrinth(instance.value.path).catch(handleError)
|
||||
reinstalling.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
let shouldTrack = false
|
||||
try {
|
||||
if (isImportedModpack.value) {
|
||||
shouldTrack = await installLocalModpackFromPicker()
|
||||
} else {
|
||||
await update_repair_modrinth(instance.value.id).catch(handleError)
|
||||
shouldTrack = true
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
if (shouldTrack) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
debug('reinstallModpack: done')
|
||||
},
|
||||
|
||||
async swapModpack() {
|
||||
debug('swapModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
try {
|
||||
const installed = await installLocalModpackFromPicker()
|
||||
if (installed) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
debug('swapModpack: done')
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
await edit(instance.value.path, {
|
||||
linked_data: null as unknown as undefined,
|
||||
debug('unlinkModpack: called', { instanceId: instance.value.id })
|
||||
await edit(instance.value.id, {
|
||||
link: null as unknown as undefined,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
onUnlinked()
|
||||
debug('unlinkModpack: done')
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => null,
|
||||
async fetchModpackVersions() {
|
||||
const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
|
||||
handleError,
|
||||
)
|
||||
debug('fetchModpackVersions: called', {
|
||||
projectId: instance.value.link?.project_id,
|
||||
})
|
||||
const versions = await get_project_versions(instance.value.link!.project_id!).catch(handleError)
|
||||
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
|
||||
return (versions ?? []) as Labrinth.Versions.v2.Version[]
|
||||
},
|
||||
|
||||
async getVersionChangelog(versionId: string) {
|
||||
debug('getVersionChangelog: called', { versionId })
|
||||
return (await get_version(versionId, 'must_revalidate').catch(
|
||||
() => null,
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
},
|
||||
|
||||
async onModpackVersionConfirm(version) {
|
||||
await update_managed_modrinth_version(instance.value.path, version.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.path],
|
||||
debug('onModpackVersionConfirm: called', {
|
||||
versionId: version.id,
|
||||
instanceId: instance.value.id,
|
||||
})
|
||||
await update_managed_modrinth_version(instance.value.id, version.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
debug('onModpackVersionConfirm: done')
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
isApp: true,
|
||||
currentVersionId:
|
||||
modpackInfo.value?.update_version_id ?? instance.value.linked_data?.version_id ?? '',
|
||||
currentVersionId: modpackInfo.value?.update_version_id ?? instance.value.link?.version_id ?? '',
|
||||
projectIconUrl: modpackInfo.value?.project?.icon_url,
|
||||
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
|
||||
currentGameVersion: instance.value.game_version,
|
||||
@@ -271,7 +436,10 @@ provideInstallationSettings({
|
||||
|
||||
isServer: false,
|
||||
isApp: true,
|
||||
showModpackVersionActions: !isMinecraftServer.value,
|
||||
showModpackVersionActions: computed(
|
||||
() => isModrinthLinkedModpack.value && !isMinecraftServer.value,
|
||||
),
|
||||
isLocalFile: isImportedModpack,
|
||||
repairing,
|
||||
reinstalling,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CoffeeIcon,
|
||||
FolderSearchIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
@@ -8,11 +17,13 @@ import {
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { computed, readonly, ref, watch } from 'vue'
|
||||
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
import useJavaTest from '@/composables/useJavaTest'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
import { edit, get_optimal_jre_key } from '@/helpers/profile'
|
||||
import { edit, get_optimal_jre_key } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
@@ -25,9 +36,54 @@ const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
|
||||
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.value.id).catch(handleError))
|
||||
|
||||
const overrideJavaInstall = ref(!!instance.value.java_path)
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError))
|
||||
const javaInstall = ref({ path: optimalJava.path ?? instance.value.java_path })
|
||||
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
|
||||
|
||||
const activePath = computed(() =>
|
||||
overrideJavaInstall.value ? javaPath.value : (optimalJava?.path ?? ''),
|
||||
)
|
||||
|
||||
watch(overrideJavaInstall, (enabled) => {
|
||||
if (enabled && !javaPath.value) {
|
||||
javaPath.value = optimalJava?.path ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
const { testingJava, javaTestResult, testJavaInstallationDebounced, testJavaInstallation } =
|
||||
useJavaTest()
|
||||
|
||||
const hoveringTest = ref(false)
|
||||
let hasInitialized = false
|
||||
|
||||
watch(
|
||||
activePath,
|
||||
(newPath) => {
|
||||
if (newPath && optimalJava?.parsed_version) {
|
||||
if (!hasInitialized) {
|
||||
testJavaInstallation(newPath, optimalJava?.parsed_version, false)
|
||||
hasInitialized = true
|
||||
} else {
|
||||
testJavaInstallationDebounced(newPath, optimalJava?.parsed_version)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const javaDetectionModal = ref<{ show: (version: number, current: object) => void } | null>(null)
|
||||
|
||||
async function handleBrowseJava() {
|
||||
const result = await open({ multiple: false })
|
||||
if (result) {
|
||||
javaPath.value = result
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetectJava() {
|
||||
javaDetectionModal.value?.show(optimalJava?.parsed_version, { path: javaPath.value })
|
||||
}
|
||||
|
||||
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
|
||||
const javaArgs = ref(
|
||||
@@ -48,11 +104,11 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a
|
||||
snapPoints: number[]
|
||||
}
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
const editInstanceObject = computed(() => {
|
||||
return {
|
||||
java_path:
|
||||
overrideJavaInstall.value && javaInstall.value.path !== ''
|
||||
? javaInstall.value.path.replace('java.exe', 'javaw.exe')
|
||||
overrideJavaInstall.value && javaPath.value
|
||||
? javaPath.value.replace('java.exe', 'javaw.exe')
|
||||
: null,
|
||||
extra_launch_args: overrideJavaArgs.value
|
||||
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
|
||||
@@ -71,7 +127,7 @@ const editProfileObject = computed(() => {
|
||||
watch(
|
||||
[
|
||||
overrideJavaInstall,
|
||||
javaInstall,
|
||||
javaPath,
|
||||
overrideJavaArgs,
|
||||
javaArgs,
|
||||
overrideEnvVars,
|
||||
@@ -80,7 +136,7 @@ watch(
|
||||
memory,
|
||||
],
|
||||
async () => {
|
||||
await edit(instance.value.path, editProfileObject.value)
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -90,17 +146,45 @@ const messages = defineMessages({
|
||||
id: 'instance.settings.tabs.java.java-installation',
|
||||
defaultMessage: 'Java installation',
|
||||
},
|
||||
customJavaInstallation: {
|
||||
id: 'instance.settings.tabs.java.custom-java-installation',
|
||||
defaultMessage: 'Custom Java installation',
|
||||
},
|
||||
javaPathPlaceholder: {
|
||||
id: 'instance.settings.tabs.java.java-path-placeholder',
|
||||
defaultMessage: '/path/to/java',
|
||||
},
|
||||
javaMemory: {
|
||||
id: 'instance.settings.tabs.java.java-memory',
|
||||
defaultMessage: 'Memory allocated',
|
||||
},
|
||||
customMemoryAllocation: {
|
||||
id: 'instance.settings.tabs.java.custom-memory-allocation',
|
||||
defaultMessage: 'Custom memory allocation',
|
||||
},
|
||||
javaArguments: {
|
||||
id: 'instance.settings.tabs.java.java-arguments',
|
||||
defaultMessage: 'Java arguments',
|
||||
},
|
||||
customJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.custom-java-arguments',
|
||||
defaultMessage: 'Custom Java arguments',
|
||||
},
|
||||
enterJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.enter-java-arguments',
|
||||
defaultMessage: 'Enter Java arguments...',
|
||||
},
|
||||
javaEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.environment-variables',
|
||||
defaultMessage: 'Environment variables',
|
||||
},
|
||||
javaMemory: {
|
||||
id: 'instance.settings.tabs.java.java-memory',
|
||||
defaultMessage: 'Memory allocated',
|
||||
customEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.custom-environment-variables',
|
||||
defaultMessage: 'Custom environment variables',
|
||||
},
|
||||
enterEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.enter-environment-variables',
|
||||
defaultMessage: 'Enter environmental variables...',
|
||||
},
|
||||
hooks: {
|
||||
id: 'instance.settings.tabs.java.hooks',
|
||||
@@ -111,43 +195,86 @@ const messages = defineMessages({
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 id="project-name" class="m-0 mb-2.5 text-lg font-semibold text-contrast block">
|
||||
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
|
||||
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaInstallation) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2.5" />
|
||||
<template v-if="!overrideJavaInstall">
|
||||
<div class="flex my-2 items-center gap-2 font-semibold">
|
||||
<template v-if="javaInstall">
|
||||
<CheckCircleIcon class="text-brand-green h-4 w-4" />
|
||||
<span>Using default Java {{ optimalJava.major_version }} installation:</span>
|
||||
</template>
|
||||
<template v-else-if="optimalJava">
|
||||
<XCircleIcon class="text-brand-red h-5 w-5" />
|
||||
<span
|
||||
>Could not find a default Java {{ optimalJava.major_version }} installation. Please set
|
||||
one below:</span
|
||||
<Checkbox
|
||||
v-model="overrideJavaInstall"
|
||||
:label="formatMessage(messages.customJavaInstallation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="flex gap-4 p-4 bg-bg rounded-2xl">
|
||||
<div class="flex gap-3 items-start flex-1 min-w-0">
|
||||
<div
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
|
||||
>
|
||||
<CoffeeIcon />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 flex-1 min-w-0">
|
||||
<span class="font-semibold leading-none mt-2"
|
||||
>Java {{ optimalJava?.parsed_version }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<XCircleIcon class="text-brand-red h-5 w-5" />
|
||||
<span
|
||||
>Could not automatically determine a Java installation to use. Please set one
|
||||
below:</span
|
||||
>
|
||||
</template>
|
||||
<div class="flex gap-2 items-center">
|
||||
<StyledInput
|
||||
:model-value="activePath"
|
||||
:disabled="!overrideJavaInstall"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.javaPathPlaceholder)"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
@update:model-value="(val) => (javaPath = String(val))"
|
||||
/>
|
||||
<ButtonStyled
|
||||
:color="
|
||||
!hoveringTest && !testingJava
|
||||
? javaTestResult === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard'
|
||||
"
|
||||
color-fill="text"
|
||||
>
|
||||
<button
|
||||
:disabled="!overrideJavaInstall || testingJava"
|
||||
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
|
||||
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
|
||||
@mouseleave="hoveringTest = false"
|
||||
>
|
||||
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
|
||||
<CheckCircleIcon
|
||||
v-else-if="javaTestResult === true && !hoveringTest"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
|
||||
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="overrideJavaInstall" class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDetectJava">
|
||||
<SearchIcon />
|
||||
Detect
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleBrowseJava">
|
||||
<FolderSearchIcon />
|
||||
Browse
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="javaInstall && !overrideJavaInstall"
|
||||
class="p-4 bg-bg rounded-xl text-xs text-secondary leading-none font-mono"
|
||||
>
|
||||
{{ javaInstall.path }}
|
||||
</div>
|
||||
</template>
|
||||
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
|
||||
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
|
||||
</div>
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaMemory) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2.5" />
|
||||
<Checkbox
|
||||
v-model="overrideMemorySettings"
|
||||
:label="formatMessage(messages.customMemoryAllocation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<Slider
|
||||
id="max-memory"
|
||||
v-model="memory.maximum"
|
||||
@@ -159,28 +286,36 @@ const messages = defineMessages({
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaArguments) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
|
||||
<Checkbox
|
||||
v-model="overrideJavaArgs"
|
||||
:label="formatMessage(messages.customJavaArguments)"
|
||||
class="my-2"
|
||||
/>
|
||||
<StyledInput
|
||||
id="java-args"
|
||||
v-model="javaArgs"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideJavaArgs"
|
||||
placeholder="Enter java arguments..."
|
||||
:placeholder="formatMessage(messages.enterJavaArguments)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaEnvironmentVariables) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2.5" />
|
||||
<Checkbox
|
||||
v-model="overrideEnvVars"
|
||||
:label="formatMessage(messages.customEnvironmentVariables)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<StyledInput
|
||||
id="env-vars"
|
||||
v-model="envVars"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideEnvVars"
|
||||
placeholder="Enter environmental variables..."
|
||||
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/profile'
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
@@ -32,7 +32,7 @@ const fullscreenSetting: Ref<boolean> = ref(
|
||||
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
|
||||
)
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
const editInstanceObject = computed(() => {
|
||||
if (!overrideWindowSettings.value) {
|
||||
return {
|
||||
force_fullscreen: null,
|
||||
@@ -48,7 +48,7 @@ const editProfileObject = computed(() => {
|
||||
watch(
|
||||
[overrideWindowSettings, resolution, fullscreenSetting],
|
||||
async () => {
|
||||
await edit(instance.value.path, editProfileObject.value)
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
import { type MinecraftAuthError, minecraftAuthErrors } from './minecraft-auth-errors'
|
||||
import { findMinecraftAuthError, type MinecraftAuthError } from './minecraft-auth-errors'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const rawError = ref<string>('')
|
||||
@@ -26,7 +26,7 @@ const loadingSignIn = ref(false)
|
||||
function show(errorVal: { message?: string }) {
|
||||
rawError.value = errorVal?.message ?? String(errorVal)
|
||||
|
||||
matchedError.value = minecraftAuthErrors.find((e) => rawError.value.includes(e.errorCode)) ?? null
|
||||
matchedError.value = findMinecraftAuthError(rawError.value)
|
||||
|
||||
debugCollapsed.value = true
|
||||
hide_ads_window()
|
||||
|
||||
+111
-1
@@ -1,10 +1,93 @@
|
||||
export interface MinecraftAuthError {
|
||||
errorCode: string
|
||||
errorCode?: string
|
||||
errorMatchers?: string[]
|
||||
matches?: (message: string) => boolean
|
||||
whatHappened: string
|
||||
stepsToFix: string[]
|
||||
}
|
||||
|
||||
export const minecraftAuthErrors: MinecraftAuthError[] = [
|
||||
{
|
||||
errorMatchers: ['Failed to deserialize response to JSON during step RefreshOAuthToken:'],
|
||||
whatHappened:
|
||||
'Your saved Microsoft sign-in token has expired or was revoked, so Modrinth App cannot refresh your Minecraft session.',
|
||||
stepsToFix: [
|
||||
'Sign out of the affected Minecraft account in Modrinth App',
|
||||
'Sign in to the account again',
|
||||
'Once the new sign-in finishes, try launching Minecraft again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorMatchers: ['Failed to deserialize response to JSON during step SisuAuthenticate:'],
|
||||
whatHappened:
|
||||
'Xbox services rejected the first sign-in response. This is most often caused by your system clock or time zone being out of sync.',
|
||||
stepsToFix: [
|
||||
'Open your system date and time settings',
|
||||
'Turn on automatic time zone and automatic time, if available',
|
||||
'Use the sync option in your system settings to synchronize the clock',
|
||||
'Restart Modrinth App',
|
||||
'Try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
matches: (message) =>
|
||||
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
|
||||
message.includes('429 Too Many Requests'),
|
||||
whatHappened:
|
||||
'Microsoft or Minecraft temporarily blocked the sign-in request because there were too many recent attempts.',
|
||||
stepsToFix: [
|
||||
'Wait about an hour before trying again',
|
||||
'Restart Modrinth App after waiting',
|
||||
'Try signing in once more',
|
||||
'If the same message appears, wait longer before retrying so the temporary limit can clear',
|
||||
],
|
||||
},
|
||||
{
|
||||
matches: (message) =>
|
||||
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
|
||||
/Status Code: 5\d\d/.test(message),
|
||||
whatHappened:
|
||||
"Minecraft's authentication service is returning a server error, so Modrinth App cannot finish signing you in right now.",
|
||||
stepsToFix: [
|
||||
'Wait a few minutes and try signing in again',
|
||||
'Check <a href="https://support.xbox.com/xbox-live-status">Xbox Status</a> for current service issues',
|
||||
'Try signing in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> to confirm whether Minecraft sign-in is also affected there',
|
||||
'If the service is healthy and this keeps happening, contact support with the debug information below',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorMatchers: ['Failed to fetch player profile'],
|
||||
whatHappened:
|
||||
'Minecraft services could not return a Java Edition profile for this account. This most often happens when the game was purchased recently, the Java profile has not finished being created, or the wrong Microsoft account is being used.',
|
||||
stepsToFix: [
|
||||
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
|
||||
'Launch Minecraft: Java Edition once from the official launcher',
|
||||
'Wait up to an hour if the purchase or profile setup was recent',
|
||||
'Make sure you are using the Microsoft account that owns Minecraft. See <a href="https://support.modrinth.com/en/articles/9409136-finding-the-right-xbox-account">Finding the right Xbox account</a> for help',
|
||||
'Try signing in to Modrinth App again',
|
||||
],
|
||||
},
|
||||
{
|
||||
matches: (message) =>
|
||||
message.includes('error sending request for url (') &&
|
||||
[
|
||||
'minecraft.net',
|
||||
'minecraftservices.com',
|
||||
'mojang.com',
|
||||
'xbox.com',
|
||||
'xboxlive.com',
|
||||
'live.com',
|
||||
].some((domain) => message.includes(domain)),
|
||||
whatHappened:
|
||||
'Modrinth App could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually caused by a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.',
|
||||
stepsToFix: [
|
||||
'Restart Modrinth App and try signing in again',
|
||||
'Check that your internet connection is working',
|
||||
'Allow Modrinth App through your firewall, antivirus, proxy, VPN, and hosts file rules',
|
||||
'Try a different network or temporarily disable VPN/proxy software if you use one',
|
||||
'If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorCode: '2148916222',
|
||||
whatHappened:
|
||||
@@ -87,4 +170,31 @@ export const minecraftAuthErrors: MinecraftAuthError[] = [
|
||||
'Once finished, try signing in again',
|
||||
],
|
||||
},
|
||||
{
|
||||
errorMatchers: ['Failed to deserialize response to JSON during step XstsAuthorize:'],
|
||||
whatHappened:
|
||||
'Xbox services rejected the request to authorize this account for Minecraft services, but did not return a specific account restriction that Modrinth App recognizes.',
|
||||
stepsToFix: [
|
||||
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
|
||||
'Complete any prompts shown by Microsoft, Xbox, or Minecraft',
|
||||
'Try signing in to Modrinth App again',
|
||||
'If the official launcher also fails, follow the error shown there or contact Xbox Support',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function findMinecraftAuthError(message: string): MinecraftAuthError | null {
|
||||
return (
|
||||
minecraftAuthErrors.find((error) => {
|
||||
if (error.errorCode && message.includes(error.errorCode)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (error.errorMatchers?.some((matcher) => message.includes(matcher))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return error.matches?.(message) ?? false
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ defineProps<{
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
size="24px"
|
||||
:tint-by="instance.path"
|
||||
:tint-by="instance.id"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
</span>
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
type TabbedModalTab,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import type { PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
@@ -26,12 +27,15 @@ import InstallationSettings from '@/components/ui/instance_settings/Installation
|
||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
|
||||
import { get_project_v3 } from '@/helpers/cache'
|
||||
import { get_linked_modpack_info } from '@/helpers/profile'
|
||||
import { get_linked_modpack_info } from '@/helpers/instance'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { provideInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
@@ -45,20 +49,26 @@ const isMinecraftServer = ref(false)
|
||||
const handleUnlinked = () => emit('unlinked')
|
||||
|
||||
const instanceRef = computed(() => props.instance)
|
||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
function hide() {
|
||||
tabbedModal.value?.hide()
|
||||
}
|
||||
|
||||
provideInstanceSettings({
|
||||
instance: instanceRef,
|
||||
offline: props.offline,
|
||||
isMinecraftServer,
|
||||
onUnlinked: handleUnlinked,
|
||||
closeModal: hide,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance,
|
||||
(instance) => {
|
||||
isMinecraftServer.value = false
|
||||
if (instance.linked_data?.project_id) {
|
||||
get_project_v3(instance.linked_data.project_id, 'must_revalidate')
|
||||
if (instance.link?.project_id) {
|
||||
get_project_v3(instance.link.project_id, 'must_revalidate')
|
||||
.then((project: Labrinth.Projects.v3.Project | undefined) => {
|
||||
if (project?.minecraft_server != null) {
|
||||
isMinecraftServer.value = true
|
||||
@@ -113,14 +123,50 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
function getSupportedModpackLoaders() {
|
||||
return get_loaders().then((value: PlatformTag[]) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
|
||||
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
|
||||
)
|
||||
}
|
||||
|
||||
// Preload
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'fabric'],
|
||||
queryFn: () => get_loader_versions('fabric'),
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'forge'],
|
||||
queryFn: () => get_loader_versions('forge'),
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'quilt'],
|
||||
queryFn: () => get_loader_versions('quilt'),
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'neo'],
|
||||
queryFn: () => get_loader_versions('neo'),
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'game-versions'],
|
||||
queryFn: get_game_versions,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loaders', 'modpack'],
|
||||
queryFn: getSupportedModpackLoaders,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', props.instance.id]),
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
enabled: computed(() => !!props.instance.link?.project_id && !props.offline),
|
||||
})
|
||||
|
||||
function show(tabIndex?: number) {
|
||||
if (props.instance.linked_data?.project_id) {
|
||||
if (props.instance.link?.project_id) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['linkedModpackInfo', props.instance.path],
|
||||
queryFn: () => get_linked_modpack_info(props.instance.path, 'stale_while_revalidate'),
|
||||
queryKey: ['linkedModpackInfo', props.instance.id],
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
})
|
||||
}
|
||||
tabbedModal.value?.show()
|
||||
@@ -129,7 +175,7 @@ function show(tabIndex?: number) {
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<TabbedModal
|
||||
@@ -143,7 +189,7 @@ defineExpose({ show })
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
size="24px"
|
||||
:tint-by="props.instance.path"
|
||||
:tint-by="props.instance.id"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
<span class="font-extrabold text-contrast">{{
|
||||
|
||||
@@ -68,17 +68,17 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'go-to-instance', instancePath: string): void
|
||||
(e: 'go-to-instance', instanceId: string): void
|
||||
(e: 'create-anyway'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const instanceName = ref('')
|
||||
const instancePath = ref('')
|
||||
const instanceId = ref('')
|
||||
|
||||
function show(name: string, path: string) {
|
||||
function show(name: string, id: string) {
|
||||
instanceName.value = name
|
||||
instancePath.value = path
|
||||
instanceId.value = id
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ function handleCancel() {
|
||||
|
||||
function handleGoToInstance() {
|
||||
modal.value?.hide()
|
||||
emit('go-to-instance', instancePath.value)
|
||||
emit('go-to-instance', instanceId.value)
|
||||
}
|
||||
|
||||
function handleCreateAnyway() {
|
||||
|
||||
@@ -31,7 +31,8 @@ import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { update_managed_modrinth_version } from '@/helpers/profile'
|
||||
import { wait_for_install_job } from '@/helpers/install'
|
||||
import { update_managed_modrinth_version } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
|
||||
@@ -202,15 +203,15 @@ async function computeDependencyDiffs(
|
||||
}
|
||||
|
||||
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
|
||||
if (!inst.linked_data) return null
|
||||
if (!modpackVersionId.value || !inst.linked_data.version_id) return null
|
||||
if (!inst.link) return null
|
||||
if (!modpackVersionId.value || !inst.link.version_id) return null
|
||||
|
||||
try {
|
||||
// For server projects, linked_data.project_id is the server project but
|
||||
// linked_data.version_id references a content modpack version from a different project.
|
||||
// Detect this by comparing the version's project_id with linked_data.project_id.
|
||||
// For server projects, link.project_id is the server project but
|
||||
// link.version_id references a content modpack version from a different project.
|
||||
// Detect this by comparing the version's project_id with link.project_id.
|
||||
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
|
||||
const instanceModpackVersion = await get_version(inst.linked_data.version_id, 'bypass')
|
||||
const instanceModpackVersion = await get_version(inst.link.version_id, 'bypass')
|
||||
|
||||
// Compute dependency diffs between current and latest version
|
||||
if (instanceModpackVersion && modpackVersion.value) {
|
||||
@@ -238,11 +239,12 @@ watch(
|
||||
|
||||
async function handleUpdate() {
|
||||
hide()
|
||||
const serverProjectId = instance.value?.linked_data?.project_id
|
||||
const serverProjectId = instance.value?.link?.project_id
|
||||
if (serverProjectId) startInstallingServer(serverProjectId)
|
||||
try {
|
||||
if (modpackVersionId.value && instance.value) {
|
||||
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
|
||||
const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value)
|
||||
await wait_for_install_job(job.job_id)
|
||||
await onUpdateComplete.value()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -253,10 +255,8 @@ async function handleUpdate() {
|
||||
}
|
||||
|
||||
function handleReport() {
|
||||
if (instance.value?.linked_data?.project_id) {
|
||||
openUrl(
|
||||
`https://modrinth.com/report?item=project&itemID=${instance.value.linked_data.project_id}`,
|
||||
)
|
||||
if (instance.value?.link?.project_id) {
|
||||
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.value.link.project_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,11 +297,8 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const hasUpdate = computed(() => {
|
||||
if (!instance.value?.linked_data) return false
|
||||
return (
|
||||
modpackVersionId.value != null &&
|
||||
modpackVersionId.value !== instance.value.linked_data.version_id
|
||||
)
|
||||
if (!instance.value?.link) return false
|
||||
return modpackVersionId.value != null && modpackVersionId.value !== instance.value.link.version_id
|
||||
})
|
||||
|
||||
defineExpose({ show, hide, hasUpdate })
|
||||
|
||||
@@ -10,8 +10,10 @@ import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
|
||||
const themeStore = useTheming()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const worldsInHomeFeatureFlag = 'worlds_in_home' as FeatureFlag
|
||||
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
|
||||
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
|
||||
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
|
||||
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
|
||||
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
|
||||
|
||||
const messages = defineMessages({
|
||||
colorThemeTitle: {
|
||||
@@ -100,6 +102,23 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
|
||||
},
|
||||
skipNonEssentialWarningsTitle: {
|
||||
id: 'app.appearance-settings.skip-non-essential-warnings.title',
|
||||
defaultMessage: 'Skip non-essential warnings',
|
||||
},
|
||||
skipNonEssentialWarningsDescription: {
|
||||
id: 'app.appearance-settings.skip-non-essential-warnings.description',
|
||||
defaultMessage:
|
||||
'Automatically skips low-risk confirmations like duplicate modpack installs, normal content deletion, bulk updates, unlinking modpacks, and repair prompts. Dangerous warnings will still be shown.',
|
||||
},
|
||||
showPlayTimeTitle: {
|
||||
id: 'app.appearance-settings.show-play-time.title',
|
||||
defaultMessage: 'Show play time',
|
||||
},
|
||||
showPlayTimeDescription: {
|
||||
id: 'app.appearance-settings.show-play-time.description',
|
||||
defaultMessage: `Displays how much time you've spent playing an instance.`,
|
||||
},
|
||||
})
|
||||
|
||||
const os = ref(await getOS())
|
||||
@@ -153,16 +172,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.hideNametagTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
|
||||
</div>
|
||||
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
@@ -183,6 +192,44 @@ watch(
|
||||
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.showPlayTimeTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.showPlayTimeDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag(showPlayTimeFlag)"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag(showPlayTimeFlag)
|
||||
themeStore.featureFlags[showPlayTimeFlag] = newValue
|
||||
settings.feature_flags[showPlayTimeFlag] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.hideNametagTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="hide-nametag-skins-page"
|
||||
:model-value="themeStore.hideNametagSkinsPage"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
themeStore.hideNametagSkinsPage = !!e
|
||||
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
@@ -217,12 +264,12 @@ watch(
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.jumpBackIntoWorldsDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag(worldsInHomeFeatureFlag)"
|
||||
:model-value="themeStore.getFeatureFlag(worldsInHomeFlag)"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag(worldsInHomeFeatureFlag)
|
||||
themeStore.featureFlags[worldsInHomeFeatureFlag] = newValue
|
||||
settings.feature_flags[worldsInHomeFeatureFlag] = newValue
|
||||
const newValue = !themeStore.getFeatureFlag(worldsInHomeFlag)
|
||||
themeStore.featureFlags[worldsInHomeFlag] = newValue
|
||||
settings.feature_flags[worldsInHomeFlag] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -236,13 +283,32 @@ watch(
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.unknownPackWarningDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)"
|
||||
:model-value="!themeStore.getFeatureFlag(skipUnknownPackWarningFlag)"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
const warnBeforeUnknownPackInstall = !!e
|
||||
const skipUnknownPackWarning = !warnBeforeUnknownPackInstall
|
||||
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
|
||||
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = skipUnknownPackWarning
|
||||
themeStore.featureFlags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
|
||||
settings.feature_flags[skipUnknownPackWarningFlag] = skipUnknownPackWarning
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.skipNonEssentialWarningsTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
|
||||
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
|
||||
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager, Slider, StyledInput } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { ref, watch } from 'vue'
|
||||
@@ -7,9 +7,13 @@ import { ref, watch } from 'vue'
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
import { purge_cache_types } from '@/helpers/cache.js'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const themeStore = useTheming()
|
||||
const settings = ref(await get())
|
||||
const purgeCacheConfirmModal = ref(null)
|
||||
|
||||
watch(
|
||||
settings,
|
||||
@@ -48,6 +52,19 @@ async function purgeCache() {
|
||||
]).catch(handleError)
|
||||
}
|
||||
|
||||
function handlePurgeCacheClick() {
|
||||
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
|
||||
void purgeCache()
|
||||
return
|
||||
}
|
||||
|
||||
purgeCacheConfirmModal.value?.show()
|
||||
}
|
||||
|
||||
async function openDbBackupsFolder() {
|
||||
await showAppDbBackupsFolder().catch(handleError)
|
||||
}
|
||||
|
||||
async function findLauncherDir() {
|
||||
const newDir = await open({
|
||||
multiple: false,
|
||||
@@ -97,7 +114,7 @@ async function findLauncherDir() {
|
||||
@proceed="purgeCache"
|
||||
/>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">App cache</h2>
|
||||
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
|
||||
<button id="purge-cache" class="btn min-w-max" @click="handlePurgeCacheClick">
|
||||
<TrashIcon />
|
||||
Purge cache
|
||||
</button>
|
||||
@@ -136,5 +153,16 @@ async function findLauncherDir() {
|
||||
value if you are frequently getting I/O errors. (app restart required to take effect)
|
||||
</p>
|
||||
</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">
|
||||
<FolderOpenIcon />
|
||||
Open backups folder
|
||||
</button>
|
||||
<p class="m-0 leading-tight text-secondary">
|
||||
Backups of important app data are stored here in case you need to recover them later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,149 +1,241 @@
|
||||
<template>
|
||||
<UploadSkinModal ref="uploadModal" />
|
||||
<ModalWrapper ref="modal" @on-hide="resetState">
|
||||
<NewModal ref="modal" :on-hide="handleModalHide">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
|
||||
{{ formatMessage(mode === 'edit' ? messages.editSkinTitle : messages.addSkinTitle) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-[25rem] w-[16rem] min-w-[16rem] flex-shrink-0 md:self-center">
|
||||
<SkinPreviewRenderer
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
framing="modal"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Texture</h2>
|
||||
<section v-if="mode === 'edit' && canEditTextureAndModel">
|
||||
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
|
||||
<ButtonStyled>
|
||||
<button @click="openUploadSkinModal"><UploadIcon /> Replace texture</button>
|
||||
<button class="!shadow-none" @click="openTextureFileBrowser">
|
||||
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<input
|
||||
ref="textureFileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="onTextureFileInputChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Arm style</h2>
|
||||
<section v-if="canEditTextureAndModel">
|
||||
<h2 class="text-base font-semibold mb-2">
|
||||
{{ formatMessage(messages.armStyleSection) }}
|
||||
</h2>
|
||||
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
|
||||
<template #default="{ item }">
|
||||
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
|
||||
{{
|
||||
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
|
||||
}}
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Cape</h2>
|
||||
<div class="flex gap-2">
|
||||
<CapeButton
|
||||
v-if="defaultCape"
|
||||
:id="defaultCape.id"
|
||||
:texture="defaultCape.texture"
|
||||
:name="undefined"
|
||||
:selected="!selectedCape"
|
||||
faded
|
||||
@select="selectCape(undefined)"
|
||||
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.capeSection) }}</h2>
|
||||
<div class="relative w-fit max-w-full">
|
||||
<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-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<span>Use default cape</span>
|
||||
</CapeButton>
|
||||
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
|
||||
<span>Use default cape</span>
|
||||
</CapeLikeTextButton>
|
||||
<div
|
||||
v-if="showCapeTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<CapeButton
|
||||
v-for="cape in visibleCapeList"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:texture="cape.texture"
|
||||
:name="cape.name || 'Cape'"
|
||||
:selected="selectedCape?.id === cape.id"
|
||||
@select="selectCape(cape)"
|
||||
/>
|
||||
|
||||
<CapeLikeTextButton
|
||||
v-if="(capes?.length ?? 0) > 2"
|
||||
tooltip="View more capes"
|
||||
@mouseup="openSelectCapeModal"
|
||||
<div
|
||||
ref="capeListRef"
|
||||
class="grid grid-cols-[repeat(4,max-content)] auto-rows-max gap-2 overflow-y-auto pr-1"
|
||||
:style="{ maxHeight: capeListMaxHeight }"
|
||||
@scroll="checkCapeScrollState"
|
||||
>
|
||||
<template #icon><ChevronRightIcon /></template>
|
||||
<span>More</span>
|
||||
</CapeLikeTextButton>
|
||||
<CapeLikeTextButton
|
||||
:tooltip="formatMessage(messages.noCapeTooltip)"
|
||||
:highlighted="!selectedCape"
|
||||
@click="selectCape(undefined)"
|
||||
>
|
||||
<template #icon><XIcon /></template>
|
||||
<span>{{ formatMessage(messages.noneCapeOption) }}</span>
|
||||
</CapeLikeTextButton>
|
||||
|
||||
<CapeButton
|
||||
v-for="cape in sortedCapes"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:texture="cape.texture"
|
||||
:name="cape.name || formatMessage(messages.capeFallbackName)"
|
||||
:selected="selectedCape?.id === cape.id"
|
||||
@select="selectCape(cape)"
|
||||
/>
|
||||
</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-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showCapeBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-12">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<CheckIcon v-else-if="mode === 'new'" />
|
||||
<SaveIcon v-else />
|
||||
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="isSaving" @click="hide"><XIcon />Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
|
||||
<SelectCapeModal
|
||||
ref="selectCapeModal"
|
||||
:capes="capes || []"
|
||||
@select="handleCapeSelected"
|
||||
@cancel="handleCapeCancel"
|
||||
/>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="isSaving" @click="hide">
|
||||
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<CheckIcon v-else-if="mode === 'new'" />
|
||||
<SaveIcon v-else />
|
||||
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
RadioButtons,
|
||||
SkinPreviewRenderer,
|
||||
useScrollIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
import {
|
||||
add_and_equip_custom_skin,
|
||||
type Cape,
|
||||
determineModelType,
|
||||
equip_skin,
|
||||
get_normalized_skin_texture,
|
||||
remove_custom_skin,
|
||||
normalize_skin_texture,
|
||||
save_custom_skin,
|
||||
type Skin,
|
||||
type SkinModel,
|
||||
type SkinTextureUrl,
|
||||
unequip_skin,
|
||||
} from '@/helpers/skins.ts'
|
||||
|
||||
const CAPE_LIST_MAX_HEIGHT = 334
|
||||
const messages = defineMessages({
|
||||
editSkinTitle: {
|
||||
id: 'app.skins.modal.edit-title',
|
||||
defaultMessage: 'Editing skin',
|
||||
},
|
||||
addSkinTitle: {
|
||||
id: 'app.skins.modal.add-title',
|
||||
defaultMessage: 'Adding a skin',
|
||||
},
|
||||
textureSection: {
|
||||
id: 'app.skins.modal.texture-section',
|
||||
defaultMessage: 'Texture',
|
||||
},
|
||||
replaceTextureButton: {
|
||||
id: 'app.skins.modal.replace-texture-button',
|
||||
defaultMessage: 'Replace texture',
|
||||
},
|
||||
armStyleSection: {
|
||||
id: 'app.skins.modal.arm-style-section',
|
||||
defaultMessage: 'Arm style',
|
||||
},
|
||||
wideArmStyle: {
|
||||
id: 'app.skins.modal.arm-style-wide',
|
||||
defaultMessage: 'Wide',
|
||||
},
|
||||
slimArmStyle: {
|
||||
id: 'app.skins.modal.arm-style-slim',
|
||||
defaultMessage: 'Slim',
|
||||
},
|
||||
capeSection: {
|
||||
id: 'app.skins.modal.cape-section',
|
||||
defaultMessage: 'Cape',
|
||||
},
|
||||
noCapeTooltip: {
|
||||
id: 'app.skins.modal.no-cape-tooltip',
|
||||
defaultMessage: 'No cape',
|
||||
},
|
||||
noneCapeOption: {
|
||||
id: 'app.skins.modal.none-cape-option',
|
||||
defaultMessage: 'None',
|
||||
},
|
||||
capeFallbackName: {
|
||||
id: 'app.skins.modal.cape-fallback-name',
|
||||
defaultMessage: 'Cape',
|
||||
},
|
||||
savingTooltip: {
|
||||
id: 'app.skins.modal.saving-tooltip',
|
||||
defaultMessage: 'Saving...',
|
||||
},
|
||||
uploadSkinFirstTooltip: {
|
||||
id: 'app.skins.modal.upload-skin-first-tooltip',
|
||||
defaultMessage: 'Upload a skin first!',
|
||||
},
|
||||
makeEditFirstTooltip: {
|
||||
id: 'app.skins.modal.make-edit-first-tooltip',
|
||||
defaultMessage: 'Make an edit to the skin first!',
|
||||
},
|
||||
addSkinButton: {
|
||||
id: 'app.skins.modal.add-skin-button',
|
||||
defaultMessage: 'Add skin',
|
||||
},
|
||||
saveSkinButton: {
|
||||
id: 'app.skins.modal.save-skin-button',
|
||||
defaultMessage: 'Save skin',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const textureFileInput = useTemplateRef<HTMLInputElement>('textureFileInput')
|
||||
const capeListRef = ref<HTMLElement | null>(null)
|
||||
const capeListMaxHeight = ref(`${CAPE_LIST_MAX_HEIGHT}px`)
|
||||
const mode = ref<'new' | 'edit'>('new')
|
||||
const currentSkin = ref<Skin | null>(null)
|
||||
const shouldRestoreModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
|
||||
@@ -151,10 +243,49 @@ const previewSkin = ref<string>('')
|
||||
|
||||
const variant = ref<SkinModel>('CLASSIC')
|
||||
const selectedCape = ref<Cape | undefined>(undefined)
|
||||
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
|
||||
const props = defineProps<{ capes?: Cape[] }>()
|
||||
|
||||
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
|
||||
const visibleCapeList = ref<Cape[]>([])
|
||||
const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default')
|
||||
const {
|
||||
showTopFade: showCapeTopFade,
|
||||
showBottomFade: showCapeBottomFade,
|
||||
checkScrollState: checkCapeScrollState,
|
||||
forceCheck: forceCapeScrollCheck,
|
||||
} = useScrollIndicator(capeListRef)
|
||||
|
||||
let capeListLayoutFrame: number | null = null
|
||||
function updateCapeListLayout() {
|
||||
const capeList = capeListRef.value
|
||||
const modalContent = capeList?.closest('[data-modal-content]') as HTMLElement | null
|
||||
|
||||
if (!capeList || !modalContent) {
|
||||
capeListMaxHeight.value = `${CAPE_LIST_MAX_HEIGHT}px`
|
||||
forceCapeScrollCheck()
|
||||
return
|
||||
}
|
||||
|
||||
const availableHeight =
|
||||
modalContent.getBoundingClientRect().bottom - capeList.getBoundingClientRect().top
|
||||
|
||||
capeListMaxHeight.value = `${Math.min(
|
||||
CAPE_LIST_MAX_HEIGHT,
|
||||
Math.max(0, Math.floor(availableHeight)),
|
||||
)}px`
|
||||
|
||||
nextTick(() => forceCapeScrollCheck())
|
||||
}
|
||||
|
||||
function refreshCapeListLayout() {
|
||||
if (capeListLayoutFrame !== null) {
|
||||
cancelAnimationFrame(capeListLayoutFrame)
|
||||
}
|
||||
|
||||
capeListLayoutFrame = requestAnimationFrame(() => {
|
||||
capeListLayoutFrame = null
|
||||
updateCapeListLayout()
|
||||
})
|
||||
}
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...(props.capes || [])].sort((a, b) => {
|
||||
@@ -164,32 +295,6 @@ const sortedCapes = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
function initVisibleCapeList() {
|
||||
if (!props.capes || props.capes.length === 0) {
|
||||
visibleCapeList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (visibleCapeList.value.length === 0) {
|
||||
if (selectedCape.value) {
|
||||
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
|
||||
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
|
||||
} else {
|
||||
visibleCapeList.value = getSortedCapes(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSortedCapes(count: number): Cape[] {
|
||||
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
|
||||
return sortedCapes.value.slice(0, count)
|
||||
}
|
||||
|
||||
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
|
||||
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
|
||||
return sortedCapes.value.find((cape) => cape.id !== excludeId)
|
||||
}
|
||||
|
||||
async function loadPreviewSkin() {
|
||||
if (uploadedTextureUrl.value) {
|
||||
previewSkin.value = uploadedTextureUrl.value.normalized
|
||||
@@ -221,9 +326,13 @@ const disableSave = computed(
|
||||
)
|
||||
|
||||
const saveTooltip = computed(() => {
|
||||
if (isSaving.value) return 'Saving...'
|
||||
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
|
||||
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
|
||||
if (isSaving.value) return formatMessage(messages.savingTooltip)
|
||||
if (mode.value === 'new' && !uploadedTextureUrl.value) {
|
||||
return formatMessage(messages.uploadSkinFirstTooltip)
|
||||
}
|
||||
if (mode.value === 'edit' && !hasEdits.value) {
|
||||
return formatMessage(messages.makeEditFirstTooltip)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -234,11 +343,13 @@ function resetState() {
|
||||
previewSkin.value = ''
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
shouldRestoreModal.value = false
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
function handleModalHide() {
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
async function show(e: MouseEvent, skin?: Skin) {
|
||||
mode.value = skin ? 'edit' : 'new'
|
||||
currentSkin.value = skin ?? null
|
||||
@@ -249,12 +360,11 @@ async function show(e: MouseEvent, skin?: Skin) {
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
}
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
}
|
||||
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
|
||||
@@ -263,98 +373,54 @@ async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = await determineModelType(skinTextureUrl.original)
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
}
|
||||
|
||||
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
async function setUploadedTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
function selectCape(cape: Cape | undefined) {
|
||||
if (cape && selectedCape.value?.id !== cape.id) {
|
||||
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
|
||||
if (!isInVisibleList && visibleCapeList.value.length > 0) {
|
||||
visibleCapeList.value.splice(0, 1, cape)
|
||||
|
||||
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
|
||||
const otherCape = getSortedCapeExcluding(cape.id)
|
||||
if (otherCape) {
|
||||
visibleCapeList.value.splice(1, 1, otherCape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedCape.value = cape
|
||||
}
|
||||
|
||||
function handleCapeSelected(cape: Cape | undefined) {
|
||||
selectCape(cape)
|
||||
function openTextureFileBrowser() {
|
||||
textureFileInput.value?.click()
|
||||
}
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
async function onTextureFileInputChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
const file = files?.[0]
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function handleCapeCancel() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectCapeModal(e: MouseEvent) {
|
||||
if (!selectCapeModal.value) return
|
||||
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
|
||||
setTimeout(() => {
|
||||
selectCapeModal.value?.show(
|
||||
e,
|
||||
currentSkin.value?.texture_key,
|
||||
selectedCape.value,
|
||||
previewSkin.value,
|
||||
variant.value,
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
emit('open-upload-modal', e)
|
||||
}
|
||||
|
||||
function restoreModal() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
modal.value?.show(fakeEvent)
|
||||
shouldRestoreModal.value = false
|
||||
}, 500)
|
||||
try {
|
||||
const originalSkinTexUrl = `data:image/png;base64,${arrayBufferToBase64(
|
||||
await file.arrayBuffer(),
|
||||
)}`
|
||||
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
|
||||
await setUploadedTexture({
|
||||
original: originalSkinTexUrl,
|
||||
normalized: `data:image/png;base64,${arrayBufferToBase64(skinTextureNormalized)}`,
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
if (textureFileInput.value) {
|
||||
textureFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,17 +436,45 @@ async function save() {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
await unequip_skin()
|
||||
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved')
|
||||
const addedSkin = await save_custom_skin(
|
||||
{
|
||||
texture_key: '',
|
||||
variant: variant.value,
|
||||
cape_id: selectedCape.value?.id,
|
||||
texture: textureUrl,
|
||||
source: 'custom',
|
||||
is_equipped: false,
|
||||
},
|
||||
bytes,
|
||||
variant.value,
|
||||
selectedCape.value,
|
||||
true,
|
||||
)
|
||||
emit('saved', {
|
||||
applied: false,
|
||||
skin: addedSkin,
|
||||
})
|
||||
} else {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
await remove_custom_skin(currentSkin.value!)
|
||||
emit('saved')
|
||||
const updatedSkin = await save_custom_skin(
|
||||
currentSkin.value!,
|
||||
bytes,
|
||||
variant.value,
|
||||
selectedCape.value,
|
||||
!!uploadedTextureUrl.value && textureUrl !== currentSkin.value?.texture,
|
||||
)
|
||||
|
||||
if (currentSkin.value?.is_equipped) {
|
||||
await equip_skin(updatedSkin)
|
||||
}
|
||||
|
||||
emit('saved', {
|
||||
applied: !!currentSkin.value?.is_equipped,
|
||||
skin: updatedSkin,
|
||||
previousSkin: currentSkin.value!,
|
||||
})
|
||||
}
|
||||
|
||||
hide()
|
||||
@@ -393,28 +487,53 @@ async function save() {
|
||||
|
||||
watch([uploadedTextureUrl, currentSkin], async () => {
|
||||
await loadPreviewSkin()
|
||||
refreshCapeListLayout()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.capes,
|
||||
() => {
|
||||
initVisibleCapeList()
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
capeListRef,
|
||||
(capeList, _, onCleanup) => {
|
||||
if (!capeList) return
|
||||
|
||||
const modalContent = capeList.closest('[data-modal-content]')
|
||||
const resizeObserver = new ResizeObserver(() => refreshCapeListLayout())
|
||||
|
||||
if (modalContent instanceof HTMLElement) {
|
||||
resizeObserver.observe(modalContent)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', refreshCapeListLayout, { passive: true })
|
||||
refreshCapeListLayout()
|
||||
|
||||
onCleanup(() => {
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', refreshCapeListLayout)
|
||||
|
||||
if (capeListLayoutFrame !== null) {
|
||||
cancelAnimationFrame(capeListLayoutFrame)
|
||||
capeListLayoutFrame = null
|
||||
}
|
||||
})
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'saved'): void
|
||||
(event: 'saved', options: { applied: boolean; skin?: Skin; previousSkin?: Skin }): void
|
||||
(event: 'deleted', skin: Skin): void
|
||||
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showNew,
|
||||
restoreWithNewTexture,
|
||||
hide,
|
||||
shouldRestoreModal,
|
||||
restoreModal,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
ScrollablePanel,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import type { Cape, SkinModel } from '@/helpers/skins.ts'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', cape: Cape | undefined): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
capes: Cape[]
|
||||
}>()
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...props.capes].sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase()
|
||||
const nameB = (b.name || '').toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
const currentSkinId = ref<string | undefined>()
|
||||
const currentSkinTexture = ref<string | undefined>()
|
||||
const currentSkinVariant = ref<SkinModel>('CLASSIC')
|
||||
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
|
||||
const currentCape = ref<Cape | undefined>()
|
||||
|
||||
function show(
|
||||
e: MouseEvent,
|
||||
skinId?: string,
|
||||
selected?: Cape,
|
||||
skinTexture?: string,
|
||||
variant?: SkinModel,
|
||||
) {
|
||||
currentSkinId.value = skinId
|
||||
currentSkinTexture.value = skinTexture
|
||||
currentSkinVariant.value = variant || 'CLASSIC'
|
||||
currentCape.value = selected
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function select() {
|
||||
emit('select', currentCape.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function updateSelectedCape(cape: Cape | undefined) {
|
||||
currentCape.value = cape
|
||||
}
|
||||
|
||||
function onModalHide() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="onModalHide">
|
||||
<template #title>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-extrabold text-heading">Change cape</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
v-if="currentSkinTexture"
|
||||
:cape-src="currentCapeTexture"
|
||||
:texture-src="currentSkinTexture"
|
||||
:variant="currentSkinVariant"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI + Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full my-auto">
|
||||
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
|
||||
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
|
||||
<CapeLikeTextButton
|
||||
tooltip="No Cape"
|
||||
:highlighted="!currentCape"
|
||||
@click="updateSelectedCape(undefined)"
|
||||
>
|
||||
<template #icon>
|
||||
<XIcon />
|
||||
</template>
|
||||
<span>None</span>
|
||||
</CapeLikeTextButton>
|
||||
<CapeButton
|
||||
v-for="cape in sortedCapes"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:name="cape.name"
|
||||
:texture="cape.texture"
|
||||
:selected="currentCape?.id === cape.id"
|
||||
@select="updateSelectedCape(cape)"
|
||||
/>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="select">
|
||||
<CheckIcon />
|
||||
Select
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="hide(true)">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
|
||||
</template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
|
||||
<UploadIcon /> Select skin texture file
|
||||
</p>
|
||||
<p class="mx-auto mt-0 text-secondary text-sm text-center">
|
||||
Drag and drop or click here to browse
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="handleInputFileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_dragged_skin_data } from '@/helpers/skins'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const modal = ref()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const unlisten = ref<() => void>()
|
||||
const modalVisible = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploaded', data: ArrayBuffer): void
|
||||
(e: 'canceled'): void
|
||||
}>()
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
modalVisible.value = true
|
||||
setupDragDropListener()
|
||||
}
|
||||
|
||||
function hide(emitCanceled = false) {
|
||||
modal.value?.hide()
|
||||
modalVisible.value = false
|
||||
cleanupDragDropListener()
|
||||
resetState()
|
||||
if (emitCanceled) {
|
||||
emit('canceled')
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleInputFileChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
const file = files[0]
|
||||
const buffer = await file.arrayBuffer()
|
||||
await processData(buffer)
|
||||
}
|
||||
|
||||
async function setupDragDropListener() {
|
||||
try {
|
||||
if (modalVisible.value) {
|
||||
await cleanupDragDropListener()
|
||||
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.payload.paths || event.payload.paths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = event.payload.paths[0]
|
||||
|
||||
try {
|
||||
const data = await get_dragged_skin_data(filePath)
|
||||
await processData(data.buffer)
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
title: 'Error processing file',
|
||||
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set up drag and drop listener:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDragDropListener() {
|
||||
if (unlisten.value) {
|
||||
unlisten.value()
|
||||
unlisten.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function processData(buffer: ArrayBuffer) {
|
||||
emit('uploaded', buffer)
|
||||
hide()
|
||||
}
|
||||
|
||||
watch(modalVisible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
setupDragDropListener()
|
||||
} else {
|
||||
cleanupDragDropListener()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDragDropListener()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,578 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
SkinButton,
|
||||
SkinLikeTextButton,
|
||||
useScrollViewport,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Skin } from '@/helpers/skins.ts'
|
||||
|
||||
type SkinSectionKind = 'saved' | 'default'
|
||||
type SkinLikeTextButtonExpose = {
|
||||
getRootElement: () => HTMLElement | null | undefined
|
||||
}
|
||||
type AddSkinButtonRef = SkinLikeTextButtonExpose | SkinLikeTextButtonExpose[]
|
||||
|
||||
interface DefaultSkinSection {
|
||||
title: string
|
||||
infoTooltip?: string
|
||||
skins: Skin[]
|
||||
}
|
||||
|
||||
interface SkinSection {
|
||||
key: string
|
||||
title: string
|
||||
kind: SkinSectionKind
|
||||
infoTooltip?: string
|
||||
skins: Skin[]
|
||||
}
|
||||
|
||||
interface VirtualSkinSection {
|
||||
section: SkinSection
|
||||
top: number
|
||||
index: number
|
||||
}
|
||||
|
||||
const SKIN_CARD_ASPECT_WIDTH = 31
|
||||
const SKIN_CARD_ASPECT_HEIGHT = 40
|
||||
const SKIN_GRID_GAP = 12
|
||||
const SKIN_SECTION_FIRST_SPACING = 4
|
||||
const SKIN_SECTION_SPACING = 24
|
||||
const SKIN_SECTION_HEADER_HEIGHT = 28
|
||||
const SKIN_SECTION_CONTENT_SPACING = 8
|
||||
const SKIN_SECTION_OVERSCAN = 900
|
||||
const FALLBACK_CARD_WIDTH = 220
|
||||
const messages = defineMessages({
|
||||
savedSkinsSection: {
|
||||
id: 'app.skins.section.saved-skins',
|
||||
defaultMessage: 'Saved skins',
|
||||
},
|
||||
addSkinButton: {
|
||||
id: 'app.skins.add-button',
|
||||
defaultMessage: 'Add skin',
|
||||
},
|
||||
dragAndDropSubtitle: {
|
||||
id: 'app.skins.add-button.drag-and-drop',
|
||||
defaultMessage: 'Drag and drop',
|
||||
},
|
||||
editSkinButton: {
|
||||
id: 'app.skins.edit-button',
|
||||
defaultMessage: 'Edit skin',
|
||||
},
|
||||
deleteSkinButton: {
|
||||
id: 'app.skins.delete-button',
|
||||
defaultMessage: 'Delete skin',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
savedSkins: Skin[]
|
||||
defaultSkinSections: DefaultSkinSection[]
|
||||
getBakedSkinTextures: (skin: Skin) => RenderResult | undefined
|
||||
isSkinSelected: (skin: Skin) => boolean
|
||||
isSkinActive: (skin: Skin) => boolean
|
||||
isAddSkinButtonDragActive: boolean
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [skin: Skin]
|
||||
edit: [skin: Skin, event: MouseEvent]
|
||||
delete: [skin: Skin]
|
||||
'reorder-saved-skins': [skins: Skin[]]
|
||||
'add-skin': []
|
||||
'add-skin-dragenter': [event: DragEvent]
|
||||
'add-skin-dragover': [event: DragEvent]
|
||||
'add-skin-dragleave': [event: DragEvent]
|
||||
'add-skin-drop': [event: DragEvent]
|
||||
}>()
|
||||
|
||||
const addSkinButton = useTemplateRef<AddSkinButtonRef>('addSkinButton')
|
||||
const { formatMessage } = useVIntl()
|
||||
const { listContainer, relativeScrollTop, scrollContainer, viewportHeight } = useScrollViewport()
|
||||
const openSectionKeys = ref<Set<string>>(new Set())
|
||||
const hasSettledInitialLayout = ref(false)
|
||||
const knownSectionKeys = new Set<string>()
|
||||
let enableLayoutTransitionsFrame: number | null = null
|
||||
let isEnableLayoutTransitionsScheduled = false
|
||||
let isUnmounted = false
|
||||
|
||||
const { width: listWidth } = useElementSize(listContainer)
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
|
||||
const columnCount = computed(() => {
|
||||
if (windowWidth.value >= 2050) {
|
||||
return 6
|
||||
}
|
||||
|
||||
if (windowWidth.value >= 1750) {
|
||||
return 5
|
||||
}
|
||||
|
||||
if (windowWidth.value >= 1300) {
|
||||
return 4
|
||||
}
|
||||
|
||||
return 3
|
||||
})
|
||||
|
||||
const cardWidth = computed(() => {
|
||||
if (listWidth.value <= 0) {
|
||||
return FALLBACK_CARD_WIDTH
|
||||
}
|
||||
|
||||
const gapsWidth = (columnCount.value - 1) * SKIN_GRID_GAP
|
||||
return Math.max(0, (listWidth.value - gapsWidth) / columnCount.value)
|
||||
})
|
||||
|
||||
const cardHeight = computed(
|
||||
() => (cardWidth.value * SKIN_CARD_ASPECT_HEIGHT) / SKIN_CARD_ASPECT_WIDTH,
|
||||
)
|
||||
|
||||
const sections = computed<SkinSection[]>(() => [
|
||||
{
|
||||
key: 'saved-skins',
|
||||
title: formatMessage(messages.savedSkinsSection),
|
||||
kind: 'saved',
|
||||
skins: props.savedSkins,
|
||||
},
|
||||
...props.defaultSkinSections.map((section) => ({
|
||||
key: defaultSkinSectionKey(section.title),
|
||||
title: section.title,
|
||||
kind: 'default' as const,
|
||||
infoTooltip: section.infoTooltip,
|
||||
skins: section.skins,
|
||||
})),
|
||||
])
|
||||
|
||||
const draggableSavedSkins = ref<Skin[]>([])
|
||||
const isDraggingSavedSkin = ref(false)
|
||||
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
|
||||
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
|
||||
|
||||
const sectionLayouts = computed(() => {
|
||||
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
|
||||
let top = 0
|
||||
|
||||
sections.value.forEach((section, index) => {
|
||||
const height = getSectionHeightEstimate(section, index)
|
||||
layouts.push({ section, top, height, index })
|
||||
top += height
|
||||
})
|
||||
|
||||
return layouts
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
const lastSection = sectionLayouts.value[sectionLayouts.value.length - 1]
|
||||
return lastSection ? lastSection.top + lastSection.height : 0
|
||||
})
|
||||
|
||||
const visibleSections = computed<VirtualSkinSection[]>(() => {
|
||||
if (!listContainer.value || !scrollContainer.value) {
|
||||
return sectionLayouts.value.slice(0, 4)
|
||||
}
|
||||
|
||||
const viewportStart = Math.max(0, relativeScrollTop.value - SKIN_SECTION_OVERSCAN)
|
||||
const viewportEnd = relativeScrollTop.value + viewportHeight.value + SKIN_SECTION_OVERSCAN
|
||||
|
||||
return sectionLayouts.value
|
||||
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
|
||||
.map(({ section, top, index }) => ({ section, top, index }))
|
||||
})
|
||||
|
||||
watch(
|
||||
sections,
|
||||
(nextSections) => {
|
||||
const sectionKeys = new Set(nextSections.map((section) => section.key))
|
||||
const openKeys = new Set(openSectionKeys.value)
|
||||
|
||||
for (const section of nextSections) {
|
||||
if (!knownSectionKeys.has(section.key)) {
|
||||
knownSectionKeys.add(section.key)
|
||||
openKeys.add(section.key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of knownSectionKeys) {
|
||||
if (!sectionKeys.has(key)) {
|
||||
knownSectionKeys.delete(key)
|
||||
openKeys.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
openSectionKeys.value = openKeys
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.savedSkins,
|
||||
(nextSkins) => {
|
||||
if (isDraggingSavedSkin.value) {
|
||||
return
|
||||
}
|
||||
|
||||
draggableSavedSkins.value = nextSkins.filter(canDragSavedSkin)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
listWidth,
|
||||
(width) => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
width <= 0 ||
|
||||
hasSettledInitialLayout.value ||
|
||||
isEnableLayoutTransitionsScheduled
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
isEnableLayoutTransitionsScheduled = true
|
||||
void nextTick(() => {
|
||||
if (isUnmounted) return
|
||||
|
||||
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
|
||||
if (isUnmounted) return
|
||||
|
||||
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
|
||||
if (isUnmounted) return
|
||||
|
||||
hasSettledInitialLayout.value = true
|
||||
enableLayoutTransitionsFrame = null
|
||||
isEnableLayoutTransitionsScheduled = false
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
|
||||
if (enableLayoutTransitionsFrame !== null) {
|
||||
window.cancelAnimationFrame(enableLayoutTransitionsFrame)
|
||||
}
|
||||
})
|
||||
|
||||
function defaultSkinSectionKey(title: string) {
|
||||
return `default-skins-${title}`
|
||||
}
|
||||
|
||||
function skinKey(skin: Skin, prefix: string) {
|
||||
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
|
||||
}
|
||||
|
||||
function savedSkinKey(skin: Skin) {
|
||||
return skinKey(skin, 'saved-skin')
|
||||
}
|
||||
|
||||
function canDragSavedSkin(skin: Skin) {
|
||||
return skin.source === 'custom' || skin.source === 'custom_external'
|
||||
}
|
||||
|
||||
function doSkinOrdersMatch(firstSkins: Skin[], secondSkins: Skin[]) {
|
||||
const draggableSecondSkins = secondSkins.filter(canDragSavedSkin)
|
||||
|
||||
return (
|
||||
firstSkins.length === draggableSecondSkins.length &&
|
||||
firstSkins.every(
|
||||
(skin, index) => savedSkinKey(skin) === savedSkinKey(draggableSecondSkins[index]),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function onSavedSkinDragStart() {
|
||||
isDraggingSavedSkin.value = true
|
||||
}
|
||||
|
||||
function onSavedSkinDragEnd() {
|
||||
isDraggingSavedSkin.value = false
|
||||
|
||||
if (doSkinOrdersMatch(draggableSavedSkins.value, props.savedSkins)) {
|
||||
draggableSavedSkins.value = props.savedSkins.filter(canDragSavedSkin)
|
||||
return
|
||||
}
|
||||
|
||||
emit('reorder-saved-skins', [...draggableSavedSkins.value])
|
||||
}
|
||||
|
||||
function isSectionOpen(key: string) {
|
||||
return openSectionKeys.value.has(key)
|
||||
}
|
||||
|
||||
function setSectionOpen(key: string, open: boolean) {
|
||||
const openKeys = new Set(openSectionKeys.value)
|
||||
|
||||
if (open) {
|
||||
openKeys.add(key)
|
||||
} else {
|
||||
openKeys.delete(key)
|
||||
}
|
||||
|
||||
openSectionKeys.value = openKeys
|
||||
}
|
||||
|
||||
function getSectionHeightEstimate(section: SkinSection, index: number) {
|
||||
const spacing = index === 0 ? SKIN_SECTION_FIRST_SPACING : SKIN_SECTION_SPACING
|
||||
|
||||
if (!isSectionOpen(section.key)) {
|
||||
return spacing + SKIN_SECTION_HEADER_HEIGHT
|
||||
}
|
||||
|
||||
const cardCount = section.kind === 'saved' ? section.skins.length + 1 : section.skins.length
|
||||
const rowCount = Math.ceil(cardCount / columnCount.value)
|
||||
const gridHeight = rowCount * cardHeight.value + Math.max(0, rowCount - 1) * SKIN_GRID_GAP
|
||||
|
||||
return spacing + SKIN_SECTION_HEADER_HEIGHT + SKIN_SECTION_CONTENT_SPACING + gridHeight
|
||||
}
|
||||
|
||||
function getAddSkinButtonElement() {
|
||||
const button = Array.isArray(addSkinButton.value)
|
||||
? addSkinButton.value.find((candidate) => candidate.getRootElement())
|
||||
: addSkinButton.value
|
||||
|
||||
return button?.getRootElement()
|
||||
}
|
||||
|
||||
defineExpose({ getAddSkinButtonElement })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="listContainer"
|
||||
class="relative w-full"
|
||||
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div
|
||||
v-for="{ section, top, index } in visibleSections"
|
||||
:key="section.key"
|
||||
class="absolute inset-x-0"
|
||||
:class="[
|
||||
index === 0 ? 'pt-1' : 'pt-6',
|
||||
hasSettledInitialLayout
|
||||
? 'transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none'
|
||||
: '',
|
||||
]"
|
||||
:style="{ transform: `translateY(${top}px)` }"
|
||||
>
|
||||
<Accordion
|
||||
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 border-none cursor-pointer text-left"
|
||||
content-class="pt-2"
|
||||
:open-by-default="isSectionOpen(section.key)"
|
||||
@on-open="setSectionOpen(section.key, true)"
|
||||
@on-close="setSectionOpen(section.key, false)"
|
||||
>
|
||||
<template #title>
|
||||
{{ section.title }}
|
||||
</template>
|
||||
<template #button="{ open }">
|
||||
<DropdownIcon
|
||||
class="size-6 shrink-0 text-primary transition-transform duration-300"
|
||||
:class="{ 'rotate-180': open }"
|
||||
/>
|
||||
<span class="min-w-0 text-xl font-semibold leading-7 text-primary">
|
||||
{{ section.title }}
|
||||
</span>
|
||||
<Tooltip
|
||||
v-if="section.infoTooltip"
|
||||
theme="dismissable-prompt"
|
||||
placement="top"
|
||||
:triggers="['hover', 'focus']"
|
||||
>
|
||||
<span
|
||||
class="inline-flex size-6 shrink-0 items-center justify-center text-secondary transition-colors group-hover:text-primary"
|
||||
@click.stop
|
||||
>
|
||||
<UnknownIcon class="size-5" />
|
||||
</span>
|
||||
<template #popper>
|
||||
<p class="m-0 max-w-96 text-wrap text-sm font-medium leading-tight">
|
||||
{{ section.infoTooltip }}
|
||||
</p>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
<Draggable
|
||||
v-if="section.kind === 'saved'"
|
||||
:list="draggableSavedSkins"
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
:item-key="savedSkinKey"
|
||||
:disabled="readOnly || !canReorderSavedSkins"
|
||||
:animation="250"
|
||||
:swap-threshold="1"
|
||||
:invert-swap="false"
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="true"
|
||||
:fallback-tolerance="4"
|
||||
ghost-class="skin-reorder-ghost"
|
||||
chosen-class="skin-reorder-chosen"
|
||||
drag-class="skin-reorder-drag"
|
||||
fallback-class="skin-reorder-fallback"
|
||||
@start="onSavedSkinDragStart"
|
||||
@end="onSavedSkinDragEnd"
|
||||
>
|
||||
<template #header>
|
||||
<SkinLikeTextButton
|
||||
ref="addSkinButton"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
dropzone
|
||||
:disabled="readOnly"
|
||||
:drag-active="!readOnly && isAddSkinButtonDragActive"
|
||||
@click="emit('add-skin')"
|
||||
@dragenter="emit('add-skin-dragenter', $event)"
|
||||
@dragover="emit('add-skin-dragover', $event)"
|
||||
@dragleave="emit('add-skin-dragleave', $event)"
|
||||
@drop="emit('add-skin-drop', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
{{ formatMessage(messages.addSkinButton) }}
|
||||
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
|
||||
</SkinLikeTextButton>
|
||||
</template>
|
||||
|
||||
<template #item="{ element: skin }">
|
||||
<div
|
||||
:key="savedSkinKey(skin)"
|
||||
class="relative aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
>
|
||||
<SkinButton
|
||||
class="h-full w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:disabled="readOnly"
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template v-if="!readOnly" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
@click.stop="emit('delete', skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
v-for="skin in fixedSavedSkins"
|
||||
:key="savedSkinKey(skin)"
|
||||
class="relative aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
>
|
||||
<SkinButton
|
||||
class="h-full w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:disabled="readOnly"
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template v-if="!readOnly" #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
@click.stop="emit('delete', skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
>
|
||||
<SkinButton
|
||||
v-for="skin in section.skins"
|
||||
:key="skinKey(skin, section.key)"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:tooltip="skin.name"
|
||||
:disabled="readOnly"
|
||||
:is-dragging="isDraggingSavedSkin"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.skin-reorder-ghost) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
:global(.skin-reorder-drag) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
:global(.skin-reorder-fallback) {
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -27,10 +27,10 @@ import { useRouter } from 'vue-router'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project } from '@/helpers/cache'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import { kill, run } from '@/helpers/profile'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { showProfileInFolder } from '@/helpers/utils'
|
||||
import { showInstanceInFolder } from '@/helpers/utils'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
@@ -52,13 +52,13 @@ const props = defineProps<{
|
||||
last_played: Dayjs
|
||||
}>()
|
||||
|
||||
const loadingModpack = ref(!!props.instance.linked_data)
|
||||
const loadingModpack = ref(!!props.instance.link)
|
||||
|
||||
const modpack = ref()
|
||||
|
||||
if (props.instance.linked_data) {
|
||||
if (props.instance.link) {
|
||||
nextTick().then(async () => {
|
||||
modpack.value = await get_project(props.instance.linked_data?.project_id, 'must_revalidate')
|
||||
modpack.value = await get_project(props.instance.link?.project_id, 'must_revalidate')
|
||||
loadingModpack.value = false
|
||||
})
|
||||
}
|
||||
@@ -81,8 +81,8 @@ const playing = ref(false)
|
||||
const play = async (event: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
loading.value = true
|
||||
await run(props.instance.path)
|
||||
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
|
||||
await run(props.instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: props.instance.id }))
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: props.instance.loader,
|
||||
@@ -97,7 +97,7 @@ const play = async (event: MouseEvent) => {
|
||||
const stop = async (event: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
loading.value = true
|
||||
await kill(props.instance.path).catch(handleError)
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
@@ -112,7 +112,7 @@ const unlistenProcesses = await process_listener(async () => {
|
||||
})
|
||||
|
||||
const checkProcess = async () => {
|
||||
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
|
||||
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
}
|
||||
@@ -130,7 +130,7 @@ onUnmounted(() => {
|
||||
<template #clickable>
|
||||
<router-link
|
||||
class="no-click-animation"
|
||||
:to="`/instance/${encodeURIComponent(instance.path)}`"
|
||||
:to="`/instance/${encodeURIComponent(instance.id)}`"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
@@ -138,7 +138,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<Avatar
|
||||
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
|
||||
:tint-by="instance.path"
|
||||
:tint-by="instance.id"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col col-span-2 justify-between h-full">
|
||||
@@ -206,12 +206,12 @@ onUnmounted(() => {
|
||||
:options="[
|
||||
{
|
||||
id: 'open-instance',
|
||||
shown: !!instance.path,
|
||||
action: () => router.push(encodeURI(`/instance/${instance.path}`)),
|
||||
shown: !!instance.id,
|
||||
action: () => router.push(encodeURI(`/instance/${instance.id}`)),
|
||||
},
|
||||
{
|
||||
id: 'open-folder',
|
||||
action: () => showProfileInFolder(instance.path),
|
||||
action: () => showInstanceInFolder(instance.id),
|
||||
},
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -10,13 +10,13 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener, profile_listener } from '@/helpers/events'
|
||||
import { instance_listener, process_listener } from '@/helpers/events'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { get_all } from '@/helpers/process'
|
||||
import { kill, run } from '@/helpers/profile'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
get_profile_protocol_version,
|
||||
get_instance_protocol_version,
|
||||
get_recent_worlds,
|
||||
getWorldIdentifier,
|
||||
hasServerQuickPlaySupport,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
type ServerWorld,
|
||||
start_join_server,
|
||||
start_join_singleplayer_world,
|
||||
type WorldWithProfile,
|
||||
type WorldWithInstance,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
@@ -66,7 +66,7 @@ type InstanceJumpBackInItem = BaseJumpBackInItem & {
|
||||
|
||||
type WorldJumpBackInItem = BaseJumpBackInItem & {
|
||||
type: 'world'
|
||||
world: WorldWithProfile
|
||||
world: WorldWithInstance
|
||||
}
|
||||
|
||||
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
|
||||
@@ -100,7 +100,7 @@ async function populateJumpBackIn() {
|
||||
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite'])
|
||||
|
||||
worlds.forEach((world) => {
|
||||
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
|
||||
const instance = props.recentInstances.find((instance) => instance.id === world.instance_id)
|
||||
|
||||
if (!instance || !world.last_played) {
|
||||
return
|
||||
@@ -115,23 +115,23 @@ async function populateJumpBackIn() {
|
||||
})
|
||||
|
||||
const servers: {
|
||||
instancePath: string
|
||||
instanceId: string
|
||||
address: string
|
||||
}[] = worldItems
|
||||
.filter((item) => item.world.type === 'server' && item.instance)
|
||||
.map((item) => ({
|
||||
instancePath: item.instance.path,
|
||||
instanceId: item.instance.id,
|
||||
address: (item.world as ServerWorld).address,
|
||||
}))
|
||||
|
||||
// fetch protocol versions for all unique MC versions with server worlds
|
||||
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
|
||||
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instanceId))
|
||||
await Promise.all(
|
||||
[...uniqueServerInstances].map((path) =>
|
||||
get_profile_protocol_version(path)
|
||||
.then((protoVer) => (protocolVersions.value[path] = protoVer))
|
||||
[...uniqueServerInstances].map((instanceId) =>
|
||||
get_instance_protocol_version(instanceId)
|
||||
.then((protoVer) => (protocolVersions.value[instanceId] = protoVer))
|
||||
.catch(() => {
|
||||
console.error(`Failed to get profile protocol for: ${path} `)
|
||||
console.error(`Failed to get instance protocol for: ${instanceId} `)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -145,14 +145,14 @@ async function populateJumpBackIn() {
|
||||
}
|
||||
})
|
||||
|
||||
servers.forEach(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
servers.forEach(({ instanceId, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instanceId], address),
|
||||
)
|
||||
}
|
||||
|
||||
const instanceItems: InstanceJumpBackInItem[] = []
|
||||
for (const instance of props.recentInstances) {
|
||||
const worldItem = worldItems.find((item) => item.instance.path === instance.path)
|
||||
const worldItem = worldItems.find((item) => item.instance.id === instance.id)
|
||||
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
|
||||
continue
|
||||
}
|
||||
@@ -171,14 +171,14 @@ async function populateJumpBackIn() {
|
||||
.slice(0, MAX_JUMP_BACK_IN)
|
||||
}
|
||||
|
||||
function refreshServer(address: string, instancePath: string) {
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||
function refreshServer(address: string, instanceId: string) {
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instanceId], address)
|
||||
}
|
||||
|
||||
async function joinWorld(world: WorldWithProfile, instance?: GameInstance) {
|
||||
async function joinWorld(world: WorldWithInstance, instance?: GameInstance) {
|
||||
console.log(`Joining world ${getWorldIdentifier(world)}`)
|
||||
if (world.type === 'server') {
|
||||
await start_join_server(world.profile, world.address).catch(handleError)
|
||||
await start_join_server(world.instance_id, world.address).catch(handleError)
|
||||
if (instance) {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
@@ -187,13 +187,13 @@ async function joinWorld(world: WorldWithProfile, instance?: GameInstance) {
|
||||
})
|
||||
}
|
||||
} else if (world.type === 'singleplayer') {
|
||||
await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
|
||||
await start_join_singleplayer_world(world.instance_id, world.path).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function playInstance(instance: GameInstance) {
|
||||
await run(instance.path)
|
||||
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
|
||||
await run(instance.id)
|
||||
.catch((err) => handleSevereError(err, { instanceId: instance.id }))
|
||||
.finally(() => {
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
@@ -210,14 +210,14 @@ async function stopInstance(path: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const currentProfile = ref<string>()
|
||||
const currentInstance = ref<string>()
|
||||
const currentWorld = ref<string>()
|
||||
|
||||
const unlistenProcesses = await process_listener(async () => {
|
||||
await checkProcesses()
|
||||
})
|
||||
|
||||
const unlistenProfiles = await profile_listener(async () => {
|
||||
const unlistenInstances = await instance_listener(async () => {
|
||||
await populateJumpBackIn().catch(() => {
|
||||
console.error('Failed to populate jump back in')
|
||||
})
|
||||
@@ -227,18 +227,18 @@ const runningInstances = ref<string[]>([])
|
||||
|
||||
type ProcessMetadata = {
|
||||
uuid: string
|
||||
profile_path: string
|
||||
instance_id: string
|
||||
start_time: string
|
||||
}
|
||||
|
||||
const checkProcesses = async () => {
|
||||
const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError)
|
||||
|
||||
const runningPaths = runningProcesses.map((x) => x.profile_path)
|
||||
const runningPaths = runningProcesses.map((x) => x.instance_id)
|
||||
|
||||
const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x))
|
||||
if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) {
|
||||
currentProfile.value = undefined
|
||||
if (currentInstance.value && stoppedInstances.includes(currentInstance.value)) {
|
||||
currentInstance.value = undefined
|
||||
currentWorld.value = undefined
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcesses()
|
||||
unlistenProfiles()
|
||||
unlistenInstances()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -278,14 +278,14 @@ onUnmounted(() => {
|
||||
<div class="grid-when-huge flex flex-col w-full gap-2">
|
||||
<template
|
||||
v-for="item in jumpBackInItems"
|
||||
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
|
||||
:key="`${item.instance.id}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
|
||||
>
|
||||
<WorldItem
|
||||
v-if="item.type === 'world'"
|
||||
:world="item.world"
|
||||
:playing-instance="runningInstances.includes(item.instance.path)"
|
||||
:playing-instance="runningInstances.includes(item.instance.id)"
|
||||
:playing-world="
|
||||
currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world)
|
||||
currentInstance === item.instance.id && currentWorld === getWorldIdentifier(item.world)
|
||||
"
|
||||
:refreshing="
|
||||
item.world.type === 'server'
|
||||
@@ -306,34 +306,34 @@ onUnmounted(() => {
|
||||
:rendered-motd="
|
||||
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
|
||||
"
|
||||
:current-protocol="protocolVersions[item.instance.path]"
|
||||
:current-protocol="protocolVersions[item.instance.id]"
|
||||
:game-mode="
|
||||
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
|
||||
"
|
||||
:instance-path="item.instance.path"
|
||||
:instance-id="item.instance.id"
|
||||
:instance-name="item.instance.name"
|
||||
:instance-icon="item.instance.icon_path"
|
||||
@refresh="
|
||||
() =>
|
||||
item.world.type === 'server'
|
||||
? refreshServer(item.world.address, item.instance.path)
|
||||
? refreshServer(item.world.address, item.instance.id)
|
||||
: {}
|
||||
"
|
||||
@update="() => populateJumpBackIn()"
|
||||
@play="
|
||||
() => {
|
||||
currentProfile = item.instance.path
|
||||
currentInstance = item.instance.id
|
||||
currentWorld = getWorldIdentifier(item.world)
|
||||
joinWorld(item.world, item.instance)
|
||||
}
|
||||
"
|
||||
@play-instance="
|
||||
() => {
|
||||
currentProfile = item.instance.path
|
||||
currentInstance = item.instance.id
|
||||
playInstance(item.instance)
|
||||
}
|
||||
"
|
||||
@stop="() => stopInstance(item.instance.path)"
|
||||
@stop="() => stopInstance(item.instance.id)"
|
||||
/>
|
||||
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" />
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
IssuesIcon,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
SmartClickable,
|
||||
TagItem,
|
||||
@@ -39,7 +41,7 @@ import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { copyToClipboard } from '@/helpers/utils'
|
||||
import { copyToClipboard, createInstanceShortcut } from '@/helpers/utils'
|
||||
import type {
|
||||
ProtocolVersion,
|
||||
ServerStatus,
|
||||
@@ -60,6 +62,7 @@ const formatDateTime = useFormatDateTime({
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
|
||||
@@ -91,9 +94,10 @@ const props = withDefaults(
|
||||
managed?: boolean
|
||||
|
||||
// Instance
|
||||
instancePath?: string
|
||||
instanceId?: string
|
||||
instanceName?: string
|
||||
instanceIcon?: string
|
||||
shortcutInstanceId?: string
|
||||
}>(),
|
||||
{
|
||||
playingInstance: false,
|
||||
@@ -110,9 +114,10 @@ const props = withDefaults(
|
||||
gameMode: undefined,
|
||||
managed: false,
|
||||
|
||||
instancePath: undefined,
|
||||
instanceId: undefined,
|
||||
instanceName: undefined,
|
||||
instanceIcon: undefined,
|
||||
shortcutInstanceId: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -131,6 +136,33 @@ const serverIncompatible = computed(
|
||||
|
||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||
const managed = computed(() => props.managed)
|
||||
const shortcutInstanceId = computed(() => props.shortcutInstanceId ?? props.instanceId)
|
||||
|
||||
async function createShortcut() {
|
||||
if (!shortcutInstanceId.value) return
|
||||
|
||||
try {
|
||||
const shortcutPath = await createInstanceShortcut(
|
||||
props.world.name,
|
||||
shortcutInstanceId.value,
|
||||
props.world.type === 'server'
|
||||
? { server: (props.world as ServerWorld).address }
|
||||
: { singleplayerWorld: (props.world as SingleplayerWorld).path },
|
||||
)
|
||||
if (!shortcutPath) return
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Shortcut created',
|
||||
})
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to create shortcut',
|
||||
text: error instanceof Error ? error.message : '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
hardcore: {
|
||||
@@ -185,6 +217,10 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.dont_show_on_home',
|
||||
defaultMessage: `Don't show on Home`,
|
||||
},
|
||||
createShortcut: {
|
||||
id: 'instance.worlds.create_shortcut',
|
||||
defaultMessage: 'Create shortcut',
|
||||
},
|
||||
linkedServer: {
|
||||
id: 'instance.worlds.linked_server',
|
||||
defaultMessage: 'Managed by server project',
|
||||
@@ -209,10 +245,10 @@ const messages = defineMessages({
|
||||
</script>
|
||||
<template>
|
||||
<SmartClickable>
|
||||
<template v-if="instancePath" #clickable>
|
||||
<template v-if="instanceId" #clickable>
|
||||
<router-link
|
||||
class="no-click-animation"
|
||||
:to="`/instance/${encodeURIComponent(instancePath)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
|
||||
:to="`/instance/${encodeURIComponent(instanceId)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
@@ -244,7 +280,7 @@ const messages = defineMessages({
|
||||
</TagItem>
|
||||
<div
|
||||
v-if="world.type === 'singleplayer'"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
|
||||
>
|
||||
<UserIcon
|
||||
aria-hidden="true"
|
||||
@@ -324,16 +360,16 @@ const messages = defineMessages({
|
||||
</template>
|
||||
<template v-else> {{ formatMessage(messages.notPlayedYet) }} </template>
|
||||
</div>
|
||||
<template v-if="instancePath">
|
||||
<template v-if="instanceId">
|
||||
•
|
||||
<router-link
|
||||
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
|
||||
:to="`/instance/${instancePath}`"
|
||||
:to="`/instance/${instanceId}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
|
||||
size="16px"
|
||||
:tint-by="instancePath"
|
||||
:tint-by="instanceId"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ instanceName }}</span>
|
||||
@@ -420,14 +456,14 @@ const messages = defineMessages({
|
||||
:options="[
|
||||
{
|
||||
id: 'play-instance',
|
||||
shown: !!instancePath,
|
||||
shown: !!instanceId,
|
||||
disabled: playingInstance,
|
||||
action: () => emit('play-instance'),
|
||||
},
|
||||
{
|
||||
id: 'open-instance',
|
||||
shown: !!instancePath,
|
||||
action: () => router.push(encodeURI(`/instance/${instancePath}`)),
|
||||
shown: !!instanceId,
|
||||
action: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
|
||||
},
|
||||
{
|
||||
id: 'refresh',
|
||||
@@ -442,7 +478,7 @@ const messages = defineMessages({
|
||||
{
|
||||
id: 'edit',
|
||||
action: () => emit('edit'),
|
||||
shown: !instancePath,
|
||||
shown: !instanceId,
|
||||
disabled: locked || managed,
|
||||
tooltip: locked
|
||||
? formatMessage(messages.worldInUse)
|
||||
@@ -457,14 +493,14 @@ const messages = defineMessages({
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: !!instancePath,
|
||||
shown: !!instanceId,
|
||||
},
|
||||
{
|
||||
id: 'dont-show-on-home',
|
||||
shown: !!instancePath,
|
||||
shown: !!instanceId,
|
||||
action: () => {
|
||||
set_world_display_status(
|
||||
instancePath,
|
||||
instanceId,
|
||||
world.type,
|
||||
getWorldIdentifier(world),
|
||||
'hidden',
|
||||
@@ -473,16 +509,21 @@ const messages = defineMessages({
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'create-shortcut',
|
||||
shown: !!shortcutInstanceId,
|
||||
action: () => createShortcut(),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: !instancePath,
|
||||
shown: !instanceId,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => emit('delete'),
|
||||
shown: !instancePath,
|
||||
shown: !instanceId,
|
||||
disabled: locked || managed,
|
||||
tooltip: locked
|
||||
? formatMessage(messages.worldInUse)
|
||||
@@ -517,6 +558,10 @@ const messages = defineMessages({
|
||||
<UpdatedIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</template>
|
||||
<template #create-shortcut>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createShortcut) }}
|
||||
</template>
|
||||
<template #dont-show-on-home>
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.dontShowOnHome) }}
|
||||
|
||||
@@ -12,7 +12,11 @@ import { ref } from 'vue'
|
||||
|
||||
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
|
||||
import {
|
||||
add_server_to_instance,
|
||||
type ServerPackStatus,
|
||||
type ServerWorld,
|
||||
} from '@/helpers/worlds.ts'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -35,8 +39,8 @@ async function addServer(play: boolean) {
|
||||
const serverName = name.value ? name.value : address.value
|
||||
const resourcePackStatus = resourcePack.value
|
||||
const index =
|
||||
(await add_server_to_profile(
|
||||
props.instance.path,
|
||||
(await add_server_to_instance(
|
||||
props.instance.id,
|
||||
serverName,
|
||||
address.value,
|
||||
resourcePackStatus,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { World } from '@/helpers/worlds.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
world: World | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [world: World]
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
removeServerTitle: {
|
||||
id: 'app.instance.worlds.remove-server-modal.title',
|
||||
defaultMessage: 'Remove server',
|
||||
},
|
||||
deleteWorldTitle: {
|
||||
id: 'app.instance.worlds.delete-world-modal.title',
|
||||
defaultMessage: 'Delete world',
|
||||
},
|
||||
removeServerWarningHeader: {
|
||||
id: 'app.instance.worlds.remove-server-modal.warning-header',
|
||||
defaultMessage: 'Removing {name}',
|
||||
},
|
||||
deleteWorldWarningHeader: {
|
||||
id: 'app.instance.worlds.delete-world-modal.warning-header',
|
||||
defaultMessage: 'Deleting {name}',
|
||||
},
|
||||
removeServerWarningBody: {
|
||||
id: 'app.instance.worlds.remove-server-modal.warning-body',
|
||||
defaultMessage:
|
||||
'This server will be removed from your server list and from the in-game server list. You can add it again later if you know the address.',
|
||||
},
|
||||
deleteWorldWarningBody: {
|
||||
id: 'app.instance.worlds.delete-world-modal.warning-body',
|
||||
defaultMessage:
|
||||
'This world will be permanently deleted from this instance. This action cannot be undone.',
|
||||
},
|
||||
removeServerButton: {
|
||||
id: 'app.instance.worlds.remove-server-modal.remove-button',
|
||||
defaultMessage: 'Remove server',
|
||||
},
|
||||
deleteWorldButton: {
|
||||
id: 'app.instance.worlds.delete-world-modal.delete-button',
|
||||
defaultMessage: 'Delete world',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const isServer = computed(() => props.world?.type === 'server')
|
||||
const isSingleplayer = computed(() => props.world?.type === 'singleplayer')
|
||||
const titleMessage = computed(() =>
|
||||
isServer.value ? messages.removeServerTitle : messages.deleteWorldTitle,
|
||||
)
|
||||
const actionMessage = computed(() =>
|
||||
isServer.value ? messages.removeServerButton : messages.deleteWorldButton,
|
||||
)
|
||||
const warningHeaderMessage = computed(() =>
|
||||
isServer.value ? messages.removeServerWarningHeader : messages.deleteWorldWarningHeader,
|
||||
)
|
||||
const warningBodyMessage = computed(() =>
|
||||
isServer.value ? messages.removeServerWarningBody : messages.deleteWorldWarningBody,
|
||||
)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!props.world) return
|
||||
emit('confirm', props.world)
|
||||
hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(titleMessage)" fade="danger" max-width="500px">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition
|
||||
type="critical"
|
||||
:header="formatMessage(warningHeaderMessage, { name: world?.name })"
|
||||
>
|
||||
{{ formatMessage(warningBodyMessage) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="!isServer && !isSingleplayer" @click="confirm">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(actionMessage) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -15,7 +15,7 @@ import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
type DisplayStatus,
|
||||
edit_server_in_profile,
|
||||
edit_server_in_instance,
|
||||
type ServerPackStatus,
|
||||
type ServerWorld,
|
||||
set_world_display_status,
|
||||
@@ -46,8 +46,8 @@ const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal
|
||||
async function saveServer() {
|
||||
const serverName = name.value ? name.value : address.value
|
||||
const resourcePackStatus = resourcePack.value
|
||||
await edit_server_in_profile(
|
||||
props.instance.path,
|
||||
await edit_server_in_instance(
|
||||
props.instance.id,
|
||||
index.value,
|
||||
serverName,
|
||||
address.value,
|
||||
@@ -56,7 +56,7 @@ async function saveServer() {
|
||||
|
||||
if (newDisplayStatus.value !== displayStatus.value) {
|
||||
await set_world_display_status(
|
||||
props.instance.path,
|
||||
props.instance.id,
|
||||
'server',
|
||||
address.value,
|
||||
newDisplayStatus.value,
|
||||
|
||||
@@ -40,14 +40,14 @@ const hideFromHome = ref(false)
|
||||
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
|
||||
|
||||
async function saveWorld() {
|
||||
await rename_world(props.instance.path, path.value, name.value).catch(handleError)
|
||||
await rename_world(props.instance.id, path.value, name.value).catch(handleError)
|
||||
|
||||
if (removeIcon.value) {
|
||||
await reset_world_icon(props.instance.path, path.value).catch(handleError)
|
||||
await reset_world_icon(props.instance.id, path.value).catch(handleError)
|
||||
}
|
||||
if (newDisplayStatus.value !== displayStatus.value) {
|
||||
await set_world_display_status(
|
||||
props.instance.path,
|
||||
props.instance.id,
|
||||
'singleplayer',
|
||||
path.value,
|
||||
newDisplayStatus.value,
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { UpdatedIcon } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
type PopupNotificationButton,
|
||||
type PopupNotificationProgressItem,
|
||||
type PopupNotificationProgressType,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import { install_job_listener } from '@/helpers/events'
|
||||
import {
|
||||
install_job_dismiss,
|
||||
install_job_list,
|
||||
install_job_retry,
|
||||
installJobInstanceId,
|
||||
type InstallJobSnapshot,
|
||||
type InstallJobStatus,
|
||||
type InstallProgress,
|
||||
} from '@/helpers/install'
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
|
||||
const messages = defineMessages({
|
||||
installs: {
|
||||
id: 'app.action-bar.installs',
|
||||
defaultMessage: 'Installs',
|
||||
},
|
||||
retry: {
|
||||
id: 'app.action-bar.install.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
dismiss: {
|
||||
id: 'app.action-bar.install.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
},
|
||||
openInstance: {
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
const phaseMessages = defineMessages({
|
||||
preparing_instance: {
|
||||
id: 'app.install.phase.preparing_instance',
|
||||
defaultMessage: 'Preparing instance',
|
||||
},
|
||||
resolving_pack: {
|
||||
id: 'app.install.phase.resolving_pack',
|
||||
defaultMessage: 'Resolving content',
|
||||
},
|
||||
downloading_pack_file: {
|
||||
id: 'app.install.phase.downloading_pack_file',
|
||||
defaultMessage: 'Downloading pack file',
|
||||
},
|
||||
reading_pack_manifest: {
|
||||
id: 'app.install.phase.reading_pack_manifest',
|
||||
defaultMessage: 'Reading pack manifest',
|
||||
},
|
||||
downloading_content: {
|
||||
id: 'app.install.phase.downloading_content',
|
||||
defaultMessage: 'Downloading content',
|
||||
},
|
||||
extracting_overrides: {
|
||||
id: 'app.install.phase.extracting_overrides',
|
||||
defaultMessage: 'Extracting overrides',
|
||||
},
|
||||
resolving_minecraft: {
|
||||
id: 'app.install.phase.resolving_minecraft',
|
||||
defaultMessage: 'Resolving Minecraft',
|
||||
},
|
||||
resolving_loader: {
|
||||
id: 'app.install.phase.resolving_loader',
|
||||
defaultMessage: 'Resolving loader',
|
||||
},
|
||||
preparing_java: {
|
||||
id: 'app.install.phase.preparing_java',
|
||||
defaultMessage: 'Preparing Java',
|
||||
},
|
||||
downloading_minecraft: {
|
||||
id: 'app.install.phase.downloading_minecraft',
|
||||
defaultMessage: 'Downloading Minecraft',
|
||||
},
|
||||
running_loader_processors: {
|
||||
id: 'app.install.phase.running_loader_processors',
|
||||
defaultMessage: 'Running loader processors',
|
||||
},
|
||||
finalizing: {
|
||||
id: 'app.install.phase.finalizing',
|
||||
defaultMessage: 'Finalizing',
|
||||
},
|
||||
rolling_back: {
|
||||
id: 'app.install.phase.rolling_back',
|
||||
defaultMessage: 'Rolling back',
|
||||
},
|
||||
})
|
||||
|
||||
const javaStepMessages = defineMessages({
|
||||
resolving: {
|
||||
id: 'app.install.phase.preparing_java.resolving',
|
||||
defaultMessage: 'Preparing Java {version}',
|
||||
},
|
||||
fetching_metadata: {
|
||||
id: 'app.install.phase.preparing_java.fetching-metadata',
|
||||
defaultMessage: 'Fetching Java {version}',
|
||||
},
|
||||
downloading: {
|
||||
id: 'app.install.phase.preparing_java.downloading',
|
||||
defaultMessage: 'Downloading Java {version}',
|
||||
},
|
||||
extracting: {
|
||||
id: 'app.install.phase.preparing_java.extracting',
|
||||
defaultMessage: 'Extracting Java {version}',
|
||||
},
|
||||
validating: {
|
||||
id: 'app.install.phase.preparing_java.validating',
|
||||
defaultMessage: 'Validating Java {version}',
|
||||
},
|
||||
})
|
||||
|
||||
const visibleJobStatuses = new Set<InstallJobStatus>(['queued', 'running', 'failed', 'interrupted'])
|
||||
|
||||
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
if (!icon) return null
|
||||
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) return icon
|
||||
return convertFileSrc(icon)
|
||||
}
|
||||
|
||||
export async function useInstallJobNotifications(opts: {
|
||||
router: Router
|
||||
handleError: (err: unknown) => void
|
||||
onChange: () => void
|
||||
}) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const jobs = ref<InstallJobSnapshot[]>([])
|
||||
const iconUrls = ref<Record<string, string | null>>({})
|
||||
const instanceNames = ref<Record<string, string>>({})
|
||||
const jobOrder = new Map<string, number>()
|
||||
let refreshRequest = 0
|
||||
let metadataRequest = 0
|
||||
let nextJobOrder = 0
|
||||
|
||||
function getTitle(job: InstallJobSnapshot): string {
|
||||
if (job.display?.title) return job.display.title
|
||||
if (job.details.type === 'instance') return job.details.name
|
||||
if (job.details.type === 'modpack' && job.details.title) return job.details.title
|
||||
const instanceId = installJobInstanceId(job)
|
||||
return (
|
||||
(instanceId ? instanceNames.value[instanceId] : null) ??
|
||||
formatMessage(messages.unknownInstance)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if (job.phase === 'preparing_java' && job.details.type === 'java') {
|
||||
return formatMessage(javaStepMessages[job.details.step], {
|
||||
version: job.details.major_version,
|
||||
})
|
||||
}
|
||||
return formatMessage(phaseMessages[job.phase])
|
||||
}
|
||||
|
||||
function getProgressType(job: InstallJobSnapshot): PopupNotificationProgressType | undefined {
|
||||
if (!getEffectiveProgress(job)) return undefined
|
||||
if (
|
||||
job.phase === 'preparing_java' &&
|
||||
job.details.type === 'java' &&
|
||||
job.details.step === 'downloading'
|
||||
) {
|
||||
return 'bytes'
|
||||
}
|
||||
if (job.phase === 'downloading_content') {
|
||||
return job.progress?.secondary ? 'bytes' : 'count'
|
||||
}
|
||||
if (
|
||||
job.phase === 'downloading_pack_file' ||
|
||||
job.phase === 'extracting_overrides' ||
|
||||
job.phase === 'downloading_minecraft'
|
||||
) {
|
||||
return 'bytes'
|
||||
}
|
||||
if (job.phase === 'running_loader_processors') {
|
||||
return 'count'
|
||||
}
|
||||
return 'percentage'
|
||||
}
|
||||
|
||||
function getEffectiveProgress(job: InstallJobSnapshot): InstallProgress | null | undefined {
|
||||
if (job.phase === 'downloading_content' && job.progress?.secondary) {
|
||||
return job.progress.secondary
|
||||
}
|
||||
|
||||
return job.progress
|
||||
}
|
||||
|
||||
function getProgress(job: InstallJobSnapshot): number {
|
||||
if (job.status === 'succeeded') return 1
|
||||
if (job.status === 'failed' || job.status === 'interrupted') return 0
|
||||
const progress = getEffectiveProgress(job)
|
||||
if (!progress || progress.total <= 0) return 0
|
||||
return Math.max(0, Math.min(1, progress.current / progress.total))
|
||||
}
|
||||
|
||||
function isTerminalJob(job: InstallJobSnapshot): boolean {
|
||||
return job.status === 'failed' || job.status === 'interrupted'
|
||||
}
|
||||
|
||||
function getTerminalButtons(job: InstallJobSnapshot): PopupNotificationButton[] | undefined {
|
||||
if (!isTerminalJob(job)) return undefined
|
||||
|
||||
return [
|
||||
{
|
||||
label: formatMessage(messages.retry),
|
||||
icon: UpdatedIcon,
|
||||
color: 'brand',
|
||||
keepOpen: true,
|
||||
action: async () => {
|
||||
await install_job_retry(job.job_id).catch(opts.handleError)
|
||||
await refresh()
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function setJobs(nextJobs: InstallJobSnapshot[]) {
|
||||
for (const job of nextJobs) {
|
||||
if (!jobOrder.has(job.job_id)) {
|
||||
jobOrder.set(job.job_id, nextJobOrder++)
|
||||
}
|
||||
}
|
||||
|
||||
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 progressItems = computed<PopupNotificationProgressItem[]>(() =>
|
||||
jobs.value.map((job) => {
|
||||
const progress = getEffectiveProgress(job)
|
||||
|
||||
return {
|
||||
id: job.job_id,
|
||||
title: getTitle(job),
|
||||
text: getText(job),
|
||||
iconUrl: iconUrls.value[job.job_id] ?? null,
|
||||
progress: getProgress(job),
|
||||
waiting: !job.progress && ['queued', 'running'].includes(job.status),
|
||||
showProgress: !isTerminalJob(job),
|
||||
wrapText: isTerminalJob(job),
|
||||
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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const buttons = computed<PopupNotificationButton[] | undefined>(() => undefined)
|
||||
|
||||
async function refreshMetadata(notify = true) {
|
||||
const request = ++metadataRequest
|
||||
const sourceJobs = jobs.value
|
||||
const instanceIds = Array.from(
|
||||
new Set(
|
||||
sourceJobs
|
||||
.map((job) => installJobInstanceId(job))
|
||||
.filter((instanceId): instanceId is string => !!instanceId),
|
||||
),
|
||||
)
|
||||
const instances = instanceIds.length
|
||||
? await getInstances(instanceIds).catch((error) => {
|
||||
opts.handleError(error)
|
||||
return []
|
||||
})
|
||||
: []
|
||||
|
||||
if (request !== metadataRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
const instanceIconUrls = new Map(
|
||||
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
|
||||
)
|
||||
instanceNames.value = Object.fromEntries(
|
||||
instances.map((instance) => [instance.id, instance.name]),
|
||||
)
|
||||
iconUrls.value = Object.fromEntries(
|
||||
sourceJobs.map((job) => [
|
||||
job.job_id,
|
||||
getDisplayIconUrl(job.display?.icon) ??
|
||||
instanceIconUrls.get(installJobInstanceId(job) ?? '') ??
|
||||
null,
|
||||
]),
|
||||
)
|
||||
|
||||
if (notify) {
|
||||
opts.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(notify = true) {
|
||||
const request = ++refreshRequest
|
||||
const nextJobs = await install_job_list(false).catch((error) => {
|
||||
opts.handleError(error)
|
||||
return []
|
||||
})
|
||||
|
||||
if (request !== refreshRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
setJobs(nextJobs)
|
||||
await refreshMetadata(false)
|
||||
|
||||
if (request !== refreshRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
if (notify) {
|
||||
opts.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
function applyJobUpdate(job: InstallJobSnapshot) {
|
||||
refreshRequest += 1
|
||||
const existingJob = jobs.value.find((item) => item.job_id === job.job_id)
|
||||
if (existingJob && existingJob.modified.localeCompare(job.modified) > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setJobs([...jobs.value.filter((item) => item.job_id !== job.job_id), job])
|
||||
opts.onChange()
|
||||
void refreshMetadata()
|
||||
}
|
||||
|
||||
await refresh(false)
|
||||
const unlisten = await install_job_listener((job: InstallJobSnapshot) => applyJobUpdate(job))
|
||||
|
||||
return {
|
||||
active: computed(() => jobs.value.length > 0),
|
||||
title: computed(() => formatMessage(messages.installs)),
|
||||
progressItems,
|
||||
buttons,
|
||||
refresh,
|
||||
dispose: () => unlisten(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets'
|
||||
import type { CardAction } from '@modrinth/ui'
|
||||
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { onUnmounted, ref, shallowRef } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
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'
|
||||
|
||||
interface BrowseServerInstance {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface ContextMenuHandle {
|
||||
showMenu: (
|
||||
event: MouseEvent,
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
options: { name: string }[],
|
||||
) => void
|
||||
}
|
||||
|
||||
interface ContextMenuOptionClick {
|
||||
option: 'open_link' | 'copy_link'
|
||||
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject
|
||||
}
|
||||
|
||||
export interface UseAppServerBrowseOptions {
|
||||
instance: Ref<BrowseServerInstance | null>
|
||||
isFromWorlds: ComputedRef<boolean>
|
||||
allInstalledIds: ComputedRef<Set<string>>
|
||||
newlyInstalled: Ref<string[]>
|
||||
installingServerProjects: Ref<string[]>
|
||||
playServerProject: (projectId: string) => Promise<void>
|
||||
showAddServerToInstanceModal: (serverName: string, serverAddress: string) => void
|
||||
handleError: (error: unknown) => void
|
||||
router: Router
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
addToInstance: {
|
||||
id: 'app.browse.add-to-instance',
|
||||
defaultMessage: 'Add to instance',
|
||||
},
|
||||
addToInstanceName: {
|
||||
id: 'app.browse.add-to-instance-name',
|
||||
defaultMessage: 'Add to {instanceName}',
|
||||
},
|
||||
added: {
|
||||
id: 'app.browse.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
alreadyAdded: {
|
||||
id: 'app.browse.already-added',
|
||||
defaultMessage: 'Already added',
|
||||
},
|
||||
})
|
||||
|
||||
export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
const { formatMessage } = useVIntl()
|
||||
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 unlistenProcesses: (() => void) | null = null
|
||||
|
||||
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
debugLog('checkServerRunningStates', { hitCount: hits.length })
|
||||
const packs = await listInstances().catch((error) => {
|
||||
options.handleError(error)
|
||||
return []
|
||||
})
|
||||
const newRunning: Record<string, string> = {}
|
||||
for (const hit of hits) {
|
||||
const inst = packs.find((pack: GameInstance) => pack.link?.project_id === hit.project_id)
|
||||
if (inst) {
|
||||
const processes = await get_by_instance_id(inst.id).catch(() => [])
|
||||
if (Array.isArray(processes) && processes.length > 0) {
|
||||
newRunning[hit.project_id] = inst.id
|
||||
}
|
||||
}
|
||||
}
|
||||
debugLog('runningServerProjects updated', newRunning)
|
||||
runningServerProjects.value = newRunning
|
||||
}
|
||||
|
||||
async function handleStopServerProject(projectId: string) {
|
||||
debugLog('handleStopServerProject', projectId)
|
||||
const instanceId = runningServerProjects.value[projectId]
|
||||
if (!instanceId) return
|
||||
await kill(instanceId).catch(() => {})
|
||||
const { [projectId]: _, ...rest } = runningServerProjects.value
|
||||
runningServerProjects.value = rest
|
||||
}
|
||||
|
||||
async function handlePlayServerProject(projectId: string) {
|
||||
debugLog('handlePlayServerProject', projectId)
|
||||
await options.playServerProject(projectId)
|
||||
checkServerRunningStates(lastServerHits.value)
|
||||
}
|
||||
|
||||
async function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||
debugLog('handleAddServerToInstance', { projectId: project.project_id, name: project.name })
|
||||
const address = getServerAddress(project.minecraft_java_server)
|
||||
if (!address) return
|
||||
|
||||
if (options.instance.value) {
|
||||
const instanceId = options.instance.value.id
|
||||
try {
|
||||
await add_server_to_instance(
|
||||
instanceId,
|
||||
project.name,
|
||||
address,
|
||||
'prompt',
|
||||
project.project_id,
|
||||
project.minecraft_java_server?.content?.kind,
|
||||
)
|
||||
options.newlyInstalled.value.push(project.project_id)
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instanceId] })
|
||||
} catch (error) {
|
||||
options.handleError(error)
|
||||
}
|
||||
} else {
|
||||
options.showAddServerToInstanceModal(project.name, address)
|
||||
}
|
||||
}
|
||||
|
||||
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
debugLog('pingServerHits', { hitCount: hits.length })
|
||||
const pingsToFetch = hits.flatMap((hit) => {
|
||||
const address = hit.minecraft_java_server?.address
|
||||
if (!address) return []
|
||||
return [{ hit, address }]
|
||||
})
|
||||
const nextPings = { ...serverPings.value }
|
||||
for (const { hit, address } of pingsToFetch) {
|
||||
if (serverPingCache.has(address)) {
|
||||
nextPings[hit.project_id] = serverPingCache.get(address)
|
||||
}
|
||||
}
|
||||
serverPings.value = nextPings
|
||||
|
||||
await Promise.all(
|
||||
pingsToFetch.map(async ({ hit, address }) => {
|
||||
if (serverPingCache.has(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)
|
||||
}
|
||||
|
||||
const latency = await pending
|
||||
if (!serverPingCacheActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function updateServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
lastServerHits.value = hits
|
||||
pingServerHits(hits)
|
||||
checkServerRunningStates(hits)
|
||||
}
|
||||
|
||||
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||
const content = project.minecraft_java_server?.content
|
||||
if (content?.kind === 'modpack') {
|
||||
const { project_name, project_icon, project_id } = content
|
||||
if (!project_name) return undefined
|
||||
return {
|
||||
name: project_name,
|
||||
icon: project_icon ?? undefined,
|
||||
onclick:
|
||||
project_id !== project.project_id
|
||||
? () => {
|
||||
options.router.push(`/project/${project_id}`)
|
||||
}
|
||||
: undefined,
|
||||
showCustomModpackTooltip: project_id === project.project_id,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getServerCardActions(
|
||||
serverResult: Labrinth.Search.v3.ResultSearchProject,
|
||||
): CardAction[] {
|
||||
const isInstalled = options.allInstalledIds.value.has(serverResult.project_id)
|
||||
|
||||
if (options.isFromWorlds.value && options.instance.value) {
|
||||
return [
|
||||
{
|
||||
key: 'add-to-instance',
|
||||
label: formatMessage(isInstalled ? messages.added : messages.addToInstance),
|
||||
icon: isInstalled ? CheckIcon : PlusIcon,
|
||||
disabled: isInstalled,
|
||||
color: 'brand',
|
||||
type: 'outlined',
|
||||
onClick: () => handleAddServerToInstance(serverResult),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const actions: CardAction[] = []
|
||||
|
||||
actions.push({
|
||||
key: 'add',
|
||||
label: '',
|
||||
icon: isInstalled ? CheckIcon : PlusIcon,
|
||||
disabled: isInstalled,
|
||||
circular: true,
|
||||
tooltip: isInstalled
|
||||
? formatMessage(messages.alreadyAdded)
|
||||
: options.instance.value
|
||||
? formatMessage(messages.addToInstanceName, {
|
||||
instanceName: options.instance.value.name,
|
||||
})
|
||||
: formatMessage(commonMessages.addServerToInstanceButton),
|
||||
onClick: () => handleAddServerToInstance(serverResult),
|
||||
})
|
||||
|
||||
if (runningServerProjects.value[serverResult.project_id]) {
|
||||
actions.push({
|
||||
key: 'stop',
|
||||
label: formatMessage(commonMessages.stopButton),
|
||||
icon: StopCircleIcon,
|
||||
color: 'red',
|
||||
type: 'outlined',
|
||||
onClick: () => handleStopServerProject(serverResult.project_id),
|
||||
})
|
||||
} else {
|
||||
const isInstalling = options.installingServerProjects.value.includes(serverResult.project_id)
|
||||
actions.push({
|
||||
key: 'play',
|
||||
label: formatMessage(
|
||||
isInstalling ? commonMessages.installingLabel : commonMessages.playButton,
|
||||
),
|
||||
icon: PlayIcon,
|
||||
disabled: isInstalling,
|
||||
color: 'brand',
|
||||
type: 'outlined',
|
||||
onClick: () => handlePlayServerProject(serverResult.project_id),
|
||||
})
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
function handleRightClick(
|
||||
event: MouseEvent,
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
) {
|
||||
contextMenuRef.value?.showMenu(event, result, [{ name: 'open_link' }, { name: 'copy_link' }])
|
||||
}
|
||||
|
||||
function handleOptionsClick(args: ContextMenuOptionClick) {
|
||||
const url = getProjectUrl(args.item)
|
||||
switch (args.option) {
|
||||
case 'open_link':
|
||||
openUrl(url)
|
||||
break
|
||||
case 'copy_link':
|
||||
navigator.clipboard.writeText(url)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
process_listener((event: { event: string; instance_id: string }) => {
|
||||
debugLog('process event', event)
|
||||
if (event.event === 'finished') {
|
||||
const projectId = Object.entries(runningServerProjects.value).find(
|
||||
([, path]) => path === event.instance_id,
|
||||
)?.[0]
|
||||
if (projectId) {
|
||||
const { [projectId]: _, ...rest } = runningServerProjects.value
|
||||
runningServerProjects.value = rest
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenProcesses = unlisten
|
||||
})
|
||||
.catch(options.handleError)
|
||||
|
||||
onUnmounted(() => {
|
||||
serverPingCacheActive = false
|
||||
unlistenProcesses?.()
|
||||
serverPingCache.clear()
|
||||
pendingServerPings.clear()
|
||||
})
|
||||
|
||||
return {
|
||||
serverPings,
|
||||
contextMenuRef,
|
||||
updateServerHits,
|
||||
getServerModpackContent,
|
||||
getServerCardActions,
|
||||
handleRightClick,
|
||||
handleOptionsClick,
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectUrl(
|
||||
item: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
) {
|
||||
const projectType = 'project_types' in item ? item.project_types?.[0] : item.project_type
|
||||
return `https://modrinth.com/${projectType ?? 'project'}/${item.slug ?? item.project_id}`
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
interface ThemeStore {
|
||||
toggleSidebar: boolean
|
||||
$subscribe: (callback: () => void) => () => void
|
||||
}
|
||||
|
||||
interface IntercomBubblePosition {
|
||||
horizontalPadding: number
|
||||
verticalPadding: number
|
||||
}
|
||||
|
||||
const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
const INTERCOM_BUBBLE_WIDTH = 72
|
||||
const INTERCOM_BUBBLE_RIGHT_VAR = '--app-support-launcher-right'
|
||||
const INTERCOM_BUBBLE_BOTTOM_VAR = '--app-support-launcher-bottom'
|
||||
|
||||
export function useIntercomPositioning({
|
||||
route,
|
||||
themeStore,
|
||||
}: {
|
||||
route: RouteLocationNormalizedLoaded
|
||||
themeStore: ThemeStore
|
||||
}) {
|
||||
const sidebarToggled = ref(true)
|
||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||
sidebarToggled.value = !themeStore.toggleSidebar
|
||||
})
|
||||
|
||||
onUnmounted(unsubscribeSidebarToggle)
|
||||
|
||||
const forceSidebar = computed(
|
||||
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
|
||||
)
|
||||
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
||||
const intercomBubbleHorizontalPadding = computed(() =>
|
||||
sidebarVisible.value
|
||||
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
|
||||
: INTERCOM_BUBBLE_DEFAULT_PADDING,
|
||||
)
|
||||
const intercomBubbleVerticalClearance = ref<number | null>(null)
|
||||
const intercomBubblePosition = computed(() => ({
|
||||
horizontalPadding: intercomBubbleHorizontalPadding.value,
|
||||
verticalPadding: intercomBubbleVerticalClearance.value ?? INTERCOM_BUBBLE_DEFAULT_PADDING,
|
||||
}))
|
||||
const intercomBubbleClearanceRequests = new Map<symbol, number>()
|
||||
|
||||
function requestIntercomBubbleVerticalClearance(id: symbol, clearance: number | null) {
|
||||
if (clearance === null) {
|
||||
intercomBubbleClearanceRequests.delete(id)
|
||||
} else {
|
||||
intercomBubbleClearanceRequests.set(id, clearance)
|
||||
}
|
||||
|
||||
intercomBubbleVerticalClearance.value =
|
||||
intercomBubbleClearanceRequests.size > 0
|
||||
? Math.max(...intercomBubbleClearanceRequests.values())
|
||||
: null
|
||||
}
|
||||
|
||||
function updateIntercomBubbleStyles({
|
||||
horizontalPadding,
|
||||
verticalPadding,
|
||||
}: IntercomBubblePosition) {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.documentElement.style.setProperty(INTERCOM_BUBBLE_RIGHT_VAR, `${horizontalPadding}px`)
|
||||
document.documentElement.style.setProperty(INTERCOM_BUBBLE_BOTTOM_VAR, `${verticalPadding}px`)
|
||||
}
|
||||
|
||||
function clearIntercomBubbleStyles() {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_RIGHT_VAR)
|
||||
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_BOTTOM_VAR)
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarToggled,
|
||||
forceSidebar,
|
||||
sidebarVisible,
|
||||
intercomBubblePosition,
|
||||
updateIntercomBubbleStyles,
|
||||
clearIntercomBubbleStyles,
|
||||
pageContext: {
|
||||
floatingActionBarOffsets: {
|
||||
left: ref(APP_LEFT_NAV_WIDTH),
|
||||
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
|
||||
},
|
||||
intercomBubble: {
|
||||
width: ref(INTERCOM_BUBBLE_WIDTH),
|
||||
horizontalPadding: intercomBubbleHorizontalPadding,
|
||||
requestVerticalClearance: requestIntercomBubbleVerticalClearance,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ interface LogEntry {
|
||||
filename: string
|
||||
name?: string
|
||||
log_type: string
|
||||
stdout?: string
|
||||
output?: string | null
|
||||
age?: number
|
||||
live?: boolean
|
||||
}
|
||||
@@ -22,8 +22,8 @@ interface InstanceConsoleEntry {
|
||||
|
||||
const instances = new Map<string, InstanceConsoleEntry>()
|
||||
|
||||
function getOrCreate(profilePathId: string): InstanceConsoleEntry {
|
||||
let entry = instances.get(profilePathId)
|
||||
function getOrCreate(instanceId: string): InstanceConsoleEntry {
|
||||
let entry = instances.get(instanceId)
|
||||
if (entry) return entry
|
||||
|
||||
entry = {
|
||||
@@ -32,68 +32,68 @@ function getOrCreate(profilePathId: string): InstanceConsoleEntry {
|
||||
historicalCache: new Map(),
|
||||
logList: null,
|
||||
}
|
||||
instances.set(profilePathId, entry)
|
||||
instances.set(instanceId, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
async function hydrate(profilePathId: string): Promise<void> {
|
||||
const entry = getOrCreate(profilePathId)
|
||||
async function hydrate(instanceId: string): Promise<void> {
|
||||
const entry = getOrCreate(instanceId)
|
||||
if (entry.liveConsole.output.value.length > 0) return
|
||||
|
||||
const buffer = await get_live_log_buffer(profilePathId)
|
||||
const buffer = await get_live_log_buffer(instanceId)
|
||||
if (buffer) {
|
||||
entry.liveConsole.addLegacyLog(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
async function getHistoricalLogs(profilePathId: string, instancePath: string): Promise<LogEntry[]> {
|
||||
const entry = getOrCreate(profilePathId)
|
||||
async function getHistoricalLogs(instanceId: string): Promise<LogEntry[]> {
|
||||
const entry = getOrCreate(instanceId)
|
||||
if (entry.logList) return entry.logList
|
||||
|
||||
const logs: LogEntry[] = await get_logs(instancePath, false)
|
||||
const logs: LogEntry[] = await get_logs(instanceId, true)
|
||||
entry.logList = logs
|
||||
|
||||
for (const log of logs) {
|
||||
if (log.stdout && log.stdout !== '') {
|
||||
entry.historicalCache.set(log.filename, log.stdout)
|
||||
if (log.output) {
|
||||
entry.historicalCache.set(log.filename, log.output)
|
||||
}
|
||||
}
|
||||
|
||||
return logs
|
||||
}
|
||||
|
||||
function getHistoricalContent(profilePathId: string, filename: string): string | undefined {
|
||||
return instances.get(profilePathId)?.historicalCache.get(filename)
|
||||
function getHistoricalContent(instanceId: string, filename: string): string | undefined {
|
||||
return instances.get(instanceId)?.historicalCache.get(filename)
|
||||
}
|
||||
|
||||
function invalidate(profilePathId: string): void {
|
||||
const entry = instances.get(profilePathId)
|
||||
function invalidate(instanceId: string): void {
|
||||
const entry = instances.get(instanceId)
|
||||
if (!entry) return
|
||||
entry.historicalCache.clear()
|
||||
entry.logList = null
|
||||
}
|
||||
|
||||
async function clearLive(profilePathId: string): Promise<void> {
|
||||
const entry = getOrCreate(profilePathId)
|
||||
async function clearLive(instanceId: string): Promise<void> {
|
||||
const entry = getOrCreate(instanceId)
|
||||
entry.liveConsole.clear()
|
||||
await clear_log_buffer(profilePathId).catch(() => {})
|
||||
await clear_log_buffer(instanceId).catch(() => {})
|
||||
}
|
||||
|
||||
async function destroy(profilePathId: string): Promise<void> {
|
||||
instances.delete(profilePathId)
|
||||
await clear_log_buffer(profilePathId).catch(() => {})
|
||||
async function destroy(instanceId: string): Promise<void> {
|
||||
instances.delete(instanceId)
|
||||
await clear_log_buffer(instanceId).catch(() => {})
|
||||
}
|
||||
|
||||
export function useInstanceConsole(profilePathId: string) {
|
||||
const entry = getOrCreate(profilePathId)
|
||||
export function useInstanceConsole(instanceId: string) {
|
||||
const entry = getOrCreate(instanceId)
|
||||
return {
|
||||
liveConsole: entry.liveConsole,
|
||||
historicalConsole: entry.historicalConsole,
|
||||
hydrate: () => hydrate(profilePathId),
|
||||
getHistoricalLogs: (instancePath: string) => getHistoricalLogs(profilePathId, instancePath),
|
||||
getHistoricalContent: (filename: string) => getHistoricalContent(profilePathId, filename),
|
||||
invalidate: () => invalidate(profilePathId),
|
||||
clearLive: () => clearLive(profilePathId),
|
||||
destroy: () => destroy(profilePathId),
|
||||
hydrate: () => hydrate(instanceId),
|
||||
getHistoricalLogs: () => getHistoricalLogs(instanceId),
|
||||
getHistoricalContent: (filename: string) => getHistoricalContent(instanceId, filename),
|
||||
invalidate: () => invalidate(instanceId),
|
||||
clearLive: () => clearLive(instanceId),
|
||||
destroy: () => destroy(instanceId),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { test_jre } from '@/helpers/jre.js'
|
||||
|
||||
export default function useJavaTest() {
|
||||
const testingJava = ref(false)
|
||||
const javaTestResult = ref<boolean | null>(null)
|
||||
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function runJavaTest(path: string, version: number, track = true) {
|
||||
if (testDebounceTimer) {
|
||||
clearTimeout(testDebounceTimer)
|
||||
testDebounceTimer = null
|
||||
}
|
||||
if (!path) {
|
||||
javaTestResult.value = null
|
||||
return
|
||||
}
|
||||
testingJava.value = true
|
||||
try {
|
||||
javaTestResult.value = await test_jre(path, version)
|
||||
} catch {
|
||||
javaTestResult.value = false
|
||||
}
|
||||
testingJava.value = false
|
||||
|
||||
if (track) {
|
||||
trackEvent('JavaTest', { path, success: javaTestResult.value })
|
||||
}
|
||||
}
|
||||
|
||||
function testJavaInstallationDebounced(path: string, version: number, delay = 600) {
|
||||
if (testDebounceTimer) clearTimeout(testDebounceTimer)
|
||||
if (!path) {
|
||||
javaTestResult.value = null
|
||||
return
|
||||
}
|
||||
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
|
||||
}
|
||||
|
||||
async function testJavaInstallation(path: string, version: number, track = false) {
|
||||
await runJavaTest(path, version, track)
|
||||
}
|
||||
|
||||
return {
|
||||
testingJava,
|
||||
javaTestResult,
|
||||
testJavaInstallationDebounced,
|
||||
testJavaInstallation,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
@@ -31,8 +31,8 @@ import { listen } from '@tauri-apps/api/event'
|
||||
pack_name: name of the pack
|
||||
pack_id, optional, the id of the modpack
|
||||
pack_version, optional, the version of the modpack
|
||||
profile_name: name of the profile
|
||||
profile_uuid: unique identification of the profile
|
||||
instance_name: name of the instance
|
||||
instance_id: unique identification of the instance
|
||||
|
||||
}
|
||||
loader_uuid: unique identification of the loading bar
|
||||
@@ -57,18 +57,32 @@ export async function process_listener(callback) {
|
||||
return await listen('process', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
/// Payload for the 'profile' event
|
||||
/// Payload for the 'instance' event
|
||||
/*
|
||||
ProfilePayload {
|
||||
uuid: unique identification of the process in the state (currently identified by path, but that will change)
|
||||
name: name of the profile
|
||||
profile_path: relative path toprofile_listener profile (used for path identification)
|
||||
path: path to profile (used for opening the profile in the OS file explorer)
|
||||
InstancePayload {
|
||||
instance_id: unique identification of the instance
|
||||
event: event type ("Created", "Added", "Edited", "Removed")
|
||||
}
|
||||
*/
|
||||
export async function profile_listener(callback) {
|
||||
return await listen('profile', (event) => callback(event.payload))
|
||||
export async function instance_listener(callback) {
|
||||
return await listen('instance', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
/// Payload for the 'instance_bulk_update_progress' event
|
||||
/*
|
||||
InstanceBulkUpdateProgress {
|
||||
instanceId: string
|
||||
stage: "resolving_versions" | "downloading" | "finishing"
|
||||
current: number
|
||||
total: number
|
||||
}
|
||||
*/
|
||||
export async function instance_bulk_update_progress_listener(callback) {
|
||||
return await listen('instance_bulk_update_progress', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
export async function install_job_listener(callback) {
|
||||
return await listen('install_job', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
/// Payload for the 'command' event
|
||||
@@ -98,10 +112,14 @@ export async function friend_listener(callback) {
|
||||
return await listen('friend', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
export async function notification_listener(callback) {
|
||||
return await listen('notification', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
/// Payload for the 'log' event
|
||||
/*
|
||||
LogPayload {
|
||||
profile_path_id: string,
|
||||
instance_id: string,
|
||||
type: "log4j" | "legacy",
|
||||
// log4j fields (when type === "log4j"):
|
||||
timestamp_millis?: number,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
|
||||
export type UserStatus = {
|
||||
user_id: string
|
||||
profile_name: string | null
|
||||
instance_name: string | null
|
||||
last_update: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import { create } from './profile'
|
||||
import { install_import_instance } from './install'
|
||||
|
||||
/*
|
||||
API for importing instances from other launchers
|
||||
@@ -32,19 +32,8 @@ export async function get_importable_instances(launcherType, basePath) {
|
||||
}
|
||||
|
||||
/// Import an instance from a launcher type and base path
|
||||
/// eg: import_instance("profile-name-to-go-to", "MultiMC", "C:/MultiMC", "Instance 1")
|
||||
export async function import_instance(launcherType, basePath, instanceFolder) {
|
||||
// create a basic, empty instance (most properties will be filled in by the import process)
|
||||
// We do NOT watch the fs for changes to avoid duplicate events during installation
|
||||
// fs watching will be enabled once the instance is imported
|
||||
const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null, true)
|
||||
|
||||
return await invoke('plugin:import|import_instance', {
|
||||
profilePath,
|
||||
launcherType,
|
||||
basePath,
|
||||
instanceFolder,
|
||||
})
|
||||
return await install_import_instance(launcherType, basePath, instanceFolder)
|
||||
}
|
||||
|
||||
/// Checks if this instance is valid for importing, given a certain launcher type
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import { install_job_listener } from './events'
|
||||
import type { InstanceLink, InstanceLoader } from './types'
|
||||
|
||||
export interface PackLocationVersionId {
|
||||
type: 'fromVersionId'
|
||||
project_id: string
|
||||
version_id: string
|
||||
title: string
|
||||
icon_url?: string | null
|
||||
}
|
||||
|
||||
export interface PackLocationFile {
|
||||
type: 'fromFile'
|
||||
path: string
|
||||
}
|
||||
|
||||
export type CreatePackLocation = PackLocationVersionId | PackLocationFile
|
||||
|
||||
export interface InstallModpackPreview {
|
||||
name: string
|
||||
gameVersion: string
|
||||
modloader: InstanceLoader
|
||||
loaderVersion: string | null
|
||||
icon?: string | null
|
||||
iconUrl?: string | null
|
||||
link?: InstanceLink | null
|
||||
unknownFile: boolean
|
||||
}
|
||||
|
||||
export interface InstallCreateInstanceRequest {
|
||||
name: string
|
||||
gameVersion: string
|
||||
loader: InstanceLoader
|
||||
loaderVersion: string | null
|
||||
iconPath: string | null
|
||||
link?: InstanceLink | null
|
||||
}
|
||||
|
||||
export interface InstallPostInstallEdit {
|
||||
name?: string | null
|
||||
iconPath?: string | null
|
||||
link?: InstanceLink | null
|
||||
}
|
||||
|
||||
export type InstallJobStatus =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'interrupted'
|
||||
| 'canceled'
|
||||
|
||||
export type InstallPhaseId =
|
||||
| 'preparing_instance'
|
||||
| 'resolving_pack'
|
||||
| 'downloading_pack_file'
|
||||
| 'reading_pack_manifest'
|
||||
| 'downloading_content'
|
||||
| 'extracting_overrides'
|
||||
| 'resolving_minecraft'
|
||||
| 'resolving_loader'
|
||||
| 'preparing_java'
|
||||
| 'downloading_minecraft'
|
||||
| 'running_loader_processors'
|
||||
| 'finalizing'
|
||||
| 'rolling_back'
|
||||
|
||||
export interface InstallProgress {
|
||||
current: number
|
||||
total: number
|
||||
secondary?: InstallProgressSecondary | null
|
||||
}
|
||||
|
||||
export interface InstallProgressSecondary {
|
||||
current: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type InstallJavaStep =
|
||||
| 'resolving'
|
||||
| 'fetching_metadata'
|
||||
| 'downloading'
|
||||
| 'extracting'
|
||||
| 'validating'
|
||||
|
||||
export interface InstallJobSnapshot {
|
||||
job_id: string
|
||||
instance_id?: string | null
|
||||
kind:
|
||||
| 'create_instance'
|
||||
| 'create_modpack_instance'
|
||||
| 'import_instance'
|
||||
| 'duplicate_instance'
|
||||
| 'install_existing_instance'
|
||||
| 'install_pack_to_existing_instance'
|
||||
status: InstallJobStatus
|
||||
target:
|
||||
| { type: 'new_instance'; instance_id?: string | null }
|
||||
| { type: 'existing_instance'; instance_id: string }
|
||||
phase: InstallPhaseId
|
||||
progress?: InstallProgress | null
|
||||
details:
|
||||
| { type: 'empty' }
|
||||
| { type: 'instance'; name: string }
|
||||
| { type: 'minecraft'; game_version: string; loader: InstanceLoader }
|
||||
| { type: 'java'; major_version: number; step: InstallJavaStep }
|
||||
| {
|
||||
type: 'modpack'
|
||||
project_id?: string | null
|
||||
version_id?: string | null
|
||||
title?: string | null
|
||||
}
|
||||
| { type: 'import'; launcher_type: string; instance_folder: string }
|
||||
display?: { title: string; icon?: string | null } | null
|
||||
error?: { code: string; message: string } | null
|
||||
created: string
|
||||
modified: string
|
||||
finished?: string | null
|
||||
}
|
||||
|
||||
export async function install_get_modpack_preview(location: CreatePackLocation) {
|
||||
return await invoke<InstallModpackPreview>('plugin:install|install_get_modpack_preview', {
|
||||
location,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_create_instance(request: InstallCreateInstanceRequest) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_create_instance', { request })
|
||||
}
|
||||
|
||||
export async function install_create_modpack_instance(
|
||||
location: CreatePackLocation,
|
||||
postInstallEdit?: InstallPostInstallEdit | null,
|
||||
) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_create_modpack_instance', {
|
||||
location,
|
||||
postInstallEdit,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_import_instance(
|
||||
launcherType: string,
|
||||
basePath: string,
|
||||
instanceFolder: string,
|
||||
) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_import_instance', {
|
||||
launcherType,
|
||||
basePath,
|
||||
instanceFolder,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_duplicate_instance(sourceInstanceId: string) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_duplicate_instance', {
|
||||
sourceInstanceId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_existing_instance(instanceId: string, force: boolean) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_existing_instance', {
|
||||
instanceId,
|
||||
force,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_pack_to_existing_instance(
|
||||
instanceId: string,
|
||||
location: CreatePackLocation,
|
||||
postInstallEdit?: InstallPostInstallEdit | null,
|
||||
) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_pack_to_existing_instance', {
|
||||
instanceId,
|
||||
location,
|
||||
postInstallEdit,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_job_list(includeFinished: boolean) {
|
||||
return await invoke<InstallJobSnapshot[]>('plugin:install|install_job_list', { includeFinished })
|
||||
}
|
||||
|
||||
export async function install_job_get(jobId: string) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_job_get', { jobId })
|
||||
}
|
||||
|
||||
export async function install_job_retry(jobId: string) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_job_retry', { jobId })
|
||||
}
|
||||
|
||||
export async function install_job_cancel(jobId: string) {
|
||||
return await invoke<InstallJobSnapshot>('plugin:install|install_job_cancel', { jobId })
|
||||
}
|
||||
|
||||
export async function install_job_dismiss(jobId: string) {
|
||||
return await invoke<void>('plugin:install|install_job_dismiss', { jobId })
|
||||
}
|
||||
|
||||
export function installJobInstanceId(job: InstallJobSnapshot): string | null {
|
||||
return job.instance_id ?? job.target.instance_id ?? null
|
||||
}
|
||||
|
||||
export function isInstallJobFinished(status: InstallJobStatus) {
|
||||
return (
|
||||
status === 'succeeded' ||
|
||||
status === 'failed' ||
|
||||
status === 'interrupted' ||
|
||||
status === 'canceled'
|
||||
)
|
||||
}
|
||||
|
||||
function settleInstallJob(job: InstallJobSnapshot) {
|
||||
if (job.status === 'succeeded') return job
|
||||
|
||||
throw new Error(job.error?.message ?? `Install job ${job.job_id} ${job.status}`)
|
||||
}
|
||||
|
||||
export async function wait_for_install_job(jobId: string) {
|
||||
const current = await install_job_get(jobId)
|
||||
if (isInstallJobFinished(current.status)) return settleInstallJob(current)
|
||||
|
||||
return await new Promise<InstallJobSnapshot>((resolve, reject) => {
|
||||
let finished = false
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
unlisten = null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveJob = (job: InstallJobSnapshot) => {
|
||||
if (finished || job.job_id !== jobId || !isInstallJobFinished(job.status)) return
|
||||
|
||||
finished = true
|
||||
cleanup()
|
||||
|
||||
try {
|
||||
resolve(settleInstallJob(job))
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
}
|
||||
|
||||
const rejectWait = (err: unknown) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
|
||||
install_job_listener(resolveJob)
|
||||
.then((listener) => {
|
||||
if (finished) {
|
||||
listener()
|
||||
return
|
||||
}
|
||||
|
||||
unlisten = listener
|
||||
install_job_get(jobId).then(resolveJob).catch(rejectWait)
|
||||
})
|
||||
.catch(rejectWait)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
ContentItem,
|
||||
ContentModpackCardCategory,
|
||||
ContentModpackCardProject,
|
||||
ContentModpackCardVersion,
|
||||
ContentOwner,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
get_content_items,
|
||||
get_linked_modpack_info,
|
||||
type LinkedModpackInfo,
|
||||
} from '@/helpers/instance'
|
||||
import { get_categories } from '@/helpers/tags.js'
|
||||
import type { CacheBehaviour } from '@/helpers/types'
|
||||
|
||||
export type InstanceContentData = {
|
||||
path: string
|
||||
contentItems: ContentItem[] | null
|
||||
modpack: InstanceContentModpackData | null
|
||||
}
|
||||
|
||||
export type InstanceContentModpackData = {
|
||||
project: ContentModpackCardProject
|
||||
version: ContentModpackCardVersion
|
||||
owner: ContentOwner | null
|
||||
categories: ContentModpackCardCategory[]
|
||||
hasUpdate: boolean
|
||||
updateVersionId: string | null
|
||||
}
|
||||
|
||||
export async function loadInstanceContentData(
|
||||
path: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
onError?: (error: Error) => unknown,
|
||||
): Promise<InstanceContentData> {
|
||||
const [contentItems, modpackInfo, allCategories] = await Promise.all([
|
||||
get_content_items(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
|
||||
get_linked_modpack_info(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
|
||||
get_categories().catch((error) => handleLoadError(error, onError)),
|
||||
])
|
||||
|
||||
return {
|
||||
path,
|
||||
contentItems: (contentItems as ContentItem[] | null | undefined) ?? null,
|
||||
modpack: normalizeLinkedModpackInfo(
|
||||
modpackInfo as LinkedModpackInfo | null | undefined,
|
||||
allCategories as ContentModpackCardCategory[] | null | undefined,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadError(error: unknown, onError?: (error: Error) => unknown) {
|
||||
onError?.(error as Error)
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeLinkedModpackInfo(
|
||||
modpackInfo: LinkedModpackInfo | null | undefined,
|
||||
allCategories: ContentModpackCardCategory[] | null | undefined,
|
||||
): InstanceContentModpackData | null {
|
||||
if (!modpackInfo) return null
|
||||
|
||||
return {
|
||||
project: {
|
||||
...modpackInfo.project,
|
||||
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
|
||||
icon_url: modpackInfo.project.icon_url ?? undefined,
|
||||
},
|
||||
version: {
|
||||
...modpackInfo.version,
|
||||
date_published: modpackInfo.version.date_published.toString(),
|
||||
},
|
||||
owner: modpackInfo.owner
|
||||
? {
|
||||
...modpackInfo.owner,
|
||||
avatar_url: modpackInfo.owner.avatar_url ?? undefined,
|
||||
}
|
||||
: null,
|
||||
categories: resolveLinkedModpackCategories(modpackInfo, allCategories),
|
||||
hasUpdate: modpackInfo.has_update,
|
||||
updateVersionId: modpackInfo.update_version_id,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLinkedModpackCategories(
|
||||
modpackInfo: LinkedModpackInfo,
|
||||
allCategories: ContentModpackCardCategory[] | null | undefined,
|
||||
) {
|
||||
if (!allCategories || !modpackInfo.project.categories) return []
|
||||
|
||||
const seen = new Set<string>()
|
||||
return allCategories.filter((category) => {
|
||||
if (modpackInfo.project.categories.includes(category.name) && !seen.has(category.name)) {
|
||||
seen.add(category.name)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ContentItem, ContentOwner } from '@modrinth/ui'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import type { InstallJobSnapshot } from './install'
|
||||
import type {
|
||||
CacheBehaviour,
|
||||
ContentFile,
|
||||
ContentFileProjectType,
|
||||
GameInstance,
|
||||
InstanceLoader,
|
||||
} from './types'
|
||||
|
||||
export async function remove(instanceId: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_remove', { instanceId })
|
||||
}
|
||||
|
||||
export async function get(instanceId: string): Promise<GameInstance | null> {
|
||||
return await invoke('plugin:instance|instance_get', { instanceId })
|
||||
}
|
||||
|
||||
export async function get_many(instanceIds: string[]): Promise<GameInstance[]> {
|
||||
return await invoke('plugin:instance|instance_get_many', { instanceIds })
|
||||
}
|
||||
|
||||
export async function get_projects(
|
||||
instanceId: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<Record<string, ContentFile>> {
|
||||
return await invoke('plugin:instance|instance_get_projects', { instanceId, cacheBehaviour })
|
||||
}
|
||||
|
||||
export async function get_installed_project_ids(instanceId: string): Promise<string[]> {
|
||||
return await invoke('plugin:instance|instance_get_installed_project_ids', { instanceId })
|
||||
}
|
||||
|
||||
export type InstanceInstallTarget = {
|
||||
game_version: string
|
||||
loader: string
|
||||
}
|
||||
|
||||
export type InstanceInstallCandidate = {
|
||||
id: string
|
||||
name: string
|
||||
icon_path?: string | null
|
||||
game_version: string
|
||||
loader: InstanceLoader
|
||||
installed: boolean
|
||||
compatible: boolean
|
||||
}
|
||||
|
||||
export async function get_install_candidates(
|
||||
projectId: string,
|
||||
projectType: string,
|
||||
targets: InstanceInstallTarget[],
|
||||
): Promise<InstanceInstallCandidate[]> {
|
||||
return await invoke('plugin:instance|instance_get_install_candidates', {
|
||||
projectId,
|
||||
projectType,
|
||||
targets,
|
||||
})
|
||||
}
|
||||
|
||||
// Get content items with rich metadata for an instance
|
||||
// Returns content items filtered to exclude modpack files (if linked),
|
||||
// sorted alphabetically by project name
|
||||
export async function get_content_items(
|
||||
instanceId: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<ContentItem[]> {
|
||||
return await invoke('plugin:instance|instance_get_content_items', { instanceId, cacheBehaviour })
|
||||
}
|
||||
|
||||
// Linked modpack info returned from backend
|
||||
export interface LinkedModpackInfo {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
version: Labrinth.Versions.v2.Version
|
||||
owner: ContentOwner | null
|
||||
has_update: boolean
|
||||
update_version_id: string | null
|
||||
update_version: Labrinth.Versions.v2.Version | null
|
||||
}
|
||||
|
||||
// Get linked modpack info for an instance
|
||||
// Returns project, version, and owner information for the linked modpack,
|
||||
// or null if the instance is not linked to a modpack
|
||||
export async function get_linked_modpack_info(
|
||||
instanceId: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<LinkedModpackInfo | null> {
|
||||
return await invoke('plugin:instance|instance_get_linked_modpack_info', {
|
||||
instanceId,
|
||||
cacheBehaviour,
|
||||
})
|
||||
}
|
||||
|
||||
// Get content items that are part of the linked modpack
|
||||
// Returns the modpack's dependencies as ContentItem list
|
||||
// Returns empty array if the instance is not linked to a modpack
|
||||
export async function get_linked_modpack_content(
|
||||
instanceId: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<ContentItem[]> {
|
||||
return await invoke('plugin:instance|instance_get_linked_modpack_content', {
|
||||
instanceId,
|
||||
cacheBehaviour,
|
||||
})
|
||||
}
|
||||
|
||||
// Convert a list of dependencies into ContentItems with rich metadata
|
||||
export async function get_dependencies_as_content_items(
|
||||
dependencies: Labrinth.Versions.v3.Dependency[],
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<ContentItem[]> {
|
||||
return await invoke('plugin:instance|instance_get_dependencies_as_content_items', {
|
||||
dependencies,
|
||||
cacheBehaviour,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_full_path(instanceId: string): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_get_full_path', { instanceId })
|
||||
}
|
||||
|
||||
export async function get_mod_full_path(instanceId: string, projectPath: string): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_get_mod_full_path', { instanceId, projectPath })
|
||||
}
|
||||
|
||||
export interface JavaVersion {
|
||||
parsed_version: number
|
||||
version: string
|
||||
architecture: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export async function get_optimal_jre_key(instanceId: string): Promise<JavaVersion | null> {
|
||||
return await invoke('plugin:instance|instance_get_optimal_jre_key', { instanceId })
|
||||
}
|
||||
|
||||
export async function list(): Promise<GameInstance[]> {
|
||||
return await invoke('plugin:instance|instance_list')
|
||||
}
|
||||
|
||||
export async function check_installed(instanceId: string, projectId: string): Promise<boolean> {
|
||||
return await invoke('plugin:instance|instance_check_installed', { instanceId, projectId })
|
||||
}
|
||||
|
||||
export async function update_all(instanceId: string): Promise<Record<string, string>> {
|
||||
return await invoke('plugin:instance|instance_update_all', { instanceId })
|
||||
}
|
||||
|
||||
// Updates a specified project
|
||||
export async function update_project(instanceId: string, projectPath: string): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_update_project', { instanceId, projectPath })
|
||||
}
|
||||
|
||||
// Add a project to an instance from a version
|
||||
// Returns a path to the new project file
|
||||
export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update'
|
||||
|
||||
export interface ResolutionPreferences {
|
||||
game_versions?: string[]
|
||||
loaders?: string[]
|
||||
}
|
||||
|
||||
export interface ResolveContentRequest {
|
||||
project_id: string
|
||||
version_id?: string | null
|
||||
content_type: Labrinth.Content.v3.ContentType
|
||||
selected?: ResolutionPreferences
|
||||
}
|
||||
|
||||
export interface ResolvedContent {
|
||||
project_id: string
|
||||
version_id: string
|
||||
dependent_on_version_id?: string | null
|
||||
}
|
||||
|
||||
export interface ResolveContentPlan {
|
||||
primary: ResolvedContent
|
||||
dependencies: ResolvedContent[]
|
||||
skipped: Array<{
|
||||
project_id: string
|
||||
version_id?: string | null
|
||||
dependent_on_version_id?: string | null
|
||||
reason: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function add_project_from_version(
|
||||
instanceId: string,
|
||||
versionId: string,
|
||||
reason: DownloadReason,
|
||||
dependentOnVersionId?: string,
|
||||
): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_add_project_from_version', {
|
||||
instanceId,
|
||||
versionId,
|
||||
reason,
|
||||
dependentOnVersionId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function install_project_with_dependencies(
|
||||
instanceId: string,
|
||||
request: ResolveContentRequest,
|
||||
): Promise<ResolveContentPlan> {
|
||||
return await invoke('plugin:instance|instance_install_project_with_dependencies', {
|
||||
instanceId,
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
export async function switch_project_version_with_dependencies(
|
||||
instanceId: string,
|
||||
projectPath: string,
|
||||
versionId: string,
|
||||
): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_switch_project_version_with_dependencies', {
|
||||
instanceId,
|
||||
projectPath,
|
||||
versionId,
|
||||
})
|
||||
}
|
||||
|
||||
// Add a project to an instance from a path + project_type
|
||||
// Returns a path to the new project file
|
||||
export async function add_project_from_path(
|
||||
instanceId: string,
|
||||
projectPath: string,
|
||||
projectType?: ContentFileProjectType,
|
||||
): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_add_project_from_path', {
|
||||
instanceId,
|
||||
projectPath,
|
||||
projectType,
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle disabling a project
|
||||
export async function toggle_disable_project(
|
||||
instanceId: string,
|
||||
projectPath: string,
|
||||
desiredEnabled?: boolean,
|
||||
): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_toggle_disable_project', {
|
||||
instanceId,
|
||||
projectPath,
|
||||
desiredEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
// Remove a project
|
||||
export async function remove_project(instanceId: string, projectPath: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_remove_project', { instanceId, projectPath })
|
||||
}
|
||||
|
||||
// Update a managed Modrinth instance to a specific version
|
||||
export async function update_managed_modrinth_version(
|
||||
instanceId: string,
|
||||
versionId: string,
|
||||
): Promise<InstallJobSnapshot> {
|
||||
return await invoke('plugin:instance|instance_update_managed_modrinth_version', {
|
||||
instanceId,
|
||||
versionId,
|
||||
})
|
||||
}
|
||||
|
||||
// Repair a managed Modrinth instance
|
||||
export async function update_repair_modrinth(instanceId: string): Promise<InstallJobSnapshot> {
|
||||
return await invoke('plugin:instance|instance_repair_managed_modrinth', { instanceId })
|
||||
}
|
||||
|
||||
// Export an instance to .mrpack
|
||||
// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
|
||||
// Version id is optional (ie: 1.1.5)
|
||||
export async function export_instance_mrpack(
|
||||
instanceId: string,
|
||||
exportLocation: string,
|
||||
includedOverrides: string[],
|
||||
versionId?: string,
|
||||
description?: string,
|
||||
name?: string,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_export_mrpack', {
|
||||
instanceId,
|
||||
exportLocation,
|
||||
includedOverrides,
|
||||
versionId,
|
||||
description,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
// Given a folder path, populate an array of all the subfolders
|
||||
// Intended to be used for finding potential override folders
|
||||
// profile
|
||||
// -- mods
|
||||
// -- resourcepacks
|
||||
// -- file1
|
||||
// => [mods, resourcepacks]
|
||||
// allows selection for 'included_overrides' in export_instance_mrpack
|
||||
export async function get_pack_export_candidates(instanceId: string): Promise<string[]> {
|
||||
return await invoke('plugin:instance|instance_get_pack_export_candidates', { instanceId })
|
||||
}
|
||||
|
||||
// Run Minecraft using an instance
|
||||
// Returns PID of child
|
||||
export async function run(
|
||||
instanceId: string,
|
||||
serverAddress: string | null = null,
|
||||
): Promise<unknown> {
|
||||
return await invoke('plugin:instance|instance_run', { instanceId, serverAddress })
|
||||
}
|
||||
|
||||
export async function kill(instanceId: string): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_kill', { instanceId })
|
||||
}
|
||||
|
||||
// Edits an instance
|
||||
export async function edit(instanceId: string, editInstance: Partial<GameInstance>): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_edit', { instanceId, editInstance })
|
||||
}
|
||||
|
||||
// Edits an instance's icon
|
||||
export async function edit_icon(instanceId: string, iconPath: string | null): Promise<void> {
|
||||
return await invoke('plugin:instance|instance_edit_icon', { instanceId, iconPath })
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/*
|
||||
A log is a struct containing the filename string, stdout, and stderr, as follows:
|
||||
A log is a struct containing the filename string and optional output, as follows:
|
||||
|
||||
pub struct Logs {
|
||||
pub filename: String,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub output: Option<String>,
|
||||
}
|
||||
*/
|
||||
|
||||
/// Get all logs that exist for a given profile
|
||||
/// Get all logs that exist for a given instance
|
||||
/// This is returned as an array of Log objects, sorted by filename (the folder name, when the log was created)
|
||||
export async function get_logs(profilePath, clearContents) {
|
||||
return await invoke('plugin:logs|logs_get_logs', { profilePath, clearContents })
|
||||
export async function get_logs(instanceId, clearContents) {
|
||||
return await invoke('plugin:logs|logs_get_logs', { instanceId, clearContents })
|
||||
}
|
||||
|
||||
/// Get a profile's log by filename
|
||||
export async function get_logs_by_filename(profilePath, logType, filename) {
|
||||
return await invoke('plugin:logs|logs_get_logs_by_filename', { profilePath, logType, filename })
|
||||
/// Get an instance's log by filename
|
||||
export async function get_logs_by_filename(instanceId, logType, filename) {
|
||||
return await invoke('plugin:logs|logs_get_logs_by_filename', { instanceId, logType, filename })
|
||||
}
|
||||
|
||||
/// Get a profile's log text only by filename
|
||||
export async function get_output_by_filename(profilePath, logType, filename) {
|
||||
/// Get an instance's log text only by filename
|
||||
export async function get_output_by_filename(instanceId, logType, filename) {
|
||||
return await invoke('plugin:logs|logs_get_output_by_filename', {
|
||||
profilePath,
|
||||
instanceId,
|
||||
logType,
|
||||
filename,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a profile's log by filename
|
||||
export async function delete_logs_by_filename(profilePath, logType, filename) {
|
||||
/// Delete an instance's log by filename
|
||||
export async function delete_logs_by_filename(instanceId, logType, filename) {
|
||||
return await invoke('plugin:logs|logs_delete_logs_by_filename', {
|
||||
profilePath,
|
||||
instanceId,
|
||||
logType,
|
||||
filename,
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete all logs for a given profile
|
||||
export async function delete_logs(profilePath) {
|
||||
return await invoke('plugin:logs|logs_delete_logs', { profilePath })
|
||||
/// Delete all logs for a given instance
|
||||
export async function delete_logs(instanceId) {
|
||||
return await invoke('plugin:logs|logs_delete_logs', { instanceId })
|
||||
}
|
||||
|
||||
/// Get the latest log for a given profile and cursor (startpoint to read withi nthe file)
|
||||
/// Get the latest log for a given instance and cursor (startpoint to read within the file)
|
||||
/// Returns:
|
||||
/*
|
||||
{
|
||||
@@ -60,16 +59,16 @@ export async function delete_logs(profilePath) {
|
||||
*/
|
||||
|
||||
// From latest.log directly
|
||||
export async function get_latest_log_cursor(profilePath, cursor) {
|
||||
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
|
||||
export async function get_latest_log_cursor(instanceId, cursor) {
|
||||
return await invoke('plugin:logs|logs_get_latest_log_cursor', { instanceId, cursor })
|
||||
}
|
||||
|
||||
/// Get all buffered live log lines for a profile from the Rust ring buffer
|
||||
export async function get_live_log_buffer(profilePath) {
|
||||
return await invoke('plugin:logs|logs_get_live_log_buffer', { profilePath })
|
||||
/// Get all buffered live log lines for an instance from the Rust ring buffer
|
||||
export async function get_live_log_buffer(instanceId) {
|
||||
return await invoke('plugin:logs|logs_get_live_log_buffer', { instanceId })
|
||||
}
|
||||
|
||||
/// Clear the live log buffer for a profile on the Rust side
|
||||
export async function clear_log_buffer(profilePath) {
|
||||
return await invoke('plugin:logs|logs_clear_live_log_buffer', { profilePath })
|
||||
/// Clear the live log buffer for an instance on the Rust side
|
||||
export async function clear_log_buffer(instanceId) {
|
||||
return await invoke('plugin:logs|logs_clear_live_log_buffer', { instanceId })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import { create } from './profile'
|
||||
import type { InstanceLoader } from './types'
|
||||
|
||||
interface PackProfileCreator {
|
||||
name: string
|
||||
gameVersion: string
|
||||
modloader: InstanceLoader
|
||||
loaderVersion: string | null
|
||||
unknownFile: boolean
|
||||
}
|
||||
|
||||
interface PackLocationVersionId {
|
||||
type: 'fromVersionId'
|
||||
project_id: string
|
||||
version_id: string
|
||||
title: string
|
||||
icon_url?: string
|
||||
}
|
||||
|
||||
interface PackLocationFile {
|
||||
type: 'fromFile'
|
||||
path: string
|
||||
}
|
||||
|
||||
export async function create_profile_and_install(
|
||||
projectId: string,
|
||||
versionId: string,
|
||||
packTitle: string,
|
||||
iconUrl?: string,
|
||||
createInstanceCallback: (profile: string) => void = () => {},
|
||||
): Promise<void> {
|
||||
const location: PackLocationVersionId = {
|
||||
type: 'fromVersionId',
|
||||
project_id: projectId,
|
||||
version_id: versionId,
|
||||
title: packTitle,
|
||||
icon_url: iconUrl,
|
||||
}
|
||||
const profile_creator = await invoke<PackProfileCreator>(
|
||||
'plugin:pack|pack_get_profile_from_pack',
|
||||
{ location },
|
||||
)
|
||||
const profile = await create(
|
||||
profile_creator.name,
|
||||
profile_creator.gameVersion,
|
||||
profile_creator.modloader,
|
||||
profile_creator.loaderVersion,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
createInstanceCallback(profile)
|
||||
|
||||
return await invoke('plugin:pack|pack_install', { location, profile })
|
||||
}
|
||||
|
||||
export async function install_to_existing_profile(
|
||||
projectId: string,
|
||||
versionId: string,
|
||||
title: string,
|
||||
profilePath: string,
|
||||
): Promise<void> {
|
||||
const location: PackLocationVersionId = {
|
||||
type: 'fromVersionId',
|
||||
project_id: projectId,
|
||||
version_id: versionId,
|
||||
title,
|
||||
}
|
||||
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
|
||||
}
|
||||
|
||||
export async function create_profile_and_install_from_file(
|
||||
path: string,
|
||||
showUnknownPackWarningModal?: (createProfile: () => Promise<void>, fileName: string) => void,
|
||||
): Promise<void> {
|
||||
const location: PackLocationFile = {
|
||||
type: 'fromFile',
|
||||
path,
|
||||
}
|
||||
const profile_creator = await invoke<PackProfileCreator>(
|
||||
'plugin:pack|pack_get_profile_from_pack',
|
||||
{ location },
|
||||
)
|
||||
|
||||
const createProfile = async () => {
|
||||
const profile = await create(
|
||||
profile_creator.name,
|
||||
profile_creator.gameVersion,
|
||||
profile_creator.modloader,
|
||||
profile_creator.loaderVersion,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
await invoke('plugin:pack|pack_install', { location, profile })
|
||||
}
|
||||
|
||||
if (profile_creator.unknownFile && showUnknownPackWarningModal) {
|
||||
const splitPath = path.split(/[\\/]/)
|
||||
const fileName = splitPath ? splitPath[splitPath.length - 1] : path
|
||||
showUnknownPackWarningModal(createProfile, fileName)
|
||||
} else {
|
||||
await createProfile()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/// Gets all running process IDs with a given profile path
|
||||
/// Gets all running process IDs with a given instance ID
|
||||
/// Returns [u32]
|
||||
export async function get_by_profile_path(path) {
|
||||
return await invoke('plugin:process|process_get_by_profile_path', { path })
|
||||
export async function get_by_instance_id(instanceId) {
|
||||
return await invoke('plugin:process|process_get_by_instance_id', { instanceId })
|
||||
}
|
||||
|
||||
/// Gets all running process IDs with a given profile path
|
||||
/// Gets all running process IDs
|
||||
/// Returns [u32]
|
||||
export async function get_all() {
|
||||
return await invoke('plugin:process|process_get_all')
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ContentItem, ContentOwner } from '@modrinth/ui'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import { install_to_existing_profile } from '@/helpers/pack'
|
||||
|
||||
import type {
|
||||
CacheBehaviour,
|
||||
ContentFile,
|
||||
ContentFileProjectType,
|
||||
GameInstance,
|
||||
InstanceLoader,
|
||||
} from './types'
|
||||
|
||||
// Add instance
|
||||
/*
|
||||
name: String, // the name of the profile, and relative path to create
|
||||
game_version: String, // the game version of the profile
|
||||
modloader: ModLoader, // the modloader to use
|
||||
- ModLoader is an enum, with the following variants: Vanilla, Forge, Fabric, Quilt
|
||||
loader_version: String, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
|
||||
icon: Path, // the icon for the profile
|
||||
- icon is a path to an image file, which will be copied into the profile directory
|
||||
*/
|
||||
|
||||
export async function create(
|
||||
name: string,
|
||||
gameVersion: string,
|
||||
modloader: InstanceLoader,
|
||||
loaderVersion: string | null,
|
||||
icon: string | null,
|
||||
skipInstall: boolean,
|
||||
linkedData?: { project_id: string; version_id: string; locked: boolean } | null,
|
||||
): Promise<string> {
|
||||
// Trim string name to avoid "Unable to find directory"
|
||||
name = name.trim()
|
||||
return await invoke('plugin:profile-create|profile_create', {
|
||||
name,
|
||||
gameVersion,
|
||||
modloader,
|
||||
loaderVersion,
|
||||
icon,
|
||||
skipInstall,
|
||||
linkedData,
|
||||
})
|
||||
}
|
||||
|
||||
// duplicate a profile
|
||||
export async function duplicate(path: string): Promise<string> {
|
||||
return await invoke('plugin:profile-create|profile_duplicate', { path })
|
||||
}
|
||||
|
||||
// Remove a profile
|
||||
export async function remove(path: string): Promise<void> {
|
||||
return await invoke('plugin:profile|profile_remove', { path })
|
||||
}
|
||||
|
||||
// Get a profile by path
|
||||
// Returns a Profile
|
||||
export async function get(path: string): Promise<GameInstance | null> {
|
||||
return await invoke('plugin:profile|profile_get', { path })
|
||||
}
|
||||
|
||||
export async function get_many(paths: string[]): Promise<GameInstance[]> {
|
||||
return await invoke('plugin:profile|profile_get_many', { paths })
|
||||
}
|
||||
|
||||
// Get a profile's projects
|
||||
// Returns a map of a path to profile file
|
||||
export async function get_projects(
|
||||
path: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<Record<string, ContentFile>> {
|
||||
return await invoke('plugin:profile|profile_get_projects', { path, cacheBehaviour })
|
||||
}
|
||||
|
||||
// Get just the installed project IDs for a profile (lightweight, skips update checks)
|
||||
export async function get_installed_project_ids(path: string): Promise<string[]> {
|
||||
return await invoke('plugin:profile|profile_get_installed_project_ids', { path })
|
||||
}
|
||||
|
||||
// Get content items with rich metadata for a profile
|
||||
// Returns content items filtered to exclude modpack files (if linked),
|
||||
// sorted alphabetically by project name
|
||||
export async function get_content_items(
|
||||
path: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<ContentItem[]> {
|
||||
return await invoke('plugin:profile|profile_get_content_items', { path, cacheBehaviour })
|
||||
}
|
||||
|
||||
// Linked modpack info returned from backend
|
||||
export interface LinkedModpackInfo {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
version: Labrinth.Versions.v2.Version
|
||||
owner: ContentOwner | null
|
||||
has_update: boolean
|
||||
update_version_id: string | null
|
||||
update_version: Labrinth.Versions.v2.Version | null
|
||||
}
|
||||
|
||||
// Get linked modpack info for a profile
|
||||
// Returns project, version, and owner information for the linked modpack,
|
||||
// or null if the profile is not linked to a modpack
|
||||
export async function get_linked_modpack_info(
|
||||
path: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<LinkedModpackInfo | null> {
|
||||
return await invoke('plugin:profile|profile_get_linked_modpack_info', { path, cacheBehaviour })
|
||||
}
|
||||
|
||||
// Get content items that are part of the linked modpack
|
||||
// Returns the modpack's dependencies as ContentItem list
|
||||
// Returns empty array if the profile is not linked to a modpack
|
||||
export async function get_linked_modpack_content(
|
||||
path: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<ContentItem[]> {
|
||||
return await invoke('plugin:profile|profile_get_linked_modpack_content', { path, cacheBehaviour })
|
||||
}
|
||||
|
||||
// Convert a list of dependencies into ContentItems with rich metadata
|
||||
export async function get_dependencies_as_content_items(
|
||||
dependencies: Labrinth.Versions.v3.Dependency[],
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
): Promise<ContentItem[]> {
|
||||
return await invoke('plugin:profile|profile_get_dependencies_as_content_items', {
|
||||
dependencies,
|
||||
cacheBehaviour,
|
||||
})
|
||||
}
|
||||
|
||||
// Get a profile's full fs path
|
||||
// Returns a path
|
||||
export async function get_full_path(path: string): Promise<string> {
|
||||
return await invoke('plugin:profile|profile_get_full_path', { path })
|
||||
}
|
||||
|
||||
// Get's a mod's full fs path
|
||||
// Returns a path
|
||||
export async function get_mod_full_path(path: string, projectPath: string): Promise<string> {
|
||||
return await invoke('plugin:profile|profile_get_mod_full_path', { path, projectPath })
|
||||
}
|
||||
|
||||
// Get optimal java version from profile
|
||||
// Returns a java version
|
||||
export async function get_optimal_jre_key(path: string): Promise<string | null> {
|
||||
return await invoke('plugin:profile|profile_get_optimal_jre_key', { path })
|
||||
}
|
||||
|
||||
// Get a copy of the profile set
|
||||
// Returns hashmap of path -> Profile
|
||||
export async function list(): Promise<GameInstance[]> {
|
||||
return await invoke('plugin:profile|profile_list')
|
||||
}
|
||||
|
||||
export async function check_installed(path: string, projectId: string): Promise<boolean> {
|
||||
return await invoke('plugin:profile|profile_check_installed', { path, projectId })
|
||||
}
|
||||
|
||||
export async function check_installed_batch(projectId: string): Promise<Record<string, boolean>> {
|
||||
return await invoke('plugin:profile|profile_check_installed_batch', { projectId })
|
||||
}
|
||||
|
||||
// Installs/Repairs a profile
|
||||
export async function install(path: string, force: boolean): Promise<void> {
|
||||
return await invoke('plugin:profile|profile_install', { path, force })
|
||||
}
|
||||
|
||||
// Updates all of a profile's projects
|
||||
export async function update_all(path: string): Promise<Record<string, string>> {
|
||||
return await invoke('plugin:profile|profile_update_all', { path })
|
||||
}
|
||||
|
||||
// Updates a specified project
|
||||
export async function update_project(path: string, projectPath: string): Promise<string> {
|
||||
return await invoke('plugin:profile|profile_update_project', { path, projectPath })
|
||||
}
|
||||
|
||||
// Add a project to a profile from a version
|
||||
// Returns a path to the new project file
|
||||
export type DownloadReason = 'standalone' | 'dependency' | 'modpack'
|
||||
|
||||
export async function add_project_from_version(
|
||||
path: string,
|
||||
versionId: string,
|
||||
reason: DownloadReason,
|
||||
): Promise<string> {
|
||||
return await invoke('plugin:profile|profile_add_project_from_version', {
|
||||
path,
|
||||
versionId,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
// Add a project to a profile from a path + project_type
|
||||
// Returns a path to the new project file
|
||||
export async function add_project_from_path(
|
||||
path: string,
|
||||
projectPath: string,
|
||||
projectType?: ContentFileProjectType,
|
||||
): Promise<string> {
|
||||
return await invoke('plugin:profile|profile_add_project_from_path', {
|
||||
path,
|
||||
projectPath,
|
||||
projectType,
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle disabling a project
|
||||
export async function toggle_disable_project(path: string, projectPath: string): Promise<string> {
|
||||
return await invoke('plugin:profile|profile_toggle_disable_project', { path, projectPath })
|
||||
}
|
||||
|
||||
// Remove a project
|
||||
export async function remove_project(path: string, projectPath: string): Promise<void> {
|
||||
return await invoke('plugin:profile|profile_remove_project', { path, projectPath })
|
||||
}
|
||||
|
||||
// Update a managed Modrinth profile to a specific version
|
||||
export async function update_managed_modrinth_version(
|
||||
path: string,
|
||||
versionId: string,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:profile|profile_update_managed_modrinth_version', {
|
||||
path,
|
||||
versionId,
|
||||
})
|
||||
}
|
||||
|
||||
// Repair a managed Modrinth profile
|
||||
export async function update_repair_modrinth(path: string): Promise<void> {
|
||||
return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
|
||||
}
|
||||
|
||||
// Export a profile to .mrpack
|
||||
// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
|
||||
// Version id is optional (ie: 1.1.5)
|
||||
export async function export_profile_mrpack(
|
||||
path: string,
|
||||
exportLocation: string,
|
||||
includedOverrides: string[],
|
||||
versionId?: string,
|
||||
description?: string,
|
||||
name?: string,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:profile|profile_export_mrpack', {
|
||||
path,
|
||||
exportLocation,
|
||||
includedOverrides,
|
||||
versionId,
|
||||
description,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
// Given a folder path, populate an array of all the subfolders
|
||||
// Intended to be used for finding potential override folders
|
||||
// profile
|
||||
// -- mods
|
||||
// -- resourcepacks
|
||||
// -- file1
|
||||
// => [mods, resourcepacks]
|
||||
// allows selection for 'included_overrides' in export_profile_mrpack
|
||||
export async function get_pack_export_candidates(profilePath: string): Promise<string[]> {
|
||||
return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
|
||||
}
|
||||
|
||||
// Run Minecraft using a pathed profile
|
||||
// Returns PID of child
|
||||
export async function run(path: string, serverAddress: string | null = null): Promise<unknown> {
|
||||
return await invoke('plugin:profile|profile_run', { path, serverAddress })
|
||||
}
|
||||
|
||||
export async function kill(path: string): Promise<void> {
|
||||
return await invoke('plugin:profile|profile_kill', { path })
|
||||
}
|
||||
|
||||
// Edits a profile
|
||||
export async function edit(path: string, editProfile: Partial<GameInstance>): Promise<void> {
|
||||
return await invoke('plugin:profile|profile_edit', { path, editProfile })
|
||||
}
|
||||
|
||||
// Edits a profile's icon
|
||||
export async function edit_icon(path: string, iconPath: string | null): Promise<void> {
|
||||
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
|
||||
}
|
||||
|
||||
export async function finish_install(instance: GameInstance): Promise<void> {
|
||||
if (instance.install_stage !== 'pack_installed') {
|
||||
const linkedData = instance.linked_data
|
||||
if (linkedData) {
|
||||
await install_to_existing_profile(
|
||||
linkedData.project_id,
|
||||
linkedData.version_id,
|
||||
instance.name,
|
||||
instance.path,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
await install(instance.path, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { LocationQuery, LocationQueryRaw } from 'vue-router'
|
||||
|
||||
const MODRINTH_HOSTNAMES = new Set(['modrinth.com', 'www.modrinth.com'])
|
||||
|
||||
const SUPPORTED_PROJECT_TYPES = new Set([
|
||||
'mod',
|
||||
'modpack',
|
||||
'resourcepack',
|
||||
'datapack',
|
||||
'plugin',
|
||||
'shader',
|
||||
'server',
|
||||
'project',
|
||||
])
|
||||
|
||||
export function parseModrinthLink(
|
||||
href: string,
|
||||
): { slug: string; pathSuffix: string; url: URL } | null {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(href)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!MODRINTH_HOSTNAMES.has(url.hostname.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
|
||||
const segments = url.pathname.split('/').filter((p) => p.length > 0)
|
||||
if (segments.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (SUPPORTED_PROJECT_TYPES.has(segments[0].toLowerCase())) {
|
||||
const slug = segments[1]
|
||||
if (!slug) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rest: string[] = segments.slice(2)
|
||||
const pathSuffix = toValidAppSubpath(rest)
|
||||
if (pathSuffix === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { slug, pathSuffix, url }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const SUPPORTED_SUBPATHS = ['versions', 'gallery']
|
||||
|
||||
function toValidAppSubpath(rest: string[]): string | null {
|
||||
if (rest.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const subroute = rest[0].toLowerCase()
|
||||
if (rest.length === 1 && SUPPORTED_SUBPATHS.includes(subroute)) {
|
||||
return `/${subroute}`
|
||||
}
|
||||
|
||||
if (rest.length === 2 && subroute === 'version') {
|
||||
return `/version/${rest[1]}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function mergeUrlQuery(routeQuery: LocationQuery, linkUrl: URL): LocationQueryRaw {
|
||||
const newQuery: LocationQueryRaw = { ...routeQuery }
|
||||
const keys = new Set<string>()
|
||||
linkUrl.searchParams.forEach((_value, key) => {
|
||||
keys.add(key)
|
||||
})
|
||||
for (const key of keys) {
|
||||
const values = linkUrl.searchParams.getAll(key)
|
||||
newQuery[key] = values.length === 1 ? values[0] : values
|
||||
}
|
||||
return newQuery
|
||||
}
|
||||
@@ -3,9 +3,8 @@ import {
|
||||
applyCapeTexture,
|
||||
createTransparentTexture,
|
||||
disposeCaches,
|
||||
loadTexture,
|
||||
setupSkinModel,
|
||||
} from '@modrinth/utils'
|
||||
} from '@modrinth/ui'
|
||||
import * as THREE from 'three'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
@@ -16,12 +15,10 @@ import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
|
||||
export interface RenderResult {
|
||||
forwards: string
|
||||
backwards: string
|
||||
}
|
||||
|
||||
export interface RawRenderResult {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
}
|
||||
|
||||
class BatchSkinRenderer {
|
||||
@@ -29,6 +26,7 @@ class BatchSkinRenderer {
|
||||
private scene: THREE.Scene | null = null
|
||||
private camera: THREE.PerspectiveCamera | null = null
|
||||
private currentModel: THREE.Group | null = null
|
||||
private transparentTexture: THREE.Texture | null = null
|
||||
private readonly width: number
|
||||
private readonly height: number
|
||||
|
||||
@@ -52,6 +50,7 @@ class BatchSkinRenderer {
|
||||
})
|
||||
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.renderer.shadowMap.enabled = false
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 10.0
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
@@ -62,7 +61,7 @@ class BatchSkinRenderer {
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.castShadow = false
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
@@ -91,12 +90,9 @@ class BatchSkinRenderer {
|
||||
}
|
||||
|
||||
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
|
||||
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
|
||||
|
||||
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
|
||||
const backwards = await this.renderView(backCameraPos, lookAtTarget)
|
||||
|
||||
return { forwards, backwards }
|
||||
return { forwards }
|
||||
}
|
||||
|
||||
private async renderView(
|
||||
@@ -112,9 +108,19 @@ class BatchSkinRenderer {
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
|
||||
const response = await fetch(dataUrl)
|
||||
return await response.blob()
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
this.renderer!.domElement.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from rendered canvas'))
|
||||
}
|
||||
},
|
||||
'image/webp',
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
|
||||
@@ -122,14 +128,10 @@ class BatchSkinRenderer {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl)
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
|
||||
|
||||
if (capeUrl) {
|
||||
const capeTexture = await loadTexture(capeUrl)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
} else {
|
||||
const transparentTexture = createTransparentTexture()
|
||||
applyCapeTexture(model, null, transparentTexture)
|
||||
if (!capeUrl) {
|
||||
applyCapeTexture(model, null, this.getTransparentTexture())
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
@@ -141,39 +143,38 @@ class BatchSkinRenderer {
|
||||
this.currentModel = group
|
||||
}
|
||||
|
||||
private clearScene(): void {
|
||||
if (!this.scene) return
|
||||
|
||||
while (this.scene.children.length > 0) {
|
||||
const child = this.scene.children[0]
|
||||
this.scene.remove(child)
|
||||
|
||||
if (child instanceof THREE.Mesh) {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((material) => material.dispose())
|
||||
} else {
|
||||
child.material.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
private getTransparentTexture(): THREE.Texture {
|
||||
if (!this.transparentTexture) {
|
||||
this.transparentTexture = createTransparentTexture()
|
||||
}
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
return this.transparentTexture
|
||||
}
|
||||
|
||||
private clearScene(): void {
|
||||
if (!this.scene || !this.currentModel) return
|
||||
|
||||
this.scene.remove(this.currentModel)
|
||||
this.currentModel.clear()
|
||||
this.currentModel = null
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clearScene()
|
||||
|
||||
if (this.transparentTexture) {
|
||||
this.transparentTexture.dispose()
|
||||
this.transparentTexture = null
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose()
|
||||
}
|
||||
|
||||
this.renderer = null
|
||||
this.scene = null
|
||||
this.camera = null
|
||||
|
||||
disposeCaches()
|
||||
}
|
||||
}
|
||||
@@ -194,6 +195,9 @@ export const headBlobUrlMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
|
||||
let sharedRenderer: BatchSkinRenderer | null = null
|
||||
let latestPreviewGeneration = 0
|
||||
let previewGenerationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function getSharedRenderer(): BatchSkinRenderer {
|
||||
if (!sharedRenderer) {
|
||||
sharedRenderer = new BatchSkinRenderer()
|
||||
@@ -356,7 +360,27 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
|
||||
return await generateHeadRender(skin)
|
||||
}
|
||||
|
||||
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
export function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
const generation = ++latestPreviewGeneration
|
||||
const skinsSnapshot = [...skins]
|
||||
const capesSnapshot = [...capes]
|
||||
|
||||
const generationPromise = previewGenerationQueue.then(() =>
|
||||
generateSkinPreviewsForGeneration(skinsSnapshot, capesSnapshot, generation),
|
||||
)
|
||||
|
||||
previewGenerationQueue = generationPromise.catch(() => {})
|
||||
|
||||
return generationPromise
|
||||
}
|
||||
|
||||
async function generateSkinPreviewsForGeneration(
|
||||
skins: Skin[],
|
||||
capes: Cape[],
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
const isCurrentGeneration = () => generation === latestPreviewGeneration
|
||||
|
||||
try {
|
||||
const skinKeys = skins.map(
|
||||
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
|
||||
@@ -368,33 +392,35 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
headStorage.batchRetrieve(headKeys),
|
||||
])
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
for (let i = 0; i < skins.length; i++) {
|
||||
const skinKey = skinKeys[i]
|
||||
const headKey = headKeys[i]
|
||||
|
||||
const rawCached = cachedSkinPreviews[skinKey]
|
||||
if (rawCached) {
|
||||
if (rawCached && !skinBlobUrlMap.has(skinKey)) {
|
||||
const cached: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawCached.forwards),
|
||||
backwards: URL.createObjectURL(rawCached.backwards),
|
||||
}
|
||||
skinBlobUrlMap.set(skinKey, cached)
|
||||
}
|
||||
|
||||
const cachedHead = cachedHeadPreviews[headKey]
|
||||
if (cachedHead) {
|
||||
if (cachedHead && !headBlobUrlMap.has(headKey)) {
|
||||
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
|
||||
}
|
||||
}
|
||||
|
||||
for (const skin of skins) {
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
|
||||
if (skinBlobUrlMap.has(key)) {
|
||||
if (DEBUG_MODE) {
|
||||
const result = skinBlobUrlMap.get(key)!
|
||||
URL.revokeObjectURL(result.forwards)
|
||||
URL.revokeObjectURL(result.backwards)
|
||||
skinBlobUrlMap.delete(key)
|
||||
} else continue
|
||||
}
|
||||
@@ -419,9 +445,10 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
cape?.texture,
|
||||
)
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const renderResult: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||
backwards: URL.createObjectURL(rawRenderResult.backwards),
|
||||
}
|
||||
|
||||
skinBlobUrlMap.set(key, renderResult)
|
||||
@@ -439,9 +466,12 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
}
|
||||
} finally {
|
||||
disposeSharedRenderer()
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
if (isCurrentGeneration()) {
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface Cape {
|
||||
id: string
|
||||
name: string
|
||||
texture: string
|
||||
is_default: boolean
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
@@ -15,6 +14,7 @@ export type SkinSource = 'default' | 'custom_external' | 'custom'
|
||||
export interface Skin {
|
||||
texture_key: string
|
||||
name?: string
|
||||
section?: string
|
||||
variant: SkinModel
|
||||
cape_id?: string
|
||||
texture: string
|
||||
@@ -121,17 +121,11 @@ export async function get_available_skins(): Promise<Skin[]> {
|
||||
export async function add_and_equip_custom_skin(
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
capeOverride?: Cape,
|
||||
): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
cape?: Cape,
|
||||
): Promise<Skin> {
|
||||
return await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
textureBlob,
|
||||
variant,
|
||||
capeOverride,
|
||||
})
|
||||
}
|
||||
|
||||
export async function set_default_cape(cape?: Cape): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|set_default_cape', {
|
||||
cape,
|
||||
})
|
||||
}
|
||||
@@ -148,6 +142,28 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function set_custom_skin_order(textureKeys: string[]): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|set_custom_skin_order', {
|
||||
textureKeys,
|
||||
})
|
||||
}
|
||||
|
||||
export async function save_custom_skin(
|
||||
skin: Skin,
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
cape: Cape | undefined,
|
||||
replaceTexture: boolean,
|
||||
): Promise<Skin> {
|
||||
return await invoke('plugin:minecraft-skins|save_custom_skin', {
|
||||
skin,
|
||||
textureBlob,
|
||||
variant,
|
||||
cape,
|
||||
replaceTexture,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
|
||||
const data = await normalize_skin_texture(skin.texture)
|
||||
const base64 = arrayBufferToBase64(data)
|
||||
@@ -162,6 +178,16 @@ export async function unequip_skin(): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|unequip_skin')
|
||||
}
|
||||
|
||||
export async function flush_pending_skin_change(): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|flush_pending_skin_change')
|
||||
}
|
||||
|
||||
export async function flush_pending_skin_change_for_profile(profileId: string): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', {
|
||||
profileId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
|
||||
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
|
||||
return new Uint8Array(data)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
@@ -8,8 +8,10 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
export interface LoadingBarType {
|
||||
type?: string
|
||||
version?: string
|
||||
profile_path?: string
|
||||
instance_id?: string
|
||||
instance_name?: string
|
||||
pack_name?: string
|
||||
icon?: string | null
|
||||
}
|
||||
|
||||
export interface LoadingBar {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { RawRenderResult } from '../rendering/batch-skin-renderer'
|
||||
|
||||
interface StoredPreview {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
@@ -38,7 +37,6 @@ export class SkinPreviewStorage {
|
||||
|
||||
const storedPreview: StoredPreview = {
|
||||
forwards: result.forwards,
|
||||
backwards: result.backwards,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
@@ -67,7 +65,7 @@ export class SkinPreviewStorage {
|
||||
return
|
||||
}
|
||||
|
||||
resolve({ forwards: result.forwards, backwards: result.backwards })
|
||||
resolve({ forwards: result.forwards })
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
@@ -95,7 +93,7 @@ export class SkinPreviewStorage {
|
||||
const result = request.result as StoredPreview | undefined
|
||||
|
||||
if (result) {
|
||||
results[key] = { forwards: result.forwards, backwards: result.backwards }
|
||||
results[key] = { forwards: result.forwards }
|
||||
} else {
|
||||
results[key] = null
|
||||
}
|
||||
@@ -173,7 +171,7 @@ export class SkinPreviewStorage {
|
||||
const key = cursor.primaryKey as string
|
||||
const value = cursor.value as StoredPreview
|
||||
|
||||
const entrySize = value.forwards.size + value.backwards.size
|
||||
const entrySize = value.forwards.size
|
||||
totalSize += entrySize
|
||||
count++
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* So, for example, addDefaultInstance creates a blank instance object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
+55
-6
@@ -1,19 +1,23 @@
|
||||
import type { ModrinthId } from '@modrinth/utils'
|
||||
|
||||
export type GameInstance = {
|
||||
id: string
|
||||
path: string
|
||||
install_stage: InstallStage
|
||||
launcher_feature_version: string
|
||||
|
||||
name: string
|
||||
icon_path?: string
|
||||
|
||||
game_version: string
|
||||
protocol_version?: number
|
||||
loader: InstanceLoader
|
||||
loader_version?: string
|
||||
|
||||
groups: string[]
|
||||
|
||||
linked_data?: LinkedData
|
||||
link?: InstanceLink | null
|
||||
update_channel: ReleaseChannel
|
||||
|
||||
created: Date
|
||||
modified: Date
|
||||
@@ -39,16 +43,61 @@ type InstallStage =
|
||||
| 'pack_installing'
|
||||
| 'not_installed'
|
||||
|
||||
type LinkedData = {
|
||||
project_id: ModrinthId
|
||||
version_id: ModrinthId
|
||||
|
||||
locked: boolean
|
||||
type InstanceLinkIdentity = {
|
||||
project_id?: ModrinthId | null
|
||||
version_id?: ModrinthId | null
|
||||
server_project_id?: ModrinthId | null
|
||||
content_project_id?: ModrinthId | null
|
||||
content_version_id?: ModrinthId | null
|
||||
}
|
||||
|
||||
export type InstanceLink = InstanceLinkIdentity &
|
||||
(
|
||||
| {
|
||||
type: 'modrinth_modpack'
|
||||
project_id: ModrinthId
|
||||
version_id: ModrinthId
|
||||
}
|
||||
| {
|
||||
type: 'server_project'
|
||||
project_id: ModrinthId
|
||||
}
|
||||
| {
|
||||
type: 'server_project_modpack'
|
||||
server_project_id: ModrinthId
|
||||
content_project_id?: ModrinthId | null
|
||||
content_version_id: ModrinthId
|
||||
project_id?: ModrinthId
|
||||
version_id?: ModrinthId
|
||||
}
|
||||
| {
|
||||
type: 'imported_modpack'
|
||||
project_id?: ModrinthId | null
|
||||
version_id?: ModrinthId | null
|
||||
name?: string | null
|
||||
version_number?: string | null
|
||||
filename?: string | null
|
||||
}
|
||||
| {
|
||||
type: 'modrinth_hosting'
|
||||
server_id: string
|
||||
instance_ids: string[]
|
||||
active_instance_id?: string | null
|
||||
}
|
||||
| {
|
||||
type: 'shared_instance'
|
||||
shared_instance_id: string
|
||||
}
|
||||
)
|
||||
|
||||
export type Instance = GameInstance
|
||||
|
||||
type ReleaseChannel = 'release' | 'beta' | 'alpha'
|
||||
|
||||
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
|
||||
|
||||
type ContentFile = {
|
||||
enabled: boolean
|
||||
metadata?: {
|
||||
project_id: string
|
||||
version_id: string
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
const MIDAS_BITFLAG = 1 << 0
|
||||
const PRIDE_26_MIDAS_DURATION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
type Pride26Campaign = Labrinth.Users.v3.Pride26CampaignDonation | null | undefined
|
||||
|
||||
export function hasMidasBadge(user?: { badges?: number } | null) {
|
||||
return !!user?.badges && (user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG
|
||||
}
|
||||
|
||||
export function hasPride26Badge(campaign: Pride26Campaign) {
|
||||
return campaign?.has_badge === true
|
||||
}
|
||||
|
||||
export function hasActivePride26Midas(campaign: Pride26Campaign, now = Date.now()) {
|
||||
if (campaign?.has_midas !== true) {
|
||||
return false
|
||||
}
|
||||
|
||||
const donatedAt = new Date(campaign.last_donated_at).getTime()
|
||||
return Number.isFinite(donatedAt) && donatedAt + PRIDE_26_MIDAS_DURATION_MS > now
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
|
||||
import { get_full_path, get_mod_full_path } from '@/helpers/profile'
|
||||
import { get_full_path, get_mod_full_path } from '@/helpers/instance'
|
||||
|
||||
export async function isDev() {
|
||||
return await invoke('is_dev')
|
||||
@@ -22,6 +23,10 @@ export async function removeEnqueuedUpdate() {
|
||||
return await invoke('remove_enqueued_update')
|
||||
}
|
||||
|
||||
export async function setRestartAfterPendingUpdate(should_restart) {
|
||||
return await invoke('set_restart_after_pending_update', { shouldRestart: should_restart })
|
||||
}
|
||||
|
||||
// One of 'Windows', 'Linux', 'MacOS'
|
||||
export async function getOS() {
|
||||
return await invoke('plugin:utils|get_os')
|
||||
@@ -43,14 +48,34 @@ export async function showLauncherLogsFolder() {
|
||||
return await invoke('plugin:utils|show_launcher_logs_folder', {})
|
||||
}
|
||||
|
||||
// Opens a profile's folder in the OS file explorer
|
||||
export async function showProfileInFolder(path) {
|
||||
const fullPath = await get_full_path(path)
|
||||
export async function createInstanceShortcut(instanceName, instanceId, options = {}) {
|
||||
const outputPath = await save({
|
||||
defaultPath: `Modrinth - ${instanceName}`,
|
||||
})
|
||||
|
||||
if (!outputPath) return null
|
||||
|
||||
return await invoke('plugin:shortcuts|create_instance_shortcut', {
|
||||
instanceName,
|
||||
instanceId,
|
||||
outputPath,
|
||||
server: options.server,
|
||||
singleplayerWorld: options.singleplayerWorld,
|
||||
})
|
||||
}
|
||||
|
||||
export async function showAppDbBackupsFolder() {
|
||||
return await invoke('plugin:utils|show_app_db_backups_folder', {})
|
||||
}
|
||||
|
||||
// Opens an instance's folder in the OS file explorer
|
||||
export async function showInstanceInFolder(instanceId) {
|
||||
const fullPath = await get_full_path(instanceId)
|
||||
return await openPath(fullPath)
|
||||
}
|
||||
|
||||
export async function highlightModInProfile(profilePath, projectPath) {
|
||||
const fullPath = await get_mod_full_path(profilePath, projectPath)
|
||||
export async function highlightModInInstance(instanceId, projectPath) {
|
||||
const fullPath = await get_mod_full_path(instanceId, projectPath)
|
||||
return await highlightInFolder(fullPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { autoToHTML } from '@sfirew/minecraft-motd-parser'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { get_full_path } from '@/helpers/profile'
|
||||
import { get_full_path } from '@/helpers/instance'
|
||||
import { openPath } from '@/helpers/utils'
|
||||
|
||||
type BaseWorld = {
|
||||
@@ -36,8 +36,8 @@ export type ServerWorld = BaseWorld & {
|
||||
|
||||
export type World = SingleplayerWorld | ServerWorld
|
||||
|
||||
export type WorldWithProfile = {
|
||||
profile: string
|
||||
export type WorldWithInstance = {
|
||||
instance_id: string
|
||||
} & World
|
||||
|
||||
export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
|
||||
@@ -88,12 +88,12 @@ export type ProtocolVersion = {
|
||||
export async function get_recent_worlds(
|
||||
limit: number,
|
||||
displayStatuses?: DisplayStatus[],
|
||||
): Promise<WorldWithProfile[]> {
|
||||
): Promise<WorldWithInstance[]> {
|
||||
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
|
||||
}
|
||||
|
||||
export async function get_profile_worlds(path: string): Promise<World[]> {
|
||||
return await invoke('plugin:worlds|get_profile_worlds', { path })
|
||||
export async function get_instance_worlds(instanceId: string): Promise<World[]> {
|
||||
return await invoke('plugin:worlds|get_instance_worlds', { instanceId })
|
||||
}
|
||||
|
||||
export async function get_singleplayer_world(
|
||||
@@ -137,16 +137,16 @@ export async function delete_world(instance: string, world: string): Promise<voi
|
||||
return await invoke('plugin:worlds|delete_world', { instance, world })
|
||||
}
|
||||
|
||||
export async function add_server_to_profile(
|
||||
path: string,
|
||||
export async function add_server_to_instance(
|
||||
instanceId: string,
|
||||
name: string,
|
||||
address: string,
|
||||
packStatus: ServerPackStatus,
|
||||
projectId?: string,
|
||||
contentKind?: string,
|
||||
): Promise<number> {
|
||||
return await invoke('plugin:worlds|add_server_to_profile', {
|
||||
path,
|
||||
return await invoke('plugin:worlds|add_server_to_instance', {
|
||||
instanceId,
|
||||
name,
|
||||
address,
|
||||
packStatus,
|
||||
@@ -155,15 +155,15 @@ export async function add_server_to_profile(
|
||||
})
|
||||
}
|
||||
|
||||
export async function edit_server_in_profile(
|
||||
path: string,
|
||||
export async function edit_server_in_instance(
|
||||
instanceId: string,
|
||||
index: number,
|
||||
name: string,
|
||||
address: string,
|
||||
packStatus: ServerPackStatus,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:worlds|edit_server_in_profile', {
|
||||
path,
|
||||
return await invoke('plugin:worlds|edit_server_in_instance', {
|
||||
instanceId,
|
||||
index,
|
||||
name,
|
||||
address,
|
||||
@@ -171,12 +171,17 @@ export async function edit_server_in_profile(
|
||||
})
|
||||
}
|
||||
|
||||
export async function remove_server_from_profile(path: string, index: number): Promise<void> {
|
||||
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
||||
export async function remove_server_from_instance(
|
||||
instanceId: string,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:worlds|remove_server_from_instance', { instanceId, index })
|
||||
}
|
||||
|
||||
export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
|
||||
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
||||
export async function get_instance_protocol_version(
|
||||
instanceId: string,
|
||||
): Promise<ProtocolVersion | null> {
|
||||
return await invoke('plugin:worlds|get_instance_protocol_version', { instanceId })
|
||||
}
|
||||
|
||||
export async function get_server_status(
|
||||
@@ -186,16 +191,19 @@ export async function get_server_status(
|
||||
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
||||
}
|
||||
|
||||
export async function start_join_singleplayer_world(path: string, world: string): Promise<unknown> {
|
||||
return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
|
||||
export async function start_join_singleplayer_world(
|
||||
instanceId: string,
|
||||
world: string,
|
||||
): Promise<unknown> {
|
||||
return await invoke('plugin:worlds|start_join_singleplayer_world', { instanceId, world })
|
||||
}
|
||||
|
||||
export async function start_join_server(path: string, address: string): Promise<unknown> {
|
||||
return await invoke('plugin:worlds|start_join_server', { path, address })
|
||||
export async function start_join_server(instanceId: string, address: string): Promise<unknown> {
|
||||
return await invoke('plugin:worlds|start_join_server', { instanceId, address })
|
||||
}
|
||||
|
||||
export async function showWorldInFolder(instancePath: string, worldPath: string) {
|
||||
const fullPath = await get_full_path(instancePath)
|
||||
export async function showWorldInFolder(instanceId: string, worldPath: string) {
|
||||
const fullPath = await get_full_path(instanceId)
|
||||
return await openPath(fullPath + '/saves/' + worldPath)
|
||||
}
|
||||
|
||||
@@ -348,6 +356,28 @@ export function resolveManagedServerWorld(
|
||||
)
|
||||
}
|
||||
|
||||
export function getServerAddress(javaServer?: { address?: string | null } | null) {
|
||||
if (!javaServer) return null
|
||||
return javaServer.address ?? null
|
||||
}
|
||||
|
||||
export async function ensureManagedServerWorldExists(
|
||||
instanceId: string,
|
||||
serverName: string,
|
||||
serverAddress: string | null,
|
||||
) {
|
||||
if (!instanceId || !serverAddress) return
|
||||
try {
|
||||
const worlds = await get_instance_worlds(instanceId)
|
||||
const managedWorld = resolveManagedServerWorld(worlds, serverName, serverAddress)
|
||||
if (!managedWorld) {
|
||||
await add_server_to_instance(instanceId, serverName, serverAddress, 'prompt')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to ensure managed server world exists:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerLatency(
|
||||
address: string,
|
||||
protocolVersion: ProtocolVersion | null = null,
|
||||
@@ -420,9 +450,9 @@ export function refreshServers(
|
||||
)
|
||||
}
|
||||
|
||||
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
|
||||
export async function refreshWorld(worlds: World[], instanceId: string, worldPath: string) {
|
||||
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
|
||||
const newWorld = await get_singleplayer_world(instancePath, worldPath)
|
||||
const newWorld = await get_singleplayer_world(instanceId, worldPath)
|
||||
if (index !== -1) {
|
||||
worlds[index] = newWorld
|
||||
} else {
|
||||
@@ -432,13 +462,13 @@ export async function refreshWorld(worlds: World[], instancePath: string, worldP
|
||||
sortWorlds(worlds)
|
||||
}
|
||||
|
||||
export async function handleDefaultProfileUpdateEvent(
|
||||
export async function handleDefaultInstanceUpdateEvent(
|
||||
worlds: World[],
|
||||
instancePath: string,
|
||||
e: ProfileEvent,
|
||||
instanceId: string,
|
||||
e: InstanceEvent,
|
||||
) {
|
||||
if (e.event === 'world_updated') {
|
||||
await refreshWorld(worlds, instancePath, e.world)
|
||||
await refreshWorld(worlds, instanceId, e.world)
|
||||
}
|
||||
|
||||
if (e.event === 'server_joined') {
|
||||
@@ -456,9 +486,9 @@ export async function handleDefaultProfileUpdateEvent(
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshWorlds(instancePath: string): Promise<World[]> {
|
||||
const worlds = await get_profile_worlds(instancePath).catch((err) => {
|
||||
console.error(`Error refreshing worlds for instance: ${instancePath}`, err)
|
||||
export async function refreshWorlds(instanceId: string): Promise<World[]> {
|
||||
const worlds = await get_instance_worlds(instanceId).catch((err) => {
|
||||
console.error(`Error refreshing worlds for instance: ${instanceId}`, err)
|
||||
})
|
||||
if (worlds) {
|
||||
sortWorlds(worlds)
|
||||
@@ -489,7 +519,7 @@ export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVer
|
||||
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
|
||||
}
|
||||
|
||||
export type ProfileEvent = { profile_path_id: string } & (
|
||||
export type InstanceEvent = { instance_id: string } & (
|
||||
| {
|
||||
event: 'servers_updated'
|
||||
}
|
||||
|
||||
@@ -1,16 +1,85 @@
|
||||
{
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "جاز تنزيل إصدار جافا {version}"
|
||||
},
|
||||
"app.action-bar.downloading-update": {
|
||||
"message": "تحميل التحديث"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "التنزيلات"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "إخفاء المزيد من الحالات قيد التشغيل"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "إنشاء نسخة أساسية"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "لا توجد نسخ قيد التشغيل"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "غير متصل"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "النسخة ألرئيسية"
|
||||
},
|
||||
"app.action-bar.reload-to-update": {
|
||||
"message": "أعد التشغيل للتحديث"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "عرض المزيد من النسخ قيد التشغيل"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "إيقاف النسخة"
|
||||
},
|
||||
"app.action-bar.update": {
|
||||
"message": "تحديث"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "عرض التحميلات الفعالة"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "عرض النسخة"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "عرض التسجيل"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "يتيح العرض المتقدم لتأثيرات مثل التشويش الذي قد تسبب مشكلات في الأداء دون استخدام العرض المسرع بالأجهزة."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "العرض المتقدم"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "اختر لون سمتك المفضل من أجل تطبيق Modrinth App."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "لون السمة"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "غير الصفحة التي يفتح عليها المشغل."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "صفحة الرئيسية"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "مكتبة"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "الصفحة الرئيسة الافتراضية"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "ألغ تفعيل عرض الاسم فوق لاعبك في صفحة المظاهر."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "إخفاء عرض الاسم"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "قد تكون خوادم مصادقة ماينكرافت معطلة حاليًا. تحقق من اتصالك بالإنترنت وحاول مرة أخرى لاحقًا."
|
||||
},
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "تعذر الوصول إلى خوادم المصادقة"
|
||||
},
|
||||
"app.browse.add-server-to-instance": {
|
||||
"message": "أضف الخادم للنموذج"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "أضف الخوادم لنموذجك"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "أضف للنموذج"
|
||||
},
|
||||
@@ -23,14 +92,17 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "مضاف فعلا"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "العودة للنموذج"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "استكشف محتوى"
|
||||
},
|
||||
"app.browse.discover-servers": {
|
||||
"message": "استكشف خوادم"
|
||||
},
|
||||
"app.browse.install-content-to-instance": {
|
||||
"message": "تثبيت محتوى لنموذجك"
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "إخفاء الخوادم المضافة مسبقًا"
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "جاري التثبيت"
|
||||
@@ -51,7 +123,7 @@
|
||||
"message": "إسم حزمة التعديل"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "حدد الملفات والمجلدات المراد تضمينها في الحزمة"
|
||||
"message": "أختيار الملفات التي سيتم تصديرها"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "رقم الإصدار"
|
||||
@@ -72,13 +144,13 @@
|
||||
"message": "حذف النموذج"
|
||||
},
|
||||
"app.instance.modpack-already-installed.body": {
|
||||
"message": "حُزْمَة التعديل هذه مثبته فعلًا في نموذج <bold>{instanceName}</bold>. هل انت متأكد بإرادة نسخه؟"
|
||||
"message": "حُزْمَة التعديل مثبته مسبقًا فعلًا في نموذج <bold>{instanceName}</bold>. هل أنت متأكد بإرادة نسخه؟"
|
||||
},
|
||||
"app.instance.modpack-already-installed.create": {
|
||||
"message": "إنشاء"
|
||||
},
|
||||
"app.instance.modpack-already-installed.header": {
|
||||
"message": "حُزْمَة التعديل مثبتة بالفعل"
|
||||
"message": "حُزْمَة التعديل مثبتة فعلًا"
|
||||
},
|
||||
"app.instance.modpack-already-installed.instance": {
|
||||
"message": "النموذج"
|
||||
@@ -96,7 +168,7 @@
|
||||
"message": "تحقق من المشاريع التي أستخدمها في حزمة التعديل الخاص بي!"
|
||||
},
|
||||
"app.instance.mods.share-title": {
|
||||
"message": "مشاركة محتوى حزمة التعديل"
|
||||
"message": "مشاركة محتوى حُزْمَة التعديل"
|
||||
},
|
||||
"app.instance.mods.successfully-uploaded": {
|
||||
"message": "تم الرفع بنجاح"
|
||||
@@ -107,12 +179,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "تصفح الخوادم"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "سيتم حذف '{name}' **نهائيا**, و لن هناك أي طريقة لاسترداده."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "هل أنت متيقِّن من رغبتك بحذف هذا العالم نهائيا؟"
|
||||
},
|
||||
"app.instance.worlds.filter-modded": {
|
||||
"message": "معدّل"
|
||||
},
|
||||
@@ -131,21 +197,9 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "لم تتم إضافة أي من الخوادم أو العوالم"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "ستتم إزالة '{name}' من قائمتك, إضافة لما داخل اللعبة, و لن يكون هناك أي طريقة لاسترداده."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "ستتم إزالة '{name}' ({address}) من قائمتك, إضافة لما داخل اللعبة, و لن يكون هناك أي طريقة لاسترداده."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "هل أنت متيقِّن من رغبتك في إزالة {name}؟"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "ابحث بين ال{count} عوالم..."
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "هذا الخادم"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "المحتوى مطلوب"
|
||||
},
|
||||
@@ -159,7 +213,7 @@
|
||||
"message": "{count, plural, one {# تعديل} other {# تعديلات}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "حزمة التعديل مطلوبة"
|
||||
"message": "حُزْمَة التعديل مطلوبة"
|
||||
},
|
||||
"app.modal.install-to-play.server-requires-mods": {
|
||||
"message": "يتطلب هذا الخادم تعديلات للعب. انقر فوق \"تثبيت\" لإعداد الملفات المطلوبة من Modrinth، ثم قم بتشغيله مباشرة إلى الخادم."
|
||||
@@ -180,7 +234,13 @@
|
||||
"message": "يلزم التحديث"
|
||||
},
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "هناك تحديث مطلوب للعب بـ {name}. الرجاء التحديث إلى آخر اصدار لتشغيل اللعبة."
|
||||
"message": "هناك تحديث مطلوب للعب بـ {name}. الرجاء التحديث إلى أحدث اصدار لتشغيل اللعبة."
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "العودة إلى الاستكشاف"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "تثبيت المحتويات الى النموذج"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "تم تفعيل وضع المطوّر."
|
||||
@@ -206,8 +266,74 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "إدارة الموارد"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "تطبيق Modrinth v{version} جاهز للتثبيت! أعد التحميل للتحديث الآن، أو تلقائيًا عند إغلاق تطبيق Modrinth."
|
||||
"app.skins.add-button": {
|
||||
"message": "إضافة مظهر"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "أسحب ثم أفلت"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "تطبيق"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "حذف المظهر"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "سيتم حذف المظهر بشكل نهائي، ولايمكن إرجاعه."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "هل أنت متأكد من حذف المظهر هذا؟"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "تعديل المظهر"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "إضافة المظهر"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "إضافة المظهر"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "رداء"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "رداء"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "تعديل المظهر"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "عدل المظهر أولا للحفظ !"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "بدون رداء"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "لا شيء"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "حفظ المظهر"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "حفظ..."
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "أرفع المظهر أولا !"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "تعديل المظهر"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "أستعراض"
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "تمهل !"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "الرجاء تسجيل الدخول"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "اختيار المظهر"
|
||||
},
|
||||
"app.update-popup.body.download-complete": {
|
||||
"message": "انتهى تنزيل تطبيق Modrinth v{version}. أعد التحميل للتحديث الآن، أو تلقائيًا عند إغلاق تطبيق Modrinth."
|
||||
@@ -227,9 +353,6 @@
|
||||
"app.update-popup.download-complete": {
|
||||
"message": "اكتمل التنزيل"
|
||||
},
|
||||
"app.update-popup.reload": {
|
||||
"message": "إعادة تحميل"
|
||||
},
|
||||
"app.update-popup.title": {
|
||||
"message": "تحديث متاح"
|
||||
},
|
||||
@@ -239,17 +362,8 @@
|
||||
"app.update.complete-toast.title": {
|
||||
"message": "تم تثبيت الإصدار {version} بنجاح!"
|
||||
},
|
||||
"app.update.download-update": {
|
||||
"message": "تنزيل التحديث"
|
||||
},
|
||||
"app.update.downloading-update": {
|
||||
"message": "جار تنزيل التحديث ({percent}٪)"
|
||||
},
|
||||
"app.update.reload-to-update": {
|
||||
"message": "أعد التحميل لتثبيت التحديث"
|
||||
},
|
||||
"app.world.server-modal.placeholder-address": {
|
||||
"message": "مثال.مودرنث.جج"
|
||||
"message": "مثال (example.modrinth.gg)"
|
||||
},
|
||||
"app.world.server-modal.select-an-option": {
|
||||
"message": "حدد خيارا"
|
||||
@@ -500,6 +614,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "الذاكرة المخصَّصة"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "النافذة"
|
||||
},
|
||||
@@ -572,6 +689,18 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "العالم قيد الاستخدام"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "إضافة حساب"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "حساب ماينكرافت"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "اختيار الحساب"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "سجل دخولك لـ ماينكرافت"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "مقدَّم من النسخة"
|
||||
},
|
||||
@@ -595,5 +724,8 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "يتم توفير المحمّل من قبل الخادم"
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "لا تعرض هذا التحذير مجددا"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,132 @@
|
||||
{
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "Stahování Javy {version}"
|
||||
},
|
||||
"app.action-bar.downloading-update": {
|
||||
"message": "Stahování aktualizace"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Stahování"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Skrýt více spuštěných instancí"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Nastavit jako primární instanci"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Žádné spuštěné instance"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Primární instance"
|
||||
},
|
||||
"app.action-bar.reload-to-update": {
|
||||
"message": "Znovu načíst pro aktualizaci"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Zobrazit další spuštěné instance"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Zastavit instanci"
|
||||
},
|
||||
"app.action-bar.update": {
|
||||
"message": "Aktualizace"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Zobrazit probíhající stahování"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Zobrazit instanci"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Zobrazit logy"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Povolí pokročilé vykreslování, například efekty rozmazání, které mohou bez hardwarového zrychlení způsobovat problémy s výkonem."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Pokročilé vykreslování"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Vyberte si váš preferovaný motiv pro aplikaci Modrinth."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Barevný motiv"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Změňte stránku, která se otevře po spuštění launcheru."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Domů"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Knihovna"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Výchozí stránka po spuštění"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Zakáže zobrazení jmenovky nad vaší postavou na stránce skinů."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Skrýt jmenovku"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "Zahrne nedávné světy do sekce „Vrátit se do hry“ na domovské stránce."
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.title": {
|
||||
"message": "Rychlý návrat do světů"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Minimalizovat launcher při spuštění Minecraftu."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Minimalizovat launcher"
|
||||
},
|
||||
"app.appearance-settings.native-decorations.description": {
|
||||
"message": "Použít systémový rámeček okna (vyžaduje restart aplikace)."
|
||||
},
|
||||
"app.appearance-settings.native-decorations.title": {
|
||||
"message": "Systémové dekorace"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Vyberte možnost"
|
||||
},
|
||||
"app.appearance-settings.show-play-time.description": {
|
||||
"message": "Zobrazuje, kolik času jsi strávil hraním instance."
|
||||
},
|
||||
"app.appearance-settings.show-play-time.title": {
|
||||
"message": "Zobrazit odehraný čas"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Umožní přepínat zobrazení postranního panelu."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Přepnout postranní panel"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Pokud se pokusíte nainstalovat soubor Modrinth Pack (.mrpack), který není hostován na Modrinth, zajistíme, abyste si před instalací uvědomili související rizika."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Upozorni mě před instalací neznámých modpacků"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Autořizační servery Minecraftu aktuálně nejsou k dispozici. Zkontrolujte si své připojení k internetu a zkuste to znovu později."
|
||||
"message": "Ověřovací Minecraft servery mohou být právě teď nedostupné. Zkontroluj si své připojení k internetu a zkus to znovu později."
|
||||
},
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Připojení k autorizačním serverům se nezdařilo"
|
||||
},
|
||||
"app.browse.add-server-to-instance": {
|
||||
"message": "Přidat server do instance"
|
||||
"message": "Nelze se připojit k ověřovacím serverům"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Přidat servery do instance"
|
||||
"message": "Přidání serveru do instance"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Přidat do instance"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Přidat do instalace"
|
||||
"message": "Přidat do instance"
|
||||
},
|
||||
"app.browse.add-to-instance-name": {
|
||||
"message": "Přidat do {instanceName}"
|
||||
@@ -21,19 +135,34 @@
|
||||
"message": "Přidáno"
|
||||
},
|
||||
"app.browse.already-added": {
|
||||
"message": "Už přidáno"
|
||||
"message": "Již přidáno"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Zpět k instanci"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Prozkoumat obsah"
|
||||
"message": "Objevovat obsah"
|
||||
},
|
||||
"app.browse.discover-servers": {
|
||||
"message": "Prozkoumat servery"
|
||||
"message": "Objevovat servery"
|
||||
},
|
||||
"app.browse.install-content-to-instance": {
|
||||
"message": "Nainstalovat obsah do instnce"
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Skrýt již přidané servery"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacky"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Přidání obsahu může narušit kompatibilitu při připojení k serveru. Veškerý přidaný obsah se navíc ztratí při aktualizaci obsahu instance serveru."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Instalování"
|
||||
},
|
||||
"app.content-install.no-compatible-versions": {
|
||||
"message": "Žádná dostupná verze se neshoduje s verzí {compatibilityLabel}. Vyber verzi, abys ji i přesto nainstaloval/a. Závislosti nebudou automaticky nainstalovány."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Přidej popis modpacku..."
|
||||
"message": "Zadej popis modpacku..."
|
||||
},
|
||||
"app.export-modal.export-button": {
|
||||
"message": "Exportovat"
|
||||
@@ -41,14 +170,17 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Exportovat modpack"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Zahrnout \"{file}\"?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Jméno Modpacku"
|
||||
"message": "Název modpacku"
|
||||
},
|
||||
"app.export-modal.modpack-name-placeholder": {
|
||||
"message": "Jméno modpacku"
|
||||
"message": "Název modpacku"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Vybrat soubory a složky co zahrnout do modpacku"
|
||||
"message": "Nastavte, které soubory mají být do tohoto exportu zahrnuty"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Číslo verze"
|
||||
@@ -57,7 +189,7 @@
|
||||
"message": "1.0.0"
|
||||
},
|
||||
"app.instance.confirm-delete.admonition-body": {
|
||||
"message": "Všechna data z instance budou navždy smazána, včetně světů, nastavení a všeho ostatního."
|
||||
"message": "Všechna data tvé instance budou trvale smazána, včetně světů, konfigurací a veškerého nainstalovaného obsahu."
|
||||
},
|
||||
"app.instance.confirm-delete.admonition-header": {
|
||||
"message": "Tato akce nemůže být vrácena"
|
||||
@@ -66,19 +198,19 @@
|
||||
"message": "Odstranit instanci"
|
||||
},
|
||||
"app.instance.confirm-delete.header": {
|
||||
"message": "Odstanit instanci"
|
||||
"message": "Odstranění instance"
|
||||
},
|
||||
"app.instance.modpack-already-installed.body": {
|
||||
"message": "Tento modpack je už nainstalovaný v instanci <bold>{instanceName}</bold>. Opravdu ho chceš duplikovat?"
|
||||
"message": "Tento modpack je již nainstalovaný v instanci <bold>{instanceName}</bold>. Opravdu ho chceš duplikovat?"
|
||||
},
|
||||
"app.instance.modpack-already-installed.create": {
|
||||
"message": "Vytvořit"
|
||||
},
|
||||
"app.instance.modpack-already-installed.header": {
|
||||
"message": "Modpack už je nainstalovaný"
|
||||
"message": "Modpack je již nainstalován"
|
||||
},
|
||||
"app.instance.modpack-already-installed.instance": {
|
||||
"message": "Instalace"
|
||||
"message": "Instance"
|
||||
},
|
||||
"app.instance.mods.content-type-project": {
|
||||
"message": "projekt"
|
||||
@@ -90,7 +222,7 @@
|
||||
"message": "{count} projektů bylo přidáno"
|
||||
},
|
||||
"app.instance.mods.share-text": {
|
||||
"message": "Podívejte se na projekty co používám ve svém modpacku!"
|
||||
"message": "Podívejte se na projekty, které používám ve svém modpacku!"
|
||||
},
|
||||
"app.instance.mods.share-title": {
|
||||
"message": "Sdílení obsahu modpacku"
|
||||
@@ -104,14 +236,8 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Procházet servery"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "\"{name}\" bude **navždy smazáno** a nebude způsob, jak to obnovit."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Opravdu si jsi jistý, že chceš navždy smazat tenhle svět?"
|
||||
},
|
||||
"app.instance.worlds.filter-modded": {
|
||||
"message": "Módováno"
|
||||
"message": "Módovaný"
|
||||
},
|
||||
"app.instance.worlds.filter-offline": {
|
||||
"message": "Offline"
|
||||
@@ -123,31 +249,31 @@
|
||||
"message": "Vanilla"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-description": {
|
||||
"message": "Přidej nebo procházej servery"
|
||||
"message": "Přidej server nebo začni procházet"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Žádné servery nebo světy nebyly přidány"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "„{name}“ bude odstraněn z tvého seznamu, včetně hry, a nebude žádný způsob, jak ho obnovit."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Opravdu chceš odstranit {name}?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Hledat ve světech {count}..."
|
||||
"message": "Hledat v {count} světech..."
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "tento server"
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Požadovaný obsah"
|
||||
},
|
||||
"app.modal.install-to-play.header": {
|
||||
"message": "Nainstaluj ke hraní"
|
||||
"message": "Nainstaluj pro hraní"
|
||||
},
|
||||
"app.modal.install-to-play.install-button": {
|
||||
"message": "Instalovat"
|
||||
},
|
||||
"app.modal.install-to-play.mod-count": {
|
||||
"message": "{count, plural,one {#mód} other {#módy}}"
|
||||
"message": "{count, plural, one {# mód} few {# módy} other {# módů}}"
|
||||
},
|
||||
"app.modal.install-to-play.required-modpack": {
|
||||
"message": "Požadovaný modpack"
|
||||
},
|
||||
"app.modal.install-to-play.server-requires-mods": {
|
||||
"message": "Tento server k hraní vyžaduje módy. Klikni na instalovat pro získání potřebných módů z Modrinth a poté se rovnou připoj na server."
|
||||
},
|
||||
"app.modal.install-to-play.shared-instance": {
|
||||
"message": "Sdílená instance"
|
||||
@@ -155,14 +281,26 @@
|
||||
"app.modal.install-to-play.shared-server-instance": {
|
||||
"message": "Sdílená serverová instance"
|
||||
},
|
||||
"app.modal.install-to-play.view-contents": {
|
||||
"message": "Zobrazit obsah"
|
||||
},
|
||||
"app.modal.update-to-play.header": {
|
||||
"message": "Updatuj ke hraní"
|
||||
"message": "Aktualizuj pro hraní"
|
||||
},
|
||||
"app.modal.update-to-play.update-required": {
|
||||
"message": "Je vyžadována aktualizace"
|
||||
},
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "Je nutná aktualizace ke hraní {name}. Prosím updatuj na nejnovější verzi k spuštění hry."
|
||||
"message": "Pro hraní {name} je vyžadována aktualizace. Prosím aktualizuj na nejnovější verzi, abys mohl hru spustit."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Tento projekt je již nainstalován"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Zpět k Objevování"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Nainstalovat obsah do instance"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Vývojářský režim povolen."
|
||||
@@ -188,47 +326,224 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Správa zdrojů"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Přidat skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Přetáhni a pusť"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Použít"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Smazat skin"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Tahle akce navždy smaže vybraný skin a nejde vrátit zpět."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Opravdu chceš smazat tenhle skin?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Nepodařilo se načíst přetažený soubor."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Chyba při načítání souboru"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Upravit skin"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Přidat skin"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Přidání skinu"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Typ paží"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Štíhlý"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Široký"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Plášť"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Plášť"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Úprava skinu"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Nejdříve uprav skin!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Bez pláště"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Žádný"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Nahradit texturu"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Uložit skin"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Ukládání..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Textura"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Nejdříve nahraj skin!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Upravit skin"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Náhled"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Měníš svůj skin příliš často. Servery Mojangu dočasně zablokovaly další požadavky. Počkej chvíli, než to zkusíš znovu."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Zpomal!"
|
||||
},
|
||||
"app.skins.reorder-error.text": {
|
||||
"message": "Tvé pořadí skinů se nepodařilo uložit."
|
||||
},
|
||||
"app.skins.reorder-error.title": {
|
||||
"message": "Nepodařilo se změnit pořadí skinů"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Builders & Biomes"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Chase the Skies"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Výchozí skiny"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Tyto skiny jsi obdržel za přispění do sbírky Modrinth Pride behěm Pride měsíce."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Uložené skiny"
|
||||
},
|
||||
"app.skins.section.striding-hero": {
|
||||
"message": "Striding Hero"
|
||||
},
|
||||
"app.skins.section.the-copper-age": {
|
||||
"message": "The Copper Age"
|
||||
},
|
||||
"app.skins.section.the-garden-awakens": {
|
||||
"message": "The Garden Awakens"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "Tiny Takeover"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Přihlásit se"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Přihlas se prosím ke svému Minecraft účtu, abys mohl/a využívat funkce pro správu skinů v aplikaci Modrinth."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Nadšený Modrinth Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Přihlas se prosím"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Výběr skinu"
|
||||
},
|
||||
"app.update-popup.body.download-complete": {
|
||||
"message": "Stahování aplikace Modrinth v{version} bylo dokončeno. Aktualizujte ji znovunačtením nebo se automaticky aktualizuje po zavření."
|
||||
},
|
||||
"app.update-popup.body.linux": {
|
||||
"message": "Aplikace Modrinth v{version} je k dispozici. Aktualizujte ji pomocí svého správce balíčků, abyste získali nejnovější funkce a opravy!"
|
||||
},
|
||||
"app.update-popup.body.metered": {
|
||||
"message": "Aplikace Modrinth v{version} je nyní k dispozici! Protože jste v měřené síti, nebyla stažena automaticky."
|
||||
},
|
||||
"app.update-popup.changelog": {
|
||||
"message": "Seznam změn"
|
||||
},
|
||||
"app.update-popup.download": {
|
||||
"message": "Stáhnout ({size})"
|
||||
},
|
||||
"app.update-popup.download-complete": {
|
||||
"message": "Stahování bylo dokončeno"
|
||||
},
|
||||
"app.update-popup.reload": {
|
||||
"message": "Znovu načíst pro aktualizaci"
|
||||
},
|
||||
"app.update-popup.title": {
|
||||
"message": "Aktualizace k dispozici"
|
||||
},
|
||||
"app.update.complete-toast.text": {
|
||||
"message": "Kliknutím sem zobrazíte seznam změn."
|
||||
},
|
||||
"app.update.complete-toast.title": {
|
||||
"message": "Verze {version} byla úspěšně nainstalována!"
|
||||
},
|
||||
"app.update.download-update": {
|
||||
"message": "Stáhnout aktualizaci"
|
||||
"app.world.server-modal.placeholder-address": {
|
||||
"message": "priklad.modrinth.gg"
|
||||
},
|
||||
"app.update.downloading-update": {
|
||||
"message": "Stahování aktualizace ({percent}%)"
|
||||
"app.world.server-modal.select-an-option": {
|
||||
"message": "Vybrat možnost"
|
||||
},
|
||||
"app.update.reload-to-update": {
|
||||
"message": "Restartovat aplikaci pro nainstalování aktualizace"
|
||||
"app.world.world-item.incompatible-version": {
|
||||
"message": "Nekompatibilní verze {version}"
|
||||
},
|
||||
"app.world.world-item.not-played-yet": {
|
||||
"message": "Zatím nehráno"
|
||||
},
|
||||
"app.world.world-item.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.world.world-item.players-online": {
|
||||
"message": "{count} online"
|
||||
},
|
||||
"friends.action.add-friend": {
|
||||
"message": "Přidat přítele"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} přátelé {count, plural, one {request} other {requests}}"
|
||||
"message": "{count} {count, plural, one {žádost} few {žádosti} other {žádostí}} o přátelství"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Poslat žádost o přátelství"
|
||||
},
|
||||
"friends.add-friend.title": {
|
||||
"message": "Přidávání kamaráda"
|
||||
"message": "Přidání přítele"
|
||||
},
|
||||
"friends.add-friend.username.description": {
|
||||
"message": "Může být jiné než jejich Minecraft jméno!"
|
||||
"message": "Může se lišit od jejich Minecraft přezdívky!"
|
||||
},
|
||||
"friends.add-friend.username.placeholder": {
|
||||
"message": "Zadejte uživatelské jméno Modrinth..."
|
||||
"message": "Zadej přezdívku na Modrinthu..."
|
||||
},
|
||||
"friends.add-friend.username.title": {
|
||||
"message": "Jak se váš kamarád jmenuje na Modrinthu?"
|
||||
"message": "Jaká je přezdívka tvého přítele na Modrinthu?"
|
||||
},
|
||||
"friends.add-friends-to-share": {
|
||||
"message": "<link>Přidejte si přátelé</link> a podívejte se, co hrají!"
|
||||
"message": "<link>Přidej si přátele</link>, abys viděl, co hrají!"
|
||||
},
|
||||
"friends.friend.cancel-request": {
|
||||
"message": "Zrušit žádost"
|
||||
@@ -243,7 +558,7 @@
|
||||
"message": "Zobrazit profil"
|
||||
},
|
||||
"friends.heading": {
|
||||
"message": "Kamarádi"
|
||||
"message": "Přátelé"
|
||||
},
|
||||
"friends.heading.active": {
|
||||
"message": "Aktivní"
|
||||
@@ -258,7 +573,7 @@
|
||||
"message": "Čeká na vyřízení"
|
||||
},
|
||||
"friends.no-friends-match": {
|
||||
"message": "Žádní přátelé neodpovídají '{query}''"
|
||||
"message": "Žádní přátelé neodpovídají ''{query}''"
|
||||
},
|
||||
"friends.search-friends-placeholder": {
|
||||
"message": "Hledat přátele..."
|
||||
@@ -267,7 +582,7 @@
|
||||
"message": "{title} - {count}"
|
||||
},
|
||||
"friends.sign-in-to-add-friends": {
|
||||
"message": "<link>Přihlašte se ke svému Modrinth účtu</link>, abyste jsi přidali přátele a uviděli co hrají!"
|
||||
"message": "<link>Přihlas se k Modrinth účtu</link>, abys mohl přidávat přátele a vidět, co hrají!"
|
||||
},
|
||||
"instance.add-server.add-and-play": {
|
||||
"message": "Přidat a hrát"
|
||||
@@ -279,10 +594,10 @@
|
||||
"message": "Vypnuto"
|
||||
},
|
||||
"instance.add-server.resource-pack.enabled": {
|
||||
"message": "Zapnuto"
|
||||
"message": "Povolený"
|
||||
},
|
||||
"instance.add-server.resource-pack.prompt": {
|
||||
"message": "Ptát se"
|
||||
"message": "Zeptat se"
|
||||
},
|
||||
"instance.add-server.title": {
|
||||
"message": "Přidat server"
|
||||
@@ -291,7 +606,7 @@
|
||||
"message": "Upravit server"
|
||||
},
|
||||
"instance.edit-world.hide-from-home": {
|
||||
"message": "Skrýt z domovské obrazovky"
|
||||
"message": "Skrýt z domovské stránky"
|
||||
},
|
||||
"instance.edit-world.name": {
|
||||
"message": "Název"
|
||||
@@ -300,11 +615,17 @@
|
||||
"message": "Minecraft svět"
|
||||
},
|
||||
"instance.edit-world.reset-icon": {
|
||||
"message": "Resetovat ikonu"
|
||||
"message": "Obnovit ikonu"
|
||||
},
|
||||
"instance.edit-world.title": {
|
||||
"message": "Upravit svět"
|
||||
},
|
||||
"instance.files.adding-files": {
|
||||
"message": "Přidávání souborů ({completed}/{total})"
|
||||
},
|
||||
"instance.files.save-as": {
|
||||
"message": "Uložit jako..."
|
||||
},
|
||||
"instance.server-modal.address": {
|
||||
"message": "Adresa"
|
||||
},
|
||||
@@ -312,22 +633,22 @@
|
||||
"message": "Název"
|
||||
},
|
||||
"instance.server-modal.placeholder-name": {
|
||||
"message": "Minecraft Server"
|
||||
"message": "Minecraft server"
|
||||
},
|
||||
"instance.server-modal.resource-pack": {
|
||||
"message": "Balíčky textur"
|
||||
"message": "Balíček textur"
|
||||
},
|
||||
"instance.settings.tabs.general": {
|
||||
"message": "Obecné"
|
||||
},
|
||||
"instance.settings.tabs.general.delete": {
|
||||
"message": "Odstranit instalaci"
|
||||
"message": "Odstranění instance"
|
||||
},
|
||||
"instance.settings.tabs.general.delete.button": {
|
||||
"message": "Odstranit instalaci"
|
||||
"message": "Odstranit instanci"
|
||||
},
|
||||
"instance.settings.tabs.general.delete.description": {
|
||||
"message": "Trvale smaže instalaci z vašeho zařízení, včetně vašich světů, konfigurací a veškerého nainstalovaného obsahu. Buďte opatrní, protože jakmile instalaci smažete, nebude možné ji obnovit."
|
||||
"message": "Trvale smaže instanci z tvého zařízení, včetně světů, konfigurací a veškerého nainstalovaného obsahu. Buď opatrný, jakmile instanci smažeš, nepůjde ji obnovit."
|
||||
},
|
||||
"instance.settings.tabs.general.deleting.button": {
|
||||
"message": "Mazání..."
|
||||
@@ -336,40 +657,64 @@
|
||||
"message": "Duplikovat"
|
||||
},
|
||||
"instance.settings.tabs.general.duplicate-button.tooltip.installing": {
|
||||
"message": "Nelze duplikovat při instalaci."
|
||||
"message": "Nelze duplikovat během instalace."
|
||||
},
|
||||
"instance.settings.tabs.general.duplicate-instance": {
|
||||
"message": "Duplikovat instalaci"
|
||||
"message": "Duplikace instance"
|
||||
},
|
||||
"instance.settings.tabs.general.duplicate-instance.description": {
|
||||
"message": "Vytvoří kopii této instalace, včetně světů, konfigurací, modů atd."
|
||||
"message": "Vytvoří kopii této instance, včetně světů, konfigurací, modů, atd."
|
||||
},
|
||||
"instance.settings.tabs.general.edit-icon": {
|
||||
"message": "Upravit ikonu"
|
||||
},
|
||||
"instance.settings.tabs.general.edit-icon.remove": {
|
||||
"message": "Odstranit ikonu"
|
||||
"message": "Odebrat ikonu"
|
||||
},
|
||||
"instance.settings.tabs.general.edit-icon.replace": {
|
||||
"message": "Změnit ikonu"
|
||||
"message": "Nahradit ikonu"
|
||||
},
|
||||
"instance.settings.tabs.general.edit-icon.select": {
|
||||
"message": "Vybrat ikonu"
|
||||
},
|
||||
"instance.settings.tabs.general.library-groups": {
|
||||
"message": "Skupina knihoven"
|
||||
"message": "Skupiny knihovny"
|
||||
},
|
||||
"instance.settings.tabs.general.library-groups.create": {
|
||||
"message": "Vytvořit novou skupinu"
|
||||
},
|
||||
"instance.settings.tabs.general.library-groups.description": {
|
||||
"message": "Skupiny knihoven vám umožňují uspořádat instalace do různých sekcí."
|
||||
"message": "Skupiny knihovny ti umožňují uspořádat instance do různých sekcí v knihovně."
|
||||
},
|
||||
"instance.settings.tabs.general.library-groups.enter-name": {
|
||||
"message": "Zadejte název skupiny"
|
||||
"message": "Zadej název skupiny"
|
||||
},
|
||||
"instance.settings.tabs.general.name": {
|
||||
"message": "Název"
|
||||
"message": "Jméno"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel": {
|
||||
"message": "Aktualizační kanál"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.alpha": {
|
||||
"message": "Alfa"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.alpha.description": {
|
||||
"message": "Vydané, beta a alfa verze se zobrazí jako dostupné aktualizace."
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.beta": {
|
||||
"message": "Beta"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.beta.description": {
|
||||
"message": "Vydané a beta verze se zobrazí jako dostupné aktualizace."
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.release": {
|
||||
"message": "Vydání"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.release.description": {
|
||||
"message": "Pouze vydané verze se zobrazí jako dostupné aktualizace."
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Vyber aktualizační kanál"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Spouštěcí hooky"
|
||||
@@ -378,28 +723,28 @@
|
||||
"message": "Vlastní spouštěcí hooky"
|
||||
},
|
||||
"instance.settings.tabs.hooks.description": {
|
||||
"message": "Hooky umožňují pokročilým uživatelům spouštět určité systémové příkazy před a po spuštění hry."
|
||||
"message": "Hooky umožňují pokročilým uživatelům spustit určité systémové příkazy před a po spuštění hry."
|
||||
},
|
||||
"instance.settings.tabs.hooks.post-exit": {
|
||||
"message": "Po ukončení"
|
||||
},
|
||||
"instance.settings.tabs.hooks.post-exit.description": {
|
||||
"message": "Spušteno po vypnutí hry."
|
||||
"message": "Provede se po ukončení hry."
|
||||
},
|
||||
"instance.settings.tabs.hooks.post-exit.enter": {
|
||||
"message": "Zadejte příkaz k provedení po ukončení..."
|
||||
"message": "Zadej příkaz k provedení po ukončení..."
|
||||
},
|
||||
"instance.settings.tabs.hooks.pre-launch": {
|
||||
"message": "Před spuštěním"
|
||||
},
|
||||
"instance.settings.tabs.hooks.pre-launch.description": {
|
||||
"message": "Spouštějí se před spuštěním instalace."
|
||||
"message": "Provede se před spuštěním instance."
|
||||
},
|
||||
"instance.settings.tabs.hooks.pre-launch.enter": {
|
||||
"message": "Zadejte příkaz k provedení před spuštěním..."
|
||||
"message": "Zadej příkaz k provedení před spuštěním..."
|
||||
},
|
||||
"instance.settings.tabs.hooks.title": {
|
||||
"message": "Spouštěcí hooky hry"
|
||||
"message": "Spouštěcí příkazy hry"
|
||||
},
|
||||
"instance.settings.tabs.hooks.wrapper": {
|
||||
"message": "Wrapper"
|
||||
@@ -408,7 +753,7 @@
|
||||
"message": "Příkaz wrapperu pro spuštění Minecraftu."
|
||||
},
|
||||
"instance.settings.tabs.hooks.wrapper.enter": {
|
||||
"message": "Zadejte příkaz wrapperu..."
|
||||
"message": "Zadej příkaz wrapperu..."
|
||||
},
|
||||
"instance.settings.tabs.installation": {
|
||||
"message": "Instalace"
|
||||
@@ -419,6 +764,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java a pamět"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Vlastní proměnná prostředí"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Vlastní argumenty Javy"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Vlastní instalace Javy"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Vlastní nastavení paměti"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Zadej proměnná prostředí..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Zadej argumenty Javy..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Proměnné prostředí"
|
||||
},
|
||||
@@ -426,13 +789,16 @@
|
||||
"message": "Hooky"
|
||||
},
|
||||
"instance.settings.tabs.java.java-arguments": {
|
||||
"message": "Java argumenty"
|
||||
"message": "Argumenty Javy"
|
||||
},
|
||||
"instance.settings.tabs.java.java-installation": {
|
||||
"message": "Instalace Javy"
|
||||
},
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Paměti přiřazeno"
|
||||
"message": "Přiřazeno paměti"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/path/to/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Okno"
|
||||
@@ -441,10 +807,10 @@
|
||||
"message": "Vlastní nastavení okna"
|
||||
},
|
||||
"instance.settings.tabs.window.fullscreen": {
|
||||
"message": "Celá obrazovka"
|
||||
"message": "Režim celé obrazovky"
|
||||
},
|
||||
"instance.settings.tabs.window.fullscreen.description": {
|
||||
"message": "Spustit hru v plné obrazovce pokud spuštěno (použitím options.txt)."
|
||||
"message": "Při spuštění otevřít hru v režimu celé obrazovky (použitím options.txt)."
|
||||
},
|
||||
"instance.settings.tabs.window.height": {
|
||||
"message": "Výška"
|
||||
@@ -453,7 +819,7 @@
|
||||
"message": "Výška herního okna při spuštění."
|
||||
},
|
||||
"instance.settings.tabs.window.height.enter": {
|
||||
"message": "Zadejte výšku..."
|
||||
"message": "Zadej výšku..."
|
||||
},
|
||||
"instance.settings.tabs.window.width": {
|
||||
"message": "Šířka"
|
||||
@@ -462,19 +828,19 @@
|
||||
"message": "Šířka herního okna při spuštění."
|
||||
},
|
||||
"instance.settings.tabs.window.width.enter": {
|
||||
"message": "Zadejte šířku..."
|
||||
"message": "Zadej šířku..."
|
||||
},
|
||||
"instance.worlds.a_minecraft_server": {
|
||||
"message": "Minecraft Server"
|
||||
"message": "Minecraft server"
|
||||
},
|
||||
"instance.worlds.cant_connect": {
|
||||
"message": "Nelze připojit k serveru"
|
||||
"message": "Nelze se připojit k serveru"
|
||||
},
|
||||
"instance.worlds.copy_address": {
|
||||
"message": "Kopírovat adresu"
|
||||
},
|
||||
"instance.worlds.dont_show_on_home": {
|
||||
"message": "Nezobrazuje na domovské stránce"
|
||||
"message": "Nezobrazovat na domovské stránce"
|
||||
},
|
||||
"instance.worlds.game_already_open": {
|
||||
"message": "Instance je již otevřená"
|
||||
@@ -485,14 +851,17 @@
|
||||
"instance.worlds.incompatible_server": {
|
||||
"message": "Server je nekompatibilní"
|
||||
},
|
||||
"instance.worlds.linked_server": {
|
||||
"message": "Spravováno projektem serveru"
|
||||
},
|
||||
"instance.worlds.no_contact": {
|
||||
"message": "Server se nepodařilo spojit"
|
||||
"message": "Se serverem se nepodařilo spojit"
|
||||
},
|
||||
"instance.worlds.no_server_quick_play": {
|
||||
"message": "Můžeš rovnou skočit na server pouze v Minecraftu Alpha 1.0.5+"
|
||||
"message": "Připojit se rovnou k serverům můžeš pouze s Minecraft verzí Alpha 1.0.5+"
|
||||
},
|
||||
"instance.worlds.no_singleplayer_quick_play": {
|
||||
"message": "V Minecraftu 1.20+ se dá rovnou přeskočit pouze do singleplayerových světů"
|
||||
"message": "Můžeš se rovnou připojit do světů pro jednoho hráče pouze s Minecraft verzí 1.20+"
|
||||
},
|
||||
"instance.worlds.play_instance": {
|
||||
"message": "Hrát instanci"
|
||||
@@ -501,27 +870,69 @@
|
||||
"message": "Zobrazit instanci"
|
||||
},
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "Svět je použit"
|
||||
"message": "Svět je používán"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Přidat účet"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Minecraft účet"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Nejste přihlášeni"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Odebrat účet"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Zvolte účet"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Přihlásit se do Minecraftu"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Poskytováno instancí"
|
||||
},
|
||||
"search.filter.locked.instance-game-version.title": {
|
||||
"message": "Verze hry je poskytnut instanci"
|
||||
"message": "Verze hry je poskytována instancí"
|
||||
},
|
||||
"search.filter.locked.instance-loader.title": {
|
||||
"message": "Spouštěč je poskytnut instancí"
|
||||
"message": "Načítací modul je poskytován instancí"
|
||||
},
|
||||
"search.filter.locked.instance.sync": {
|
||||
"message": "Synchronizováno z instancí"
|
||||
"message": "Synchronizováno s instancí"
|
||||
},
|
||||
"search.filter.locked.server": {
|
||||
"message": "Poskytováno serverem"
|
||||
},
|
||||
"search.filter.locked.server-environment.title": {
|
||||
"message": "Jen klientské mody mohou být přidány na server"
|
||||
"message": "Pouze módy ze strany klientu mohou být přidány na server"
|
||||
},
|
||||
"search.filter.locked.server-game-version.title": {
|
||||
"message": "Verzi hry poskytuje server"
|
||||
"message": "Verze hry je poskytována serverem"
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Loader zprostředkovává server"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "Soubor je zkontrolován pouze v případě, že je nahrán na Modrinth, bez ohledu na jeho formát (včetně formátu .mrpack)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Toto varování znovu nezobrazovat"
|
||||
},
|
||||
"unknown-pack-warning-modal.header": {
|
||||
"message": "Potvrdit instalaci"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Přesto nainstalovat"
|
||||
},
|
||||
"unknown-pack-warning-modal.malware-statement": {
|
||||
"message": "Malware se často šíří prostřednictvím souborů s modpacky, které se sdílejí na platformách jako Discord."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.body": {
|
||||
"message": "Tento soubor jsme na Modrinthu nenašli. Důrazně doporučujeme instalovat pouze soubory z důvěryhodných zdrojů."
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.title": {
|
||||
"message": "Upozornění na neznámý soubor"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,127 @@
|
||||
{
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "Downloader Java {version}"
|
||||
},
|
||||
"app.action-bar.downloading-update": {
|
||||
"message": "Downloader opdatering"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Downloads"
|
||||
},
|
||||
"app.action-bar.hide-more-running-instances": {
|
||||
"message": "Gem flere kørende instances"
|
||||
},
|
||||
"app.action-bar.make-primary-instance": {
|
||||
"message": "Gør til primære instance"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Ingen instances kørende"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Primære instance"
|
||||
},
|
||||
"app.action-bar.reload-to-update": {
|
||||
"message": "Genindlæs for at opdater"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Vis flere kørende instances"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Stop instance"
|
||||
},
|
||||
"app.action-bar.update": {
|
||||
"message": "Opdater"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Vis aktive downloads"
|
||||
},
|
||||
"app.action-bar.view-instance": {
|
||||
"message": "Vis instance"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Vis logs"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Aktiverer avanceret gengivelse som sløringseffekter, der kan forårsage ydelsesproblemer uden hardwareaccelereret gengivelse."
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.title": {
|
||||
"message": "Avanceret rendering"
|
||||
},
|
||||
"app.appearance-settings.color-theme.description": {
|
||||
"message": "Vælg dit foretrukne farvetema til Modrinth App."
|
||||
},
|
||||
"app.appearance-settings.color-theme.title": {
|
||||
"message": "Farvetema"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.description": {
|
||||
"message": "Skift hvilken side launcheren åbner i."
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.home": {
|
||||
"message": "Hjem"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.library": {
|
||||
"message": "Bibliotek"
|
||||
},
|
||||
"app.appearance-settings.default-landing-page.title": {
|
||||
"message": "Standard startside"
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.description": {
|
||||
"message": "Slår nametagget over din spiller på skins siden fra."
|
||||
},
|
||||
"app.appearance-settings.hide-nametag.title": {
|
||||
"message": "Gem nametag"
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.description": {
|
||||
"message": "Inkluderer de sidste verdener i afsnittet \"Hop tilbage\" på startsiden."
|
||||
},
|
||||
"app.appearance-settings.jump-back-into-worlds.title": {
|
||||
"message": "Hop tilbage ind i verdener"
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.description": {
|
||||
"message": "Minimer launcheren når Minecraft starter."
|
||||
},
|
||||
"app.appearance-settings.minimize-launcher.title": {
|
||||
"message": "Minimer launcher"
|
||||
},
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Vælg en mulighed"
|
||||
},
|
||||
"app.appearance-settings.show-play-time.description": {
|
||||
"message": "Vis hvor meget tid du har brugt på at spille en instance."
|
||||
},
|
||||
"app.appearance-settings.show-play-time.title": {
|
||||
"message": "Vis tid spillet"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Aktivere muligheden for at slå sidepanelet til og fra."
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.title": {
|
||||
"message": "Slå sidepanel til og fra"
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.description": {
|
||||
"message": "Hvis du prøver at installere en Modrinth Pack fil (.mrpack) som ikke er hosted på Modrinth, så skal vi nok sikre os du forstår riskoen før du installere den."
|
||||
},
|
||||
"app.appearance-settings.unknown-pack-warning.title": {
|
||||
"message": "Advar mig før jeg installere en ukendt modpacks"
|
||||
},
|
||||
"app.auth-servers.unreachable.body": {
|
||||
"message": "Minecraft authentication servere kan måske være nede lige nu. Tjek din internet forbindelse og prøv igen senere."
|
||||
},
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Kan ikke nå autentificeringsservere"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Tilføjet server til instance"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Tilføj til en instance"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Tilføj til instance"
|
||||
},
|
||||
"app.browse.add-to-instance-name": {
|
||||
"message": "Tilføjet til {instanceName}"
|
||||
},
|
||||
@@ -14,12 +131,30 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Allerede tilføjet"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Tilbage til instance"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Opdag indhold"
|
||||
},
|
||||
"app.browse.discover-servers": {
|
||||
"message": "Opdag servere"
|
||||
},
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Gem allerede tilføjet til servere"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Ved at tilføje indhold kan der opstå kompatibilitetsproblemer, når du tilslutter dig serveren. Alt tilføjet indhold går desuden tabt, når du opdaterer indholdet på din server instance."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Installere"
|
||||
},
|
||||
"app.content-install.no-compatible-versions": {
|
||||
"message": "Ingen tilgængelige versioner passer til {compatibilityLabel}. Vælg en version for at installere. Afhængigheder bliver ikke installeret automatisk."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Indtast modpack beskrivelse..."
|
||||
},
|
||||
@@ -29,6 +164,9 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Eksporter modpack"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "Inkludere \"{file}\"?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Modpack Navn"
|
||||
},
|
||||
@@ -36,7 +174,7 @@
|
||||
"message": "Modpack navn"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Vælg filer og mapper til at inkludere i pakken"
|
||||
"message": "Konfigurer hvilke filer inkludere i denne eksport"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Versionsnummer"
|
||||
@@ -62,6 +200,12 @@
|
||||
"app.instance.modpack-already-installed.create": {
|
||||
"message": "Opret"
|
||||
},
|
||||
"app.instance.modpack-already-installed.header": {
|
||||
"message": "Modpack allerede installeret"
|
||||
},
|
||||
"app.instance.modpack-already-installed.instance": {
|
||||
"message": "Instance"
|
||||
},
|
||||
"app.instance.mods.content-type-project": {
|
||||
"message": "projekt"
|
||||
},
|
||||
@@ -80,6 +224,33 @@
|
||||
"app.instance.mods.successfully-uploaded": {
|
||||
"message": "Uploadet"
|
||||
},
|
||||
"app.instance.worlds.add-server": {
|
||||
"message": "Tilføj server"
|
||||
},
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Søg servere"
|
||||
},
|
||||
"app.instance.worlds.filter-modded": {
|
||||
"message": "Moddet"
|
||||
},
|
||||
"app.instance.worlds.filter-offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.instance.worlds.filter-online": {
|
||||
"message": "Online"
|
||||
},
|
||||
"app.instance.worlds.filter-vanilla": {
|
||||
"message": "Vanilla"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-description": {
|
||||
"message": "Tilføj en server, eller søg efter en for at komme i gang"
|
||||
},
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Ingen servere eller verdener tilføjet"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Søg {count} verdener..."
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Indhold krævet"
|
||||
},
|
||||
@@ -116,6 +287,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "En opdatering er krævet for at spille {name}. Venligst opdater til den seneste version for at køre spillet."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Dette projekt er allerede installeret"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Tilbage til udforsk"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Installer indhold til instance"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Udvikler-tilstand aktiveret."
|
||||
},
|
||||
@@ -140,8 +320,137 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Ressourcestyring"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} er klar til at blive installeret! Genindlæs for at opdatere nu, eller automatisk når du lukker Modrinth App."
|
||||
"app.skins.add-button": {
|
||||
"message": "Tilføj skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Træk og slip"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Brug"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Slet skin"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Dette vil permanent slette det valgte skin. Dette vil ikke kunne blive fortydes."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Er du sikker på, at du vil slette dette skin?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Fejl ved læsning af den afleverede fil."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Fejl under behandling af fil"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Rediger skin"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Tilføj skin"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Tilføjer et skin"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Slank"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Bred"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Kappe"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Kappe"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Rediger skin"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Rediger skinnet først!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Ingen kappe"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Ingen"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Gem skin"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Gemmer..."
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Upload et skin først!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Rediger skin"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Forhåndsvisning"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Du skifter dit skin for ofte. Mojang's servere har midlertidigt blokeret fremtidige anmodninger. Vent venligst et øjeblik for du prøver igen."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Ro på!"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Builders & Biomes"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Chase the Skies"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Standard skins"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Du modtog disse skins for at donere til en Modrinth Pride indsamling imens der er Pride Month."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Gemte skins"
|
||||
},
|
||||
"app.skins.section.striding-hero": {
|
||||
"message": "Striding Hero"
|
||||
},
|
||||
"app.skins.section.the-copper-age": {
|
||||
"message": "The Copper Age"
|
||||
},
|
||||
"app.skins.section.the-garden-awakens": {
|
||||
"message": "The Garden Awakens"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "Tiny Takeover"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Log Ind"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Venligst log ind til din Minecraft konto for at kunne funktionerne til at administrere dit skin i Modrinth appen."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Overrasket Modrinth Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Venligst log ind"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Skin vælger"
|
||||
},
|
||||
"app.update-popup.body.download-complete": {
|
||||
"message": "Modrinth App v{version} er færdig med at download. Genindlæs for at opdatere nu, eller automatisk når du lukker Modrinth App."
|
||||
@@ -161,9 +470,6 @@
|
||||
"app.update-popup.download-complete": {
|
||||
"message": "Download færdiggjort"
|
||||
},
|
||||
"app.update-popup.reload": {
|
||||
"message": "Geninlæs"
|
||||
},
|
||||
"app.update-popup.title": {
|
||||
"message": "Opdatering tilgængelig"
|
||||
},
|
||||
@@ -173,14 +479,23 @@
|
||||
"app.update.complete-toast.title": {
|
||||
"message": "Version {version} var installeret med succes!"
|
||||
},
|
||||
"app.update.download-update": {
|
||||
"message": "Download opdatering"
|
||||
"app.world.server-modal.placeholder-address": {
|
||||
"message": "eksemple.modrinth.gg"
|
||||
},
|
||||
"app.update.downloading-update": {
|
||||
"message": "Downloader opdatering ({percent}%)"
|
||||
"app.world.server-modal.select-an-option": {
|
||||
"message": "Vælg en mulighed"
|
||||
},
|
||||
"app.update.reload-to-update": {
|
||||
"message": "Genindlæs for at installere opdatering"
|
||||
"app.world.world-item.incompatible-version": {
|
||||
"message": "Ikke kompatibel version {version}"
|
||||
},
|
||||
"app.world.world-item.not-played-yet": {
|
||||
"message": "Ikke spillet endnu"
|
||||
},
|
||||
"app.world.world-item.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.world.world-item.players-online": {
|
||||
"message": "{count} online"
|
||||
},
|
||||
"friends.action.add-friend": {
|
||||
"message": "Tilføj en ven"
|
||||
@@ -281,6 +596,12 @@
|
||||
"instance.edit-world.title": {
|
||||
"message": "Rediger verden"
|
||||
},
|
||||
"instance.files.adding-files": {
|
||||
"message": "Tilføjer filer ({completed}/{total})"
|
||||
},
|
||||
"instance.files.save-as": {
|
||||
"message": "Gem som..."
|
||||
},
|
||||
"instance.server-modal.address": {
|
||||
"message": "Adresse"
|
||||
},
|
||||
@@ -395,6 +716,9 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java og hukommelse"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Indskriv Java argumenter..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Omgivelses variabler"
|
||||
},
|
||||
@@ -410,6 +734,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Allokeret hukommelse"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/vej/til/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Vindue"
|
||||
},
|
||||
@@ -482,6 +809,24 @@
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "Verden er i brug"
|
||||
},
|
||||
"minecraft-account.add-account": {
|
||||
"message": "Tilføj konto"
|
||||
},
|
||||
"minecraft-account.label": {
|
||||
"message": "Minecraft konto"
|
||||
},
|
||||
"minecraft-account.not-signed-in": {
|
||||
"message": "Ikke logged ind"
|
||||
},
|
||||
"minecraft-account.remove-account": {
|
||||
"message": "Fjern konto"
|
||||
},
|
||||
"minecraft-account.select-account": {
|
||||
"message": "Vælg konto"
|
||||
},
|
||||
"minecraft-account.sign-in": {
|
||||
"message": "Log ind på Minecraft"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Givet af instanceen"
|
||||
},
|
||||
@@ -505,5 +850,20 @@
|
||||
},
|
||||
"search.filter.locked.server-loader.title": {
|
||||
"message": "Loader er givet af serveren"
|
||||
},
|
||||
"unknown-pack-warning-modal.body": {
|
||||
"message": "En fil behandles kun, hvis den uploades til Modrinth, uanset filformat (herunder .mrpack)."
|
||||
},
|
||||
"unknown-pack-warning-modal.dont-show-again": {
|
||||
"message": "Vis ikke denne advarsel igen"
|
||||
},
|
||||
"unknown-pack-warning-modal.header": {
|
||||
"message": "Bekræft installation"
|
||||
},
|
||||
"unknown-pack-warning-modal.install-anyway": {
|
||||
"message": "Installer alligevel"
|
||||
},
|
||||
"unknown-pack-warning-modal.warning.title": {
|
||||
"message": "Ukendt fil advarsel"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "Java {version} wird heruntergeladen"
|
||||
},
|
||||
"app.action-bar.downloading-update": {
|
||||
"message": "Update wird heruntergeladen"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Downloads"
|
||||
},
|
||||
@@ -20,12 +23,18 @@
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Primäre Instanz"
|
||||
},
|
||||
"app.action-bar.reload-to-update": {
|
||||
"message": "Zum Aktualisieren neu laden"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Weitere laufende Instanzen anzeigen"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Instanz stoppen"
|
||||
},
|
||||
"app.action-bar.update": {
|
||||
"message": "Aktualisieren"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Aktive Downloads anzeigen"
|
||||
},
|
||||
@@ -86,6 +95,12 @@
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Option wählen"
|
||||
},
|
||||
"app.appearance-settings.show-play-time.description": {
|
||||
"message": "Zeigt an, wie viel Zeit du mit dem Spielen einer Instanz verbracht hast."
|
||||
},
|
||||
"app.appearance-settings.show-play-time.title": {
|
||||
"message": "Spielzeit anzeigen"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Ermöglicht das Umschalten der Seitenleiste."
|
||||
},
|
||||
@@ -104,11 +119,11 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Authentifizierungsserver sind nicht erreichbar"
|
||||
},
|
||||
"app.browse.add-server-to-instance": {
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Server zu Instanz hinzufügen"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Server zu deiner Instanz hinzufügen"
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Zu Instanz hinzufügen"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Zu Instanz hinzufügen"
|
||||
@@ -122,6 +137,9 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Bereits hinzugefügt"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Zu Instanz zurückgehen"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Inhalte entdecken"
|
||||
},
|
||||
@@ -131,29 +149,17 @@
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Bereits hinzugefügte Server ausblenden"
|
||||
},
|
||||
"app.browse.hide-installed-content": {
|
||||
"message": "Bereits installierte Inhalte ausblenden"
|
||||
},
|
||||
"app.browse.install-content-to-instance": {
|
||||
"message": "Inhalt in Instanz installieren"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"app.browse.server.install": {
|
||||
"message": "Installieren"
|
||||
},
|
||||
"app.browse.server.installed": {
|
||||
"message": "Installiert"
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Das hinzufügen von Inhalten kann Kompatibilitätsprobleme beim beitreten eines Servers verursachen. Sämtliche hinzugefügte Inhalten gehen ausserdem beim aktualisieren der Server Instanz-Inhalte verloren."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Wird installiert"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "Modpack wird installiert..."
|
||||
"app.content-install.no-compatible-versions": {
|
||||
"message": "Keine verfügbaren Versionen stimmen mit {compatibilityLabel} überein. Wähle eine Version aus, um sie trotzdem zu installieren. Abhängigkeiten werden nicht automatisch installiert."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Modpaketbeschreibung eingeben..."
|
||||
@@ -164,6 +170,9 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Modpack exportieren"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "\"{file}\" einschliessen?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Modpaketname"
|
||||
},
|
||||
@@ -171,7 +180,7 @@
|
||||
"message": "Modpaketname"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Wähle Dateien und Ordner zum hinzufügen im Paket aus"
|
||||
"message": "Konfiguriere, welche Dateien in diesem Export miteinbezogen werden"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Versionsnummer"
|
||||
@@ -227,12 +236,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Server durchsuchen"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "„{name}“ wird **endgültig gelöscht**, und es gibt keine Möglichkeit, sie wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Möchtest du diese Welt wirklich endgültig löschen?"
|
||||
},
|
||||
"app.instance.worlds.filter-modded": {
|
||||
"message": "Modifiziert"
|
||||
},
|
||||
@@ -251,21 +254,9 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Keine Server oder Welten hinzugefügt"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "„{name}“ wird aus deiner Liste entfernt, auch im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "„{name}“ ({address}) wird aus deiner Liste entfernt, inklusive im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Möchtest du {name} wirklich entfernen?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Durchsuche {count} Welten..."
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "dieser Server"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Inhalte benötigt"
|
||||
},
|
||||
@@ -302,6 +293,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "Eine aktualisierung zum spielen von {name} ist benötigt. Bitte aktualisiere auf die neuste Version um das Spiel zu starten."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Dieses Projekt ist bereits installiert"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Zurück zu Entdecken"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Inhalt in Instanz installieren"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Entwicklermodus aktiviert."
|
||||
},
|
||||
@@ -326,8 +326,152 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Ressourcenmanagement"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} ist bereit zur Installation! Lade die App neu um jetzt zu aktualisieren, oder automatisch nach dem schliessen der Modrinth App."
|
||||
"app.skins.add-button": {
|
||||
"message": "Skin hinzufügen"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Drag-and-Drop"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Anwenden"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Skin löschen"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Dies wird den ausgewählten Skin unwiderruflich löschen. Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Bist du dir sicher, dass du diesen Skin löschen willst?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Fehler beim Lesen der abgelegten Datei."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Fehler bei der Dateiverarbeitung"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Skin hinzufügen"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Skin wird hinzugefügt"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Arm Stil"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Schlank"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Breit"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Umhang"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Umhang"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Nimm zuerst eine Änderung am Skin vor!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Kein Umhang"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Keines"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Textur ersetzen"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Skin speichern"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Speichern..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Textur"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Lade zuerst einen Skin hoch!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Vorschau"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Du änderst deinen Skin zu oft. Die Server von Mojang haben weitere Anfragen vorübergehend blockiert. Bitte warte einen Moment, bevor du es erneut versuchst."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Nicht so schnell!"
|
||||
},
|
||||
"app.skins.reorder-error.text": {
|
||||
"message": "Deine Skin-Reihenfolge konnte nicht gespeichert werden."
|
||||
},
|
||||
"app.skins.reorder-error.title": {
|
||||
"message": "Skins konnten nicht neu angeordnet werden"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Builders & Biomes"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Chase the Skies"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Standard-Skins"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Du hast diese Skins als Dankeschön für eine Spende bei der Modrinth Pride Spendenaktion während des Pride Month erhalten."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Gespeicherte Skins"
|
||||
},
|
||||
"app.skins.section.striding-hero": {
|
||||
"message": "Striding Hero"
|
||||
},
|
||||
"app.skins.section.the-copper-age": {
|
||||
"message": "The Copper Age"
|
||||
},
|
||||
"app.skins.section.the-garden-awakens": {
|
||||
"message": "The Garden Awakens"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "Tiny Takeover"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Anmelden"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Bitte melde dich in deinem Minecraft-Konto an, um die Skin-Verwaltungsfunktionen der Modrinth App zu nutzen."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Begeisterter Modrinth-Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Bitte anmelden"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Skin-Auswahl"
|
||||
},
|
||||
"app.update-popup.body.download-complete": {
|
||||
"message": "Modrinth App v{version} wurde heruntergeladen. Lade die App neu um jetzt zu aktualisieren, oder automatisch nach dem schliessen der Modrinth App."
|
||||
@@ -348,7 +492,7 @@
|
||||
"message": "Download abgeschlossen"
|
||||
},
|
||||
"app.update-popup.reload": {
|
||||
"message": "Neu Laden"
|
||||
"message": "Zum Aktualisieren neu laden"
|
||||
},
|
||||
"app.update-popup.title": {
|
||||
"message": "Aktualisierung verfügbar"
|
||||
@@ -359,15 +503,6 @@
|
||||
"app.update.complete-toast.title": {
|
||||
"message": "Version {version} wurde erfolgreich installiert!"
|
||||
},
|
||||
"app.update.download-update": {
|
||||
"message": "Aktualisierung herunterladen"
|
||||
},
|
||||
"app.update.downloading-update": {
|
||||
"message": "Lade Aktualisierung herunter ({percent}%)"
|
||||
},
|
||||
"app.update.reload-to-update": {
|
||||
"message": "Lade neu um Aktualisierung zu installieren"
|
||||
},
|
||||
"app.world.server-modal.placeholder-address": {
|
||||
"message": "example.modrinth.gg"
|
||||
},
|
||||
@@ -557,6 +692,30 @@
|
||||
"instance.settings.tabs.general.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel": {
|
||||
"message": "Update-Kanal"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.alpha": {
|
||||
"message": "Alpha"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.alpha.description": {
|
||||
"message": "Release-, Beta- und Alpha-Versionen werden als verfügbare Updates angezeigt."
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.beta": {
|
||||
"message": "Beta"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.beta.description": {
|
||||
"message": "Release- und Beta-Versionen werden als verfügbare Updates angezeigt."
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.release": {
|
||||
"message": "Release"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.release.description": {
|
||||
"message": "Nur Release-Versionen werden als verfügbare Updates angezeigt."
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Update-Kanal auswählen"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Start Hooks"
|
||||
},
|
||||
@@ -605,6 +764,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java und Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Benutzerdefinierte Umgebungsvariablen"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Benutzerdefinierte Java-Argumente"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Benutzerdefinierte Java-Installation"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Benutzerdefinierte Speicherzuweisung"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Umgebungsvariablen eingeben..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Java-Argumente eingeben..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Umgebungsvariablen"
|
||||
},
|
||||
@@ -620,6 +797,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Zugewiesener Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/pfad/zu/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Spielfenster"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"app.action-bar.downloading-java": {
|
||||
"message": "Java {version} wird heruntergeladen"
|
||||
},
|
||||
"app.action-bar.downloading-update": {
|
||||
"message": "Update wird heruntergeladen"
|
||||
},
|
||||
"app.action-bar.downloads": {
|
||||
"message": "Downloads"
|
||||
},
|
||||
@@ -12,7 +15,7 @@
|
||||
"message": "Zur primären Instanz machen"
|
||||
},
|
||||
"app.action-bar.no-instances-running": {
|
||||
"message": "Keine Instanzen laufen"
|
||||
"message": "Keine laufenden Instanzen"
|
||||
},
|
||||
"app.action-bar.offline": {
|
||||
"message": "Offline"
|
||||
@@ -20,12 +23,18 @@
|
||||
"app.action-bar.primary-instance": {
|
||||
"message": "Primäre Instanz"
|
||||
},
|
||||
"app.action-bar.reload-to-update": {
|
||||
"message": "Zum Aktualisieren neu laden"
|
||||
},
|
||||
"app.action-bar.show-more-running-instances": {
|
||||
"message": "Weitere laufende Instanzen anzeigen"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Instanz stoppen"
|
||||
},
|
||||
"app.action-bar.update": {
|
||||
"message": "Aktualisieren"
|
||||
},
|
||||
"app.action-bar.view-active-downloads": {
|
||||
"message": "Aktive Downloads anzeigen"
|
||||
},
|
||||
@@ -33,7 +42,7 @@
|
||||
"message": "Instanz anzeigen"
|
||||
},
|
||||
"app.action-bar.view-logs": {
|
||||
"message": "Protokolle anzeigen"
|
||||
"message": "Logs anzeigen"
|
||||
},
|
||||
"app.appearance-settings.advanced-rendering.description": {
|
||||
"message": "Aktiviert erweiterte Rendering-Funktionen wie Unschärfe-Effekte, welche ohne Hardware-beschleunigtes Rendering zu Leistungsproblemen führen können."
|
||||
@@ -86,6 +95,12 @@
|
||||
"app.appearance-settings.select-option": {
|
||||
"message": "Wähle eine Option"
|
||||
},
|
||||
"app.appearance-settings.show-play-time.description": {
|
||||
"message": "Zeigt an, wie viel Zeit du damit verbracht hast, eine Instanz zu spielen."
|
||||
},
|
||||
"app.appearance-settings.show-play-time.title": {
|
||||
"message": "Spielzeit anzeigen"
|
||||
},
|
||||
"app.appearance-settings.toggle-sidebar.description": {
|
||||
"message": "Ermöglicht das Umschalten der Seitenleiste."
|
||||
},
|
||||
@@ -104,11 +119,11 @@
|
||||
"app.auth-servers.unreachable.header": {
|
||||
"message": "Authentifizierungsserver sind nicht erreichbar"
|
||||
},
|
||||
"app.browse.add-server-to-instance": {
|
||||
"message": "Server zur Instanz hinzufügen"
|
||||
},
|
||||
"app.browse.add-servers-to-instance": {
|
||||
"message": "Server zu deiner Instanz hinzufügen"
|
||||
"message": "Server zu Instanz hinzufügen"
|
||||
},
|
||||
"app.browse.add-to-an-instance": {
|
||||
"message": "Zu Instanz hinzufügen"
|
||||
},
|
||||
"app.browse.add-to-instance": {
|
||||
"message": "Zur Instanz hinzufügen"
|
||||
@@ -122,6 +137,9 @@
|
||||
"app.browse.already-added": {
|
||||
"message": "Bereits hinzugefügt"
|
||||
},
|
||||
"app.browse.back-to-instance": {
|
||||
"message": "Zurück zur Instanz"
|
||||
},
|
||||
"app.browse.discover-content": {
|
||||
"message": "Inhalte entdecken"
|
||||
},
|
||||
@@ -131,29 +149,17 @@
|
||||
"app.browse.hide-added-servers": {
|
||||
"message": "Bereits hinzugefügte Server ausblenden"
|
||||
},
|
||||
"app.browse.hide-installed-content": {
|
||||
"message": "Bereits installierte Inhalte ausblenden"
|
||||
},
|
||||
"app.browse.install-content-to-instance": {
|
||||
"message": "Inhalt in Instanz installieren"
|
||||
},
|
||||
"app.browse.project-type.modpacks": {
|
||||
"message": "Modpacks"
|
||||
},
|
||||
"app.browse.server.install": {
|
||||
"message": "Installieren"
|
||||
},
|
||||
"app.browse.server.installed": {
|
||||
"message": "Installiert"
|
||||
"app.browse.server-instance-content-warning": {
|
||||
"message": "Das Hinzufügen von Inhalten kann die Kompatibilität beim Beitreten zum Server beeinträchtigen. Außerdem gehen alle hinzugefügten Inhalte verloren, wenn du die Inhalte der Serverinstanz aktualisierst."
|
||||
},
|
||||
"app.browse.server.installing": {
|
||||
"message": "Wird installiert"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "Modpack wird installiert..."
|
||||
"app.content-install.no-compatible-versions": {
|
||||
"message": "Keine verfügbaren Versionen stimmen mit {compatibilityLabel} überein. Wähle eine Version aus, um sie trotzdem zu installieren. Abhängigkeiten werden nicht automatisch installiert."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Beschreibung des Modpacks eingeben..."
|
||||
@@ -164,6 +170,9 @@
|
||||
"app.export-modal.header": {
|
||||
"message": "Modpack exportieren"
|
||||
},
|
||||
"app.export-modal.include-file-accessibility-label": {
|
||||
"message": "\"{file}\" einschließen?"
|
||||
},
|
||||
"app.export-modal.modpack-name-label": {
|
||||
"message": "Modpackname"
|
||||
},
|
||||
@@ -171,7 +180,7 @@
|
||||
"message": "Modpackname"
|
||||
},
|
||||
"app.export-modal.select-files-label": {
|
||||
"message": "Wähle Dateien und Ordner aus, die in das Paket sollen"
|
||||
"message": "Konfiguriere, welche Dateien in diesen Export enthalten sind"
|
||||
},
|
||||
"app.export-modal.version-number-label": {
|
||||
"message": "Versionsnummer"
|
||||
@@ -227,12 +236,6 @@
|
||||
"app.instance.worlds.browse-servers": {
|
||||
"message": "Server durchsuchen"
|
||||
},
|
||||
"app.instance.worlds.delete-world-description": {
|
||||
"message": "„{name}“ wird **endgültig gelöscht**, und es gibt keine Möglichkeit, sie wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.delete-world-title": {
|
||||
"message": "Möchtest du diese Welt wirklich endgültig löschen?"
|
||||
},
|
||||
"app.instance.worlds.filter-modded": {
|
||||
"message": "Modifiziert"
|
||||
},
|
||||
@@ -251,21 +254,9 @@
|
||||
"app.instance.worlds.no-worlds-heading": {
|
||||
"message": "Keine Server oder Welten hinzugefügt"
|
||||
},
|
||||
"app.instance.worlds.remove-server-description": {
|
||||
"message": "„{name}“ wird aus deiner Liste entfernt, auch im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-description-with-address": {
|
||||
"message": "„{name}“ ({address}) wird aus deiner Liste entfernt, inklusive im Spiel, und es gibt keine Möglichkeit, ihn wiederherzustellen."
|
||||
},
|
||||
"app.instance.worlds.remove-server-title": {
|
||||
"message": "Möchtest du {name} wirklich entfernen?"
|
||||
},
|
||||
"app.instance.worlds.search-worlds-placeholder": {
|
||||
"message": "Durchsuche {count} Welten..."
|
||||
},
|
||||
"app.instance.worlds.this-server": {
|
||||
"message": "dieser Server"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Inhalte benötigt"
|
||||
},
|
||||
@@ -302,6 +293,15 @@
|
||||
"app.modal.update-to-play.update-required-description": {
|
||||
"message": "Zum Spielen von {name} ist eine Aktualisierung erforderlich. Bitte aktualisiere auf die neueste Version, um das Spiel zu starten."
|
||||
},
|
||||
"app.project.install-button.already-installed": {
|
||||
"message": "Dieses Projekt ist bereits installiert"
|
||||
},
|
||||
"app.project.install-context.back-to-browse": {
|
||||
"message": "Zurück zum Entdecken"
|
||||
},
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Inhalt in Instanz installieren"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Entwicklermodus aktiviert."
|
||||
},
|
||||
@@ -326,8 +326,152 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Ressourcenmanagement"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} ist bereit zur Installation! Neu laden, um jetzt zu aktualisieren, oder automatisch, wenn du die Modrinth App schließt."
|
||||
"app.skins.add-button": {
|
||||
"message": "Skin hinzufügen"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Ziehen und Ablegen"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Anwenden"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Skin löschen"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "Dadurch wird der ausgewählte Skin permanent gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Möchtest du diesen Skin wirklich löschen?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Die abgelegte Datei konnte nicht gelesen werden."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Fehler bei der Verarbeitung der Datei"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Skin hinzufügen"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Einen Skin hinzufügen"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Arm-Stil"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Schlank"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Breit"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Umhang"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Umhang"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Nimm zuerst eine Änderung am Skin vor!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "Kein Umhang"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "Keinen"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Textur ersetzen"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Skin speichern"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Wird gespeichert..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Textur"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Lade zuerst ein Skin hoch!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Skin bearbeiten"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Vorschau"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "Du wechselst deinen Skin zu oft. Die Server von Mojang haben weitere Anfragen vorübergehend blockiert. Bitte warte einen Moment, bevor du es erneut versuchst."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Langsamer!"
|
||||
},
|
||||
"app.skins.reorder-error.text": {
|
||||
"message": "Die Anordnung deiner Skins konnte nicht gespeichert werden."
|
||||
},
|
||||
"app.skins.reorder-error.title": {
|
||||
"message": "Skins konnten nicht neu angeordnet werden"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Builders & Biomes"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Chase the Skies"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Standard-Skins"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.modrinth": {
|
||||
"message": "Modrinth"
|
||||
},
|
||||
"app.skins.section.modrinth-pride": {
|
||||
"message": "Modrinth Pride"
|
||||
},
|
||||
"app.skins.section.modrinth-pride.tooltip": {
|
||||
"message": "Du hast diese Skins erhalten, da du während des Pride-Monats für eine Modrinth-Pride-Spendenaktion gespendet hast."
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Gespeicherte Skins"
|
||||
},
|
||||
"app.skins.section.striding-hero": {
|
||||
"message": "Striding Hero"
|
||||
},
|
||||
"app.skins.section.the-copper-age": {
|
||||
"message": "The Copper Age"
|
||||
},
|
||||
"app.skins.section.the-garden-awakens": {
|
||||
"message": "The Garden Awakens"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "Tiny Takeover"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Anmelden"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Bitte melde dich bei deinem Minecraft-Konto an, um die Funktionen zur Skin-Verwaltung der Modrinth-App nutzen zu können."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Begeisterter Modrinth-Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Bitte melde dich an"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Skin-Auswahl"
|
||||
},
|
||||
"app.update-popup.body.download-complete": {
|
||||
"message": "Modrinth App v{version} wurde heruntergeladen. Neu laden, um jetzt zu aktualisieren, oder automatisch aktualisieren, wenn du die Modrinth App schließt."
|
||||
@@ -348,7 +492,7 @@
|
||||
"message": "Download abgeschlossen"
|
||||
},
|
||||
"app.update-popup.reload": {
|
||||
"message": "Neu laden"
|
||||
"message": "Zum Aktualisieren neu laden"
|
||||
},
|
||||
"app.update-popup.title": {
|
||||
"message": "Aktualisierung verfügbar"
|
||||
@@ -359,15 +503,6 @@
|
||||
"app.update.complete-toast.title": {
|
||||
"message": "Version {version} wurde erfolgreich installiert!"
|
||||
},
|
||||
"app.update.download-update": {
|
||||
"message": "Update herunterladen"
|
||||
},
|
||||
"app.update.downloading-update": {
|
||||
"message": "Update wird heruntergeladen ({percent}%)"
|
||||
},
|
||||
"app.update.reload-to-update": {
|
||||
"message": "Neu laden, um das Update zu installieren"
|
||||
},
|
||||
"app.world.server-modal.placeholder-address": {
|
||||
"message": "example.modrinth.gg"
|
||||
},
|
||||
@@ -519,7 +654,7 @@
|
||||
"message": "Wird gelöscht..."
|
||||
},
|
||||
"instance.settings.tabs.general.duplicate-button": {
|
||||
"message": "Kopieren"
|
||||
"message": "Duplizieren"
|
||||
},
|
||||
"instance.settings.tabs.general.duplicate-button.tooltip.installing": {
|
||||
"message": "Kann während der Installation nicht dupliziert werden."
|
||||
@@ -557,6 +692,30 @@
|
||||
"instance.settings.tabs.general.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel": {
|
||||
"message": "Aktualisierungskanal"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.alpha": {
|
||||
"message": "Alpha"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.alpha.description": {
|
||||
"message": "Voll, Beta- und Alpha-Versionen werden als verfügbare Aktualisierungen angezeigt."
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.beta": {
|
||||
"message": "Beta"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.beta.description": {
|
||||
"message": "Voll- und Beta-Versionen werden als verfügbare Aktualisierungen angezeigt."
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.release": {
|
||||
"message": "Vollversion"
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.release.description": {
|
||||
"message": "Nur Vollversionen werden als verfügbare Aktualisierungen angezeigt."
|
||||
},
|
||||
"instance.settings.tabs.general.update-channel.select": {
|
||||
"message": "Aktualisierungskanal auswählen"
|
||||
},
|
||||
"instance.settings.tabs.hooks": {
|
||||
"message": "Startargumente"
|
||||
},
|
||||
@@ -605,6 +764,24 @@
|
||||
"instance.settings.tabs.java": {
|
||||
"message": "Java und Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-environment-variables": {
|
||||
"message": "Benutzerdefinierte Umgebungsvariablen"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-arguments": {
|
||||
"message": "Benutzerdefinierte Java-Argumente"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-java-installation": {
|
||||
"message": "Benutzerdefinierte Java-Installation"
|
||||
},
|
||||
"instance.settings.tabs.java.custom-memory-allocation": {
|
||||
"message": "Benutzerdefinierte Speicherzuweisung"
|
||||
},
|
||||
"instance.settings.tabs.java.enter-environment-variables": {
|
||||
"message": "Umgebungsvariablen eingeben..."
|
||||
},
|
||||
"instance.settings.tabs.java.enter-java-arguments": {
|
||||
"message": "Java-Argumente eingeben..."
|
||||
},
|
||||
"instance.settings.tabs.java.environment-variables": {
|
||||
"message": "Umgebungsvariablen"
|
||||
},
|
||||
@@ -620,6 +797,9 @@
|
||||
"instance.settings.tabs.java.java-memory": {
|
||||
"message": "Zugewiesener Arbeitsspeicher"
|
||||
},
|
||||
"instance.settings.tabs.java.java-path-placeholder": {
|
||||
"message": "/pfad/zu/java"
|
||||
},
|
||||
"instance.settings.tabs.window": {
|
||||
"message": "Fenster"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user